diff --git a/CHANGELOG.md b/CHANGELOG.md index b979014a98..e2b57f532b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - [BREAKING] Removed `AuthMethod` enum, `AccountAuthComponent` / `AccountAuthScheme`, and the `AccessControl::AuthControlled` variant. Faucet and wallet factories now take concrete auth-component types so invalid configurations are rejected at compile time ([#2944](https://github.com/0xMiden/protocol/pull/2944)). - [BREAKING] Split `create_fungible_faucet` into `create_user_fungible_faucet(auth_component: AuthSingleSigAcl, ...)` (installs `Authority::AuthControlled` directly) and the opinionated `create_network_fungible_faucet(access_control, ...)` (always `AccountType::Public`, builds the `AuthNetworkAccount` allowlist internally from `MintNote` + `BurnNote` script roots with an empty tx-script allowlist). Other auth schemes / shapes are no longer supported through these helpers — fall back to `AccountBuilder` directly. A `user_faucet_single_sig_acl` testing helper is provided behind the `testing` feature ([#2944](https://github.com/0xMiden/protocol/pull/2944)). - Added `create_multisig_wallet` and `create_guarded_wallet` helpers for `BasicWallet` accounts authenticated by `AuthMultisig` and `AuthGuardedMultisig` respectively ([#2944](https://github.com/0xMiden/protocol/pull/2944)). +- Added a standalone `Guardian` account component storing a single guardian account ID, with `get_guardian` / `set_guardian` procedures and `is_sender_guardian` / `assert_sender_is_guardian` authorization primitives ([#3125](https://github.com/0xMiden/protocol/pull/3125)). - [BREAKING] `create_basic_wallet` now takes `AuthSingleSig` directly and returns `AccountError` instead of the removed `BasicWalletError` ([#2944](https://github.com/0xMiden/protocol/pull/2944)). - [BREAKING] Removed `AccountInterface::auth()` and `AccountComponentInterface::auth_scheme()`. Auth components are now discovered via `AccountInterface::auth_components()`, which iterates `AccountComponentInterface` variants flagged by `is_auth_component()` ([#2944](https://github.com/0xMiden/protocol/pull/2944)). - [BREAKING] `FungibleFaucet` no longer installs the `is_paused` storage slot itself. Faucet factories (`create_user_fungible_faucet` / `create_network_fungible_faucet`) now bundle the `Pausable` component (slot + `is_paused()` view procedure) alongside `PausableManager`. Callers using `AccountBuilder` directly must also install `Pausable` or the faucet's mint / burn / transfer / metadata-setter procedures will panic at runtime ([#2944](https://github.com/0xMiden/protocol/pull/2944)). diff --git a/crates/miden-protocol/asm/shared_modules/account_id.masm b/crates/miden-protocol/asm/shared_modules/account_id.masm index 15f2a3764b..e552a55c98 100644 --- a/crates/miden-protocol/asm/shared_modules/account_id.masm +++ b/crates/miden-protocol/asm/shared_modules/account_id.masm @@ -39,6 +39,23 @@ pub proc is_equal # => [is_id_equal] end +#! Returns a boolean indicating whether the given account ID is the zero address `(0, 0)`, without +#! consuming the ID. +#! +#! The zero address is not a valid account ID, so this can be used to detect an unassigned or +#! cleared account ID. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [is_zero, account_id_suffix, account_id_prefix] +#! +#! Where: +#! - account_id_{suffix,prefix} are the suffix and prefix felts of the account ID. +#! - is_zero is a boolean indicating whether the account ID is the zero address. +pub proc testz + dup.1 eq.0 dup.1 eq.0 and + # => [is_zero, account_id_suffix, account_id_prefix] +end + #! Validates an account ID. #! #! Note that this does not validate anything about the account type, since any 1-bit pattern is a diff --git a/crates/miden-standards/asm/account_components/access/guardian.masm b/crates/miden-standards/asm/account_components/access/guardian.masm new file mode 100644 index 0000000000..5820cc9c14 --- /dev/null +++ b/crates/miden-standards/asm/account_components/access/guardian.masm @@ -0,0 +1,6 @@ +# The MASM code of the Guardian Account Component. +# +# See the `Guardian` Rust type's documentation for more details. + +pub use ::miden::standards::access::guardian::get_guardian +pub use ::miden::standards::access::guardian::set_guardian diff --git a/crates/miden-standards/asm/standards/access/guardian.masm b/crates/miden-standards/asm/standards/access/guardian.masm new file mode 100644 index 0000000000..abc030abc8 --- /dev/null +++ b/crates/miden-standards/asm/standards/access/guardian.masm @@ -0,0 +1,182 @@ +# miden::standards::access::guardian +# +# Provides a standalone "guardian" actor for account components: a single guardian account ID +# stored in one value slot, and account-ID equality authorization primitives. +# +# The guardian is a second privileged actor, distinct from the owner, intended as a safety brake: +# other components consult `assert_sender_is_guardian` / `is_sender_guardian` to authorize +# some actions (e.g. an emergency freeze, or cancelling a scheduled operation). The guardian +# can never grant roles, move assets, or perform owner-only actions. It is purely an authorization +# subject checked by the procedures that opt into it. +# +# Authorization is a plain account-ID equality check, so this component needs no RBAC +# infrastructure and behaves the same across all authority modes. If no guardian is assigned the +# stored ID is the zero address `(0, 0)`, and the equality check returns 0 for every real sender, +# yielding "no guardian => nobody passes the guardian check" for free. +# +# Storage layout (single slot): +# Word: [guardian_suffix, guardian_prefix, 0, 0] + +use miden::protocol::active_account +use miden::protocol::account_id +use miden::protocol::active_note +use miden::protocol::native_account +use miden::standards::access::authority + +# CONSTANTS +# ================================================================================================ + +# The slot in this component's storage layout where the guardian account ID is stored. +const GUARDIAN_SLOT = word("miden::standards::access::guardian::guardian_id") + +# ERRORS +# ================================================================================================ + +const ERR_SENDER_NOT_GUARDIAN = "note sender is not the guardian" + +# INTERNAL PROCEDURES +# ================================================================================================ + +#! Returns the guardian account ID from storage. +#! +#! Inputs: [] +#! Outputs: [guardian_suffix, guardian_prefix] +#! +#! Where: +#! - guardian_{suffix, prefix} are the suffix and prefix felts of the guardian account ID. Both +#! are zero if no guardian is assigned. +proc get_guardian_internal + push.GUARDIAN_SLOT[0..2] exec.active_account::get_item + # => [guardian_suffix, guardian_prefix, 0, 0] + + movup.2 drop movup.2 drop + # => [guardian_suffix, guardian_prefix] +end + +#! Builds the guardian word, writes it to storage and drops the old value. +#! +#! Inputs: [guardian_suffix, guardian_prefix] +#! Outputs: [] +proc save_guardian_info + push.0.0 movup.3 movup.3 + # => [guardian_suffix, guardian_prefix, 0, 0] + + push.GUARDIAN_SLOT[0..2] + # => [slot_suffix, slot_prefix, guardian_suffix, guardian_prefix, 0, 0] + + exec.native_account::set_item + # => [OLD_GUARDIAN_WORD] + + dropw + # => [] +end + +#! Checks if the given account ID is the guardian. +#! +#! Inputs: [account_id_suffix, account_id_prefix] +#! Outputs: [is_guardian] +#! +#! Where: +#! - account_id_{suffix, prefix} are the suffix and prefix felts of the account ID to check. +#! - is_guardian is 1 if the account is the guardian, 0 otherwise. +proc is_guardian_internal + exec.get_guardian_internal + # => [guardian_suffix, guardian_prefix, account_id_suffix, account_id_prefix] + + exec.account_id::is_equal + # => [is_guardian] +end + +# PUBLIC INTERFACE +# ================================================================================================ + +#! Returns 1 if the note sender is the guardian, otherwise 0. +#! +#! Inputs: [] +#! Outputs: [is_sender_guardian] +#! +#! Where: +#! - is_sender_guardian is 1 if the note sender is the guardian, otherwise 0. +#! +#! Invocation: exec +pub proc is_sender_guardian + exec.active_note::get_sender + # => [sender_suffix, sender_prefix] + + exec.is_guardian_internal + # => [is_sender_guardian] +end + +#! Asserts that the note sender is the guardian. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - the note sender is not the guardian. +#! +#! Invocation: exec +pub proc assert_sender_is_guardian + exec.is_sender_guardian + # => [is_sender_guardian] + + assert.err=ERR_SENDER_NOT_GUARDIAN + # => [] +end + +#! Returns the guardian account ID. +#! +#! Inputs: [pad(16)] +#! Outputs: [guardian_suffix, guardian_prefix, pad(14)] +#! +#! Where: +#! - guardian_{suffix, prefix} are the suffix and prefix felts of the guardian account ID. Both +#! are zero if no guardian is assigned. +#! +#! Invocation: call +pub proc get_guardian + exec.get_guardian_internal + # => [guardian_suffix, guardian_prefix, pad(16)] + + movup.2 drop movup.2 drop + # => [guardian_suffix, guardian_prefix, pad(14)] +end + +#! Sets or clears the guardian account ID. +#! +#! Authorized through `authority::assert_authorized`, so the gate is mode-aware. The owner under +#! `OwnerControlled`, the configured role (or the owner fallback) under `RbacControlled`, and the +#! account's auth component under `AuthControlled`. Requires the [`Authority`] component to be +#! installed on the account. +#! +#! Clearing behaviour: +#! - If `new_guardian` is the zero address `(0, 0)`, the guardian is cleared. The zero address is +#! treated as a clear value and is not validated as an account ID. +#! - Otherwise, `new_guardian` is validated and stored as the guardian. +#! +#! Inputs: [new_guardian_suffix, new_guardian_prefix, pad(14)] +#! Outputs: [pad(16)] +#! +#! Panics if: +#! - the sender is not authorized. +#! - new_guardian is non-zero and the account ID is invalid. +#! +#! Invocation: call +pub proc set_guardian + exec.authority::assert_authorized + # => [new_guardian_suffix, new_guardian_prefix, pad(14)] + + # Detect explicit clear via the zero address. The zero address is not a valid account ID, so + # we must check for it before validating. + exec.account_id::testz + # => [is_zero_address, new_guardian_suffix, new_guardian_prefix, pad(14)] + + if.false + # Non-zero new guardian: validate before storing. + dup.1 dup.1 exec.account_id::validate + end + # => [new_guardian_suffix, new_guardian_prefix, pad(14)] + + exec.save_guardian_info + # => [pad(16)] +end diff --git a/crates/miden-standards/src/account/access/guardian.rs b/crates/miden-standards/src/account/access/guardian.rs new file mode 100644 index 0000000000..5b09942ebc --- /dev/null +++ b/crates/miden-standards/src/account/access/guardian.rs @@ -0,0 +1,191 @@ +use miden_protocol::account::component::{ + AccountComponentCode, + AccountComponentMetadata, + FeltSchema, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountComponentName, + AccountId, + AccountStorage, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::errors::AccountIdError; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::account::account_component_code; + +account_component_code!(GUARDIAN_CODE, "access/guardian.masl"); + +static GUARDIAN_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::access::guardian::guardian_id") + .expect("storage slot name should be valid") +}); + +/// A standalone guardian actor for account components. +/// +/// Stores a single guardian account ID. The guardian is a second privileged actor, distinct from +/// the owner, intended as a safety brake: other components consult the MASM +/// `assert_sender_is_guardian` / `is_sender_guardian` primitives to authorize stop-like actions. +/// The guardian can never grant roles, move assets, or perform owner-only actions. +/// +/// Authorization is a plain account-ID equality check, so the component needs no RBAC +/// infrastructure and behaves the same across all authority modes. When no guardian is assigned +/// the stored ID is the zero address and the guardian check fails for every sender. +/// +/// ## Storage Layout +/// +/// The guardian data is stored in a single word: +/// +/// ```text +/// [guardian_suffix, guardian_prefix, 0, 0] +/// ``` +pub struct Guardian { + /// The current guardian. `None` when no guardian is assigned. + guardian: Option, +} + +impl Guardian { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::access::guardian"; + + /// Returns the canonical [`AccountComponentName`] of this component. + pub const fn name() -> AccountComponentName { + AccountComponentName::from_static_str(Self::NAME) + } + + /// Returns the [`AccountComponentCode`] of this component. + pub fn code() -> &'static AccountComponentCode { + &GUARDIAN_CODE + } + + // CONSTRUCTORS + // -------------------------------------------------------------------------------------------- + + /// Creates a new [`Guardian`] with the given guardian account ID. + pub fn new(guardian: AccountId) -> Self { + Self { guardian: Some(guardian) } + } + + /// Creates a new [`Guardian`] with no guardian assigned. + pub fn unassigned() -> Self { + Self { guardian: None } + } + + /// Reads guardian data from account storage, validating any non-zero account ID. + /// + /// Returns an error if the guardian contains an invalid (but non-zero) account ID. + pub fn try_from_storage(storage: &AccountStorage) -> Result { + let word: Word = storage + .get_item(Self::slot_name()) + .map_err(GuardianError::StorageLookupFailed)?; + + Self::try_from_word(word) + } + + /// Reconstructs a [`Guardian`] from a raw storage word. + /// + /// Format: `[guardian_suffix, guardian_prefix, 0, 0]` + pub fn try_from_word(word: Word) -> Result { + let guardian = account_id_from_felt_pair(word[0], word[1]) + .map_err(GuardianError::InvalidGuardianId)?; + + Ok(Self { guardian }) + } + + // PUBLIC ACCESSORS + // -------------------------------------------------------------------------------------------- + + /// Returns the [`StorageSlotName`] where guardian data is stored. + pub fn slot_name() -> &'static StorageSlotName { + &GUARDIAN_SLOT_NAME + } + + /// Returns the storage slot schema for the guardian configuration slot. + pub fn slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::slot_name().clone(), + StorageSlotSchema::value( + "Guardian account ID", + [ + FeltSchema::felt("guardian_suffix"), + FeltSchema::felt("guardian_prefix"), + FeltSchema::felt("unused_0"), + FeltSchema::felt("unused_1"), + ], + ), + ) + } + + /// Returns the current guardian account ID, or `None` if no guardian is assigned. + pub fn account_id(&self) -> Option { + self.guardian + } + + /// Converts this guardian data into a [`StorageSlot`]. + pub fn to_storage_slot(&self) -> StorageSlot { + StorageSlot::with_value(Self::slot_name().clone(), self.to_word()) + } + + /// Converts this guardian data into a raw [`Word`]. + pub fn to_word(&self) -> Word { + let (guardian_suffix, guardian_prefix) = match self.guardian { + Some(id) => (id.suffix(), id.prefix().as_felt()), + None => (Felt::ZERO, Felt::ZERO), + }; + [guardian_suffix, guardian_prefix, Felt::ZERO, Felt::ZERO].into() + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = + StorageSchema::new([Self::slot_schema()]).expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME) + .with_description("Standalone guardian actor component") + .with_storage_schema(storage_schema) + } +} + +impl From for AccountComponent { + fn from(guardian: Guardian) -> Self { + let storage_slot = guardian.to_storage_slot(); + let metadata = Guardian::component_metadata(); + + AccountComponent::new(Guardian::code().clone(), vec![storage_slot], metadata).expect( + "Guardian component should satisfy the requirements of a valid account component", + ) + } +} + +// GUARDIAN ERROR +// ================================================================================================ + +/// Errors that can occur when reading [`Guardian`] data from storage. +#[derive(Debug, thiserror::Error)] +pub enum GuardianError { + #[error("failed to read guardian slot from storage")] + StorageLookupFailed(#[source] miden_protocol::errors::AccountError), + #[error("invalid guardian account ID in storage")] + InvalidGuardianId(#[source] AccountIdError), +} + +// HELPERS +// ================================================================================================ + +/// Constructs an `Option` from a suffix/prefix felt pair. +/// Returns `Ok(None)` when both felts are zero (no guardian assigned). +fn account_id_from_felt_pair( + suffix: Felt, + prefix: Felt, +) -> Result, AccountIdError> { + if suffix == Felt::ZERO && prefix == Felt::ZERO { + Ok(None) + } else { + AccountId::try_from_elements(suffix, prefix).map(Some) + } +} diff --git a/crates/miden-standards/src/account/access/mod.rs b/crates/miden-standards/src/account/access/mod.rs index 53d245ee57..28610612f0 100644 --- a/crates/miden-standards/src/account/access/mod.rs +++ b/crates/miden-standards/src/account/access/mod.rs @@ -4,6 +4,7 @@ use alloc::vec; use miden_protocol::account::{AccountComponent, AccountId, AccountProcedureRoot, RoleSymbol}; pub mod authority; +pub mod guardian; pub mod ownable2step; pub mod pausable; pub mod rbac; @@ -81,6 +82,7 @@ impl IntoIterator for AccessControl { } pub use authority::{Authority, AuthorityError}; +pub use guardian::{Guardian, GuardianError}; pub use ownable2step::{Ownable2Step, Ownable2StepError}; pub use pausable::{Pausable, PausableManager, PausableStorage}; pub use rbac::RoleBasedAccessControl; diff --git a/crates/miden-testing/tests/scripts/guardian.rs b/crates/miden-testing/tests/scripts/guardian.rs new file mode 100644 index 0000000000..a9391cbe0b --- /dev/null +++ b/crates/miden-testing/tests/scripts/guardian.rs @@ -0,0 +1,188 @@ +//! Tests for the standalone `Guardian` component (`get_guardian` / `set_guardian`). +//! +//! `set_guardian` is gated through `authority::assert_authorized`, so on an `Ownable2Step` +//! (`OwnerControlled`) account only the owner can set or clear the guardian. + +use miden_protocol::Felt; +use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType}; +use miden_protocol::asset::AssetAmount; +use miden_protocol::note::Note; +use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::access::{AccessControl, Guardian}; +use miden_standards::account::faucets::{FungibleFaucet, TokenName}; +use miden_standards::errors::standards::ERR_SENDER_NOT_OWNER; +use miden_testing::{ + AccountState, + Auth, + MockChain, + MockChainBuilder, + assert_transaction_executor_error, +}; + +use super::pausable::{ + NON_OWNER_ID, + OWNER_ID, + build_note, + execute_note_on_faucet, + test_account_id, +}; + +// FAUCET BUILDER +// ================================================================================================ + +/// Builds a fungible faucet with `Guardian + Ownable2Step(owner)`. `set_guardian` is gated by the +/// owner via `Authority::OwnerControlled` (installed automatically by +/// `AccessControl::Ownable2Step`). +fn add_guardian_faucet( + builder: &mut MockChainBuilder, + owner: AccountId, + guardian: Option, + seed: u8, +) -> anyhow::Result { + let faucet = FungibleFaucet::builder() + .name(TokenName::new("SYM")?) + .symbol("SYM".try_into()?) + .decimals(8) + .max_supply(AssetAmount::new(1_000_000)?) + .build()?; + + let guardian_component = match guardian { + Some(id) => Guardian::new(id), + None => Guardian::unassigned(), + }; + + let account_builder = AccountBuilder::new([seed; 32]) + .account_type(AccountType::Public) + .with_component(faucet) + .with_components(AccessControl::Ownable2Step { owner }) + .with_component(guardian_component); + + builder.add_account_from_builder(Auth::IncrNonce, account_builder, AccountState::Exists) +} + +// NOTE BUILDERS +// ================================================================================================ + +/// Builds a note that calls `guardian::set_guardian` with the given account ID felts. +fn build_set_guardian_note( + sender: AccountId, + new_guardian_suffix: Felt, + new_guardian_prefix: Felt, +) -> anyhow::Result { + build_note( + sender, + format!( + r#" + use miden::standards::access::guardian + + @note_script + pub proc main + repeat.14 push.0 end + push.{new_guardian_prefix} + push.{new_guardian_suffix} + call.guardian::set_guardian + dropw dropw dropw dropw + end + "# + ), + ) +} + +// HELPERS +// ================================================================================================ + +/// Reads the configured guardian from the faucet's storage. +fn read_guardian( + mock_chain: &MockChain, + faucet_id: AccountId, +) -> anyhow::Result> { + let account = mock_chain.committed_account(faucet_id)?; + Ok(Guardian::try_from_storage(account.storage())?.account_id()) +} + +// TESTS +// ================================================================================================ + +#[tokio::test] +async fn guardian_installs_with_initial_value() -> anyhow::Result<()> { + let initial_guardian = test_account_id(30); + + let mut builder = MockChain::builder(); + let faucet = add_guardian_faucet(&mut builder, *OWNER_ID, Some(initial_guardian), 70)?; + + let mock_chain = builder.build()?; + + assert_eq!(read_guardian(&mock_chain, faucet.id())?, Some(initial_guardian)); + + Ok(()) +} + +#[tokio::test] +async fn owner_sets_guardian() -> anyhow::Result<()> { + let new_guardian = test_account_id(31); + + let mut builder = MockChain::builder(); + let faucet = add_guardian_faucet(&mut builder, *OWNER_ID, None, 71)?; + + let set_note = + build_set_guardian_note(*OWNER_ID, new_guardian.suffix(), new_guardian.prefix().as_felt())?; + builder.add_output_note(RawOutputNote::Full(set_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &set_note).await?; + + assert_eq!(read_guardian(&mock_chain, faucet.id())?, Some(new_guardian)); + + Ok(()) +} + +#[tokio::test] +async fn non_owner_cannot_set_guardian() -> anyhow::Result<()> { + let new_guardian = test_account_id(32); + + let mut builder = MockChain::builder(); + let faucet = add_guardian_faucet(&mut builder, *OWNER_ID, None, 72)?; + + let attacker_note = build_set_guardian_note( + *NON_OWNER_ID, + new_guardian.suffix(), + new_guardian.prefix().as_felt(), + )?; + 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_tx_context(faucet.id(), &[attacker_note.id()], &[])? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_SENDER_NOT_OWNER); + + Ok(()) +} + +#[tokio::test] +async fn owner_clears_guardian() -> anyhow::Result<()> { + let initial_guardian = test_account_id(33); + + let mut builder = MockChain::builder(); + let faucet = add_guardian_faucet(&mut builder, *OWNER_ID, Some(initial_guardian), 73)?; + + // Clearing is done by setting the zero address `(0, 0)`. + let clear_note = build_set_guardian_note(*OWNER_ID, Felt::ZERO, Felt::ZERO)?; + builder.add_output_note(RawOutputNote::Full(clear_note.clone())); + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + execute_note_on_faucet(&mut mock_chain, faucet.id(), &clear_note).await?; + + assert_eq!(read_guardian(&mock_chain, faucet.id())?, None); + + Ok(()) +} diff --git a/crates/miden-testing/tests/scripts/mod.rs b/crates/miden-testing/tests/scripts/mod.rs index 3569771278..74f1108d9b 100644 --- a/crates/miden-testing/tests/scripts/mod.rs +++ b/crates/miden-testing/tests/scripts/mod.rs @@ -3,6 +3,7 @@ mod authority; mod blocklist; mod expiration; mod faucet; +mod guardian; mod ownable2step; mod p2id; mod p2ide;