From 388bc77e021f629ea0d71529d99aa923a1f1f668 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 16:55:38 +0200 Subject: [PATCH 1/7] feat(standards): add per-procedure pause to Authority --- .../access/authority/authority.masm | 5 +- .../asm/standards/access/authority.masm | 146 +++++++++++++++++- .../src/account/access/authority.rs | 145 +++++++++++++++++ 3 files changed, 287 insertions(+), 9 deletions(-) diff --git a/crates/miden-standards/asm/components/access/authority/authority.masm b/crates/miden-standards/asm/components/access/authority/authority.masm index 3cb7b262e5..f495c8c40a 100644 --- a/crates/miden-standards/asm/components/access/authority/authority.masm +++ b/crates/miden-standards/asm/components/access/authority/authority.masm @@ -5,13 +5,16 @@ # `miden::standards::access::authority` and is `exec`'d inline by gating procedures within # the active account's context. This component exposes `get_authority` as a call-padded # accessor so other accounts can read this account's authority, and re-exports the owner-gated -# emergency switch (`freeze` / `unfreeze`) as `call` entrypoints. +# circuit breakers (`freeze` / `unfreeze` and `pause_procedure` / `unpause_procedure`) as `call` +# entrypoints. use miden::protocol::active_account use {AUTHORITY_SLOT} from miden::standards::access::authority pub use {freeze} from miden::standards::access::authority pub use {unfreeze} from miden::standards::access::authority +pub use {pause_procedure} from miden::standards::access::authority +pub use {unpause_procedure} from miden::standards::access::authority #! Returns the authority discriminator stored on the account. #! diff --git a/crates/miden-standards/asm/standards/access/authority.masm b/crates/miden-standards/asm/standards/access/authority.masm index edb606dec3..65dc54f5af 100644 --- a/crates/miden-standards/asm/standards/access/authority.masm +++ b/crates/miden-standards/asm/standards/access/authority.masm @@ -1,15 +1,21 @@ -use {Bool} from miden::protocol::types +use {AccountProcedureRoot, Bool} from miden::protocol::types # miden::standards::access::authority # # Single source of truth for the account-wide authority. Components that gate state-mutating # procedures (TokenPolicyManager `set_*_policy`, fungible token metadata `set_*` procedures, # future NFT metadata setters, ...) all consult this slot via `assert_authorized`. +# +# Two independent circuit breakers gate that surface: the account-wide `is_frozen` emergency +# switch, and a per-procedure pause keyed by procedure root. A gated procedure runs only when the +# account is not frozen and that procedure is not itself paused. +use miden::core::word use miden::protocol::active_account use miden::protocol::native_account use miden::standards::access::ownable2step use miden::standards::access::rbac use miden::standards::access::role_symbol +use {ONE_WORD, ZERO_WORD} from miden::standards::utils # TYPE ALIASES # ================================================================================================= @@ -35,15 +41,26 @@ pub const AUTHORITY_SLOT = word("miden::standards::access::authority::authority_ # Map entries: [PROCEDURE_ROOT] -> [role_symbol, 0, 0, 0]. pub const AUTHORITY_PROCEDURE_ROLES_SLOT = word("miden::standards::access::authority::procedure_roles") +# Map slot holding the per-procedure circuit breaker. Present under every authority kind, since a +# paused procedure is blocked regardless of how it would otherwise be authorized. +# Map entries: [PROCEDURE_ROOT] -> [is_paused, 0, 0, 0]. An unmapped procedure reads the zero word +# and is therefore not paused. +pub const AUTHORITY_PAUSED_PROCEDURES_SLOT = word("miden::standards::access::authority::paused_procedures") + # Emergency-switch states for the `is_frozen` flag of `AUTHORITY_SLOT`. const UNFROZEN = 0 const FROZEN = 1 +# Per-procedure pause states written to `AUTHORITY_PAUSED_PROCEDURES_SLOT`. +const PAUSED_WORD = ONE_WORD +const UNPAUSED_WORD = ZERO_WORD + # ERRORS # ================================================================================================= const ERR_UNSUPPORTED_AUTHORITY = "authority is not supported" const ERR_AUTHORITY_FROZEN = "authority is frozen" +const ERR_AUTHORITY_PROCEDURE_PAUSED = "authority-gated procedure is paused" # PUBLIC INTERFACE # ================================================================================================= @@ -84,6 +101,53 @@ pub proc unfreeze() # => [pad(16)] end +#! Pauses a single authority-gated procedure, leaving the rest of the surface untouched. +#! +#! Pausing a root that is not a procedure of this account is a harmless no-op: the entry is stored +#! but nothing ever reads it. Pausing an already paused procedure is also a no-op. +#! +#! Inputs: [PROCEDURE_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - PROCEDURE_ROOT is the root of the gated procedure to pause. +#! +#! Panics if: +#! - the note sender is not the account's emergency authority. +#! +#! Invocation: call +@account_procedure +pub proc pause_procedure(procedure_root: AccountProcedureRoot) + exec.assert_sender_is_emergency_authority + # => [PROCEDURE_ROOT, pad(12)] + + push.PAUSED_WORD exec.write_procedure_pause_state + # => [pad(16)] +end + +#! Unpauses a single authority-gated procedure. +#! +#! Unpausing a procedure that is not paused is a no-op. +#! +#! Inputs: [PROCEDURE_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - PROCEDURE_ROOT is the root of the gated procedure to unpause. +#! +#! Panics if: +#! - the note sender is not the account's emergency authority. +#! +#! Invocation: call +@account_procedure +pub proc unpause_procedure(procedure_root: AccountProcedureRoot) + exec.assert_sender_is_emergency_authority + # => [PROCEDURE_ROOT, pad(12)] + + push.UNPAUSED_WORD exec.write_procedure_pause_state + # => [pad(16)] +end + # PUBLIC HELPERS # ================================================================================================= @@ -97,9 +161,13 @@ end #! [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be #! installed on the account; otherwise linking the account fails. #! -#! This procedure never panics under AuthControlled so the account's auth component is the sole -#! gate and MUST authenticate every authority-gated procedure root, otherwise those procedures are -#! permissionless. +#! Before dispatching on the authority, two circuit breakers are applied: the account-wide +#! `is_frozen` emergency switch, and the calling procedure's own entry in the paused-procedures +#! map. Both are checked under every authority kind, including AuthControlled. +#! +#! Apart from those breakers this procedure never panics under AuthControlled, so the account's +#! auth component is the sole gate and MUST authenticate every authority-gated procedure root, +#! otherwise those procedures are permissionless. #! #! Because the calling procedure is identified via `caller`, `assert_authorized` MUST be invoked #! with `exec` (inlined) from the gated procedure, and the gated procedure MUST be a `call` @@ -109,6 +177,8 @@ end #! Outputs: [] #! #! Panics if: +#! - the account's authority-gated surface is frozen. +#! - the calling procedure is paused. #! - the authority is OwnerControlled and the sender is not the registered owner. #! - the authority is RbacControlled, a role is configured for the procedure, and the sender does #! not hold it. @@ -125,6 +195,10 @@ pub proc assert_authorized() dup.1 assertz.err=ERR_AUTHORITY_FROZEN # => [authority, is_frozen, 0, 0] + # Circuit breaker: block this procedure alone when it is individually paused. + exec.assert_caller_not_paused + # => [authority, is_frozen, 0, 0] + dup eq.AUTH_CONTROLLED if.true # AuthControlled — auth component already gated the call. @@ -161,11 +235,13 @@ end #! Asserts the sender may toggle the emergency switch. #! -#! Reads only the authority discriminant, so it bypasses the frozen flag and the authority can -#! always toggle it. Dispatch: +#! Reads only the authority discriminant, so it bypasses both the frozen flag and the per-procedure +#! pause: the authority can always toggle either breaker. Without that, pausing `unpause_procedure` +#! would brick the account permanently. Dispatch: #! - OwnerControlled → the Ownable2Step owner. -#! - RbacControlled → the caller procedure's configured role in the procedure-roles map (freeze -#! and unfreeze may carry distinct roles, e.g. FREEZER / UNFREEZER), or ADMIN when unmapped. +#! - RbacControlled → the caller procedure's configured role in the procedure-roles map (each of +#! `freeze`, `unfreeze`, `pause_procedure` and `unpause_procedure` may carry a distinct role, +#! e.g. FREEZER / UNFREEZER), or ADMIN when unmapped. #! - AuthControlled → panics (no owner slot and no role graph). #! #! Inputs: [] @@ -232,6 +308,60 @@ proc write_frozen_flag(frozen_flag: Bool) # => [] end +#! Writes a procedure's pause state into the paused-procedures map. +#! +#! Inputs: [PAUSE_STATE, PROCEDURE_ROOT, pad(12)] +#! Outputs: [pad(16)] +#! +#! Where: +#! - PAUSE_STATE is [1, 0, 0, 0] to pause the procedure and [0, 0, 0, 0] to unpause it. +#! - PROCEDURE_ROOT is the root of the gated procedure the state applies to. +#! +#! Invocation: exec +proc write_procedure_pause_state(pause_state: word, procedure_root: AccountProcedureRoot) + swapw + # => [PROCEDURE_ROOT, PAUSE_STATE, pad(12)] + + push.AUTHORITY_PAUSED_PROCEDURES_SLOT[0..2] + # => [slot_suffix, slot_prefix, PROCEDURE_ROOT, PAUSE_STATE, pad(12)] + + exec.native_account::set_map_item + # => [OLD_PAUSE_STATE, pad(12)] + + dropw + # => [pad(16)] +end + +#! Asserts the calling procedure is not individually paused. +#! +#! Resolves the calling procedure's root via `caller` and reads its entry in the paused-procedures +#! map. A procedure with no entry reads the zero word and is therefore not paused, so an account +#! that pauses nothing keeps the default-open behaviour. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the calling procedure is paused. +#! +#! Invocation: exec +proc assert_caller_not_paused() + padw caller + # => [CALLER_ROOT] + + push.AUTHORITY_PAUSED_PROCEDURES_SLOT[0..2] + # => [slot_suffix, slot_prefix, CALLER_ROOT] + + exec.active_account::get_map_item + # => [is_paused, 0, 0, 0] + + exec.word::eqz + # => [is_not_paused] + + assert.err=ERR_AUTHORITY_PROCEDURE_PAUSED + # => [] +end + #! Asserts the sender is authorized under the RbacControlled authority. #! #! Resolves the calling procedure's root via `caller` and looks up its assigned role in the diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index 5dbb94e107..6784381f78 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -54,6 +54,20 @@ procedure_root!( Authority::code() ); +procedure_root!( + AUTHORITY_PAUSE_PROCEDURE, + AUTHORITY_LIBRARY_PATH, + Authority::PAUSE_PROCEDURE_PROC_NAME, + Authority::code() +); + +procedure_root!( + AUTHORITY_UNPAUSE_PROCEDURE, + AUTHORITY_LIBRARY_PATH, + Authority::UNPAUSE_PROCEDURE_PROC_NAME, + Authority::code() +); + static AUTHORITY_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::access::authority::authority_config") .expect("storage slot name should be valid") @@ -64,6 +78,11 @@ static AUTHORITY_PROCEDURE_ROLES_SLOT_NAME: LazyLock = LazyLock .expect("storage slot name should be valid") }); +static AUTHORITY_PAUSED_PROCEDURES_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::authority::paused_procedures") + .expect("storage slot name should be valid") +}); + /// Authority value written to the storage slot for [`Authority::AuthControlled`]. const AUTH_CONTROLLED: u8 = 0; /// Authority value written to the storage slot for [`Authority::OwnerControlled`]. @@ -95,6 +114,17 @@ const RBAC_CONTROLLED: u8 = 2; /// runtime `assert_authorized` identifies the calling procedure via the `caller` instruction and /// looks up its role. A procedure without a mapping falls back to the `ADMIN` role check. /// +/// # Per-procedure pause +/// +/// Independently of `is_frozen`, a single gated procedure can be taken offline by writing its +/// [`AccountProcedureRoot`] into the paused-procedures map via `pause_procedure`, and brought back +/// with `unpause_procedure`. `assert_authorized` resolves the calling procedure via `caller` and +/// panics while its entry is set, leaving every other gated procedure working. A procedure with no +/// entry is not paused, so an account that pauses nothing behaves exactly as before. +/// +/// Both mutators are gated on the same emergency authority as `freeze` / `unfreeze` and bypass +/// both breakers, so `unpause_procedure` can never itself be paused. +/// /// # Emergency switch (`is_frozen`) /// /// The component includes an `is_frozen` flag. If it is `true`, all procedures that call @@ -156,6 +186,7 @@ const RBAC_CONTROLLED: u8 = 2; /// Storage layout: /// - Value slot: `[authority, is_frozen, 0, 0]`. /// - Map slot (only under RBAC): `procedure_root` → `[role_symbol, 0, 0, 0]`. +/// - Map slot: `procedure_root` → `[is_paused, 0, 0, 0]`. #[repr(u8)] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -189,6 +220,10 @@ impl Authority { const FREEZE_PROC_NAME: &'static str = "freeze"; /// Name of the owner-gated procedure that unfreezes the authority-gated surface. const UNFREEZE_PROC_NAME: &'static str = "unfreeze"; + /// Name of the owner-gated procedure that pauses a single authority-gated procedure. + const PAUSE_PROCEDURE_PROC_NAME: &'static str = "pause_procedure"; + /// Name of the owner-gated procedure that unpauses a single authority-gated procedure. + const UNPAUSE_PROCEDURE_PROC_NAME: &'static str = "unpause_procedure"; /// Returns the [`AccountComponentCode`] of this component. pub fn code() -> &'static AccountComponentCode { @@ -223,11 +258,32 @@ impl Authority { &AUTHORITY_SLOT_NAME } + /// Returns the procedure root of `pause_procedure`. + /// + /// Gated on the same emergency authority as [`Authority::freeze_root`], and like it bypasses + /// both breakers so the authority can never be locked out of its own switches. + pub fn pause_procedure_root() -> AccountProcedureRoot { + *AUTHORITY_PAUSE_PROCEDURE + } + + /// Returns the procedure root of `unpause_procedure`. + /// + /// Gated on the same emergency authority as [`Authority::unfreeze_root`], and like it bypasses + /// both breakers so the authority can never be locked out of its own switches. + pub fn unpause_procedure_root() -> AccountProcedureRoot { + *AUTHORITY_UNPAUSE_PROCEDURE + } + /// Returns the [`StorageSlotName`] holding the per-procedure role map (RBAC only). pub fn procedure_roles_slot() -> &'static StorageSlotName { &AUTHORITY_PROCEDURE_ROLES_SLOT_NAME } + /// Returns the [`StorageSlotName`] holding the per-procedure pause map. + pub fn paused_procedures_slot() -> &'static StorageSlotName { + &AUTHORITY_PAUSED_PROCEDURES_SLOT_NAME + } + /// Reads the authority configuration from account storage. pub fn try_from_storage(storage: &AccountStorage) -> Result { let word = Self::read_config_word(storage)?; @@ -258,6 +314,31 @@ impl Authority { Ok(word[1] != Felt::ZERO) } + /// Reads the pause state of a single authority-gated procedure from account storage. + /// + /// Returns `true` if `procedure_root` is currently paused, meaning that procedure panics on + /// `assert_authorized` while every other gated procedure keeps working. A procedure that was + /// never paused has no map entry and reads as `false`. + pub fn try_read_procedure_paused( + storage: &AccountStorage, + procedure_root: &AccountProcedureRoot, + ) -> Result { + let word = storage + .get_map_item( + Self::paused_procedures_slot(), + StorageMapKey::new(procedure_root.as_word()), + ) + .map_err(|_| AuthorityError::MissingPausedProceduresSlot)?; + + // Enforce the canonical encoding on read: the reserved felts must be zero and the flag + // must be a boolean - the exact form the MASM write path always produces. + if word[1..4].iter().any(|felt| *felt != Felt::ZERO) || word[0].as_canonical_u64() > 1 { + return Err(AuthorityError::NonCanonicalConfig); + } + + Ok(word[0] != Felt::ZERO) + } + /// Returns the [`AccountComponentMetadata`] for this configuration. pub fn component_metadata(&self) -> AccountComponentMetadata { let mut slots = vec![( @@ -284,6 +365,15 @@ impl Authority { )); } + slots.push(( + AUTHORITY_PAUSED_PROCEDURES_SLOT_NAME.clone(), + StorageSlotSchema::map( + "Per-procedure pause flag (procedure root -> is_paused)", + SchemaType::native_word(), + SchemaType::native_word(), + ), + )); + let storage_schema = StorageSchema::new(slots).expect("storage schema should be valid"); AccountComponentMetadata::new(Self::NAME) @@ -377,6 +467,12 @@ impl From for AccountComponent { )); } + // Every account starts with nothing paused; entries are written by `pause_procedure`. + slots.push(StorageSlot::with_map( + AUTHORITY_PAUSED_PROCEDURES_SLOT_NAME.clone(), + StorageMap::new(), + )); + AccountComponent::new(Authority::code().clone(), slots, metadata).expect( "authority component should satisfy the requirements of a valid account component", ) @@ -404,6 +500,8 @@ pub enum AuthorityError { MissingStorageSlot(#[source] AccountError), #[error("authority procedure-roles slot is missing or not a map")] MissingProcedureRolesSlot, + #[error("authority paused-procedures slot is missing or not a map")] + MissingPausedProceduresSlot, } #[cfg(test)] @@ -415,6 +513,9 @@ mod tests { /// Procedure-root key of the single entry inserted by [`rbac_storage_with_role_value`]. const ROLE_KEY_WORD: [u32; 4] = [1, 2, 3, 4]; + /// Procedure-root key of the single entry inserted by [`storage_with_paused_value`]. + const PAUSED_KEY_WORD: [u32; 4] = [5, 6, 7, 8]; + /// Builds account storage whose authority value slot holds `word`. fn storage_with_config(word: Word) -> AccountStorage { let slot = StorageSlot::with_value(Authority::authority_slot().clone(), word); @@ -434,6 +535,19 @@ mod tests { AccountStorage::new(vec![config, roles]).expect("storage should be valid") } + /// Builds account storage whose paused-procedures map holds `value` for + /// [`PAUSED_KEY_WORD`]. + fn storage_with_paused_value(value: Word) -> AccountStorage { + let config = StorageSlot::with_value( + Authority::authority_slot().clone(), + Word::from([u32::from(AUTH_CONTROLLED), 0, 0, 0]), + ); + let key = StorageMapKey::new(Word::from(PAUSED_KEY_WORD)); + let map = StorageMap::with_entries([(key, value)]).expect("map should be valid"); + let paused = StorageSlot::with_map(Authority::paused_procedures_slot().clone(), map); + AccountStorage::new(vec![config, paused]).expect("storage should be valid") + } + #[test] fn canonical_config_is_accepted() { // AuthControlled, not frozen. @@ -482,6 +596,37 @@ mod tests { )); } + #[test] + fn procedure_pause_state_is_read_per_procedure() { + let paused_root = AccountProcedureRoot::from_raw(Word::from(PAUSED_KEY_WORD)); + let unmapped_root = AccountProcedureRoot::from_raw(Word::from(ROLE_KEY_WORD)); + + let storage = storage_with_paused_value(Word::from([1u32, 0, 0, 0])); + assert!(Authority::try_read_procedure_paused(&storage, &paused_root).unwrap()); + + // A procedure with no entry reads the zero word and is therefore not paused. + assert!(!Authority::try_read_procedure_paused(&storage, &unmapped_root).unwrap()); + } + + #[test] + fn non_canonical_pause_state_is_rejected() { + let root = AccountProcedureRoot::from_raw(Word::from(PAUSED_KEY_WORD)); + + // word[2] carries unexpected trailing data. + let storage = storage_with_paused_value(Word::from([1u32, 0, 7, 0])); + assert_matches!( + Authority::try_read_procedure_paused(&storage, &root), + Err(AuthorityError::NonCanonicalConfig) + ); + + // is_paused (word[0]) must be 0 or 1; 2 is non-canonical. + let storage = storage_with_paused_value(Word::from([2u32, 0, 0, 0])); + assert_matches!( + Authority::try_read_procedure_paused(&storage, &root), + Err(AuthorityError::NonCanonicalConfig) + ); + } + #[test] fn non_zero_reserved_felt_in_role_value_is_rejected() { let role = RoleSymbol::new("ADMIN").unwrap(); From 58178f93c0fb5b813478d6685a0924e7f787692f Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 16:56:07 +0200 Subject: [PATCH 2/7] test(standards): cover the Authority per-procedure pause --- .../miden-testing/tests/scripts/authority.rs | 279 +++++++++++++++++- 1 file changed, 278 insertions(+), 1 deletion(-) diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index fbf230f77e..08c2775d75 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -1,7 +1,10 @@ -//! Tests for the `Authority` global emergency switch (`freeze` / `unfreeze`). +//! Tests for the `Authority` circuit breakers: the account-wide emergency switch +//! (`freeze` / `unfreeze`) and the per-procedure pause (`pause_procedure` / +//! `unpause_procedure`). use std::collections::BTreeMap; +use miden_protocol::Word; use miden_protocol::account::{ Account, AccountBuilder, @@ -18,6 +21,7 @@ use miden_standards::account::access::{AccessControl, Authority}; use miden_standards::account::faucets::{FungibleFaucet, TokenName}; use miden_standards::errors::standards::{ ERR_AUTHORITY_FROZEN, + ERR_AUTHORITY_PROCEDURE_PAUSED, ERR_SENDER_LACKS_ROLE, ERR_SENDER_NOT_OWNER, }; @@ -128,6 +132,39 @@ fn build_unfreeze_note(sender: AccountId) -> anyhow::Result { ) } +/// Builds a note that calls `authority::pause_procedure` for `procedure_root`. +fn build_pause_procedure_note( + sender: AccountId, + procedure_root: AccountProcedureRoot, +) -> anyhow::Result { + build_note(sender, pause_procedure_script("pause_procedure", procedure_root.as_word())) +} + +/// Builds a note that calls `authority::unpause_procedure` for `procedure_root`. +fn build_unpause_procedure_note( + sender: AccountId, + procedure_root: AccountProcedureRoot, +) -> anyhow::Result { + build_note(sender, pause_procedure_script("unpause_procedure", procedure_root.as_word())) +} + +/// Builds the script of a note calling `proc_name` with `procedure_root` as its only argument. +fn pause_procedure_script(proc_name: &str, procedure_root: Word) -> String { + format!( + r#" + use miden::standards::access::authority + + @note_script + pub proc main + repeat.12 push.0 end + push.{procedure_root} + call.authority::{proc_name} + dropw dropw dropw dropw + end + "# + ) +} + // HELPERS // ================================================================================================ @@ -137,6 +174,16 @@ fn is_frozen(mock_chain: &MockChain, faucet_id: AccountId) -> anyhow::Result anyhow::Result { + let account = mock_chain.committed_account(faucet_id)?; + Ok(Authority::try_read_procedure_paused(account.storage(), procedure_root)?) +} + // TESTS — OWNER-CONTROLLED EMERGENCY SWITCH // ================================================================================================ @@ -450,3 +497,233 @@ async fn freezer_can_freeze_but_cannot_unfreeze_or_authorize() -> anyhow::Result Ok(()) } + +// TESTS — PER-PROCEDURE PAUSE +// ================================================================================================ + +#[tokio::test] +async fn pausing_one_procedure_leaves_the_rest_of_the_surface_working() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 67)?; + + let pause_procedure_note = + build_pause_procedure_note(*OWNER_ID, PausableManager::pause_root())?; + let pause_note = build_pause_note(*OWNER_ID)?; + let set_max_supply_note = build_set_max_supply_note(*OWNER_ID, 500_000)?; + for note in [&pause_procedure_note, &pause_note, &set_max_supply_note] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // The owner takes `PausableManager::pause` offline. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_procedure_note).await?; + assert!(is_procedure_paused(&mock_chain, faucet.id(), &PausableManager::pause_root())?); + + // That procedure is now blocked, even for the owner. + let result = mock_chain + .build_transaction(faucet.id()) + .authenticated_input_note(pause_note.id()) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_AUTHORITY_PROCEDURE_PAUSED); + + // Every other authority-gated procedure keeps working, and the account is not frozen. + assert!(!is_frozen(&mock_chain, faucet.id())?); + execute_note_on_faucet(&mut mock_chain, faucet.id(), &set_max_supply_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn unpause_procedure_restores_the_paused_procedure() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 68)?; + + let pause_procedure_note = + build_pause_procedure_note(*OWNER_ID, PausableManager::pause_root())?; + let unpause_procedure_note = + build_unpause_procedure_note(*OWNER_ID, PausableManager::pause_root())?; + let pause_note = build_pause_note(*OWNER_ID)?; + for note in [&pause_procedure_note, &unpause_procedure_note, &pause_note] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_procedure_note).await?; + assert!(is_procedure_paused(&mock_chain, faucet.id(), &PausableManager::pause_root())?); + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_procedure_note).await?; + assert!(!is_procedure_paused(&mock_chain, faucet.id(), &PausableManager::pause_root())?); + + // The procedure works again. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_note).await?; + + Ok(()) +} + +#[tokio::test] +async fn non_owner_cannot_pause_a_procedure() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 69)?; + + let attacker_note = build_pause_procedure_note(*NON_OWNER_ID, PausableManager::pause_root())?; + builder.add_output_note(RawOutputNote::Full(attacker_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let result = mock_chain + .build_transaction(faucet.id()) + .authenticated_input_note(attacker_note.id()) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + assert!(!is_procedure_paused(&mock_chain, faucet.id(), &PausableManager::pause_root())?); + + Ok(()) +} + +/// The two breakers are independent: unfreezing does not clear a per-procedure pause. +#[tokio::test] +async fn unfreeze_does_not_clear_a_paused_procedure() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 70)?; + + let pause_procedure_note = + build_pause_procedure_note(*OWNER_ID, PausableManager::pause_root())?; + let freeze_note = build_freeze_note(*OWNER_ID)?; + let unfreeze_note = build_unfreeze_note(*OWNER_ID)?; + let pause_note = build_pause_note(*OWNER_ID)?; + for note in [&pause_procedure_note, &freeze_note, &unfreeze_note, &pause_note] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_procedure_note).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &freeze_note).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unfreeze_note).await?; + assert!(!is_frozen(&mock_chain, faucet.id())?); + + // The account is open again, but the individually paused procedure stays closed. + let result = mock_chain + .build_transaction(faucet.id()) + .authenticated_input_note(pause_note.id()) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_AUTHORITY_PROCEDURE_PAUSED); + + Ok(()) +} + +/// Pausing `unpause_procedure` must not brick the account: both mutators bypass the breakers. +#[tokio::test] +async fn pausing_unpause_procedure_does_not_brick_the_account() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let faucet = add_owner_faucet(&mut builder, *OWNER_ID, 71)?; + + let pause_unpause_note = + build_pause_procedure_note(*OWNER_ID, Authority::unpause_procedure_root())?; + let unpause_unpause_note = + build_unpause_procedure_note(*OWNER_ID, Authority::unpause_procedure_root())?; + for note in [&pause_unpause_note, &unpause_unpause_note] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + // The owner pauses the very procedure that clears pauses. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pause_unpause_note).await?; + assert!(is_procedure_paused( + &mock_chain, + faucet.id(), + &Authority::unpause_procedure_root() + )?); + + // It still runs, because it is gated on the emergency authority rather than + // `assert_authorized`, and so clears its own pause. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_unpause_note).await?; + assert!(!is_procedure_paused( + &mock_chain, + faucet.id(), + &Authority::unpause_procedure_root() + )?); + + Ok(()) +} + +#[tokio::test] +async fn pause_and_unpause_procedure_use_distinct_roles() -> anyhow::Result<()> { + let pauser = test_account_id(27); + let unpauser = test_account_id(28); + + let roles = BTreeMap::from([ + (Authority::pause_procedure_root(), role("PAUSER")), + (Authority::unpause_procedure_root(), role("UNPAUSER")), + ]); + + let admin = *ADMIN_ID; + let mut builder = MockChain::builder(); + let faucet = add_rbac_faucet(&mut builder, admin, roles, 72)?; + + let target = PausableManager::pause_root(); + let grant_pauser = build_grant_role_note(admin, &role("PAUSER"), pauser)?; + let grant_unpauser = build_grant_role_note(admin, &role("UNPAUSER"), unpauser)?; + let admin_pause_note = build_pause_procedure_note(admin, target)?; + let pauser_pause_note = build_pause_procedure_note(pauser, target)?; + let pauser_unpause_note = build_unpause_procedure_note(pauser, target)?; + let unpauser_unpause_note = build_unpause_procedure_note(unpauser, target)?; + for note in [ + &grant_pauser, + &grant_unpauser, + &admin_pause_note, + &pauser_pause_note, + &pauser_unpause_note, + &unpauser_unpause_note, + ] { + builder.add_output_note(RawOutputNote::Full(note.clone())); + } + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_pauser).await?; + execute_note_on_faucet(&mut mock_chain, faucet.id(), &grant_unpauser).await?; + + // `pause_procedure` is mapped to PAUSER, so it never falls back to ADMIN. + let admin_result = mock_chain + .build_transaction(faucet.id()) + .authenticated_input_note(admin_pause_note.id()) + .build()? + .execute() + .await; + assert_transaction_executor_error!(admin_result, ERR_SENDER_LACKS_ROLE); + + // The PAUSER takes the procedure offline but cannot bring it back. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &pauser_pause_note).await?; + assert!(is_procedure_paused(&mock_chain, faucet.id(), &target)?); + + let pauser_unpause_result = mock_chain + .build_transaction(faucet.id()) + .authenticated_input_note(pauser_unpause_note.id()) + .build()? + .execute() + .await; + assert_transaction_executor_error!(pauser_unpause_result, ERR_SENDER_LACKS_ROLE); + assert!(is_procedure_paused(&mock_chain, faucet.id(), &target)?); + + // Only the UNPAUSER re-opens it. + execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpauser_unpause_note).await?; + assert!(!is_procedure_paused(&mock_chain, faucet.id(), &target)?); + + Ok(()) +} From 27ab51ca4bbea51a3a61e46d273dfbe9a871b99a Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 16:56:21 +0200 Subject: [PATCH 3/7] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563279e4d2..1e691e1f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Added a per-procedure pause to the `Authority` component, so a single authority-gated procedure can be taken offline without freezing the whole account ([#3XXX](https://github.com/0xMiden/protocol/pull/3XXX)). + ### Changes - Added type signatures where missing throughout the protocol and standards Miden Assembly libraries From 176d335eb6d914e6acb4eea1a24665e21e48f21e Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 16:59:25 +0200 Subject: [PATCH 4/7] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e691e1f8a..7c88472120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added a per-procedure pause to the `Authority` component, so a single authority-gated procedure can be taken offline without freezing the whole account ([#3XXX](https://github.com/0xMiden/protocol/pull/3XXX)). +- Added a per-procedure pause to the `Authority` component, so a single authority-gated procedure can be taken offline without freezing the whole account ([#3855](https://github.com/0xMiden/protocol/pull/3855)). ### Changes From ee56417aab3f5d2e03f765ec7c4cdf32c5f825c9 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 17:03:14 +0200 Subject: [PATCH 5/7] rename the functionality --- .../access/authority/authority.masm | 4 ++-- .../asm/standards/access/authority.masm | 22 +++++++++---------- .../src/account/access/authority.rs | 11 ++++++---- .../miden-testing/tests/scripts/authority.rs | 10 ++++----- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/miden-standards/asm/components/access/authority/authority.masm b/crates/miden-standards/asm/components/access/authority/authority.masm index f495c8c40a..03e4eec6ff 100644 --- a/crates/miden-standards/asm/components/access/authority/authority.masm +++ b/crates/miden-standards/asm/components/access/authority/authority.masm @@ -5,8 +5,8 @@ # `miden::standards::access::authority` and is `exec`'d inline by gating procedures within # the active account's context. This component exposes `get_authority` as a call-padded # accessor so other accounts can read this account's authority, and re-exports the owner-gated -# circuit breakers (`freeze` / `unfreeze` and `pause_procedure` / `unpause_procedure`) as `call` -# entrypoints. +# emergency switch (`freeze` / `unfreeze`) and per-procedure pause (`pause_procedure` / +# `unpause_procedure`) as `call` entrypoints. use miden::protocol::active_account use {AUTHORITY_SLOT} from miden::standards::access::authority diff --git a/crates/miden-standards/asm/standards/access/authority.masm b/crates/miden-standards/asm/standards/access/authority.masm index 65dc54f5af..652b018554 100644 --- a/crates/miden-standards/asm/standards/access/authority.masm +++ b/crates/miden-standards/asm/standards/access/authority.masm @@ -5,9 +5,9 @@ use {AccountProcedureRoot, Bool} from miden::protocol::types # procedures (TokenPolicyManager `set_*_policy`, fungible token metadata `set_*` procedures, # future NFT metadata setters, ...) all consult this slot via `assert_authorized`. # -# Two independent circuit breakers gate that surface: the account-wide `is_frozen` emergency -# switch, and a per-procedure pause keyed by procedure root. A gated procedure runs only when the -# account is not frozen and that procedure is not itself paused. +# Two independent checks precede authorization: the account-wide `is_frozen` emergency switch, +# and a per-procedure pause keyed by procedure root. A gated procedure runs only when the account +# is not frozen and that procedure is not itself paused. use miden::core::word use miden::protocol::active_account @@ -41,8 +41,8 @@ pub const AUTHORITY_SLOT = word("miden::standards::access::authority::authority_ # Map entries: [PROCEDURE_ROOT] -> [role_symbol, 0, 0, 0]. pub const AUTHORITY_PROCEDURE_ROLES_SLOT = word("miden::standards::access::authority::procedure_roles") -# Map slot holding the per-procedure circuit breaker. Present under every authority kind, since a -# paused procedure is blocked regardless of how it would otherwise be authorized. +# Map slot holding the per-procedure pause. Present under every authority kind, since a paused +# procedure is blocked regardless of how it would otherwise be authorized. # Map entries: [PROCEDURE_ROOT] -> [is_paused, 0, 0, 0]. An unmapped procedure reads the zero word # and is therefore not paused. pub const AUTHORITY_PAUSED_PROCEDURES_SLOT = word("miden::standards::access::authority::paused_procedures") @@ -161,11 +161,11 @@ end #! [`RoleBasedAccessControl`][crate::account::access::RoleBasedAccessControl] component to be #! installed on the account; otherwise linking the account fails. #! -#! Before dispatching on the authority, two circuit breakers are applied: the account-wide -#! `is_frozen` emergency switch, and the calling procedure's own entry in the paused-procedures -#! map. Both are checked under every authority kind, including AuthControlled. +#! Before dispatching on the authority, two checks are applied: the account-wide `is_frozen` +#! emergency switch, and the calling procedure's own entry in the paused-procedures map. Both +#! apply under every authority kind, including AuthControlled. #! -#! Apart from those breakers this procedure never panics under AuthControlled, so the account's +#! Apart from those two checks this procedure never panics under AuthControlled, so the account's #! auth component is the sole gate and MUST authenticate every authority-gated procedure root, #! otherwise those procedures are permissionless. #! @@ -195,7 +195,7 @@ pub proc assert_authorized() dup.1 assertz.err=ERR_AUTHORITY_FROZEN # => [authority, is_frozen, 0, 0] - # Circuit breaker: block this procedure alone when it is individually paused. + # Per-procedure pause: block this procedure alone when it is paused. exec.assert_caller_not_paused # => [authority, is_frozen, 0, 0] @@ -236,7 +236,7 @@ end #! Asserts the sender may toggle the emergency switch. #! #! Reads only the authority discriminant, so it bypasses both the frozen flag and the per-procedure -#! pause: the authority can always toggle either breaker. Without that, pausing `unpause_procedure` +#! pause: the authority can always toggle either one. Without that, pausing `unpause_procedure` #! would brick the account permanently. Dispatch: #! - OwnerControlled → the Ownable2Step owner. #! - RbacControlled → the caller procedure's configured role in the procedure-roles map (each of diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index 6784381f78..140be0ccb3 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -123,7 +123,8 @@ const RBAC_CONTROLLED: u8 = 2; /// entry is not paused, so an account that pauses nothing behaves exactly as before. /// /// Both mutators are gated on the same emergency authority as `freeze` / `unfreeze` and bypass -/// both breakers, so `unpause_procedure` can never itself be paused. +/// both the frozen flag and the per-procedure pause, so `unpause_procedure` can never itself be +/// paused. /// /// # Emergency switch (`is_frozen`) /// @@ -261,15 +262,17 @@ impl Authority { /// Returns the procedure root of `pause_procedure`. /// /// Gated on the same emergency authority as [`Authority::freeze_root`], and like it bypasses - /// both breakers so the authority can never be locked out of its own switches. + /// both the frozen flag and the per-procedure pause, so the authority can never be locked out + /// of its own switches. pub fn pause_procedure_root() -> AccountProcedureRoot { *AUTHORITY_PAUSE_PROCEDURE } /// Returns the procedure root of `unpause_procedure`. /// - /// Gated on the same emergency authority as [`Authority::unfreeze_root`], and like it bypasses - /// both breakers so the authority can never be locked out of its own switches. + /// Gated on the same emergency authority as [`Authority::unfreeze_root`], and like it + /// bypasses both the frozen flag and the per-procedure pause, so the authority can never be + /// locked out of its own switches. pub fn unpause_procedure_root() -> AccountProcedureRoot { *AUTHORITY_UNPAUSE_PROCEDURE } diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index 08c2775d75..a80b1e178b 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -1,6 +1,5 @@ -//! Tests for the `Authority` circuit breakers: the account-wide emergency switch -//! (`freeze` / `unfreeze`) and the per-procedure pause (`pause_procedure` / -//! `unpause_procedure`). +//! Tests for the `Authority` account-wide emergency switch (`freeze` / `unfreeze`) and the +//! per-procedure pause (`pause_procedure` / `unpause_procedure`). use std::collections::BTreeMap; @@ -589,7 +588,8 @@ async fn non_owner_cannot_pause_a_procedure() -> anyhow::Result<()> { Ok(()) } -/// The two breakers are independent: unfreezing does not clear a per-procedure pause. +/// The emergency switch and the per-procedure pause are independent: unfreezing does not clear a +/// paused procedure. #[tokio::test] async fn unfreeze_does_not_clear_a_paused_procedure() -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -624,7 +624,7 @@ async fn unfreeze_does_not_clear_a_paused_procedure() -> anyhow::Result<()> { Ok(()) } -/// Pausing `unpause_procedure` must not brick the account: both mutators bypass the breakers. +/// Pausing `unpause_procedure` must not brick the account: both mutators bypass both checks. #[tokio::test] async fn pausing_unpause_procedure_does_not_brick_the_account() -> anyhow::Result<()> { let mut builder = MockChain::builder(); From c1a6b86d1fa183f5871d96780a3b64ec4e20f572 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 17:11:22 +0200 Subject: [PATCH 6/7] fix documentation --- CHANGELOG.md | 2 +- .../asm/standards/access/authority.masm | 20 ++++--------------- .../src/account/access/authority.rs | 12 ----------- 3 files changed, 5 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c88472120..547fd7bf78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Added a per-procedure pause to the `Authority` component, so a single authority-gated procedure can be taken offline without freezing the whole account ([#3855](https://github.com/0xMiden/protocol/pull/3855)). +- Added a per-procedure pause to the `Authority` component ([#3855](https://github.com/0xMiden/protocol/pull/3855)). ### Changes diff --git a/crates/miden-standards/asm/standards/access/authority.masm b/crates/miden-standards/asm/standards/access/authority.masm index 652b018554..c7566d0e29 100644 --- a/crates/miden-standards/asm/standards/access/authority.masm +++ b/crates/miden-standards/asm/standards/access/authority.masm @@ -41,10 +41,8 @@ pub const AUTHORITY_SLOT = word("miden::standards::access::authority::authority_ # Map entries: [PROCEDURE_ROOT] -> [role_symbol, 0, 0, 0]. pub const AUTHORITY_PROCEDURE_ROLES_SLOT = word("miden::standards::access::authority::procedure_roles") -# Map slot holding the per-procedure pause. Present under every authority kind, since a paused -# procedure is blocked regardless of how it would otherwise be authorized. -# Map entries: [PROCEDURE_ROOT] -> [is_paused, 0, 0, 0]. An unmapped procedure reads the zero word -# and is therefore not paused. +# Map slot holding the per-procedure pause. +# Map entries: [PROCEDURE_ROOT] -> [is_paused, 0, 0, 0]. pub const AUTHORITY_PAUSED_PROCEDURES_SLOT = word("miden::standards::access::authority::paused_procedures") # Emergency-switch states for the `is_frozen` flag of `AUTHORITY_SLOT`. @@ -101,10 +99,7 @@ pub proc unfreeze() # => [pad(16)] end -#! Pauses a single authority-gated procedure, leaving the rest of the surface untouched. -#! -#! Pausing a root that is not a procedure of this account is a harmless no-op: the entry is stored -#! but nothing ever reads it. Pausing an already paused procedure is also a no-op. +#! Pauses a single authority-gated procedure. #! #! Inputs: [PROCEDURE_ROOT, pad(12)] #! Outputs: [pad(16)] @@ -127,8 +122,6 @@ end #! Unpauses a single authority-gated procedure. #! -#! Unpausing a procedure that is not paused is a no-op. -#! #! Inputs: [PROCEDURE_ROOT, pad(12)] #! Outputs: [pad(16)] #! @@ -236,8 +229,7 @@ end #! Asserts the sender may toggle the emergency switch. #! #! Reads only the authority discriminant, so it bypasses both the frozen flag and the per-procedure -#! pause: the authority can always toggle either one. Without that, pausing `unpause_procedure` -#! would brick the account permanently. Dispatch: +#! pause: the authority can always toggle either one. Dispatch: #! - OwnerControlled → the Ownable2Step owner. #! - RbacControlled → the caller procedure's configured role in the procedure-roles map (each of #! `freeze`, `unfreeze`, `pause_procedure` and `unpause_procedure` may carry a distinct role, @@ -334,10 +326,6 @@ end #! Asserts the calling procedure is not individually paused. #! -#! Resolves the calling procedure's root via `caller` and reads its entry in the paused-procedures -#! map. A procedure with no entry reads the zero word and is therefore not paused, so an account -#! that pauses nothing keeps the default-open behaviour. -#! #! Inputs: [] #! Outputs: [] #! diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index 140be0ccb3..cd688f17e6 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -260,19 +260,11 @@ impl Authority { } /// Returns the procedure root of `pause_procedure`. - /// - /// Gated on the same emergency authority as [`Authority::freeze_root`], and like it bypasses - /// both the frozen flag and the per-procedure pause, so the authority can never be locked out - /// of its own switches. pub fn pause_procedure_root() -> AccountProcedureRoot { *AUTHORITY_PAUSE_PROCEDURE } /// Returns the procedure root of `unpause_procedure`. - /// - /// Gated on the same emergency authority as [`Authority::unfreeze_root`], and like it - /// bypasses both the frozen flag and the per-procedure pause, so the authority can never be - /// locked out of its own switches. pub fn unpause_procedure_root() -> AccountProcedureRoot { *AUTHORITY_UNPAUSE_PROCEDURE } @@ -318,10 +310,6 @@ impl Authority { } /// Reads the pause state of a single authority-gated procedure from account storage. - /// - /// Returns `true` if `procedure_root` is currently paused, meaning that procedure panics on - /// `assert_authorized` while every other gated procedure keeps working. A procedure that was - /// never paused has no map entry and reads as `false`. pub fn try_read_procedure_paused( storage: &AccountStorage, procedure_root: &AccountProcedureRoot, From 3f617dfd958216b7e69bb2d8f57eb3bcf128a742 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 11 Sep 2026 17:16:37 +0200 Subject: [PATCH 7/7] clarify unpause_procedure --- crates/miden-standards/src/account/access/authority.rs | 6 +++--- crates/miden-testing/tests/scripts/authority.rs | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/miden-standards/src/account/access/authority.rs b/crates/miden-standards/src/account/access/authority.rs index cd688f17e6..356b336a6a 100644 --- a/crates/miden-standards/src/account/access/authority.rs +++ b/crates/miden-standards/src/account/access/authority.rs @@ -122,9 +122,9 @@ const RBAC_CONTROLLED: u8 = 2; /// panics while its entry is set, leaving every other gated procedure working. A procedure with no /// entry is not paused, so an account that pauses nothing behaves exactly as before. /// -/// Both mutators are gated on the same emergency authority as `freeze` / `unfreeze` and bypass -/// both the frozen flag and the per-procedure pause, so `unpause_procedure` can never itself be -/// paused. +/// Both mutators are gated on the same emergency authority as `freeze` / `unfreeze` and read +/// neither the frozen flag nor the pause map. An entry written for one of their own roots is +/// stored but never read, so `unpause_procedure` cannot be locked out. /// /// # Emergency switch (`is_frozen`) /// diff --git a/crates/miden-testing/tests/scripts/authority.rs b/crates/miden-testing/tests/scripts/authority.rs index a80b1e178b..f255c64641 100644 --- a/crates/miden-testing/tests/scripts/authority.rs +++ b/crates/miden-testing/tests/scripts/authority.rs @@ -624,7 +624,8 @@ async fn unfreeze_does_not_clear_a_paused_procedure() -> anyhow::Result<()> { Ok(()) } -/// Pausing `unpause_procedure` must not brick the account: both mutators bypass both checks. +/// An entry written for `unpause_procedure` is stored but never read, so pausing it cannot brick +/// the account. #[tokio::test] async fn pausing_unpause_procedure_does_not_brick_the_account() -> anyhow::Result<()> { let mut builder = MockChain::builder(); @@ -649,8 +650,8 @@ async fn pausing_unpause_procedure_does_not_brick_the_account() -> anyhow::Resul &Authority::unpause_procedure_root() )?); - // It still runs, because it is gated on the emergency authority rather than - // `assert_authorized`, and so clears its own pause. + // Gated on the emergency authority rather than `assert_authorized`, it never + // reads the pause map, and clears its own entry. execute_note_on_faucet(&mut mock_chain, faucet.id(), &unpause_unpause_note).await?; assert!(!is_procedure_paused( &mock_chain,