# Audit of Aleo Multisig Wallet and its usage in Compliant Stablecoin and Bridges

- **Client**: Aleo Foundation
- **Date**: December 1st, 2025
- **Tags**: Multisig, Aleo, Wallet

# Introduction

On December 1st, 2025, Aleo Foundation tasked zkSecurity with auditing their Multisig Wallet program and its integration into several applications and protocols. The specific code to review was shared via both public and private GitHub repositories. The audit lasted one week with two consultants.

## Scope

The main purpose of multisig wallet currently boils down to two use cases:

1. Requiring multiple signatures for program upgrades.
2. Requiring multiple signatures for admin-specific functions of several different applications and protocols.

For this reason, this audit not only focused on the multisig core engine, `multisig_core`, but also covered how this engine is utilized by other applications and protocols to gate upgrades (e.g., in `test_upgrade`) and admin functions (e.g., in Hyperlane’s warp tokens).

More specifically, we reviewed the following programs:

Repo: https://github.com/AleoNet/aleo-multisig

- The `multisig_core` program, which is the core multisignature engine, at commit `940050959f5877ba59f2889430f5b0e7a6a6e96f`.
- The `test_upgrades` program, which shows how program upgrades can be gated behind a multisig operation, at commit `940050959f5877ba59f2889430f5b0e7a6a6e96f`.

Repo: https://github.com/eranrund/hyperlane-aleo/tree/eran/multisig

- The `hyp_multisig` program, which can be used to gate privileged operations on Hyperlane’s `mailbox`, `ism_manager`, and `hook_manager` programs, at commit `a978035035900d72ea68073ccb23cda73bf5a68f`.
- The `hyp_warp_multisig` program, which represents the interface program between `multisig_core` and Hyperlane’s warp token programs, at commit `a978035035900d72ea68073ccb23cda73bf5a68f`.

Repo: https://github.com/sealance-io/compliant-transfer-aleo

- The `compliant_token_template` program, which provides a token template that includes freeze list enforcement, at commit `052bbd9d26336ac3f9cefae2c9595df7c26bcaaf`.
- The `sealance_freezelist_registry` program, which is the freeze list registry used by the `compliant_token_template`, at commit `052bbd9d26336ac3f9cefae2c9595df7c26bcaaf`.

Repo: private

- The `circle_bridge` program, which handles cross-chain token transfers between Ethereum and Aleo, at commit `45e0aa94d6041cd0874c0c73eec697c53decc8ed`.

## Scope Variance

This section outlines several changes present in the deployed commit that were not included in the original audit scope, while noting that the deployed version continues to use multisig program upgrades covered in the audit.

More specifically, the differences are as follows:

- The same `multisig_core` program, which differs only by the removal of the `UPGRADER_ADDRESS` constant. Instead, the `UPGRADER_ADDRESS` is stored in a mapping and is initialized as part of the `init()` transition. It can later be updated via the `set_upgrader_address()` transition (until upgrades are disabled by `disallow_upgrades()`).
- The `stablecoin_program`, which is equivalent to the audited `compliant_token_template`, but differs by removing multisig administrative actions (not to be confused with multisig-controlled program upgrades, which remain intact), and removing multisig version of mint and burn.
- The `freezelist_program`, which is equivalent to the audited `sealance_freezelist_registry`, which differs only by the removal of multisig administrative actions.
- The same `circle_bridge` program, which incorporates the same difference as above: removal of multisig administrative actions.

Although these variants were not part of the original audit scope, they rely on the same multisig upgrade flow and structural assumptions evaluated in the audited codebase. 

For completeness in the report, we also reference the corresponding commits in the Provable `compliant-stablecoin` repository (https://github.com/ProvableHQ/compliant-stablecoin) at commit `d3dda6117c47d043f12b448e6c8f912f519bddb1` and note that these versions have been reviewed at a high level as part of this variance assessment.

# Overview

## Multisig core

`multisig_core.aleo` implements a reusable t-of-n threshold multi-signature engine for Aleo programs. It manages wallets (identified by Aleo addresses), tracks authorized signers (both Aleo signature and ECDSA), enforces signature thresholds for arbitrary operations, and exposes admin transitions to adjust signer sets or thresholds. State lives in the following mappings: 

- `wallets_map`: stores each wallet's threshold and signer count
- `signers_map`: tracks hashed signer identities
- `pending_signing_ops`: tracks signing rounds
- `completed_signing_ops`: tracks completion status

### Program Flow

1. **Wallet setup:** Use `create_wallet` to register a wallet ID, threshold, and signer set. This populates `wallets_map` and hashes each signer into `signers_map`.
2. **Operation initiation:** A participant calls `initiate_signing_op` from their Aleo account. The transition hashes `(wallet_id, signing_op_id)` into `wallet_signing_op_id_hash`, ensures no active/completed round exists, creates a new `pending_signing_op` struct, and, if the caller is a registered signer, records their signature as the first confirmation.
3. **Collect signatures:** Each authorized signer calls `sign` (for Aleo signature) or `sign_ecdsa` (for ECDSA) with the same `wallet_id` and `signing_op_id`. The contract confirms the signer is registered, the pending op exists and hasn’t expired, the round value matches (if specified), and the signer hasn't already signed this round. It increments confirmations, and once the count meets the current threshold, records the op in `completed_signing_ops`.
4. **Execution in consuming program**: Program can then call `assert_signing_completed(wallet_id, signing_op_id)` which checks if `wallet_signing_op_id_hash` exist in the `completed_signing_ops`, before performing any sensitive action. This ensures that the specific action (bound to the `signing_op_id`) is gated by the multisignature approval before proceeding.

In addition to the basic flow, the program also handles administrative actions for updating wallet policy — such as adding or removing signers and increasing or decreasing the signing threshold — following the same flow as normal wallet-signing operations. This ensures that governance updates are subject to the same multisig protections as any other action.

Note that during wallet setup, the program also supports a Guarded wallet configuration, where any call to `create_wallet` must be pre-approved by the “guard wallet,” which is simply the `multisig_core.aleo` program’s own address used as the `wallet_id`. This special wallet can only be created by the program deployer when the Guarded wallet setting is enabled.

### Multisig Program Upgrades

The multisig functionality can be used to gate program upgrades behind multisignature approvals.

It is demonstrated in the `test_upgrades` example below:

```rust
program test_upgrades.aleo {
    struct ChecksumEdition {
        checksum: [u8; 32],
        edition: u16,
    }

    // A helper for calculating a signing_op_id from the program's checksum and edition.
    // By deriving the signing_op_id from both we ensure that downgrades cannot take place.
    transition get_signing_op_id_for_deploy(checksum: [u8; 32], edition: u16) -> field {
        return BHP256::hash_to_field(ChecksumEdition { checksum: checksum, edition: edition });
    }

    @custom
    async constructor() {
        // Only require multisig for upgrades - initial deployment has no checks.
        if self.edition > 0u16 {
            let signing_op_id = BHP256::hash_to_field(ChecksumEdition { checksum: self.checksum, edition: self.edition });

            let wallet_signing_op_id_hash = BHP256::hash_to_field(WalletSigningOpId { wallet_id: self.address, signing_op_id: signing_op_id });
            let signing_complete = multisig_core.aleo/completed_signing_ops.contains(wallet_signing_op_id_hash);
            assert(signing_complete);
        }
    }

    transition main(public a: u32, b: u32) -> u32 {
        let c: u32 = a + b;
        return c;
    }
}
```

Before deploying a new program edition, maintainers derive a `signing_op_id` from the target edition and the bytecode checksum, and then run a multisig round using a `wallet_id` equal to the program’s address.

Once enough signatures have accumulated, the constructor of the upgraded program checks `completed_signing_ops` for the corresponding `wallet_signing_op_id_hash`, which binds together the program address, program edition, and its checksum. If the signing operation has not been approved, the constructor rejects the deployment.

## Hyperlane Multisig

### The Hyperlane Protocol

Hyperlane is a permissionless cross-chain messaging protocol that enables applications to send arbitrary messages between blockchains.

Conceptually, the protocol has two main flows:

1. **The Dispatch Flow** (on the origin chain)
An application calls `dispatch` to initiate the sending of a message from the origin chain to the destination chain.
2. **The Process Flow** (on the destination chain)
An off-chain relayer collects that message and calls `process` to submit the message on the destination chain, where it is verified and then executed by a target application.

The Hyperlane protocol has three main actors: applications, validators, and relayers. Applications are the “users” of the protocol. They are the ones initiating and receiving cross-chain messages. On the origin chain, an application’s entry point for sending a message is Hyperlane’s `Mailbox`. This program records the message and triggers post-dispatch hooks such as an **IGP Hook** to charge and account for gas on the destination, and a **Merkle Tree Hook** to append the message to a Merkle tree whose root will later be signed by validators. Validators watch the `Mailbox`, compute Merkle roots over dispatched messages, sign these roots, and make these signatures available for the relayers. An off-chain relayer can then fetch the signed Merkle root and the corresponding message, and then provide these to the `process` function on the destination chain’s `Mailbox`. During that call, the relayer will additionally pass the address of an **Interchain Security Module (ISM)** that specifies verification rules for the message to be considered valid (e.g., what validator quorum is required). The `Mailbox` then delegates verification to the ISM, and on success, invokes the destination application to apply the message.

![image](https://hackmd.io/_uploads/SknGQveMWx.png)

Lastly, it’s important to note that the architecture of Hyperlane’s Aleo integration differs slightly from the standard Hyperlane protocol due to constraints imposed by the Leo programming language. For instance, to dispatch messages, applications on Aleo don’t call dispatch directly on the mailbox but rather on a dedicated dispatch_proxy program. However, these Aleo-specific differences are not really relevant to this audit, so we omit them here. For a more detailed account on this topic, we refer the reader to our previous audit report for Hyperlane’s Aleo integration.

### The `hyp_multisig` Program

The `hyp_multisig` program represents the multisig-controlled governance layer of Hyperlane’s core programs on Aleo. More specifically, it will become the `owner` of the `mailbox`, the `ism_manager`, and the `hook_manager`. Once in place, all privileged operations on these programs are routed through `hyp_multisig` and are only executed if a corresponding multisig operation has been correctly initialized and fully signed via `multisig_core`. The high-level usage pattern is as follows:

1. **Wallet Setup**
After deployment, `init` is called to create a wallet in `multisig_core` with a specified threshold and signer set. The corresponding `wallet_id` is deterministically derived from `self.address`.
2. **Operation Initialization**
To initialize a multisig operation, an admin proposes by calling `init_multisig_op` and providing a `signing_op_id`, an expiration block, and an `op::Op` struct that encodes what will eventually be executed (e.g., “set the default ISM to address X”).
3. **Signing** (off-program, in `multisig_core`)
The signers sign the operation with `multisig_core` tracking the signatures and completion status.
4. **Execution**
When enough signatures have been collected, an executor can call the corresponding `exec_*` transition (e.g., `exec_mailbox_set_default_ism`) to execute the proposed multisig operation.

Using this pattern, the following Hyperlane admin actions will be gated by multisig:

**Mailbox**

- `set_dispatch_proxy`
- `set_owner`
- `set_default_ism`
- `set_default_hook`
- `set_required_hook`

**ISM Manager**

- `set_domain`
- `remove_domain`
- `transfer_routing_ism_ownership`

**Hook Manager**

- `set_destination_gas_config`
- `remove_destination_gas_config`
- `transfer_igp_ownership`
- `claim`

The hook manager’s `claim` will send accumulated fees to the `hyp_multisig` program. To withdraw these funds, admins can use the `exec_credits_transfer_to_caller` transition, provided they first initiated and signed a corresponding multisig operation.

Lastly, the `hyp_multisig` program has a `@custom constructor` which enforces that **program upgrades** are gated by multisig: for editions beyond the initial deployment (i.e., `self.edition > 0`), the constructor checks that an associated signing operation has been completed in `multisig_core`.

### The `hyp_warp_multisig` Program

Hyperlane Warp Routes (HWRs) are cross-chain asset bridges that enable the transfer of tokens between chains using Hyperlane. Developers can permissionlessly deploy HWRs to move assets between chains. Hyperlane’s Aleo integration currently provides three different program templates that can be used to create HWRs. These are:

- **The `hyp_native` template.** This can be used to interact with the native currency of Aleo, i.e., the `credits` program.
- **The `hyp_synthetic` template.** This enables bridging of synthetic (wrapped) representations of external assets on Aleo. On inbound transfers, synthetic tokens are minted to mirror the origin chain asset. On outbound transfers, they are burned before dispatch.
- **The `hyp_collateral` template.** This enables bridging of existing assets that are already registered in the `token_registry` program. On inbound transfers, tokens are transferred from the `hyp_collateral` program to the recipient. On outbound transfers, they are transferred from the sender to the `hyp_collateral` program before dispatch.

The `hyp_warp_multisig` represents the interface program between `multisig_core` and any one of the above warp-token programs. This way, all admin-level operations on a given warp token can be gated by multisig logic. Developers will have to deploy an instance of `hyp_warp_multisig` for each warp token they are deploying.

The usage of `hyp_warp_multisig` follows the same “Setup-OpInit-Signing-Execution” pattern as in `hyp_multisig`, which is why we don’t repeat it here. 

The specific warp-token actions that can be multisig-gated by `hyp_warp_multisig` are:

- `set_custom_hook`
- `set_custom_ism`
- `set_owner`
- `enroll_remote_router`
- `unroll_remote_router`

As with `hyp_multisig`, the constructor enforces multisig-based upgrades: for program editions beyond the initial deployment, it checks that the upgrade has been approved as a completed signing operation in `multisig_core`.

## Compliant Token Template

This token program is a token that allows addresses to transfer tokens between accounts, supporting public to public, public to private, private to public and private to private transfers.

In addition to the privacy-preserving transfers, the program has its compliance enforced with a freeze list, where frozen accounts cannot move funds. The latest pull request has multisig supported, where a completed signing request is required in order to:

- Upgrade the compliant token program to the next edition,
- Update the role of a wallet or an address, given that the operating wallet has the `MANAGER_ROLE`,
- Mint publicly or privately given that the operating wallet has the `MINTER_ROLE`,
- Burn publicly or privately given that the operating wallet has the `BURNER_ROLE`,
- Pause (or unpause) the compliant token program, which disables (resp. enables) the exchange of tokens, given that the operating wallet has the `PAUSE_ROLE`.

Except program upgrade, each of the other functions already had a counterpart that allows an address with appropriate roles to perform the same action.

## Freeze List

The freeze list is maintained as an ordered Merkle tree of frozen addresses in `sealance_freezelist_registry.leo`. Multisig is enabled in the latest pull request, where the below features are implemented:

- Upgrade the freeze list program to the next edition,
- Update the roles of a wallet or an address, given that the operating wallet has the `MANAGER_ROLE`,
- Update the block height window given that the operating wallet has the `FREEZELIST_MANAGER_ROLE`, and
- Update the freeze list, given that the operating wallet has the `FREEZELIST_MANAGER_ROLE`.

## Bridge Program

The bridge program (`circle_bridge.aleo`) implements the integration of Circle’s XReserve protocol, enabling minting and burning of wrapped USDC in a way consistent with Circle's attestation model. At its core, the contract processes deposit payloads signed by Circle, verifies freeze-list requirements, ensures replay protection through a deposit nonce, and initiates mint or burn operations in the downstream `bridged_usdc.aleo` program, which based from Compliant Token Template.

The integration of multisig in the current audit is to gate administrative actions behind multisignature approvals, which enforces correct binding, expiry, and role authorization before applying changes.

There are three administrative operations that utilize multisig:

- **Pause bridge:** Pause/unpause bridge by the pause admin role.
- **Update Circle attester:** Rotate and update Circle attester key used for ECDSA verification.
- **Update role:** Allows reassignment of wallet roles by the manager role.

In addition to those administrative operations, the multisig is also used to gate program upgrades as explained in the [multisig program upgrades section](#multisig-program-upgrades).

## Findings

### Role takeover in bridge program due to unbound target wallet

- **Severity**: High
- **Location**: bridge_program/src/main.leo

**Description.**

In the bridge program, the manager role can assign any roles to another wallet via the `update_wallet_id_role` call. However, the `BridgeMultisigOp` structure does not include the `target_wallet_id`, which causes the computed `signing_op_id` to remain the same even when different `target_wallet_id` values are used.

Because the approved `init_multisig_op` does not bind to a specific `target_wallet_id`, an adversary can take an already-approved multisig operation and call `update_wallet_id_role` with a different `target_wallet_id`.

**Impact.**

This enables unauthorized takeover of roles, including privileged roles such as the manager role, by allowing an attacker to redirect the approved operation to an arbitrary wallet they control.

**Recommendation.**

Include the `target_wallet_id` in the `BridgeMultisigOp` structure to ensure that the `signing_op_id` is uniquely and immutably tied to a specific `target_wallet_id`.

**Client response.**

Fixed in commit `57fc89ef59ca4918df452dc88875a9c4bfda17b0`.

### Multisig payout in `hyp_multisig` does not bind the recipient address

- **Severity**: High
- **Location**: hyp_multisig/src/main.leo

**Description.**

The Hyperlane multisig records the intent of an operation by hashing an `op::Op` struct during `init_multisig_op` and later ensuring the same hash matches before execution. For `op::CREDITS_TRANSFER_TO_CALLER` the struct only captures the approved `amount` (`arg_u128_0`). During execution, the program pays `credits.aleo/transfer_public(self.caller, amount)`, so the actual recipient is whoever calls the transition, not a value committed in the signed payload.

**Impact.**

An attacker can monitor for a completed signing operation approving a payout, front‑run the intended beneficiary, and receive the funds.

**Recommendation.**

Include the recipient address in the signed `op::Op` (e.g., store it in `arg_addr_0`) and, during execution, assert that it matches `self.caller` (or transfer to the signed recipient directly) so that signatures bind to a specific beneficiary.

**Client response.**

Fixed in commit `00e4000a83b200978cb4ddcb8b6ecbae945ae16e`.

### ECDSA signatures cannot be invalidated, allowing them to be replayed for incompleted operations

- **Severity**: Medium
- **Location**: multisig_core/src/main.leo

**Description.**

In the `multisig_core` program, `sign_ecdsa` would take a signature to the tuple `(wallet_id, signing_op_id)` as a public input. It will then contribute to the number of confirmations if the signature is valid.

However, the `finalize_initiate_signing_op` function (used by the initiate signing operations) allows an expired, incomplete signing operation of the **same** `(wallet_id, signing_op_id)` pair to be created.

**Impact.**

This allows an adversary to replay the signing requests to increase the number of confirmations. In the unfortunate scenario where there are ECDSA signatures contributing to an incompleted signing operation, an adversary could dump the signatures and proceed with the operation if the threshold is decreased.

For operations that have no cryptographic bindings (for instance, the operations in `hyp_multisig` and `hyp_warp_multisig`), an adversary can create the same `(wallet_id, signing_op_id)` with an arbitrary operation. This might lead to loss of funds if either the threshold is decreased, or enough signatures are collected from multiple rounds of signing operations.

**Recommendation.**

Ensure that no signing operations have the same `signing_op_id`s. A nonce can be introduced if the operation is cryptographically tied to the operation ID.

**Client response.**

Fixed in commit `4bee24a276a7ff6a430b476357911811b3cced14`.

### Incomplete binding of admin operation in the multisig_core

- **Severity**: Medium
- **Location**: multisig_core/src/main.leo

**Description.**

The `wallet_signing_op_id_hash` in `multisig_core` does not currently bind the associated `AdminOp`, allowing the operation to be silently swapped. When a pending admin operation expires, any caller can re-initialize the same `signing_op_id`, which overwrites `pending_admin_ops` while reusing the existing `pending_signing_ops`. Because the `signing_op_id` remains unchanged, signers will continue signing under the assumption that they are approving the original admin operation, while in reality the underlying `AdminOp` has been replaced.

**Impact.**

This enables a potential front-running scenario: an adversary can detect that a pending admin operation is about to expire, submit a re-initialization transaction with the same `signing_op_id` but a different `AdminOp`, and race ahead of the original re-init attempt. As a result, signers may be unknowingly tricked into approving an unintended admin operation.

**Recommendation.**

Bind the `AdminOp` into the `wallet_signing_op_id_hash` so that it is uniquely and immutably tied to a specific admin operation, and cannot be reused with different parameters.

**Client response.**

Fixed in commit `0e9076a2d36f870d43b423311f50371d8e559f97`.

### Incorrect role assertion in the update role for non-multisig bridge program

- **Severity**: Medium
- **Location**: bridge_program/src/main.leo

**Description.**

The bridge program allows a single-admin configuration where role updates do not require a multisig operation and can be executed solely by the `MANAGER_ROLE`.

However, in `f_update_wallet_id_role`, the function asserts the `ATTESTER_ROTATOR` role instead of the `MANAGER_ROLE`, as shown in the snippet below:

```rust
assert_eq(multisig_op.salt, 0scalar);
let current_role: u16 = address_to_role.get(caller);
assert(current_role & ATTESTER_ROTATOR == ATTESTER_ROTATOR);
```

**Impact.**

This allows an `ATTESTER_ROTATOR` (whose responsibility should be limited to rotating attesters) to assign arbitrary wallet roles, which can lead to privilege escalation.

**Recommendation.**

Update the assertion to require the `MANAGER_ROLE` instead of `ATTESTER_ROTATOR`.

**Client response.**

Fixed in commit `2063af3121e7fbd393c75d3ebd6828384c189b0d`.

### Missing request clearing in pause and attester update allow for replays

- **Severity**: Medium
- **Location**: bridge_program/src/main.leo

**Description.**

In the bridge program, a wallet with the `PAUSE_ADMIN` role can pause the bridge via `multisig_pause_bridge`. In the finalize function (`f_update_bridge_pause`), the program checks that the multisig operation is completed, has not expired, and is tied to the correct `BridgeMultisigOp` stored in `pending_request`.

However, the program does not remove the `wallet_signing_op_id_hash` from `pending_request`, which allows the function to be replayed indefinitely as long as the `pending_request` entry remains unexpired.

Note that the same issue also occurs in `f_update_circle_attester`.

**Impact.**

This enables indefinite replays of pause or attester-change requests, allowing an adversary to reuse stale approvals or toggle bridge settings long after signers intended those operations to expire.

**Recommendation.**

Add the following line after validation in both `f_update_bridge_pause` and `f_update_circle_attester`:

```rust
pending_requests.remove(wallet_signing_op_id_hash);
```

**Client response.**

Fixed in commit `8248bdac6b77d82c44a0a0f8bd802f126f4a6aca`.

### Initialization of upgrader address should not rely on manual updates of hardcoded constants

- **Severity**: Low
- **Location**: multisig_core/src/main.leo

**Description.**

The program hardcodes `DEPLOYER_ADDRESS` and `UPGRADER_ADDRESS` to public test addresses, and expects the production deployer to manually update these constants before deployment. While the constructor guards against deployments from an unexpected `DEPLOYER_ADDRESS`, the `UPGRADER_ADDRESS` is not protected in the same way. 

**Impact.**

If the production deployer forgets to replace the test upgrader address `aleo1s3ws5tra87fjycnjrwsjcrnw2qxr8jfqqdugnf0xzqqw29q9m5pqem2u4t` (whose private key is publicly known to be `APrivateKey1zkp2RWGDcde3efb89rjhME1VYA8QMxcxep5DShNBR6n8Yjh`) before deployment, any attacker can impersonate it to upgrade the program or permanently disable upgrades.

**Recommendation.**

Instead of hardcoding the `UPGRADER_ADDRESS`, consider storing an `upgrader_address` value in `ProgramSettings` during initialization (e.g., as done for `guard_create_wallet`), then enforcing it at every use. More specifically, assert 

```jsx
assert_eq(self.program_owner, program_settings.upgrader_address);
```

in the `else` branch of the constructor, and

```jsx
assert_eq(caller, program_settings.upgrader_address);
```

in `disallow_upgrades`. This removes the reliance on the manual code edit for `UPGRADER_ADDRESS` and ensures the intended upgrader is explicitly configured during initialization.

**Client response.**

Fixed in commit `f2b227185d94c32703f1e44cec5db424c16edeee`.

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
