From 689183746b13f07957141702ed9188cd301c2e59 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Wed, 15 Apr 2026 21:47:47 +0000 Subject: [PATCH 1/4] feat: add emergency pause mechanism for AggLayer bridge Add an `emergency_paused` flag in bridge storage that, when set, blocks all 4 public entry points (bridge_out, claim, register_faucet, update_ger). The bridge admin can toggle the flag via a dedicated EMERGENCY_PAUSE note type gated by assert_sender_is_bridge_admin. Closes #2696 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../asm/agglayer/bridge/bridge_config.masm | 57 +++++ .../asm/agglayer/bridge/bridge_in.masm | 3 + .../asm/agglayer/bridge/bridge_out.masm | 3 + .../miden-agglayer/asm/components/bridge.masm | 1 + .../asm/note_scripts/emergency_pause.masm | 63 ++++++ crates/miden-agglayer/src/bridge.rs | 36 +++ .../src/emergency_pause_note.rs | 109 +++++++++ crates/miden-agglayer/src/errors/agglayer.rs | 7 + crates/miden-agglayer/src/lib.rs | 2 + .../tests/agglayer/emergency_pause.rs | 213 ++++++++++++++++++ crates/miden-testing/tests/agglayer/mod.rs | 1 + 11 files changed, 495 insertions(+) create mode 100644 crates/miden-agglayer/asm/note_scripts/emergency_pause.masm create mode 100644 crates/miden-agglayer/src/emergency_pause_note.rs create mode 100644 crates/miden-testing/tests/agglayer/emergency_pause.rs diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 4df7db2aac..8078ea8083 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -12,6 +12,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_BRIDGE_IS_PAUSED = "bridge is currently paused" # CONSTANTS # ================================================================================================= @@ -22,6 +23,7 @@ const GER_MANAGER_SLOT = word("agglayer::bridge::ger_manager_account_id") const GER_MAP_STORAGE_SLOT = word("agglayer::bridge::ger_map") const FAUCET_REGISTRY_MAP_SLOT = word("agglayer::bridge::faucet_registry_map") const TOKEN_REGISTRY_MAP_SLOT = word("agglayer::bridge::token_registry_map") +const PAUSED_SLOT = word("agglayer::bridge::paused") # Flags const GER_KNOWN_FLAG = 1 @@ -46,6 +48,9 @@ const TOKEN_ADDR_HASH_PTR = 0 #! #! Invocation: call pub proc update_ger + # assert the bridge is not paused. + exec.assert_not_paused + # assert the note sender is the global exit root manager. exec.assert_sender_is_ger_manager # => [GER_LOWER[4], GER_UPPER[4], pad(8)] @@ -117,6 +122,9 @@ end #! #! Invocation: call pub proc register_faucet + # assert the bridge is not paused. + exec.assert_not_paused + # assert the note sender is the bridge admin. exec.assert_sender_is_bridge_admin # => [origin_token_addr(5), faucet_id_suffix, faucet_id_prefix, pad(9)] @@ -228,9 +236,58 @@ proc lookup_faucet_by_token_address # => [faucet_id_suffix, faucet_id_prefix] end +#! Sets or clears the emergency paused flag in bridge storage. +#! +#! Only the bridge admin can invoke this procedure. NOT guarded by assert_not_paused so the admin +#! can always unpause the bridge. +#! +#! Inputs: [paused_flag, pad(15)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - paused_flag: 1 to pause, 0 to unpause. +#! +#! Panics if: +#! - the note sender is not the bridge admin. +#! +#! Invocation: call +pub proc set_emergency_paused + # assert the note sender is the bridge admin. + exec.assert_sender_is_bridge_admin + # => [paused_flag, pad(15)] + + # The top 4 elements are already [paused_flag, 0, 0, 0] - the VALUE word to store. + push.PAUSED_SLOT[0..2] + exec.native_account::set_item + # => [OLD_VALUE, pad(12)] + + dropw + # => [pad(16)] +end + # HELPER PROCEDURES # ================================================================================================= +#! Asserts that the bridge is not currently paused. +#! +#! Reads the paused flag from storage and panics if it is non-zero. +#! +#! Inputs: [any(16)] +#! Outputs: [any(16)] +#! +#! Panics if: +#! - the bridge is currently paused. +#! +#! Invocation: exec +proc assert_not_paused + push.PAUSED_SLOT[0..2] + exec.active_account::get_item + # => [paused_flag, 0, 0, 0, any(16)] + + assertz.err=ERR_BRIDGE_IS_PAUSED drop drop drop + # => [any(16)] +end + #! Hashes a 5-felt origin token address using Poseidon2. #! #! Writes the 5 felts to memory and computes the Poseidon2 hash. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm index 0364f4a3f0..c044b7a24d 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm @@ -199,6 +199,9 @@ const CLAIM_DEST_ID_SUFFIX_LOCAL = 1 #! Invocation: call @locals(2) # 0: dest_prefix, 1: dest_suffix pub proc claim + # assert the bridge is not paused. + exec.bridge_config::assert_not_paused + # Write output note faucet amount to memory movup.8 mem_store.CLAIM_OUTPUT_NOTE_FAUCET_AMOUNT # => [PROOF_DATA_KEY, LEAF_DATA_KEY, pad(8)] diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index db367d415f..7547b401c1 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -105,6 +105,9 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 pub proc bridge_out # => [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] + # assert the bridge is not paused. + exec.bridge_config::assert_not_paused + # Save ASSET to local memory for later BURN note creation locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::store diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 4c38d5a019..2e5e210325 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -12,3 +12,4 @@ pub use ::agglayer::bridge::bridge_config::register_faucet pub use ::agglayer::bridge::bridge_config::update_ger pub use ::agglayer::bridge::bridge_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out +pub use ::agglayer::bridge::bridge_config::set_emergency_paused diff --git a/crates/miden-agglayer/asm/note_scripts/emergency_pause.masm b/crates/miden-agglayer/asm/note_scripts/emergency_pause.masm new file mode 100644 index 0000000000..962be5b5dd --- /dev/null +++ b/crates/miden-agglayer/asm/note_scripts/emergency_pause.masm @@ -0,0 +1,63 @@ +use agglayer::bridge::bridge_config +use miden::protocol::active_note +use miden::standards::attachments::network_account_target + +# CONSTANTS +# ================================================================================================= + +const EMERGENCY_PAUSE_NOTE_NUM_STORAGE_ITEMS = 1 +const STORAGE_PTR = 0 + +# ERRORS +# ================================================================================================= + +const ERR_EMERGENCY_PAUSE_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS = "EMERGENCY_PAUSE script expects exactly 1 note storage item" +const ERR_EMERGENCY_PAUSE_TARGET_ACCOUNT_MISMATCH = "EMERGENCY_PAUSE note attachment target account does not match consuming account" + +# NOTE SCRIPT +# ================================================================================================= + +#! Agglayer Bridge EMERGENCY_PAUSE script: sets or clears the emergency paused flag by calling +#! bridge_config::set_emergency_paused. +#! +#! 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 bridge admin. +#! +#! Requires that the account exposes: +#! - agglayer::bridge_config::set_emergency_paused procedure. +#! +#! Inputs: [ARGS, pad(12)] +#! Outputs: [pad(16)] +#! +#! NoteStorage layout (1 felt total): +#! - paused_flag [0] : 1 felt (1 = pause, 0 = unpause) +#! +#! Panics if: +#! - account does not expose set_emergency_paused procedure. +#! - target account ID does not match the consuming account ID. +#! - number of note storage items is not exactly 1. +#! - the note sender is not the bridge admin. +begin + dropw + # => [pad(16)] + + # Ensure note attachment targets the consuming bridge account. + exec.network_account_target::active_account_matches_target_account + assert.err=ERR_EMERGENCY_PAUSE_TARGET_ACCOUNT_MISMATCH + # => [pad(16)] + + # Load note storage to memory + push.STORAGE_PTR exec.active_note::get_storage + # => [num_storage_items, dest_ptr, pad(16)] + + # Validate the number of storage items + push.EMERGENCY_PAUSE_NOTE_NUM_STORAGE_ITEMS assert_eq.err=ERR_EMERGENCY_PAUSE_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS drop + # => [pad(16)] + + # Load paused_flag word from note storage (replaces top 4, depth-neutral) + mem_loadw_le.STORAGE_PTR + # => [paused_flag, 0, 0, 0, pad(12)] + + call.bridge_config::set_emergency_paused + # => [pad(16)] +end diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index 2ec155232b..a31717b836 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -71,6 +71,14 @@ static TOKEN_REGISTRY_MAP_SLOT_NAME: LazyLock = LazyLock::new(| .expect("token registry map storage slot name should be valid") }); +// emergency pause +// ------------------------------------------------------------------------------------------------ + +static PAUSED_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("agglayer::bridge::paused") + .expect("paused storage slot name should be valid") +}); + // bridge in // ------------------------------------------------------------------------------------------------ @@ -125,6 +133,8 @@ static LET_NUM_LEAVES_SLOT_NAME: LazyLock = LazyLock::new(|| { /// - [`Self::ger_map_slot_name`]: Stores the GERs. /// - [`Self::faucet_registry_map_slot_name`]: Stores the faucet registry map. /// - [`Self::token_registry_map_slot_name`]: Stores the token address → faucet ID map. +/// - [`Self::paused_slot_name`]: Stores the emergency paused flag (\[0, 0, 0, 0\] = active, \[1, 0, +/// 0, 0\] = paused). /// - [`Self::claim_nullifiers_slot_name`]: Stores the CLAIM note nullifiers map (RPO(leaf_index, /// source_bridge_network) → \[1, 0, 0, 0\]). /// - [`Self::cgi_chain_hash_lo_slot_name`]: Stores the lower 128 bits of the CGI chain hash. @@ -186,6 +196,13 @@ impl AggLayerBridge { &TOKEN_REGISTRY_MAP_SLOT_NAME } + // --- emergency pause -- + + /// Storage slot name for the emergency paused flag. + pub fn paused_slot_name() -> &'static StorageSlotName { + &PAUSED_SLOT_NAME + } + // --- bridge in -------- /// Storage slot name for the CLAIM note nullifiers map. @@ -225,6 +242,23 @@ impl AggLayerBridge { &LET_NUM_LEAVES_SLOT_NAME } + /// Returns whether the bridge is currently paused. + /// + /// # Errors + /// + /// Returns an error if: + /// - the provided account is not an [`AggLayerBridge`] account. + pub fn is_paused(bridge_account: &Account) -> Result { + Self::assert_bridge_account(bridge_account)?; + + let paused_word = bridge_account + .storage() + .get_item(Self::paused_slot_name()) + .expect("provided account should have AggLayer Bridge specific storage slots"); + + Ok(paused_word.to_vec()[0] != ZERO) + } + /// Returns a boolean indicating whether the provided GER is present in storage of the provided /// bridge account. /// @@ -416,6 +450,7 @@ impl AggLayerBridge { &*GER_MANAGER_ID_SLOT_NAME, &*CGI_CHAIN_HASH_LO_SLOT_NAME, &*CGI_CHAIN_HASH_HI_SLOT_NAME, + &*PAUSED_SLOT_NAME, &*CLAIM_NULLIFIERS_SLOT_NAME, ] } @@ -438,6 +473,7 @@ impl From for AccountComponent { StorageSlot::with_value(GER_MANAGER_ID_SLOT_NAME.clone(), ger_manager_word), StorageSlot::with_value(CGI_CHAIN_HASH_LO_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_value(CGI_CHAIN_HASH_HI_SLOT_NAME.clone(), Word::empty()), + StorageSlot::with_value(PAUSED_SLOT_NAME.clone(), Word::empty()), StorageSlot::with_empty_map(CLAIM_NULLIFIERS_SLOT_NAME.clone()), ]; bridge_component(bridge_storage_slots) diff --git a/crates/miden-agglayer/src/emergency_pause_note.rs b/crates/miden-agglayer/src/emergency_pause_note.rs new file mode 100644 index 0000000000..83159360e8 --- /dev/null +++ b/crates/miden-agglayer/src/emergency_pause_note.rs @@ -0,0 +1,109 @@ +//! EMERGENCY_PAUSE note creation utilities. +//! +//! This module provides helpers for creating EMERGENCY_PAUSE notes, +//! which are used to set or clear the emergency paused flag on the bridge account. + +extern crate alloc; + +use alloc::string::ToString; +use alloc::vec; + +use miden_assembly::serde::Deserializable; +use miden_core::{Felt, Word}; +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_protocol::vm::Program; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint}; +use miden_utils_sync::LazyLock; + +// NOTE SCRIPT +// ================================================================================================ + +// Initialize the EMERGENCY_PAUSE note script only once +static EMERGENCY_PAUSE_SCRIPT: LazyLock = LazyLock::new(|| { + let bytes = + include_bytes!(concat!(env!("OUT_DIR"), "/assets/note_scripts/emergency_pause.masb")); + let program = + Program::read_from_bytes(bytes).expect("shipped EMERGENCY_PAUSE script is well-formed"); + NoteScript::new(program) +}); + +// EMERGENCY_PAUSE NOTE +// ================================================================================================ + +/// EMERGENCY_PAUSE note. +/// +/// This note is used to set or clear the emergency paused flag on the bridge account. +/// It carries a single felt (1 = pause, 0 = unpause) and is always public. +pub struct EmergencyPauseNote; + +impl EmergencyPauseNote { + // CONSTANTS + // -------------------------------------------------------------------------------------------- + + /// Expected number of storage items for an EMERGENCY_PAUSE note. + pub const NUM_STORAGE_ITEMS: usize = 1; + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the EMERGENCY_PAUSE note script. + pub fn script() -> NoteScript { + EMERGENCY_PAUSE_SCRIPT.clone() + } + + /// Returns the EMERGENCY_PAUSE note script root. + pub fn script_root() -> Word { + EMERGENCY_PAUSE_SCRIPT.root() + } + + // BUILDERS + // -------------------------------------------------------------------------------------------- + + /// Creates an EMERGENCY_PAUSE note with the given paused flag. + /// + /// The note storage contains 1 felt: `paused_flag` (1 = pause, 0 = unpause). + /// + /// # Parameters + /// - `paused`: Whether to pause (`true`) or unpause (`false`) the bridge + /// - `sender_account_id`: The account ID of the note creator (must be bridge admin) + /// - `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( + paused: bool, + sender_account_id: AccountId, + target_account_id: AccountId, + rng: &mut R, + ) -> Result { + let paused_flag = if paused { Felt::ONE } else { Felt::ZERO }; + let note_storage = NoteStorage::new(vec![paused_flag])?; + + 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); + + let assets = NoteAssets::new(vec![])?; + + Ok(Note::new(assets, metadata, recipient)) + } +} diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs index 2a3d08335a..c5d3213fff 100644 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ b/crates/miden-agglayer/src/errors/agglayer.rs @@ -16,6 +16,8 @@ pub const ERR_B2AGG_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS: MasmError = MasmError::f /// Error Message: "B2AGG script requires exactly 1 note asset" pub const ERR_B2AGG_WRONG_NUMBER_OF_ASSETS: MasmError = MasmError::from_static_str("B2AGG script requires exactly 1 note asset"); +/// Error Message: "bridge is currently paused" +pub const ERR_BRIDGE_IS_PAUSED: MasmError = MasmError::from_static_str("bridge is currently paused"); /// Error Message: "mainnet flag must be 1 for a mainnet deposit" pub const ERR_BRIDGE_NOT_MAINNET: MasmError = MasmError::from_static_str("mainnet flag must be 1 for a mainnet deposit"); /// Error Message: "mainnet flag must be 0 for a rollup deposit" @@ -31,6 +33,11 @@ pub const ERR_CONFIG_AGG_BRIDGE_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError:: /// Error Message: "CONFIG_AGG_BRIDGE expects exactly 7 note storage items" pub const ERR_CONFIG_AGG_BRIDGE_UNEXPECTED_STORAGE_ITEMS: MasmError = MasmError::from_static_str("CONFIG_AGG_BRIDGE expects exactly 7 note storage items"); +/// Error Message: "EMERGENCY_PAUSE note attachment target account does not match consuming account" +pub const ERR_EMERGENCY_PAUSE_TARGET_ACCOUNT_MISMATCH: MasmError = MasmError::from_static_str("EMERGENCY_PAUSE note attachment target account does not match consuming account"); +/// Error Message: "EMERGENCY_PAUSE script expects exactly 1 note storage item" +pub const ERR_EMERGENCY_PAUSE_UNEXPECTED_NUMBER_OF_STORAGE_ITEMS: MasmError = MasmError::from_static_str("EMERGENCY_PAUSE script expects exactly 1 note storage item"); + /// Error Message: "faucet is not registered in the bridge's faucet registry" pub const ERR_FAUCET_NOT_REGISTERED: MasmError = MasmError::from_static_str("faucet is not registered in the bridge's faucet registry"); diff --git a/crates/miden-agglayer/src/lib.rs b/crates/miden-agglayer/src/lib.rs index 21ba7c40be..5a1382bddb 100644 --- a/crates/miden-agglayer/src/lib.rs +++ b/crates/miden-agglayer/src/lib.rs @@ -25,6 +25,7 @@ pub mod b2agg_note; pub mod bridge; pub mod claim_note; pub mod config_note; +pub mod emergency_pause_note; pub mod errors; pub mod eth_types; pub mod faucet; @@ -44,6 +45,7 @@ pub use claim_note::{ create_claim_note, }; pub use config_note::ConfigAggBridgeNote; +pub use emergency_pause_note::EmergencyPauseNote; #[cfg(any(test, feature = "testing"))] pub use eth_types::GlobalIndexExt; pub use eth_types::{ diff --git a/crates/miden-testing/tests/agglayer/emergency_pause.rs b/crates/miden-testing/tests/agglayer/emergency_pause.rs new file mode 100644 index 0000000000..8aec78cb46 --- /dev/null +++ b/crates/miden-testing/tests/agglayer/emergency_pause.rs @@ -0,0 +1,213 @@ +extern crate alloc; + +use miden_agglayer::{ + AggLayerBridge, + EmergencyPauseNote, + ExitRoot, + UpdateGerNote, + create_existing_bridge_account, +}; +use miden_protocol::account::auth::AuthScheme; +use miden_protocol::crypto::rand::FeltRng; +use miden_protocol::transaction::RawOutputNote; +use miden_testing::{Auth, MockChain}; + +/// Tests that pausing the bridge blocks update_ger. +/// +/// Flow: +/// 1. Create admin and GER manager accounts +/// 2. Create bridge account +/// 3. Admin pauses the bridge via EMERGENCY_PAUSE note +/// 4. GER manager sends UPDATE_GER note - should panic because bridge is paused +#[tokio::test] +async fn test_pause_blocks_update_ger() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + // Step 1: Pause the bridge + let pause_note = EmergencyPauseNote::create( + true, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + let mock_chain = builder.build()?; + + let tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[pause_note.id()], &[])? + .build()?; + let executed_transaction = tx_context.execute().await?; + + let mut paused_bridge = bridge_account.clone(); + paused_bridge.apply_delta(executed_transaction.account_delta())?; + assert!(AggLayerBridge::is_paused(&paused_bridge)?, "bridge should be paused"); + + // Step 2: Try to update GER while paused - should fail + let mut builder2 = MockChain::builder(); + builder2.add_account(ger_manager.clone())?; + builder2.add_account(paused_bridge.clone())?; + + let ger_bytes: [u8; 32] = [0xab; 32]; + let ger = ExitRoot::from(ger_bytes); + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), paused_bridge.id(), builder2.rng_mut())?; + builder2.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + let mock_chain2 = builder2.build()?; + + let tx_context2 = mock_chain2 + .build_tx_context(paused_bridge.id(), &[update_ger_note.id()], &[])? + .build()?; + let result = tx_context2.execute().await; + assert!(result.is_err(), "update_ger should fail when bridge is paused"); + + Ok(()) +} + +/// Tests that unpausing the bridge restores operations. +/// +/// Flow: +/// 1. Admin pauses the bridge +/// 2. Admin unpauses the bridge +/// 3. GER manager sends UPDATE_GER note - should succeed +#[tokio::test] +async fn test_unpause_restores_operations() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + // Step 1: Pause the bridge + let pause_note = EmergencyPauseNote::create( + true, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + let mock_chain = builder.build()?; + + let tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[pause_note.id()], &[])? + .build()?; + let executed_transaction = tx_context.execute().await?; + + let mut paused_bridge = bridge_account.clone(); + paused_bridge.apply_delta(executed_transaction.account_delta())?; + assert!(AggLayerBridge::is_paused(&paused_bridge)?, "bridge should be paused"); + + // Step 2: Unpause the bridge + let mut builder2 = MockChain::builder(); + builder2.add_account(bridge_admin.clone())?; + builder2.add_account(paused_bridge.clone())?; + + let unpause_note = EmergencyPauseNote::create( + false, + bridge_admin.id(), + paused_bridge.id(), + builder2.rng_mut(), + )?; + builder2.add_output_note(RawOutputNote::Full(unpause_note.clone())); + let mock_chain2 = builder2.build()?; + + // Execute unpause + let tx_context2 = mock_chain2 + .build_tx_context(paused_bridge.id(), &[unpause_note.id()], &[])? + .build()?; + let executed_transaction2 = tx_context2.execute().await?; + + let mut unpaused_bridge = paused_bridge.clone(); + unpaused_bridge.apply_delta(executed_transaction2.account_delta())?; + assert!(!AggLayerBridge::is_paused(&unpaused_bridge)?, "bridge should be unpaused"); + + // Step 3: Verify update_ger succeeds on the unpaused bridge + let ger_bytes: [u8; 32] = [0xcd; 32]; + let ger = ExitRoot::from(ger_bytes); + + let mut builder3 = MockChain::builder(); + builder3.add_account(ger_manager.clone())?; + builder3.add_account(unpaused_bridge.clone())?; + + let update_ger_note = + UpdateGerNote::create(ger, ger_manager.id(), unpaused_bridge.id(), builder3.rng_mut())?; + builder3.add_output_note(RawOutputNote::Full(update_ger_note.clone())); + let mock_chain3 = builder3.build()?; + + let tx_context3 = mock_chain3 + .build_tx_context(unpaused_bridge.id(), &[update_ger_note.id()], &[])? + .build()?; + let executed_transaction3 = tx_context3.execute().await?; + + let mut final_bridge = unpaused_bridge.clone(); + final_bridge.apply_delta(executed_transaction3.account_delta())?; + assert!( + AggLayerBridge::is_ger_registered(ger, final_bridge)?, + "GER should be registered after unpause" + ); + + Ok(()) +} + +/// Tests that a non-admin cannot pause the bridge. +/// +/// Flow: +/// 1. Create admin, GER manager, and a random non-admin account +/// 2. Non-admin sends EMERGENCY_PAUSE note - should panic with ERR_SENDER_NOT_BRIDGE_ADMIN +#[tokio::test] +async fn test_non_admin_cannot_pause() -> 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, + })?; + + // Non-admin account + let non_admin = 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()); + builder.add_account(bridge_account.clone())?; + + // Non-admin tries to pause + let pause_note = + EmergencyPauseNote::create(true, non_admin.id(), bridge_account.id(), builder.rng_mut())?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + let mock_chain = builder.build()?; + + let tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[pause_note.id()], &[])? + .build()?; + let result = tx_context.execute().await; + assert!(result.is_err(), "non-admin should not be able to pause the bridge"); + + Ok(()) +} diff --git a/crates/miden-testing/tests/agglayer/mod.rs b/crates/miden-testing/tests/agglayer/mod.rs index 6f61b354ee..e75a988e56 100644 --- a/crates/miden-testing/tests/agglayer/mod.rs +++ b/crates/miden-testing/tests/agglayer/mod.rs @@ -2,6 +2,7 @@ pub mod asset_conversion; mod bridge_in; mod bridge_out; mod config_bridge; +mod emergency_pause; mod faucet_helpers; mod global_index; mod leaf_utils; From 1daad65678d89dd84fb056ef56dc142dee805476 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 16 Apr 2026 09:10:51 +0000 Subject: [PATCH 2/4] fix: address review feedback on emergency pause - Add "bridge is currently paused" panic condition to doc comments on all 4 guarded procedures (update_ger, register_faucet, claim, bridge_out) - Validate paused_flag is 0 or 1 in set_emergency_paused (defense-in-depth) - Update component wrapper comment to list set_emergency_paused Co-Authored-By: Claude Opus 4.6 (1M context) --- .../miden-agglayer/asm/agglayer/bridge/bridge_config.masm | 8 ++++++++ crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm | 1 + crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm | 3 +++ crates/miden-agglayer/asm/components/bridge.masm | 1 + crates/miden-agglayer/src/errors/agglayer.rs | 2 ++ 5 files changed, 15 insertions(+) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm index 8078ea8083..76476b0f34 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_config.masm @@ -13,6 +13,7 @@ const ERR_TOKEN_NOT_REGISTERED = "token address is not registered in the bridge' 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_BRIDGE_IS_PAUSED = "bridge is currently paused" +const ERR_INVALID_PAUSE_FLAG = "paused flag must be 0 or 1" # CONSTANTS # ================================================================================================= @@ -44,6 +45,7 @@ const TOKEN_ADDR_HASH_PTR = 0 #! Outputs: [pad(16)] #! #! Panics if: +#! - the bridge is currently paused. #! - the note sender is not the global exit root manager. #! #! Invocation: call @@ -118,6 +120,7 @@ end #! Outputs: [pad(16)] #! #! Panics if: +#! - the bridge is currently paused. #! - the note sender is not the bridge admin. #! #! Invocation: call @@ -249,6 +252,7 @@ end #! #! Panics if: #! - the note sender is not the bridge admin. +#! - paused_flag is not 0 or 1. #! #! Invocation: call pub proc set_emergency_paused @@ -256,6 +260,10 @@ pub proc set_emergency_paused exec.assert_sender_is_bridge_admin # => [paused_flag, pad(15)] + # validate paused_flag is 0 or 1 + dup u32lt.2 assert.err=ERR_INVALID_PAUSE_FLAG + # => [paused_flag, pad(15)] + # The top 4 elements are already [paused_flag, 0, 0, 0] - the VALUE word to store. push.PAUSED_SLOT[0..2] exec.native_account::set_item diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm index c044b7a24d..c8a28880d0 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_in.masm @@ -192,6 +192,7 @@ const CLAIM_DEST_ID_SUFFIX_LOCAL = 1 #! } #! #! Panics if: +#! - the bridge is currently paused. #! - the leaf type is not 0 (not an asset claim). #! - the Merkle proof validation fails. #! - the origin token address is not registered in the bridge's token registry. diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index 7547b401c1..3b175190f9 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -100,6 +100,9 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! - dest_network_id is the u32 destination network/chain ID. #! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. #! +#! Panics if: +#! - the bridge is currently paused. +#! #! Invocation: call @locals(14) pub proc bridge_out diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 2e5e210325..3f3c498ee4 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -5,6 +5,7 @@ # The bridge exposes: # - `register_faucet` from the bridge_config module # - `update_ger` from the bridge_config module +# - `set_emergency_paused` from the bridge_config module # - `claim` for bridge-in # - `bridge_out` for bridge-out diff --git a/crates/miden-agglayer/src/errors/agglayer.rs b/crates/miden-agglayer/src/errors/agglayer.rs index c5d3213fff..4eb9587058 100644 --- a/crates/miden-agglayer/src/errors/agglayer.rs +++ b/crates/miden-agglayer/src/errors/agglayer.rs @@ -49,6 +49,8 @@ pub const ERR_GER_NOT_FOUND: MasmError = MasmError::from_static_str("GER not fou /// Error Message: "invalid leaf type: only asset claims (leafType=0) are supported" pub const ERR_INVALID_LEAF_TYPE: MasmError = MasmError::from_static_str("invalid leaf type: only asset claims (leafType=0) are supported"); +/// Error Message: "paused flag must be 0 or 1" +pub const ERR_INVALID_PAUSE_FLAG: MasmError = MasmError::from_static_str("paused flag must be 0 or 1"); /// Error Message: "leading bits of global index must be zero" pub const ERR_LEADING_BITS_NON_ZERO: MasmError = MasmError::from_static_str("leading bits of global index must be zero"); From 072a5e0f4e31c5cb9a9bad1aced9b7358f160a70 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 16 Apr 2026 09:14:36 +0000 Subject: [PATCH 3/4] fix: assert specific error codes in tests, reorder re-exports - Assert ERR_BRIDGE_IS_PAUSED in test_pause_blocks_update_ger - Assert ERR_SENDER_NOT_BRIDGE_ADMIN in test_non_admin_cannot_pause - Reorder set_emergency_paused re-export alphabetically with other bridge_config exports Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/miden-agglayer/asm/components/bridge.masm | 2 +- .../miden-testing/tests/agglayer/emergency_pause.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/miden-agglayer/asm/components/bridge.masm b/crates/miden-agglayer/asm/components/bridge.masm index 3f3c498ee4..fa18cc149b 100644 --- a/crates/miden-agglayer/asm/components/bridge.masm +++ b/crates/miden-agglayer/asm/components/bridge.masm @@ -10,7 +10,7 @@ # - `bridge_out` for bridge-out pub use ::agglayer::bridge::bridge_config::register_faucet +pub use ::agglayer::bridge::bridge_config::set_emergency_paused pub use ::agglayer::bridge::bridge_config::update_ger pub use ::agglayer::bridge::bridge_in::claim pub use ::agglayer::bridge::bridge_out::bridge_out -pub use ::agglayer::bridge::bridge_config::set_emergency_paused diff --git a/crates/miden-testing/tests/agglayer/emergency_pause.rs b/crates/miden-testing/tests/agglayer/emergency_pause.rs index 8aec78cb46..30ec19e0a5 100644 --- a/crates/miden-testing/tests/agglayer/emergency_pause.rs +++ b/crates/miden-testing/tests/agglayer/emergency_pause.rs @@ -1,5 +1,6 @@ extern crate alloc; +use miden_agglayer::errors::{ERR_BRIDGE_IS_PAUSED, ERR_SENDER_NOT_BRIDGE_ADMIN}; use miden_agglayer::{ AggLayerBridge, EmergencyPauseNote, @@ -72,6 +73,12 @@ async fn test_pause_blocks_update_ger() -> anyhow::Result<()> { .build()?; let result = tx_context2.execute().await; assert!(result.is_err(), "update_ger should fail when bridge is paused"); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_BRIDGE_IS_PAUSED.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for 'bridge is currently paused', got: {error_msg}" + ); Ok(()) } @@ -208,6 +215,12 @@ async fn test_non_admin_cannot_pause() -> anyhow::Result<()> { .build()?; let result = tx_context.execute().await; assert!(result.is_err(), "non-admin should not be able to pause the bridge"); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_SENDER_NOT_BRIDGE_ADMIN.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for 'note sender is not the bridge admin', got: {error_msg}" + ); Ok(()) } From 7d98ba23967ecff36d3c8bd713a19127de0dbc0e Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 16 Apr 2026 09:19:40 +0000 Subject: [PATCH 4/4] fix: add test for pause blocking register_faucet, fix comment nit - Add test_pause_blocks_register_faucet covering a second guarded entry point - Extract create_paused_bridge helper to reduce test boilerplate - Fix comment dash count in bridge.rs section header Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/miden-agglayer/src/bridge.rs | 2 +- .../tests/agglayer/emergency_pause.rs | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/miden-agglayer/src/bridge.rs b/crates/miden-agglayer/src/bridge.rs index a31717b836..a51fc6994f 100644 --- a/crates/miden-agglayer/src/bridge.rs +++ b/crates/miden-agglayer/src/bridge.rs @@ -196,7 +196,7 @@ impl AggLayerBridge { &TOKEN_REGISTRY_MAP_SLOT_NAME } - // --- emergency pause -- + // --- emergency pause - /// Storage slot name for the emergency paused flag. pub fn paused_slot_name() -> &'static StorageSlotName { diff --git a/crates/miden-testing/tests/agglayer/emergency_pause.rs b/crates/miden-testing/tests/agglayer/emergency_pause.rs index 30ec19e0a5..b93d22cf5c 100644 --- a/crates/miden-testing/tests/agglayer/emergency_pause.rs +++ b/crates/miden-testing/tests/agglayer/emergency_pause.rs @@ -3,12 +3,21 @@ extern crate alloc; use miden_agglayer::errors::{ERR_BRIDGE_IS_PAUSED, ERR_SENDER_NOT_BRIDGE_ADMIN}; use miden_agglayer::{ AggLayerBridge, + ConfigAggBridgeNote, EmergencyPauseNote, + EthAddress, ExitRoot, UpdateGerNote, create_existing_bridge_account, }; use miden_protocol::account::auth::AuthScheme; +use miden_protocol::account::{ + Account, + AccountId, + AccountIdVersion, + AccountStorageMode, + AccountType, +}; use miden_protocol::crypto::rand::FeltRng; use miden_protocol::transaction::RawOutputNote; use miden_testing::{Auth, MockChain}; @@ -224,3 +233,84 @@ async fn test_non_admin_cannot_pause() -> anyhow::Result<()> { Ok(()) } + +/// Helper: creates admin, GER manager, bridge, pauses the bridge, and returns the paused bridge +/// along with admin and GER manager accounts. +async fn create_paused_bridge() -> anyhow::Result<(Account, Account, Account)> { + let mut builder = MockChain::builder(); + + let bridge_admin = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let ger_manager = builder.add_existing_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + })?; + + let bridge_seed = builder.rng_mut().draw_word(); + let bridge_account = + create_existing_bridge_account(bridge_seed, bridge_admin.id(), ger_manager.id()); + builder.add_account(bridge_account.clone())?; + + let pause_note = EmergencyPauseNote::create( + true, + bridge_admin.id(), + bridge_account.id(), + builder.rng_mut(), + )?; + builder.add_output_note(RawOutputNote::Full(pause_note.clone())); + let mock_chain = builder.build()?; + + let tx_context = mock_chain + .build_tx_context(bridge_account.id(), &[pause_note.id()], &[])? + .build()?; + let executed_transaction = tx_context.execute().await?; + + let mut paused_bridge = bridge_account; + paused_bridge.apply_delta(executed_transaction.account_delta())?; + assert!(AggLayerBridge::is_paused(&paused_bridge)?); + + Ok((bridge_admin, ger_manager, paused_bridge)) +} + +/// Tests that pausing the bridge blocks register_faucet. +#[tokio::test] +async fn test_pause_blocks_register_faucet() -> anyhow::Result<()> { + let (bridge_admin, _ger_manager, paused_bridge) = create_paused_bridge().await?; + + let mut builder2 = MockChain::builder(); + builder2.add_account(bridge_admin.clone())?; + builder2.add_account(paused_bridge.clone())?; + + let faucet_to_register = AccountId::dummy( + [42; 15], + AccountIdVersion::Version0, + AccountType::FungibleFaucet, + AccountStorageMode::Network, + ); + let origin_token_address = + EthAddress::from_hex("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let config_note = ConfigAggBridgeNote::create( + faucet_to_register, + &origin_token_address, + bridge_admin.id(), + paused_bridge.id(), + builder2.rng_mut(), + )?; + builder2.add_output_note(RawOutputNote::Full(config_note.clone())); + let mock_chain2 = builder2.build()?; + + let tx_context2 = mock_chain2 + .build_tx_context(paused_bridge.id(), &[config_note.id()], &[])? + .build()?; + let result = tx_context2.execute().await; + assert!(result.is_err(), "register_faucet should fail when bridge is paused"); + let error_msg = result.unwrap_err().to_string(); + let expected_err_code = ERR_BRIDGE_IS_PAUSED.code().to_string(); + assert!( + error_msg.contains(&expected_err_code), + "expected error code {expected_err_code} for 'bridge is currently paused', got: {error_msg}" + ); + + Ok(()) +}