# Audit of Hyperlane's Aleo Integration

- **Client**: Hyperlane
- **Date**: November 17th, 2025
- **Tags**: Aleo, cross-chain

## Introduction

On November 17th, 2025, Hyperlane tasked zkSecurity with auditing its Aleo integration. The specific code to review was extracted and shared via a public GitHub repository. Additionally, Hyperlane shared its [documentation](https://github.com/hyperlane-xyz/hyperlane-aleo), which outlines various aspects of the protocol.

The codebase was found to be of high quality, accompanied by thorough tests and comprehensive documentation. In this report, we highlight a few informational-level findings and offer several remarks on extending the existing protocol or implementing new applications, where, if certain considerations are overlooked, potential issues could arise. The current protocol and its applications are not susceptible to these issues.

## Scope

The scope of the audit included all `*/**/src/main.leo` programs in the [GitHub repo](https://github.com/hyperlane-xyz/hyperlane-aleo) of Hyperlane’s Aleo integration at commit `faaa2c717e0de9e08ec6cf816551c00fd313fc30`. More specifically, we reviewed the following components:

- The `dispatch_proxy`, a lightweight contract that acts as an intermediary for dispatching messages to the mailbox.
- The `hook_manager`, which provides functionality for registering, executing, and managing post-dispatch hooks that are called after message dispatch operations.
- The `ism_manager`, which implements the Interchain Security Module (ISM) management system for message verification on Aleo.
- The `mailbox`, which provides the core message passing functionality.
- The `validator` program, a Hyperlane Validator Announce contract, which allows validators to announce their storage locations.

In addition to the above protocol-level programs, we also reviewed three application-level bridge programs for transferring assets between Aleo and other blockchains using the Hyperlane protocol:

- `hyp_native.aleo`: to enable bridging of Aleo (credit) tokens to other chains,
- `hyp_synthetic.aleo`: to enable bridging of tokens from other chains to Aleo, where the program is minting new tokens,
- and `hyp_collateral.aleo` which enables the minting of tokens that are natively supported (or already exist) in Aleo (e.g., native USDC).

The fixes applied in the following commits were also reviewed.

- `58e53ca`, `db9d8d4`, `fc12d64`, `97f12c6` `147b6ca`, `e83927f`.

## Overview

### Hyperlane Overview

<img src="/img/reports/hyperlane-aleo-1/hyperlane-overview.png"/>

Hyperlane is a permissionless cross-chain messaging protocol that allows passing arbitrary messages between different blockchains.

There are two main flows in the protocol: the first is the "dispatch" flow, which is used to dispatch a message from Chain A (origin) to Chain B (destination). The second is the "process" flow, which is used to process the same message on Chain B using a custom verification logic.

There are three main actors in the protocol:

- Users: the entities that want to send and receive messages between chains
- Validators: the entities that are responsible for verifying the messages on the origin chain
- Relayers (optional, i.e., a user can become a relayer): the entities that are responsible for processing the messages on the destination chain

The flow starts with the user calling the `dispatch` function on the `Application` contract. This function will call into the `Mailbox` contract, which will store the message and invoke the post-dispatch hook. There can be multiple hooks, but for illustration purposes, we will describe the IGP hook and the Merkle Tree hook. The IGP hook is responsible for charging the user for processing the message on the destination chain, and the Merkle Tree hook is responsible for updating an existing merkle tree with the new message.

Once the `dispatch` function is finished, the contract will emit an event that indicates that a message has been dispatched. The validator, which will be monitoring the `Mailbox` contract for new messages, will then fetch the new message and merkle root. Upon confirming that the new root is valid, the validator will sign the new merkle root and store it off-chain.

Validators make these signature checkpoints discoverable through the `validator_announce` program. Each validator publicly registers where their signed checkpoints are stored, e.g., an S3 bucket, allowing relayers to retrieve them. The program acts as an on-chain registry linking each validator to its checkpoint-storage endpoint, and remains fully independent from the rest of the core protocol.

At any later point in time, the relayer can fetch this signed merkle root, along with the message, and call the `process` function on the `Mailbox` contract on the destination chain. The relayer will also send along an address to an Interchain Security Module (ISM) contract that contains the verification logic for the given message (in this case, it will be a contract that contains the public keys of the validators that signed the merkle root). Then, the `Mailbox` contract will call the ISM contract to verify the signatures and if valid, process the message by invoking the `Application` contract.

### Differences in the Aleo implementation

<img src="/img/reports/hyperlane-aleo-1/aleo-hyperlane-overview.png"/>

Unlike other Hyperlane implementations, the Aleo integration requires *applications* to serve as the entry point for both message dispatch and processing operations. This design is necessitated by the Aleo Virtual Machine's (AVM) lack of support for dynamic contract calls. Further, since Aleo does not support dynamic array lengths, the Hyperlane's Aleo integration does not support arbitrary sizes for messages, validator sets, and similar structures.

In this pattern, applications like `hyp_native.aleo`, `hyp_synthetic.aleo`, and `hyp_collateral.aleo` call the dispatch proxy on behalf of users.
The `dispatch_proxy.aleo` program serves as an intermediary coordinator for dispatching messages to the mailbox. 
Because the Aleo VM doesn't support dynamic contract calls, it breaks the dependency cycle between the mailbox and hooks and provides a stable entry point for dispatching while ensuring post-dispatch hooks run after processing messages in the mailbox.
Notably, users have to pass the following arguments when dispatching messages (e.g., transfer in `hyp_native`).

```jsx
    async transition transfer_remote(
        public unverified_token_metadata: Metadata,
        public unverified_mailbox_state: MailboxState,
        public unverified_remote_router: RemoteRouter,
        public destination: u32,
        public recipient: [u128; 2],
        public amount: u64,
        public allowance: [CreditAllowance; 4])
```

Then this function has to call the dispatch proxy as follows:

```jsx
let message_body = get_message_body(recipient, body_amount);
let dispatch_future: Future = dispatch_proxy.aleo/dispatch(
    unverified_mailbox_state, destination, 
    unverified_remote_router.recipient, message_body, 
    custom_hook, hook_metadata, allowance
);
```

The dispatch proxy will first check that the mailbox state the caller passed on still matches the current on-chain state. If it does not, the dispatch is aborted. If it does, the message is dispatched, and then the configured hooks execute. This guarantees that hooks run against the proper messages and in the correct order.

Similarly, when messages need to be processed, the relayers will call the relevant application instead of the mailbox. Then it is the responsibility of the application to correctly call the mailbox, which in turn will verify the message through calling the ISM manager, and will record the processed messages.

### ISMs in Aleo

In the Aleo implementation, three ISM variants are provided.

- `ISM_NULL`: a no-op ISM intended only for testing.
- `ISM_MESSAGE_ID_MULTISIG`: verifies validator signatures over a digest constructed from the message ID and associated metadata.
- `ISM_ROUTING`: routes verification to another ISM based on the message’s origin domain.

ISMs are not separate contracts on Aleo. Instead, they are entries stored in the ISM Manager program. An ISM's address is derived by applying `BHP256::hash_to_address` to a struct describing its configuration. For example, for a MessageIdMultisigIsm:

```jsx
let ism = MessageIdMultisigIsm {
    validators,
    validator_count,
    threshold,
    nonce: current_nonce,
};

let ism_address = BHP256::hash_to_address(ism);
```

Since only `ISM_MESSAGE_ID_MULTISIG` actually verifies signatures, we focus on its behavior below.

When verifying a message, the verifier receives the message's `origin_domain`, its `message_id`, and its message `metadata`, which has the following layout:

```text
+--------------------------------------------------------------------------------------+
| 0 .. 31   (32 bytes)  | origin_merkle_tree_hook (bytes32)                            |
+--------------------------------------------------------------------------------------+
| 32 .. 63  (32 bytes)  | checkpoint_root (bytes32)                                    |
+--------------------------------------------------------------------------------------+
| 64 .. 67  (4 bytes)   | checkpoint_index (uint32, ABI-packed big-endian)             |
+--------------------------------------------------------------------------------------+
| 68 .. 132 | signature[0] (65 bytes: r[32] || s[32] || v[1])                          |
+--------------------------------------------------------------------------------------+
| 133 ..197 | signature[1] (65 bytes)                                                  |
+--------------------------------------------------------------------------------------+
| ...                                                                                  |
+--------------------------------------------------------------------------------------+
| 68 + n*65 .. 68+(n+1)*65-1 | signature[n] (65 bytes)                                 |
+--------------------------------------------------------------------------------------+
```

where we have:

- `origin_merkle_tree_hook`
    - Type: bytes32 (20-byte address left-padded)
    - Purpose: Identifies the origin chain's `MerkleTreeHook` contract that inserts dispatched `message_ids` into an incremental Merkle tree. Included in the "domain hash" to bind validator signatures to a specific, approved tree instance on the origin chain.
- `checkpoint_root`
    - Type: bytes32
    - Purpose: The Merkle root at the time of signing. Validators attest that, at this root, the `message_id` appears at `checkpoint_index` in the origin tree (Message-ID Multisig variant does not carry an on-chain Merkle proof; it relies on this attestation).
- `checkpoint_index`
    - Type: uint32 (4 bytes, ABI big-endian)
    - Purpose: Leaf position in the origin tree for the signed checkpoint. Combined with root and `message_id`, pins the exact leaf the validators attested to.
- signatures
    - Type: threshold concatenated ECDSA signatures, each 65 bytes (r||s||v)

Then, the digest is computed using the following code:

```jsx
let merkle_tree_hook = origin_merkle_tree_hook(metadata);
let hash = domain_hash(origin_domain, merkle_tree_hook);
let id_bytes = message_id_bytes(message_id);
let msg: [u8; 100] = [0u8; 100];

// domain hash (32 bytes)
for i in 0u8..32u8 {
    msg[i] = hash[i];
}

// checkpoint root (32 bytes)
for i in 0u8..32u8 {
    msg[32u8 + i] = metadata[MERKLE_ROOT_OFFSET + i];
}

// merkle index (4 bytes)
for i in 0u8..4u8 {
    msg[64u8 + i] = metadata[MERKLE_INDEX_OFFSET + i];
}

// message id (32 bytes)
for i in 0u8..32u8 {
    msg[68u8 + i] = id_bytes[i];
}

let checkpoint_bits = Keccak256::hash_native_raw(msg);
let checkpoint = Deserialize::from_bits_raw::[[u8; 32]](checkpoint_bits);

return to_eth_signed_message_hash(checkpoint);
```

This corresponds to the following steps:

1. Domain hash

```jsx
domain_hash = keccak(origin_domain_BE || origin_merkle_tree_hook || "HYPERLANE")
```

2. Checkpoint hash

```jsx
checkpoint = keccak(domain_hash || checkpoint_root || checkpoint_index || message_id)
```

3. Ethereum signed message hash

```jsx
digest = keccak("\x19Ethereum Signed Message:\n32" || checkpoint)
```

Finally, for signature verification, for each signature `signatures[i]` we have the following steps:

1. Recover the ECDSA signer from the signature and the `digest`.
2. Require that the recovered address equals `validators[i]`.
3. Count successful matches toward meeting the multisig threshold.

Verification succeeds only if at least threshold signatures match their corresponding validator addresses.

## Threat Model

In this section, we describe the threat model for Hyperlane's implementation and deployment on Aleo.

### **Actors**

- **Applications (untrusted)**
  Programs using Hyperlane. Must implement the `dispatch`/`process` functions correctly; users and relayers must trust them before interacting with them.
- **Users (untrusted)**
  Can supply arbitrary inputs to applications.
- **Validators (trusted, security-critical)**
  n-of-m set signing Merkle roots. If more than `threshold` are malicious or sign malformed roots, forged messages become valid.
- **Relayers (untrusted, liveness only)**
  Transport messages and proofs. Can delay, censor, reorder, but cannot break safety. Users can become relayers to push their messages if needed.
- **Application admins (trusted relative to the app)**
  Configure ISM, sender/recipient/domain rules. If compromised, that application is unsafe.
- **Core protocol admins (trusted, system-critical)**
  Control mailbox, ISM configs, required hook, upgrades.
  If compromised: full system breaks (e.g., resetting ISM/hook/dispatch proxy).

### **Security Goals**

- **Only valid messages are processed**
  - Must originate from the claimed chain + expected application.
  - Must be verified under the configured ISM.

- **Message integrity**
  - Delivered message must match exactly what was emitted.

- **Application-level authorization**
  - `process` enforces the origin domain and the message sender, while the mailbox handles version, destination domain, and recipient (per the provided configuration). 
  - `dispatch` enforces recipient, destination domain, while the mailbox sets version, origin domain, nonce, and the sender. 
  - Both also enforce any app-specific checks and ensure the mailbox is correctly called to validate messages and record them.

- **No trust in relayers**
  - Malicious relayers affect liveness only, never safety.

### **Core Trust Assumptions**

**Validator assumptions**

- **Threshold honesty.**
  `MessageIdMultisigIsm` uses a threshold over a validator set (`MAX_VALIDATORS = 6`).
  If malicious validators ≥ threshold → they can sign invalid roots.
- **Correct Merkle root computation.**
  Validators must compute/encode the Merkle root correctly.
  Signing malformed data could break the system.

**Core protocol assumptions**

- **Correct configuration at deployment**
  - Required hook set.
  - ISM set correctly per route.
  - No fallback paths that bypass ISM/hook.
- **Core admins are honest.**
  They must not downgrade ISMs, disable hooks, or push malicious upgrades.
  If compromised, they effectively control message validity.

**Relayer assumptions**

Not trusted for safety.
May drop, delay, or reorder messages; cannot forge signatures or bypass ISM.

**Application assumptions**

- Applications must configure the ISM correctly (validator set, threshold, routing) and wire themselves to it properly.
- Applications must implement **their own security checks**. The mailbox does **not** enforce sender, origin, destination, or recipient validation. Any missing check directly weakens the application, even if the ISM is correct.
- **`process` must check:**
  - origin domain
  - sender program
- **`dispatch` must check:**
  - destination domain
  - recipient
- Admin-only configuration actions (such as registering program IDs in the Mailbox) must be performed correctly.

## Remarks regarding Hyperlane Aleo Implementation

In this section, we highlight several findings identified during the engagement that do not affect the current applications. These issues are not exploitable under the assumed threat model with proper configurations. However, if misconfigurations occur or future applications are not careful when implementing their Hyperlane programs, these issues could lead to severe vulnerabilities.

### Duplicated validators configuration bypass n out of m signatures.

The following code verifies the ECDSA signatures of the validators:

```jsx
let multisig = message_id_multisigs.get(ism_address);
let validators = multisig.validators;
let valid_signatures = 0u8;

for index in 0u8..MAX_VALIDATORS {
    let valid = ECDSA::verify_digest_eth(signatures[index], validators[index].bytes, digest);
    valid_signatures += valid ? 1 : 0;
}
assert(valid_signatures >= multisig.threshold);
```

Each signature must be supplied in the same order as the corresponding validator in the configuration. If a validator appears multiple times in the configuration, its signature will be verified multiple times and counted as distinct signatures. To preserve the threshold invariant, both applications and the mailbox must ensure that the ISM configuration contains no duplicated validators. Otherwise, compromising a duplicated validator could have a disproportionately large impact compared to other Hyperlane deployments.

### `mailbox/dynamic_message_id()` doesn't check the message length

The following code in Mailbox's `finalize_process` function computes the message ID from the user-provided message and asserts that it matches the message ID previously supplied for verification. Correct computation here is critical:

```jsx
let calculated_id = dynamic_message_id(message, message_length);
assert_eq(calculated_id, id);
```

The implementation of `dynamic_message_id` is shown below. Note that `message_length` is fully user- (or relayer-) controlled:

```jsx
inline dynamic_message_id(static_message: Message, message_length: u32) -> [u128; 2] {
    assert(message_length >= 77 && message_length <= 333);
    assert((message_length - 77) % 16 == 0 || message_length == 206 || message_length == 149 || message_length == 167);
    let id = [0u128; 2];
    let message_raw = message_to_raw(static_message);
    if message_length == 77u32 {
        let message = [0u8; 77];
        for i in 0u32..77u32 { message[i] = message_raw[i]; }
        let hash = Keccak256::hash_native_raw(message);
        id = Deserialize::from_bits_raw::[[u128; 2]](hash);
    } else if message_length == 93u32 {
        ...
    } else if ... {
        ...
    } else if message_length == 333u32 {
        let hash = Keccak256::hash_native_raw(message_raw);
        id = Deserialize::from_bits_raw::[[u128; 2]](hash);
    }

    // These are special cases with commonly used message lengths
    // We manually added them here to add more hyperlane compatibility
    if message_length == 206 {
        ...
    }

    return id;
}
```

Because both `static_message` and `message_length` are user-controlled, applications that support multiple message sizes are vulnerable to a message substitution attack: a malicious user could submit on origin and verify on destination one message and execute another. For instance, suppose an application accepts a short 77-byte "test" message containing only header fields, as well as larger messages (e.g., 333 bytes) containing transfer data. An attacker could:

1. Submit the 77-byte header-only message on the origin chain and obtain validator signatures.
2. On the destination chain, provide a larger message (same header and nonce, but with a transfer body) while falsely claiming a message_length of 77.

Because the validator-signed message ID corresponds to the short message, but `dynamic_message_id` is computed using only the first 77 bytes, the check passes, even though the attacker executes a different, larger message. This would allow draining the bridge.

Current applications avoid this problem because they only support a single, fixed message size. However, future applications that support multiple sizes must enforce application-level checks ensuring that the provided message length matches the actual message body. A robust solution is to require every message to include a body where the first field encodes the message length, emit that length on the origin chain, have validators sign it, and verify it again in the Aleo application logic. This prevents the class of substitution attacks described above.

### Check of gas payment for IGP might fail

In `finalize_pay_for_gas` in the IGP hook (`hook_manager` program), the following check ensures that the credit amount supplied by the user matches the relayer's required quote:

```jsx
assert_eq(quote, credits_amount);
```

Because the quote is derived from a configuration that may change between transaction submission and processing (e.g., due to price updates), the final price may differ. Even if the updated quote is lower than what the user was willing to pay, the transaction will still fail. This is an intentional design choice, as implementing refunds in such cases is difficult/costly on Aleo. Users should therefore ensure they compute the price precisely, or Hyperlane could modify the logic to support an upper-bound check instead.

## Arithmetic Scaling in Hyp Applications

**Scaling** maps token amounts between chains with different unit granularities by multiplying or dividing by powers of ten. Both `hyp_native` and `hyp_synthetic`/`hyp_collateral` use fixed, integral decimal scaling, and all divisions **floor** (truncate), never round up. This truncation bias prevents over-crediting or over-minting; users may lose **< 1 unit** at the target precision per transfer when amounts are not exact multiples of $10^{\Delta}$.

**Hyp Native (credits)**

A single parameter `scale` in the range $0 \ldots 19$ specifies the power-of-ten factor relating local `u64` credits to the message amount.

-   **Outbound (local → message):**
    $$
    \text{message\_amount} = \text{local\_amount} \times 10^{\text{scale}}
    $$

-   **Inbound (message → local):**
    $$
    \text{local\_amount} = \left\lfloor \frac{\text{message\_amount}}{10^{\text{scale}}} \right\rfloor
    $$

Outbound is exact; inbound loses the truncated fraction. If $\text{message\_amount} < 10^{\text{scale}}$, then the inbound amount is zero.

**Hyp Synthetic (wrapped ERC‑20--style assets)**

Two parameters govern scaling: `local_decimals` and `remote_decimals`.

Outbound (local → remote)

-   **Equal decimals:**
    $$
    \text{message\_amount} = \text{local\_amount}
    $$
-   **Local decimals > remote decimals:**
    $$
    \text{message\_amount} = \left\lfloor \frac{\text{local\_amount}}{10^{\text{local} - \text{remote}}} \right\rfloor
    $$
-   **Local decimals < remote decimals:**
    $$
    \text{message\_amount} = \text{local\_amount} \times 10^{\text{remote} - \text{local}}
    $$

Inbound (remote → local)

-   **Equal decimals:**
    $$
    \text{local\_amount} = \text{message\_amount}
    $$
-   **Local decimals > remote decimals:**
    $$
    \text{local\_amount} = \text{message\_amount} \times 10^{\text{local} - \text{remote}}
    $$
-   **Local decimals < remote decimals:**
    $$
    \text{local\_amount} = \left\lfloor \frac{\text{message\_amount}}{10^{\text{remote} - \text{local}}} \right\rfloor
    $$

Scale‑downs floor; scale‑ups multiply with overflow checks. A small consistency issue exists in the equal‑decimals inbound branch: it does
not assert upper 128 bits are zero, but this is a deliberate choice, since they still want to process such messages even if they lead to loss of funds.

## A Remark on Secure Deployments on Aleo

Because the deployment scripts for Hyperlane's Aleo integration were not part of this audit's scope, we would like to emphasize an important, Aleo-specific subtlety that should be taken into account during program deployment.

In Aleo, programs are identified only by their program name, not by content. An attacker can front-run a deployment and publish a program with the same name but different logic. Similarly, if a program imports another by name, an attacker can deploy a fake dependency.
If such a front-running attack goes unnoticed, this can lead to a severe compromise of protocol functionality.
For this reason, the deployment scripts of Hyperlane's Aleo integration should include corresponding post-deployment verification checks to confirm that the programs have actually been deployed by the expected address. This can, for example, easily be done by checking a deployed program's owner using the following two endpoints of [Aleo's public API](https://developer.aleo.org/apis/public_api/):

```jsx
/find/transactionID/deployment/{program_id}

/transaction/{id}
```

Alternatively, one could also do a manual check (e.g., via a [block explorer](https://explorer.provable.com/programs)) after the programs have been deployed.

## Additional Encoding/Decoding Work for Relayers and Validators

To optimize the underlying ZK circuits, the Aleo integration of Hyperlane represents certain protocol objects differently from the standard Hyperlane implementation on other blockchains. More specifically, on Aleo, the message ID and the Merkle root are not represented as 32-byte arrays, but rather as little-endian encoded `u128` arrays of length 2. Furthermore, the message body is not a dynamic byte array, but rather a little-endian encoded `u128` array of length 16.

This means that, when Aleo is the _destination_ chain, before calling `process` on the application, the relayer needs to:

- Convert the `id` from `bytes32` to a little-endian encoded `[u128;2]` array.
- Convert the `body` from `bytes` to a little-endian encoded `[u128;16]` array.

Similarly, when Aleo is the _origin_ chain, the relayer has to:

- Convert the `id` from a little-endian encoded `[u128;2]` array to `bytes32`.
- Convert the `body` from a little-endian encoded `[u128;16]` array to `bytes`.

For Validators, on the other hand, this means that when they’re attesting to messages by signing the Merkle root on Aleo, they need to convert this root from `[u128;2]` to `bytes32` before signing it.

### A Concrete Example

The conversion from one type to the other is best understood by looking at a concrete example. Here, we’ll focus on the conversions the relayer would do when delivering a message from Ethereum to Aleo, e.g., to the `hyp_synthetic.aleo` program. The message we’ll consider will have the following concrete values:

- `version = 3 (uint8)`
- `nonce = 1 (uint32)`
- `origin_domain = 100 (uint32)`
- `sender = 0x1 (address)` This will be converted to `bytes32` by the Solidity mailbox contract.
- `destination_domain = 200 (uint32)`
- `recipient = TypeCasts.addressToBytes32(0x2) (bytes32)` Here, we just assume that the result of this is indeed the address encoding of the actual recipient-application address. To get such an address-encoding for a given Aleo address, one can use [this function](https://github.com/hyperlane-xyz/hyperlane-aleo/blob/2c7ef0cdbcdb74aedb8d3efffb12945522833f8d/mailbox/src/main.leo#L233-L240).

Furthermore, we'll use the following message body (of type `bytes`) for our example:

```text
0x
01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01
02 02 02 02 02 02 02 02 02 02 02 02 02 02 02 02
03 03 03 03 03 03 03 03 03 03 03 03 03 03 03 03
04 04 04 04 04 04 04 04 04 04 04 04 04 04 04 04
05 05 05 05 05 05 05 05 05 05 05 05 05 05 05 05
06 06 06 06 06 06 06 06 06 06 06 06 06 06 06 06
07 07 07 07 07 07 07 07 07 07 07 07 07 07 07 07
08 08 08 08 08 08 08 08 08 08 08 08 08 08 08 08
09 09 09 09 09 09 09 09 09 09 09 09 09 09 09 09
0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a 0a
0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b 0b
0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c 0c
0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d 0d
0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e
0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f 0f
10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
```

So, in this particular example, we have a message body of exactly 256 bytes (which is the maximum Hyperlane’s Aleo integration can handle).

During message dispatch, the concrete message fields listed above are then parsed by the `formatMessage` function in the [Message.sol library](https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/43ecd628c4d4db6815e527a8db5761abb51ba18e/solidity/contracts/libs/Message.sol#L33) of Hyperlane’s Solidity implementation. Concretely, this function uses `abi.encodePacked` to encode the message fields and returns the corresponding object of type `bytes`. For our concrete message values, we can implement this encoding step, e.g., in [Chisel](https://getfoundry.sh/chisel/overview/), via

```solidity
// version, nonce, domains
uint8 version = 3;
uint32 nonce = 1;
uint32 origin = 100;
uint32 dest = 200;

// sender & recipient addresses
address sender = address(0x1);
address recipient = address(0x2);

// build body: 16 blocks of 16 bytes: 0x01..0x10
bytes memory body = new bytes(256);
for (uint i = 0; i < 16; i++) {
    for (uint j = 0; j < 16; j++) {
        body[i * 16 + j] = bytes1(uint8(i + 1));
    }
}

// build the message like Hyperlane's Message.formatMessage
bytes memory msgBytes = abi.encodePacked(
    version,             // uint8
    nonce,               // uint32 (big endian)
    origin,              // uint32
    bytes32(uint256(uint160(sender))),     // sender as bytes32
    dest,                // uint32
    bytes32(uint256(uint160(recipient))),  // recipient as bytes32
    body                 // 256 bytes
);
```

and then print the resulting encoding by entering:

```solidity
// print encoded message
msgBytes
```

This will return the following encoded message:

```solidity
0x0300000001000000640000000000000000000000000000000000000000000000000000000000000001000000c800000000000000000000000000000000000000000000000000000000000000020101010101010101010101010101010102020202020202020202020202020202030303030303030303030303030303030404040404040404040404040404040405050505050505050505050505050505060606060606060606060606060606060707070707070707070707070707070708080808080808080808080808080808090909090909090909090909090909090a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f1010101010101010101010101010101000000000000000000000000000000000000000
```

To get the corresponding message ID, Hyperlane’s Solidity implementation then simply hashes the above encoding with `keccak256`:

```solidity
// prints the message ID as:
// 0x02c5e59f511a6c8d8507902a41400b599345c6d7d8019550e46c3cc94fc7aa22
keccak256(msgBytes)
```

Let us now discuss what happens on the Aleo side: When the relayer has picked up the message that was dispatched by the Ethereum mailbox, it will call `process` on the receiving application’s Aleo program (in the context of this audit, this could be `hyp_collateral`, `hyp_native`, or `hyp_synthetic`).

The application’s `process` will, in turn, call the mailbox’s `process`, which will, among other things, check that the `id` and `message` that were passed in by the relayer are consistent with each other.

```jsx
// Assert that the passed id matches the calculated id
let calculated_id = dynamic_message_id(message, message_length);
assert_eq(calculated_id, id);
```

The `message` that the relayer has to supply to the Aleo application is of the following type:

```jsx
struct Message {
    version: u8,
    nonce: u32,
    origin_domain: u32,
    sender: [u8; 32],
    destination_domain: u32,
    recipient: [u8; 32],
    body: [u128; 16],
}
```

Notice that there’s a subtle difference to the Solidity implementation: the `body` is now a `u128` array of fixed length `16`!

**In other words, the relayer will have to take the original `bytes` body that was dispatched on the origin chain and convert it to the `[u128; 16]` type that’s expected by Hyperlane’s Aleo integration.** More specifically, the relayer will have to do this conversion as a _little-endian_ encoding — more on that particular step in a bit. Let’s first get back to our discussion of how the mailbox checks that the supplied `id` indeed corresponds to the provided `message`.

Inside `dynamic_message_id`, the first step is to convert the `message` supplied by the relayer to the “raw” version of the message (i.e., the byte representation). This conversion is handled by the `message_to_raw` function. An easy way to test this conversion for our particular message is to run

```bash
leo run message_to_raw
```

using the following code on the [Leo playground](https://play.leo-lang.org/):

```jsx
program sample.aleo {
    // Message type on Hyperlane's Aleo implementation
    struct Message {
        version: u8,
        nonce: u32,
        origin_domain: u32,
        sender: [u8; 32],
        destination_domain: u32,
        recipient: [u8; 32],
        body: [u128; 16],
    }
    // Constant, corresponding to the message sender address that was
    // byte-encoded by the Solidity mailbox
    const SENDER: [u8; 32] = [
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8,
    ];

    // Constant, corresponding to the byte-encoded message-recipient address
    // that was specified by the sending application that called `dispatch` on
    // the Solidity mailbox
    const RECIPIENT: [u8; 32] = [
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 2u8,
    ];

    // Message body that was passed in by the relayer,
    // represented as little-endian encoded [u128; 16].
    // We will talk about how to do this conversion in a bit.
    const BODY: [u128; 16] = [
        1334440654591915542993625911497130241u128,
        2668881309183831085987251822994260482u128,
        4003321963775746628980877734491390723u128,
        5337762618367662171974503645988520964u128,
        6672203272959577714968129557485651205u128,
        8006643927551493257961755468982781446u128,
        9341084582143408800955381380479911687u128,
        10675525236735324343949007291977041928u128,
        12009965891327239886942633203474172169u128,
        13344406545919155429936259114971302410u128,
        14678847200511070972929885026468432651u128,
        16013287855102986515923510937965562892u128,
        17347728509694902058917136849462693133u128,
        18682169164286817601910762760959823374u128,
        20016609818878733144904388672456953615u128,
        21351050473470648687898014583954083856u128,
    ];

    transition message_to_raw() -> [u8; 333] {

        let MESSAGE = Message {
            version: 3u8,
            nonce: 1u32,
            origin_domain: 100u32,
            sender: SENDER,
            destination_domain: 200u32,
            recipient: RECIPIENT,
            body: BODY
        };

        let raw = [0u8; 333];
        raw[0] = MESSAGE.version;

        let nonce = u32_to_bytes(MESSAGE.nonce);
        let origin_domain = u32_to_bytes(MESSAGE.origin_domain);
        let destination_domain = u32_to_bytes(MESSAGE.destination_domain);

        for i in 0u8..4u8 {
            raw[i + 1] = nonce[i];
            raw[i + 5] = origin_domain[i];
            raw[i + 41] = destination_domain[i];
        }

        for i in 0u8..32u8 {
            raw[i + 9] = MESSAGE.sender[i];
            raw[i + 45] = MESSAGE.recipient[i];
        }

        for i in 0u16..16u16 {
            let bytes = u128_to_bytes(MESSAGE.body[i]);
            for k in 0u16..16u16 {
                raw[77 + i * 16 + k] = bytes[k];
            }
        }

		// Byte layout of returned raw message will be:
        // [0]         - version (1 byte)
        // [1-4]       - message nonce (4 bytes)
        // [5-8]       - origin domain (4 bytes)
        // [9-40]      - sender address (32 bytes)
        // [41-44]     - destination domain (4 bytes)
        // [45-76]     - recipient address (32 bytes)
        // [77-333]    - message body (256 bytes)
        return raw;
    }

    inline u32_to_bytes(value: u32) -> [u8; 4] {
        let b3: u8 = (value >> 24u32) as u8;
        let b2: u8 = ((value >> 16u32) % 256u32) as u8;
        let b1: u8 = ((value >> 8u32) % 256u32) as u8;
        let b0: u8 = (value % 256u32) as u8;
        return [b3, b2, b1, b0];
    }

    inline u128_to_bytes(value: u128) -> [u8; 16] {
        let bits = Serialize::to_bits_raw(value);
        return Deserialize::from_bits_raw::[[u8; 16]](bits);
    }

    // The constructor is configured to prevent upgrades.
    @noupgrade
    async constructor() {}
}

```

The output should exactly correspond to the raw byte-encoded message we saw on the Solidity side. The only “difference” is that the individual bytes are now represented as `u8`s instead of hexadecimal. So, instead of `03`, we have `3u8`, and instead of `64`, we have `100u8`, etc.

Inside `dynamic_message_id`, the next step is to compute the `Keccak256` hash of the raw message bytes. Again, we can test this on the Leo Playground, this time with the following code:

```jsx
program sample.aleo {

    struct Message {
        version: u8,
        nonce: u32,
        origin_domain: u32,
        sender: [u8; 32],
        destination_domain: u32,
        recipient: [u8; 32],
        body: [u128; 16],
    }

    const RAW_MESSAGE: [u8; 333] = [
        3u8, 0u8, 0u8, 0u8, 1u8, 0u8, 0u8, 0u8, 100u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 0u8, 0u8, 0u8, 200u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,
        0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 2u8, 1u8, 1u8, 1u8,
        1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 1u8, 2u8, 2u8, 2u8,
        2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 2u8, 3u8, 3u8, 3u8,
        3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 3u8, 4u8, 4u8, 4u8,
        4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 4u8, 5u8, 5u8, 5u8,
        5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 5u8, 6u8, 6u8, 6u8,
        6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 6u8, 7u8, 7u8, 7u8,
        7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 7u8, 8u8, 8u8, 8u8,
        8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 8u8, 9u8, 9u8, 9u8,
        9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 9u8, 10u8, 10u8, 10u8,
        10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 10u8, 11u8, 11u8, 11u8,
        11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 11u8, 12u8, 12u8, 12u8,
        12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 12u8, 13u8, 13u8, 13u8,
        13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 13u8, 14u8, 14u8, 14u8,
        14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 14u8, 15u8, 15u8, 15u8,
        15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 15u8, 16u8, 16u8, 16u8,
        16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8, 16u8
    ];

    transition raw_message_to_id() -> [u8; 32] {
        let hash = Keccak256::hash_native_raw(RAW_MESSAGE);
        // Show hash as raw bytes:
        let bytes = Deserialize::from_bits_raw::[[u8; 32]](hash);
        return bytes;
    }

    // The constructor is configured to prevent upgrades.
    @noupgrade
    async constructor() {}
}

```

When we execute

```jsx
leo run raw_message_to_id
```

we’ll get the expected output:

```jsx
[2u8, 197u8, 229u8, 159u8, 81u8, 26u8, 108u8, 141u8, 133u8, 7u8, 144u8, 42u8, 65u8, 64u8, 11u8, 89u8, 147u8, 69u8, 198u8, 215u8, 216u8, 1u8, 149u8, 80u8, 228u8, 108u8, 60u8, 201u8, 79u8, 199u8, 170u8, 34u8]

// In Solidity, we had
// 0x02c5e59f511a6c8d8507902a41400b599345c6d7d8019550e46c3cc94fc7aa22
// which corresponds exactly to the above result, since
// 0x02 ~ 2u8, 0xc5 ~ 197u8, etc.
```

While this proves that the computation on Aleo is consistent with the computation on the origin chain, it’s actually _not_ what `dynamic_message_id` is actually returning. The difference is that, instead of deserializing the `Keccak256` output to `[u8; 32]`, it deserializes the hash output to `[u128; 2]`.

To adjust our test code to the logic that’s actually executed in Hyperlane’s Aleo integration, we can simply update `raw_message_to_id` in the above code as follows:

```jsx
transition raw_message_to_id() -> [u128; 2] {
    let hash = Keccak256::hash_native_raw(RAW_MESSAGE);
    // Show hash as raw bytes:
    let bytes = Deserialize::from_bits_raw::[[u128; 2]](hash);
    return bytes;
}
```

Running the updated code will produce the following final message ID:

```jsx
[118359710127519084177135630402206418178u128, 46080484843453562435388244435510117779u128]
```

To double-check this split in Solidity, we can do the following conversion in Chisel:

```solidity
// Helper to split id into the little-endian encoding used in Leo
function le128LimbsFromSolidityId(bytes32 id) internal pure returns (uint128 limb0, uint128 limb1) {
    // Turn bytes32 into a 32-byte array in memory
    bytes memory b = abi.encodePacked(id); // b[0]..b[31]

    // limb0: little-endian interpretation of b[0..15]
    for (uint256 i = 0; i < 16; i++) {
        limb0 |= uint128(uint256(uint8(b[i])) << (8 * i));
    }

    // limb1: little-endian interpretation of b[16..31]
    for (uint256 i = 0; i < 16; i++) {
        limb1 |= uint128(uint256(uint8(b[16 + i])) << (8 * i));
    }
}

(uint128 limb0, uint128 limb1) = le128LimbsFromSolidityId(0x02c5e59f511a6c8d8507902a41400b599345c6d7d8019550e46c3cc94fc7aa22);
```

If we then execute `limb0`, we’ll get the expected `118359710127519084177135630402206418178`. Similarly, executing `limb1` will return `46080484843453562435388244435510117779`.

The above function uses a lot of nested type casts, and might be a bit hard to follow for this reason. However, the main logic behind the function is actually quite simple:

1. Take the `id` as 32 bytes.
2. Split these 32 bytes into 2 chunks of 16 bytes: `id[16*i .. 16*i+15]` for `i = 0,1`.
3. For each chunk, interpret it as a 128-bit integer in **little-endian**, i.e., the first byte of each 16-byte chunk becomes the _least_ significant byte of the `uint128`:

$$
\text{limb}_i = \sum_{k=0}^{15}\text{chunk}_i[k]\cdot 256^k
$$

where $\text{chunk}_i[k]=\text{id}[i\cdot 16+k]$ for $i=0,1$.

**This is exactly the conversion the relayer needs to do when picking up the message ID from the origin chain and passing it on to Hyperlane’s Aleo integration. Before calling `process` on the receiving Aleo application, the relayer needs to convert the 32-byte message ID from `bytes32` to the type expected by the Aleo application, namely `[u128; 2]`.**

**Similarly, as mentioned earlier, the relayer is also in charge of converting the message _body_ it received from the origin chain from type `bytes` to type `[u128; 16]` before passing the `Message` to the receiving Aleo application’s `process`.**

As before, we can illustrate this conversion in Chisel:

```solidity
function le128LimbsFromSolidityBody(bytes memory body)
    internal
    pure
    returns (uint128[16] memory limbs)
{
    for (uint256 limbIdx = 0; limbIdx < 16; limbIdx++) {
        uint128 limb;
        uint256 base = limbIdx * 16;

        for (uint256 i = 0; i < 16; i++) {
            limb |= uint128(uint256(uint8(body[base + i])) << (8 * i));
        }

        limbs[limbIdx] = limb;
    }
}

uint128[16] memory limbs = le128LimbsFromSolidityBody(hex"0101010101010101010101010101010102020202020202020202020202020202030303030303030303030303030303030404040404040404040404040404040405050505050505050505050505050505060606060606060606060606060606060707070707070707070707070707070708080808080808080808080808080808090909090909090909090909090909090a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f10101010101010101010101010101010");
```

If we now execute `limbs`, we’ll get the exact `BODY` constant that we used in our initial Leo example:

```jsx
const BODY: [u128; 16] = [
    1334440654591915542993625911497130241u128,
    ...
    21351050473470648687898014583954083856u128,
];
```

The above function follows the same logic we saw earlier in `le128LimbsFromSolidityId`:

1. Take the `body` as 256 bytes. (While message bodies of length greater than 256 bytes are not supported in Hyperlane’s Aleo integration, the `body` could, in practice, be _smaller_ than 256 bytes. Here, in our particular example, we just chose a full-size 256 bytes `body` for the sake of simplicity.)
2. Split these 256 bytes into 16 chunks of 16 bytes: `id[16*i .. 16*i+15]` for `i = 0 .. 15`.
3. For each chunk, interpret it as a 128-bit integer in **little-endian**, i.e., the first byte of each 16-byte chunk becomes the _least_ significant byte of the `uint128`:

$$
\text{limb}_i = \sum_{k=0}^{15}\text{chunk}_i[k]\cdot 256^k
$$

where $\text{chunk}_i[k]=\text{body}[i\cdot 16+k]$ for $i=0,1, ..., 15$.

## Findings

### Incorrect Comment in Merkle Tree Hook

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

**Description**. The hook manager is the component that defines and implements the existing hooks for Hyperlane's Aleo implementation. Those hooks serve two main goals. First, the `HOOK_MERKLE_TREE` is responsible to insert `message_id`s in the Merkle Tree and recompute the root of the tree and store them in `merkle_tree_hooks`. This hook is typically the required hook of the mailbox and all dispatch messages actually execute them and update the Merkle Tree that is then used by the validators that they include the Merkle Tree root into the digest they sign. In the Leo implementation, there is the following comment.

```rust
// Only the parent is allowed to call the merkle_tree_hook
// We assume that the parent calls the merkle tree hook & dispatch function of the mailbox in the correct order
// This means the parent has to make sure, that the merkle tree hook won't get invoked twice for the same message
// In practice the parent is usually the dispatch_proxy
```

The part of the `merkle_tree_hook` is configured to be the dispatch_proxy. Still, anyone can go and call the dispatch proxy and call it multiple times. Those messages will get rejected by the destination chain since the sender will not be enrolled; still, those are valid messages for the origin chain, and could actually bypass the invariant of the comment.

After discussing it with the Hyperlane team, this won't lead to any security issues as the validators reconstruct the Merkle Tree solely based on the state recording on-chain in Aleo (i.e., in the `merkle_tree_hooks`). Further, no duplicate messages will be meaningful, although getting verified on-chain they will be rejected because the sender is not enrolled.

**Impact**. As discussed above the comment was wrong and no security implications arise.

**Recommendation**. We recommend fixing the comment accordingly.

**Client Response**. The issue was fixed in the following PR:

<https://github.com/hyperlane-xyz/hyperlane-aleo/pull/44> (`e83927f`)

### Miscellaneous: use Leo's new `storage` instead of Mappings

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

**Description**  
[Leo v4.3](https://provable.com/blog/announcing-aleo-stack-v4-3-0) introduces the `storage` keyword, which replaces most uses of mapping for persistent state. Storage variables and storage vectors are now the recommended way to hold global program state. They remove boilerplate, simplify access patterns, and compile down to mappings automatically.

**Advice:**  
Replace "singleton mapping" patterns (`mapping X: bool => T`) with `storage X: T`.  
This reduces code size, increases readability, and aligns with the current Leo idioms.

**Old pattern (current Hyperlane code):**

```leo
struct Mailbox { ... }

mapping mailbox: bool => Mailbox;

async function finalize_init(local_domain: u32, caller: address) {
    assert(!mailbox.contains(true));
    mailbox.set(true, Mailbox { ... });
}
```

**Preferred pattern using `storage`:**

```leo
struct Mailbox { ... }

storage mailbox: Mailbox;

async transition finalize_init(local_domain: u32, caller: address) -> Future {
    return async {
        assert(mailbox == none);
        mailbox = Mailbox { ... };
    }
}
```

Examples can be found [in the Leo codebase](https://github.com/ProvableHQ/leo/tree/3046c87df8a04600a08b01b534cced9081ca99e0/tests/tests/compiler/storage)

**Impact**. These issues do not have any security implications.

**Recommendation**. We recommend updating to the new `storage` for better code readability.

**Client Response**. Since this feature is a compilation sugar, and the underlying storage variables are prefixed with an underscore, this would require changes to the off-chain code; hence, it is preferred to keep it as a mapping.

### Miscellaneous Findings Regarding Code Quality

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

**Description**. Throughout the codebase, we identified several instances of either dead code, slight inefficiencies, or differences in coding patterns. While these issues are minor and pose no direct security risk, we provide a brief summary here for completeness:

- `ISM_MERKLE_ROOT_MULTISIG` in `ism_manager` is not used and thus can be removed.
- In `dispatch_proxy`, there is no need to pass the `default_ism` since it is never used.
- In `verify` transition (`ism_manager`), just the `origin_domain` could be passed instead of the message.
- `DeliveryKey` can be replaced with `MessageId` and then used instead of `[u128; 2]` types with it in `mailbox`.
- In `hyp_synthetic`, it will be better to use `self.signer` instead of `self.caller` when minting and burning tokens to be equivalent with the other apps.
- The following in `hyp_synthetic` could be a one-liner like in `hyp_native`:

```rust
assert_eq(actual_remote_router.domain, unverified_remote_router.domain);
assert_eq(actual_remote_router.recipient, unverified_remote_router.recipient);
assert_eq(actual_remote_router.gas, unverified_remote_router.gas);
```

to

```rust
assert_eq(actual_remote_router, unverified_remote_router);
```

Similarly:

```rust
let address_raw = [0u128, 0u128];
address_raw[0] = message.body[0];
address_raw[1] = message.body[1];
```

Can be:

```rust
let address_raw = [message.body[0], message.body[1]];
```

The function `metadata_gas_limit` is never used. Also, the `u128_to_bytes` is not used.

**Impact**. These issues do not have any security implications.

**Recommendation**. We recommend fixing those issues to improve the code quality and maintainability.

**Client Response**. The issues mentioned above were fixed in the following commits and PRs.

- Remove unused code and streamline app implementation code: https://github.com/hyperlane-xyz/hyperlane-aleo/pull/41 (`58e53ca`)
- Use self.signer for every token transfer type: https://github.com/hyperlane-xyz/hyperlane-aleo/pull/42 (`db9d8d4`, `fc12d64`)
- Remove default_ism from the dispatch_proxy state: https://github.com/hyperlane-xyz/hyperlane-aleo/pull/43 (`97f12c6`, `147b6ca`)

---

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