# Sealance Compliance Technology for Aleo and Provable CUR Bridge

- **Date**: October 20th, 2025
- **Tags**: aleo, leo, compliance, token

## Introduction

On October 20th, 2025, zkSecurity started a security audit of Sealance's compliant token system for the Aleo blockchain and a Circle unified reserve bridge transferring funds from the Ethereum network to the compliant Aleo token developed by Provable. The audit lasted one week with one consultant. We reviewed the [compliant-transfer-aleo](https://github.com/sealance-io/compliant-transfer-aleo) repository at commit [`22d1159`](https://github.com/sealance-io/compliant-transfer-aleo/tree/22d1159299e116900d9b109579a4c685ba9bf5b8), as well as the bridge implementation in the [CUR_Bridge](https://github.com/ProvableHQ/CUR_Bridge) repository at commit [`a14f51b`](https://github.com/ProvableHQ/CUR_Bridge/tree/a14f51ba7a60ad3c44aaebbc4918b21686228a3e).

### Scope

We reviewed the following Leo programs implementing privacy-preserving compliance for token transfers on Aleo:

- `compliant_token_template.leo`: A compliant token contract template with freeze list enforcement, role-based access control, and support for both public and private token balances. Includes privacy-preserving transfers using Merkle proofs for sanctions screening.

- `sealance_freezelist_registry.leo`: A standalone freeze list registry managing sanctioned addresses with dual Merkle root system (current and previous) to enable graceful list updates without disrupting active transactions.

- `merkle_tree.leo`: Cryptographic utility library providing Merkle tree operations including verification of inclusion and non-inclusion proofs using Poseidon hashing.

Additionally, we reviewed the Circle unified reserve cross-chain bridge implementation:

- `bridge_program/src/main.leo` (from [CUR_Bridge](https://github.com/ProvableHQ/CUR_Bridge)): A bridge contract enabling cross-chain token transfers between Ethereum and Aleo via Circle's Cross-Chain Transfer Protocol. The bridge validates ECDSA-signed attestations from Circle, extracts deposit parameters from CCTP payloads, enforces freeze list checks, and mints bridged tokens on Aleo.

<div style="page-break-after: always;"></div>

## Overview

### Compliant Token System

The compliant token contract provides a privacy-preserving token system on Aleo that enforces sanctions compliance through a freeze list mechanism. The system supports both public and private token balances, enabling four types of transfers:

- *Public to public*: Standard transparent transfers between public balances
- *Public to private*: Converts public balance to a private record
- *Private to public*: Converts a private record to public balance
- *Private to private*: Privacy-preserving transfers between records

The core compliance invariant is that *frozen accounts cannot move funds*. This is enforced differently depending on the transfer type. For public transfers, the freeze list is checked directly in the finalize function. For private transfers, the system uses privacy-preserving Merkle non-inclusion proofs to verify the sender is not on the freeze list without revealing the sender's identity on-chain. 

An important design choice is that private transfers can still send to frozen accounts. This represents a deliberate efficiency trade-off: preventing transfers to frozen addresses would require the sender to prove non-inclusion for the recipient as well, doubling the proof overhead. The policy assumes senders performing private transfers understand this risk.

**Compliance records.**
All private transfers emit a *compliance record* encrypted for a designated investigator address. These records contain the sender, recipient, and amount of each private transfer, providing an audit trail while maintaining privacy from the general public. Only the investigator with the corresponding private key can decrypt and view these records, enabling regulatory oversight without compromising user privacy.

**Allowances and credentials.**
The token system includes two additional mechanisms to improve usability. *Allowances* enable one address to spend tokens on behalf of another, following the standard ERC-20 pattern but adapted for both public and private balances. *Credentials* are reusable proof records that cache a valid freeze list root, allowing users to perform multiple private transfers without regenerating Merkle proofs for each transaction, significantly reducing the computational overhead for frequent users.

**Access control.**
The token contract implements a role-based access control system using bitwise flags. The primary roles include:

- *Manager Role*: Can grant or revoke any role to other addresses, including other managers
- *Minter Role*: Authorized to mint new tokens (public or private)
- *Burner Role*: Authorized to burn tokens from circulation
- *Pause Role*: Can pause token operations in emergency situations

Roles can be combined through bitwise OR operations, allowing a single address to hold multiple roles simultaneously. Token supply is managed through role-based mint and burn operations that respect the maximum supply limit. The token contract supports both `mint_public` (adding to public balance) and `mint_private` (creating private records), as well as `burn_public` (destroying public tokens) and `burn_private` (destroying private records).

**Upgrades.**
Both the token contract and bridge implement an edition-based upgrade mechanism. New versions can be deployed with incremented edition numbers, and the constructor enforces that only authorized addresses can deploy upgrades (editions > 0). This allows the contracts to evolve while maintaining security controls over who can publish new versions.

#### Freeze List and Root Rotation

The freeze list is maintained as an ordered Merkle tree of frozen addresses in `sealance_freezelist_registry.leo`. To allow smooth updates without disrupting active transactions, the system maintains both a current root and a previous root with a configurable time window.

When the freeze list is updated, the old root becomes the previous root and remains valid for a grace period (defined by `block_height_window`). This prevents transactions that were created with valid Merkle proofs from failing if the freeze list is updated before the transaction is processed. Transactions can use either the current root or the previous root, as long as the previous root was updated within the time window (`updated_height + window > block.height`).

#### Ordered Merkle Trees and Non-Inclusion Proofs

The technique for proving non-inclusion in ordered Merkle trees is well established in the literature on authenticated dictionaries, where non-inclusion proofs are constructed by showing the two bounding keys where the queried key would otherwise belong ([Crosby & Wallach, 2011](https://doi.org/10.1145/2019599.2019602)). Modern implementations include [Aztec's Indexed Merkle Tree](https://docs.aztec.network/aztec/concepts/advanced/storage/indexed_merkle_tree), which adapts this approach to zero-knowledge circuits.

The freeze list uses an ordered Merkle tree where leaves are sorted by address value. This structure enables efficient non-inclusion proofs: to prove an address is not in the tree, a user provides two Merkle proofs for adjacent leaves that bracket the target address.

For example, to prove address `0x500` is not frozen, a user provides proofs for the two adjacent frozen addresses `0x400` and `0x600`. The contract verifies:

1. Both proofs resolve to the same Merkle root
2. The target address falls between the two proven addresses (`0x400 < 0x500 < 0x600`)
3. The two proven addresses are adjacent in the tree (consecutive leaf indices)

This approach allows proving non-membership with only two Merkle proofs, regardless of tree size, while maintaining privacy since the verifier only learns about two specific frozen addresses.

### Circle Cross-Chain Bridge

The bridge contract enables transferring tokens from Ethereum to Aleo. The bridge validates ECDSA-signed attestations from Circle that authorize minting bridged tokens on Aleo.

The mint operation accepts a 240-byte deposit payload structured as follows:

| Offset (bytes) | Size | Type      | Field              | Description                                              |
|----------------|------|-----------|--------------------|----------------------------------------------------------|
| **0**          | 4    | `uint32`  | `magic`            | Magic value (`0x2bcc5ce7`)                               |
| **4**          | 4    | `uint32`  | `version`          | Protocol version (always `1`)                            |
| **8**          | 32   | `uint256` | `amount`           | Token amount to deposit                                  |
| **40**         | 4    | `uint32`  | `remoteDomain`     | Destination domain ID (Aleo)                             |
| **44**         | 32   | `bytes32` | `remoteToken`      | Aleo token contract identifier                           |
| **76**         | 32   | `bytes32` | `remoteRecipient`  | Aleo recipient address                                   |
| **108**        | 32   | `address` | `localToken`       | Ethereum source token address                            |
| **140**        | 32   | `address` | `localDepositor`   | Ethereum depositor address                               |
| **172**        | 32   | `uint256` | `maxFee`           | Maximum fee for destination chain                        |
| **204**        | 32   | `bytes32` | `nonce`            | Replay-protection nonce                                  |
| **236**        | 4    | `uint32`  | `hookDataLen`      | Hook data length                                         |

**Main validations.** The bridge receives three inputs for minting: the deposit payload, an ECDSA signature, and a Keccak256 digest of the payload. The contract first verifies the digest matches the payload hash, then validates the ECDSA signature against Circle's attestation service public key. After extracting the recipient address and amount from the payload, the bridge verifies the recipient is not on the freeze list using Merkle non-inclusion proofs. The nonce prevents replay attacks by tracking used attestations on-chain. 

**Minting and burning.** Upon successful validation, the bridge calls `mint_private` on the compliant token contract to create a private token record for the recipient. For the reverse direction, burning tokens to withdraw back to Ethereum uses `burn_public`, requiring users to first convert any private tokens to public balance. The burn operation also enforces freeze list checks to prevent sanctioned addresses from moving funds out of the system. Note that currently the `mint_private` operation conveniently creates private Aleo tokens for the recipient, but does not protect the privacy of the transaction, given that the transaction details are public on the Ethereum side.

**Access control.** The bridge implements a simpler access control model compared to the token contract. It uses two privileged addresses:

- *Admin Address*: Can pause/unpause bridge operations and update the Circle attester public key
- *Emergency Pause Address*: Can trigger an emergency pause independently of the admin for rapid incident response

Both pause mechanisms provide circuit breakers to halt bridge operations if issues are detected, with the emergency pause providing an additional layer of security through separation of concerns.

### Summary and recommendations

Overall, the code on both token and bridge side was found to be of high quality and well commented and tested. The major issue found had to do with freeze list checks when moving tokens through the bridge (see [Freeze list bypass in bridge](#finding-freeze-list-bypass-bridge)).

An important invariant of the Merkle tree used to privately prove non-inclusion is that the tree must be properly sorted. For efficiency reasons, the sorting and calculation of the root is done off-chain, while the root and its updates are stored on-chain. The trust assumption here is that the updater uses the correct off-chain code to recompute the tree after modification. This is visible to all network participants who can recompute the tree independently. It is therefore recommended that operationally this root is monitored for correctness to detect erroneous updates early.

## Findings

### Freeze list not enforced in mint and burn operations

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

**Description**. Neither the bridge contract nor the compliant token contract check the freeze list when minting or burning tokens.

In `bridge_program/src/main.leo`, the `mint` transition does not verify that the recipient is not on the freeze list before minting tokens. Similarly, the `burn` transition does not check whether the caller is frozen before allowing the burn operation.

In `compliant_token_template.leo`, the `mint_private` and `burn_public` functions are callable by the bridge without any freeze list enforcement. These functions lack inclusion checks when invoked through the bridge.

This allows frozen accounts to bypass freeze list restrictions by using the bridge to move funds in and out of the system.

**Impact**. Sanctioned addresses can receive bridged tokens via mint operations and can burn tokens to move funds back to Ethereum, completely bypassing the freeze list enforcement that is otherwise present in standard token transfers.

**Recommendation**. Add freeze list checks to bridge operations. For consistency with the compliant token policy, the most important invariant is that burning (moving funds out) should be restricted for frozen accounts, while minting (transfers to frozen accounts) is optional. The bridge side is the preferred location for these checks to allow for exceptional scenarios where frozen account funds might need to be burned to escrow addresses if required by compliance procedures.

**Client response**. Acknowledged and fixed on the bridge repository in commit [`69a8db4`](https://github.com/ProvableHQ/CUR_Bridge/commit/69a8db4ddd6251b0df7b8317e04f5df5bd69ac23), adding both private inclusion checks in the freeze list for minting and public checks for burning.

### Missing exact validation of localToken field in bridge

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

**Description**. The bridge's `mint` function extracts the `localToken` field from the deposit payload but only validates that it is non-zero, not that it matches the expected Ethereum token address.

At lines 71-75:

```rust
// Ensure the local token contract address is not the zero address.
let local_token_contract_address: [u8; 32] = [0u8; 32];
for i in 0u8..32u8{
    local_token_contract_address[i] = deposit_input[108u8 + i];
}
assert (local_token_contract_address != [0u8; 32]);
```

The code only checks `!= 0` rather than comparing against an expected constant value representing the specific Ethereum token that should collateralize this bridge.

**Impact**. Without exact validation, the bridge could accept Circle attestations for deposits of the wrong Ethereum token. For example, test token attestations could be used on production bridges. While Circle's attestation service likely has safeguards, the bridge contract should not rely solely on those to prevent mismatched tokens. Given that the information on the Ethereum token address is part of the deposit parameters, checking this on the Aleo contract follows the defense-in-depth principle and prevents minted Aleo tokens being backed by incorrect Ethereum collateral.

**Recommendation**. Define a constant for the expected Ethereum token address and validate exact equality:

```rust
const EXPECTED_LOCAL_TOKEN: [u8; 32] = [...];  // Expected Ethereum token address
assert_eq(local_token_contract_address, EXPECTED_LOCAL_TOKEN);
```

This ensures the bridge only accepts deposits for the specific Ethereum asset it is designed to bridge.

**Client response**. The client acknowledges this and is considering adding an expected constant value to address this finding.

### Manager can accidentally remove their own role without ensuring replacement

- **Severity**: Low
- **Location**: compliant_token_template.leo, sealance_freezelist_registry.leo

**Description**. The `update_role` function allows a manager to accidentally remove their own `MANAGER_ROLE` without verifying that at least one manager remains in the system. A manager calling `update_role` on their own address with a role value that does not include `MANAGER_ROLE` will successfully demote themselves, potentially leaving the system without any managers. This affects both the token contract and the freeze list contract.

**Impact**. If the last manager accidentally removes their own role, the system's role management becomes locked. While this is recoverable by having `UPGRADE_ADDRESS` deploy a new edition with a recovery function, the recovery process could be operationally cumbersome.

**Recommendation**. Add a self-demotion check to prevent managers from accidentally removing their own manager role:

```rust
if (caller == new_address) {
    assert(new_role & MANAGER_ROLE == MANAGER_ROLE);
}
```

This would prevent accidental self-demotion while still allowing intentional role transfers where Manager A grants manager role to Manager B, then B demotes A.

**Client response**. Acknowledged and fixed in [PR #108](https://github.com/sealance-io/compliant-transfer-aleo/pull/108/files), including adding test cases for this scenario.

### Confusing variable names in bridge due to perspective mismatch

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

**Description**. The README describes the deposit payload layout from the Ethereum sender's perspective, while the Aleo bridge code parses it from the Aleo recipient's perspective. This creates confusing variable names that don't accurately reflect what the fields represent. The README field `remoteRecipient` at offset 76 (the Aleo address receiving tokens) is extracted into a variable named `local_depositor_address` at line 80:

```rust
// Ensure the local depositor address is not the zero address.
let local_depositor_address: [u8; 32] = [0u8; 32];
for i in 0u8..32u8{
    local_depositor_address[i] = deposit_input[76u8 + i];
}
```

This is later converted to `depositor_address`, but this field actually represents the Aleo recipient, not a depositor. A clearer name would be `local_recipient_address`. Similarly, the field names `remote_token_contract_address` (offset 44) and `local_token_contract_address` (offset 108) are also potentially confusing. If offset 76 is considered "local" from the Aleo bridge's perspective, then the token addresses should follow the same convention: what the README calls `remoteToken` should be `local_token_contract_address` (the Aleo token), and `localToken` should be `remote_token_contract_address` (the Ethereum token).

**Recommendation**. Update variable names to be consistent and clear from the Aleo bridge's perspective:

- Rename `local_depositor_address` to `local_recipient_address` (offset 76)
- Consider swapping the naming of `remote_token_contract_address` and `local_token_contract_address` to match the perspective used for the recipient address

**Client response**. The client acknowledged the issue and already fixed the `local_depositor_address` vs `local_recipient_address` issue in commit [2fbb807](https://github.com/ProvableHQ/CUR_Bridge/commit/2fbb807aa3db3be131b86b70f7114009aac800a9) and is considering renaming other variables for clarity.

### Comment and documentation discrepancies

- **Severity**: Informational
- **Location**: compliant_token_template.leo

**Description**. Some comments in the token contract do not accurately reflect the implementation:

**Lines 99-100 - Initialize function comment**. The `initialize` function comment states:
```rust
// Sets the block height window, the initial empty Merkle root,
// and initialize the freeze_list_last_index, freeze_list, and freeze_list_index with a ZERO_ADDRESS placeholder.
```
However, these operations are currently performed in the `initialize` function of `sealance_freezelist_registry.leo` and not here.

**Line 648 - Transfer private to public comment**. The `transfer_private_to_public` function comment states:
```rust
// A Credentials record is emitted for the sender
```
However, the function returns `(ComplianceRecord, Token, Future)` (no Credentials record).

**Lines 471, 552 - Inconsistent parameter naming**. In `transfer_from_public` and `transfer_from_public_to_private`, the parameter is named `owner` in the transition signature but referred to as `sender` in the finalize functions. While not a logical issue, unifying the naming would improve readability.

**Burn and mint comments**. Both functions have comments stating:

```rust
// Can be called by the admin, a minter (burner), or a supply manager.
```

However, the implementations only check `MINTER_ROLE` and `BURNER_ROLE`. No `admin` or `supply_manager` constants exist in the code. 

**Recommendation**. Update comments to accurately reflect the implementation, or clarify the intended meaning where role composition is implied.

**Client response**. Acknowledged and fixed in [PR #109](https://github.com/sealance-io/compliant-transfer-aleo/pull/109/files).

### First deployment (edition 0) not restricted by constructor

- **Severity**: Informational
- **Location**: compliant_token_template.leo, sealance_freezelist_registry.leo

**Description**. The constructor does not restrict who can deploy the first edition (number 0) of the contracts, despite comments suggesting otherwise.

In `compliant_token_template.leo` at line 57, a comment states:
```rust
// Only this address can deploy the program
```
referring to `INITIAL_CONFIGURATION_ADDRESS`. A similar comment appears in `sealance_freezelist_registry.leo` at line 14.

However, the constructor only enforces ownership restrictions for editions greater than 0:

```rust
async constructor() {
    if (self.edition > 0) {
        assert_eq(UPGRADE_ADDRESS, self.program_owner);
    }
}
```

This means any address can deploy edition 0. In the current `upgrade.test.ts` tests, edition 0 is deployed using the key corresponding to `UPGRADE_ADDRESS`, not the `INITIAL_CONFIGURATION_ADDRESS`. Different addresses, including the admin key, can also deploy edition 0.

The behavior for upgrades to editions > 0 is correct and properly restricted to `UPGRADE_ADDRESS` as verified by tests.

**Impact**. A malicious actor could front-run the legitimate deployment of edition 0 and deploy it under their own address. While operational procedures should verify the logic and owner of edition 0 anyway to prevent name squatting, allowing unrestricted deployment of edition 0 could enable the legitimate program logic to be owned by an incorrect address, which might be harder to detect than obvious name squatting.

**Recommendation**. Either restrict edition 0 deployment to `INITIAL_CONFIGURATION_ADDRESS` as suggested by the comment, or to `UPGRADE_ADDRESS` if that is the intended deployer. Alternatively, update the comments to accurately reflect that edition 0 deployment is unrestricted.

**Client response**. Acknowledged as a comment typo. The team discussed adding a role restriction to edition 0 but decided this is not critical. They would also like to keep the initial deployment actor not fixed, such that the deployer is independent from the initializer and the upgrade roles. Comment typo fixed in [PR #106](https://github.com/sealance-io/compliant-transfer-aleo/pull/106/files).

### Merkle proof depths not validated to match in non-inclusion verification

- **Severity**: Informational
- **Location**: compliant_token_template.leo, bridge_program/src/main.leo

**Description**. The `verify_non_inclusion` function calculates the depth from two Merkle proofs (`depth1` and `depth2`) and asserts that the roots match, but does not validate that the depths are equal.

The function computes both depths:

```rust
let (root1, depth1): (field, u32) = calculate_root_depth_siblings(merkle_proofs[0u32]);
let (root2, depth2): (field, u32) = calculate_root_depth_siblings(merkle_proofs[1u32]);

// Ensure the roots from the merkle proofs are the same
assert_eq(root1, root2);
```

However, `depth2` is calculated but never used in subsequent validation. The function only checks that `root1 == root2`, not that `depth1 == depth2`.

This means an attacker could potentially provide two proofs with the same root but different tree depths. While finding such proofs is very unlikely, the check would be straightforward to add.

**Impact**. By construction, two valid sibling proofs for adjacent leaves in the same tree should always have matching depths. The missing depth comparison creates a theoretical validation gap, though exploiting this would be extremely difficult in practice.

**Recommendation**. Add a depth equality check after the root comparison:

```rust
assert_eq(root1, root2);
assert_eq(depth1, depth2);
```

**Client response**. Client acknowledged this issue and fixed it in [PR #110](https://github.com/sealance-io/compliant-transfer-aleo/pull/110).

---

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).
