From 4b74055ce12fccee33c4d7108e9367f306a38cd4 Mon Sep 17 00:00:00 2001 From: riemann Date: Tue, 9 Jun 2026 23:13:01 -0400 Subject: [PATCH 1/8] feat(agglayer): add GER removal mechanism Adds a remove_ger MASM procedure gated by a new ger_remover role (separate from the existing GER manager) that revokes a previously registered Global Exit Root and folds it into a running keccak256 hash chain. Mirrors the Solidity sovereign-chain removeGlobalExitRoots / removedGERHashChain pair. Adds a REMOVE_GER note script, a RemoveGerNote Rust helper, integration tests, and updates the spec. --- CHANGELOG.md | 1 + bin/bench-transaction/src/context_setups.rs | 19 +- crates/miden-agglayer/SPEC.md | 25 +- .../asm/agglayer/bridge/bridge_config.masm | 157 ++++++ .../miden-agglayer/asm/components/bridge.masm | 2 + .../asm/note_scripts/remove_ger.masm | 71 +++ crates/miden-agglayer/src/bridge.rs | 93 +++- crates/miden-agglayer/src/lib.rs | 11 +- crates/miden-agglayer/src/remove_ger_note.rs | 115 ++++ .../miden-testing/tests/agglayer/bridge_in.rs | 85 ++- .../tests/agglayer/bridge_out.rs | 46 +- .../tests/agglayer/config_bridge.rs | 13 +- .../tests/agglayer/faucet_helpers.rs | 4 + crates/miden-testing/tests/agglayer/mod.rs | 1 + .../agglayer/network_account_regression.rs | 14 +- .../tests/agglayer/remove_ger.rs | 496 ++++++++++++++++++ .../tests/agglayer/update_ger.rs | 27 +- 17 files changed, 1147 insertions(+), 33 deletions(-) create mode 100644 crates/miden-agglayer/asm/note_scripts/remove_ger.masm create mode 100644 crates/miden-agglayer/src/remove_ger_note.rs create mode 100644 crates/miden-testing/tests/agglayer/remove_ger.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe4a8a3c3..dda78bcc2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - [BREAKING] Refactored `TokenPolicyManager` by adding `invoke_send_policy` / `invoke_receive_policy` wrappers (stored in the protocol reserved asset callback slots) that read the active policy root from the new `active_send_policy_proc_root` / `active_receive_policy_proc_root` storage slots ([#3047](https://github.com/0xMiden/protocol/pull/3047)). - Added a definition of the Miden operator on the architecture overview page and linked it from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). - Clarified Miden's operational roles on the architecture overview page and linked them from the note lifecycle ([#3017](https://github.com/0xMiden/protocol/pull/3017)). +- [BREAKING] Added GER removal mechanism with a dedicated `ger_remover` role, `remove_ger` MASM procedure, `REMOVE_GER` note script, `RemoveGerNote` Rust helper, and a running keccak256 removed-GER hash chain; `AggLayerBridge::new`, `create_bridge_account`, and `create_existing_bridge_account` now take a `ger_remover_id` argument ([#2837](https://github.com/0xMiden/protocol/pull/2837)). ### Fixes - Fixed `update_ger` to explicitly reject duplicate GER insertions with `ERR_GER_ALREADY_REGISTERED` instead of silently accepting them ([#2983](https://github.com/0xMiden/protocol/pull/2983)). diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index 75e47ef1f3..7fb31a71be 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -187,10 +187,19 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result) -> Result permanently unconsumable rather than silently accepted. Rejecting duplicates makes the > failure explicit and prevents the GER manager from accidentally creating unconsumed notes. -TODO: GERs cannot be removed once inserted -([#2702](https://github.com/0xMiden/protocol/issues/2702)). +A separate GER Remover role can revoke a previously-registered GER by sending a +`REMOVE_GER` note. The bridge consumes such a note and: + +1. Asserts the note sender is the designated GER remover (a role distinct from the GER + manager so that insertion and revocation authority can be split). +2. Computes `KEY = poseidon2::merge(GER_LOWER, GER_UPPER)`. +3. Asserts that `ger_map[KEY] == [1, 0, 0, 0]`, i.e. that the GER is currently known. +4. Overwrites `ger_map[KEY]` with `[0, 0, 0, 0]`, the Miden equivalent of Solidity's + `delete globalExitRootMap[ger]`. After this, any CLAIM note referencing the removed + GER will fail `assert_valid_ger`. +5. Updates a running keccak256 hash chain over all removed GERs: + `removed_ger_hash_chain = keccak256(removed_ger_hash_chain || removed_ger)`. This + chain is stored across two Word slots (`removed_ger_hash_chain_lo` / + `removed_ger_hash_chain_hi`) and mirrors the + `removeGlobalExitRoots` chain in Solidity's + `GlobalExitRootManagerL2SovereignChain`, providing an auditable record of every + removal. + +Note that removal does not blocklist a GER permanently: because the map entry is reset +to the empty word, the GER manager can re-register the same GER via a subsequent +`UPDATE_GER` note (re-insertion does not touch the removal chain). The removed-GER hash +chain is therefore an append-only log of removal events, not a registry of currently +revoked GERs - a GER listed in the chain may have been revived since its removal. TODO: No hash chain tracks GER insertions for proof generation ([#2707](https://github.com/0xMiden/protocol/issues/2707)). diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 4eed0812bc..20a2b96aca 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -1,3 +1,4 @@ +use miden::core::crypto::hashes::keccak256 use miden::core::crypto::hashes::poseidon2 use miden::core::word use miden::protocol::account_id @@ -15,6 +16,7 @@ const ERR_FAUCET_NOT_REGISTERED = "faucet is not registered in the bridge's fauc const ERR_TOKEN_NOT_REGISTERED = "(origin token address, origin network) pair is not registered in the bridge's token registry" const ERR_SENDER_NOT_BRIDGE_ADMIN = "note sender is not the bridge admin" const ERR_SENDER_NOT_GER_MANAGER = "note sender is not the global exit root manager" +const ERR_SENDER_NOT_GER_REMOVER = "note sender is not the global exit root remover" # CONSTANTS # ================================================================================================= @@ -22,11 +24,18 @@ const ERR_SENDER_NOT_GER_MANAGER = "note sender is not the global exit root mana # Storage slots const BRIDGE_ADMIN_SLOT = word("agglayer::bridge::admin_account_id") const GER_MANAGER_SLOT = word("agglayer::bridge::ger_manager_account_id") +const GER_REMOVER_SLOT = word("agglayer::bridge::ger_remover_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") +# Storage slot constants for the removed GER hash chain. +# The chain is updated as `keccak256(prev_chain || ger)` on each removal and stored in two +# separate value slots (lo/hi) since a Word holds only 4 felts but the chain is 8 felts. +const REMOVED_GER_HASH_CHAIN_LO_SLOT = word("agglayer::bridge::removed_ger_hash_chain_lo") +const REMOVED_GER_HASH_CHAIN_HI_SLOT = word("agglayer::bridge::removed_ger_hash_chain_hi") + # Flags const GER_KNOWN_FLAG = [1, 0, 0, 0] const FAUCET_REGISTERED_FLAG = 1 @@ -92,6 +101,71 @@ pub proc update_ger # => [pad(16)] end +#! Removes a Global Exit Root (GER) from the bridge account storage and folds it into the running +#! removed-GER keccak256 hash chain. +#! +#! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER), asserts that the GER is currently +#! known (map value equals GER_KNOWN_FLAG), overwrites the map entry with [0, 0, 0, 0] +#! (Miden equivalent of Solidity's `delete globalExitRootMap[ger]`), and updates the removed-GER +#! hash chain as NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). +#! +#! Inputs: [GER_LOWER[4], GER_UPPER[4], pad(8)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender is not the global exit root remover. +#! - the GER is not currently registered in the bridge's GER map. +#! +#! Invocation: call +pub proc remove_ger + # assert the note sender is the global exit root remover. + exec.assert_sender_is_ger_remover + # => [GER_LOWER[4], GER_UPPER[4], pad(8)] + + # duplicate the GER (16 felts) so we can use one copy to compute the map key + clear the map + # and the other copy as the keccak256 preimage for the chain hash update later. + dupw.1 dupw.1 + # => [GER_LOWER, GER_UPPER, GER_LOWER, GER_UPPER, pad(8)] + + # compute hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) on the top copy. + exec.poseidon2::merge + # => [GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + # assert the GER is currently known: map[GER_HASH] must equal GER_KNOWN_FLAG. + dupw + # => [GER_HASH, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + push.GER_MAP_STORAGE_SLOT[0..2] + # => [slot_id_prefix, slot_id_suffix, GER_HASH, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + exec.active_account::get_map_item + # => [VALUE, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + push.GER_KNOWN_FLAG + assert_eqw.err=ERR_GER_NOT_FOUND + # => [GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + # overwrite the map entry with [0, 0, 0, 0] to mark it removed. + push.0.0.0.0 + # => [0, 0, 0, 0, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + + swapw + # => [GER_HASH, [0, 0, 0, 0], GER_LOWER, GER_UPPER, pad(8)] + + push.GER_MAP_STORAGE_SLOT[0..2] + # => [slot_id_prefix, slot_id_suffix, GER_HASH, [0, 0, 0, 0], GER_LOWER, GER_UPPER, pad(8)] + + exec.native_account::set_map_item + # => [OLD_VALUE, GER_LOWER, GER_UPPER, pad(8)] + + dropw + # => [GER_LOWER, GER_UPPER, pad(8)] + + # update the removed-GER keccak256 hash chain: NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). + exec.update_removed_ger_hash_chain + # => [pad(16)] +end + #! Asserts that the provided GER is valid (exists in storage). #! #! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER) and looks up the hash in the GER @@ -541,3 +615,86 @@ proc assert_sender_is_ger_manager assert.err=ERR_SENDER_NOT_GER_MANAGER # => [pad(16)] end + +#! Asserts that the note sender matches the global exit root remover stored in account storage. +#! +#! Reads the GER remover account ID from GER_REMOVER_SLOT and compares it against the sender of the +#! currently executing note. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the note sender does not match the GER remover account ID. +#! +#! Invocation: exec +proc assert_sender_is_ger_remover + push.GER_REMOVER_SLOT[0..2] + exec.active_account::get_item + # => [0, 0, rem_suffix, rem_prefix, pad(16)] + + drop drop + # => [rem_suffix, rem_prefix, pad(16)] + + exec.active_note::get_sender + # => [sender_suffix, sender_prefix, rem_suffix, rem_prefix, pad(16)] + + exec.account_id::is_equal + assert.err=ERR_SENDER_NOT_GER_REMOVER + # => [pad(16)] +end + +#! Updates the removed-GER keccak256 hash chain by folding in the provided GER. +#! +#! Computes NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER), then writes the new chain to the +#! REMOVED_GER_HASH_CHAIN_LO_SLOT and REMOVED_GER_HASH_CHAIN_HI_SLOT slots. +#! +#! Inputs: [GER_LOWER[4], GER_UPPER[4]] +#! Outputs: [] +#! +#! Invocation: exec +proc update_removed_ger_hash_chain + # load OLD_CHAIN above the GER preimage on the stack so that keccak256::merge produces + # keccak256(OLD_CHAIN || GER), matching Solidity's + # `removedGERHashChain = efficientKeccak256(removedGERHashChain, removedGER)`. + exec.load_removed_ger_hash_chain_data + # => [OLD_CHAIN[8], GER_LOWER, GER_UPPER] + + exec.keccak256::merge + # => [NEW_CHAIN_LO, NEW_CHAIN_HI] + + exec.store_removed_ger_hash_chain + # => [] +end + +#! Loads the old removed-GER hash chain onto the stack, below the existing GER preimage. +#! +#! Inputs: [GER_LOWER[4], GER_UPPER[4]] +#! Outputs: [OLD_CHAIN[8], GER_LOWER, GER_UPPER] +#! +#! Invocation: exec +proc load_removed_ger_hash_chain_data + push.REMOVED_GER_HASH_CHAIN_HI_SLOT[0..2] + exec.active_account::get_item + # => [OLD_CHAIN_HI, GER_LOWER, GER_UPPER] + + push.REMOVED_GER_HASH_CHAIN_LO_SLOT[0..2] + exec.active_account::get_item + # => [OLD_CHAIN_LO, OLD_CHAIN_HI, GER_LOWER, GER_UPPER] +end + +#! Stores the updated removed-GER hash chain into the corresponding lo/hi storage slots. +#! +#! Inputs: [NEW_CHAIN_LO, NEW_CHAIN_HI] +#! Outputs: [] +#! +#! Invocation: exec +proc store_removed_ger_hash_chain + push.REMOVED_GER_HASH_CHAIN_LO_SLOT[0..2] + exec.native_account::set_item dropw + # => [NEW_CHAIN_HI] + + push.REMOVED_GER_HASH_CHAIN_HI_SLOT[0..2] + exec.native_account::set_item dropw + # => [] +end diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 14c5169f93..e8028400f9 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -6,11 +6,13 @@ # - `register_faucet` from the bridge_config module # - `store_faucet_metadata_hash` from the bridge_config module # - `update_ger` from the bridge_config module +# - `remove_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_config::remove_ger pub use ::agglayer::bridge::bridge_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out diff --git a/crates/miden-agglayer/asm/note_scripts/remove_ger.masm b/crates/miden-agglayer/asm/note_scripts/remove_ger.masm new file mode 100644 index 0000000000..e591675646 --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/remove_ger.masm @@ -0,0 +1,71 @@ +use agglayer::bridge::bridge_config +use miden::protocol::active_note +use miden::standards::attachments::network_account_target + +# CONSTANTS +# ================================================================================================= + +const REMOVE_GER_NOTE_NUM_STORAGE_ITEMS = 8 +const STORAGE_PTR_GER_LOWER = 0 +const STORAGE_PTR_GER_UPPER = 4 + +# ERRORS +# ================================================================================================= + +const ERR_REMOVE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS = "REMOVE_GER script expects exactly 8 note storage items" +const ERR_REMOVE_GER_TARGET_ACCOUNT_MISMATCH = "REMOVE_GER note attachment target account does not match consuming account" + +# NOTE SCRIPT +# ================================================================================================= + +#! Agglayer Bridge REMOVE_GER script: removes a GER from the bridge account by calling the +#! bridge_config::remove_ger function. +#! +#! This note can only be consumed by the specific agglayer bridge account whose ID is provided +#! in the note attachment (target_account_id), and only if the note was sent by the global exit root +#! remover. +#! +#! Requires that the account exposes: +#! - agglayer::bridge_config::remove_ger procedure. +#! +#! Inputs: [ARGS, pad(12)] +#! Outputs: [pad(16)] +#! +#! NoteStorage layout (8 felts total): +#! - GER_LOWER [0..3] : 4 felts +#! - GER_UPPER [4..7] : 4 felts +#! +#! Panics if: +#! - account does not expose remove_ger procedure. +#! - target account ID does not match the consuming account ID. +#! - number of note storage items is not exactly 8. +#! - the note sender is not the global exit root remover. +#! - the GER is not currently registered in the bridge's GER map. +@note_script +pub proc main + dropw + # => [pad(16)] + + # Ensure note attachment targets the consuming bridge account. + exec.network_account_target::active_account_matches_target_account + assert.err=ERR_REMOVE_GER_TARGET_ACCOUNT_MISMATCH + # => [pad(16)] + + # Load note storage to memory + push.STORAGE_PTR_GER_LOWER exec.active_note::get_storage + # => [num_storage_items, pad(16)] + + # Validate the number of storage items + push.REMOVE_GER_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_REMOVE_GER_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS + # => [pad(16)] + + # Load GER_LOWER and GER_UPPER from note storage + mem_loadw_le.STORAGE_PTR_GER_UPPER + # => [GER_UPPER[4], pad(12)] + + swapw mem_loadw_le.STORAGE_PTR_GER_LOWER + # => [GER_LOWER[4], GER_UPPER[4], pad(8)] + + call.bridge_config::remove_ger + # => [pad(16)] +end diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 06d1825c33..7833d680d7 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -30,6 +30,7 @@ pub use crate::{ LeafData, MetadataHash, ProofData, + RemoveGerNote, SmtNode, UpdateGerNote, }; @@ -53,10 +54,22 @@ static GER_MANAGER_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::ger_manager_account_id") .expect("GER manager account ID storage slot name should be valid") }); +static GER_REMOVER_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::ger_remover_account_id") + .expect("GER remover account ID storage slot name should be valid") +}); static GER_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::ger_map") .expect("GER map storage slot name should be valid") }); +static REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::removed_ger_hash_chain_lo") + .expect("removed GER hash chain lo storage slot name should be valid") +}); +static REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::removed_ger_hash_chain_hi") + .expect("removed GER hash chain hi storage slot name should be valid") +}); static FAUCET_REGISTRY_MAP_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("agglayer::bridge::faucet_registry_map") .expect("faucet registry map storage slot name should be valid") @@ -113,6 +126,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// The procedures of this component are: /// - `register_faucet`, which registers a faucet in the bridge. /// - `update_ger`, which injects a new GER into the storage map. +/// - `remove_ger`, which removes a GER from the storage map and folds it into the running +/// removed-GER keccak256 hash chain. /// - `bridge_out`, which bridges an asset out of Miden to the destination network. /// - `claim`, which validates a claim against the AggLayer bridge and creates a MINT note for the /// AggLayer Faucet. @@ -121,7 +136,12 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// /// - [`Self::bridge_admin_id_slot_name`]: Stores the bridge admin account ID. /// - [`Self::ger_manager_id_slot_name`]: Stores the GER manager account ID. +/// - [`Self::ger_remover_id_slot_name`]: Stores the GER remover account ID. /// - [`Self::ger_map_slot_name`]: Stores the GERs. +/// - [`Self::removed_ger_hash_chain_lo_slot_name`]: Stores the lower 128 bits of the removed-GER +/// keccak256 hash chain. +/// - [`Self::removed_ger_hash_chain_hi_slot_name`]: Stores the upper 128 bits of the removed-GER +/// keccak256 hash chain. /// - [`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 @@ -146,6 +166,7 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { pub struct AggLayerBridge { bridge_admin_id: AccountId, ger_manager_id: AccountId, + ger_remover_id: AccountId, } impl AggLayerBridge { @@ -163,8 +184,16 @@ impl AggLayerBridge { // -------------------------------------------------------------------------------------------- /// Creates a new AggLayer bridge component with the standard configuration. - pub fn new(bridge_admin_id: AccountId, ger_manager_id: AccountId) -> Self { - Self { bridge_admin_id, ger_manager_id } + pub fn new( + bridge_admin_id: AccountId, + ger_manager_id: AccountId, + ger_remover_id: AccountId, + ) -> Self { + Self { + bridge_admin_id, + ger_manager_id, + ger_remover_id, + } } // PUBLIC ACCESSORS @@ -182,11 +211,26 @@ impl AggLayerBridge { &GER_MANAGER_ID_SLOT_NAME } + /// Storage slot name for the GER remover account ID. + pub fn ger_remover_id_slot_name() -> &'static StorageSlotName { + &GER_REMOVER_ID_SLOT_NAME + } + /// Storage slot name for the GERs map. pub fn ger_map_slot_name() -> &'static StorageSlotName { &GER_MAP_SLOT_NAME } + /// Storage slot name for the lower 128 bits of the removed-GER keccak256 hash chain. + pub fn removed_ger_hash_chain_lo_slot_name() -> &'static StorageSlotName { + &REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME + } + + /// Storage slot name for the upper 128 bits of the removed-GER keccak256 hash chain. + pub fn removed_ger_hash_chain_hi_slot_name() -> &'static StorageSlotName { + &REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME + } + /// Storage slot name for the faucet registry map. pub fn faucet_registry_map_slot_name() -> &'static StorageSlotName { &FAUCET_REGISTRY_MAP_SLOT_NAME @@ -260,6 +304,7 @@ impl AggLayerBridge { B2AggNote::script_root(), ConfigAggBridgeNote::script_root(), UpdateGerNote::script_root(), + RemoveGerNote::script_root(), ]) } @@ -377,6 +422,43 @@ impl AggLayerBridge { )) } + /// Returns the removed-GER keccak256 hash chain from the corresponding storage slots as a + /// 32-byte array. + /// + /// The chain is the running keccak256 of all removed GERs: + /// `chain_n = keccak256(chain_{n-1} || removed_ger_n)` with `chain_0 = 0...0`. + /// + /// # Errors + /// + /// Returns an error if: + /// - the provided account is not an [`AggLayerBridge`] account. + pub fn removed_ger_hash_chain( + bridge_account: &Account, + ) -> Result<[u8; 32], AgglayerBridgeError> { + // check that the provided account is a bridge account + Self::assert_bridge_account(bridge_account)?; + + let chain_lo = bridge_account + .storage() + .get_item(AggLayerBridge::removed_ger_hash_chain_lo_slot_name()) + .expect("failed to get removed GER hash chain lo slot"); + let chain_hi = bridge_account + .storage() + .get_item(AggLayerBridge::removed_ger_hash_chain_hi_slot_name()) + .expect("failed to get removed GER hash chain hi slot"); + + let chain_bytes = chain_lo + .iter() + .chain(chain_hi.iter()) + .flat_map(|felt| { + (u32::try_from(felt.as_canonical_u64()).expect("Felt value does not fit into u32")) + .to_le_bytes() + }) + .collect::>(); + + Ok(chain_bytes.try_into().expect("keccak hash should consist of exactly 32 bytes")) + } + // HELPER FUNCTIONS // -------------------------------------------------------------------------------------------- @@ -453,6 +535,9 @@ impl AggLayerBridge { &*FAUCET_METADATA_MAP_SLOT_NAME, &*BRIDGE_ADMIN_ID_SLOT_NAME, &*GER_MANAGER_ID_SLOT_NAME, + &*GER_REMOVER_ID_SLOT_NAME, + &*REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME, + &*REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, &*CGI_CHAIN_HASH_HI_SLOT_NAME, &*CLAIM_NULLIFIERS_SLOT_NAME, @@ -464,6 +549,7 @@ impl From for AccountComponent { fn from(bridge: AggLayerBridge) -> Self { let bridge_admin_word = AccountIdKey::new(bridge.bridge_admin_id).as_word(); let ger_manager_word = AccountIdKey::new(bridge.ger_manager_id).as_word(); + let ger_remover_word = AccountIdKey::new(bridge.ger_remover_id).as_word(); let bridge_storage_slots = vec![ StorageSlot::with_empty_map(GER_MAP_SLOT_NAME.clone()), @@ -476,6 +562,9 @@ impl From for AccountComponent { 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(GER_REMOVER_ID_SLOT_NAME.clone(), ger_remover_word), + StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_LO_SLOT_NAME.clone(), Word::empty()), + StorageSlot::with_value(REMOVED_GER_HASH_CHAIN_HI_SLOT_NAME.clone(), Word::empty()), 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()), diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 19f35dd8ce..d0fa02555c 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -25,6 +25,7 @@ pub mod config_note; pub mod errors; pub mod eth_types; pub mod faucet; +pub mod remove_ger_note; #[cfg(feature = "testing")] pub mod testing; pub mod update_ger_note; @@ -55,6 +56,7 @@ pub use eth_types::{ MetadataHash, }; pub use faucet::{AggLayerFaucet, AgglayerFaucetError}; +pub use remove_ger_note::RemoveGerNote; pub use update_ger_note::UpdateGerNote; pub use utils::Keccak256Output; @@ -133,10 +135,11 @@ fn create_bridge_account_builder( seed: Word, bridge_admin_id: AccountId, ger_manager_id: AccountId, + ger_remover_id: AccountId, ) -> AccountBuilder { Account::builder(seed.into()) .account_type(AccountType::Public) - .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id)) + .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id, ger_remover_id)) .with_auth_component( AuthNetworkAccount::with_allowed_notes(AggLayerBridge::allowed_notes()) .expect("bridge note allowlist is non-empty"), @@ -150,8 +153,9 @@ pub fn create_bridge_account( seed: Word, bridge_admin_id: AccountId, ger_manager_id: AccountId, + ger_remover_id: AccountId, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) + create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id, ger_remover_id) .build() .expect("bridge account should be valid") } @@ -164,8 +168,9 @@ pub fn create_existing_bridge_account( seed: Word, bridge_admin_id: AccountId, ger_manager_id: AccountId, + ger_remover_id: AccountId, ) -> Account { - create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id) + create_bridge_account_builder(seed, bridge_admin_id, ger_manager_id, ger_remover_id) .build_existing() .expect("bridge account should be valid") } diff --git a/crates/miden-agglayer/src/remove_ger_note.rs b/crates/miden-agglayer/src/remove_ger_note.rs new file mode 100644 index 0000000000..7fd7905e34 --- /dev/null +++ b/crates/miden-agglayer/src/remove_ger_note.rs @@ -0,0 +1,115 @@ +//! REMOVE_GER note creation utilities. +//! +//! This module provides helpers for creating REMOVE_GER notes, +//! which are used to remove a Global Exit Root from the bridge account and fold it into the +//! running removed-GER keccak256 hash chain. + +extern crate alloc; + +use alloc::string::ToString; +use alloc::vec; + +use miden_assembly::Library; +use miden_assembly::serde::Deserializable; +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteScriptRoot, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_utils_sync::LazyLock; + +use crate::ExitRoot; + +// NOTE SCRIPT +// ================================================================================================ + +// Initialize the REMOVE_GER note script only once +static REMOVE_GER_SCRIPT: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/remove_ger.masl")); + let library = + Library::read_from_bytes(bytes).expect("shipped REMOVE_GER script library is well-formed"); + NoteScript::from_library(&library).expect("shipped REMOVE_GER script is well-formed") +}); + +// REMOVE_GER NOTE +// ================================================================================================ + +/// REMOVE_GER note. +/// +/// This note is used to remove a Global Exit Root (GER) from the bridge account and fold it into +/// the running removed-GER keccak256 hash chain. It carries the GER data and is always public. +pub struct RemoveGerNote; + +impl RemoveGerNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for a REMOVE_GER note. + pub const NUM_STORAGE_ITEMS: usize = 8; + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the REMOVE_GER note script. + pub fn script() -> NoteScript { + REMOVE_GER_SCRIPT.clone() + } + + /// Returns the REMOVE_GER note script root. + pub fn script_root() -> NoteScriptRoot { + REMOVE_GER_SCRIPT.root() + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates a REMOVE_GER note with the given GER (Global Exit Root) data. + /// + /// The note storage contains 8 felts: GER[0..7] + /// + /// # Parameters + /// - `ger`: The Global Exit Root data to remove + /// - `sender_account_id`: The account ID of the note creator (must be the GER remover) + /// - `target_account_id`: The account ID that will consume this note (bridge account) + /// - `rng`: Random number generator for creating the note serial number + /// + /// # Errors + /// Returns an error if note creation fails. + pub fn create( + ger: ExitRoot, + sender_account_id: AccountId, + target_account_id: AccountId, + rng: &mut R, + ) -> Result { + // Create note storage with 8 felts: GER[0..7] + let storage_values = ger.to_elements().to_vec(); + + let note_storage = NoteStorage::new(storage_values)?; + + // Generate a serial number for the note + let serial_num = rng.draw_word(); + + let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); + + let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|e| NoteError::other(e.to_string()))?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); + + // REMOVE_GER notes don't carry assets + let assets = NoteAssets::new(vec![])?; + + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + } +} diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index d238655ba6..525a83416f 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -150,11 +150,21 @@ async fn test_bridge_in_claim_to_p2id(#[case] data_source: ClaimDataSource) -> a auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // 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()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON (source depends on the test case) @@ -438,10 +448,17 @@ async fn test_mint_cannot_be_consumed_by_unrelated_faucet() -> anyhow::Result<() let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); @@ -629,11 +646,21 @@ async fn test_claim_rejects_wrong_destination_network() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // 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()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON @@ -767,10 +794,19 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // 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()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // GET CLAIM DATA FROM JSON @@ -933,8 +969,15 @@ async fn bridge_in_unlock_native_token() -> anyhow::Result<()> { })?; let bridge_seed = builder.rng_mut().draw_word(); - let mut bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let mut bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // Claim data: leaf data's origin_token_address + metadata_hash must match the registration @@ -1190,8 +1233,15 @@ async fn bridge_in_unlock_native_duplicate_rejected() -> anyhow::Result<()> { })?; let bridge_seed = builder.rng_mut().draw_word(); - let mut bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let mut bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); @@ -1423,8 +1473,15 @@ async fn test_claim_fails_when_origin_network_unregistered() -> anyhow::Result<( })?; let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = - create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let (proof_data, leaf_data, ger, _cgi_chain_hash) = data_source.get_data(); diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index ae77a994a6..5499d0794a 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -89,10 +89,16 @@ async fn bridge_out_consecutive() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + let ger_remover = 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(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -367,10 +373,15 @@ async fn bridge_out_at_high_num_leaves(#[case] initial_num_leaves: u32) -> anyho auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); populate_let_state(&mut bridge_account, initial_num_leaves, &initial_frontier); builder.add_account(bridge_account.clone())?; @@ -491,12 +502,18 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // CREATE BRIDGE ACCOUNT (empty faucet registry — no faucets registered) // -------------------------------------------------------------------------------------------- let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -586,10 +603,15 @@ async fn test_bridge_out_rejects_invalid_b2agg_note( // CREATE BRIDGE ACCOUNT // -------------------------------------------------------------------------------------------- + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -749,11 +771,17 @@ async fn b2agg_note_reclaim_scenario() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // Create a GER remover account (not used in this test, but distinct from admin and manager) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // Create a bridge account (includes a `bridge` component) let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -863,11 +891,17 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // Create a GER remover account (not used in this test, but distinct from admin and manager) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // Create a bridge account as the designated TARGET for the B2AGG note let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -881,6 +915,7 @@ async fn b2agg_note_non_target_account_cannot_consume() -> anyhow::Result<()> { builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(malicious_account.clone())?; @@ -941,10 +976,15 @@ async fn bridge_out_lock_native_token() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let mut bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index ffe5792be3..a2fd6ede52 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -51,11 +51,17 @@ async fn test_config_agg_bridge_registers_faucet() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // CREATE BRIDGE ACCOUNT (starts with empty faucet registry) let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -140,10 +146,15 @@ async fn test_config_agg_bridge_distinguishes_origin_network() -> anyhow::Result })?; // CREATE BRIDGE ACCOUNT (starts with empty token registry) + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index ee848e2ccf..55d6fc3a70 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -21,11 +21,15 @@ fn test_faucet_helper_methods() -> anyhow::Result<()> { let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( builder.rng_mut().draw_word(), bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index d6f44ff14a..cf2362ca00 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -7,6 +7,7 @@ mod global_index; mod leaf_utils; mod merkle_tree_frontier; mod network_account_regression; +mod remove_ger; mod solidity_miden_address_conversion; pub mod test_utils; mod update_ger; diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index 6c97851245..9188040f5c 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -52,10 +52,15 @@ async fn bridge_rejects_tx_script() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; @@ -98,10 +103,15 @@ async fn bridge_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + let bridge_seed = builder.rng_mut().draw_word(); + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; let bridge_account = create_existing_bridge_account( - builder.rng_mut().draw_word(), + bridge_seed, bridge_admin.id(), ger_manager.id(), + ger_remover.id(), ); builder.add_account(bridge_account.clone())?; diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs new file mode 100644 index 0000000000..6e447f0a93 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -0,0 +1,496 @@ +extern crate alloc; + +use miden_agglayer::errors::{ERR_GER_NOT_FOUND, ERR_SENDER_NOT_GER_REMOVER}; +use miden_agglayer::{ + AggLayerBridge, + ExitRoot, + RemoveGerNote, + UpdateGerNote, + create_existing_bridge_account, +}; +use miden_core_lib::handlers::keccak256::KeccakPreimage; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::transaction::RawOutputNote; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; + +const GER_BYTES: [u8; 32] = [ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, +]; + +/// Computes one fold of the removed-GER hash chain, `keccak256(prev_chain || ger)`, in the +/// same byte representation that [`AggLayerBridge::removed_ger_hash_chain`] returns. +fn fold_removed_ger_chain(prev_chain: [u8; 32], ger_bytes: [u8; 32]) -> [u8; 32] { + let mut preimage = [0u8; 64]; + preimage[..32].copy_from_slice(&prev_chain); + preimage[32..].copy_from_slice(&ger_bytes); + let chain_felts: alloc::vec::Vec<_> = + KeccakPreimage::new(preimage.to_vec()).digest().as_ref().to_vec(); + let mut chain_bytes = [0u8; 32]; + for (i, felt) in chain_felts.iter().enumerate() { + let limb = u32::try_from(felt.as_canonical_u64()).expect("felt fits in u32"); + chain_bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb.to_le_bytes()); + } + chain_bytes +} + +/// Tests the happy path: register a GER via UPDATE_GER, then remove it via REMOVE_GER. +/// Verifies that the GER is no longer registered and that the removed-GER hash chain +/// advanced to `keccak256(0...0 || ger)`. +#[tokio::test] +async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + // STEP 1: Register the GER via UPDATE_GER + let ger = ExitRoot::from(GER_BYTES); + 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())); + + // STEP 2: Remove the GER via REMOVE_GER (sent by the GER remover) + let remove_ger_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + let mut mock_chain = builder.build()?; + + let update_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_executed = update_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_executed)?; + mock_chain.prove_next_block()?; + + let remove_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()?; + let remove_executed = remove_tx_context.execute().await?; + + // VERIFY GER IS NO LONGER REGISTERED AND CHAIN HASH ADVANCED + let mut updated_bridge_account = bridge_account.clone(); + updated_bridge_account.apply_delta(update_executed.account_delta())?; + updated_bridge_account.apply_delta(remove_executed.account_delta())?; + + let is_registered = AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?; + assert!(!is_registered, "GER should have been removed from the bridge account"); + + // Expected chain = keccak256(0...0 || ger_bytes) + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!(actual_chain, expected_chain_bytes, "removed-GER hash chain mismatch"); + + Ok(()) +} + +/// Tests that REMOVE_GER reverts when the GER was never registered in the first place. +#[tokio::test] +async fn remove_ger_unknown_ger_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let ger = ExitRoot::from(GER_BYTES); + let remove_ger_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); + + Ok(()) +} + +/// Tests that removing a GER from the middle of a sequence of inserted GERs leaves the +/// other GERs in place. Inserts A, B, C, removes B, and verifies that A and C remain +/// registered while B does not. +#[tokio::test] +async fn remove_ger_middle_of_multi_insert_leaves_others_intact() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let mut ger_a_bytes = GER_BYTES; + ger_a_bytes[31] = 0xaa; + let mut ger_b_bytes = GER_BYTES; + ger_b_bytes[31] = 0xbb; + let mut ger_c_bytes = GER_BYTES; + ger_c_bytes[31] = 0xcc; + let ger_a = ExitRoot::from(ger_a_bytes); + let ger_b = ExitRoot::from(ger_b_bytes); + let ger_c = ExitRoot::from(ger_c_bytes); + + let update_a = + UpdateGerNote::create(ger_a, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let update_b = + UpdateGerNote::create(ger_b, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let update_c = + UpdateGerNote::create(ger_c, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let remove_b = + RemoveGerNote::create(ger_b, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_a.clone())); + builder.add_output_note(RawOutputNote::Full(update_b.clone())); + builder.add_output_note(RawOutputNote::Full(update_c.clone())); + builder.add_output_note(RawOutputNote::Full(remove_b.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_a, &update_b, &update_c] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_delta(executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + let remove_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_b.id()], &[])? + .build()?; + let remove_executed = remove_tx_context.execute().await?; + updated_bridge_account.apply_delta(remove_executed.account_delta())?; + + assert!( + AggLayerBridge::is_ger_registered(ger_a, &updated_bridge_account)?, + "GER A should still be registered after removing B" + ); + assert!( + !AggLayerBridge::is_ger_registered(ger_b, &updated_bridge_account)?, + "GER B should have been removed" + ); + assert!( + AggLayerBridge::is_ger_registered(ger_c, &updated_bridge_account)?, + "GER C should still be registered after removing B" + ); + + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], ger_b_bytes); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain, expected_chain_bytes, + "removed-GER hash chain should equal keccak256(0...0 || B)" + ); + + Ok(()) +} + +/// Tests two successful sequential removals: inserts GERs A and B, removes A, then removes B, +/// and verifies the chain folds over the non-zero intermediate value, i.e. ends at +/// `keccak256(keccak256(0...0 || A) || B)`. +#[tokio::test] +async fn remove_ger_sequential_removals_fold_chain() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let mut ger_a_bytes = GER_BYTES; + ger_a_bytes[31] = 0xaa; + let mut ger_b_bytes = GER_BYTES; + ger_b_bytes[31] = 0xbb; + let ger_a = ExitRoot::from(ger_a_bytes); + let ger_b = ExitRoot::from(ger_b_bytes); + + let update_a = + UpdateGerNote::create(ger_a, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let update_b = + UpdateGerNote::create(ger_b, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let remove_a = + RemoveGerNote::create(ger_a, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let remove_b = + RemoveGerNote::create(ger_b, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_a.clone())); + builder.add_output_note(RawOutputNote::Full(update_b.clone())); + builder.add_output_note(RawOutputNote::Full(remove_a.clone())); + builder.add_output_note(RawOutputNote::Full(remove_b.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_a, &update_b, &remove_a, &remove_b] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_delta(executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + assert!( + !AggLayerBridge::is_ger_registered(ger_a, &updated_bridge_account)?, + "GER A should have been removed" + ); + assert!( + !AggLayerBridge::is_ger_registered(ger_b, &updated_bridge_account)?, + "GER B should have been removed" + ); + + let expected_chain_bytes = + fold_removed_ger_chain(fold_removed_ger_chain([0u8; 32], ger_a_bytes), ger_b_bytes); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain, expected_chain_bytes, + "removed-GER hash chain should equal keccak256(keccak256(0...0 || A) || B)" + ); + + Ok(()) +} + +/// Tests that calling REMOVE_GER twice on the same GER reverts the second call with +/// ERR_GER_NOT_FOUND. Locks in the invariant that a removed entry stays at [0,0,0,0] +/// and cannot be re-removed. +#[tokio::test] +async fn remove_ger_double_remove_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let ger = ExitRoot::from(GER_BYTES); + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let remove_ger_note_first = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let remove_ger_note_second = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + builder.add_output_note(RawOutputNote::Full(remove_ger_note_first.clone())); + builder.add_output_note(RawOutputNote::Full(remove_ger_note_second.clone())); + + let mut mock_chain = builder.build()?; + + for note in [&update_ger_note, &remove_ger_note_first] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note_second.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); + + Ok(()) +} + +/// Tests that re-inserting a previously-removed GER succeeds and that the re-insertion +/// does NOT touch the removed-GER hash chain. Documents current `update_ger` behavior: +/// it overwrites the map entry unconditionally, so a removed GER can be revived. If +/// preventing revival is ever desired, `update_ger` itself must be hardened — this test +/// would then need to be updated to expect a revert. +#[tokio::test] +async fn remove_ger_then_reinsert_succeeds() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + let ger = ExitRoot::from(GER_BYTES); + let update_first = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + let remove_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + let update_second = + UpdateGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + + builder.add_output_note(RawOutputNote::Full(update_first.clone())); + builder.add_output_note(RawOutputNote::Full(remove_note.clone())); + builder.add_output_note(RawOutputNote::Full(update_second.clone())); + + let mut mock_chain = builder.build()?; + + let mut updated_bridge_account = bridge_account.clone(); + for note in [&update_first, &remove_note, &update_second] { + let tx_context = + mock_chain.build_tx_context(bridge_account.id(), &[note.id()], &[])?.build()?; + let executed = tx_context.execute().await?; + updated_bridge_account.apply_delta(executed.account_delta())?; + mock_chain.add_pending_executed_transaction(&executed)?; + mock_chain.prove_next_block()?; + } + + assert!( + AggLayerBridge::is_ger_registered(ger, &updated_bridge_account)?, + "GER should be registered again after re-insertion" + ); + + let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); + let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; + assert_eq!( + actual_chain, expected_chain_bytes, + "re-insertion must not advance the removed-GER hash chain" + ); + + Ok(()) +} + +/// Tests that REMOVE_GER reverts when the note sender is not the GER remover. +#[tokio::test] +async fn remove_ger_non_remover_sender_reverts() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + // Register a GER first so the failure is exclusively due to the sender check. + let ger = ExitRoot::from(GER_BYTES); + 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())); + + // The GER manager (not the remover) attempts to send the REMOVE_GER note. + let remove_ger_note = + RemoveGerNote::create(ger, ger_manager.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + let mut mock_chain = builder.build()?; + + let update_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[update_ger_note.id()], &[])? + .build()?; + let update_executed = update_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&update_executed)?; + mock_chain.prove_next_block()?; + + let result = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_GER_REMOVER); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/update_ger.rs b/crates/miden-testing/tests/agglayer/update_ger.rs index 2729bf817a..46d1670d5d 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -65,11 +65,21 @@ async fn update_ger_note_updates_storage() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in this test, but distinct from admin and manager) + // -------------------------------------------------------------------------------------------- + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // 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()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; // CREATE UPDATE_GER NOTE WITH 8 STORAGE ITEMS (NEW GER AS TWO WORDS) @@ -282,10 +292,19 @@ async fn update_ger_rejects_duplicate() -> anyhow::Result<()> { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // 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()); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); builder.add_account(bridge_account.clone())?; let ger_bytes: [u8; 32] = [ From dbbd87ab5d6b1439062a48349941b3b2a6b7ff49 Mon Sep 17 00:00:00 2001 From: riemann Date: Wed, 10 Jun 2026 00:02:05 -0400 Subject: [PATCH 2/8] test(agglayer): assert CLAIM is rejected after its GER is removed --- .../miden-testing/tests/agglayer/bridge_in.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 525a83416f..f9e17ccf9f 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -7,6 +7,7 @@ use anyhow::Context; use miden_agglayer::errors::{ ERR_CLAIM_ALREADY_SPENT, ERR_CLAIM_LEAF_DESTINATION_NETWORK_MISMATCH, + ERR_GER_NOT_FOUND, ERR_TOKEN_NOT_REGISTERED, }; use miden_agglayer::{ @@ -20,6 +21,7 @@ use miden_agglayer::{ EthEmbeddedAccountId, ExitRoot, LeafValue, + RemoveGerNote, SmtNode, UpdateGerNote, agglayer_library, @@ -939,6 +941,150 @@ async fn test_duplicate_claim_note_rejected() -> anyhow::Result<()> { Ok(()) } +/// Tests that a CLAIM note referencing a removed GER is rejected. +/// +/// Uses the same known-good claim data as `test_bridge_in_claim_to_p2id`, so the failure is +/// attributable solely to the GER removal: +/// 1. Sets up the bridge (CONFIG + UPDATE_GER) so the CLAIM would succeed. +/// 2. Removes the GER via REMOVE_GER. +/// 3. Attempts to execute the CLAIM note and asserts it fails with `ERR_GER_NOT_FOUND`. +#[tokio::test] +async fn test_claim_rejects_removed_ger() -> anyhow::Result<()> { + let data_source = ClaimDataSource::L1ToMiden; + let mut builder = MockChain::builder(); + + // CREATE BRIDGE ADMIN ACCOUNT + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER MANAGER ACCOUNT + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // CREATE GER REMOVER ACCOUNT (sends the REMOVE_GER note) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + // 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(), + ger_remover.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 = FungibleAsset::MAX_AMOUNT.into(); + 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(), + ); + 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 CLAIM NOTE + let claim_inputs = ClaimNoteStorage { + proof_data: proof_data.clone(), + leaf_data: leaf_data.clone(), + miden_claim_amount, + }; + + let claim_note = + ClaimNote::create(claim_inputs, bridge_account.id(), bridge_admin.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(claim_note.clone())); + + // CREATE CONFIG_AGG_BRIDGE NOTE + let config_note = ConfigAggBridgeNote::create( + 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(), + )?; + builder.add_output_note(RawOutputNote::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(RawOutputNote::Full(update_ger_note.clone())); + + // CREATE REMOVE_GER NOTE (removes the GER the claim's proof is verified against) + let remove_ger_note = + RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); + + // BUILD MOCK CHAIN + let mut mock_chain = builder.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: REMOVE_GER + let remove_ger_tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? + .build()?; + let remove_ger_executed = remove_ger_tx_context.execute().await?; + mock_chain.add_pending_executed_transaction(&remove_ger_executed)?; + mock_chain.prove_next_block()?; + + // TX3: CLAIM (should fail because its GER was removed) + let faucet_foreign_inputs = mock_chain.get_foreign_account_inputs(agglayer_faucet.id())?; + let result = mock_chain + .build_tx_context(bridge_account.id(), &[], &[claim_note])? + .foreign_accounts(vec![faucet_foreign_inputs]) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); + + 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 From 878f25bf88fb3b0e4a0ea970146cd97c2366a420 Mon Sep 17 00:00:00 2001 From: riemann Date: Wed, 10 Jun 2026 00:41:55 -0400 Subject: [PATCH 3/8] docs(agglayer): document GER remover role, slots, and REMOVE_GER note in SPEC Updates the sections that were missed when the GER removal mechanism was added: the administration section now lists the GER remover as a third role, the bridge component procedure list includes remove_ger, the storage table covers the ger_remover_account_id and removed_ger_hash_chain_lo/hi slots, and a new section 4.5 specifies the REMOVE_GER note (BURN/P2ID/MINT renumbered to 4.6-4.8). --- crates/miden-agglayer/SPEC.md | 81 +++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index d6ed227eb9..a5af38d995 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -49,7 +49,7 @@ asset and the destination network/address. The bridge account consumes this note 5. Computes the Keccak-256 leaf value and appends it to the Local Exit Tree (LET). 6. Dispatches on the faucet's `is_native` flag (also read from the registry): - **Wrapped faucet (`is_native = false`):** the bridge does not hold the asset onchain; it - emits a public [`BURN`](#45-burn-generated) note targeting the faucet, which the faucet + emits a public [`BURN`](#46-burn-generated) note targeting the faucet, which the faucet consumes to burn the asset and decrement the faucet's token supply. - **Miden-native faucet (`is_native = true`):** the bridge does not hold mint/burn authority for the faucet, so it cannot emit a `BURN`. Instead it locks the asset by adding it to @@ -84,9 +84,9 @@ The `CLAIM` note is consumed by the bridge account: registry. 7. Verifies the claim amount against the leaf's U256 amount and the faucet's scale factor. 8. Dispatches on the faucet's `is_native` flag: - - **Wrapped faucet (`is_native = false`):** the bridge emits a [`MINT`](#47-mint-generated) + - **Wrapped faucet (`is_native = false`):** the bridge emits a [`MINT`](#48-mint-generated) note targeting the faucet. The faucet consumes the `MINT` note, mints the specified amount, - and creates a [`P2ID`](#46-p2id-generated) note delivering the minted assets to the + and creates a [`P2ID`](#47-p2id-generated) note delivering the minted assets to the recipient's Miden account. - **Miden-native faucet (`is_native = true`):** the bridge cannot mint via the faucet, so it removes the asset from its own vault (`native_account::remove_asset`) and emits a @@ -126,7 +126,7 @@ to be valid. > failure explicit and prevents the GER manager from accidentally creating unconsumed notes. A separate GER Remover role can revoke a previously-registered GER by sending a -`REMOVE_GER` note. The bridge consumes such a note and: +[`REMOVE_GER`](#45-remove_ger) note. The bridge consumes such a note and: 1. Asserts the note sender is the designated GER remover (a role distinct from the GER manager so that insertion and revocation authority can be split). @@ -182,14 +182,17 @@ TODO: Faucet existence and code commitment are not validated during registration ### 2.5 Administration -The bridge has two administrative roles set at account creation time: +The bridge has three administrative roles set at account creation time: - **Bridge admin** (`admin_account_id`): authorizes faucet registration via [`CONFIG_AGG_BRIDGE`](#43-config_agg_bridge) notes. - **GER manager** (`ger_manager_account_id`): authorizes GER updates via [`UPDATE_GER`](#44-update_ger) notes. +- **GER remover** (`ger_remover_account_id`): authorizes GER removals via + [`REMOVE_GER`](#45-remove_ger) notes. Kept distinct from the GER manager so that insertion + and revocation authority can be split. -Both roles are verified by checking the note sender against the stored account ID. +All roles are verified by checking the note sender against the stored account ID. TODO: Administrative roles cannot be transferred after account creation ([#2706](https://github.com/0xMiden/protocol/issues/2706)). @@ -208,6 +211,7 @@ which is a thin wrapper that re-exports procedures from the `agglayer` library m - `bridge_config::register_faucet` - `bridge_config::update_ger` +- `bridge_config::remove_ger` - `bridge_in::claim` - `bridge_out::bridge_out` @@ -308,7 +312,7 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: 7. Verifies the `faucet_mint_amount` against the leaf data's U256 amount and the faucet's scale factor (via FPI to `agglayer_faucet::get_scale`), using `asset_conversion::verify_u256_to_native_amount_conversion`. -8. Builds a MINT output note targeting the faucet (see [Section 4.7](#47-mint-generated)). +8. Builds a MINT output note targeting the faucet (see [Section 4.8](#48-mint-generated)). #### Bridge Account Storage @@ -326,9 +330,13 @@ Validates a bridge-in claim and creates a MINT note targeting the faucet: | `agglayer::bridge::cgi_chain_hash_hi` | Value | -- | Upper word of the CGI chain hash | CGI chain hash high word (Keccak-256 upper 16 bytes) | | `agglayer::bridge::admin_account_id` | Value | -- | `[0, 0, admin_suffix, admin_prefix]` | Bridge admin account ID for CONFIG note authorization | | `agglayer::bridge::ger_manager_account_id` | Value | -- | `[0, 0, mgr_suffix, mgr_prefix]` | GER manager account ID for UPDATE_GER note authorization | +| `agglayer::bridge::ger_remover_account_id` | Value | -- | `[0, 0, rem_suffix, rem_prefix]` | GER remover account ID for REMOVE_GER note authorization | +| `agglayer::bridge::removed_ger_hash_chain_lo` | Value | -- | Lower word of the removed-GER hash chain | Removed-GER hash chain low word (Keccak-256 lower 16 bytes) | +| `agglayer::bridge::removed_ger_hash_chain_hi` | Value | -- | Upper word of the removed-GER hash chain | Removed-GER hash chain high word (Keccak-256 upper 16 bytes) | Initial state: all map slots empty, all value slots `[0, 0, 0, 0]` except -`admin_account_id` and `ger_manager_account_id` (set at account creation time). +`admin_account_id`, `ger_manager_account_id`, and `ger_remover_account_id` (set at account +creation time). ### 3.2 Faucet Account Component @@ -361,7 +369,7 @@ recipient. Requires the faucet's owner (the bridge account) to be the creator of `mint_and_send` executes the current access policy via `exec.policy_manager::execute_mint_policy`). `mint_and_send` then derives the asset to mint for the active faucet and panics if the stored `ASSET_KEY` does not belong to that faucet, -which binds the MINT note to its resolved faucet (see §4.7). +which binds the MINT note to its resolved faucet (see §4.8). #### `agglayer_faucet::get_metadata_hash` @@ -665,7 +673,56 @@ CLAIM notes can be verified against it. | **Issuer** | GER manager only -- **enforced** by `bridge_config::update_ger` procedure | | **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | -### 4.5 BURN (generated) +### 4.5 REMOVE_GER + +**Purpose:** Removes a previously-registered Global Exit Root (GER) from the bridge account so +that subsequent CLAIM notes referencing it fail validation, and folds the removed GER into the +removed-GER keccak256 hash chain. + +**`NoteHeader`** + +*`NoteMetadata`:* + +| Field | Value | +|-------|-------| +| `sender` | GER remover (sender authorization enforced by the bridge's `remove_ger` procedure) | +| `note_type` | `NoteType::Public` | +| `tag` | `NoteTag::default()` | +| `attachment` | `NetworkAccountTarget` -- target is the bridge account; execution hint: Always | + +**`NoteDetails`** + +*`NoteAssets`:* None (empty). + +*`NoteRecipient`:* + +| Field | Value | +|-------|-------| +| `serial_num` | Random (`rng.draw_word()`) | +| `script` | `remove_ger.masm` | +| `storage` | 8 felts -- see layout below | + +**Storage layout (8 felts):** + +| Range | Field | Encoding | +|-------|-------|----------| +| 0-3 | `GER_LOWER` | First 16 bytes as 4 x u32 felts | +| 4-7 | `GER_UPPER` | Last 16 bytes as 4 x u32 felts | + +**Consumption:** Script validates attachment target, loads storage, and calls +`bridge_config::remove_ger` (which asserts sender is GER remover), which computes +`poseidon2::merge(GER_LOWER, GER_UPPER)`, asserts the GER map entry equals `[1, 0, 0, 0]` +while overwriting it with `[0, 0, 0, 0]`, and updates the removed-GER hash chain as +`keccak256(prev_chain || GER)` (see [Section 2.3](#23-ger-injection)). + +#### Permissions + +| Role | Enforcement | +|------|------------| +| **Issuer** | GER remover only -- **enforced** by `bridge_config::remove_ger` procedure | +| **Consumer** | Bridge account -- **enforced** via `NetworkAccountTarget` attachment | + +### 4.6 BURN (generated) **Purpose:** Created by `bridge_out::bridge_out` to burn the bridged asset on the faucet. @@ -709,7 +766,7 @@ decreases the faucet's total token supply by the burned amount. | **Issuer** | Bridge account (created by `bridge_out::bridge_out`) | | **Consumer** | Target faucet only -- **enforced** via `NetworkAccountTarget` attachment | -### 4.6 P2ID (generated) +### 4.7 P2ID (generated) **Purpose:** Created by the faucet (via `mint_and_send`) when consuming a MINT note, to deliver minted assets to the recipient. @@ -757,7 +814,7 @@ script). All note assets are added to the consuming account via | **Issuer** | Faucet account (created by `mint_and_send`) | | **Consumer** | Destination account only -- **enforced** by P2ID script (checks `target_account_id`) | -### 4.7 MINT (generated) +### 4.8 MINT (generated) **Purpose:** Created by `bridge_in::claim` on the bridge account. Consumed by the faucet to mint and distribute assets to the recipient. From 75b0667998f6fe1264595900c6caa79caae25c72 Mon Sep 17 00:00:00 2001 From: riemann Date: Wed, 10 Jun 2026 00:41:55 -0400 Subject: [PATCH 4/8] refactor(agglayer): address review findings on GER removal - remove_ger now clears the map entry with a single set_map_item and asserts the returned OLD_VALUE equals GER_KNOWN_FLAG, matching the update_ger idiom and dropping one SMT operation per removal. - Extract the duplicated felt-to-LE-bytes conversion shared by cgi_chain_hash and removed_ger_hash_chain into a chain_hash_bytes helper, and return a RemovedGerHashChain newtype (Keccak256Output) instead of a raw byte array, mirroring CgiChainHash. - Deduplicate the per-test wallet/bridge setup in the remove_ger integration tests into a setup_bridge helper. --- .../asm/agglayer/bridge/bridge_config.masm | 33 ++-- crates/miden-agglayer/src/bridge.rs | 46 ++--- crates/miden-agglayer/src/lib.rs | 2 +- .../tests/agglayer/remove_ger.rs | 185 +++++------------- 4 files changed, 79 insertions(+), 187 deletions(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 20a2b96aca..b76624ecf7 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -104,10 +104,10 @@ end #! Removes a Global Exit Root (GER) from the bridge account storage and folds it into the running #! removed-GER keccak256 hash chain. #! -#! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER), asserts that the GER is currently -#! known (map value equals GER_KNOWN_FLAG), overwrites the map entry with [0, 0, 0, 0] -#! (Miden equivalent of Solidity's `delete globalExitRootMap[ger]`), and updates the removed-GER -#! hash chain as NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). +#! Computes hash(GER) = poseidon2::merge(GER_LOWER, GER_UPPER), overwrites the map entry with +#! [0, 0, 0, 0] (Miden equivalent of Solidity's `delete globalExitRootMap[ger]`) while asserting +#! that the previous map value equals GER_KNOWN_FLAG (i.e. the GER was currently known), and +#! updates the removed-GER hash chain as NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). #! #! Inputs: [GER_LOWER[4], GER_UPPER[4], pad(8)] #! Outputs: [pad(16)] @@ -122,7 +122,7 @@ pub proc remove_ger exec.assert_sender_is_ger_remover # => [GER_LOWER[4], GER_UPPER[4], pad(8)] - # duplicate the GER (16 felts) so we can use one copy to compute the map key + clear the map + # duplicate the GER (16 felts) so we can use one copy to compute the map key # and the other copy as the keccak256 preimage for the chain hash update later. dupw.1 dupw.1 # => [GER_LOWER, GER_UPPER, GER_LOWER, GER_UPPER, pad(8)] @@ -131,23 +131,9 @@ pub proc remove_ger exec.poseidon2::merge # => [GER_HASH, GER_LOWER, GER_UPPER, pad(8)] - # assert the GER is currently known: map[GER_HASH] must equal GER_KNOWN_FLAG. - dupw - # => [GER_HASH, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] - - push.GER_MAP_STORAGE_SLOT[0..2] - # => [slot_id_prefix, slot_id_suffix, GER_HASH, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] - - exec.active_account::get_map_item - # => [VALUE, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] - - push.GER_KNOWN_FLAG - assert_eqw.err=ERR_GER_NOT_FOUND - # => [GER_HASH, GER_LOWER, GER_UPPER, pad(8)] - - # overwrite the map entry with [0, 0, 0, 0] to mark it removed. + # prepare VALUE = [0, 0, 0, 0] to mark the entry removed. push.0.0.0.0 - # => [0, 0, 0, 0, GER_HASH, GER_LOWER, GER_UPPER, pad(8)] + # => [[0, 0, 0, 0], GER_HASH, GER_LOWER, GER_UPPER, pad(8)] swapw # => [GER_HASH, [0, 0, 0, 0], GER_LOWER, GER_UPPER, pad(8)] @@ -158,7 +144,10 @@ pub proc remove_ger exec.native_account::set_map_item # => [OLD_VALUE, GER_LOWER, GER_UPPER, pad(8)] - dropw + # assert the GER was currently known: OLD_VALUE must equal GER_KNOWN_FLAG. A failed + # assertion aborts the transaction, discarding the map write above. + push.GER_KNOWN_FLAG + assert_eqw.err=ERR_GER_NOT_FOUND # => [GER_LOWER, GER_UPPER, pad(8)] # update the removed-GER keccak256 hash chain: NEW_CHAIN = keccak256::merge(OLD_CHAIN, GER). diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 7833d680d7..71b66c54fa 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -15,6 +15,10 @@ use thiserror::Error; use super::agglayer_bridge_component_library; use crate::claim_note::CgiChainHash; +use crate::utils::Keccak256Output; + +/// Removed-GER hash chain representation (32-byte Keccak256 hash) +pub type RemovedGerHashChain = Keccak256Output; pub use crate::{ B2AggNote, ClaimNote, @@ -406,24 +410,10 @@ impl AggLayerBridge { .get_item(AggLayerBridge::cgi_chain_hash_hi_slot_name()) .expect("failed to get CGI hash chain hi slot"); - let cgi_chain_hash_bytes = cgi_chain_hash_lo - .iter() - .chain(cgi_chain_hash_hi.iter()) - .flat_map(|felt| { - (u32::try_from(felt.as_canonical_u64()).expect("Felt value does not fit into u32")) - .to_le_bytes() - }) - .collect::>(); - - Ok(CgiChainHash::new( - cgi_chain_hash_bytes - .try_into() - .expect("keccak hash should consist of exactly 32 bytes"), - )) + Ok(CgiChainHash::new(Self::chain_hash_bytes(cgi_chain_hash_lo, cgi_chain_hash_hi))) } - /// Returns the removed-GER keccak256 hash chain from the corresponding storage slots as a - /// 32-byte array. + /// Returns the removed-GER keccak256 hash chain from the corresponding storage slots. /// /// The chain is the running keccak256 of all removed GERs: /// `chain_n = keccak256(chain_{n-1} || removed_ger_n)` with `chain_0 = 0...0`. @@ -434,7 +424,7 @@ impl AggLayerBridge { /// - the provided account is not an [`AggLayerBridge`] account. pub fn removed_ger_hash_chain( bridge_account: &Account, - ) -> Result<[u8; 32], AgglayerBridgeError> { + ) -> Result { // check that the provided account is a bridge account Self::assert_bridge_account(bridge_account)?; @@ -447,21 +437,25 @@ impl AggLayerBridge { .get_item(AggLayerBridge::removed_ger_hash_chain_hi_slot_name()) .expect("failed to get removed GER hash chain hi slot"); - let chain_bytes = chain_lo - .iter() - .chain(chain_hi.iter()) + Ok(RemovedGerHashChain::new(Self::chain_hash_bytes(chain_lo, chain_hi))) + } + + // HELPER FUNCTIONS + // -------------------------------------------------------------------------------------------- + + /// Converts a keccak256 hash stored across two lo/hi storage words into its 32-byte form. + fn chain_hash_bytes(lo: Word, hi: Word) -> [u8; 32] { + lo.iter() + .chain(hi.iter()) .flat_map(|felt| { (u32::try_from(felt.as_canonical_u64()).expect("Felt value does not fit into u32")) .to_le_bytes() }) - .collect::>(); - - Ok(chain_bytes.try_into().expect("keccak hash should consist of exactly 32 bytes")) + .collect::>() + .try_into() + .expect("keccak hash should consist of exactly 32 bytes") } - // HELPER FUNCTIONS - // -------------------------------------------------------------------------------------------- - /// Checks that the provided account is an [`AggLayerBridge`] account. /// /// # Errors diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index d0fa02555c..925c150579 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -32,7 +32,7 @@ pub mod update_ger_note; pub mod utils; pub use b2agg_note::B2AggNote; -pub use bridge::{AggLayerBridge, AgglayerBridgeError}; +pub use bridge::{AggLayerBridge, AgglayerBridgeError, RemovedGerHashChain}; pub use claim_note::{ CgiChainHash, ClaimNote, diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs index 6e447f0a93..a944de0b63 100644 --- a/crates/miden-testing/tests/agglayer/remove_ger.rs +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -9,16 +9,44 @@ use miden_agglayer::{ create_existing_bridge_account, }; use miden_core_lib::handlers::keccak256::KeccakPreimage; +use miden_protocol::account::Account; use miden_protocol::account::auth::AuthScheme; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; -use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; +use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; const GER_BYTES: [u8; 32] = [ 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, ]; +/// Creates the bridge admin, GER manager, and GER remover wallets, builds the bridge account +/// wired to those roles, and registers the bridge account with the builder. +/// +/// Returns the bridge account together with the GER manager and GER remover wallets. +fn setup_bridge(builder: &mut MockChainBuilder) -> anyhow::Result<(Account, Account, 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 ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = create_existing_bridge_account( + bridge_seed, + bridge_admin.id(), + ger_manager.id(), + ger_remover.id(), + ); + builder.add_account(bridge_account.clone())?; + + Ok((bridge_account, ger_manager, ger_remover)) +} + /// Computes one fold of the removed-GER hash chain, `keccak256(prev_chain || ger)`, in the /// same byte representation that [`AggLayerBridge::removed_ger_hash_chain`] returns. fn fold_removed_ger_chain(prev_chain: [u8; 32], ger_bytes: [u8; 32]) -> [u8; 32] { @@ -41,25 +69,7 @@ fn fold_removed_ger_chain(prev_chain: [u8; 32], ger_bytes: [u8; 32]) -> [u8; 32] #[tokio::test] async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, ger_remover) = setup_bridge(&mut builder)?; // STEP 1: Register the GER via UPDATE_GER let ger = ExitRoot::from(GER_BYTES); @@ -97,7 +107,11 @@ async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<() // Expected chain = keccak256(0...0 || ger_bytes) let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; - assert_eq!(actual_chain, expected_chain_bytes, "removed-GER hash chain mismatch"); + assert_eq!( + actual_chain.as_bytes(), + &expected_chain_bytes, + "removed-GER hash chain mismatch" + ); Ok(()) } @@ -106,25 +120,7 @@ async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<() #[tokio::test] async fn remove_ger_unknown_ger_reverts() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, _ger_manager, ger_remover) = setup_bridge(&mut builder)?; let ger = ExitRoot::from(GER_BYTES); let remove_ger_note = @@ -150,25 +146,7 @@ async fn remove_ger_unknown_ger_reverts() -> anyhow::Result<()> { #[tokio::test] async fn remove_ger_middle_of_multi_insert_leaves_others_intact() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, ger_remover) = setup_bridge(&mut builder)?; let mut ger_a_bytes = GER_BYTES; ger_a_bytes[31] = 0xaa; @@ -228,7 +206,8 @@ async fn remove_ger_middle_of_multi_insert_leaves_others_intact() -> anyhow::Res let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], ger_b_bytes); let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; assert_eq!( - actual_chain, expected_chain_bytes, + actual_chain.as_bytes(), + &expected_chain_bytes, "removed-GER hash chain should equal keccak256(0...0 || B)" ); @@ -241,25 +220,7 @@ async fn remove_ger_middle_of_multi_insert_leaves_others_intact() -> anyhow::Res #[tokio::test] async fn remove_ger_sequential_removals_fold_chain() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, ger_remover) = setup_bridge(&mut builder)?; let mut ger_a_bytes = GER_BYTES; ger_a_bytes[31] = 0xaa; @@ -307,7 +268,8 @@ async fn remove_ger_sequential_removals_fold_chain() -> anyhow::Result<()> { fold_removed_ger_chain(fold_removed_ger_chain([0u8; 32], ger_a_bytes), ger_b_bytes); let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; assert_eq!( - actual_chain, expected_chain_bytes, + actual_chain.as_bytes(), + &expected_chain_bytes, "removed-GER hash chain should equal keccak256(keccak256(0...0 || A) || B)" ); @@ -320,25 +282,7 @@ async fn remove_ger_sequential_removals_fold_chain() -> anyhow::Result<()> { #[tokio::test] async fn remove_ger_double_remove_reverts() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, ger_remover) = setup_bridge(&mut builder)?; let ger = ExitRoot::from(GER_BYTES); let update_ger_note = @@ -381,25 +325,7 @@ async fn remove_ger_double_remove_reverts() -> anyhow::Result<()> { #[tokio::test] async fn remove_ger_then_reinsert_succeeds() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, ger_remover) = setup_bridge(&mut builder)?; let ger = ExitRoot::from(GER_BYTES); let update_first = @@ -433,7 +359,8 @@ async fn remove_ger_then_reinsert_succeeds() -> anyhow::Result<()> { let expected_chain_bytes = fold_removed_ger_chain([0u8; 32], GER_BYTES); let actual_chain = AggLayerBridge::removed_ger_hash_chain(&updated_bridge_account)?; assert_eq!( - actual_chain, expected_chain_bytes, + actual_chain.as_bytes(), + &expected_chain_bytes, "re-insertion must not advance the removed-GER hash chain" ); @@ -444,25 +371,7 @@ async fn remove_ger_then_reinsert_succeeds() -> anyhow::Result<()> { #[tokio::test] async fn remove_ger_non_remover_sender_reverts() -> anyhow::Result<()> { let mut builder = MockChain::builder(); - - let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { - auth_scheme: AuthScheme::Falcon512Poseidon2, - })?; - - let bridge_seed = builder.rng_mut().draw_word(); - let bridge_account = create_existing_bridge_account( - bridge_seed, - bridge_admin.id(), - ger_manager.id(), - ger_remover.id(), - ); - builder.add_account(bridge_account.clone())?; + let (bridge_account, ger_manager, _ger_remover) = setup_bridge(&mut builder)?; // Register a GER first so the failure is exclusively due to the sender check. let ger = ExitRoot::from(GER_BYTES); From 668eef17231735e6e0899c89465dcd7aaa4a659c Mon Sep 17 00:00:00 2001 From: riemann Date: Mon, 15 Jun 2026 17:06:03 -0400 Subject: [PATCH 5/8] docs(agglayer): clarify removed-GER hash-chain proc I/O and initial value - load_removed_ger_hash_chain_data only appends OLD_CHAIN and passes the GER preimage through untouched, so its doc declares Inputs: [] / Outputs: [OLD_CHAIN[8]]. - Document that the chain seeds from the empty word [0, 0, 0, 0], so the first removal yields keccak256(0..0 || ger), matching the zero-initialized removedGERHashChain bytes32 in Solidity's GlobalExitRootManagerL2SovereignChain. --- .../asm/agglayer/bridge/bridge_config.masm | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index b76624ecf7..fcfa8370bf 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -33,6 +33,10 @@ const FAUCET_METADATA_MAP_SLOT = word("agglayer::bridge::faucet_metadata_map") # Storage slot constants for the removed GER hash chain. # The chain is updated as `keccak256(prev_chain || ger)` on each removal and stored in two # separate value slots (lo/hi) since a Word holds only 4 felts but the chain is 8 felts. +# Both slots default to the empty value [0, 0, 0, 0] until the first removal, so the chain starts +# from 32 zero bytes and the first removal yields `keccak256(0..0 || ger)`. This matches the +# zero-initialized `removedGERHashChain` (a `bytes32`) in Solidity's +# `GlobalExitRootManagerL2SovereignChain`. const REMOVED_GER_HASH_CHAIN_LO_SLOT = word("agglayer::bridge::removed_ger_hash_chain_lo") const REMOVED_GER_HASH_CHAIN_HI_SLOT = word("agglayer::bridge::removed_ger_hash_chain_hi") @@ -656,10 +660,11 @@ proc update_removed_ger_hash_chain # => [] end -#! Loads the old removed-GER hash chain onto the stack, below the existing GER preimage. +#! Pushes the old removed-GER hash chain onto the stack, above the GER preimage which is left +#! untouched below it. #! -#! Inputs: [GER_LOWER[4], GER_UPPER[4]] -#! Outputs: [OLD_CHAIN[8], GER_LOWER, GER_UPPER] +#! Inputs: [] +#! Outputs: [OLD_CHAIN[8]] #! #! Invocation: exec proc load_removed_ger_hash_chain_data From 876549dd179fadffdd1c8f0fc08e133af1c24abc Mon Sep 17 00:00:00 2001 From: riemann Date: Mon, 15 Jun 2026 17:06:55 -0400 Subject: [PATCH 6/8] docs(agglayer): document GER-removal rationale and re-registration caveat Adds the conditions under which a GER removal is expected (an exceptional, emergency-only response to an invalid/erroneous upstream root) and its impact on the Miden chain (unprocessed CLAIMs against the removed GER revert; already-processed claims are unaffected). Makes explicit that a compromised or faulty GER manager can re-register a removed GER and undo the emergency patch, bounded only once role rotation lands (#2706). --- crates/miden-agglayer/SPEC.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/miden-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index a5af38d995..cd00c48d25 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -143,11 +143,25 @@ A separate GER Remover role can revoke a previously-registered GER by sending a `GlobalExitRootManagerL2SovereignChain`, providing an auditable record of every removal. +GER removal is an exceptional, emergency-only control: under normal operation GERs are +only ever injected, never removed. A removal is expected when a GER was registered that +should not have been - for example because an invalid or malicious exit root was +propagated from the upstream AggLayer/L1 state, or a GER was injected in error. Removing +the GER closes the claim window it opened: any `CLAIM` note that has not yet been +processed and that references the removed GER will fail `assert_valid_ger` and revert. +Claims that were already processed against the GER are not reversed - removal only +prevents future claims against that root. + Note that removal does not blocklist a GER permanently: because the map entry is reset to the empty word, the GER manager can re-register the same GER via a subsequent -`UPDATE_GER` note (re-insertion does not touch the removal chain). The removed-GER hash -chain is therefore an append-only log of removal events, not a registry of currently -revoked GERs - a GER listed in the chain may have been revived since its removal. +`UPDATE_GER` note (re-insertion does not touch the removal chain). This is a security +caveat worth calling out: a compromised or faulty GER manager can undo a `REMOVE_GER` +emergency patch and re-open the very claim window the removal was meant to close. The +split between the manager and remover roles bounds this only if the offending role can be +rotated out, which is not yet supported +([#2706](https://github.com/0xMiden/protocol/issues/2706)). The removed-GER hash chain is +therefore an append-only log of removal events, not a registry of currently revoked GERs +- a GER listed in the chain may have been revived since its removal. TODO: No hash chain tracks GER insertions for proof generation ([#2707](https://github.com/0xMiden/protocol/issues/2707)). From 32c7d1d8f4fef61240cf5d875aafc3644867e67d Mon Sep 17 00:00:00 2001 From: riemann Date: Mon, 15 Jun 2026 17:08:15 -0400 Subject: [PATCH 7/8] test(agglayer): drop redundant remove_ger_unknown_ger_reverts remove_ger_double_remove_reverts already exercises the ERR_GER_NOT_FOUND path (and the stronger invariant that a removed entry stays at [0,0,0,0] and cannot be re-removed), subsuming the standalone never-registered case. --- .../tests/agglayer/remove_ger.rs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/crates/miden-testing/tests/agglayer/remove_ger.rs b/crates/miden-testing/tests/agglayer/remove_ger.rs index a944de0b63..d23c79838b 100644 --- a/crates/miden-testing/tests/agglayer/remove_ger.rs +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -116,30 +116,6 @@ async fn remove_ger_note_clears_storage_and_updates_chain() -> anyhow::Result<() Ok(()) } -/// Tests that REMOVE_GER reverts when the GER was never registered in the first place. -#[tokio::test] -async fn remove_ger_unknown_ger_reverts() -> anyhow::Result<()> { - let mut builder = MockChain::builder(); - let (bridge_account, _ger_manager, ger_remover) = setup_bridge(&mut builder)?; - - let ger = ExitRoot::from(GER_BYTES); - let remove_ger_note = - RemoveGerNote::create(ger, ger_remover.id(), bridge_account.id(), builder.rng_mut())?; - builder.add_output_note(RawOutputNote::Full(remove_ger_note.clone())); - - let mock_chain = builder.build()?; - - let result = mock_chain - .build_tx_context(bridge_account.id(), &[remove_ger_note.id()], &[])? - .build()? - .execute() - .await; - - assert_transaction_executor_error!(result, ERR_GER_NOT_FOUND); - - Ok(()) -} - /// Tests that removing a GER from the middle of a sequence of inserted GERs leaves the /// other GERs in place. Inserts A, B, C, removes B, and verifies that A and C remain /// registered while B does not. From bd1a18463c75fad84f94d0ef3033d645e261a74a Mon Sep 17 00:00:00 2001 From: riemann Date: Mon, 15 Jun 2026 17:10:45 -0400 Subject: [PATCH 8/8] refactor(agglayer): share GER note construction between UPDATE_GER and REMOVE_GER UpdateGerNote::create and RemoveGerNote::create built structurally identical notes (8 felts of GER storage, a network-account target on the bridge, public metadata, no assets), differing only in the note script. Extract the shared body into a crate-internal create_ger_note helper that each note type calls with its own script. --- crates/miden-agglayer/src/ger_note.rs | 72 ++++++++++++++++++++ crates/miden-agglayer/src/lib.rs | 1 + crates/miden-agglayer/src/remove_ger_note.rs | 40 +---------- crates/miden-agglayer/src/update_ger_note.rs | 40 +---------- 4 files changed, 79 insertions(+), 74 deletions(-) create mode 100644 crates/miden-agglayer/src/ger_note.rs diff --git a/crates/miden-agglayer/src/ger_note.rs b/crates/miden-agglayer/src/ger_note.rs new file mode 100644 index 0000000000..7ea151bb04 --- /dev/null +++ b/crates/miden-agglayer/src/ger_note.rs @@ -0,0 +1,72 @@ +//! Shared construction for the GER note builders (UPDATE_GER and REMOVE_GER). +//! +//! Both notes carry the same payload (8 felts of GER data), target the bridge account, are +//! always public, and carry no assets; they differ only in the note script they reference. + +extern crate alloc; + +use alloc::string::ToString; +use alloc::vec; + +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteAttachments, + NoteRecipient, + NoteScript, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; + +use crate::ExitRoot; + +/// Creates a GER note (UPDATE_GER or REMOVE_GER) carrying the given GER data and running the +/// provided note `script`. +/// +/// The two GER notes are structurally identical - 8 felts of GER storage, a network-account +/// target on the bridge, public metadata, and no assets - so this helper holds their shared +/// construction and each note type only supplies its own script. +/// +/// The note storage contains 8 felts: GER[0..7]. +/// +/// # Parameters +/// - `ger`: the Global Exit Root data the note carries +/// - `sender_account_id`: the account ID of the note creator (the GER manager or remover) +/// - `target_account_id`: the account ID that will consume this note (the bridge account) +/// - `script`: the note script to run (UPDATE_GER or REMOVE_GER) +/// - `rng`: random number generator for the note serial number +/// +/// # Errors +/// Returns an error if note creation fails. +pub(crate) fn create_ger_note( + ger: ExitRoot, + sender_account_id: AccountId, + target_account_id: AccountId, + script: NoteScript, + rng: &mut R, +) -> Result { + // Create note storage with 8 felts: GER[0..7] + let storage_values = ger.to_elements().to_vec(); + let note_storage = NoteStorage::new(storage_values)?; + + // Generate a serial number for the note + let serial_num = rng.draw_word(); + + let recipient = NoteRecipient::new(serial_num, script, note_storage); + + let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|e| NoteError::other(e.to_string()))?; + let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); + let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); + + // GER notes don't carry assets + let assets = NoteAssets::new(vec![])?; + + Ok(Note::with_attachments(assets, metadata, recipient, attachments)) +} diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 925c150579..7e06262f35 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -25,6 +25,7 @@ pub mod config_note; pub mod errors; pub mod eth_types; pub mod faucet; +mod ger_note; pub mod remove_ger_note; #[cfg(feature = "testing")] pub mod testing; diff --git a/crates/miden-agglayer/src/remove_ger_note.rs b/crates/miden-agglayer/src/remove_ger_note.rs index 7fd7905e34..828d159648 100644 --- a/crates/miden-agglayer/src/remove_ger_note.rs +++ b/crates/miden-agglayer/src/remove_ger_note.rs @@ -4,32 +4,16 @@ //! which are used to remove a Global Exit Root from the bridge account and fold it into the //! running removed-GER keccak256 hash chain. -extern crate alloc; - -use alloc::string::ToString; -use alloc::vec; - use miden_assembly::Library; use miden_assembly::serde::Deserializable; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; -use miden_protocol::note::{ - Note, - NoteAssets, - NoteAttachment, - NoteAttachments, - NoteRecipient, - NoteScript, - NoteScriptRoot, - NoteStorage, - NoteType, - PartialNoteMetadata, -}; -use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; use miden_utils_sync::LazyLock; use crate::ExitRoot; +use crate::ger_note::create_ger_note; // NOTE SCRIPT // ================================================================================================ @@ -92,24 +76,6 @@ impl RemoveGerNote { target_account_id: AccountId, rng: &mut R, ) -> Result { - // Create note storage with 8 felts: GER[0..7] - let storage_values = ger.to_elements().to_vec(); - - let note_storage = NoteStorage::new(storage_values)?; - - // Generate a serial number for the note - let serial_num = rng.draw_word(); - - let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); - - let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?; - let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); - let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); - - // REMOVE_GER notes don't carry assets - let assets = NoteAssets::new(vec![])?; - - Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + create_ger_note(ger, sender_account_id, target_account_id, Self::script(), rng) } } diff --git a/crates/miden-agglayer/src/update_ger_note.rs b/crates/miden-agglayer/src/update_ger_note.rs index 3f1e7ef89a..ad5189862c 100644 --- a/crates/miden-agglayer/src/update_ger_note.rs +++ b/crates/miden-agglayer/src/update_ger_note.rs @@ -3,32 +3,16 @@ //! This module provides helpers for creating UPDATE_GER notes, //! which are used to update the Global Exit Root in the bridge account. -extern crate alloc; - -use alloc::string::ToString; -use alloc::vec; - use miden_assembly::Library; use miden_assembly::serde::Deserializable; use miden_protocol::account::AccountId; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::errors::NoteError; -use miden_protocol::note::{ - Note, - NoteAssets, - NoteAttachment, - NoteAttachments, - NoteRecipient, - NoteScript, - NoteScriptRoot, - NoteStorage, - NoteType, - PartialNoteMetadata, -}; -use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_protocol::note::{Note, NoteScript, NoteScriptRoot}; use miden_utils_sync::LazyLock; use crate::ExitRoot; +use crate::ger_note::create_ger_note; // NOTE SCRIPT // ================================================================================================ @@ -91,24 +75,6 @@ impl UpdateGerNote { target_account_id: AccountId, rng: &mut R, ) -> Result { - // Create note storage with 8 felts: GER[0..7] - let storage_values = ger.to_elements().to_vec(); - - let note_storage = NoteStorage::new(storage_values)?; - - // Generate a serial number for the note - let serial_num = rng.draw_word(); - - let recipient = NoteRecipient::new(serial_num, Self::script(), note_storage); - - let attachment = NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) - .map_err(|e| NoteError::other(e.to_string()))?; - let attachments = NoteAttachments::from(NoteAttachment::from(attachment)); - let metadata = PartialNoteMetadata::new(sender_account_id, NoteType::Public); - - // UPDATE_GER notes don't carry assets - let assets = NoteAssets::new(vec![])?; - - Ok(Note::with_attachments(assets, metadata, recipient, attachments)) + create_ger_note(ger, sender_account_id, target_account_id, Self::script(), rng) } }