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, 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 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
validatorprogram, 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.aleowhich 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,97f12c6147b6ca,e83927f.
Overview
Hyperlane Overview

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

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).
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:
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:
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:
+--------------------------------------------------------------------------------------+
| 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
MerkleTreeHookcontract that inserts dispatchedmessage_idsinto 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_idappears atcheckpoint_indexin 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:
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:
- Domain hash
domain_hash = keccak(origin_domain_BE || origin_merkle_tree_hook || "HYPERLANE")
- Checkpoint hash
checkpoint = keccak(domain_hash || checkpoint_root || checkpoint_index || message_id)
- Ethereum signed message hash
digest = keccak("\x19Ethereum Signed Message:\n32" || checkpoint)
Finally, for signature verification, for each signature signatures[i] we have the following steps:
- Recover the ECDSA signer from the signature and the
digest. - Require that the recovered address equals
validators[i]. - 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/processfunctions 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
thresholdare 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
processenforces the origin domain and the message sender, while the mailbox handles version, destination domain, and recipient (per the provided configuration).dispatchenforces 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.
MessageIdMultisigIsmuses 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.
processmust check:- origin domain
- sender program
dispatchmust 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:
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:
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:
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:
- Submit the 77-byte header-only message on the origin chain and obtain validator signatures.
- 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:
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 .
Hyp Native (credits)
A single parameter scale in the range specifies the power-of-ten factor relating local u64 credits to the message amount.
-
Outbound (local → message):
-
Inbound (message → local):
Outbound is exact; inbound loses the truncated fraction. If , 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:
- Local decimals > remote decimals:
- Local decimals < remote decimals:
Inbound (remote → local)
- Equal decimals:
- Local decimals > remote decimals:
- Local decimals < remote decimals:
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:
/find/transactionID/deployment/{program_id}
/transaction/{id}
Alternatively, one could also do a manual check (e.g., via a block explorer) 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
idfrombytes32to a little-endian encoded[u128;2]array. - Convert the
bodyfrombytesto a little-endian encoded[u128;16]array.
Similarly, when Aleo is the origin chain, the relayer has to:
- Convert the
idfrom a little-endian encoded[u128;2]array tobytes32. - Convert the
bodyfrom a little-endian encoded[u128;16]array tobytes.
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 tobytes32by 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.
Furthermore, we’ll use the following message body (of type bytes) for our example:
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 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, via
// 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:
// print encoded message
msgBytes
This will return the following encoded message:
0x0300000001000000640000000000000000000000000000000000000000000000000000000000000001000000c800000000000000000000000000000000000000000000000000000000000000020101010101010101010101010101010102020202020202020202020202020202030303030303030303030303030303030404040404040404040404040404040405050505050505050505050505050505060606060606060606060606060606060707070707070707070707070707070708080808080808080808080808080808090909090909090909090909090909090a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f1010101010101010101010101010101000000000000000000000000000000000000000
To get the corresponding message ID, Hyperlane’s Solidity implementation then simply hashes the above encoding with keccak256:
// 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.
// 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:
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
leo run message_to_raw
using the following code on the Leo playground:
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 u8s 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:
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
leo run raw_message_to_id
we’ll get the expected output:
[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:
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:
[118359710127519084177135630402206418178u128, 46080484843453562435388244435510117779u128]
To double-check this split in Solidity, we can do the following conversion in Chisel:
// 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:
- Take the
idas 32 bytes. - Split these 32 bytes into 2 chunks of 16 bytes:
id[16*i .. 16*i+15]fori = 0,1. - 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:
where for .
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:
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:
const BODY: [u128; 16] = [
1334440654591915542993625911497130241u128,
...
21351050473470648687898014583954083856u128,
];
The above function follows the same logic we saw earlier in le128LimbsFromSolidityId:
- Take the
bodyas 256 bytes. (While message bodies of length greater than 256 bytes are not supported in Hyperlane’s Aleo integration, thebodycould, in practice, be smaller than 256 bytes. Here, in our particular example, we just chose a full-size 256 bytesbodyfor the sake of simplicity.) - Split these 256 bytes into 16 chunks of 16 bytes:
id[16*i .. 16*i+15]fori = 0 .. 15. - 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:
where for .