diff --git a/CHANGELOG.md b/CHANGELOG.md index 860d5cf257..2e181751a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v0.16.0 (TBD) +### Features + +- Added outbound message bridging (`bridge_message`) to the AggLayer bridge, enabling arbitrary cross-chain messages from Miden to Ethereum/AggLayer chains via leafType=1 leaves in the Local Exit Tree. Includes `BRIDGE_MESSAGE` note script, `MessageNote` Rust type, and `from_account_id` address conversion ([#3012](https://github.com/0xMiden/protocol/pull/3012)). + ### Changes - [BREAKING] Renamed `AccountStorageDelta` to `AccountStoragePatch` ([#3002](https://github.com/0xMiden/protocol/pull/3002)). diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 7db98dc04f..84e768eb05 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -16,6 +16,7 @@ use agglayer::common::asset_conversion use agglayer::bridge::bridge_config use agglayer::bridge::leaf_utils use agglayer::bridge::merkle_tree_frontier +use agglayer::common::eth_address use agglayer::common::eth_address::EthereumAddressFormat # ERRORS @@ -23,6 +24,8 @@ use agglayer::common::eth_address::EthereumAddressFormat const ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN = "B2AGG note destination network ID must not be Miden's AggLayer network ID" const ERR_B2AGG_NOTE_MUST_BE_PUBLIC = "B2AGG note must be public" +const ERR_BRIDGE_MSG_DESTINATION_NETWORK_IS_MIDEN = "BRIDGE_MESSAGE note destination network ID must not be Miden's AggLayer network ID" +const ERR_BRIDGE_MSG_NOTE_MUST_BE_PUBLIC = "BRIDGE_MESSAGE note must be public" # CONSTANTS # ================================================================================================= @@ -73,6 +76,22 @@ const DESTINATION_ADDRESS_4_LOC=12 const DESTINATION_NETWORK_LOC=13 const BRIDGE_OUT_IS_NATIVE_LOC=14 +# bridge_message memory locals +const BRIDGE_MSG_DEST_NETWORK_LOC=0 +const BRIDGE_MSG_DEST_ADDRESS_0_LOC=1 +const BRIDGE_MSG_DEST_ADDRESS_1_LOC=2 +const BRIDGE_MSG_DEST_ADDRESS_2_LOC=3 +const BRIDGE_MSG_DEST_ADDRESS_3_LOC=4 +const BRIDGE_MSG_DEST_ADDRESS_4_LOC=5 +const BRIDGE_MSG_METADATA_HASH_0_LOC=6 +const BRIDGE_MSG_METADATA_HASH_1_LOC=7 +const BRIDGE_MSG_METADATA_HASH_2_LOC=8 +const BRIDGE_MSG_METADATA_HASH_3_LOC=9 +const BRIDGE_MSG_METADATA_HASH_4_LOC=10 +const BRIDGE_MSG_METADATA_HASH_5_LOC=11 +const BRIDGE_MSG_METADATA_HASH_6_LOC=12 +const BRIDGE_MSG_METADATA_HASH_7_LOC=13 + # create_burn_note memory locals const CREATE_BURN_NOTE_BURN_ASSET_LOC=0 const ATTACHMENT_LOC=8 @@ -82,6 +101,7 @@ const ATTACHMENT_SCHEME_LOC=12 # ------------------------------------------------------------------------------------------------- const LEAF_TYPE_ASSET=0 +const LEAF_TYPE_MESSAGE=1 use miden::protocol::note::NOTE_TYPE_PUBLIC const BURN_NOTE_NUM_STORAGE_ITEMS=0 @@ -243,6 +263,142 @@ pub proc bridge_out end end +#! Bridges a message out via the AggLayer. +#! +#! This procedure handles the complete bridge-message operation: +#! 1. Validates the note is public and the destination is not Miden +#! 2. Builds the leaf data with leafType=1, the caller-provided metadata hash, +#! and the note sender's address as the origin token address +#! 3. Computes Keccak hash of the leaf data and appends it to the Local Exit Tree +#! +#! Unlike bridge_out, this procedure does NOT involve any faucet lookup, burn, or lock operations. +#! The amount field is always zero. +#! +#! Inputs: [dest_network_id, dest_address(5), metadata_hash(8), pad(2)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - dest_network_id is the u32 destination network/chain ID. +#! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. +#! - metadata_hash(8) is the Keccak256 hash of the message metadata (8 u32 limbs). +#! +#! Panics if: +#! - the note calling `bridge_message` is not public. +#! - destination network ID is Miden's AggLayer network ID. +#! +#! Invocation: call +@locals(14) +pub proc bridge_message + exec.active_note::is_public assert.err=ERR_BRIDGE_MSG_NOTE_MUST_BE_PUBLIC + # => [dest_network_id, dest_address(5), metadata_hash(8), pad(2)] + + dup exec.utils::swap_u32_bytes push.MIDEN_NETWORK_ID neq + assert.err=ERR_BRIDGE_MSG_DESTINATION_NETWORK_IS_MIDEN + # => [dest_network_id, dest_address(5), metadata_hash(8), pad(2)] + + # Save all 14 input values to locals. Within a `call` frame, the stack floor is 16, + # so `loc_store` at depth 16 pops the value into local memory while keeping depth at 16 + # (zeros fill from below). This drains the meaningful input values into locals. + loc_store.BRIDGE_MSG_DEST_NETWORK_LOC + loc_store.BRIDGE_MSG_DEST_ADDRESS_0_LOC + loc_store.BRIDGE_MSG_DEST_ADDRESS_1_LOC + loc_store.BRIDGE_MSG_DEST_ADDRESS_2_LOC + loc_store.BRIDGE_MSG_DEST_ADDRESS_3_LOC + loc_store.BRIDGE_MSG_DEST_ADDRESS_4_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_0_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_1_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_2_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_3_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_4_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_5_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_6_LOC + loc_store.BRIDGE_MSG_METADATA_HASH_7_LOC + # => [pad(16)] + + # --- 1. Store destination_network --- + loc_load.BRIDGE_MSG_DEST_NETWORK_LOC + push.LEAF_DATA_START_PTR push.DESTINATION_NETWORK_OFFSET add + mem_store + # => [pad(16)] + + # --- 2. Store destination_address (5 felts) --- + loc_load.BRIDGE_MSG_DEST_ADDRESS_4_LOC + loc_load.BRIDGE_MSG_DEST_ADDRESS_3_LOC + loc_load.BRIDGE_MSG_DEST_ADDRESS_2_LOC + loc_load.BRIDGE_MSG_DEST_ADDRESS_1_LOC + loc_load.BRIDGE_MSG_DEST_ADDRESS_0_LOC + push.LEAF_DATA_START_PTR push.DESTINATION_ADDRESS_OFFSET add + exec.write_address_to_memory + # => [pad(16)] + + # --- 3. Store metadata_hash (8 felts) --- + loc_load.BRIDGE_MSG_METADATA_HASH_7_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_6_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_5_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_4_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_3_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_2_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_1_LOC + loc_load.BRIDGE_MSG_METADATA_HASH_0_LOC + push.LEAF_DATA_START_PTR push.METADATA_HASH_OFFSET add + movdn.8 + exec.utils::mem_store_double_word_unaligned + # => [pad(16)] + + # --- 4. Store leafType=1 (byte-swapped) --- + push.LEAF_TYPE_MESSAGE + exec.utils::swap_u32_bytes + push.LEAF_DATA_START_PTR push.LEAF_TYPE_OFFSET add + mem_store + # => [pad(16)] + + # --- 5. Store originNetwork = MIDEN_NETWORK_ID (byte-swapped to LE) --- + push.MIDEN_NETWORK_ID + exec.utils::swap_u32_bytes + push.LEAF_DATA_START_PTR push.ORIGIN_NETWORK_OFFSET add + mem_store + # => [pad(16)] + + # --- 6. Store origin_token_address from note sender --- + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, pad(16)] + + # from_account_id expects [suffix, prefix] — get_sender already returns this order + exec.eth_address::from_account_id + # => [limb0, limb1, limb2, limb3, limb4, pad(16)] + + push.LEAF_DATA_START_PTR push.ORIGIN_TOKEN_ADDRESS_OFFSET add + exec.write_address_to_memory + # => [pad(16)] + + # --- 7. Store amount = 0 (8 felts, all zero) --- + # Push 8 zeros and the address above the call frame floor, then store. + padw padw + push.LEAF_DATA_START_PTR push.AMOUNT_OFFSET add + movdn.8 + exec.utils::mem_store_double_word_unaligned + # => [pad(16)] + + # --- 8. Zero the 3 padding felts --- + push.0 + push.LEAF_DATA_START_PTR push.PADDING_OFFSET add + mem_store + + push.0 + push.LEAF_DATA_START_PTR push.PADDING_OFFSET add.1 add + mem_store + + push.0 + push.LEAF_DATA_START_PTR push.PADDING_OFFSET add.2 add + mem_store + # => [pad(16)] + + # --- 9. Compute leaf value and append to LET --- + push.LEAF_DATA_START_PTR + exec.add_leaf_bridge + # => [pad(16)] +end + # HELPER PROCEDURES # ================================================================================================= diff --git a/crates/miden-agglayer/asm/agglayer/common/eth_address.masm b/crates/miden-agglayer/asm/agglayer/common/eth_address.masm index b9cc4a9c33..f61f4e0bca 100644 --- a/crates/miden-agglayer/asm/agglayer/common/eth_address.masm +++ b/crates/miden-agglayer/asm/agglayer/common/eth_address.masm @@ -69,6 +69,66 @@ pub proc to_account_id # => [suffix, prefix] end +#! Converts an AccountId [suffix, prefix] into the Ethereum address format (address[5] type). +#! +#! This is the inverse of `to_account_id`. Each felt encodes a conceptual u64 = (hi << 32) | lo +#! in big-endian limb order. The procedure splits each felt back into two u32 limbs via u32split, +#! then byte-swaps each limb from big-endian back to little-endian (matching the bridge convention). +#! +#! The most-significant 4 bytes (limb0) are always zero since an AccountId only occupies 16 bytes. +#! +#! Inputs: [suffix, prefix] +#! Outputs: [limb0, limb1, limb2, limb3, limb4] +#! +#! Invocation: exec +pub proc from_account_id(suffix: felt, prefix: felt) -> EthereumAddressFormat + # split prefix into BE limbs + swap + # => [prefix, suffix] + + u32split swap + # => [hi_be, lo_be, suffix] + # prefix = hi_be * 2^32 + lo_be + # (u32split returns [lo, hi]; swap corrects to [hi, lo]) + + # byte-swap hi_be to get limb1 (LE) + exec.utils::swap_u32_bytes + # => [limb1, lo_be, suffix] + + swap + # => [lo_be, limb1, suffix] + + # byte-swap lo_be to get limb2 (LE) + exec.utils::swap_u32_bytes + # => [limb2, limb1, suffix] + + # split suffix into BE limbs + movup.2 + # => [suffix, limb2, limb1] + + u32split swap + # => [hi_be, lo_be, limb2, limb1] + # (u32split returns [lo, hi]; swap corrects to [hi, lo]) + + # byte-swap hi_be to get limb3 (LE) + exec.utils::swap_u32_bytes + # => [limb3, lo_be, limb2, limb1] + + swap + # => [lo_be, limb3, limb2, limb1] + + # byte-swap lo_be to get limb4 (LE) + exec.utils::swap_u32_bytes + # => [limb4, limb3, limb2, limb1] + + # reorder from [limb4, limb3, limb2, limb1] to [limb1, limb2, limb3, limb4] + movdn.3 movdn.2 swap + # => [limb1, limb2, limb3, limb4] + + push.0 + # => [limb0=0, limb1, limb2, limb3, limb4] +end + # HELPER PROCEDURES # ================================================================================================= diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 14c5169f93..6c7ff4bb15 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -14,3 +14,4 @@ pub use ::agglayer::bridge::bridge_config::store_faucet_metadata_hash pub use ::agglayer::bridge::bridge_config::update_ger pub use ::agglayer::bridge::bridge_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out +pub use ::agglayer::bridge::bridge_out::bridge_message diff --git a/crates/miden-agglayer/asm/note_scripts/bridge_message.masm b/crates/miden-agglayer/asm/note_scripts/bridge_message.masm new file mode 100644 index 0000000000..bf44a76222 --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/bridge_message.masm @@ -0,0 +1,119 @@ +use agglayer::bridge::bridge_out +use miden::protocol::account_id +use miden::protocol::active_account +use miden::protocol::active_note +use miden::standards::attachments::network_account_target + +# CONSTANTS +# ================================================================================================= + +const BRIDGE_MSG_NOTE_ASSETS_PTR = 0 +const BRIDGE_MSG_NOTE_STORAGE_PTR = 8 +const BRIDGE_MSG_NOTE_NUM_STORAGE_ITEMS = 14 + +# ERRORS +# ================================================================================================= +const ERR_BRIDGE_MSG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS="BRIDGE_MESSAGE script expects exactly 14 note storage items" +const ERR_BRIDGE_MSG_TARGET_ACCOUNT_MISMATCH="BRIDGE_MESSAGE note attachment target account does not match consuming account" +const ERR_BRIDGE_MSG_NOTE_HAS_ASSETS="BRIDGE_MESSAGE note must not contain any assets" + +# NOTE SCRIPT +# ================================================================================================= + +#! Bridge Message note script: bridges a message (no assets) from Miden to an AggLayer-connected +#! chain. +#! +#! This note can be consumed in two ways: +#! - If the consuming account is the sender (reclaim case): no-op, since there are no assets. +#! - If the consuming account is the Agglayer Bridge: the note details are hashed into a leaf and +#! appended to the Local Exit Tree. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Note storage layout (14 felts total): +#! - destination_network [0] : 1 felt +#! - destination_address [1..5] : 5 felts +#! - metadata_hash [6..13] : 8 felts +#! +#! Where: +#! - destination_network: Destination network identifier (uint32) +#! - destination_address: 20-byte address as 5 u32 felts +#! - metadata_hash: 32-byte hash as 8 u32 felts +#! +#! Note attachment is constructed from a NetworkAccountTarget standard: +#! - [0, exec_hint_tag, target_id_prefix, target_id_suffix] +#! +#! Panics if: +#! - The note contains any assets (message notes must be asset-free). +#! - The note does not contain exactly 14 storage items. +#! - The note attachment does not target the consuming account. +#! - The note is not public (enforced by `bridge_message`). +#! - The destination network ID equals Miden's AggLayer network ID. +@note_script +pub proc main + dropw + # => [pad(16)] + + # Check if reclaim + exec.active_account::get_id + # => [account_id_prefix, account_id_suffix, pad(16)] + + exec.active_note::get_sender + # => [sender_id_suffix, sender_id_prefix, account_id_prefix, account_id_suffix, pad(16)] + + exec.account_id::is_equal + # => [reclaim, pad(16)] + + # BRIDGE_MESSAGE note is being reclaimed; no-op since there are no assets + if.true + # Nothing to do on reclaim — message notes carry no assets. + # => [pad(16)] + else + # Ensure note attachment targets the consuming bridge account. + exec.network_account_target::active_account_matches_target_account + assert.err=ERR_BRIDGE_MSG_TARGET_ACCOUNT_MISMATCH + # => [pad(16)] + + # Assert the note carries no assets (message notes are asset-free). + push.BRIDGE_MSG_NOTE_ASSETS_PTR exec.active_note::get_assets + # => [num_assets, pad(16)] + + assertz.err=ERR_BRIDGE_MSG_NOTE_HAS_ASSETS + # => [pad(16)] + + # Store note storage -> mem[8..22] + push.BRIDGE_MSG_NOTE_STORAGE_PTR exec.active_note::get_storage + # => [num_storage_items, pad(16)] + + # Validate the number of storage items + push.BRIDGE_MSG_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_BRIDGE_MSG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS + # => [pad(16)] + + # Load the 14 storage felts as 4 words in reverse order so the stack top matches + # the expected call layout: [dest_network, da(5), mh(8), pad(2)] + # + # Memory layout (BRIDGE_MSG_NOTE_STORAGE_PTR = 8, 4 words): + # word at addr 8: [dest_network, da0, da1, da2] + # word at addr 12: [da3, da4, mh0, mh1] + # word at addr 16: [mh2, mh3, mh4, mh5] + # word at addr 20: [mh6, mh7, 0, 0] + # + # Each `padw` + `mem_loadw_le.X` pushes 4 zeros then overwrites them with the word at X. + # Loading in reverse address order so dest_network ends up on top. + padw push.BRIDGE_MSG_NOTE_STORAGE_PTR add.12 mem_loadw_le + padw push.BRIDGE_MSG_NOTE_STORAGE_PTR add.8 mem_loadw_le + padw push.BRIDGE_MSG_NOTE_STORAGE_PTR add.4 mem_loadw_le + padw mem_loadw_le.BRIDGE_MSG_NOTE_STORAGE_PTR + # => [dest_network, da(5), mh(8), 0, 0, pad(16)] + # Top 16 elements: [dest_network, da(5), mh(8), 0, 0] + + call.bridge_out::bridge_message + # => [pad(16), pad(16)] + + # Clean up the 16 extra elements below the call's return frame + dropw dropw dropw dropw + # => [pad(16)] + end + # => [pad(16)] +end diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 06d1825c33..8c4bc0ce35 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -28,6 +28,7 @@ pub use crate::{ GlobalIndex, GlobalIndexError, LeafData, + MessageNote, MetadataHash, ProofData, SmtNode, @@ -114,6 +115,7 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// - `register_faucet`, which registers a faucet in the bridge. /// - `update_ger`, which injects a new GER into the storage map. /// - `bridge_out`, which bridges an asset out of Miden to the destination network. +/// - `bridge_message`, which sends an arbitrary cross-chain message to the destination network. /// - `claim`, which validates a claim against the AggLayer bridge and creates a MINT note for the /// AggLayer Faucet. /// @@ -260,6 +262,7 @@ impl AggLayerBridge { B2AggNote::script_root(), ConfigAggBridgeNote::script_root(), UpdateGerNote::script_root(), + MessageNote::script_root(), ]) } diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 0826a1e777..d44a86ecec 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -26,6 +26,7 @@ pub mod config_note; pub mod errors; pub mod eth_types; pub mod faucet; +pub mod message_note; #[cfg(feature = "testing")] pub mod testing; pub mod update_ger_note; @@ -56,6 +57,7 @@ pub use eth_types::{ MetadataHash, }; pub use faucet::{AggLayerFaucet, AgglayerFaucetError}; +pub use message_note::MessageNote; pub use update_ger_note::UpdateGerNote; pub use utils::Keccak256Output; diff --git a/crates/miden-agglayer/src/message_note.rs b/crates/miden-agglayer/src/message_note.rs new file mode 100644 index 0000000000..4e367647e9 --- /dev/null +++ b/crates/miden-agglayer/src/message_note.rs @@ -0,0 +1,146 @@ +//! Bridge Message note creation utilities. +//! +//! This module provides helpers for creating Bridge Message notes, +//! which are used to send cross-chain messages (without assets) via the AggLayer network. + +use alloc::vec::Vec; + +use miden_assembly::Library; +use miden_assembly::serde::Deserializable; +use miden_core::Felt; +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_utils_sync::LazyLock; + +use crate::{EthAddress, MetadataHash}; + +// NOTE SCRIPT +// ================================================================================================ + +// Initialize the Bridge Message note script only once +static BRIDGE_MESSAGE_SCRIPT: LazyLock = LazyLock::new(|| { + let bytes = + include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/bridge_message.masl")); + let library = Library::read_from_bytes(bytes) + .expect("shipped Bridge Message script library is well-formed"); + NoteScript::from_library(&library).expect("shipped Bridge Message script is well-formed") +}); + +// MESSAGE NOTE +// ================================================================================================ + +/// Bridge Message note. +/// +/// This note is used to send a cross-chain message from Miden to another network via the AggLayer. +/// Unlike B2AGG notes, message notes carry no assets — they only convey routing information +/// (destination network, destination address) and an arbitrary metadata hash. +/// Message notes are always public. +pub struct MessageNote; + +impl MessageNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for a Bridge Message note. + /// Layout: 1 (destination_network) + 5 (destination_address) + 8 (metadata_hash) = 14 + pub const NUM_STORAGE_ITEMS: usize = 14; + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the Bridge Message note script. + pub fn script() -> NoteScript { + BRIDGE_MESSAGE_SCRIPT.clone() + } + + /// Returns the Bridge Message note script root. + pub fn script_root() -> NoteScriptRoot { + BRIDGE_MESSAGE_SCRIPT.root() + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates a Bridge Message note. + /// + /// This note is used to send a cross-chain message from Miden to another network via the + /// AggLayer. When consumed by a bridge account, the message metadata is forwarded to the + /// destination network. Message notes carry no assets and are always public. + /// + /// # Parameters + /// - `destination_network`: The AggLayer-assigned network ID for the destination chain + /// - `destination_address`: The Ethereum address on the destination network + /// - `metadata_hash`: The hash of the message metadata + /// - `target_account_id`: The account ID that will consume this note (bridge account) + /// - `sender_account_id`: The account ID of the note creator + /// - `rng`: Random number generator for creating the note serial number + /// + /// # Errors + /// Returns an error if note creation fails. + pub fn create( + destination_network: u32, + destination_address: EthAddress, + metadata_hash: MetadataHash, + target_account_id: AccountId, + sender_account_id: AccountId, + rng: &mut R, + ) -> Result { + let note_storage = + build_note_storage(destination_network, destination_address, metadata_hash)?; + + let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|error| { + NoteError::other_with_source( + "failed to create bridge message network account target", + error, + ) + })?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); + + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); + + let recipient = NoteRecipient::new(rng.draw_word(), Self::script(), note_storage); + + let assets = NoteAssets::default(); + + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + } +} + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Builds the note storage for a Bridge Message note. +/// +/// The storage layout is: +/// - 1 felt: destination_network (byte-swapped to LE u32, matching the leaf data convention) +/// - 5 felts: destination_address (20 bytes as 5 LE-packed u32 values) +/// - 8 felts: metadata_hash (32 bytes as 8 LE-packed u32 values) +fn build_note_storage( + destination_network: u32, + destination_address: EthAddress, + metadata_hash: MetadataHash, +) -> Result { + let mut elements = Vec::with_capacity(14); + + let destination_network = u32::from_le_bytes(destination_network.to_be_bytes()); + elements.push(Felt::from(destination_network)); + elements.extend(destination_address.to_elements()); + elements.extend(metadata_hash.to_elements()); + + NoteStorage::new(elements) +} diff --git a/crates/miden-testing/tests/agglayer/bridge_message.rs b/crates/miden-testing/tests/agglayer/bridge_message.rs new file mode 100644 index 0000000000..be6dc182c5 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/bridge_message.rs @@ -0,0 +1,581 @@ +extern crate alloc; + +use miden_agglayer::errors::{ + ERR_BRIDGE_MSG_DESTINATION_NETWORK_IS_MIDEN, + ERR_BRIDGE_MSG_TARGET_ACCOUNT_MISMATCH, +}; +use miden_agglayer::{ + AggLayerBridge, + EthAddress, + MessageNote, + MetadataHash, + create_existing_bridge_account, +}; +use miden_crypto::rand::FeltRng; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::transaction::RawOutputNote; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; + +/// Tests that consuming a Bridge Message note against the bridge account updates the LET. +/// +/// This test exercises the bridge_message outbound path: +/// 1. Creates a bridge account (with bridge admin and GER manager) +/// 2. Creates a MessageNote with known parameters (no assets) +/// 3. Consumes the note against the bridge account +/// 4. Verifies: +/// - LET num_leaves incremented by 1 +/// - LET root is non-zero (changed from initial empty state) +/// - No output notes were created (messages don't produce BURN notes) +/// - Bridge vault is empty (no assets locked) +#[tokio::test] +async fn bridge_message_updates_let() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER MANAGER ACCOUNT + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE BRIDGE ACCOUNT + let mut bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + // CREATE SENDER ACCOUNT (the user sending the message) + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE THE MESSAGE NOTE + let destination_network = 1u32; + let destination_address = EthAddress::from_hex("0x1234567890abcdef1234567890abcdef12345678") + .expect("valid Ethereum address"); + let metadata_hash = MetadataHash::new([0x42u8; 32]); + + let note = MessageNote::create( + destination_network, + destination_address, + metadata_hash, + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // CONSUME THE MESSAGE NOTE AGAINST THE BRIDGE ACCOUNT + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[note.id()], &[])? + .build()? + .execute() + .await?; + + // VERIFY LET UPDATED + bridge_account.apply_delta(executed_tx.account_delta())?; + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + 1, + "LET num_leaves should be 1 after consuming one message note" + ); + + let root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + assert!( + root.iter().any(|f| f.as_canonical_u64() != 0), + "LET root should be non-zero after message" + ); + + // VERIFY NO OUTPUT NOTES (no BURN note for messages) + assert_eq!( + executed_tx.output_notes().num_notes(), + 0, + "Message notes should not produce any output notes" + ); + + // VERIFY BRIDGE VAULT IS EMPTY (no assets locked) + assert!( + bridge_account.vault().assets().next().is_none(), + "Bridge vault should be empty after message (no assets involved)" + ); + + Ok(()) +} + +/// Tests that the on-chain LET root matches an independently computed Keccak256 leaf hash. +/// +/// This test: +/// 1. Creates a bridge + sender, creates and consumes a MessageNote +/// 2. Reads the on-chain leaf hash from the LET frontier storage +/// 3. Independently computes the expected Keccak256 leaf hash in Rust using the Solidity +/// `abi.encodePacked` layout (113 bytes) +/// 4. Verifies the on-chain frontier[0] feeds into the MTF to produce the on-chain root +/// 5. Compares the on-chain leaf hash against the independently computed one +#[tokio::test] +async fn bridge_message_leaf_hash_matches_independent_computation() -> anyhow::Result<()> { + use miden_agglayer::{EthEmbeddedAccountId, ExitRoot}; + use miden_crypto::hash::keccak::{Keccak256, Keccak256Digest}; + use miden_processor::utils::packed_u32_elements_to_bytes; + use miden_protocol::{Felt, Word}; + + use super::merkle_tree_frontier::MerkleTreeFrontier32; + + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let mut bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let destination_network = 1u32; + let destination_address = EthAddress::from_hex("0x1234567890abcdef1234567890abcdef12345678") + .expect("valid Ethereum address"); + let metadata_hash = MetadataHash::new([0x42u8; 32]); + + let note = MessageNote::create( + destination_network, + destination_address, + metadata_hash, + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Consume the message note against the bridge account + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[note.id()], &[])? + .build()? + .execute() + .await?; + + bridge_account.apply_delta(executed_tx.account_delta())?; + + // Verify LET was updated + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + 1, + "LET num_leaves should be 1 after consuming one message note" + ); + + // Read on-chain frontier[0] (the leaf hash) from bridge storage. + // The frontier is stored as a double_word_array in a map slot. + // For index 0, keys are [0,0,0,0] (word 0) and [0,0,1,0] (word 1). + let frontier_slot = AggLayerBridge::let_frontier_slot_name(); + let key_lo = Word::new([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ZERO]); + let key_hi = Word::new([Felt::ZERO, Felt::ZERO, Felt::from(1u32), Felt::ZERO]); + + let frontier0_lo = bridge_account.storage().get_map_item(frontier_slot, key_lo)?; + let frontier0_hi = bridge_account.storage().get_map_item(frontier_slot, key_hi)?; + + let on_chain_leaf_felts: Vec = + frontier0_lo.iter().chain(frontier0_hi.iter()).copied().collect(); + let on_chain_leaf_bytes = packed_u32_elements_to_bytes(&on_chain_leaf_felts); + + // Verify the MTF is consistent: MTF(frontier[0]) should equal the on-chain root. + let on_chain_leaf_digest = + Keccak256Digest::from(<[u8; 32]>::try_from(&on_chain_leaf_bytes[..]).unwrap()); + let mut mtf = MerkleTreeFrontier32::<32>::new(); + let root_from_frontier = mtf.append_and_update_frontier(on_chain_leaf_digest); + let expected_root = ExitRoot::new(root_from_frontier.into()); + let on_chain_root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + + assert_eq!( + on_chain_root, + expected_root.to_elements(), + "MTF(frontier[0]) should match on-chain root" + ); + + // Independently compute the expected leaf hash. + // + // The leaf is the Keccak256 hash of 113 bytes packed as abi.encodePacked: + // byte 0: leafType (1 byte) = 1 for messages + // bytes 1-4: originNetwork (4 bytes, BE) = 77 (MIDEN_NETWORK_ID) + // bytes 5-24: originTokenAddress (20 bytes) = sender's AccountId as Ethereum address + // bytes 25-28: destinationNetwork (4 bytes, BE) + // bytes 29-48: destinationAddress (20 bytes) + // bytes 49-80: amount (32 bytes) = all zeros for messages + // bytes 81-112: metadataHash (32 bytes) + let origin_address: EthAddress = + EthEmbeddedAccountId::from_account_id(sender.id()).to_eth_address(); + + let mut packed = Vec::with_capacity(113); + packed.push(1u8); // leafType = 1 (message) + packed.extend_from_slice(&77u32.to_be_bytes()); // originNetwork = MIDEN_NETWORK_ID + packed.extend_from_slice(origin_address.as_bytes()); // 20 bytes + packed.extend_from_slice(&destination_network.to_be_bytes()); // 4 bytes + packed.extend_from_slice(destination_address.as_bytes()); // 20 bytes + packed.extend_from_slice(&[0u8; 32]); // amount = 0 + packed.extend_from_slice(metadata_hash.as_bytes()); // 32 bytes + assert_eq!(packed.len(), 113); + + let leaf_hash: Keccak256Digest = Keccak256::hash(&packed); + + // Compare the on-chain leaf hash against the independently computed one + let expected_leaf_felts: Vec = ExitRoot::new(*leaf_hash.as_bytes()).to_elements(); + + assert_eq!( + on_chain_leaf_felts + .iter() + .map(|f| f.as_canonical_u64() as u32) + .collect::>(), + expected_leaf_felts + .iter() + .map(|f| f.as_canonical_u64() as u32) + .collect::>(), + "On-chain leaf hash should match independently computed Keccak256 leaf hash" + ); + + Ok(()) +} + +/// Tests that the reclaim path (sender consuming their own MessageNote) is a no-op. +/// +/// When the sender consumes a MessageNote themselves (instead of the bridge consuming it), +/// the note script should detect the reclaim condition and skip all bridge logic: +/// - No output notes should be created +/// - The bridge's LET should remain unchanged (num_leaves = 0) +/// - The transaction should succeed +#[tokio::test] +async fn bridge_message_reclaim_is_noop() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let note = MessageNote::create( + 1u32, + EthAddress::from_hex("0x1234567890abcdef1234567890abcdef12345678") + .expect("valid Ethereum address"), + MetadataHash::new([0x42u8; 32]), + bridge_account.id(), + sender.id(), // sender == consuming account in reclaim + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // Sender consumes their own note (reclaim path) + let executed_tx = mock_chain + .build_tx_context(sender.id(), &[note.id()], &[])? + .build()? + .execute() + .await?; + + // No output notes + assert_eq!(executed_tx.output_notes().num_notes(), 0); + + // LET unchanged on bridge (note wasn't consumed by bridge) + assert_eq!(AggLayerBridge::read_let_num_leaves(&bridge_account), 0); + + Ok(()) +} + +/// Tests that consuming multiple MessageNotes consecutively updates the LET correctly. +/// +/// Sends 3 messages with different parameters and verifies: +/// - LET num_leaves increments to 1, 2, 3 +/// - LET root changes after each message (is different from the previous) +/// - No output notes after any consumption +#[tokio::test] +async fn bridge_message_consecutive() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let mut bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Create 3 MessageNotes with different destinations and metadata hashes + let destinations = [ + (1u32, "0x1111111111111111111111111111111111111111", [0x01u8; 32]), + (2u32, "0x2222222222222222222222222222222222222222", [0x02u8; 32]), + (3u32, "0x3333333333333333333333333333333333333333", [0x03u8; 32]), + ]; + + let mut notes = Vec::with_capacity(3); + for (dest_network, dest_addr, mh_bytes) in &destinations { + let note = MessageNote::create( + *dest_network, + EthAddress::from_hex(dest_addr).expect("valid Ethereum address"), + MetadataHash::new(*mh_bytes), + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + notes.push(note); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let mut prev_root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + + for (i, note) in notes.iter().enumerate() { + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[note.id()], &[])? + .build()? + .execute() + .await?; + + // No output notes for messages + assert_eq!( + executed_tx.output_notes().num_notes(), + 0, + "Message note #{} should not produce any output notes", + i + 1 + ); + + bridge_account.apply_delta(executed_tx.account_delta())?; + + // LET num_leaves increments + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + (i + 1) as u64, + "LET num_leaves should be {} after consuming message note #{}", + i + 1, + i + 1 + ); + + // LET root changed from previous + let current_root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + assert_ne!( + current_root, + prev_root, + "LET root should change after consuming message note #{}", + i + 1 + ); + prev_root = current_root; + + mock_chain.add_pending_executed_transaction(&executed_tx)?; + mock_chain.prove_next_block()?; + } + + Ok(()) +} + +/// Tests that a MessageNote with destination_network == MIDEN_NETWORK_ID is rejected. +/// +/// The bridge_message procedure in bridge_out.masm checks that the destination network +/// is not Miden's own network ID (77) and panics with ERR_BRIDGE_MSG_DESTINATION_NETWORK_IS_MIDEN. +#[tokio::test] +async fn bridge_message_rejects_destination_miden() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Create a MessageNote with destination_network = MIDEN_NETWORK_ID (77) + let note = MessageNote::create( + AggLayerBridge::MIDEN_NETWORK_ID, + EthAddress::from_hex("0x1234567890abcdef1234567890abcdef12345678") + .expect("valid Ethereum address"), + MetadataHash::new([0x42u8; 32]), + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_BRIDGE_MSG_DESTINATION_NETWORK_IS_MIDEN); + + Ok(()) +} + +/// Tests that a non-target account cannot consume a MessageNote. +/// +/// The bridge_message note script checks that the consuming account matches the target +/// specified in the note attachment. If neither the bridge (target) nor the sender (reclaim), +/// the transaction fails with ERR_BRIDGE_MSG_TARGET_ACCOUNT_MISMATCH. +#[tokio::test] +async fn bridge_message_non_target_cannot_consume() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Create a "other" account (neither bridge nor sender) + let other_account = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let note = MessageNote::create( + 1u32, + EthAddress::from_hex("0x1234567890abcdef1234567890abcdef12345678") + .expect("valid Ethereum address"), + MetadataHash::new([0x42u8; 32]), + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mock_chain = builder.build()?; + + // Try to consume with other_account (not bridge, not sender) + let result = mock_chain + .build_tx_context(other_account.id(), &[note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_BRIDGE_MSG_TARGET_ACCOUNT_MISMATCH); + + Ok(()) +} + +/// Tests that two messages with identical destinations but different metadata_hashes produce +/// different LET roots. +/// +/// This verifies that the metadata_hash is included in the leaf hash computation, so +/// distinct messages produce distinct tree states. +#[tokio::test] +async fn bridge_message_distinct_metadata_produces_distinct_roots() -> anyhow::Result<()> { + // Helper: build a fresh bridge, consume one MessageNote, return the final LET root. + async fn consume_single_message( + metadata_hash: MetadataHash, + ) -> anyhow::Result> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let mut bridge_account = create_existing_bridge_account( + builder.rng_mut().draw_word(), + bridge_admin.id(), + ger_manager.id(), + ); + builder.add_account(bridge_account.clone())?; + + let sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let note = MessageNote::create( + 1u32, + EthAddress::from_hex("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("valid Ethereum address"), + metadata_hash, + bridge_account.id(), + sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[note.id()], &[])? + .build()? + .execute() + .await?; + + bridge_account.apply_delta(executed_tx.account_delta())?; + let root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + Ok(root) + } + + let root_a = consume_single_message(MetadataHash::new([0xaau8; 32])).await?; + let root_b = consume_single_message(MetadataHash::new([0xbbu8; 32])).await?; + + assert_ne!(root_a, root_b, "Different metadata_hashes should produce different LET roots"); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/leaf_utils.rs b/crates/miden-testing/tests/agglayer/leaf_utils.rs index 87013815d8..87670c3a33 100644 --- a/crates/miden-testing/tests/agglayer/leaf_utils.rs +++ b/crates/miden-testing/tests/agglayer/leaf_utils.rs @@ -3,12 +3,13 @@ extern crate alloc; use alloc::sync::Arc; use alloc::vec::Vec; -use miden_agglayer::{LeafValue, agglayer_library}; +use miden_agglayer::{EthAddress, LeafValue, MetadataHash, agglayer_library}; use miden_assembly::{Assembler, DefaultSourceManager}; use miden_core_lib::CoreLibrary; use miden_crypto::SequentialCommit; +use miden_crypto::hash::keccak::Keccak256; use miden_processor::advice::AdviceInputs; -use miden_processor::utils::packed_u32_elements_to_bytes; +use miden_processor::utils::{bytes_to_packed_u32_elements, packed_u32_elements_to_bytes}; use miden_protocol::{Felt, Word}; use miden_tx::utils::hex_to_bytes; @@ -195,3 +196,127 @@ async fn get_leaf_value() -> anyhow::Result<()> { assert_eq!(computed_leaf_value, expected_leaf_value); Ok(()) } + +/// Verifies that manually storing leaf data with leafType=1 (message) and calling +/// `compute_leaf_value` produces the same Keccak256 hash as the Rust-side computation. +/// +/// This isolates the MASM hash pipeline from the full bridge_message flow. +#[tokio::test] +async fn compute_leaf_value_message_type() -> anyhow::Result<()> { + let origin_network = 77u32; + let destination_network = 1u32; + let origin_address = EthAddress::new([0u8; 20]); + let destination_address = EthAddress::new([0u8; 20]); + let metadata_hash = MetadataHash::new([0x42u8; 32]); + + // Byte-swap network IDs to LE (matching MASM convention) + let leaf_type_swapped = u32::from_le_bytes(1u32.to_be_bytes()); + let origin_network_swapped = u32::from_le_bytes(origin_network.to_be_bytes()); + let dest_network_swapped = u32::from_le_bytes(destination_network.to_be_bytes()); + let origin_addr_felts = origin_address.to_elements(); + let dest_addr_felts = destination_address.to_elements(); + let mh: Vec = metadata_hash.to_elements().iter().map(|f| f.as_canonical_u64()).collect(); + + // Build expected packed bytes (113 bytes, abi.encodePacked) + let mut packed = Vec::with_capacity(113); + packed.push(1u8); + packed.extend_from_slice(&origin_network.to_be_bytes()); + packed.extend_from_slice(origin_address.as_bytes()); + packed.extend_from_slice(&destination_network.to_be_bytes()); + packed.extend_from_slice(destination_address.as_bytes()); + packed.extend_from_slice(&[0u8; 32]); + packed.extend_from_slice(metadata_hash.as_bytes()); + assert_eq!(packed.len(), 113); + + let expected_hash = Keccak256::hash(&packed); + let expected_felts: Vec = bytes_to_packed_u32_elements(expected_hash.as_bytes()); + + // Build MASM program that stores leaf data manually, then calls compute_leaf_value + let source = format!( + r#" + use miden::core::sys + use agglayer::bridge::leaf_utils + + begin + push.{leaf_type_swapped} push.0 mem_store + push.{origin_network_swapped} push.1 mem_store + push.{oa0} push.2 mem_store + push.{oa1} push.3 mem_store + push.{oa2} push.4 mem_store + push.{oa3} push.5 mem_store + push.{oa4} push.6 mem_store + push.{dest_network_swapped} push.7 mem_store + push.{da0} push.8 mem_store + push.{da1} push.9 mem_store + push.{da2} push.10 mem_store + push.{da3} push.11 mem_store + push.{da4} push.12 mem_store + push.0 push.13 mem_store + push.0 push.14 mem_store + push.0 push.15 mem_store + push.0 push.16 mem_store + push.0 push.17 mem_store + push.0 push.18 mem_store + push.0 push.19 mem_store + push.0 push.20 mem_store + push.{mh0} push.21 mem_store + push.{mh1} push.22 mem_store + push.{mh2} push.23 mem_store + push.{mh3} push.24 mem_store + push.{mh4} push.25 mem_store + push.{mh5} push.26 mem_store + push.{mh6} push.27 mem_store + push.{mh7} push.28 mem_store + push.0 push.29 mem_store + push.0 push.30 mem_store + push.0 push.31 mem_store + push.0 + exec.leaf_utils::compute_leaf_value + exec.sys::truncate_stack + end + "#, + leaf_type_swapped = leaf_type_swapped, + origin_network_swapped = origin_network_swapped, + oa0 = origin_addr_felts[0], + oa1 = origin_addr_felts[1], + oa2 = origin_addr_felts[2], + oa3 = origin_addr_felts[3], + oa4 = origin_addr_felts[4], + dest_network_swapped = dest_network_swapped, + da0 = dest_addr_felts[0], + da1 = dest_addr_felts[1], + da2 = dest_addr_felts[2], + da3 = dest_addr_felts[3], + da4 = dest_addr_felts[4], + mh0 = mh[0], + mh1 = mh[1], + mh2 = mh[2], + mh3 = mh[3], + mh4 = mh[4], + mh5 = mh[5], + mh6 = mh[6], + mh7 = mh[7], + ); + + let agglayer_lib = agglayer_library(); + let program = Assembler::new(Arc::new(DefaultSourceManager::default())) + .with_dynamic_library(CoreLibrary::default()) + .unwrap() + .with_dynamic_library(agglayer_lib) + .unwrap() + .assemble_program(&source) + .unwrap(); + + let exec_output = execute_program_with_default_host(program, None).await?; + let computed: Vec = + exec_output.stack[0..8].iter().map(|f| f.as_canonical_u64() as u32).collect(); + let expected: Vec = + expected_felts.iter().map(|f: &Felt| f.as_canonical_u64() as u32).collect(); + + assert_eq!( + computed, expected, + "MASM compute_leaf_value with leafType=1 should match Rust Keccak256" + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index d6f44ff14a..2c7b8c1e69 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -1,5 +1,6 @@ pub mod asset_conversion; mod bridge_in; +mod bridge_message; mod bridge_out; mod config_bridge; mod faucet_helpers; diff --git a/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs index 5107fcbf54..e943a40466 100644 --- a/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs +++ b/crates/miden-testing/tests/agglayer/solidity_miden_address_conversion.rs @@ -176,3 +176,51 @@ async fn test_ethereum_address_to_account_id_in_masm() -> anyhow::Result<()> { Ok(()) } + +/// MASM unit test: verifies that `eth_address::from_account_id` produces the same 5 felts +/// as the Rust `EthEmbeddedAccountId::from_account_id().to_elements()`. +#[tokio::test] +async fn test_from_account_id_matches_rust() -> anyhow::Result<()> { + use miden_protocol::account::{AccountIdVersion, AccountType}; + + let account_id = AccountId::dummy([42; 15], AccountIdVersion::Version1, AccountType::Public); + + let expected_elements: Vec = + EthEmbeddedAccountId::from_account_id(account_id).to_elements(); + assert_eq!(expected_elements.len(), 5); + + let prefix_val = account_id.prefix().as_u64(); + let suffix_val = account_id.suffix().as_canonical_u64(); + + let source = format!( + r#" + use miden::core::sys + use agglayer::common::eth_address + + begin + push.{prefix_val} push.{suffix_val} + exec.eth_address::from_account_id + exec.sys::truncate_stack + end + "#, + ); + + let program = Assembler::new(Arc::new(DefaultSourceManager::default())) + .with_dynamic_library(CoreLibrary::default()) + .unwrap() + .with_dynamic_library(agglayer_library()) + .unwrap() + .assemble_program(&source) + .unwrap(); + + let exec_output = execute_program_with_default_host(program).await?; + let actual: Vec = exec_output.stack[0..5].iter().map(|f| f.as_canonical_u64()).collect(); + let expected: Vec = expected_elements.iter().map(|f| f.as_canonical_u64()).collect(); + + assert_eq!( + actual, expected, + "MASM from_account_id output should match Rust EthEmbeddedAccountId::to_elements()" + ); + + Ok(()) +}