diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm index d30fa9019b..3c23856ed4 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm @@ -11,13 +11,13 @@ 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::native_account -use miden::protocol::active_account # TYPE ALIASES # ================================================================================================= @@ -35,6 +35,8 @@ const ERR_LEADING_BITS_NON_ZERO = "leading bits of global index must be zero" const ERR_MAINNET_FLAG_INVALID = "mainnet flag must be 0 or 1" const ERR_ROLLUP_INDEX_NON_ZERO = "rollup index must be zero for a mainnet deposit" const ERR_SMT_ROOT_VERIFICATION_FAILED = "merkle proof verification failed: provided SMT root does not match the computed root" +const ERR_CLAIM_ALREADY_SPENT = "claim note has already been spent" +const ERR_SOURCE_BRIDGE_NETWORK_OVERFLOW = "source bridge network overflowed u32" # CONSTANTS # ================================================================================================= @@ -47,6 +49,16 @@ const ERR_SMT_ROOT_VERIFICATION_FAILED = "merkle proof verification failed: prov const CGI_CHAIN_HASH_LO_SLOT_NAME = word("miden::agglayer::bridge::cgi_chain_hash_lo") const CGI_CHAIN_HASH_HI_SLOT_NAME = word("miden::agglayer::bridge::cgi_chain_hash_hi") +# Storage Slots +# ------------------------------------------------------------------------------------------------- + +# The slot in this component's storage layout where claim nullifiers are stored. +# Map entries: RPO(leaf_index, source_bridge_network) => [1, 0, 0, 0] +const CLAIM_NULLIFIERS_SLOT = word("miden::agglayer::bridge::claim_nullifiers") + +# Claim Nullifier Flag +const IS_CLAIMED_FLAG = [1, 0, 0, 0] + # Data sizes # ------------------------------------------------------------------------------------------------- @@ -98,6 +110,11 @@ const CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT = 568 const CLAIM_PROOF_DATA_KEY_MEM_ADDR = 700 const CLAIM_LEAF_DATA_KEY_MEM_ADDR = 704 +# Memory addresses used to temporarily store leaf_index and source_bridge_network +# across proof verification, and later as input to the claim nullifier hash. +const CLAIM_LEAF_INDEX_MEM_ADDR = 900 +const CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR = 901 + # Memory addresses for leaf data fields (derived from leaf data layout at CLAIM_LEAF_DATA_START_PTR=536) const ORIGIN_TOKEN_ADDRESS_0 = 538 const ORIGIN_TOKEN_ADDRESS_1 = 539 @@ -143,6 +160,7 @@ const CLAIM_DEST_ID_SUFFIX_LOCAL = 1 # PUBLIC INTERFACE # ================================================================================================= + #! Validates a claim against the AggLayer bridge and creates a MINT note for the aggfaucet. #! #! This procedure is called by the CLAIM note script. It validates the Merkle proof and then @@ -217,7 +235,7 @@ pub proc claim # Verify faucet_mint_amount matches the leaf data amount exec.verify_claim_amount # => [faucet_id_prefix, faucet_id_suffix, pad(16)] - + # Build MINT output note targeting the aggfaucet loc_load.CLAIM_DEST_ID_SUFFIX_LOCAL loc_load.CLAIM_DEST_ID_PREFIX_LOCAL # => [destination_id_prefix, destination_id_suffix, faucet_id_prefix, faucet_id_suffix, pad(16)] @@ -302,7 +320,9 @@ end #! Invocation: exec pub proc process_global_index_mainnet # the top 191 bits of the global index are zero - repeat.5 assertz.err=ERR_LEADING_BITS_NON_ZERO end + repeat.5 + assertz.err=ERR_LEADING_BITS_NON_ZERO + end # the next element is the mainnet flag (LE-packed u32) # byte-swap to get the BE value, then assert it is exactly 1 @@ -495,7 +515,7 @@ pub proc get_leaf_value(leaf_data_key: BeWord) -> DoubleWord # => [LEAF_VALUE[8]] end -#! Verify leaf and checks that it has not been claimed. +#! Verify leaf, check that it has not been claimed, and mark it as claimed. #! #! Inputs: [LEAF_VALUE[8], PROOF_DATA_KEY] #! Outputs: [] @@ -504,6 +524,7 @@ end #! - the computed GER is invalid (never injected). #! - the global index is invalid. #! - the Merkle proof for the provided leaf-index tuple against the computed GER is invalid. +#! - the claim has already been spent. #! #! Invocation: exec proc verify_leaf @@ -547,6 +568,10 @@ proc verify_leaf exec.process_global_index_mainnet # => [leaf_index, LEAF_VALUE[8]] + # Save leaf_index to memory for set_and_check_claimed after proof verification + dup mem_store.CLAIM_LEAF_INDEX_MEM_ADDR + # => [leaf_index, LEAF_VALUE[8]] + # verify single Merkle proof: leaf against mainnetExitRoot push.MAINNET_EXIT_ROOT_PTR swap push.SMT_PROOF_LOCAL_EXIT_ROOT_PTR @@ -560,6 +585,11 @@ proc verify_leaf assert.err=ERR_SMT_ROOT_VERIFICATION_FAILED # => [] + + # For mainnet deposits, source_bridge_network = 0 + # globalIndex = uint256(leafIndex), so source_bridge_network is always 0 + push.0 mem_store.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR + # => [] else # ==================== ROLLUP DEPOSIT ==================== # mainnet_flag = 0; extract rollup_index and leaf_index via helper, @@ -567,6 +597,15 @@ proc verify_leaf exec.process_global_index_rollup # => [leaf_index, rollup_index, LEAF_VALUE[8]] + # Save leaf_index and rollup_index to memory for set_and_check_claimed + # mem[CLAIM_LEAF_INDEX_MEM_ADDR] = leaf_index + # mem[CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR] = rollup_index (temporarily, updated below) + dup mem_store.CLAIM_LEAF_INDEX_MEM_ADDR + # => [leaf_index, rollup_index, LEAF_VALUE[8]] + + dup.1 mem_store.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR + # => [leaf_index, rollup_index, LEAF_VALUE[8]] + # Step 1: calculate_root(leafValue, smtProofLocalExitRoot, leafIndex) -> localExitRoot # calculate_root expects: [LEAF_VALUE_LO, LEAF_VALUE_HI, merkle_path_ptr, leaf_index] movdn.9 movdn.9 @@ -592,7 +631,90 @@ proc verify_leaf assert.err=ERR_SMT_ROOT_VERIFICATION_FAILED # => [] + + # For rollup deposits, source_bridge_network = rollup_index + 1 + # Compute source_bridge_network and store it to memory + mem_load.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR u32overflowing_add.1 assertz.err=ERR_SOURCE_BRIDGE_NETWORK_OVERFLOW + # => [source_bridge_network] + + mem_store.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR + # => [] end + + # 4. Check the claim has not been spent and mark it as spent. + # Load leaf_index and source_bridge_network from memory. + mem_load.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR + mem_load.CLAIM_LEAF_INDEX_MEM_ADDR + # => [leaf_index, source_bridge_network] + + exec.set_and_check_claimed + # => [] +end + +#! Computes the claim nullifier as RPO(leaf_index, source_bridge_network), then checks +#! that the claim has not been spent and marks it as spent. +#! +#! This mimics the Solidity `_setAndCheckClaimed(leafIndex, sourceBridgeNetwork)` function. +#! See: https://github.com/agglayer/agglayer-contracts/blob/60d06fc3224792ce55dc2690d66b6719a73398e7/contracts/v2/PolygonZkEVMBridgeV2.sol#L987 +#! +#! Inputs: [leaf_index, source_bridge_network] +#! Outputs: [] +#! +#! Panics if: +#! - the CLAIM note has already been spent (nullifier already exists in the nullifier map). +#! +#! Invocation: exec +proc set_and_check_claimed + # Write both values to memory for hashing + # mem[CLAIM_LEAF_INDEX_MEM_ADDR] = leaf_index + # mem[CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR] = source_bridge_network + mem_store.CLAIM_LEAF_INDEX_MEM_ADDR + # => [source_bridge_network] + + mem_store.CLAIM_SOURCE_BRIDGE_NETWORK_MEM_ADDR + # => [] + + # Hash the two elements using RPO to produce the nullifier + # TODO: Replace rpo256::hash_elements with poseidon2::hash_elements when VM is updated + push.2 push.CLAIM_LEAF_INDEX_MEM_ADDR + exec.rpo256::hash_elements + # => [NULLIFIER] + + exec.assert_claim_not_spent + # => [] +end + +#! Checks that the CLAIM note has not already been spent, and marks it as spent +#! by storing [1, 0, 0, 0] in the CLAIM_NULLIFIERS_SLOT map. +#! +#! The nullifier is computed as RPO(leaf_index, source_bridge_network), which uniquely +#! identifies a claim in the Global Exit Root (GER) as per the AggLayer protocol. +#! +#! Inputs: [NULLIFIER] +#! Outputs: [] +#! +#! Panics if: +#! - the CLAIM note has already been spent (nullifier already exists in the nullifier map). +#! +#! Invocation: exec +proc assert_claim_not_spent(nullifier: BeWord) + push.IS_CLAIMED_FLAG + # => [IS_CLAIMED_FLAG, NULLIFIER] + + swapw + # => [NULLIFIER, IS_CLAIMED_FLAG] + + push.CLAIM_NULLIFIERS_SLOT[0..2] + # => [slot_prefix, slot_suffix, NULLIFIER, IS_CLAIMED_FLAG] + + exec.native_account::set_map_item + # => [OLD_VALUE] + + drop drop drop + # => [old_is_claimed] + + assertz.err=ERR_CLAIM_ALREADY_SPENT + # => [] end # Inputs: [PROOF_DATA_KEY, LEAF_DATA_KEY] @@ -796,6 +918,8 @@ proc build_mint_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 diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index a31f93a2b8..6c3af9f94e 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -81,6 +81,10 @@ static CGI_CHAIN_HASH_HI_SLOT_NAME: LazyLock = LazyLock::new(|| StorageSlotName::new("miden::agglayer::bridge::cgi_chain_hash_hi") .expect("CGI chain hash hi storage slot name should be valid") }); +static CLAIM_NULLIFIERS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::agglayer::bridge::claim_nullifiers") + .expect("claim nullifiers storage slot name should be valid") +}); /// An [`AccountComponent`] implementing the AggLayer Bridge. /// @@ -106,6 +110,8 @@ static CGI_CHAIN_HASH_HI_SLOT_NAME: LazyLock = LazyLock::new(|| /// - [`Self::ger_manager_slot_name`]: Stores the GER manager account ID. /// - [`Self::cgi_lo_slot_name`]: Stores the lower 128 bits of the CGI chain hash. /// - [`Self::cgi_hi_slot_name`]: Stores the upper 128 bits of the CGI chain hash. +/// - [`Self::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (RPO(leaf_index, +/// source_bridge_network) → \[1, 0, 0, 0\]). /// /// The bridge starts with an empty faucet registry; faucets are registered at runtime via /// CONFIG_AGG_BRIDGE notes. @@ -187,6 +193,11 @@ impl AggLayerBridge { &CGI_CHAIN_HASH_HI_SLOT_NAME } + /// Storage slot name for the CLAIM note nullifiers map. + pub fn claim_nullifiers_slot_name() -> &'static StorageSlotName { + &CLAIM_NULLIFIERS_SLOT_NAME + } + /// Returns a boolean indicating whether the provided GER is present in storage of the provided /// bridge account. /// @@ -388,6 +399,7 @@ impl AggLayerBridge { &*GER_MANAGER_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, &*CGI_CHAIN_HASH_HI_SLOT_NAME, + &*CLAIM_NULLIFIERS_SLOT_NAME, ] } } @@ -419,6 +431,7 @@ impl From for AccountComponent { StorageSlot::with_value(GER_MANAGER_SLOT_NAME.clone(), ger_manager_word), StorageSlot::with_value(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(CGI_CHAIN_HASH_HI_SLOT_NAME.clone(), Word::empty()), + StorageSlot::with_empty_map(CLAIM_NULLIFIERS_SLOT_NAME.clone()), ]; bridge_component(bridge_storage_slots) } diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs index 3cf6d90bee..6ccc7d6f57 100644 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ b/crates/miden-agglayer/src/errors/agglayer.rs @@ -21,6 +21,8 @@ pub const ERR_BRIDGE_NOT_MAINNET: MasmError = MasmError::from_static_str("mainne /// Error Message: "mainnet flag must be 0 for a rollup deposit" pub const ERR_BRIDGE_NOT_ROLLUP: MasmError = MasmError::from_static_str("mainnet flag must be 0 for a rollup deposit"); +/// Error Message: "claim note has already been spent" +pub const ERR_CLAIM_ALREADY_SPENT: MasmError = MasmError::from_static_str("claim note has already been spent"); /// Error Message: "CLAIM note attachment target account does not match consuming account" pub const ERR_CLAIM_TARGET_ACCT_MISMATCH: MasmError = MasmError::from_static_str("CLAIM note attachment target account does not match consuming account"); @@ -70,6 +72,9 @@ pub const ERR_SENDER_NOT_GER_MANAGER: MasmError = MasmError::from_static_str("no /// Error Message: "merkle proof verification failed: provided SMT root does not match the computed root" pub const ERR_SMT_ROOT_VERIFICATION_FAILED: MasmError = MasmError::from_static_str("merkle proof verification failed: provided SMT root does not match the computed root"); +/// Error Message: "source bridge network overflowed u32" +pub const ERR_SOURCE_BRIDGE_NETWORK_OVERFLOW: MasmError = MasmError::from_static_str("source bridge network overflowed u32"); + /// Error Message: "token address is not registered in the bridge's token registry" pub const ERR_TOKEN_NOT_REGISTERED: MasmError = MasmError::from_static_str("token address is not registered in the bridge's token registry"); diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 9f1c2d08ec..a2d9dbf0b0 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -5,6 +5,7 @@ use alloc::string::String; use anyhow::Context; use miden_agglayer::claim_note::Keccak256Output; +use miden_agglayer::errors::ERR_CLAIM_ALREADY_SPENT; use miden_agglayer::{ ClaimNoteStorage, ConfigAggBridgeNote, @@ -397,6 +398,160 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a Ok(()) } +/// Tests that consuming a CLAIM note with the same PROOF_DATA_KEY twice fails. +/// +/// This test verifies the nullifier tracking mechanism: +/// 1. Sets up the bridge (CONFIG + UPDATE_GER) +/// 2. Executes the first CLAIM note successfully +/// 3. Creates a second CLAIM note with the same proof data +/// 4. Attempts to execute the second CLAIM note and asserts it fails with "claim note has already +/// been spent" +#[tokio::test] +async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { + let data_source = ClaimDataSource::Simulated; + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT + let bridge_admin = + builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Rpo })?; + + // CREATE GER MANAGER ACCOUNT + let ger_manager = + builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Rpo })?; + + // CREATE BRIDGE ACCOUNT + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + // GET CLAIM DATA FROM JSON + let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); + + // CREATE AGGLAYER FAUCET ACCOUNT + let token_symbol = "AGG"; + let decimals = 8u8; + let max_supply = Felt::new(FungibleAsset::MAX_AMOUNT); + let agglayer_faucet_seed = builder.rng_mut().draw_word(); + + let origin_token_address = leaf_data.origin_token_address; + let origin_network = leaf_data.origin_network; + let scale = 10u8; + + let agglayer_faucet = create_existing_agglayer_faucet( + agglayer_faucet_seed, + token_symbol, + decimals, + max_supply, + Felt::ZERO, + bridge_account.id(), + &origin_token_address, + origin_network, + scale, + ); + builder.add_account(agglayer_faucet.clone())?; + + // Calculate the scaled-down Miden amount + let miden_claim_amount = leaf_data + .amount + .scale_to_token_amount(scale as u32) + .expect("amount should scale successfully"); + + // CREATE FIRST CLAIM NOTE + 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(), + bridge_admin.id(), + builder.rng_mut(), + )?; + builder.add_output_note(OutputNote::Full(claim_note_1.clone())); + + // CREATE SECOND CLAIM NOTE (same proof data = same PROOF_DATA_KEY) + 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(), + bridge_admin.id(), + builder.rng_mut(), + )?; + builder.add_output_note(OutputNote::Full(claim_note_2.clone())); + + // CREATE CONFIG_AGG_BRIDGE NOTE + let config_note = ConfigAggBridgeNote::create( + agglayer_faucet.id(), + &origin_token_address, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(OutputNote::Full(config_note.clone())); + + // CREATE UPDATE_GER NOTE + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(OutputNote::Full(update_ger_note.clone())); + + // BUILD MOCK CHAIN + let mut mock_chain = builder.clone().build()?; + + // TX0: CONFIG_AGG_BRIDGE + let config_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[config_note.id()], &[])? + .build()?; + let config_executed = config_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&config_executed)?; + mock_chain.prove_next_block()?; + + // TX1: UPDATE_GER + let update_ger_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_ger_executed = update_ger_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX2: FIRST CLAIM (should succeed) + let faucet_foreign_inputs_1 = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let claim_tx_context_1 = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note_1])? + .foreign_accounts(vec![faucet_foreign_inputs_1]) + .build()?; + let claim_executed_1 = claim_tx_context_1.execute().await?; + assert_eq!(claim_executed_1.output_notes().num_notes(), 1); + + mock_chain.add_pending_executed_transaction(&claim_executed_1)?; + mock_chain.prove_next_block()?; + + // TX3: SECOND CLAIM WITH SAME PROOF_DATA_KEY (should fail) + let faucet_foreign_inputs_2 = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let claim_tx_context_2 = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note_2])? + .foreign_accounts(vec![faucet_foreign_inputs_2]) + .build()?; + let result = claim_tx_context_2.execute().await; + + assert!(result.is_err(), "Second claim with 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;