diff --git a/CHANGELOG.md b/CHANGELOG.md index a489c8ae51..c0414673b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ ### Features +- 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 ([#2837](https://github.com/0xMiden/protocol/pull/2837)). - Added single-word `Array` standard ([#2203](https://github.com/0xMiden/miden-base/pull/2203)). - Added `SignedBlock` struct ([#2355](https://github.com/0xMiden/miden-base/pull/2235)). - Enabled `CodeBuilder` to add advice map entries to compiled scripts ([#2275](https://github.com/0xMiden/miden-base/pull/2275)). diff --git a/bin/bench-transaction/src/context_setups.rs b/bin/bench-transaction/src/context_setups.rs index b399402fe1..beb35ea51c 100644 --- a/bin/bench-transaction/src/context_setups.rs +++ b/bin/bench-transaction/src/context_setups.rs @@ -178,10 +178,19 @@ pub async fn tx_consume_claim_note(data_source: ClaimDataSource) -> Result Result { auth_scheme: AuthScheme::Falcon512Poseidon2, })?; + // CREATE GER REMOVER ACCOUNT (not used in bridge-out, but required for bridge creation) + let ger_remover = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + // CREATE BRIDGE ACCOUNT 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-agglayer/SPEC.md b/crates/miden-agglayer/SPEC.md index cddf6461fd..f071cfa601 100644 --- a/crates/miden-agglayer/SPEC.md +++ b/crates/miden-agglayer/SPEC.md @@ -103,8 +103,23 @@ on Miden. The bridge consumes these notes: Subsequent CLAIM notes reference a GER that must be present in this map for the claim to be valid. -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. 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 4df7db2aac..24c0f1399b 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::protocol::account_id use miden::protocol::active_account @@ -12,6 +13,7 @@ const ERR_FAUCET_NOT_REGISTERED = "faucet is not registered in the bridge's fauc const ERR_TOKEN_NOT_REGISTERED = "token address 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 # ================================================================================================= @@ -19,10 +21,17 @@ 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") +# 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 const IS_FAUCET_REGISTERED_FLAG = 1 @@ -71,6 +80,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, 0, 0, 0]), 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, 0, 0, 0]. + 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.0.0.0.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 @@ -307,3 +381,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 4c38d5a019..2a3dc86a7e 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -5,10 +5,12 @@ # The bridge exposes: # - `register_faucet` 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::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..5da566de4c --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/REMOVE_GER.masm @@ -0,0 +1,69 @@ +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 GER is not currently registered in the bridge's GER map. +begin + 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, dest_ptr, 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 drop + # => [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 2ec155232b..7025c1eb6e 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -34,6 +34,7 @@ pub use crate::{ LeafData, MetadataHash, ProofData, + RemoveGerNote, SmtNode, UpdateGerNote, create_claim_note, @@ -58,10 +59,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") @@ -114,6 +127,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. @@ -122,7 +137,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::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (RPO(leaf_index, @@ -140,6 +160,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 { @@ -152,8 +173,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 @@ -171,11 +200,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 @@ -339,6 +383,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 // -------------------------------------------------------------------------------------------- @@ -414,6 +495,9 @@ impl AggLayerBridge { &*TOKEN_REGISTRY_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, @@ -425,6 +509,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()), @@ -436,6 +521,9 @@ impl From for AccountComponent { StorageSlot::with_empty_map(TOKEN_REGISTRY_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 dde029da27..54bdc4c4a9 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -28,6 +28,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; @@ -58,6 +59,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; @@ -169,10 +171,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()) .storage_mode(AccountStorageMode::Network) - .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id)) + .with_component(AggLayerBridge::new(bridge_admin_id, ger_manager_id, ger_remover_id)) } /// Creates a new bridge account with the standard configuration. @@ -182,8 +185,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) .with_auth_component(AccountComponent::from(NoAuth)) .build() .expect("bridge account should be valid") @@ -197,8 +201,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) .with_auth_component(AccountComponent::from(NoAuth)) .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..34849b28d0 --- /dev/null +++ b/crates/miden-agglayer/src/remove_ger_note.rs @@ -0,0 +1,116 @@ +//! 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::serde::Deserializable; +use miden_core::Word; +use miden_core::program::Program; +use miden_protocol::account::AccountId; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::errors::NoteError; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteAttachment, + NoteMetadata, + NoteRecipient, + NoteScript, + NoteStorage, + NoteType, +}; +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.masb")); + let program = + Program::read_from_bytes(bytes).expect("shipped REMOVE_GER script is well-formed"); + NoteScript::new(program) +}); + +// 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() -> Word { + 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 = NoteAttachment::from( + NetworkAccountTarget::new(target_account_id, NoteExecutionHint::Always) + .map_err(|e| NoteError::other(e.to_string()))?, + ); + let metadata = + NoteMetadata::new(sender_account_id, NoteType::Public).with_attachment(attachment); + + // REMOVE_GER notes don't carry assets + let assets = NoteAssets::new(vec![])?; + + Ok(Note::new(assets, metadata, recipient)) + } +} diff --git a/crates/miden-testing/tests/agglayer/bridge_in.rs b/crates/miden-testing/tests/agglayer/bridge_in.rs index 9e45a965ab..0a86372fd0 100644 --- a/crates/miden-testing/tests/agglayer/bridge_in.rs +++ b/crates/miden-testing/tests/agglayer/bridge_in.rs @@ -141,11 +141,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) @@ -442,10 +452,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 diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index e0a61d3e47..5a85106a31 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -70,10 +70,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())?; @@ -283,12 +289,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())?; @@ -394,11 +406,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())?; @@ -512,11 +530,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())?; @@ -530,6 +554,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())?; diff --git a/crates/miden-testing/tests/agglayer/config_bridge.rs b/crates/miden-testing/tests/agglayer/config_bridge.rs index f4e760ba58..a9668dcc5d 100644 --- a/crates/miden-testing/tests/agglayer/config_bridge.rs +++ b/crates/miden-testing/tests/agglayer/config_bridge.rs @@ -36,11 +36,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())?; diff --git a/crates/miden-testing/tests/agglayer/faucet_helpers.rs b/crates/miden-testing/tests/agglayer/faucet_helpers.rs index 84ea5b226c..f9479576c7 100644 --- a/crates/miden-testing/tests/agglayer/faucet_helpers.rs +++ b/crates/miden-testing/tests/agglayer/faucet_helpers.rs @@ -23,11 +23,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 6f61b354ee..17f6d3924b 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -6,6 +6,7 @@ mod faucet_helpers; mod global_index; mod leaf_utils; mod merkle_tree_frontier; +mod remove_ger; mod solidity_miden_address_conversion; pub mod test_utils; mod update_ger; 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..544b260e15 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/remove_ger.rs @@ -0,0 +1,426 @@ +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, +]; + +/// 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.clone())?; + assert!(!is_registered, "GER should have been removed from the bridge account"); + + // Expected chain = keccak256(0...0 || ger_bytes) + let mut preimage = [0u8; 64]; + preimage[32..].copy_from_slice(&GER_BYTES); + let expected_chain_felts: alloc::vec::Vec<_> = + KeccakPreimage::new(preimage.to_vec()).digest().as_ref().to_vec(); + let mut expected_chain_bytes = [0u8; 32]; + for (i, felt) in expected_chain_felts.iter().enumerate() { + let limb = u32::try_from(felt.as_canonical_u64()).expect("felt fits in u32"); + expected_chain_bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb.to_le_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.clone())?, + "GER A should still be registered after removing B" + ); + assert!( + !AggLayerBridge::is_ger_registered(ger_b, updated_bridge_account.clone())?, + "GER B should have been removed" + ); + assert!( + AggLayerBridge::is_ger_registered(ger_c, updated_bridge_account.clone())?, + "GER C should still be registered after removing B" + ); + + let mut preimage = [0u8; 64]; + preimage[32..].copy_from_slice(&ger_b_bytes); + let expected_chain_felts: alloc::vec::Vec<_> = + KeccakPreimage::new(preimage.to_vec()).digest().as_ref().to_vec(); + let mut expected_chain_bytes = [0u8; 32]; + for (i, felt) in expected_chain_felts.iter().enumerate() { + let limb = u32::try_from(felt.as_canonical_u64()).expect("felt fits in u32"); + expected_chain_bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb.to_le_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 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.clone())?, + "GER should be registered again after re-insertion" + ); + + let mut preimage = [0u8; 64]; + preimage[32..].copy_from_slice(&GER_BYTES); + let expected_chain_felts: alloc::vec::Vec<_> = + KeccakPreimage::new(preimage.to_vec()).digest().as_ref().to_vec(); + let mut expected_chain_bytes = [0u8; 32]; + for (i, felt) in expected_chain_felts.iter().enumerate() { + let limb = u32::try_from(felt.as_canonical_u64()).expect("felt fits in u32"); + expected_chain_bytes[i * 4..(i + 1) * 4].copy_from_slice(&limb.to_le_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 ed8126a0d0..3d91ab9adf 100644 --- a/crates/miden-testing/tests/agglayer/update_ger.rs +++ b/crates/miden-testing/tests/agglayer/update_ger.rs @@ -64,11 +64,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)