diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6fc7e8d8..747864d6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.15.0 (TBD) + +### Features + +- Added lock/unlock path for Miden-native tokens in the AggLayer bridge: `is_native` flag in `faucet_registry_map`, bridge-local `faucet_metadata_map` (replacing FPI to faucets for conversion data), and `lock_asset` / `unlock_and_send` procedures so the bridge holds native assets in its own vault instead of burn/mint via a faucet ([#2771](https://github.com/0xMiden/protocol/pull/2771)). + ## 0.14.0 (2026-03-23) ### Features diff --git a/Cargo.lock b/Cargo.lock index f22524ba70..bea5221dc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,7 +287,7 @@ dependencies = [ "miden-standards", "miden-testing", "miden-tx", - "rand 0.9.2", + "rand 0.9.4", "serde", "serde_json", "tokio", @@ -1663,7 +1663,7 @@ dependencies = [ "p3-miden-lifted-stark", "p3-symmetric", "p3-util", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha", "rand_core 0.9.5", "rand_hc", @@ -1716,7 +1716,7 @@ dependencies = [ "p3-field", "p3-goldilocks", "paste", - "rand 0.10.0", + "rand 0.10.1", "serde", "subtle", "thiserror", @@ -1822,7 +1822,7 @@ dependencies = [ "miden-utils-sync", "miden-verifier", "pprof", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha", "rand_xoshiro", "regex", @@ -1886,7 +1886,7 @@ dependencies = [ "miden-processor", "miden-protocol", "miden-standards", - "rand 0.9.2", + "rand 0.9.4", "regex", "thiserror", "walkdir", @@ -1911,7 +1911,7 @@ dependencies = [ "miden-tx", "miden-tx-batch-prover", "primitive-types", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha", "rstest", "serde", @@ -2279,7 +2279,7 @@ dependencies = [ "p3-maybe-rayon", "p3-util", "paste", - "rand 0.10.0", + "rand 0.10.1", "serde", "tracing", ] @@ -2300,7 +2300,7 @@ dependencies = [ "p3-symmetric", "p3-util", "paste", - "rand 0.10.0", + "rand 0.10.1", "serde", ] @@ -2325,7 +2325,7 @@ dependencies = [ "p3-field", "p3-maybe-rayon", "p3-util", - "rand 0.10.0", + "rand 0.10.1", "serde", "tracing", ] @@ -2349,7 +2349,7 @@ dependencies = [ "p3-field", "p3-symmetric", "p3-util", - "rand 0.10.0", + "rand 0.10.1", ] [[package]] @@ -2380,7 +2380,7 @@ dependencies = [ "p3-miden-lmcs", "p3-miden-transcript", "p3-util", - "rand 0.10.0", + "rand 0.10.1", "thiserror", "tracing", ] @@ -2420,7 +2420,7 @@ dependencies = [ "p3-miden-transcript", "p3-symmetric", "p3-util", - "rand 0.10.0", + "rand 0.10.1", "serde", "thiserror", "tracing", @@ -2466,7 +2466,7 @@ dependencies = [ "p3-symmetric", "p3-util", "paste", - "rand 0.10.0", + "rand 0.10.1", "serde", "spin 0.10.0", "tracing", @@ -2480,7 +2480,7 @@ checksum = "6dd56ae3a51ded1b77f7b1b21d0b157ae82b9d5ca8f2cba347c0b821fe771a79" dependencies = [ "p3-field", "p3-symmetric", - "rand 0.10.0", + "rand 0.10.1", ] [[package]] @@ -2493,7 +2493,7 @@ dependencies = [ "p3-mds", "p3-symmetric", "p3-util", - "rand 0.10.0", + "rand 0.10.1", ] [[package]] @@ -2743,7 +2743,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bitflags 2.11.0", "num-traits", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2802,9 +2802,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -2812,9 +2812,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "rand_core 0.10.0", ] @@ -3000,7 +3000,7 @@ checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" dependencies = [ "proptest", "rand 0.8.5", - "rand 0.9.2", + "rand 0.9.4", "ruint-macro", "serde_core", "valuable", diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index b399402fe1..22dcba87f9 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -4,6 +4,7 @@ use miden_agglayer::{ B2AggNote, ClaimNoteStorage, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, MetadataHash, UpdateGerNote, @@ -204,10 +205,6 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result Result Result { Felt::new(FungibleAsset::MAX_AMOUNT), Felt::new(bridge_amount), bridge_account.id(), - &origin_token_address, - origin_network, - scale, - MetadataHash::from_token_info("AGG", "AGG", 8), ); builder.add_account(faucet.clone())?; // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) + let metadata_hash = MetadataHash::from_token_info("AGG", "AGG", 8); let config_note = ConfigAggBridgeNote::create( - faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index cddf6461fd..f45ebe7a81 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -988,17 +988,33 @@ Terminology: deployed wrapped ERC20 contract. A faucet must be registered in the [Bridge Contract](#31-bridge-account-component) before it can participate in bridging. The -bridge maintains two registry maps: +bridge maintains three registry maps, all keyed by faucet account ID and populated atomically +by `bridge_config::register_faucet` during the [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) note +consumption: - **Faucet registry** (`agglayer::bridge::faucet_registry_map`): maps faucet account IDs - to a registration flag. Used during bridge-out to verify an asset's faucet is authorized - (see `bridge_config::assert_faucet_registered`). + to a registration value `[1, is_native, 0, 0]`. Used during bridge-out to verify an + asset's faucet is authorized (`bridge_config::assert_faucet_registered`) and, via the + `is_native` flag, to branch between burn/lock on bridge-out and mint/unlock on bridge-in. - **Token registry** (`agglayer::bridge::token_registry_map`): maps Poseidon2 hashes of native token addresses to faucet account IDs. Used during bridge-in to look up the - correct faucet for a given origin token (see - `bridge_config::lookup_faucet_by_token_address`). - -Both registries are populated atomically by `bridge_config::register_faucet` during the [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) note consumption. + correct faucet for a given origin token (`bridge_config::lookup_faucet_by_token_address`). +- **Faucet metadata map** (`agglayer::bridge::faucet_metadata_map`): stores all conversion + metadata — origin address, origin network, scale, and the precomputed + `keccak256(abi.encode(name, symbol, decimals))` metadata hash — for every registered + faucet. A single map with four sub-keys per faucet ID is enough to hold the full set: + + | Sub-key | Value | + | ------------------------------------------ | ---------------------------------------------- | + | `[0, 0, faucet_id_suffix, faucet_id_prefix]` | `[addr0, addr1, addr2, addr3]` | + | `[1, 0, faucet_id_suffix, faucet_id_prefix]` | `[addr4, origin_network, scale, 0]` | + | `[2, 0, faucet_id_suffix, faucet_id_prefix]` | `[mh_lo0, mh_lo1, mh_lo2, mh_lo3]` | + | `[3, 0, faucet_id_suffix, faucet_id_prefix]` | `[mh_hi0, mh_hi1, mh_hi2, mh_hi3]` | + + The metadata map lets `bridge_out` and `bridge_in` read conversion data from bridge-local + storage rather than issuing foreign-procedure-invocation (FPI) calls into the faucet; this + is required for native-token support, where the faucet is not under the bridge's control + and does not necessarily expose any AggLayer-specific procedures. ### 7.1 Bridging-in: Registering non-native faucets on Miden @@ -1006,42 +1022,44 @@ When a new ERC20 token is bridged to Miden for the first time, a corresponding A faucet account must be created and registered. The faucet serves as the mint/burn authority for the wrapped token on Miden. -The `AggLayerFaucet` struct (Rust, `src/faucet.rs`) captures the faucet-specific -configuration: - -- Token metadata: symbol, decimals, max_supply, token_supply (TODO Missing information about the token name ([#2585](https://github.com/0xMiden/protocol/issues/2585))) -- Origin token address: the ERC20 contract address on the origin chain -- Origin network: the chain ID of the origin chain -- Scale factor: the exponent used to convert between EVM U256 amounts and Field elements on Miden -- Metadata hash: `keccak256(abi.encode(name, symbol, decimals))`. This is precomputed by the bridge admin at faucet creation time and is currently not verified onchain (TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))) +The `AggLayerFaucet` Rust struct (`src/faucet.rs`) holds only +token metadata — symbol, decimals, max supply, and token supply +(TODO Missing token name ([#2585](https://github.com/0xMiden/protocol/issues/2585))). +Conversion metadata (origin address, origin network, scale, and metadata hash) is +*not* stored on the faucet; it is carried by the `CONFIG_AGG_BRIDGE` note at registration +time and written directly into the bridge's `faucet_metadata_map`. The metadata hash is +precomputed by the bridge admin and is currently not verified onchain +(TODO Verify metadata hash onchain ([#2586](https://github.com/0xMiden/protocol/issues/2586))). Registration is performed via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. The bridge -operator creates a `CONFIG_AGG_BRIDGE` note containing the faucet's account ID and the -origin token address, then sends it to the bridge account. On consumption, the note -script calls `bridge_config::register_faucet`, which performs a two-step registration: - -1. Writes a registration flag under the faucet ID key in the `faucet_registry_map`: - `[0, 0, faucet_id_suffix, faucet_id_prefix]` -> `[1, 0, 0, 0]`. -2. Hashes the origin token address using Poseidon2 and writes - the mapping into the `token_registry_map`: - `hash(origin_token_addr)` -> `[0, 0, faucet_id_suffix, faucet_id_prefix]`. - -The token registry enables the bridge to resolve which Miden-side faucet corresponds to a given -origin token address during CLAIM note processing. When the bridge -processes a [`CLAIM`](#42-claim) note, it reads the origin token address from the leaf data and calls -`bridge_config::lookup_faucet_by_token_address` to find the registered faucet. This -lookup hashes the address with Poseidon2 and retrieves the faucet ID from the token -registry map. If the token address is not registered, the `CLAIM` note consumption will fail. - -This means that the bridge admin must register the faucet on the Miden side before the corresponding tokens can be bridged in. - -The bridge admin is a trusted role, and is the sole entity that can register faucets on the Miden side (due to the caller restriction on [`bridge_config::register_faucet`](#bridge_configregister_faucet)). - -### 7.2 Bridging-out: How Miden-native tokens are registered on other chains - -When an asset is bridged out from Miden, [`bridge_out::bridge_out`](#bridge_outbridge_out) constructs a leaf for -the Local Exit Tree. The leaf includes the metadata hash, which the bridge fetches from -the faucet via FPI (`agglayer_faucet::get_metadata_hash`), as well as the other leaf data fields, including origin network and origin token address. +operator creates a `CONFIG_AGG_BRIDGE` note carrying the faucet's account ID, origin token +address, origin network, scale, metadata hash, and the `is_native` flag, then sends it to +the bridge. On consumption, the note script calls `bridge_config::register_faucet` (plus +`store_faucet_metadata_hash` for the metadata hash — split into two calls because the +16-element MASM stack cannot fit all 18 registration felts at once). These procedures +perform the following writes: + +1. `faucet_registry_map`: `[0, 0, faucet_id_suffix, faucet_id_prefix]` → `[1, is_native, 0, 0]`. +2. `faucet_metadata_map`: origin-address + origin-network + scale under sub-keys + `[0, 0, fid_s, fid_p]` and `[1, 0, fid_s, fid_p]`; metadata hash (lo/hi) under + `[2, 0, fid_s, fid_p]` and `[3, 0, fid_s, fid_p]`. +3. `token_registry_map`: `Poseidon2(origin_token_addr)` → `[0, 0, fid_s, fid_p]`. + +The token registry enables the bridge to resolve which Miden-side faucet corresponds to a +given origin token address during CLAIM note processing (`bridge_config::lookup_faucet_by_token_address`). +If the token address is not registered, the `CLAIM` note consumption will fail. + +The bridge admin is a trusted role, and is the sole entity that can register faucets on +the Miden side (enforced by the caller restriction on +[`bridge_config::register_faucet`](#bridge_configregister_faucet)). + +### 7.2 Bridging-out: How tokens are registered on other chains + +When an asset is bridged out from Miden, [`bridge_out::bridge_out`](#bridge_outbridge_out) +constructs a leaf for the Local Exit Tree. The metadata hash, origin token address, origin +network, and scale factor are all read from the bridge's local `faucet_metadata_map` +(`bridge_config::get_faucet_conversion_info` and `bridge_config::get_faucet_metadata_hash`). +No FPI into the faucet is required — the bridge is fully self-contained for conversion data. On the EVM destination chain, when a user claims the bridged asset via `PolygonZkEVMBridgeV2.claimAsset()`, the wrapped token is deployed lazily on first claim. @@ -1051,22 +1069,35 @@ as a parameter to `claimAsset()`. The EVM bridge verifies that no wrapped token exists yet, the bridge deploys a new `TokenWrapped` ERC20 using the decoded name, symbol, and decimals from the metadata bytes. -#### Miden-native faucets - -A Miden-native faucet uses the same storage -layout and registration flow as a wrapped faucet. The key difference is what values are -stored in the conversion metadata: +For Miden-native faucets, the registered metadata uses: - `origin_token_address`: the faucet's own `AccountId` as per the [Embedded Format](#62-embedded-format). - `origin_network`: Miden's network ID as assigned by AggLayer (currently unassigned). -- `metadata_hash`: `keccak256(abi.encode(name, symbol, decimals))` - same as for wrapped +- `metadata_hash`: `keccak256(abi.encode(name, symbol, decimals))` — same as for wrapped faucets. -On the EVM side, `claimAsset()` sees `originNetwork != networkID` (foreign asset), so it -follows the wrapped token path: computes +On the EVM side, `claimAsset()` sees `originNetwork != networkID` (foreign asset) for a +Miden-native token, so it follows the wrapped token path: computes `tokenInfoHash = keccak256(abi.encodePacked(originNetwork, originTokenAddress))`, and deploys a new `TokenWrapped` ERC20 via `CREATE2` on first claim, minting on subsequent -claims. The `CREATE2` salt is `tokenInfoHash`, so the wrapper address is deterministic -from the `(originNetwork, originTokenAddress)` pair. The metadata bytes provided by the -claimer (which must hash to the leaf's `metadataHash`) are used to initialize the wrapped -token's name, symbol, and decimals. +claims. + +### 7.3 Native vs non-native paths on the Miden side + +The `is_native` flag recorded in `faucet_registry_map` splits the bridge's own Miden-side +behavior on both directions: + +| Direction | `is_native = false` (wrapped / foreign) | `is_native = true` (Miden-native) | +| ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Bridge-out | `bridge_out::create_burn_note` — emits a BURN note consumed by the faucet. | `bridge_out::lock_asset` — `native_account::add_asset` locks the asset in the bridge vault. No BURN note is emitted. | +| Bridge-in | `bridge_in_output::build_mint_output_note` — emits a MINT note consumed by the faucet. | `bridge_in_output::unlock_and_send` — `native_account::remove_asset` unlocks from the vault, then emits a P2ID note directly to the recipient. No MINT note is emitted. | + +The LET leaf is constructed identically in both bridge-out branches. The native branch +does not require the bridge to be the faucet's owner, and `ownable2step::assert_sender_is_owner` +is not invoked on the native path. The P2ID note emitted by `unlock_and_send` uses the +`PROOF_DATA_KEY` as its serial number, which makes the note commitment deterministic for +a given claim and prevents double-spend within the same claim. + +This mirrors `PolygonZkEVMBridgeV2.claimAsset()`'s handling of +`originNetwork == networkID`: the EVM bridge transfers native tokens from / to its own +balance instead of minting / burning them via the token contract. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 4df7db2aac..ef783426d0 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -22,6 +22,7 @@ const GER_MANAGER_SLOT = word("agglayer::bridge::ger_manager_account_id") const GER_MAP_STORAGE_SLOT = word("agglayer::bridge::ger_map") const FAUCET_REGISTRY_MAP_SLOT = word("agglayer::bridge::faucet_registry_map") const TOKEN_REGISTRY_MAP_SLOT = word("agglayer::bridge::token_registry_map") +const FAUCET_METADATA_MAP_SLOT = word("agglayer::bridge::faucet_metadata_map") # Flags const GER_KNOWN_FLAG = 1 @@ -30,6 +31,14 @@ const IS_FAUCET_REGISTERED_FLAG = 1 # Offset in the local memory of the `hash_token_address` procedure const TOKEN_ADDR_HASH_PTR = 0 +# Local memory slot offsets used inside the `register_faucet` procedure +const REG_TOKEN_HASH_LOC = 0 +const REG_FAUCET_ID_SUFFIX_LOC = 4 +const REG_FAUCET_ID_PREFIX_LOC = 5 +const REG_SCALE_LOC = 6 +const REG_ORIGIN_NETWORK_LOC = 7 +const REG_IS_NATIVE_LOC = 8 + # PUBLIC INTERFACE # ================================================================================================= @@ -102,70 +111,145 @@ proc assert_valid_ger # => [] end -#! Registers a faucet in the bridge's faucet registry and token registry. +#! Registers a faucet in the bridge's faucet registry, token registry, and metadata map. +#! +#! Stores conversion metadata for the faucet using a sub-key scheme in faucet_metadata_map: +#! 1. KEY [0, 0, faucet_id_suffix, faucet_id_prefix] -> [addr0, addr1, addr2, addr3] (origin address part 1) +#! 2. KEY [1, 0, faucet_id_suffix, faucet_id_prefix] -> [addr4, origin_network, scale, 0] (origin address part 2) #! -#! 1. Writes `KEY -> [1, 0, 0, 0]` into the `faucet_registry` map, where -#! `KEY = [0, 0, faucet_id_suffix, faucet_id_prefix]`. -#! 2. Writes `hash(tokenAddress[5]) -> [faucet_id_suffix, faucet_id_prefix, 0, 0]` into the -#! `token_registry` map. +#! Also registers: +#! 3. faucet_registry_map: [0, 0, faucet_id_suffix, faucet_id_prefix] -> [1, is_native, 0, 0] +#! 4. token_registry_map: hash(tokenAddress) -> [0, 0, faucet_id_suffix, faucet_id_prefix] #! -#! Inputs: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] +#! Inputs: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] #! Outputs: [pad(16)] #! #! Panics if: #! - the note sender is not the bridge admin. #! #! Invocation: call +@locals(14) pub proc register_faucet - # assert the note sender is the bridge admin. exec.assert_sender_is_bridge_admin - # => [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] - # Save faucet ID for later use in token_registry - dup.6 dup.6 - # => [faucet_id_suffix, faucet_id_prefix, origin_token_addr(5), - # faucet_id_suffix, faucet_id_prefix, pad(9)] + # Save non-address data to locals + movup.9 loc_store.REG_IS_NATIVE_LOC + movup.8 loc_store.REG_ORIGIN_NETWORK_LOC + movup.7 loc_store.REG_SCALE_LOC + movup.6 loc_store.REG_FAUCET_ID_PREFIX_LOC + movup.5 loc_store.REG_FAUCET_ID_SUFFIX_LOC + # => [addr0, addr1, addr2, addr3, addr4, pad(11)] - # --- 1. Register faucet in faucet_registry --- + # Duplicate address for hashing before it gets consumed + dup.4 dup.4 dup.4 dup.4 dup.4 + # => [addr0, addr1, addr2, addr3, addr4, addr0, addr1, addr2, addr3, addr4, pad(6)] - # set_map_item expects [slot_id(2), KEY, VALUE] and returns [OLD_VALUE]. - # Build KEY = [0, 0, suffix, prefix] and VALUE = [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0] - push.0.0.0.IS_FAUCET_REGISTERED_FLAG - # => [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0, - # faucet_id_suffix, faucet_id_prefix, origin_token_addr(5), - # faucet_id_suffix, faucet_id_prefix, pad(9)] + exec.hash_token_address + # => [TOKEN_ADDR_HASH, addr0, addr1, addr2, addr3, addr4, pad(6)] - movup.5 movup.5 push.0.0 - # => [ - # [0, 0, faucet_id_suffix, faucet_id_prefix], - # [IS_FAUCET_REGISTERED_FLAG, 0, 0, 0], - # origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9) - # ] + loc_storew_le.REG_TOKEN_HASH_LOC dropw + # => [addr0, addr1, addr2, addr3, addr4, pad(11)] - push.FAUCET_REGISTRY_MAP_SLOT[0..2] + # --- Step 1: Store origin address part 1 in faucet_metadata_map --- + # KEY = [0, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [addr0, addr1, addr2, addr3] + + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix, addr0, addr1, addr2, addr3, addr4, pad(11)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] exec.native_account::set_map_item - # => [OLD_VALUE, origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + dropw + # => [addr4, pad(15)] + # --- Step 2: Store origin address part 2 + origin_network + scale --- + # KEY = [1, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [addr4, origin_network, scale, 0] + + push.0 + loc_load.REG_SCALE_LOC + loc_load.REG_ORIGIN_NETWORK_LOC + movup.3 + # => [addr4, origin_network, scale, 0, pad(11)] + + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0, pad(11)] + + push.0.1 + # => [1, 0, faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0, pad(11)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item dropw - # => [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + # => [pad(11)] - # --- 2. Register token address → faucet ID in token_registry --- + # --- Step 3: Store [1, is_native, 0, 0] in faucet_registry_map --- + # KEY = [0, 0, faucet_id_suffix, faucet_id_prefix], VALUE = [1, is_native, 0, 0] + # The trailing [0, 0] of VALUE is supplied by the stack's bottom pads. - # Hash the token address - exec.hash_token_address - # => [TOKEN_ADDR_HASH, faucet_id_suffix, faucet_id_prefix, pad(10)] + loc_load.REG_IS_NATIVE_LOC push.IS_FAUCET_REGISTERED_FLAG + # => [1, is_native, pad(14)] - # Build VALUE = [0, 0, faucet_id_suffix, faucet_id_prefix] - movup.5 movup.5 push.0.0 - # => [0, 0, faucet_id_suffix, faucet_id_prefix, TOKEN_ADDR_HASH, pad(10)] + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix, 1, is_native, pad(14)] - swapw - # => [TOKEN_ADDR_HASH, 0, 0, faucet_id_suffix, faucet_id_prefix, pad(10)] + push.FAUCET_REGISTRY_MAP_SLOT[0..2] + exec.native_account::set_map_item + dropw + # => [pad(16)] + + # --- Step 4: Store TOKEN_ADDR_HASH -> [0, 0, faucet_id_suffix, faucet_id_prefix] in token_registry --- + + loc_load.REG_FAUCET_ID_PREFIX_LOC loc_load.REG_FAUCET_ID_SUFFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix, pad(11)] + + push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix, pad(11)] + + padw loc_loadw_le.REG_TOKEN_HASH_LOC + # => [TOKEN_ADDR_HASH, 0, 0, faucet_id_suffix, faucet_id_prefix, pad(7)] push.TOKEN_REGISTRY_MAP_SLOT[0..2] exec.native_account::set_map_item - # => [OLD_VALUE, pad(12)] + dropw + # => [pad(16)] +end + +#! Stores the metadata hash for a registered faucet in the bridge's faucet metadata map. +#! +#! This is the second call in the faucet registration flow (called after register_faucet). +#! Stores the metadata hash using sub-keys 2 and 3 in faucet_metadata_map: +#! - KEY [2, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_LO +#! - KEY [3, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_HI +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix, METADATA_HASH_LO, METADATA_HASH_HI, pad(6)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the bridge admin. +#! +#! Invocation: call +pub proc store_faucet_metadata_hash + exec.assert_sender_is_bridge_admin + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] + # --- Store METADATA_HASH_LO at key [2, 0, faucet_id_suffix, faucet_id_prefix] --- + dup.1 dup.1 swapw movup.5 movup.5 + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.0.2 + # => [2, 0, faucet_id_suffix, faucet_id_prefix, MH_LO, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item + dropw + # => [faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + # --- Store METADATA_HASH_HI at key [3, 0, faucet_id_suffix, faucet_id_prefix] --- + push.0.3 + # => [3, 0, faucet_id_suffix, faucet_id_prefix, MH_HI, pad(6)] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.native_account::set_map_item dropw # => [pad(16)] end @@ -173,6 +257,7 @@ end #! Asserts that a faucet is registered in the bridge's faucet registry. #! #! Looks up the faucet ID in the faucet registry map and asserts the registration flag is set. +#! The stored value is [is_registered, is_native, 0, 0]. #! #! Inputs: [faucet_id_suffix, faucet_id_prefix] #! Outputs: [] @@ -190,11 +275,132 @@ proc assert_faucet_registered exec.active_account::get_map_item # => [VALUE] - # the stored word must be [1, 0, 0, 0] for registered faucets + # the stored word is [1, is_native, 0, 0] for registered faucets + # assert element 0 (registration flag) is non-zero assert.err=ERR_FAUCET_NOT_REGISTERED drop drop drop # => [] end +#! Returns the scale factor for a registered faucet from the bridge's faucet metadata map. +#! +#! Reads the metadata from the faucet_metadata_map at key [1, 0, faucet_id_suffix, faucet_id_prefix]. +#! The stored value is [addr4, origin_network, scale, 0]. +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [scale] +#! +#! Invocation: exec +proc get_faucet_scale + # Build KEY = [1, 0, faucet_id_suffix, faucet_id_prefix] + push.0.1 + # => [1, 0, faucet_id_suffix, faucet_id_prefix] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr4, origin_network, scale, 0] + + drop drop swap drop + # => [scale] +end + +#! Returns the origin token address (5 felts), origin network, and scale factor for a registered +#! faucet from the bridge's faucet metadata map. +#! +#! Reads sub-keys 0 and 1 from faucet_metadata_map: +#! - Key [0, 0, faucet_id_suffix, faucet_id_prefix] -> [addr0, addr1, addr2, addr3] +#! - Key [1, 0, faucet_id_suffix, faucet_id_prefix] -> [addr4, origin_network, scale, 0] +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [origin_addr(5), origin_network, scale] +#! +#! Invocation: exec +proc get_faucet_conversion_info + # Prepare the sub-key 0 KEY underneath. + push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix] + + # Prepare the sub-key 1 KEY on top. + dup.3 dup.3 push.0.1 + # => [1, 0, faucet_id_suffix, faucet_id_prefix, 0, 0, faucet_id_suffix, faucet_id_prefix] + + # Read sub-key 1: [addr4, origin_network, scale, 0] + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr4, origin_network, scale, 0, 0, 0, faucet_id_suffix, faucet_id_prefix] + + # Surface the pre-built sub-key 0 KEY for the second read. + swapw + # => [0, 0, faucet_id_suffix, faucet_id_prefix, addr4, origin_network, scale, 0] + + # Read sub-key 0: [addr0, addr1, addr2, addr3] + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale, 0] + + # Drop the trailing 0 left over from sub-key 1. + movup.7 drop + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale] +end + +#! Returns the metadata hash (8 u32 felts) for a registered faucet from the bridge's faucet +#! metadata map. +#! +#! Reads sub-keys 2 and 3 from faucet_metadata_map: +#! - Key [2, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_LO +#! - Key [3, 0, faucet_id_suffix, faucet_id_prefix] -> METADATA_HASH_HI +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [METADATA_HASH_LO, METADATA_HASH_HI] +#! +#! Invocation: exec +proc get_faucet_metadata_hash + # Save faucet_id for second read + dup.1 dup.1 + # => [faucet_id_suffix, faucet_id_prefix, faucet_id_suffix, faucet_id_prefix] + + # Read sub-key 3 (MH_HI) first so MH_LO ends up on top after the second read without a swapw. + push.0.3 + # => [3, 0, faucet_id_suffix, faucet_id_prefix, faucet_id_suffix, faucet_id_prefix] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [MH_HI, faucet_id_suffix, faucet_id_prefix] + + # Move fid below MH_HI + movup.5 movup.5 + # => [faucet_id_suffix, faucet_id_prefix, MH_HI] + + # Read sub-key 2: METADATA_HASH_LO + push.0.2 + # => [2, 0, faucet_id_suffix, faucet_id_prefix, MH_HI] + + push.FAUCET_METADATA_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [MH_LO, MH_HI] +end + +#! Returns whether a faucet is native (not owned by the bridge). +#! +#! Reads the faucet_registry_map value [1, is_native, 0, 0] and returns the is_native flag. +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix] +#! Outputs: [is_native] +#! +#! Invocation: exec +proc is_faucet_native + # Build KEY = [0, 0, faucet_id_suffix, faucet_id_prefix] + push.0.0 + # => [0, 0, faucet_id_suffix, faucet_id_prefix] + + push.FAUCET_REGISTRY_MAP_SLOT[0..2] + exec.active_account::get_map_item + # => [1, is_native, 0, 0] + + # Drop element 0 (registration flag), sink is_native below the trailing zeros, drop them. + drop movdn.2 drop drop + # => [is_native] +end + #! Looks up the faucet account ID for a given origin token address. #! #! Hashes the origin token address (5 felts) and looks up the result in the token_registry map. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm index f101c90b40..fa20bca76c 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm @@ -1,23 +1,15 @@ use agglayer::bridge::bridge_config +use agglayer::bridge::bridge_in_output use agglayer::bridge::leaf_utils use agglayer::common::utils use agglayer::common::asset_conversion use agglayer::common::eth_address -use agglayer::faucet -> agglayer_faucet use miden::core::crypto::hashes::keccak256 use miden::core::crypto::hashes::poseidon2 use miden::core::mem use miden::core::word -use miden::protocol::note -use miden::protocol::output_note -use miden::protocol::output_note::ATTACHMENT_KIND_NONE use miden::protocol::active_account use miden::protocol::native_account -use miden::protocol::tx -use miden::standards::note_tag -use miden::standards::note_tag::DEFAULT_TAG -use miden::standards::attachments::network_account_target -use miden::standards::note::execution_hint::ALWAYS use miden::protocol::types::DoubleWord use miden::protocol::types::MemoryAddress @@ -58,24 +50,6 @@ const CGI_CHAIN_HASH_HI_SLOT_NAME = word("agglayer::bridge::cgi_chain_hash_hi") const CLAIM_PROOF_DATA_WORD_LEN = 134 const CLAIM_LEAF_DATA_WORD_LEN = 8 -# MINT note storage layout (public mode, 18 felts total): -# - tag [0] : 1 felt -# - amount [1] : 1 felt -# - attachment_kind [2] : 1 felt -# - attachment_scheme [3] : 1 felt -# - ATTACHMENT [4..7] : 4 felts -# - P2ID_SCRIPT_ROOT [8..11] : 4 felts -# - SERIAL_NUM [12..15] : 4 felts -# - account_id_suffix [16] : 1 felt -# - account_id_prefix [17] : 1 felt -const MINT_NOTE_NUM_STORAGE_ITEMS = 18 - -# P2ID output note constants -const OUTPUT_NOTE_TYPE_PUBLIC = 1 - -# P2ID attachment constants (the P2ID note created by the faucet has no attachment) -const P2ID_ATTACHMENT_SCHEME_NONE = 0 - # Global memory pointers # ------------------------------------------------------------------------------------------------- @@ -102,9 +76,11 @@ const LEAF_DATA_START_PTR = 0 # Memory pointers for piped advice map data (used by claim procedure) const CLAIM_PROOF_DATA_START_PTR = 0 const CLAIM_LEAF_DATA_START_PTR = 536 +# CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT is also mirrored in bridge_in_output.masm; keep values in sync. const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568 # Memory addresses for stored keys (used by claim procedure) +# CLAIM_PROOF_DATA_KEY_MEM_ADDR is also mirrored in bridge_in_output.masm; keep values in sync. const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700 const CLAIM_LEAF_DATA_KEY_MEM_ADDR = 704 @@ -133,18 +109,6 @@ const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_5 = 554 const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_6 = 555 const OUTPUT_NOTE_ASSET_AMOUNT_MEM_ADDR_7 = 556 -# Memory addresses for MINT note output construction -const MINT_NOTE_STORAGE_MEM_ADDR_0 = 800 -const MINT_NOTE_STORAGE_DEST_TAG = 800 -const MINT_NOTE_STORAGE_NATIVE_AMOUNT = 801 -const MINT_NOTE_STORAGE_ATTACHMENT_KIND = 802 -const MINT_NOTE_STORAGE_ATTACHMENT_SCHEME = 803 -const MINT_NOTE_STORAGE_ATTACHMENT = 804 -const MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT = 808 -const MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM = 812 -const MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX = 816 -const MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX = 817 - # Local memory offsets # ------------------------------------------------------------------------------------------------- @@ -234,13 +198,25 @@ pub proc claim # Verify faucet_mint_amount matches the leaf data amount exec.verify_claim_amount # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - - # Build MINT output note targeting the AggLayer faucet - loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL - # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] - exec.build_mint_output_note - # => [pad(16)] + # Branch on is_native: native faucets unlock from the bridge vault and emit a P2ID note + # directly to the recipient; non-native faucets go through the standard MINT path. + dup.1 dup.1 exec.bridge_config::is_faucet_native + # => [is_native, faucet_id_suffix, faucet_id_prefix, pad(16)] + + if.true + loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL + # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] + + exec.bridge_in_output::unlock_and_send + # => [pad(16)] + else + loc_load.CLAIM_DEST_ID_PREFIX_LOCAL loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL + # => [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix, pad(16)] + + exec.bridge_in_output::build_mint_output_note + # => [pad(16)] + end end # HELPER PROCEDURES @@ -432,7 +408,7 @@ end #! by the faucet's scale factor. #! #! This procedure: -#! 1. Performs an FPI call to the faucet's `get_scale` procedure to retrieve the scale factor. +#! 1. Reads the scale factor from the bridge's faucet_metadata_map. #! 2. Loads the raw U256 amount from the leaf data in memory. #! 3. Calls `verify_u256_to_native_amount_conversion` to assert that #! `faucet_mint_amount == floor(raw_amount / 10^scale)`. @@ -441,44 +417,26 @@ end #! Outputs: [] #! #! Panics if: -#! - the FPI call to the faucet's get_scale fails. #! - the faucet_mint_amount does not match the expected scaled-down value. #! #! Invocation: exec proc verify_claim_amount - # Step 1: Pad the stack explicitly for FPI call (get_scale takes no inputs) - padw padw - movup.9 movup.9 - padw padw - movup.9 movup.9 - # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - - # Step 2: FPI call to faucet's get_scale procedure - procref.agglayer_faucet::get_scale - # => [PROC_MAST_ROOT(4), faucet_id_suffix, faucet_id_prefix, pad(16)] - - movup.5 movup.5 - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT(4), pad(16)] - - exec.tx::execute_foreign_procedure - # => [scale, pad(15)] - - # Clean up FPI output padding, keeping only scale - movdn.15 dropw dropw dropw drop drop drop + # Step 1: Read scale from bridge's faucet_metadata_map + exec.bridge_config::get_faucet_scale # => [scale] - # Step 3: Load the raw U256 amount from leaf data memory + # Step 2: Load the raw U256 amount from leaf data memory exec.load_raw_claim_amount # => [x7, x6, x5, x4, x3, x2, x1, x0, scale] - # Step 4: Load faucet_mint_amount (y) and position it for verification + # Step 3: Load faucet_mint_amount (y) and position it for verification mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT # => [y, x7, x6, x5, x4, x3, x2, x1, x0, scale] movdn.9 # => [x7, x6, x5, x4, x3, x2, x1, x0, scale, y] - # Step 5: Verify that y = floor(x / 10^scale) + # Step 4: Verify that y = floor(x / 10^scale) exec.asset_conversion::verify_u256_to_native_amount_conversion # => [] end @@ -793,182 +751,6 @@ proc load_origin_token_address # => [origin_token_addr(5)] end -#! Builds a PUBLIC MINT output note targeting the AggLayer Faucet. -#! -#! The MINT note uses public mode (18 storage items) so the AggLayer Faucet creates a PUBLIC P2ID -#! note on consumption. This procedure orchestrates three steps: -#! 1. Write all 18 MINT note storage items to global memory. -#! 2. Build the MINT note recipient digest from the storage. -#! 3. Create the output note, and set the attachment. -#! -#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc build_mint_output_note - # Step 1: Write all 18 MINT note storage items to global memory - exec.write_mint_note_storage - # => [faucet_id_suffix, faucet_id_prefix] - - # Step 2: Build the MINT note recipient digest - exec.build_mint_recipient - # => [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Step 3: Create the output note and set the faucet attachment - exec.create_mint_note_with_attachment - # => [] -end - -#! Writes all 18 MINT note storage items to global memory. -#! -#! Storage layout: -#! - [0]: tag (note tag for the P2ID output note, targeting the destination account) -#! - [1]: amount (the scaled-down Miden amount to mint) -#! - [2]: attachment_kind (0 = no attachment) -#! - [3]: attachment_scheme (0 = no attachment) -#! - [4-7]: ATTACHMENT ([0, 0, 0, 0]) -#! - [8-11]: P2ID_SCRIPT_ROOT (script root of the P2ID note) -#! - [12-15]: SERIAL_NUM (serial number for the P2ID note, derived from PROOF_DATA_KEY) -#! - [16]: account_id_suffix (destination account suffix) -#! - [17]: account_id_prefix (destination account prefix) -#! -#! Inputs: [destination_id_suffix, destination_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc write_mint_note_storage - # Write P2ID storage items first (before prefix is consumed): [16..17] - # Write destination_id_suffix [16] - dup mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX - # => [destination_id_suffix, destination_id_prefix] - - # Write destination_id_prefix [17] - dup.1 mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX - # => [destination_id_suffix, destination_id_prefix] - - drop - # => [destination_id_prefix] - - # Get the native amount from the pre-computed miden_claim_amount - mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT - # => [native_amount, destination_id_prefix] - - # Compute the note tag for the destination account (consumes prefix) - swap - # => [destination_id_prefix, native_amount] - - exec.note_tag::create_account_target - # => [dest_tag, native_amount] - - # Write tag to MINT note storage [0] - mem_store.MINT_NOTE_STORAGE_DEST_TAG - # => [native_amount] - - # Write amount to MINT note storage [1] - mem_store.MINT_NOTE_STORAGE_NATIVE_AMOUNT - # => [] - - # Write P2ID attachment fields (the P2ID note has no attachment) - # attachment_kind = NONE [2] - push.ATTACHMENT_KIND_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_KIND - # => [] - - # attachment_scheme = NONE [3] - push.P2ID_ATTACHMENT_SCHEME_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_SCHEME - # => [] - - # ATTACHMENT = empty word [4..7] - padw mem_storew_le.MINT_NOTE_STORAGE_ATTACHMENT dropw - # => [] - - # Write P2ID_SCRIPT_ROOT to MINT note storage [8..11] - procref.::miden::standards::notes::p2id::main - # => [P2ID_SCRIPT_ROOT] - - mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT dropw - # => [] - - # Write SERIAL_NUM (PROOF_DATA_KEY) to MINT note storage [12..15] - mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR - # => [SERIAL_NUM] - - mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM dropw - # => [] -end - -#! Builds the MINT note recipient digest from the storage items already written to global memory. -#! -#! Uses the MINT note script root and PROOF_DATA_KEY as serial number, then calls -#! `note::build_recipient` with the storage pointer and item count. -#! -#! Inputs: [] -#! Outputs: [MINT_RECIPIENT] -#! -#! Invocation: exec -proc build_mint_recipient - # Get the MINT note script root - procref.::miden::standards::notes::mint::main - # => [MINT_SCRIPT_ROOT] - - # Generate a serial number for the MINT note (use PROOF_DATA_KEY) - padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR - # => [MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - # Build the MINT note recipient - push.MINT_NOTE_NUM_STORAGE_ITEMS - # => [num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - push.MINT_NOTE_STORAGE_MEM_ADDR_0 - # => [storage_ptr, num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] - - exec.note::build_recipient - # => [MINT_RECIPIENT] -end - -#! Creates the MINT output note and sets the NetworkAccountTarget attachment on it. -#! -#! Creates a public output note with no assets, and sets the attachment so only the target faucet -#! can consume the note. -#! -#! Inputs: [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] -#! Outputs: [] -#! -#! Invocation: exec -proc create_mint_note_with_attachment - # Create the MINT output note targeting the faucet - push.OUTPUT_NOTE_TYPE_PUBLIC - # => [note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Set tag to DEFAULT - push.DEFAULT_TAG - # => [tag, note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] - - # Create the output note (no assets - MINT notes carry no assets) - exec.output_note::create - # => [note_idx, faucet_id_suffix, faucet_id_prefix] - - movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, note_idx] - - # Set the attachment on the MINT note to target the faucet account - # NetworkAccountTarget attachment: targets the faucet so only it can consume the note - # network_account_target::new expects [suffix, prefix, exec_hint] - # and returns [attachment_scheme, attachment_kind, ATTACHMENT] - push.ALWAYS # exec_hint = ALWAYS - movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, exec_hint, note_idx] - - exec.network_account_target::new - # => [attachment_scheme, attachment_kind, ATTACHMENT, note_idx] - - # Rearrange for set_attachment: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] - movup.6 - # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT(4)] - - exec.output_note::set_attachment - # => [] -end - #! Computes the root of the SMT based on the provided Merkle path, leaf value and leaf index. #! #! Inputs: [LEAF_VALUE_LO, LEAF_VALUE_HI, merkle_path_ptr, leaf_idx] diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm new file mode 100644 index 0000000000..fd202797c9 --- /dev/null +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in_output.masm @@ -0,0 +1,319 @@ +use miden::protocol::asset +use miden::protocol::native_account +use miden::protocol::note +use miden::protocol::output_note +use miden::protocol::output_note::ATTACHMENT_KIND_NONE +use miden::standards::note_tag +use miden::standards::note_tag::DEFAULT_TAG +use miden::standards::notes::p2id +use miden::standards::attachments::network_account_target +use miden::standards::note::execution_hint::ALWAYS + +# CONSTANTS +# ================================================================================================= + +# MINT note storage layout (public mode, 18 felts total): +# - tag [0] : 1 felt +# - amount [1] : 1 felt +# - attachment_kind [2] : 1 felt +# - attachment_scheme [3] : 1 felt +# - ATTACHMENT [4..7] : 4 felts +# - P2ID_SCRIPT_ROOT [8..11] : 4 felts +# - SERIAL_NUM [12..15] : 4 felts +# - account_id_suffix [16] : 1 felt +# - account_id_prefix [17] : 1 felt +const MINT_NOTE_NUM_STORAGE_ITEMS = 18 + +# P2ID output note constants +const OUTPUT_NOTE_TYPE_PUBLIC = 1 + +# P2ID attachment constants (the P2ID note created by the faucet has no attachment) +const P2ID_ATTACHMENT_SCHEME_NONE = 0 + +# Shared memory addresses — mirrored from bridge_in.masm; must stay in sync with the +# values defined there. These are global offsets into the claim's working memory that +# both `claim` (in bridge_in.masm) and the emission procs below read from. +const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568 +const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700 + +# Memory addresses for MINT note output construction +const MINT_NOTE_STORAGE_MEM_ADDR_0 = 800 +const MINT_NOTE_STORAGE_DEST_TAG = 800 +const MINT_NOTE_STORAGE_NATIVE_AMOUNT = 801 +const MINT_NOTE_STORAGE_ATTACHMENT_KIND = 802 +const MINT_NOTE_STORAGE_ATTACHMENT_SCHEME = 803 +const MINT_NOTE_STORAGE_ATTACHMENT = 804 +const MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT = 808 +const MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM = 812 +const MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX = 816 +const MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX = 817 + +# Offsets in the local memory of the `unlock_and_send` procedure +const UNLOCK_ASSET_KEY_LOC = 0 +const UNLOCK_ASSET_VALUE_LOC = 4 +const UNLOCK_DEST_SUFFIX_LOC = 8 +const UNLOCK_DEST_PREFIX_LOC = 9 + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Builds a PUBLIC MINT output note targeting the AggLayer Faucet. +#! +#! The MINT note uses public mode (18 storage items) so the AggLayer Faucet creates a PUBLIC P2ID +#! note on consumption. This procedure orchestrates three steps: +#! 1. Write all 18 MINT note storage items to global memory. +#! 2. Build the MINT note recipient digest from the storage. +#! 3. Create the output note, and set the attachment. +#! +#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +pub proc build_mint_output_note + # Step 1: Write all 18 MINT note storage items to global memory + exec.write_mint_note_storage + # => [faucet_id_suffix, faucet_id_prefix] + + # Step 2: Build the MINT note recipient digest + exec.build_mint_recipient + # => [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Step 3: Create the output note and set the faucet attachment + exec.create_mint_note_with_attachment + # => [] +end + +#! Removes the fungible asset for the claim from the bridge's vault and creates a PUBLIC P2ID +#! output note targeted at the destination account. +#! +#! Used on the bridge-in claim path for Miden-native faucets (ones whose mint authority the bridge +#! does not hold). Instead of creating a MINT note for the faucet, the asset is removed from the +#! bridge's own vault (where it was placed by a prior `lock_asset` on the bridge-out side) and +#! attached to a new P2ID note. The P2ID serial number is derived from `CLAIM_PROOF_DATA_KEY` +#! (matching the MINT path's serial-number choice) so the resulting note commitment is +#! deterministic across runs. +#! +#! Replay safety does not rely on serial-number uniqueness. A replayed claim is rejected earlier +#! in `bridge_in::claim` by the nullifier check (`assert_claim_not_spent`), so `unlock_and_send` +#! only runs once per (leaf_index, source_bridge_network) pair even though its serial number is +#! deterministic. +#! +#! Inputs: [destination_id_suffix, destination_id_prefix, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +@locals(10) +pub proc unlock_and_send + # Stash destination to locals (claim's CLAIM_DEST_ID_*_LOCAL is in a different frame and is + # not visible here — `exec` invocations get their own local frame). + loc_store.UNLOCK_DEST_SUFFIX_LOC loc_store.UNLOCK_DEST_PREFIX_LOC + # => [faucet_id_suffix, faucet_id_prefix] + + # Build the fungible asset (ASSET_KEY, ASSET_VALUE) from the faucet id and the pre-computed + # Miden claim amount. `asset::create_fungible_asset` is pure MASM (no FPI). + mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, amount] + + push.0 # enable_callbacks = 0 + # => [0, faucet_id_suffix, faucet_id_prefix, amount] + + exec.asset::create_fungible_asset + # => [ASSET_KEY, ASSET_VALUE] + + # Stash the asset to locals so we can re-use it for `output_note::add_asset` after + # `native_account::remove_asset` consumes its stack copy. + dupw.1 loc_storew_le.UNLOCK_ASSET_VALUE_LOC dropw + dupw loc_storew_le.UNLOCK_ASSET_KEY_LOC dropw + # => [ASSET_KEY, ASSET_VALUE] + + # Remove the asset from the bridge's vault. Panics if the vault does not contain enough of + # the asset, which is the desired failure mode for an invalid / double-spent claim. + exec.native_account::remove_asset + # => [REMAINING_ASSET_VALUE] + + dropw + # => [] + + # Build p2id::new's input [dest_suffix, dest_prefix, tag, note_type, SERIAL_NUM] from the + # bottom up. + padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [SERIAL_NUM] + + push.OUTPUT_NOTE_TYPE_PUBLIC + # => [note_type, SERIAL_NUM] + + loc_load.UNLOCK_DEST_PREFIX_LOC + # => [dest_prefix, note_type, SERIAL_NUM] + + exec.note_tag::create_account_target + # => [dest_tag, note_type, SERIAL_NUM] + + loc_load.UNLOCK_DEST_PREFIX_LOC loc_load.UNLOCK_DEST_SUFFIX_LOC + # => [dest_suffix, dest_prefix, dest_tag, note_type, SERIAL_NUM] + + exec.p2id::new + # => [note_idx] + + # Reload the asset from locals and attach it to the newly created P2ID note. + padw loc_loadw_le.UNLOCK_ASSET_VALUE_LOC + # => [ASSET_VALUE, note_idx] + + padw loc_loadw_le.UNLOCK_ASSET_KEY_LOC + # => [ASSET_KEY, ASSET_VALUE, note_idx] + + exec.output_note::add_asset + # => [] +end + +# MINT-PATH HELPERS (used only by build_mint_output_note) +# ================================================================================================= + +#! Writes all 18 MINT note storage items to global memory. +#! +#! Storage layout: +#! - [0]: tag (note tag for the P2ID output note, targeting the destination account) +#! - [1]: amount (the scaled-down Miden amount to mint) +#! - [2]: attachment_kind (0 = no attachment) +#! - [3]: attachment_scheme (0 = no attachment) +#! - [4-7]: ATTACHMENT ([0, 0, 0, 0]) +#! - [8-11]: P2ID_SCRIPT_ROOT (script root of the P2ID note) +#! - [12-15]: SERIAL_NUM (serial number for the P2ID note, derived from PROOF_DATA_KEY) +#! - [16]: account_id_suffix (destination account suffix) +#! - [17]: account_id_prefix (destination account prefix) +#! +#! Inputs: [destination_id_suffix, destination_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +proc write_mint_note_storage + # Write P2ID storage items first (before prefix is consumed): [16..17] + # Write destination_id_suffix [16] + dup mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_SUFFIX + # => [destination_id_suffix, destination_id_prefix] + + # Write destination_id_prefix [17] + dup.1 mem_store.MINT_NOTE_STORAGE_OUTPUT_NOTE_PREFIX + # => [destination_id_suffix, destination_id_prefix] + + drop + # => [destination_id_prefix] + + # Get the native amount from the pre-computed miden_claim_amount + mem_load.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT + # => [native_amount, destination_id_prefix] + + # Compute the note tag for the destination account (consumes prefix) + swap + # => [destination_id_prefix, native_amount] + + exec.note_tag::create_account_target + # => [dest_tag, native_amount] + + # Write tag to MINT note storage [0] + mem_store.MINT_NOTE_STORAGE_DEST_TAG + # => [native_amount] + + # Write amount to MINT note storage [1] + mem_store.MINT_NOTE_STORAGE_NATIVE_AMOUNT + # => [] + + # Write P2ID attachment fields (the P2ID note has no attachment) + # attachment_kind = NONE [2] + push.ATTACHMENT_KIND_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_KIND + # => [] + + # attachment_scheme = NONE [3] + push.P2ID_ATTACHMENT_SCHEME_NONE mem_store.MINT_NOTE_STORAGE_ATTACHMENT_SCHEME + # => [] + + # ATTACHMENT = empty word [4..7] + padw mem_storew_le.MINT_NOTE_STORAGE_ATTACHMENT dropw + # => [] + + # Write P2ID_SCRIPT_ROOT to MINT note storage [8..11] + procref.::miden::standards::notes::p2id::main + # => [P2ID_SCRIPT_ROOT] + + mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SCRIPT_ROOT dropw + # => [] + + # Write SERIAL_NUM (PROOF_DATA_KEY) to MINT note storage [12..15] + mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [SERIAL_NUM] + + mem_storew_le.MINT_NOTE_STORAGE_OUTPUT_SERIAL_NUM dropw + # => [] +end + +#! Builds the MINT note recipient digest from the storage items already written to global memory. +#! +#! Uses the MINT note script root and PROOF_DATA_KEY as serial number, then calls +#! `note::build_recipient` with the storage pointer and item count. +#! +#! Inputs: [] +#! Outputs: [MINT_RECIPIENT] +#! +#! Invocation: exec +proc build_mint_recipient + # Get the MINT note script root + procref.::miden::standards::notes::mint::main + # => [MINT_SCRIPT_ROOT] + + # Generate a serial number for the MINT note (use PROOF_DATA_KEY) + padw mem_loadw_be.CLAIM_PROOF_DATA_KEY_MEM_ADDR + # => [MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + # Build the MINT note recipient + push.MINT_NOTE_NUM_STORAGE_ITEMS + # => [num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + push.MINT_NOTE_STORAGE_MEM_ADDR_0 + # => [storage_ptr, num_storage_items, MINT_SERIAL_NUM, MINT_SCRIPT_ROOT] + + exec.note::build_recipient + # => [MINT_RECIPIENT] +end + +#! Creates the MINT output note and sets the NetworkAccountTarget attachment on it. +#! +#! Creates a public output note with no assets, and sets the attachment so only the target faucet +#! can consume the note. +#! +#! Inputs: [MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] +#! Outputs: [] +#! +#! Invocation: exec +proc create_mint_note_with_attachment + # Create the MINT output note targeting the faucet + push.OUTPUT_NOTE_TYPE_PUBLIC + # => [note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Set tag to DEFAULT + push.DEFAULT_TAG + # => [tag, note_type, MINT_RECIPIENT, faucet_id_suffix, faucet_id_prefix] + + # Create the output note (no assets - MINT notes carry no assets) + exec.output_note::create + # => [note_idx, faucet_id_suffix, faucet_id_prefix] + + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, note_idx] + + # Set the attachment on the MINT note to target the faucet account + # NetworkAccountTarget attachment: targets the faucet so only it can consume the note + # network_account_target::new expects [suffix, prefix, exec_hint] + # and returns [attachment_scheme, attachment_kind, ATTACHMENT] + push.ALWAYS # exec_hint = ALWAYS + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, exec_hint, note_idx] + + exec.network_account_target::new + # => [attachment_scheme, attachment_kind, ATTACHMENT, note_idx] + + # Rearrange for set_attachment: [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] + movup.6 + # => [note_idx, attachment_scheme, attachment_kind, ATTACHMENT] + + exec.output_note::set_attachment + # => [] +end diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 824ad3da19..203cad797d 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -3,7 +3,6 @@ use miden::protocol::active_account use miden::protocol::asset use miden::protocol::native_account use miden::protocol::note -use miden::protocol::tx use miden::standards::data_structures::double_word_array use miden::standards::attachments::network_account_target use miden::standards::note_tag::DEFAULT_TAG @@ -12,7 +11,7 @@ use miden::protocol::types::MemoryAddress use miden::protocol::output_note use miden::core::crypto::hashes::poseidon2 use agglayer::common::utils -use agglayer::faucet -> agglayer_faucet +use agglayer::common::asset_conversion use agglayer::bridge::bridge_config use agglayer::bridge::leaf_utils use agglayer::bridge::merkle_tree_frontier @@ -65,6 +64,7 @@ const DESTINATION_ADDRESS_2_LOC=10 const DESTINATION_ADDRESS_3_LOC=11 const DESTINATION_ADDRESS_4_LOC=12 const DESTINATION_NETWORK_LOC=13 +const BRIDGE_OUT_IS_NATIVE_LOC=14 # create_burn_note memory locals const CREATE_BURN_NOTE_BURN_ASSET_LOC=0 @@ -101,10 +101,8 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. #! #! Invocation: call -@locals(14) +@locals(15) pub proc bridge_out - # => [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] - # Save ASSET to local memory for later BURN note creation locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::store @@ -165,27 +163,28 @@ pub proc bridge_out exec.write_address_to_memory # => [pad(16)] - # --- 3. Fetch metadata hash from the faucet via FPI and write to memory --- - procref.agglayer_faucet::get_metadata_hash - # => [PROC_MAST_ROOT, pad(16)] - - # Reload asset to extract faucet ID for the FPI call + # --- 3. Fetch metadata hash from bridge storage and write to memory --- + # Reload asset to extract faucet id for the metadata lookup and the is_native flag. locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::load swapw dropw - # => [ASSET_KEY, PROC_MAST_ROOT, pad(16)] - # ASSET_KEY layout: [0, 0, faucet_id_suffix, faucet_id_prefix] + # => [ASSET_KEY, pad(16)] - # Extract faucet ID, drop padding and amount - drop drop - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT, pad(16)] + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, pad(16)] - exec.tx::execute_foreign_procedure - # => [METADATA_HASH_LO, METADATA_HASH_HI, pad(8)] + # Stash the is_native flag for the lock/burn branch later in this procedure. + dup.1 dup.1 + exec.bridge_config::is_faucet_native + loc_store.BRIDGE_OUT_IS_NATIVE_LOC + # => [faucet_id_suffix, faucet_id_prefix, pad(16)] + + exec.bridge_config::get_faucet_metadata_hash + # => [METADATA_HASH_LO, METADATA_HASH_HI, pad(16)] push.LEAF_DATA_START_PTR push.METADATA_HASH_OFFSET add movdn.8 - # => [METADATA_HASH_LO, METADATA_HASH_HI, metadata_hash_ptr, pad(8)] + # => [METADATA_HASH_LO, METADATA_HASH_HI, metadata_hash_ptr, pad(16)] exec.utils::mem_store_double_word_unaligned # => [pad(16)] @@ -210,21 +209,29 @@ pub proc bridge_out exec.add_leaf_bridge # => [pad(16)] - # --- 4. Create BURN output note for ASSET --- + # --- 5. Dispatch on is_native: lock into the bridge vault or burn via an output note --- locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::load # => [ASSET_KEY, ASSET_VALUE, pad(16)] - - exec.create_burn_note - # => [pad(16)] + + loc_load.BRIDGE_OUT_IS_NATIVE_LOC + # => [is_native, ASSET_KEY, ASSET_VALUE, pad(16)] + + if.true + exec.lock_asset + # => [pad(16)] + else + exec.create_burn_note + # => [pad(16)] + end end # HELPER PROCEDURES # ================================================================================================= -#! Validates that a faucet is registered in the bridge's faucet registry, then performs an FPI call -#! to the faucet's `asset_to_origin_asset` procedure to obtain the scaled amount, origin token -#! address, and origin network. +#! Validates that a faucet is registered in the bridge's faucet registry and converts the asset's +#! native Miden amount to the origin (AggLayer-side) U256 amount using bridge-local conversion +#! metadata. #! #! Inputs: [ASSET_KEY, ASSET_VALUE] #! Outputs: [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network] @@ -238,43 +245,38 @@ end #! #! Panics if: #! - The faucet is not registered in the faucet registry. -#! - The FPI call to asset_to_origin_asset fails. #! #! Invocation: exec proc convert_asset - # --- Step 1: Assert faucet is registered --- - # pad in preparation for FPI call - repeat.2 - padw padw swapdw - end - # => [ASSET_KEY, ASSET_VALUE, pad(16)] - swapw exec.asset::fungible_value_into_amount movdn.4 - # => [ASSET_KEY, amount, pad(16)] + # => [ASSET_KEY, amount] exec.asset::key_into_faucet_id - # => [faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # => [faucet_id_suffix, faucet_id_prefix, amount] dup.1 dup.1 exec.bridge_config::assert_faucet_registered - # => [faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # => [faucet_id_suffix, faucet_id_prefix, amount] - # --- Step 2: FPI to faucet's asset_to_origin_asset --- + # Fetch origin token address, origin network, and scale from bridge storage. + exec.bridge_config::get_faucet_conversion_info + # => [addr0, addr1, addr2, addr3, addr4, origin_network, scale, amount] - procref.agglayer_faucet::asset_to_origin_asset - # => [PROC_MAST_ROOT, faucet_id_suffix, faucet_id_prefix, amount, pad(16)] + # Bring [amount, scale] to the top for scale_native_amount_to_u256. + movup.6 + # => [scale, addr0, addr1, addr2, addr3, addr4, origin_network, amount] - # Move faucet_id above PROC_MAST_ROOT - movup.5 movup.5 - # => [faucet_id_suffix, faucet_id_prefix, PROC_MAST_ROOT, amount, pad(15), pad(1)] + movup.7 + # => [amount, scale, addr0, addr1, addr2, addr3, addr4, origin_network] - exec.tx::execute_foreign_procedure - # => [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network, pad(2), pad(1)] + exec.asset_conversion::scale_native_amount_to_u256 + exec.asset_conversion::reverse_limbs_and_change_byte_endianness + # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network] - # drop the 3 trailing padding elements - repeat.3 - movup.14 drop - end + # Byte-swap origin_network to match the EVM-side big-endian encoding used in the leaf layout. + movup.13 + exec.utils::swap_u32_bytes + movdn.13 # => [AMOUNT_U256_LO, AMOUNT_U256_HI, origin_addr(5), origin_network] end @@ -563,3 +565,21 @@ proc create_burn_note dropw dropw drop drop drop # => [] end + +#! Locks a fungible asset in the bridge's own vault. +#! +#! Used on the bridge-out path for Miden-native faucets (ones whose mint/burn authority the bridge +#! does not hold). Instead of creating a BURN note, the asset is simply added to the bridge's own +#! vault; the bridge-in claim side will later remove it via `unlock_and_send`. +#! +#! Inputs: [ASSET_KEY, ASSET_VALUE] +#! Outputs: [] +#! +#! Invocation: exec +proc lock_asset + exec.native_account::add_asset + # => [ASSET_VALUE'] + + dropw + # => [] +end diff --git a/crates/miden-agglayer/asm/agglayer/faucet/mod.masm b/crates/miden-agglayer/asm/agglayer/faucet/mod.masm index 408c0e93e1..4c3e59a79c 100644 --- a/crates/miden-agglayer/asm/agglayer/faucet/mod.masm +++ b/crates/miden-agglayer/asm/agglayer/faucet/mod.masm @@ -1,171 +1,6 @@ -use miden::core::sys -use agglayer::common::utils -use agglayer::common::asset_conversion -use miden::protocol::active_account - -# CONSTANTS -# ================================================================================================= - -# Storage slots for conversion metadata. -# Slot 1: [addr_felt0, addr_felt1, addr_felt2, addr_felt3] — first 4 felts of origin token address -const CONVERSION_INFO_1_SLOT = word("agglayer::faucet::conversion_info_1") -# Slot 2: [addr_felt4, origin_network, scale, 0] — remaining address felt + origin network + scale -const CONVERSION_INFO_2_SLOT = word("agglayer::faucet::conversion_info_2") - -# Storage slots for the pre-computed metadata hash (keccak256 of ABI-encoded token metadata). -# The 32-byte hash is split across two value slots, each holding 4 u32 felts. -const METADATA_HASH_LO_SLOT = word("agglayer::faucet::metadata_hash_lo") -const METADATA_HASH_HI_SLOT = word("agglayer::faucet::metadata_hash_hi") - # PUBLIC INTERFACE # ================================================================================================= -#! Returns the origin token address (5 felts) from faucet conversion storage. -#! -#! Reads conversion_info_1 (first 4 felts of address) and conversion_info_2 (5th felt) from storage. -#! -#! Inputs: [] -#! Outputs: [addr0, addr1, addr2, addr3, addr4] -#! -#! Invocation: exec -pub proc get_origin_token_address - push.CONVERSION_INFO_1_SLOT[0..2] - exec.active_account::get_item - # => [addr0, addr1, addr2, addr3] - - # Read slot 2: [addr4, origin_network, scale, 0] - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0, addr0, addr1, addr2, addr3] - - # Keep only addr4, drop origin_network, scale, 0 - movdn.7 drop drop drop - # => [addr0, addr1, addr2, addr3, addr4] -end - -#! Returns the origin network identifier from faucet conversion storage. -#! -#! Inputs: [] -#! Outputs: [origin_network] -#! -#! Invocation: exec -pub proc get_origin_network - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0] - - drop movdn.2 drop drop - # => [origin_network] -end - -#! Returns the scale factor from faucet conversion storage. -#! -#! Inputs: [] -#! Outputs: [scale] -#! -#! Invocation: exec -proc get_scale_inner - push.CONVERSION_INFO_2_SLOT[0..2] - exec.active_account::get_item - # => [addr4, origin_network, scale, 0] - - drop drop swap drop - # => [scale] -end - -#! Returns the pre-computed metadata hash (8 u32 felts) from faucet storage. -#! -#! The metadata hash is `keccak256(abi.encode(name, symbol, decimals))` and is stored across two -#! value slots (lo and hi, 4 felts each). -#! -#! Inputs: [pad(16)] -#! Outputs: [METADATA_HASH_LO(4), METADATA_HASH_HI(4), pad(8)] -#! -#! Invocation: call -pub proc get_metadata_hash - push.METADATA_HASH_LO_SLOT[0..2] - exec.active_account::get_item - # => [lo0, lo1, lo2, lo3, pad(16)] - - push.METADATA_HASH_HI_SLOT[0..2] - exec.active_account::get_item - # => [hi0, hi1, hi2, hi3, lo0, lo1, lo2, lo3, pad(16)] - - # Rearrange: move hi below lo - swapw - # => [lo0, lo1, lo2, lo3, hi0, hi1, hi2, hi3, pad(16)] - - # Drop 8 excess padding elements (24 -> 16) - swapdw dropw dropw - # => [METADATA_HASH_LO(4), METADATA_HASH_HI(4), pad(8)] -end - -#! Returns the scale factor from faucet conversion storage. -#! -#! Called via FPI from the bridge account. -#! -#! Inputs: [pad(16)] -#! Outputs: [scale, pad(15)] -#! -#! Invocation: call -pub proc get_scale - exec.get_scale_inner - # => [scale, pad(16)] - - swap drop - # => [scale, pad(15)] -end - -#! Converts a native Miden asset amount to origin asset data using the stored conversion metadata -#! (origin_token_address, origin_network, and scale). -#! -#! This procedure is intended to be called via FPI from the bridge account. -#! It reads the faucet's conversion metadata from storage, scales the native amount to U256 format, -#! and returns the result along with origin token address and network. -#! -#! Inputs: [amount, pad(15)] -#! Outputs: [AMOUNT_U256_LO, AMOUNT_U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(2)] -#! -#! Where: -#! - amount: The native Miden asset amount -#! - AMOUNT_U256: The scaled amount as 8 u32 limbs (little-endian U256) -#! - addr0..addr4: Origin token address (5 felts, u32 limbs) -#! - origin_network: Origin network identifier -#! -#! Invocation: call -pub proc asset_to_origin_asset - # => [amount, pad(15)] - - # Step 1: Get scale from storage - exec.get_scale_inner swap - # => [amount, scale, pad(15)] - - # Step 2: Scale amount to U256 - exec.asset_conversion::scale_native_amount_to_u256 - exec.asset_conversion::reverse_limbs_and_change_byte_endianness - # => [U256_LO, U256_HI, pad(15)] - - # Step 3: Get origin token address - exec.get_origin_token_address - # => [addr0, addr1, addr2, addr3, addr4, U256_LO, U256_HI, pad(15)] - - # Move address below the U256 amount - repeat.5 movdn.12 end - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, pad(15)] - - # Step 4: Get origin network - exec.get_origin_network - exec.utils::swap_u32_bytes - # => [origin_network, U256_LO, U256_HI, addr0..addr4, pad(15)] - - # Move origin_network after the address fields - movdn.13 - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(15)] - - exec.sys::truncate_stack - # => [U256_LO, U256_HI, addr0, addr1, addr2, addr3, addr4, origin_network, pad(2)] -end - #! Burns the fungible asset from the active note. #! #! This procedure retrieves the asset from the active note and burns it. The note must contain diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 4c38d5a019..14c5169f93 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -4,11 +4,13 @@ # # The bridge exposes: # - `register_faucet` from the bridge_config module +# - `store_faucet_metadata_hash` from the bridge_config module # - `update_ger` from the bridge_config module # - `claim` for bridge-in # - `bridge_out` for bridge-out pub use ::agglayer::bridge::bridge_config::register_faucet +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 diff --git a/crates/miden-agglayer/asm/components/faucet.masm b/crates/miden-agglayer/asm/components/faucet.masm index 71927c63d9..874c490bc0 100644 --- a/crates/miden-agglayer/asm/components/faucet.masm +++ b/crates/miden-agglayer/asm/components/faucet.masm @@ -5,13 +5,7 @@ # The faucet exposes: # - `mint_and_send` from the network fungible faucet (for MINT note consumption, with owner # verification) -# - `asset_to_origin_asset` for bridge-out FPI -# - `get_metadata_hash` for bridge-out FPI (metadata hash retrieval) -# - `get_scale` for bridge-in FPI (amount verification) # - `burn` for bridge-out pub use ::agglayer::faucet::mint_and_send -pub use ::agglayer::faucet::asset_to_origin_asset -pub use ::agglayer::faucet::get_metadata_hash -pub use ::agglayer::faucet::get_scale pub use ::agglayer::faucet::burn diff --git a/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm b/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm index 98df2690fd..5544eb18b1 100644 --- a/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm +++ b/crates/miden-agglayer/asm/note_scripts/CONFIG_AGG_BRIDGE.masm @@ -5,48 +5,86 @@ use miden::standards::attachments::network_account_target # CONSTANTS # ================================================================================================= -const CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS = 7 +const CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS = 18 const STORAGE_START_PTR = 0 -const ORIGIN_TOKEN_ADDR_0 = STORAGE_START_PTR + +# Memory layout after get_storage (18 felts): +# [0] origin_token_addr_0 +# [1] origin_token_addr_1 +# [2] origin_token_addr_2 +# [3] origin_token_addr_3 +# [4] origin_token_addr_4 +# [5] faucet_id_suffix +# [6] faucet_id_prefix +# [7] scale +# [8] origin_network +# [9] is_native +# [10..13] METADATA_HASH_LO_0..3 (4 felts) +# [14..17] METADATA_HASH_HI_0..3 (4 felts) + +const ORIGIN_TOKEN_ADDR_0 = 0 +const ORIGIN_TOKEN_ADDR_1 = 1 +const ORIGIN_TOKEN_ADDR_2 = 2 +const ORIGIN_TOKEN_ADDR_3 = 3 const ORIGIN_TOKEN_ADDR_4 = 4 +const FAUCET_ID_SUFFIX = 5 +const FAUCET_ID_PREFIX = 6 +const SCALE = 7 +const ORIGIN_NETWORK = 8 +const IS_NATIVE = 9 +const METADATA_HASH_LO_0 = 10 +const METADATA_HASH_LO_1 = 11 +const METADATA_HASH_LO_2 = 12 +const METADATA_HASH_LO_3 = 13 +const METADATA_HASH_HI_0 = 14 +const METADATA_HASH_HI_1 = 15 +const METADATA_HASH_HI_2 = 16 +const METADATA_HASH_HI_3 = 17 # ERRORS # ================================================================================================= -const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS = "CONFIG_AGG_BRIDGE expects exactly 7 note storage items" +const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS = "CONFIG_AGG_BRIDGE expects exactly 18 note storage items" const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH = "CONFIG_AGG_BRIDGE note attachment target account does not match consuming account" -#! Agglayer Bridge CONFIG_AGG_BRIDGE script: registers a faucet in the bridge's faucet registry and -#! token registry. +#! Agglayer Bridge CONFIG_AGG_BRIDGE script: registers a faucet in the bridge's faucet registry, +#! token registry, and faucet metadata map. #! #! This note can only be consumed by the Agglayer Bridge account that is targeted by the note #! attachment, and only if the note was sent by the bridge admin. -#! Upon consumption, it registers the faucet ID and origin token address mapping in the bridge. +#! Upon consumption, it registers the faucet ID, origin token address mapping, scale factor, +#! origin network, is_native flag, and metadata hash in the bridge. +#! +#! The registration is split into two calls due to the 16-element stack limit: +#! 1. register_faucet: stores address, scale, origin_network, is_native, and registry entries +#! 2. store_faucet_metadata_hash: stores the metadata hash #! #! Requires that the account exposes: #! - agglayer::bridge_config::register_faucet procedure. +#! - agglayer::bridge_config::store_faucet_metadata_hash procedure. #! #! Inputs: [ARGS, pad(12)] #! Outputs: [pad(16)] #! -#! NoteStorage layout (7 felts total): -#! - origin_token_addr_0 [0] : 1 felt -#! - origin_token_addr_1 [1] : 1 felt -#! - origin_token_addr_2 [2] : 1 felt -#! - origin_token_addr_3 [3] : 1 felt -#! - origin_token_addr_4 [4] : 1 felt -#! - faucet_id_suffix [5] : 1 felt -#! - faucet_id_prefix [6] : 1 felt -#! -#! Where: -#! - faucet_id_suffix: Suffix felt of the faucet account ID to register. -#! - faucet_id_prefix: Prefix felt of the faucet account ID to register. +#! NoteStorage layout (18 felts total): +#! - origin_token_addr_0 [0] : 1 felt +#! - origin_token_addr_1 [1] : 1 felt +#! - origin_token_addr_2 [2] : 1 felt +#! - origin_token_addr_3 [3] : 1 felt +#! - origin_token_addr_4 [4] : 1 felt +#! - faucet_id_suffix [5] : 1 felt +#! - faucet_id_prefix [6] : 1 felt +#! - scale [7] : 1 felt +#! - origin_network [8] : 1 felt +#! - is_native [9] : 1 felt +#! - metadata_hash_lo [10] : 4 felts +#! - metadata_hash_hi [14] : 4 felts #! #! Panics if: #! - The note attachment target account does not match the consuming bridge account. -#! - The note does not contain exactly 7 storage items. -#! - The account does not expose the register_faucet procedure. +#! - The note does not contain exactly 18 storage items. +#! - The account does not expose the register_faucet or store_faucet_metadata_hash procedures. begin dropw # => [pad(16)] @@ -64,26 +102,69 @@ begin push.CONFIG_AGG_BRIDGE_NUM_STORAGE_ITEMS assert_eq.err=ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS drop # => [pad(16)] - # Load origin_token_addr(5) and faucet_id from memory - # register_faucet expects: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] + # --- Call 1: register_faucet --- + # Expects: [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(6)] - # Load origin_token_addr_4, faucet_id_suffix, and faucet_id_prefix onto the sack. Notice that we - # can use `mem_loadw_le` here: that allows us to reuse the existing zeros on the stack, and - # since note memory was not initialized, fourth element on the stack will be equal ZERO, which - # is what we want. - mem_loadw_le.ORIGIN_TOKEN_ADDR_4 - # => [addr4, faucet_id_suffix, faucet_id_prefix, pad(13)] + # Build the stack bottom-up: load deepest elements first, then push shallower ones on top. + # This avoids complex rearrangement since each mem_load pushes onto the top. + mem_load.IS_NATIVE + mem_load.ORIGIN_NETWORK + mem_load.SCALE + mem_load.FAUCET_ID_PREFIX + mem_load.FAUCET_ID_SUFFIX + mem_load.ORIGIN_TOKEN_ADDR_4 + # => [addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(16)] - # Load remaining origin_token_addr_[0..3] onto the stack - padw mem_loadw_le.ORIGIN_TOKEN_ADDR_0 - # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, pad(13)] + # Load addr0..addr3 on top in reverse order so addr0 ends up on top + mem_load.ORIGIN_TOKEN_ADDR_3 + mem_load.ORIGIN_TOKEN_ADDR_2 + mem_load.ORIGIN_TOKEN_ADDR_1 + mem_load.ORIGIN_TOKEN_ADDR_0 + # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(16)] + + # Pad the call input region to 16 (need 6 more zeros) + padw push.0.0 + # => [0, 0, 0, 0, 0, 0, addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(16)] + + # Move 6 pad zeros to the end of the call input region + movdn.15 movdn.15 movdn.15 movdn.15 movdn.15 movdn.15 + # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, scale, origin_network, is_native, pad(22)] - # Register the faucet in the bridge - # => [addr0, addr1, addr2, addr3, addr4, faucet_id_suffix, faucet_id_prefix, pad(9), pad(4)] - call.bridge_config::register_faucet - # => [pad(16), pad(4)] + # => [pad(32)] - dropw + # --- Call 2: store_faucet_metadata_hash --- + # Expects: [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(6)] + + # Load MH_HI from memory addresses 14..17 (individual loads — not word-aligned) + mem_load.METADATA_HASH_HI_3 mem_load.METADATA_HASH_HI_2 + mem_load.METADATA_HASH_HI_1 mem_load.METADATA_HASH_HI_0 + # => [MH_HI, pad(32)] + + # Load MH_LO from memory addresses 10..13 (individual loads — not word-aligned) + mem_load.METADATA_HASH_LO_3 mem_load.METADATA_HASH_LO_2 + mem_load.METADATA_HASH_LO_1 mem_load.METADATA_HASH_LO_0 + # => [MH_LO, MH_HI, pad(32)] + + # Load faucet_id_prefix, faucet_id_suffix + mem_load.FAUCET_ID_PREFIX + mem_load.FAUCET_ID_SUFFIX + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(32)] + + # Pad the call input region to 16 (need 6 more zeros) + padw push.0.0 + # => [0, 0, 0, 0, 0, 0, faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(32)] + + # Move 6 pad zeros to the end of the call input region + movdn.15 movdn.15 movdn.15 movdn.15 movdn.15 movdn.15 + # => [faucet_id_suffix, faucet_id_prefix, MH_LO, MH_HI, pad(38)] + + call.bridge_config::store_faucet_metadata_hash + # => [pad(48)] + + # Drop 32 to bring sdepth back to the 16-minimum. + repeat.8 + dropw + end # => [pad(16)] end diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 2ec155232b..3a41dc6500 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -70,6 +70,10 @@ static TOKEN_REGISTRY_MAP_SLOT_NAME: LazyLock = LazyLock::new(| StorageSlotName::new("agglayer::bridge::token_registry_map") .expect("token registry map storage slot name should be valid") }); +static FAUCET_METADATA_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::faucet_metadata_map") + .expect("faucet metadata map storage slot name should be valid") +}); // bridge in // ------------------------------------------------------------------------------------------------ @@ -125,6 +129,9 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// - [`Self::ger_map_slot_name`]: Stores the GERs. /// - [`Self::faucet_registry_map_slot_name`]: Stores the faucet registry map. /// - [`Self::token_registry_map_slot_name`]: Stores the token address → faucet ID map. +/// - [`Self::faucet_metadata_map_slot_name`]: Stores conversion metadata (origin address, origin +/// network, scale, metadata hash) for all registered faucets, keyed by sub-key scheme based on +/// faucet ID. /// - [`Self::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (RPO(leaf_index, /// source_bridge_network) → \[1, 0, 0, 0\]). /// - [`Self::cgi_chain_hash_lo_slot_name`]: Stores the lower 128 bits of the CGI chain hash. @@ -186,6 +193,14 @@ impl AggLayerBridge { &TOKEN_REGISTRY_MAP_SLOT_NAME } + /// Storage slot name for the faucet metadata map. + /// + /// This map stores conversion metadata (origin address, origin network, scale, metadata hash) + /// for all registered faucets, keyed by sub-key scheme based on faucet ID. + pub fn faucet_metadata_map_slot_name() -> &'static StorageSlotName { + &FAUCET_METADATA_MAP_SLOT_NAME + } + // --- bridge in -------- /// Storage slot name for the CLAIM note nullifiers map. @@ -412,6 +427,7 @@ impl AggLayerBridge { &*LET_NUM_LEAVES_SLOT_NAME, &*FAUCET_REGISTRY_MAP_SLOT_NAME, &*TOKEN_REGISTRY_MAP_SLOT_NAME, + &*FAUCET_METADATA_MAP_SLOT_NAME, &*BRIDGE_ADMIN_ID_SLOT_NAME, &*GER_MANAGER_ID_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, @@ -434,6 +450,7 @@ impl From for AccountComponent { StorageSlot::with_value(LET_NUM_LEAVES_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_empty_map(FAUCET_REGISTRY_MAP_SLOT_NAME.clone()), StorageSlot::with_empty_map(TOKEN_REGISTRY_MAP_SLOT_NAME.clone()), + StorageSlot::with_empty_map(FAUCET_METADATA_MAP_SLOT_NAME.clone()), StorageSlot::with_value(BRIDGE_ADMIN_ID_SLOT_NAME.clone(), bridge_admin_word), StorageSlot::with_value(GER_MANAGER_ID_SLOT_NAME.clone(), ger_manager_word), StorageSlot::with_value(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), diff --git a/crates/miden-agglayer/src/config_note.rs b/crates/miden-agglayer/src/config_note.rs index efdd9f6663..035eaf6ba9 100644 --- a/crates/miden-agglayer/src/config_note.rs +++ b/crates/miden-agglayer/src/config_note.rs @@ -28,7 +28,7 @@ use miden_protocol::vm::Program; use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; use miden_utils_sync::LazyLock; -use crate::EthAddress; +use crate::{EthAddress, MetadataHash}; // NOTE SCRIPT // ================================================================================================ @@ -42,13 +42,56 @@ static CONFIG_AGG_BRIDGE_SCRIPT: LazyLock = LazyLock::new(|| { NoteScript::new(program) }); +// CONVERSION METADATA +// ================================================================================================ + +/// The conversion metadata registered on the bridge for a single faucet. +/// +/// Encapsulates the origin-chain identity and bridge-side policy of a faucet: the EVM token +/// address, network id, decimal scale, whether the faucet is Miden-native (lock/unlock) or +/// bridge-owned (burn/mint), and the keccak256 metadata hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversionMetadata { + /// Account ID of the faucet being registered. + pub faucet_account_id: AccountId, + /// Origin EVM token address the faucet wraps. + pub origin_token_address: EthAddress, + /// Decimal scaling factor between the origin-chain unit and the Miden-side unit + /// (e.g. 0 for USDC, 8 for ETH). + pub scale: u8, + /// Origin network / chain ID the token lives on. + pub origin_network: u32, + /// `true` for Miden-native faucets (bridge-in unlocks from the bridge vault, bridge-out + /// locks into it); `false` for bridge-owned faucets (bridge-in mints via the faucet, + /// bridge-out burns via the faucet). + pub is_native: bool, + /// keccak256 hash of the ABI-encoded token metadata (`name`, `symbol`, `decimals`). + pub metadata_hash: MetadataHash, +} + +impl ConversionMetadata { + /// Serializes the metadata to the 18-felt layout consumed by `CONFIG_AGG_BRIDGE`. + pub fn to_elements(&self) -> Vec { + let mut v = Vec::with_capacity(ConfigAggBridgeNote::NUM_STORAGE_ITEMS); + v.extend(self.origin_token_address.to_elements()); + v.push(self.faucet_account_id.suffix()); + v.push(self.faucet_account_id.prefix().as_felt()); + v.push(Felt::from(self.scale)); + v.push(Felt::from(self.origin_network)); + v.push(Felt::from(u8::from(self.is_native))); + v.extend(self.metadata_hash.to_elements()); + v + } +} + // CONFIG_AGG_BRIDGE NOTE // ================================================================================================ /// CONFIG_AGG_BRIDGE note. /// -/// This note is used to register a faucet in the bridge's faucet and token registries. -/// It carries the origin token address and faucet account ID, and is always public. +/// This note is used to register a faucet in the bridge's faucet and token registries, +/// and to store full conversion metadata (origin address, origin network, scale, metadata hash) +/// in the bridge's faucet metadata map. pub struct ConfigAggBridgeNote; impl ConfigAggBridgeNote { @@ -56,8 +99,17 @@ impl ConfigAggBridgeNote { // -------------------------------------------------------------------------------------------- /// Expected number of storage items for a CONFIG_AGG_BRIDGE note. - /// Layout: [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix] - pub const NUM_STORAGE_ITEMS: usize = 7; + /// + /// Layout (18 felts): + /// - `[0..4]` origin_token_addr (5 felts) + /// - `[5]` faucet_id_suffix + /// - `[6]` faucet_id_prefix + /// - `[7]` scale + /// - `[8]` origin_network + /// - `[9]` is_native (0 or 1) + /// - `[10..13]` METADATA_HASH_LO (4 felts) + /// - `[14..17]` METADATA_HASH_HI (4 felts) + pub const NUM_STORAGE_ITEMS: usize = 18; // PUBLIC ACCESSORS // -------------------------------------------------------------------------------------------- @@ -77,33 +129,28 @@ impl ConfigAggBridgeNote { /// Creates a CONFIG_AGG_BRIDGE note to register a faucet in the bridge's registry. /// - /// The note storage contains 7 felts: - /// - `origin_token_addr[0..5]`: The 5 u32 felts of the origin EVM token address - /// - `faucet_id_suffix`: The suffix of the faucet account ID - /// - `faucet_id_prefix`: The prefix of the faucet account ID - /// /// # Parameters - /// - `faucet_account_id`: The account ID of the faucet to register - /// - `origin_token_address`: The origin EVM token address for the token registry - /// - `sender_account_id`: The account ID of the note creator - /// - `target_account_id`: The bridge account ID that will consume this note - /// - `rng`: Random number generator for creating the note serial number + /// - `metadata`: The conversion metadata to register for the faucet. + /// - `sender_account_id`: The account ID of the note creator. + /// - `target_account_id`: The bridge account ID that will consume this note. + /// - `rng`: Random number generator for creating the note serial number. /// /// # Errors /// Returns an error if note creation fails. pub fn create( - faucet_account_id: AccountId, - origin_token_address: &EthAddress, + metadata: ConversionMetadata, sender_account_id: AccountId, target_account_id: AccountId, rng: &mut R, ) -> Result { - // Create note storage with 7 felts: [origin_token_addr(5), faucet_id_suffix, - // faucet_id_prefix] - let addr_elements = origin_token_address.to_elements(); - let mut storage_values: Vec = addr_elements; - storage_values.push(faucet_account_id.suffix()); - storage_values.push(faucet_account_id.prefix().as_felt()); + let storage_values = metadata.to_elements(); + + debug_assert_eq!( + storage_values.len(), + Self::NUM_STORAGE_ITEMS, + "CONFIG_AGG_BRIDGE storage must have exactly {} felts", + Self::NUM_STORAGE_ITEMS + ); let note_storage = NoteStorage::new(storage_values)?; @@ -116,12 +163,54 @@ impl ConfigAggBridgeNote { NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) .map_err(|e| NoteError::other(e.to_string()))?, ); - let metadata = + let note_metadata = NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); // CONFIG_AGG_BRIDGE notes don't carry assets let assets = NoteAssets::new(vec![])?; - Ok(Note::new(assets, metadata, recipient)) + Ok(Note::new(assets, note_metadata, recipient)) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; + + use super::*; + + /// Locks in the 18-felt wire layout of `CONFIG_AGG_BRIDGE` note storage. Any reordering in + /// `to_elements` would silently desync from the indices the MASM `CONFIG_AGG_BRIDGE` script + /// reads from (`ORIGIN_TOKEN_ADDR_0..4`, `FAUCET_ID_SUFFIX=5`, ... `METADATA_HASH_HI_3=17`). + #[test] + fn to_elements_layout_matches_masm_storage_indices() { + let faucet = AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET) + .expect("valid faucet account id"); + let origin_token_address = + EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let metadata_hash = MetadataHash::from_token_info("USD Coin", "USDC", 6); + + let metadata = ConversionMetadata { + faucet_account_id: faucet, + origin_token_address, + scale: 6, + origin_network: 42, + is_native: true, + metadata_hash, + }; + + let elements = metadata.to_elements(); + + assert_eq!(elements.len(), ConfigAggBridgeNote::NUM_STORAGE_ITEMS); + assert_eq!(&elements[0..5], origin_token_address.to_elements().as_slice()); + assert_eq!(elements[5], faucet.suffix()); + assert_eq!(elements[6], faucet.prefix().as_felt()); + assert_eq!(elements[7], Felt::from(6_u8)); + assert_eq!(elements[8], Felt::from(42_u32)); + assert_eq!(elements[9], Felt::from(1_u8)); + assert_eq!(&elements[10..18], metadata_hash.to_elements().as_slice()); } } diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs index 045c3226d6..0bbd6b18da 100644 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ b/crates/miden-agglayer/src/errors/agglayer.rs @@ -28,8 +28,8 @@ pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str /// Error Message: "CONFIG_AGG_BRIDGE note attachment target account does not match consuming account" pub const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE note attachment target account does not match consuming account"); -/// Error Message: "CONFIG_AGG_BRIDGE expects exactly 7 note storage items" -pub const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE expects exactly 7 note storage items"); +/// Error Message: "CONFIG_AGG_BRIDGE expects exactly 18 note storage items" +pub const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE expects exactly 18 note storage items"); /// Error Message: "faucet is not registered in the bridge's faucet registry" pub const ERR_FAUCET_NOT_REGISTERED: MasmError = MasmError::from_static_str("faucet is not registered in the bridge's faucet registry"); diff --git a/crates/miden-agglayer/src/faucet.rs b/crates/miden-agglayer/src/faucet.rs index 306a8acc99..c4c37a7629 100644 --- a/crates/miden-agglayer/src/faucet.rs +++ b/crates/miden-agglayer/src/faucet.rs @@ -18,7 +18,6 @@ use miden_protocol::errors::AccountIdError; use miden_standards::account::access::Ownable2Step; use miden_standards::account::faucets::{FungibleFaucetError, TokenMetadata}; use miden_standards::account::mint_policies::OwnerControlled; -use miden_utils_sync::LazyLock; use thiserror::Error; use super::agglayer_faucet_component_library; @@ -50,40 +49,16 @@ include!(concat!(env!("OUT_DIR"), "/agglayer_constants.rs")); // AGGLAYER FAUCET STRUCT // ================================================================================================ -static CONVERSION_INFO_1_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::conversion_info_1") - .expect("conversion info 1 storage slot name should be valid") -}); -static CONVERSION_INFO_2_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::conversion_info_2") - .expect("conversion info 2 storage slot name should be valid") -}); -static METADATA_HASH_LO_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::metadata_hash_lo") - .expect("metadata hash lo storage slot name should be valid") -}); -static METADATA_HASH_HI_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("agglayer::faucet::metadata_hash_hi") - .expect("metadata hash hi storage slot name should be valid") -}); /// An [`AccountComponent`] implementing the AggLayer Faucet. /// -/// It reexports the procedures from `agglayer::faucet`. When linking against this -/// component, the `agglayer` library must be available to the assembler. -/// The procedures of this component are: -/// - `distribute`, which mints assets and creates output notes (with owner verification). -/// - `asset_to_origin_asset`, which converts an asset to the origin asset (used in FPI from -/// bridge). -/// - `burn`, which burns an asset. +/// It re-exports `mint_and_send` (network fungible faucet) and `burn` (basic fungible faucet) +/// from the agglayer library. Conversion metadata (origin address, origin network, scale, +/// metadata hash) is held by the bridge, not the faucet — see +/// [`AggLayerBridge`] and the `faucet_metadata_map` populated on registration. /// /// ## Storage Layout /// /// - [`Self::metadata_slot`]: Stores [`TokenMetadata`]. -/// - [`Self::conversion_info_1_slot`]: Stores the first 4 felts of the origin token address. -/// - [`Self::conversion_info_2_slot`]: Stores the remaining 5th felt of the origin token address + -/// origin network + scale. -/// - [`Self::metadata_hash_lo_slot`]: Stores the first 4 u32 felts of the metadata hash. -/// - [`Self::metadata_hash_hi_slot`]: Stores the last 4 u32 felts of the metadata hash. /// /// ## Required Companion Components /// @@ -95,10 +70,6 @@ static METADATA_HASH_HI_SLOT_NAME: LazyLock = LazyLock::new(|| #[derive(Debug, Clone)] pub struct AggLayerFaucet { metadata: TokenMetadata, - origin_token_address: EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, } impl AggLayerFaucet { @@ -112,25 +83,14 @@ impl AggLayerFaucet { /// - The decimals parameter exceeds maximum value of [`TokenMetadata::MAX_DECIMALS`]. /// - The max supply exceeds maximum possible amount for a fungible asset. /// - The token supply exceeds the max supply. - #[allow(clippy::too_many_arguments)] pub fn new( symbol: TokenSymbol, decimals: u8, max_supply: Felt, token_supply: Felt, - origin_token_address: EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Result { let metadata = TokenMetadata::with_supply(symbol, decimals, max_supply, token_supply)?; - Ok(Self { - metadata, - origin_token_address, - origin_network, - scale, - metadata_hash, - }) + Ok(Self { metadata }) } /// Sets the token supply for an existing faucet (e.g. for testing scenarios). @@ -150,25 +110,6 @@ impl AggLayerFaucet { TokenMetadata::metadata_slot() } - /// Storage slot name for the first 4 felts of the origin token address. - pub fn conversion_info_1_slot() -> &'static StorageSlotName { - &CONVERSION_INFO_1_SLOT_NAME - } - - /// Storage slot name for the 5th felt of the origin token address, origin network, and scale. - pub fn conversion_info_2_slot() -> &'static StorageSlotName { - &CONVERSION_INFO_2_SLOT_NAME - } - - /// Storage slot name for the first 4 u32 felts of the metadata hash. - pub fn metadata_hash_lo_slot() -> &'static StorageSlotName { - &METADATA_HASH_LO_SLOT_NAME - } - - /// Storage slot name for the last 4 u32 felts of the metadata hash. - pub fn metadata_hash_hi_slot() -> &'static StorageSlotName { - &METADATA_HASH_HI_SLOT_NAME - } /// Storage slot name for the owner account ID (bridge), provided by the /// [`Ownable2Step`] companion component. pub fn owner_config_slot() -> &'static StorageSlotName { @@ -208,90 +149,6 @@ impl AggLayerFaucet { ownership.owner().ok_or(AgglayerFaucetError::OwnershipRenounced) } - /// Extracts the origin token address from the corresponding storage slot of the provided - /// account. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn origin_token_address( - faucet_account: &Account, - ) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let conversion_info_1 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_1_SLOT_NAME) - .expect("should be able to read the first conversion info slot"); - - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - let addr_bytes_vec = conversion_info_1 - .iter() - .chain([&conversion_info_2[0]]) - .flat_map(|felt| { - u32::try_from(felt.as_canonical_u64()) - .expect("Felt value does not fit into u32") - .to_le_bytes() - }) - .collect::>(); - - Ok(EthAddress::new( - addr_bytes_vec - .try_into() - .expect("origin token addr vector should consist of exactly 20 bytes"), - )) - } - - /// Extracts the origin network ID in form of the u32 from the corresponding storage slot of the - /// provided account. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn origin_network(faucet_account: &Account) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - Ok(conversion_info_2[1] - .as_canonical_u64() - .try_into() - .expect("origin network ID should fit into u32")) - } - - /// Extracts the scaling factor in form of the u8 from the corresponding storage slot of the - /// provided account. - /// - /// # Errors - /// - /// Returns an error if: - /// - the provided account is not an [`AggLayerFaucet`] account. - pub fn scale(faucet_account: &Account) -> Result { - // check that the provided account is a faucet account - Self::assert_faucet_account(faucet_account)?; - - let conversion_info_2 = faucet_account - .storage() - .get_item(&CONVERSION_INFO_2_SLOT_NAME) - .expect("should be able to read the second conversion info slot"); - - Ok(conversion_info_2[2] - .as_canonical_u64() - .try_into() - .expect("scaling factor should fit into u8")) - } - // HELPER FUNCTIONS // -------------------------------------------------------------------------------------------- @@ -357,10 +214,6 @@ impl AggLayerFaucet { /// Returns a vector of all [`AggLayerFaucet`] storage slot names. fn slot_names() -> Vec<&'static StorageSlotName> { vec![ - &*CONVERSION_INFO_1_SLOT_NAME, - &*CONVERSION_INFO_2_SLOT_NAME, - &*METADATA_HASH_LO_SLOT_NAME, - &*METADATA_HASH_HI_SLOT_NAME, TokenMetadata::metadata_slot(), Ownable2Step::slot_name(), OwnerControlled::active_policy_proc_root_slot(), @@ -373,35 +226,7 @@ impl AggLayerFaucet { impl From for AccountComponent { fn from(faucet: AggLayerFaucet) -> Self { let metadata_slot = StorageSlot::from(faucet.metadata); - - let (conversion_slot1_word, conversion_slot2_word) = agglayer_faucet_conversion_slots( - &faucet.origin_token_address, - faucet.origin_network, - faucet.scale, - ); - let conversion_slot1 = - StorageSlot::with_value(CONVERSION_INFO_1_SLOT_NAME.clone(), conversion_slot1_word); - let conversion_slot2 = - StorageSlot::with_value(CONVERSION_INFO_2_SLOT_NAME.clone(), conversion_slot2_word); - - let hash_elements = faucet.metadata_hash.to_elements(); - let metadata_hash_lo = StorageSlot::with_value( - METADATA_HASH_LO_SLOT_NAME.clone(), - Word::new([hash_elements[0], hash_elements[1], hash_elements[2], hash_elements[3]]), - ); - let metadata_hash_hi = StorageSlot::with_value( - METADATA_HASH_HI_SLOT_NAME.clone(), - Word::new([hash_elements[4], hash_elements[5], hash_elements[6], hash_elements[7]]), - ); - - let agglayer_storage_slots = vec![ - metadata_slot, - conversion_slot1, - conversion_slot2, - metadata_hash_lo, - metadata_hash_hi, - ]; - agglayer_faucet_component(agglayer_storage_slots) + agglayer_faucet_component(vec![metadata_slot]) } } @@ -427,51 +252,14 @@ pub enum AgglayerFaucetError { OwnershipRenounced, } -// FAUCET CONVERSION STORAGE HELPERS -// ================================================================================================ - -/// Builds the two storage slot values for faucet conversion metadata. -/// -/// The conversion metadata is stored in two value storage slots: -/// - Slot 1 (`agglayer::faucet::conversion_info_1`): `[addr0, addr1, addr2, addr3]` — first 4 felts -/// of the origin token address (5 × u32 limbs). -/// - Slot 2 (`agglayer::faucet::conversion_info_2`): `[addr4, origin_network, scale, 0]` — -/// remaining address felt + origin network + scale factor. -/// -/// # Parameters -/// - `origin_token_address`: The EVM token address in Ethereum format -/// - `origin_network`: The origin network/chain ID -/// - `scale`: The decimal scaling factor (exponent for 10^scale) -/// -/// # Returns -/// A tuple of two `Word` values representing the two storage slot contents. -fn agglayer_faucet_conversion_slots( - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, -) -> (Word, Word) { - let addr_elements = origin_token_address.to_elements(); - - let slot1 = Word::new([addr_elements[0], addr_elements[1], addr_elements[2], addr_elements[3]]); - - let slot2 = - Word::new([addr_elements[4], Felt::from(origin_network), Felt::from(scale), Felt::ZERO]); - - (slot1, slot2) -} - // HELPER FUNCTIONS // ================================================================================================ /// Creates an Agglayer Faucet component with the specified storage slots. -/// -/// This component combines network faucet functionality with bridge validation -/// via Foreign Procedure Invocation (FPI). It provides a "claim" procedure that -/// validates CLAIM notes against a bridge MMR account before minting assets. fn agglayer_faucet_component(storage_slots: Vec) -> AccountComponent { let library = agglayer_faucet_component_library(); let metadata = AccountComponentMetadata::new("agglayer::faucet", [AccountType::FungibleFaucet]) - .with_description("AggLayer faucet component with bridge validation"); + .with_description("AggLayer faucet component"); AccountComponent::new(library, storage_slots, metadata).expect( "agglayer_faucet component should satisfy the requirements of a valid account component", diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index dde029da27..91f9119678 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -45,7 +45,7 @@ pub use claim_note::{ SmtNode, create_claim_note, }; -pub use config_note::ConfigAggBridgeNote; +pub use config_note::{ConfigAggBridgeNote, ConversionMetadata}; #[cfg(any(test, feature = "testing"))] pub use eth_types::GlobalIndexExt; pub use eth_types::{ @@ -114,51 +114,30 @@ fn agglayer_faucet_component_library() -> Library { /// Creates an agglayer faucet account component with the specified configuration. /// -/// This function creates all the necessary storage slots for an agglayer faucet: -/// - Network faucet metadata slot (token_supply, max_supply, decimals, token_symbol) -/// - Conversion info slot 1: first 4 felts of origin token address -/// - Conversion info slot 2: 5th address felt + origin network + scale -/// - Owner config slot: bridge account ID for MINT note authorization +/// The faucet holds only token metadata; conversion metadata (origin address, origin network, +/// scale, metadata hash) lives on the bridge and is populated at registration time. /// /// # Parameters /// - `token_symbol`: The symbol for the fungible token (e.g., "AGG") /// - `decimals`: Number of decimal places for the token /// - `max_supply`: Maximum supply of the token /// - `token_supply`: Initial outstanding token supply (0 for new faucets) -/// - `bridge_account_id`: The account ID of the bridge account for validation -/// - `origin_token_address`: The EVM origin token address -/// - `origin_network`: The origin network/chain ID -/// - `scale`: The decimal scaling factor (exponent for 10^scale) /// /// # Returns /// Returns an [`AccountComponent`] configured for agglayer faucet operations. /// /// # Panics /// Panics if the token symbol is invalid or metadata validation fails. -#[allow(clippy::too_many_arguments)] fn create_agglayer_faucet_component( token_symbol: &str, decimals: u8, max_supply: Felt, token_supply: Felt, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> AccountComponent { let symbol = TokenSymbol::new(token_symbol).expect("token symbol should be valid"); - AggLayerFaucet::new( - symbol, - decimals, - max_supply, - token_supply, - *origin_token_address, - origin_network, - scale, - metadata_hash, - ) - .expect("agglayer faucet metadata should be valid") - .into() + AggLayerFaucet::new(symbol, decimals, max_supply, token_supply) + .expect("agglayer faucet metadata should be valid") + .into() } /// Creates a complete bridge account builder with the standard configuration. @@ -207,11 +186,10 @@ pub fn create_existing_bridge_account( /// Creates a complete agglayer faucet account builder with the specified configuration. /// /// The builder includes: -/// - The `AggLayerFaucet` component (conversion metadata + token metadata). +/// - The `AggLayerFaucet` component (token metadata only; conversion metadata lives on the bridge). /// - The `Ownable2Step` component (bridge account ID as owner for mint authorization). /// - The `OwnerControlled` component (mint policy management required by /// `network_fungible::mint_and_send`). -#[allow(clippy::too_many_arguments)] fn create_agglayer_faucet_builder( seed: Word, token_symbol: &str, @@ -219,21 +197,9 @@ fn create_agglayer_faucet_builder( max_supply: Felt, token_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> AccountBuilder { - let agglayer_component = create_agglayer_faucet_component( - token_symbol, - decimals, - max_supply, - token_supply, - origin_token_address, - origin_network, - scale, - metadata_hash, - ); + let agglayer_component = + create_agglayer_faucet_component(token_symbol, decimals, max_supply, token_supply); Account::builder(seed.into()) .account_type(AccountType::FungibleFaucet) @@ -246,17 +212,12 @@ fn create_agglayer_faucet_builder( /// Creates a new agglayer faucet account with the specified configuration. /// /// This creates a new account suitable for production use. -#[allow(clippy::too_many_arguments)] pub fn create_agglayer_faucet( seed: Word, token_symbol: &str, decimals: u8, max_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Account { create_agglayer_faucet_builder( seed, @@ -265,10 +226,6 @@ pub fn create_agglayer_faucet( max_supply, Felt::ZERO, bridge_account_id, - origin_token_address, - origin_network, - scale, - metadata_hash, ) .with_auth_component(AccountComponent::from(NoAuth)) .build() @@ -279,7 +236,6 @@ pub fn create_agglayer_faucet( /// /// This creates an existing account suitable for testing scenarios. #[cfg(any(feature = "testing", test))] -#[allow(clippy::too_many_arguments)] pub fn create_existing_agglayer_faucet( seed: Word, token_symbol: &str, @@ -287,10 +243,6 @@ pub fn create_existing_agglayer_faucet( max_supply: Felt, token_supply: Felt, bridge_account_id: AccountId, - origin_token_address: &EthAddress, - origin_network: u32, - scale: u8, - metadata_hash: MetadataHash, ) -> Account { create_agglayer_faucet_builder( seed, @@ -299,10 +251,6 @@ pub fn create_existing_agglayer_faucet( max_supply, token_supply, bridge_account_id, - origin_token_address, - origin_network, - scale, - metadata_hash, ) .with_auth_component(AccountComponent::from(NoAuth)) .build_existing() diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 9e45a965ab..d1aae3641b 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -6,8 +6,11 @@ use alloc::string::String; use anyhow::Context; use miden_agglayer::errors::ERR_CLAIM_ALREADY_SPENT; use miden_agglayer::{ + B2AggNote, ClaimNoteStorage, ConfigAggBridgeNote, + ConversionMetadata, + EthAddress, EthEmbeddedAccountId, ExitRoot, LeafValue, @@ -19,14 +22,21 @@ use miden_agglayer::{ create_existing_bridge_account, }; use miden_protocol::Felt; -use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{ + Account, + AccountId, + AccountIdVersion, + AccountStorageMode, + AccountType, +}; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::crypto::SequentialCommit; use miden_protocol::crypto::rand::FeltRng; -use miden_protocol::note::NoteType; +use miden_protocol::note::{NoteAssets, NoteType}; use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE; use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::mint_policies::OwnerControlledInitConfig; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::note::P2idNote; @@ -171,10 +181,6 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a max_supply, Felt::ZERO, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - leaf_data.metadata_hash, ); builder.add_account(agglayer_faucet.clone())?; @@ -227,6 +233,7 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a .scale_to_token_amount(scale as u32) .expect("amount should scale successfully"); + let metadata_hash = leaf_data.metadata_hash; let claim_inputs = ClaimNoteStorage { proof_data, leaf_data, @@ -246,8 +253,14 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a // CREATE CONFIG_AGG_BRIDGE NOTE (registers faucet + token address in bridge) // -------------------------------------------------------------------------------------------- let config_note = ConfigAggBridgeNote::create( - agglayer_faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -468,10 +481,6 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { max_supply, Felt::ZERO, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - leaf_data.metadata_hash, ); builder.add_account(agglayer_faucet.clone())?; @@ -513,8 +522,14 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { // CREATE CONFIG_AGG_BRIDGE NOTE let config_note = ConfigAggBridgeNote::create( - agglayer_faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: agglayer_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash: leaf_data.metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -576,6 +591,487 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { Ok(()) } +/// Tests the bridge-in unlock path for Miden-native faucets. +/// +/// When a faucet is registered with `is_native = true`, a valid CLAIM note does NOT go through +/// the MINT→faucet→P2ID flow. Instead, the bridge removes the asset from its own vault and +/// emits a P2ID note directly to the recipient. +/// +/// Flow: +/// 1. Register a native (non-bridge-owned) faucet with `is_native = true` using the +/// origin_token_address and metadata_hash from a simulated L1→Miden claim vector. +/// 2. Seed the bridge vault by running one lock transaction (bridge-out of a B2AGG note carrying +/// `miden_claim_amount` of the native asset). +/// 3. Store a GER that covers the claim's Merkle proof. +/// 4. Execute the CLAIM against the bridge — the `claim` proc dispatches into `unlock_and_send` +/// because the faucet is registered with `is_native = true`. +/// 5. Assert that exactly one output P2ID note is produced, its asset matches what was locked, the +/// bridge vault is drained to 0, and the destination can consume the P2ID. +#[tokio::test] +async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { + let data_source = ClaimDataSource::SimulatedL1ToMiden; + let mut builder = MockChain::builder(); + + // Bridge admin / GER manager / bridge account. + 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_seed = builder.rng_mut().draw_word(); + let mut bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + // Claim data: leaf data's origin_token_address + metadata_hash must match the registration + // below so the bridge's token-registry lookup resolves to the native faucet. + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let metadata_hash = leaf_data.metadata_hash; + let scale = 10u8; + + // The amount the claim will attempt to unlock: scaled from the leaf's U256 amount. + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + let miden_claim_amount_u64 = miden_claim_amount.as_canonical_u64(); + + // Native faucet: use the network-faucet pattern (bridge is not the owner). + let faucet_owner_account_id = AccountId::dummy( + [3; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + miden_claim_amount_u64.saturating_mul(4), + faucet_owner_account_id, + // Seed enough native supply for the lock step's sender to bundle into the B2AGG note. + Some(miden_claim_amount_u64.saturating_mul(2)), + OwnerControlledInitConfig::OwnerOnly, + )?; + + // Destination of the claim (derived from leaf data's destination_address). + let destination_account_id = EthEmbeddedAccountId::try_from(leaf_data.destination_address) + .expect("destination address is not an embedded Miden AccountId") + .into_account_id(); + let destination_account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, IncrNonceAuthComponent); + assert_eq!( + destination_account.id(), + destination_account_id, + "mock destination account ID must match the destination_account_id from the claim data" + ); + builder.add_account(destination_account.clone())?; + + // Sender of the CLAIM note (any wallet — just a note creator). + let claim_sender = { + let account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists)? + }; + + // Sender of the B2AGG note used to seed the bridge vault with the native asset. + let lock_sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Register the native faucet with is_native = true. + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // B2AGG note that will seed the bridge's vault with `miden_claim_amount_u64` of native asset. + let bridge_asset: Asset = + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64).unwrap().into(); + let b2agg_destination_address = + EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + let b2agg_note = B2AggNote::create( + 1u32, + b2agg_destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + lock_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + // CLAIM note targeting the bridge. + let serial_num = proof_data.to_commitment(); + let claim_inputs = ClaimNoteStorage { + proof_data, + leaf_data, + miden_claim_amount, + }; + let claim_note = + create_claim_note(claim_inputs, bridge_account.id(), claim_sender.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // GER for the claim's Merkle proof. + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: CONFIG — registers native faucet with is_native = true. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: LOCK — bridge consumes the B2AGG note, asset goes into bridge vault. + let lock_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + assert_eq!( + lock_executed.output_notes().num_notes(), + 0, + "Lock transaction should not emit any output note" + ); + bridge_account.apply_delta(lock_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(native_faucet.id())?, + miden_claim_amount_u64, + "Bridge vault should hold the locked native asset before the claim" + ); + mock_chain.add_pending_executed_transaction(&lock_executed)?; + mock_chain.prove_next_block()?; + + // TX2: UPDATE_GER. + let update_ger_executed = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(update_ger_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: CLAIM — bridge validates the proof, hits the is_native branch, unlocks and emits P2ID. + let claim_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[], &[claim_note])? + .build()? + .execute() + .await + .context("CLAIM execution against bridge failed")?; + + // Exactly one output note — a PUBLIC P2ID carrying the native asset, sent by the bridge. + assert_eq!( + claim_executed.output_notes().num_notes(), + 1, + "Unlock path should emit exactly one P2ID output note" + ); + let output_note = match claim_executed.output_notes().get_note(0) { + RawOutputNote::Full(note) => note.clone(), + other => panic!("expected Full output note, got {other:?}"), + }; + + let expected_asset: Asset = + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64).unwrap().into(); + + assert_eq!(output_note.metadata().sender(), bridge_account.id()); + assert_eq!(output_note.metadata().note_type(), NoteType::Public); + assert_eq!( + output_note.recipient().script().root(), + P2idNote::script().root(), + "Output note should use the P2ID script" + ); + assert_eq!(output_note.recipient().serial_num(), serial_num); + + let mut assets_iter = output_note.assets().iter_fungible(); + let unlocked_asset = assets_iter + .next() + .expect("P2ID output note should carry exactly one fungible asset"); + assert!(assets_iter.next().is_none(), "P2ID output note should carry only one asset"); + assert_eq!(Felt::new(unlocked_asset.amount()), miden_claim_amount); + assert_eq!(unlocked_asset.faucet_id(), native_faucet.id()); + + // Cross-check storage directly: it should encode the destination account ID the same way + // `P2idNoteStorage::from` does ([suffix, prefix]). + let expected_p2id_note = create_p2id_note_exact( + bridge_account.id(), + destination_account_id, + vec![expected_asset], + NoteType::Public, + serial_num, + ) + .unwrap(); + let actual_storage = output_note.recipient().storage(); + let expected_storage = expected_p2id_note.recipient().storage(); + assert_eq!( + actual_storage, expected_storage, + "P2ID note storage items (encoding the target account ID) should match \ + the standard P2idNoteStorage encoding for destination_account_id={destination_account_id:?}" + ); + assert_eq!( + output_note.recipient().digest(), + expected_p2id_note.recipient().digest(), + "Recipient digest should match an independently constructed P2ID to the destination" + ); + + // Bridge vault is drained after the unlock. + bridge_account.apply_delta(claim_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(native_faucet.id())?, + 0, + "Bridge vault should be empty after the unlock" + ); + + mock_chain.add_pending_executed_transaction(&claim_executed)?; + mock_chain.prove_next_block()?; + + // TX4: destination consumes the P2ID note and receives the unlocked asset. + let consume_executed = mock_chain + .build_tx_context(destination_account.id(), &[], slice::from_ref(&expected_p2id_note))? + .build()? + .execute() + .await?; + + let mut destination_account = destination_account; + destination_account.apply_delta(consume_executed.account_delta())?; + assert_eq!( + destination_account.vault().get_balance(native_faucet.id())?, + miden_claim_amount_u64, + "Destination account should receive the unlocked asset from the P2ID" + ); + + Ok(()) +} + +/// Tests that a second CLAIM reusing the same leaf against the native unlock path is rejected. +/// +/// The native unlock path in `bridge_in_output::unlock_and_send` uses a deterministic P2ID serial +/// number derived from `CLAIM_PROOF_DATA_KEY`. Replay safety therefore depends on the claim +/// nullifier check in `bridge_in::claim` running before the branch into `unlock_and_send`. This +/// test seeds the bridge vault with enough native supply to serve two unlocks, then confirms the +/// second CLAIM with the same proof data is rejected with `ERR_CLAIM_ALREADY_SPENT` rather than +/// draining the vault a second time. +#[tokio::test] +async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { + let data_source = ClaimDataSource::SimulatedL1ToMiden; + 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_seed = builder.rng_mut().draw_word(); + let mut bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let metadata_hash = leaf_data.metadata_hash; + let scale = 10u8; + + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + let miden_claim_amount_u64 = miden_claim_amount.as_canonical_u64(); + + // Seed the native faucet and the lock sender with enough supply to cover two unlocks. If the + // nullifier check is ever weakened, the second claim would otherwise succeed and drain the + // vault a second time. + let faucet_owner_account_id = AccountId::dummy( + [3; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + miden_claim_amount_u64.saturating_mul(4), + faucet_owner_account_id, + Some(miden_claim_amount_u64.saturating_mul(4)), + OwnerControlledInitConfig::OwnerOnly, + )?; + + let destination_account_id = EthEmbeddedAccountId::try_from(leaf_data.destination_address) + .expect("destination address is not an embedded Miden AccountId") + .into_account_id(); + let destination_account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE, IncrNonceAuthComponent); + assert_eq!(destination_account.id(), destination_account_id); + builder.add_account(destination_account)?; + + let claim_sender = { + let account_builder = + Account::builder(builder.rng_mut().random()).with_component(BasicWallet); + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists)? + }; + + let lock_sender = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // Lock 2x the claim amount so the bridge vault could (if nullifier were broken) serve the + // replayed claim. + let bridge_asset: Asset = + FungibleAsset::new(native_faucet.id(), miden_claim_amount_u64.saturating_mul(2)) + .unwrap() + .into(); + let b2agg_destination_address = + EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + let b2agg_note = B2AggNote::create( + 1u32, + b2agg_destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + lock_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + let claim_inputs_1 = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + let claim_note_1 = create_claim_note( + claim_inputs_1, + bridge_account.id(), + claim_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note_1.clone())); + + let claim_inputs_2 = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + let claim_note_2 = create_claim_note( + claim_inputs_2, + bridge_account.id(), + claim_sender.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(claim_note_2.clone())); + + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + + let mut mock_chain = builder.clone().build()?; + + // TX0: CONFIG — register native faucet. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: LOCK — seed bridge vault with 2x miden_claim_amount. + let lock_executed = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(lock_executed.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(native_faucet.id())?, + miden_claim_amount_u64.saturating_mul(2), + ); + mock_chain.add_pending_executed_transaction(&lock_executed)?; + mock_chain.prove_next_block()?; + + // TX2: UPDATE_GER. + let update_ger_executed = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(update_ger_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: FIRST CLAIM — should succeed and drain half the vault. + let claim_executed_1 = mock_chain + .build_tx_context(bridge_account.clone(), &[], &[claim_note_1])? + .build()? + .execute() + .await?; + assert_eq!(claim_executed_1.output_notes().num_notes(), 1); + bridge_account.apply_delta(claim_executed_1.account_delta())?; + assert_eq!( + bridge_account.vault().get_balance(native_faucet.id())?, + miden_claim_amount_u64, + "Bridge vault should hold exactly the remaining half after the first unlock" + ); + mock_chain.add_pending_executed_transaction(&claim_executed_1)?; + mock_chain.prove_next_block()?; + + // TX4: SECOND CLAIM with same proof data — should fail on the nullifier, before reaching + // `unlock_and_send`. Vault still has enough to serve it, so a pass here would mean the + // nullifier gate is broken. + let result = mock_chain + .build_tx_context(bridge_account, &[], &[claim_note_2])? + .build()? + .execute() + .await; + assert!( + result.is_err(), + "Second native-path claim with the same PROOF_DATA_KEY should fail" + ); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_CLAIM_ALREADY_SPENT.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for 'claim note has already been spent', got: {error_msg}" + ); + + Ok(()) +} + #[tokio::test] async fn solidity_verify_merkle_proof_compatibility() -> anyhow::Result<()> { let merkle_paths = &*SOLIDITY_MERKLE_PROOF_VECTORS; diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index e0a61d3e47..cb906670de 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -5,6 +5,7 @@ use miden_agglayer::{ AggLayerBridge, B2AggNote, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, ExitRoot, MetadataHash, @@ -84,7 +85,7 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { .collect::>(); let total_burned: u64 = expected_amounts.iter().sum(); - // CREATE AGGLAYER FAUCET ACCOUNT (with conversion metadata for FPI) + // CREATE AGGLAYER FAUCET ACCOUNT // -------------------------------------------------------------------------------------------- let origin_token_address = EthAddress::from_hex(&vectors.origin_token_address) .expect("valid shared origin token address"); @@ -102,17 +103,19 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { Felt::new(FungibleAsset::MAX_AMOUNT), Felt::new(total_burned), bridge_account.id(), - &origin_token_address, - origin_network, - scale, - metadata_hash, ); builder.add_account(faucet.clone())?; // CONFIG_AGG_BRIDGE note to register the faucet in the bridge (sent by bridge admin) let config_note = ConfigAggBridgeNote::create( - faucet.id(), - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -159,11 +162,8 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { let mut burn_note_ids = Vec::with_capacity(note_count); for (i, note) in notes.iter().enumerate() { - let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; - let executed_tx = mock_chain .build_tx_context(bridge_account.clone(), &[note.id()], &[])? - .foreign_accounts(vec![foreign_account_inputs]) .build()? .execute() .await?; @@ -295,12 +295,6 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // CREATE AGGLAYER FAUCET ACCOUNT (NOT registered in the bridge) // -------------------------------------------------------------------------------------------- let vectors = &*SOLIDITY_MTF_VECTORS; - let origin_token_address = EthAddress::new([0u8; 20]); - let metadata_hash = MetadataHash::from_token_info( - &vectors.token_name, - &vectors.token_symbol, - vectors.token_decimals, - ); let faucet = create_existing_agglayer_faucet( builder.rng_mut().draw_word(), &vectors.token_symbol, @@ -308,10 +302,6 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> Felt::new(FungibleAsset::MAX_AMOUNT), Felt::new(100), bridge_account.id(), - &origin_token_address, - 0, // origin_network - 0, // scale - metadata_hash, ); builder.add_account(faucet.clone())?; @@ -338,11 +328,8 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> // ATTEMPT TO BRIDGE OUT WITHOUT REGISTERING THE FAUCET (SHOULD FAIL) // -------------------------------------------------------------------------------------------- - let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; - let result = mock_chain .build_tx_context(bridge_account.id(), &[b2agg_note.id()], &[])? - .foreign_accounts(vec![foreign_account_inputs]) .build()? .execute() .await; @@ -569,3 +556,140 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { Ok(()) } + +/// Tests the bridge-out lock path for Miden-native faucets. +/// +/// When a faucet is registered with `is_native = true`, the bridge does not burn the asset on +/// bridge-out; it locks it in its own vault instead. This test verifies: +/// 1. Registration stores the `is_native = true` flag on the bridge. +/// 2. Consuming a B2AGG note carrying a native asset produces **no** output note (no BURN). +/// 3. The asset ends up in the bridge account's vault. +/// 4. The Local Exit Tree is still advanced (the leaf is committed the same way). +#[tokio::test] +async fn bridge_out_lock_native_token() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + // Bridge admin / GER manager / bridge account. + 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())?; + + // Native faucet: network-faucet pattern (not bridge-owned). + let faucet_owner_account_id = AccountId::dummy( + [2; 15], + AccountIdVersion::Version0, + AccountType::RegularAccountImmutableCode, + AccountStorageMode::Private, + ); + let native_faucet = builder.add_existing_network_faucet( + "NATIVE", + 1000, + faucet_owner_account_id, + Some(500), + OwnerControlledInitConfig::OwnerOnly, + )?; + + // Sender of the B2AGG note (any regular wallet). + let sender_account = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // Register the native faucet in the bridge with `is_native = true`. + let origin_token_address = EthAddress::from_hex("0x00000000000000000000000000000000deadbeef") + .expect("valid eth address"); + let origin_network = 7u32; // any stable u32 — Miden's test network id + let scale = 0u8; + let metadata_hash = MetadataHash::from_token_info("Native Token", "NATIVE", 8); + + let config_note = ConfigAggBridgeNote::create( + ConversionMetadata { + faucet_account_id: native_faucet.id(), + origin_token_address, + scale, + origin_network, + is_native: true, + metadata_hash, + }, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(config_note.clone())); + + // B2AGG note carrying a native asset. + let amount = 42u64; + let bridge_asset: Asset = FungibleAsset::new(native_faucet.id(), amount).unwrap().into(); + let destination_network = 1u32; + let destination_address = EthAddress::from_hex("0x1234567890abcdef1122334455667788990011aa") + .expect("valid destination address"); + + let b2agg_note = B2AggNote::create( + destination_network, + destination_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + sender_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // TX0: register the faucet. + let config_executed = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()? + .execute() + .await?; + bridge_account.apply_delta(config_executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: consume the B2AGG note against the bridge (triggers lock_asset). + let executed_tx = mock_chain + .build_tx_context(bridge_account.clone(), &[b2agg_note.id()], &[])? + .build()? + .execute() + .await?; + + // No BURN note is emitted on the lock path. + assert_eq!( + executed_tx.output_notes().num_notes(), + 0, + "Lock path should not emit any output note" + ); + + bridge_account.apply_delta(executed_tx.account_delta())?; + + // The asset now lives in the bridge's own vault. + let bridge_balance = bridge_account.vault().get_balance(native_faucet.id())?; + assert_eq!(bridge_balance, amount, "Bridge vault should hold the locked asset"); + + // Leaf was still committed to the LET; LER is non-zero. + assert_eq!( + AggLayerBridge::read_let_num_leaves(&bridge_account), + 1, + "LET should have exactly one leaf after the lock" + ); + let local_exit_root = AggLayerBridge::read_local_exit_root(&bridge_account)?; + assert!( + local_exit_root.iter().any(|f| f.as_canonical_u64() != 0), + "Local Exit Root should be non-zero after the lock" + ); + + mock_chain.add_pending_executed_transaction(&executed_tx)?; + mock_chain.prove_next_block()?; + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index f4e760ba58..d3bf1ff035 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -3,7 +3,9 @@ extern crate alloc; use miden_agglayer::{ AggLayerBridge, ConfigAggBridgeNote, + ConversionMetadata, EthAddress, + MetadataHash, create_existing_bridge_account, }; use miden_protocol::Felt; @@ -63,12 +65,20 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { ); // CREATE CONFIG_AGG_BRIDGE NOTE - // Use a dummy origin token address for this test let origin_token_address = EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let scale = 0u8; + let origin_network = 0u32; + let metadata_hash = MetadataHash::from_token_info("USD Coin", "USDC", 6); let config_note = ConfigAggBridgeNote::create( - faucet_to_register, - &origin_token_address, + ConversionMetadata { + faucet_account_id: faucet_to_register, + origin_token_address, + scale, + origin_network, + is_native: false, + metadata_hash, + }, bridge_admin.id(), bridge_account.id(), builder.rng_mut(), @@ -78,9 +88,8 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { let mock_chain = builder.build()?; // CONSUME THE CONFIG_AGG_BRIDGE NOTE WITH THE BRIDGE ACCOUNT - let tx_context = mock_chain - .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? - .build()?; + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[], &[config_note])?.build()?; let executed_transaction = tx_context.execute().await?; // VERIFY FAUCET IS NOW REGISTERED diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index 84ea5b226c..6bdb62c53e 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -2,8 +2,6 @@ extern crate alloc; use miden_agglayer::{ AggLayerFaucet, - EthAddress, - MetadataHash, create_existing_agglayer_faucet, create_existing_bridge_account, }; @@ -36,13 +34,6 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT); let token_supply = Felt::new(123_456); - let origin_token_address = EthAddress::from_hex("0x0102030405060708090a0b0c0d0e0f1011121314") - .expect("invalid token address"); - let origin_network = 42u32; - let scale = 6u8; - - let metadata_hash = MetadataHash::from_token_info(token_symbol, token_symbol, decimals); - let faucet = create_existing_agglayer_faucet( builder.rng_mut().draw_word(), token_symbol, @@ -50,16 +41,9 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { max_supply, token_supply, bridge_account.id(), - &origin_token_address, - origin_network, - scale, - metadata_hash, ); assert_eq!(AggLayerFaucet::owner_account_id(&faucet)?, bridge_account.id()); - assert_eq!(AggLayerFaucet::origin_token_address(&faucet)?, origin_token_address); - assert_eq!(AggLayerFaucet::origin_network(&faucet)?, origin_network); - assert_eq!(AggLayerFaucet::scale(&faucet)?, scale); Ok(()) }