diff --git a/CHANGELOG.md b/CHANGELOG.md index 7249447db9..041a344c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,10 @@ - Added `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). - [BREAKING] Removed unused `payback_attachment` from `SwapNoteStorage` and `attachment` from `MintNoteStorage` ([#2789](https://github.com/0xMiden/protocol/pull/2789)). - Automatically enable `concurrent` feature in `miden-tx` for `std` context ([#2791](https://github.com/0xMiden/protocol/pull/2791)). +- Added foundations for `AuthMultisigSmart` ([#2806](https://github.com/0xMiden/protocol/pull/2806)). +- Extended `AuthMultisigSmart` with spending-limit tiers, oracle-priced amount conversion, a timelock-controlled propose/cancel/execute flow, and `AuthMultisigSmartPresets` opinionated policy bundles ([#2973](https://github.com/0xMiden/protocol/pull/2973)). +- Added `AuthNetworkAccount` auth component that rejects transactions which execute a tx script or consume input notes outside of a fixed allowlist of note script roots ([#2817](https://github.com/0xMiden/protocol/pull/2817)). +- Added standardized `NetworkAccountNoteAllowlist` slot for detecting network accounts ([#2883](https://github.com/0xMiden/protocol/pull/2883)). - Added `Pausable` standard component with `pause`, `unpause`, `is_paused` procedures and `on_before_asset_added_to_account`, `on_before_asset_added_to_note` callbacks ([#2793](https://github.com/0xMiden/protocol/pull/2793)). - Added trace row counts to `bench-tx.json` ([#2794](https://github.com/0xMiden/protocol/pull/2794)). - [BREAKING] Renamed `set_attachment` to `add_attachment`, `set_word_attachment` to `add_word_attachment`, and `set_array_attachment` to `add_array_attachment` in `miden::protocol::output_note` ([#2795](https://github.com/0xMiden/protocol/pull/2795), [#2849](https://github.com/0xMiden/protocol/pull/2849)). diff --git a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm index af05afa295..691198d8fd 100644 --- a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm +++ b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm @@ -4,12 +4,26 @@ use miden::standards::auth::multisig use miden::standards::auth::multisig_smart +use miden::standards::auth::multisig_smart::oracle +use miden::standards::auth::multisig_smart::spending_limits +use miden::standards::auth::multisig_smart::delayed_execution pub use multisig::get_threshold_and_num_approvers pub use multisig::get_signer_at pub use multisig::is_signer pub use multisig_smart::set_procedure_policy pub use multisig_smart::update_signers_and_threshold +pub use delayed_execution::update_delayed_execution_policy +pub use delayed_execution::propose_transaction +pub use delayed_execution::cancel_transaction_proposal +pub use delayed_execution::cancel_and_propose_new_transaction +pub use delayed_execution::execute_proposed_transaction +pub use spending_limits::update_spending_window_policy +pub use spending_limits::update_spending_limits +pub use spending_limits::update_threshold_config +pub use oracle::update_get_price_untracked_policy +pub use oracle::update_oracle_config_and_proc_root + #! Authenticate a transaction using multisig smart-policy rules. #! diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm new file mode 100644 index 0000000000..17b0d16616 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -0,0 +1,1036 @@ +use miden::core::word +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::tx + +# CONSTANTS +# ================================================================================================= + +# [min_delay, propose_expiration_delta, 0, 0] +const DELAYED_EXECUTION_SLOT = word("miden::standards::auth::multisig_smart::delayed_execution") + +# Map entries: [TX_HASH] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] +const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_proposals") + +# Pending action slots +const PENDING_PROPOSE_SLOT = word("miden::standards::auth::multisig_smart::pending_propose") +const PENDING_CANCEL_SLOT = word("miden::standards::auth::multisig_smart::pending_cancel") +const PENDING_EXECUTE_SLOT = word("miden::standards::auth::multisig_smart::pending_execute") + +const EMPTY_WORD = [0, 0, 0, 0] +const PENDING_EXECUTE_FLAG = [1, 0, 0, 0] + +# ERRORS +# ================================================================================================= + +const ERR_MIN_DELAY_NOT_U32 = "min_delay must be u32" +const ERR_MIN_DELAY_ZERO = "min_delay must be non-zero" +const ERR_PROPOSE_EXPIRATION_DELTA_NOT_U32 = "propose_expiration_delta must be u32" +const ERR_PROPOSE_EXPIRATION_DELTA_ZERO = "propose_expiration_delta must be non-zero" +const ERR_TIMESTAMPS_NOT_U32 = "current_timestamp and unlock_timestamp must be u32" +const ERR_TX_ALREADY_PROPOSED = "tx already proposed" +const ERR_TX_NOT_PROPOSED = "tx not proposed" +const ERR_TX_STILL_TIMELOCKED = "tx still timelocked" +const ERR_PENDING_ALREADY_SET = "pending action already set" +const ERR_PROPOSE_ZERO_SIGNATURES = "num_verified_signatures cannot be zero" +const ERR_CANCEL_INSUFFICIENT_SIGNATURES = "insufficient signatures for cancel" +const ERR_EXECUTE_PATH_MISMATCH = "execute path must match delay requirement" +const ERR_EXPIRATION_DELTA_ZERO = "expiration_delta must be non-zero" +const ERR_TX_EXPIRATION_DELTA_NOT_SET = "tx expiration delta must be set" + +# CONFIGURATION +# ================================================================================================= + +#! High-impact action. +#! +#! Sets the timelock controller used by proposal lifecycle checks. +#! +#! Inputs: [min_delay, propose_expiration_delta] +#! Outputs: [] +#! +#! Side effects: +#! - Writes DELAYED_EXECUTION_SLOT with word +#! `[min_delay, propose_expiration_delta, 0, 0]` (see `get_delayed_execution`). +#! +#! Panics if: +#! - `min_delay` is not a valid `u32` field element (`ERR_MIN_DELAY_NOT_U32`). +#! - `propose_expiration_delta` is not a valid `u32` field element +#! (`ERR_PROPOSE_EXPIRATION_DELTA_NOT_U32`). +#! - `min_delay` is zero (`ERR_MIN_DELAY_ZERO`). +#! - `propose_expiration_delta` is zero (`ERR_PROPOSE_EXPIRATION_DELTA_ZERO`). +#! +#! Invocation: exec +pub proc update_delayed_execution_policy + u32assert.err=ERR_MIN_DELAY_NOT_U32 + dup eq.0 assertz.err=ERR_MIN_DELAY_ZERO + # => [min_delay, propose_expiration_delta] + + swap u32assert.err=ERR_PROPOSE_EXPIRATION_DELTA_NOT_U32 + dup eq.0 assertz.err=ERR_PROPOSE_EXPIRATION_DELTA_ZERO + # => [propose_expiration_delta, min_delay] + + push.0 push.0 + # => [0, 0, propose_expiration_delta, min_delay] + + push.DELAYED_EXECUTION_SLOT[0..2] + # => [slot_prefix, slot_suffix, 0, 0, propose_expiration_delta, min_delay] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Loads the timelock controller configuration. +#! +#! Inputs: [] +#! Outputs: [min_delay, propose_expiration_delta, 0, 0] +proc get_delayed_execution + push.DELAYED_EXECUTION_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_initial_item + # => [min_delay, propose_expiration_delta, 0, 0] +end + +#! Returns 1 if `execute_proposed_transaction` was called in the current transaction, 0 otherwise. +#! +#! Inputs: [] +#! Outputs: [is_execute_path] +#! +#! Where: +#! - is_execute_path is 1 if `PENDING_EXECUTE_SLOT` is non-empty, otherwise 0. +#! +#! Invocation: exec +pub proc is_execute_path + push.PENDING_EXECUTE_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [PENDING_EXECUTE_WORD] + + exec.word::eqz + # => [is_pending_execute_empty] + + not + # => [is_execute_path] +end + + +#! Returns `min_delay` from [`DELAYED_EXECUTION_SLOT`] +#! +#! Inputs: [] +#! Outputs: [min_delay] +#! +#! Invocation: exec +proc get_min_delay + exec.get_delayed_execution + # => [min_delay, propose_expiration_delta, 0, 0] + + movdn.3 drop drop drop + # => [min_delay] +end + +#! Returns `propose_expiration_delta` from [`DELAYED_EXECUTION_SLOT`] +#! +#! Proposal transactions must carry a non-zero expiration delta. This helper is used +#! when applying that delta to the current transaction (`apply_propose_expiration_delta`). +#! +#! Inputs: [] +#! Outputs: [propose_expiration_delta] +#! +#! Invocation: exec +proc get_propose_expiration_delta + exec.get_delayed_execution + # => [min_delay, propose_expiration_delta, 0, 0] + + drop movdn.2 drop drop + # => [propose_expiration_delta] +end + +#! Applies `expiration_delta` to the current transaction and verifies it was set. +#! +#! Inputs: [expiration_delta] +#! Outputs: [] +#! +#! Where: +#! - expiration_delta is the desired transaction expiration block delta. +#! +#! Panics if: +#! - expiration_delta is zero (`ERR_EXPIRATION_DELTA_ZERO`). +#! - `tx::update_expiration_block_delta` rejects `expiration_delta`. +#! - the stored transaction expiration delta is zero after the update +#! (`ERR_TX_EXPIRATION_DELTA_NOT_SET`). +#! +#! Invocation: exec +pub proc apply_expiration_delta + dup neq.0 assert.err=ERR_EXPIRATION_DELTA_ZERO + # => [expiration_delta] + + exec.tx::update_expiration_block_delta + # => [] + + exec.tx::get_expiration_block_delta + # => [tx_expiration_delta] + + dup neq.0 assert.err=ERR_TX_EXPIRATION_DELTA_NOT_SET + # => [tx_expiration_delta] + + drop + # => [] +end + + +#! Inputs: [] +#! Outputs: [] +proc apply_propose_expiration_delta + exec.get_propose_expiration_delta + # => [propose_expiration_delta] + + exec.apply_expiration_delta + # => [] +end + +# TIMELOCK + PROPOSALS HELPERS +# ================================================================================================= + +#! Returns the proposal entry stored for `TX_HASH`. +#! +#! Inputs: [TX_HASH] +#! Outputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] +#! +#! Where: +#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! - unlock_timestamp is the earliest timestamp at which the proposal can be executed, +#! or 0 if no proposal exists. +#! - proposal_timestamp is the timestamp at which the proposal was recorded, +#! or 0 if no proposal exists. +#! - min_cancel_sigs is the minimum number of signatures required to cancel the proposal, +#! or 0 if no proposal exists. +#! - is_set is 1 if a proposal exists for `TX_HASH`, otherwise 0. +#! +#! Invocation: exec +proc get_tx_proposal(tx_hash: word) + push.TX_PROPOSALS_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_HASH] + + exec.active_account::get_map_item + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] +end + +#! Returns 1 if a proposal exists for `TX_HASH`, 0 otherwise. +#! +#! Inputs: [TX_HASH] +#! Outputs: [is_proposed] +#! +#! Where: +#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! - is_proposed is 1 if the stored proposal word is non-empty, otherwise 0. +#! +#! Invocation: exec +proc is_tx_proposed(tx_hash: word) -> u32 + exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + + exec.word::eqz + # => [is_proposal_empty] + + not + # => [is_proposed] +end + + +#! Deletes the proposal entry for `TX_HASH` by writing `EMPTY_WORD`. +#! +#! Inputs: [TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! +#! Side effects: +#! - Writes `EMPTY_WORD` to `TX_PROPOSALS_SLOT[TX_HASH]`. +#! +#! Invocation: exec +proc remove_tx_proposal(tx_hash: word) + push.EMPTY_WORD + # => [EMPTY_WORD, TX_HASH] + + swapw + # => [TX_HASH, EMPTY_WORD] + + push.TX_PROPOSALS_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_HASH, EMPTY_WORD] + + exec.native_account::set_map_item + # => [OLD_PROPOSAL_WORD] + + dropw + # => [] +end + + +#! Writes the proposal entry for `TX_HASH` unconditionally. +#! +#! The `is_set` flag is always written as `1`. The caller is responsible for ensuring +#! the proposal does not already exist. +#! +#! Inputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - unlock_timestamp is the earliest timestamp at which the proposal can be executed. +#! - proposal_timestamp is the timestamp at which the proposal was recorded. +#! - min_cancel_sigs is the minimum number of signatures required to cancel the proposal. +#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! +#! Side effects: +#! - Writes `TX_PROPOSALS_SLOT[TX_HASH] = +#! [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1]`. +#! +#! Invocation: exec +proc write_tx_proposal + push.1 + # => [1, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_HASH] + + movdn.3 + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1, TX_HASH] + + swapw + # => [TX_HASH, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + + push.TX_PROPOSALS_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_HASH, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + + exec.native_account::set_map_item + # => [OLD_PROPOSAL_WORD] + + dropw + # => [] +end + + +#! Computes the unlock timestamp for the current proposal. +#! +#! Inputs: [] +#! Outputs: [unlock_timestamp, proposal_timestamp] +#! +#! Where: +#! - proposal_timestamp is the current transaction reference block timestamp. +#! - unlock_timestamp is `proposal_timestamp + min_delay`. +#! +#! Invocation: exec +proc compute_unlock_timestamp + exec.tx::get_block_timestamp + # => [proposal_timestamp] + + dup exec.get_min_delay + # => [min_delay, proposal_timestamp, proposal_timestamp] + + add + # => [unlock_timestamp, proposal_timestamp] +end + + +#! Asserts that the proposal unlock timestamp has been reached. +#! +#! Inputs: [current_timestamp, unlock_timestamp] +#! Outputs: [] +#! +#! Where: +#! - current_timestamp is the current transaction reference block timestamp. +#! - unlock_timestamp is the earliest timestamp at which execution is allowed. +#! +#! Panics if: +#! - current_timestamp or unlock_timestamp is not a valid `u32` field element +#! (`ERR_TIMESTAMPS_NOT_U32`). +#! - current_timestamp is less than unlock_timestamp (`ERR_TX_STILL_TIMELOCKED`). +#! +#! Invocation: exec +proc assert_unlock_reached + u32assert2.err=ERR_TIMESTAMPS_NOT_U32 + # => [current_timestamp, unlock_timestamp] + + swap + # => [unlock_timestamp, current_timestamp] + + u32lt assertz.err=ERR_TX_STILL_TIMELOCKED + # => [] +end + + +#! Enforces that `TX_HASH` is proposed and that its unlock timestamp has been reached. +#! +#! Inputs: [TX_HASH] +#! Outputs: [min_cancel_sigs] +#! +#! Where: +#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! - min_cancel_sigs is the minimum number of signatures required to cancel the proposal. +#! +#! Panics if: +#! - no proposal exists for `TX_HASH` (`ERR_TX_NOT_PROPOSED`). +#! - `assert_unlock_reached` fails. +#! +#! Invocation: exec +proc enforce_tx_timelock + dupw exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set, TX_HASH] + + dup.3 eq.1 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set, TX_HASH] + + swapw + # => [TX_HASH, unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + + dropw + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + + exec.tx::get_block_timestamp + # => [current_timestamp, unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + + exec.assert_unlock_reached + # => [proposal_timestamp, min_cancel_sigs, is_set] + + swap + # => [min_cancel_sigs, proposal_timestamp, is_set] + + movdn.2 drop drop + # => [min_cancel_sigs] +end + + +# PENDING SLOT MANAGEMENT HELPERS +# ================================================================================================= + +#! Returns the pending propose transaction summary hash, or `EMPTY_WORD` if none is set. +#! +#! Inputs: [] +#! Outputs: [PENDING_PROPOSE_HASH] +#! +#! Where: +#! - PENDING_PROPOSE_HASH is the pending propose transaction summary hash, or +#! `EMPTY_WORD` if no propose action is pending. +#! +#! Invocation: exec +proc get_pending_propose + push.PENDING_PROPOSE_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [PENDING_PROPOSE_HASH] +end + + +#! Returns the pending cancel transaction summary hash, or `EMPTY_WORD` if none is set. +#! +#! Inputs: [] +#! Outputs: [PENDING_CANCEL_HASH] +#! +#! Where: +#! - PENDING_CANCEL_HASH is the pending cancel transaction summary hash, or +#! `EMPTY_WORD` if no cancel action is pending. +#! +#! Invocation: exec +proc get_pending_cancel + push.PENDING_CANCEL_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [PENDING_CANCEL_HASH] +end + + +#! Returns the pending execute transaction summary hash, or `EMPTY_WORD` if none is set. +#! +#! Inputs: [] +#! Outputs: [PENDING_EXECUTE_HASH] +#! +#! Where: +#! - PENDING_EXECUTE_HASH is the pending execute transaction summary hash, or +#! `EMPTY_WORD` if no execute action is pending. +#! +#! Invocation: exec +proc get_pending_execute + push.PENDING_EXECUTE_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [PENDING_EXECUTE_HASH] +end + + + +#! Sets the pending propose slot to `TX_HASH`. +#! +#! Inputs: [TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - TX_HASH is the transaction summary hash to stage for proposal. +#! +#! Panics if: +#! - `PENDING_PROPOSE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). +#! +#! Side effects: +#! - Writes `TX_HASH` to `PENDING_PROPOSE_SLOT`. +#! +#! Invocation: exec +proc set_pending_propose + exec.get_pending_propose + # => [pending_propose_word, TX_HASH] + + exec.word::eqz + # => [is_pending_empty, TX_HASH] + + assert.err=ERR_PENDING_ALREADY_SET + # => [TX_HASH] + + push.PENDING_PROPOSE_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_HASH] + + exec.native_account::set_item + # => [old_pending_propose_word] + + dropw + # => [] +end + +#! Clears the pending propose transaction summary hash. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Side effects: +#! - Writes `EMPTY_WORD` to `PENDING_PROPOSE_SLOT`. +#! +#! Invocation: exec +proc clear_pending_propose + push.EMPTY_WORD + # => [EMPTY_WORD] + + push.PENDING_PROPOSE_SLOT[0..2] + # => [slot_prefix, slot_suffix, EMPTY_WORD] + + exec.native_account::set_item + # => [OLD_PENDING_PROPOSE_HASH] + + dropw + # => [] +end + + +#! Sets the pending cancel slot to `TX_HASH`. +#! +#! Inputs: [TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - TX_HASH is the transaction summary hash to stage for cancellation. +#! +#! Panics if: +#! - `PENDING_CANCEL_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). +#! +#! Side effects: +#! - Writes `TX_HASH` to `PENDING_CANCEL_SLOT`. +#! +#! Invocation: exec +proc set_pending_cancel + exec.get_pending_cancel + # => [pending_cancel_word, TX_HASH] + + exec.word::eqz + # => [is_pending_empty, TX_HASH] + + assert.err=ERR_PENDING_ALREADY_SET + # => [TX_HASH] + + push.PENDING_CANCEL_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_HASH] + + exec.native_account::set_item + # => [old_pending_cancel_word] + + dropw + # => [] +end + +#! Clears the pending cancel transaction summary hash. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Side effects: +#! - Writes `EMPTY_WORD` to `PENDING_CANCEL_SLOT`. +#! +#! Invocation: exec +proc clear_pending_cancel + push.EMPTY_WORD + # => [EMPTY_WORD] + + push.PENDING_CANCEL_SLOT[0..2] + # => [slot_prefix, slot_suffix, EMPTY_WORD] + + exec.native_account::set_item + # => [OLD_PENDING_CANCEL_HASH] + + dropw + # => [] +end + + +#! Marks the current transaction as an execute-path transaction. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - `PENDING_EXECUTE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). +#! +#! Side effects: +#! - Writes `PENDING_EXECUTE_FLAG` to `PENDING_EXECUTE_SLOT`. +#! +#! Invocation: exec +proc set_pending_execute + exec.get_pending_execute + # => [pending_execute_word] + + exec.word::eqz + # => [is_pending_empty] + + assert.err=ERR_PENDING_ALREADY_SET + # => [] + + push.PENDING_EXECUTE_FLAG + # => [PENDING_EXECUTE_FLAG] + + push.PENDING_EXECUTE_SLOT[0..2] + # => [slot_prefix, slot_suffix, PENDING_EXECUTE_FLAG] + + exec.native_account::set_item + # => [old_pending_execute_word] + + dropw + # => [] +end + + +#! Clears the pending execute transaction summary hash. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Side effects: +#! - Writes `EMPTY_WORD` to `PENDING_EXECUTE_SLOT`. +#! +#! Invocation: exec +proc clear_pending_execute + push.EMPTY_WORD + # => [EMPTY_WORD] + + push.PENDING_EXECUTE_SLOT[0..2] + # => [slot_prefix, slot_suffix, EMPTY_WORD] + + exec.native_account::set_item + # => [OLD_PENDING_EXECUTE_HASH] + + dropw + # => [] +end + + +# TX ACTIONS API +# ================================================================================================= + +#! Stages a new proposal for `TX_HASH`. +#! +#! Any signer can call this procedure. The proposal word itself is written later in the auth +#! finalizer after signature verification completes. +#! +#! Inputs: [TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - TX_HASH is the transaction summary hash of the proposal to stage. +#! +#! Panics if: +#! - a proposal already exists for `TX_HASH` (`ERR_TX_ALREADY_PROPOSED`). +#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). +#! - `apply_propose_expiration_delta` fails. +#! +#! Side effects: +#! - Applies the propose expiration delta to the current transaction. +#! - Writes `TX_HASH` to `PENDING_PROPOSE_SLOT`. +#! +#! Invocation: exec +pub proc propose_transaction + exec.apply_propose_expiration_delta + # => [TX_HASH] + + dupw + # => [TX_HASH, TX_HASH] + + exec.is_tx_proposed + # => [is_proposed, TX_HASH] + + assertz.err=ERR_TX_ALREADY_PROPOSED + # => [TX_HASH] + + exec.set_pending_propose + # => [] +end + + +#! Stages cancellation of an existing proposal. +#! +#! The proposal is not removed immediately, the actual delete happens in the auth finalizer. +#! +#! Inputs: [TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - TX_HASH is the transaction summary hash of the proposal to cancel. +#! +#! Panics if: +#! - no proposal exists for `TX_HASH` (`ERR_TX_NOT_PROPOSED`). +#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). +#! +#! Side effects: +#! - Writes `TX_HASH` to `PENDING_CANCEL_SLOT`. +#! +#! Invocation: exec +pub proc cancel_transaction_proposal + dupw + # => [TX_HASH, TX_HASH] + + exec.is_tx_proposed + # => [is_proposed, TX_HASH] + + assert.err=ERR_TX_NOT_PROPOSED + # => [TX_HASH] + + exec.set_pending_cancel + # => [] +end + + +#! Cancels an existing proposal and stages a replacement proposal in the same transaction. +#! +#! The old proposal is marked for cancellation first, and the new proposal is then staged for +#! creation in the auth finalizer. +#! +#! Inputs: [OLD_TX_HASH, NEW_TX_HASH] +#! Outputs: [] +#! +#! Where: +#! - OLD_TX_HASH is the transaction summary hash of the proposal to cancel. +#! - NEW_TX_HASH is the transaction summary hash of the proposal to stage. +#! +#! Panics if: +#! - no proposal exists for `OLD_TX_HASH` (`ERR_TX_NOT_PROPOSED`). +#! - a proposal already exists for `NEW_TX_HASH` (`ERR_TX_ALREADY_PROPOSED`). +#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). +#! - `apply_propose_expiration_delta` fails. +#! +#! Side effects: +#! - Writes `OLD_TX_HASH` to `PENDING_CANCEL_SLOT`. +#! - Applies the propose expiration delta to the current transaction. +#! - Writes `NEW_TX_HASH` to `PENDING_PROPOSE_SLOT`. +#! +#! Invocation: exec +pub proc cancel_and_propose_new_transaction + dupw + # => [OLD_TX_HASH, OLD_TX_HASH, NEW_TX_HASH] + + exec.is_tx_proposed + # => [is_old_tx_proposed, OLD_TX_HASH, NEW_TX_HASH] + + assert.err=ERR_TX_NOT_PROPOSED + # => [OLD_TX_HASH, NEW_TX_HASH] + + swapw + # => [NEW_TX_HASH, OLD_TX_HASH] + + dupw + # => [NEW_TX_HASH, NEW_TX_HASH, OLD_TX_HASH] + + exec.is_tx_proposed + # => [is_new_tx_proposed, NEW_TX_HASH, OLD_TX_HASH] + + assertz.err=ERR_TX_ALREADY_PROPOSED + # => [NEW_TX_HASH, OLD_TX_HASH] + + swapw + # => [OLD_TX_HASH, NEW_TX_HASH] + + exec.set_pending_cancel + # => [NEW_TX_HASH] + + exec.apply_propose_expiration_delta + # => [NEW_TX_HASH] + + exec.set_pending_propose + # => [] +end + + +#! Marks that this tx intends to execute a proposed action. +#! +#! Inputs: [] +#! Outputs: [] +#! +#! Panics if: +#! - `PENDING_EXECUTE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). +#! +#! Side effects: +#! - Writes `PENDING_EXECUTE_FLAG` to `PENDING_EXECUTE_SLOT`. +#! +#! Invocation: exec +pub proc execute_proposed_transaction + exec.set_pending_execute + # => [] +end + +# TX FINALIZERS +# ================================================================================================= + +#! Finalizes a pending execute action for the current transaction. +#! +#! Inputs: [num_verified_signatures, requires_delay, TX_HASH] +#! Outputs: [num_verified_signatures, TX_HASH] +#! +#! Where: +#! - num_verified_signatures is the number of signatures verified for the current transaction. +#! - requires_delay is 1 if the current transaction must use the execute lane, otherwise 0. +#! - TX_HASH is the current transaction summary hash. +#! +#! Panics if: +#! - `enforce_tx_timelock` fails. +#! +#! Side effects: +#! - If `PENDING_EXECUTE_SLOT` is non-empty, removes the proposal for `TX_HASH`. +#! - Clears `PENDING_EXECUTE_SLOT`. +#! +#! Locals: +#! 0: num_verified_signatures +#! +#! Invocation: exec +@locals(1) +proc finalize_pending_execute + loc_store.0 + # => [requires_delay, TX_HASH] + + exec.get_pending_execute + # => [PENDING_EXECUTE_HASH, requires_delay, TX_HASH] + + exec.word::eqz + # => [is_pending_execute_empty, requires_delay, TX_HASH] + + if.true + drop + # => [TX_HASH] + else + if.true + dupw + # => [TX_HASH, TX_HASH] + + exec.enforce_tx_timelock + # => [min_cancel_sigs, TX_HASH] + + drop + # => [TX_HASH] + end + + dupw + # => [TX_HASH, TX_HASH] + + exec.remove_tx_proposal + # => [TX_HASH] + end + + exec.clear_pending_execute + # => [TX_HASH] + + loc_load.0 + # => [num_verified_signatures, TX_HASH] +end + +#! Finalizes a pending cancel action for the current transaction. +#! +#! Inputs: [num_verified_signatures, TX_HASH] +#! Outputs: [num_verified_signatures, TX_HASH] +#! +#! Where: +#! - num_verified_signatures is the number of signatures verified for the current transaction. +#! - TX_HASH is the current transaction summary hash. +#! +#! Panics if: +#! - the pending cancel hash is not currently proposed (`ERR_TX_NOT_PROPOSED`). +#! - `num_verified_signatures` is less than `min_cancel_sigs` +#! (`ERR_CANCEL_INSUFFICIENT_SIGNATURES`). +#! +#! Side effects: +#! - If `PENDING_CANCEL_SLOT` is non-empty, removes the proposal for the pending cancel hash. +#! - Clears `PENDING_CANCEL_SLOT`. +#! +#! Locals: +#! 0: num_verified_signatures +#! +#! Invocation: exec +@locals(1) +proc finalize_pending_cancel + loc_store.0 + # => [TX_HASH] + + exec.get_pending_cancel + # => [PENDING_CANCEL_HASH, TX_HASH] + + exec.word::eqz + # => [is_pending_cancel_empty, TX_HASH] + + if.false + exec.get_pending_cancel + # => [PENDING_CANCEL_HASH, TX_HASH] + + dupw + # => [PENDING_CANCEL_HASH, PENDING_CANCEL_HASH, TX_HASH] + + exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set, PENDING_CANCEL_HASH, TX_HASH] + + dup.3 eq.1 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set, PENDING_CANCEL_HASH, TX_HASH] + + drop drop swap drop + # => [min_cancel_sigs, PENDING_CANCEL_HASH, TX_HASH] + + loc_load.0 + # => [num_verified_signatures, min_cancel_sigs, PENDING_CANCEL_HASH, TX_HASH] + + swap + # => [min_cancel_sigs, num_verified_signatures, PENDING_CANCEL_HASH, TX_HASH] + + u32lt assertz.err=ERR_CANCEL_INSUFFICIENT_SIGNATURES + # => [PENDING_CANCEL_HASH, TX_HASH] + + exec.remove_tx_proposal + # => [TX_HASH] + end + + exec.clear_pending_cancel + # => [TX_HASH] + + loc_load.0 + # => [num_verified_signatures, TX_HASH] +end + +#! Finalizes a pending propose action for the current transaction. +#! +#! Inputs: [num_verified_signatures, TX_HASH] +#! Outputs: [TX_HASH] +#! +#! Where: +#! - num_verified_signatures is the number of signatures verified for the current transaction. +#! - TX_HASH is the current transaction summary hash. +#! +#! Panics if: +#! - the pending propose hash is already proposed (`ERR_TX_ALREADY_PROPOSED`). +#! - `num_verified_signatures` is zero (`ERR_PROPOSE_ZERO_SIGNATURES`). +#! - `apply_propose_expiration_delta` fails. +#! +#! Side effects: +#! - If `PENDING_PROPOSE_SLOT` is non-empty, writes a new proposal for the pending propose hash. +#! - Clears `PENDING_PROPOSE_SLOT`. +#! +#! Locals: +#! 0: num_verified_signatures +#! +#! Invocation: exec +@locals(1) +proc finalize_pending_propose + loc_store.0 + # => [TX_HASH] + + exec.get_pending_propose + # => [PENDING_PROPOSE_HASH, TX_HASH] + + exec.word::eqz + # => [is_pending_propose_empty, TX_HASH] + + if.false + exec.get_pending_propose + # => [PENDING_PROPOSE_HASH, TX_HASH] + + dupw + # => [PENDING_PROPOSE_HASH, PENDING_PROPOSE_HASH, TX_HASH] + + exec.is_tx_proposed + # => [is_proposed, PENDING_PROPOSE_HASH, TX_HASH] + + assertz.err=ERR_TX_ALREADY_PROPOSED + # => [PENDING_PROPOSE_HASH, TX_HASH] + + loc_load.0 + # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + dup + # => [num_verified_signatures, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + eq.0 + # => [num_verified_eq_0, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + assertz.err=ERR_PROPOSE_ZERO_SIGNATURES + # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + exec.apply_propose_expiration_delta + # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + exec.compute_unlock_timestamp + # => [unlock_timestamp, proposal_timestamp, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + + exec.write_tx_proposal + # => [TX_HASH] + end + + exec.clear_pending_propose + # => [TX_HASH] +end + +#! Finalizes all staged timelock actions for the current transaction. +#! +#! Pending actions are processed in this order: +#! 1. execute +#! 2. cancel +#! 3. propose +#! +#! Inputs: [num_verified_signatures, requires_delay, TX_HASH] +#! Outputs: [TX_HASH] +#! +#! Where: +#! - num_verified_signatures is the number of signatures verified for the current transaction. +#! - requires_delay is 1 if the current transaction must use the execute lane, otherwise 0. +#! - TX_HASH is the current transaction summary hash. +#! +#! Panics if: +#! - `finalize_pending_execute` fails. +#! - `finalize_pending_cancel` fails. +#! - `finalize_pending_propose` fails. +#! +#! Side effects: +#! - Clears `PENDING_EXECUTE_SLOT`, `PENDING_CANCEL_SLOT`, and `PENDING_PROPOSE_SLOT`. +#! - May remove an existing proposal for `TX_HASH`. +#! - May remove an existing proposal for the pending cancel hash. +#! - May write a new proposal for the pending propose hash. +#! +#! Invocation: exec +pub proc finalize_timelock_proposals + exec.finalize_pending_execute + # => [num_verified_signatures, TX_HASH] + + exec.finalize_pending_cancel + # => [num_verified_signatures, TX_HASH] + + exec.finalize_pending_propose + # => [TX_HASH] +end diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm index 33aa44eeb6..8131a347d6 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -6,6 +6,8 @@ use miden::standards::auth::multisig use miden::standards::auth::multisig::APPROVER_PUBLIC_KEYS_SLOT use miden::standards::auth::multisig::APPROVER_SCHEME_ID_SLOT use miden::standards::auth::multisig::THRESHOLD_CONFIG_SLOT +use miden::standards::auth::multisig_smart::spending_limits +use miden::standards::auth::multisig_smart::delayed_execution use miden::standards::auth::signature use miden::standards::auth::tx_policy @@ -59,6 +61,8 @@ const ERR_INVALID_NOTE_RESTRICTIONS = "procedure policy note restrictions must b const ERR_INSUFFICIENT_SIGNATURES = "insufficient number of signatures" +const ERR_EXECUTE_PATH_MISMATCH = "execute path must match delay requirement" + # LOCAL ADDRESSES (set_procedure_policy) # ================================================================================================= @@ -158,28 +162,32 @@ proc multisig_config_to_num_approvers # => [num_approvers, MULTISIG_CONFIG] end -#! Computes the effective transaction threshold. -#! -#! Used as a fallback layer on top of `compute_called_proc_policy`: when no non-auth procedure was -#! called (policy_threshold is 0), the tx still needs to require at least `default_threshold` -#! signatures. When any non-auth procedure was called, `compute_called_proc_policy` has already -#! folded `default_threshold` into the max for every unpolicied procedure, so `policy_threshold` -#! alone reflects the correct requirement and is returned as-is. +#! Computes the effective transaction threshold by combining the procedure-policy max and the +#! spending-policy tier and applying `default_threshold` as a fallback. #! -#! Inputs: [default_threshold, policy_threshold] +#! Inputs: [default_threshold, policy_threshold, spending_threshold] #! Outputs: [transaction_threshold] #! #! Where: -#! - policy_threshold is the max contribution across all called non-auth procedures (each -#! contributes either its policy-selected threshold or `default_threshold` when unpolicied); -#! it is 0 only when no non-auth procedure was called. #! - default_threshold is the account's configured default multisig threshold. +#! - policy_threshold is the procedure-policy contribution. +#! - spending_threshold is the amount-derived spending-policy contribution. #! - transaction_threshold is the effective minimum number of signatures required. #! #! Invocation: exec -proc compute_tx_threshold(default_threshold: u32, policy_threshold: u32) -> u32 +proc compute_tx_threshold( + default_threshold: u32, + policy_threshold: u32, + spending_threshold: u32, +) -> u32 + movdn.2 u32max + # => [policy_or_spending_threshold, default_threshold] + + swap + # => [default_threshold, policy_or_spending_threshold] + dup.1 eq.0 - # => [is_policy_zero, default_threshold, policy_threshold] + # => [is_combined_zero, default_threshold, policy_or_spending_threshold] cdrop # => [effective_transaction_threshold] @@ -408,40 +416,35 @@ end #! Enforces the procedure-policy for the current transaction: #! - asserts each called procedure supports the active execution mode, #! - asserts the union of note restrictions against the transaction's input/output notes, -#! - returns the effective threshold required by the called procedure policies. +#! - returns the effective threshold required by the called procedure policies and +#! whether any policy required the delayed-execution mode. #! -#! Always uses IMMEDIATE_EXECUTION_MODE; procedures whose policies require the delayed mode -#! panic via [`compute_called_proc_policy`] because this component has no timelock. +#! The active execution mode is read from [`delayed_execution::is_execute_path`]: when +#! the timelock controller's `PENDING_EXECUTE` slot is set this transaction is on the +#! delayed-execute path (mode = 1); otherwise it is on the immediate path (mode = 0). #! #! Inputs: [default_threshold] -#! Outputs: [policy_threshold] +#! Outputs: [policy_threshold, policy_requires_delay] #! #! Where: #! - default_threshold is forwarded to [`compute_called_proc_policy`] as the per-procedure #! contribution for any called procedure without an explicit policy. +#! - policy_requires_delay is 1 when any called procedure's policy explicitly requires the +#! delayed execution mode (used downstream to enforce [`ERR_EXECUTE_PATH_MISMATCH`]). #! #! Invocation: exec -#! -#! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature lands, the -#! hardcoded IMMEDIATE_EXECUTION_MODE push will be replaced with a call to -#! `timelock_controller::execution_mode`, and the procedure will also expose -#! `policy_requires_delay` for downstream enforcement. proc enforce_procedure_policy(default_threshold: u32) - push.IMMEDIATE_EXECUTION_MODE + exec.delayed_execution::is_execute_path # => [execution_mode, default_threshold] exec.compute_called_proc_policy # => [policy_threshold, policy_requires_delay, note_restrictions] - # policy_requires_delay is always 0 on IMMEDIATE_EXECUTION_MODE; drop it. - swap drop - # => [policy_threshold, note_restrictions] - - swap - # => [note_restrictions, policy_threshold] + movup.2 + # => [note_restrictions, policy_threshold, policy_requires_delay] exec.enforce_note_restrictions - # => [policy_threshold] + # => [policy_threshold, policy_requires_delay] end #! Asserts that all configured smart per-procedure policies are valid for num_approvers. @@ -747,25 +750,41 @@ end #! Locals: #! 0: policy_threshold #! 1: default_threshold +#! 2: policy_requires_delay +#! 3: spending_threshold +#! 4: spending_requires_delay +#! 5: num_verified_signatures +#! +#! Flow: +#! 1. Compute spending policy (amount/tier-derived threshold + requires_delay flag). +#! 2. Build the tx summary commitment used for signing and timelock proposals. +#! 3. Enforce per-procedure policy (note restrictions + policy threshold + delay flag). +#! 4. Verify approver signatures. +#! 5. Combine the policy threshold with the spending threshold via `u32max`, fall back to +#! `default_threshold` when both are zero, and assert `num_verified_signatures` meets it. +#! 6. After signature verification, enforce that the active execute-path matches whether any +#! consumed policy (procedure or spending) required the delayed mode. Running this check +#! only after verification lets a caller still produce the TX_SUMMARY_COMMITMENT needed for +#! a propose/execute round-trip from an unauthorized dry-run. +#! 7. Finalize any pending timelock propose/cancel/execute slots against the verified sig count +#! and the union of `policy_requires_delay`/`spending_requires_delay`. #! #! Invocation: call -#! -#! NOTE: This procedure is a temporary form covering signer verification and -#! per-procedure policy enforcement. The following sections will be added: -#! - Spending Limits + Amount-Based Thresholds: a prologue that calls -#! `exec.spending_limits::compute_spending_policy` to derive a spending-derived threshold and -#! `spending_requires_delay` flag, and combines it via `u32max` with `policy_threshold` before -#! the final `compute_tx_threshold` fallback. -#! - TimelockedAccount: after signature verification, assert the execute-path vs. -#! `policy_requires_delay`/`spending_requires_delay` consistency (restoring -#! `ERR_EXECUTE_PATH_MISMATCH`), then call -#! `exec.timelock_controller::finalize_timelock_proposals` to advance any pending -#! propose/cancel/execute state. -@locals(2) +@locals(6) pub proc auth_tx(salt: word) exec.native_account::incr_nonce drop # => [SALT] + # ------ Computing spending policy ------ + exec.spending_limits::compute_spending_policy + # => [spending_threshold, spending_requires_delay, SALT] + + loc_store.3 + # => [spending_requires_delay, SALT] + + loc_store.4 + # => [SALT] + # ------ Computing transaction summary ------ exec.auth::create_tx_summary # => [ACCOUNT_DELTA_COMMITMENT, INPUT_NOTES_COMMITMENT, OUTPUT_NOTES_COMMITMENT, SALT] @@ -787,9 +806,12 @@ pub proc auth_tx(salt: word) # ------ Enforcing procedure policy (consumes default_threshold) ------ exec.enforce_procedure_policy - # => [policy_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + # => [policy_threshold, policy_requires_delay, num_of_approvers, TX_SUMMARY_COMMITMENT] loc_store.0 + # => [policy_requires_delay, num_of_approvers, TX_SUMMARY_COMMITMENT] + + loc_store.2 # => [num_of_approvers, TX_SUMMARY_COMMITMENT] # ------ Verifying approver signatures ------ @@ -798,12 +820,16 @@ pub proc auth_tx(salt: word) exec.::miden::standards::auth::signature::verify_signatures # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + dup loc_store.5 + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + # ------ Computing final transaction threshold ------ - # If no non-auth procedure was called, `policy_threshold` is 0 and `compute_tx_threshold` - # falls back to `default_threshold`; otherwise it returns the policy max directly. + # `compute_tx_threshold` combines the procedure-policy and spending-policy contributions and + # falls back to `default_threshold` when both are zero. + loc_load.3 loc_load.0 loc_load.1 - # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + # => [default_threshold, policy_threshold, spending_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] exec.compute_tx_threshold # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] @@ -815,4 +841,35 @@ pub proc auth_tx(salt: word) emit.AUTH_UNAUTHORIZED_EVENT push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES end + + # ------ Enforcing execute-path consistency ------ + # If a *procedure* policy demands the delayed execution mode, the transaction must already be + # on the execute path (proposed earlier, now being executed). Spending limits use their own + # `requires_delay` flag to decide whether to advance timelock proposals (below) but do not + # by themselves veto an immediate-mode transaction — that distinction belongs to the policy + # layer and is consistent with the pre-foundation behavior we are preserving here. + loc_load.2 + # => [policy_requires_delay, TX_SUMMARY_COMMITMENT] + + exec.delayed_execution::is_execute_path + # => [is_execute_path, policy_requires_delay, TX_SUMMARY_COMMITMENT] + + # is_valid = NOT(policy_requires_delay) OR is_execute_path + swap not or + # => [is_valid_execute_path, TX_SUMMARY_COMMITMENT] + + assert.err=ERR_EXECUTE_PATH_MISMATCH + # => [TX_SUMMARY_COMMITMENT] + + # ------ Finalizing timelock proposals ------ + # The timelock controller consumes the union of policy- and spending-derived delay flags so + # that high-spending transactions still advance any pending propose/cancel/execute slots. + loc_load.2 loc_load.4 or + # => [requires_delay, TX_SUMMARY_COMMITMENT] + + loc_load.5 + # => [num_verified_signatures, requires_delay, TX_SUMMARY_COMMITMENT] + + exec.delayed_execution::finalize_timelock_proposals + # => [TX_SUMMARY_COMMITMENT] end diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/oracle.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/oracle.masm new file mode 100644 index 0000000000..8794a1c938 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/oracle.masm @@ -0,0 +1,257 @@ +use miden::core::sys +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::tx + +# CONSTANTS +# ================================================================================================= + +# [oracle_id_prefix, oracle_id_suffix, 0, 0] +const ORACLE_CONFIG_SLOT = word("miden::standards::auth::multisig_smart::oracle_config") + +# Procedure root for the configured oracle price reader. +# [proc_root] +const GET_PRICE_PROC_ROOT_SLOT = word("miden::standards::auth::multisig_smart::get_price_proc_root") + +# [policy_mode, 0, 0, 0] — see `update_get_price_untracked_policy` and `apply_get_price_untracked_policy`. +const GET_PRICE_UNTRACKED_POLICY_SLOT = word("miden::standards::auth::multisig_smart::get_price_untracked_policy") + +# ERRORS +# ================================================================================================= + +const ERR_GET_PRICE_UNTRACKED_POLICY_INVALID = "get_price untracked policy must be 0 or 1" +const ERR_GET_PRICE_UNTRACKED_REJECTED = "oracle returned is_tracked=0 under reject-untracked policy" + +# High-impact oracle configuration procedures +# ================================================================================================= +# +# The following public procedures persist oracle-related storage. Expose them only through +# governance with appropriate multisig. +# +# - update_oracle_config_and_proc_root +# - update_get_price_untracked_policy +# +# ================================================================================================= + +#! Updates the oracle account id and the foreign procedure root used by `get_price`. +#! +#! Persists: +#! - `ORACLE_CONFIG_SLOT` as `[oracle_id_prefix, oracle_id_suffix, 0, 0]` (logical storage word). +#! - `GET_PRICE_PROC_ROOT_SLOT` as the 4-felt procedure root word for the foreign price reader. +#! +#! Implementation keeps the procedure root under the first `set_item` frame so no `@locals` +#! scratch is required: `native_account::set_item` consumes only its six top stack items. +#! +#! Inputs: [oracle_id_prefix, oracle_id_suffix, GET_PRICE_PROC_ROOT] +#! Outputs: [] +#! +#! Where: +#! - oracle_id_prefix / oracle_id_suffix are the two `u32` halves of the oracle `AccountId`. +#! - GET_PRICE_PROC_ROOT is the MAST root word (top of stack first: `w3`, then `w2`, `w1`, `w0`). +#! +#! Side effects: +#! - Writes ORACLE_CONFIG_SLOT. +#! - Writes GET_PRICE_PROC_ROOT_SLOT. +#! +#! Panics if: +#! - `native_account::set_item` rejects the write (per kernel / account rules). +#! +#! Invocation: exec +pub proc update_oracle_config_and_proc_root(get_price_proc_root: word) + # Inputs (top-first): [oracle_id_prefix, oracle_id_suffix, w3, w2, w1, w0] + + movdn.5 + # => [oracle_id_suffix, w3, w2, w1, w0, oracle_id_prefix] + + movdn.4 push.0 push.0 movup.6 movup.7 + # => [oracle_id_prefix, oracle_id_suffix, 0, 0, w3, w2, w1, w0] + + swap movup.2 movup.3 + # => [0, 0, oracle_id_suffix, oracle_id_prefix, w3, w2, w1, w0] + + push.ORACLE_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix, 0, 0, oracle_id_suffix, oracle_id_prefix, w3, w2, w1, w0] + + exec.native_account::set_item + # => [OLD_WORD, w3, w2, w1, w0] + + dropw push.GET_PRICE_PROC_ROOT_SLOT[0..2] + # => [slot_prefix, slot_suffix, w3, w2, w1, w0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Sets how `apply_get_price_untracked_policy` treats fungible outputs when `get_price` returns +#! `is_tracked=0`. +#! +#! Stored word layout: `[policy_mode, 0, 0, 0]` in `GET_PRICE_UNTRACKED_POLICY_SLOT`. +#! +#! `policy_mode`: +#! - **0 — omit:** drop `amount_in_usd` and do not add to the USD running sum (default, fail-open). +#! - **1 — reject:** panic if any such output is encountered (fail-closed). +#! +#! Inputs: [policy_mode] +#! Outputs: [] +#! +#! Side effects: +#! - Writes GET_PRICE_UNTRACKED_POLICY_SLOT. +#! +#! Panics if: +#! - `policy_mode` is not a valid `u32` (`u32assert`), or is not `0` or `1` +#! (ERR_GET_PRICE_UNTRACKED_POLICY_INVALID). +#! +#! Invocation: exec +pub proc update_get_price_untracked_policy + u32assert.err=ERR_GET_PRICE_UNTRACKED_POLICY_INVALID + dup eq.0 + # => [is_zero, policy_mode] + + if.true + # policy_mode == 0; `if.true` consumes the condition, leaving `policy_mode`. + else + dup eq.1 + assert.err=ERR_GET_PRICE_UNTRACKED_POLICY_INVALID + drop + end + # => [policy_mode] + + push.0 push.0 movup.3 + # => [policy_mode, 0, 0, 0] + + push.GET_PRICE_UNTRACKED_POLICY_SLOT[0..2] + # => [slot_prefix, slot_suffix, policy_mode, 0, 0, 0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Applies `GET_PRICE_UNTRACKED_POLICY_SLOT` after `get_price` returned `is_tracked=0`. +#! +#! Inputs: [amount_in_usd, remaining, note_index_keep] +#! Outputs: [remaining, note_index_keep] +#! +#! Where: +#! - `amount_in_usd` is discarded (never added to the spending sum in this path). +#! - Mode **0:** succeed with outputs above. +#! - Mode **1:** panic with ERR_GET_PRICE_UNTRACKED_REJECTED. +#! +#! Invocation: exec +pub proc apply_get_price_untracked_policy + push.GET_PRICE_UNTRACKED_POLICY_SLOT[0..2] + exec.active_account::get_initial_item + # => [m0, m1, m2, m3, amount_in_usd, remaining, note_index_keep] + + drop drop drop + # => [policy_mode, amount_in_usd, remaining, note_index_keep] + + dup eq.0 + # => [is_omit_mode, policy_mode, amount_in_usd, remaining, note_index_keep] + + if.true + drop drop + # => [remaining, note_index_keep] + else + drop drop + # => [remaining, note_index_keep] + + push.0 assert.err=ERR_GET_PRICE_UNTRACKED_REJECTED + end +end + +#! Reads the oracle account id from `ORACLE_CONFIG_SLOT` and reorders it for foreign calls. +#! +#! Storage layout is `[oracle_id_prefix, oracle_id_suffix, 0, 0]` (see +#! `update_oracle_config_and_proc_root`). `tx::execute_foreign_procedure` expects the id as +#! `[oracle_id_suffix, oracle_id_prefix]` (suffix on top of stack first, then prefix). +#! +#! Inputs: [] +#! Outputs: [oracle_id_suffix, oracle_id_prefix] +#! +#! Where: +#! - oracle_id_suffix is the suffix half of the configured oracle `AccountId`. +#! - oracle_id_prefix is the prefix half of the configured oracle `AccountId`. +#! +#! Invocation: exec +#! +#! Usage: combine with `get_oracle_price_proc_root` and `tx::execute_foreign_procedure`, as in +#! `get_price`: load the procedure root first if you need it under the id for FPI, then call +#! this procedure, then execute the foreign reader. +proc get_oracle_id + push.ORACLE_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [oracle_id_prefix, oracle_id_suffix, 0, 0] + + movdn.3 movdn.3 drop drop swap + # => [oracle_id_suffix, oracle_id_prefix] +end + +#! Reads the configured foreign price-read procedure root from `GET_PRICE_PROC_ROOT_SLOT`. +#! +#! The stored word is the MAST root of the oracle account procedure invoked by `get_price`. +#! +#! Inputs: [] +#! Outputs: [GET_PRICE_PROC_ROOT] +#! +#! Where: +#! - GET_PRICE_PROC_ROOT is a 4-element word (procedure root) passed to +#! `tx::execute_foreign_procedure`. +#! +#! Invocation: exec +#! +#! Usage: typically loaded before `get_oracle_id` so the stack matches `get_price` / FPI +#! expectations: `[oracle_id_suffix, oracle_id_prefix, GET_PRICE_PROC_ROOT, ...args]`. +proc get_oracle_price_proc_root + push.GET_PRICE_PROC_ROOT_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_item + # => [get_price_proc_root_w3, get_price_proc_root_w2, get_price_proc_root_w1, get_price_proc_root_w0] +end + +#! Calls the configured foreign oracle account procedure that implements the price reader. +#! +#! The foreign procedure must match this stack interface: +#! Inputs: [faucet_id_suffix, faucet_id_prefix, amount] +#! Outputs: [is_tracked, amount_in_usd] +#! +#! The kernel returns a padded foreign frame; this procedure truncates back to two felts for +#! callers such as `compute_spending_amount` in `spending_limits.masm`. +#! +#! Inputs: [faucet_id_suffix, faucet_id_prefix, amount] +#! Outputs: [is_tracked, amount_in_usd] +#! +#! Where: +#! - faucet_id_suffix / faucet_id_prefix identify the fungible faucet whose USD price is read. +#! - amount is the fungible amount (from the output note asset) passed into the oracle reader. +#! - is_tracked is 1 if the oracle attributed USD to this asset, 0 otherwise. +#! - amount_in_usd is the USD value when `is_tracked` is 1 (otherwise ignored for summation). +#! +#! When `is_tracked` is 0, `compute_spending_amount` calls `apply_get_price_untracked_policy`, which +#! reads `GET_PRICE_UNTRACKED_POLICY_SLOT`: mode **0** omits USD; mode **1** rejects the auth path. +#! +#! Panics if: +#! - the foreign procedure call fails (per transaction kernel / FPI rules). +#! +#! Invocation: exec +pub proc get_price + exec.get_oracle_price_proc_root + # => [GET_PRICE_PROC_ROOT, faucet_id_suffix, faucet_id_prefix, amount] + + exec.get_oracle_id + # => [oracle_id_suffix, oracle_id_prefix, GET_PRICE_PROC_ROOT, faucet_id_suffix, faucet_id_prefix, amount] + + exec.tx::execute_foreign_procedure + # => [is_tracked, amount_in_usd, foreign_pad(14)] + + exec.sys::truncate_stack + # => [is_tracked, amount_in_usd] +end diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/spending_limits.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/spending_limits.masm new file mode 100644 index 0000000000..ae84aa3e19 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/spending_limits.masm @@ -0,0 +1,768 @@ +use miden::protocol::active_account +use miden::protocol::asset +use miden::protocol::asset::COMPOSITION_FUNGIBLE +use miden::protocol::native_account +use miden::protocol::output_note +use miden::core::word +use miden::protocol::tx +use miden::standards::auth::multisig +use miden::standards::auth::multisig_smart::oracle + +# CONSTANTS +# ================================================================================================= + +# [spending_window, 0, 0, 0] +const SPENDING_WINDOW_SLOT = word("miden::standards::auth::multisig_smart::spending_window") + +# [amount_limit0, amount_limit1, amount_limit2, delay_trigger_amount] +const AMOUNT_LIMITS_SLOT = word("miden::standards::auth::multisig_smart::amount_limits") + +# [amount_spent_in_window, window_start_timestamp, 0, 0] +const SPENDING_TRACKER_SLOT = word("miden::standards::auth::multisig_smart::spending_tracker") + +# [tier_0, tier_1, tier_2, tier_3] +const TIER_THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig_smart::tier_threshold_config") + +# tmp memory area for loading assets +# NOTE: must be multiple of 4 for mem_loadw_be +const TMP_ASSETS_PTR = 1024 + +# ERRORS +# ================================================================================================= + +const ERR_SPENDING_WINDOW_NOT_U32 = "spending_window must be a valid u32" +const ERR_SPENDING_WINDOW_ZERO = "spending_window must be non-zero" +const ERR_LAST_WINDOW_START_GT_CURRENT = "window_start_timestamp is greater than current_timestamp" +const ERR_INVALID_TIER_INDEX = "invalid tier index (must be 0..3)" +const ERR_TIER0_MUST_BE_POSITIVE = "tier_0 must be > 0" +const ERR_TIER3_TOO_HIGH = "tier_3 must be <= num_approvers" +const ERR_INVALID_TIER_CONFIG = "tier config is invalid" +const ERR_INVALID_AMOUNT_LIMITS = "invalid amount limits (must satisfy limit0 <= limit1 <= limit2)" + +# High-impact configuration procedures +# ================================================================================================= +# +# The following public procedures persist fields that define spending-window accounting, tier +# approval counts, and amount breakpoints. They are high-impact: expose them only through +# governance paths with appropriate multisig. +# +# - update_spending_window_policy +# - update_threshold_config +# - update_spending_limits +# +# ================================================================================================= + +#! Replaces the account spending-window duration (seconds per bucket) used by window checks. +#! +#! Updating the window clears `SPENDING_TRACKER_SLOT`, so accumulated spend for the policy +#! restarts. Callers should schedule this when resetting counters is acceptable. +#! +#! Inputs: [spending_window] +#! Outputs: [] +#! +#! Side effects: +#! - Writes SPENDING_WINDOW_SLOT. +#! - Resets SPENDING_TRACKER_SLOT to EMPTY_WORD. +#! +#! Panics if: +#! - `spending_window` is not a valid `u32` (ERR_SPENDING_WINDOW_NOT_U32). +#! - `spending_window` is zero (ERR_SPENDING_WINDOW_ZERO). +#! +#! Invocation: exec +pub proc update_spending_window_policy + u32assert.err=ERR_SPENDING_WINDOW_NOT_U32 + dup eq.0 assertz.err=ERR_SPENDING_WINDOW_ZERO + # => [spending_window] + + push.0.0.0 + movup.3 + # => [spending_window, 0, 0, 0] + + push.SPENDING_WINDOW_SLOT[0..2] + # => [slot_prefix, slot_suffix, spending_window, 0, 0, 0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] + + push.0.0.0.0 + # => [EMPTY_WORD] + + push.SPENDING_TRACKER_SLOT[0..2] + # => [slot_prefix, slot_suffix, EMPTY_WORD] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Replaces the word in `TIER_THRESHOLD_CONFIG_SLOT`: for tier index `i` in 0..3, +#! `tier_i` is the minimum number of approvals required when that tier applies. +#! +#! On-stack validation enforces `0 < tier_0`, monotonic `tier_0 <= tier_1 <= tier_2 <= tier_3`, +#! and `tier_3 < num_approvers` from the multisig configuration. +#! +#! Inputs: [tier_0, tier_1, tier_2, tier_3] +#! Outputs: [] +#! +#! Side effects: +#! - Writes TIER_THRESHOLD_CONFIG_SLOT. +#! +#! Panics if: +#! - any tier input is not a valid `u32` where asserted. +#! - `tier_0` is zero (ERR_TIER0_MUST_BE_POSITIVE). +#! - tier ordering is non-monotonic (ERR_INVALID_TIER_CONFIG). +#! - `tier_3` is not strictly below `num_approvers` (ERR_TIER3_TOO_HIGH). +#! +#! Invocation: exec +pub proc update_threshold_config + u32assert2 movup.3 movup.3 + # => [tier_2, tier_3, tier_0, tier_1] + + u32assert2 movdn.3 movdn.3 + # => [tier_0, tier_1, tier_2, tier_3] + + # tier_0 must be > 0 + + dup eq.0 assertz.err=ERR_TIER0_MUST_BE_POSITIVE + # => [tier_0, tier_1, tier_2, tier_3] + + # Monotonic tier_0 <= tier_1 <= tier_2 <= tier_3 + + dup dup.2 u32gt assertz.err=ERR_INVALID_TIER_CONFIG + dup.1 dup.3 u32gt assertz.err=ERR_INVALID_TIER_CONFIG + dup.2 dup.4 u32gt assertz.err=ERR_INVALID_TIER_CONFIG + # => [tier_0, tier_1, tier_2, tier_3] + + # Tier 3 is less than num_approvers + + exec.multisig::get_threshold_and_num_approvers + # => [default_threshold, num_approvers, tier_0, tier_1, tier_2, tier_3] + + drop + # => [num_approvers, tier_0, tier_1, tier_2, tier_3] + + dup.4 u32gte assert.err=ERR_TIER3_TOO_HIGH + # => [tier_0, tier_1, tier_2, tier_3] + + swap movup.2 movup.3 + # => [tier_3, tier_2, tier_1, tier_0] + + push.TIER_THRESHOLD_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix, tier_3, tier_2, tier_1, tier_0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Replaces the word in `AMOUNT_LIMITS_SLOT`: strictly increasing limits +#! `amount_limit0 < amount_limit1 < amount_limit2` define tier bands for cumulative window spend, +#! and `delay_trigger_amount` is the threshold above which `get_num_required_signatures` sets +#! `requires_delay` (strictly greater than this amount). +#! +#! Inputs: [amount_limit0, amount_limit1, amount_limit2, delay_trigger_amount] +#! Outputs: [] +#! +#! Side effects: +#! - Writes AMOUNT_LIMITS_SLOT. +#! +#! Panics if: +#! - any limit input is not a valid `u32` where asserted. +#! - `amount_limit0`, `amount_limit1`, `amount_limit2` are not strictly increasing +#! (ERR_INVALID_AMOUNT_LIMITS). +#! +#! Invocation: exec +pub proc update_spending_limits + u32assert2 movup.3 movup.3 + # => [amount_limit2, delay_trigger_amount, amount_limit0, amount_limit1] + + u32assert2 movdn.3 movdn.3 + # => [amount_limit0, amount_limit1, amount_limit2, delay_trigger_amount] + + # Monotonic amount_limit0 < amount_limit1 < amount_limit2 + + dup dup.2 u32gte assertz.err=ERR_INVALID_AMOUNT_LIMITS + dup.1 dup.3 u32gte assertz.err=ERR_INVALID_AMOUNT_LIMITS + # => [amount_limit0, amount_limit1, amount_limit2, delay_trigger_amount] + + swap movup.2 movup.3 + # => [delay_trigger_amount, amount_limit2, amount_limit1, amount_limit0] + + push.AMOUNT_LIMITS_SLOT[0..2] + # => [slot_prefix, slot_suffix, delay_trigger_amount, amount_limit2, amount_limit1, amount_limit0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Returns the spending window duration stored in `SPENDING_WINDOW_SLOT`. +#! +#! Inputs: [] +#! Outputs: [spending_window] +#! +#! Where: +#! - spending_window is the duration of a single spending window, in seconds. +#! For example, 3600 represents an hourly window, 86400 a daily window. +#! +#! Invocation: exec +pub proc get_spending_window + push.SPENDING_WINDOW_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_initial_item + # => [spending_window, 0, 0, 0] + + movdn.3 drop drop drop + # => [spending_window] +end + +#! Computes the start timestamp of the currently active spending window. +#! +#! Uses integer window bucketing: +#! `current_window_start_timestamp = floor(current_timestamp / spending_window) * spending_window`. +#! +#! Inputs: [] +#! Outputs: [current_window_start_timestamp] +#! +#! Where: +#! - current_timestamp is the current block timestamp. +#! - spending_window is the configured window duration from `SPENDING_WINDOW_SLOT`. +#! - current_window_start_timestamp is the start boundary of the window containing +#! `current_timestamp`. +#! +#! Panics if: +#! - `tx::get_block_timestamp` does not return a valid `u32` timestamp. +#! - `spending_window` is zero (division by zero in `u32div`). +#! - `spending_window` is not a valid `u32` value for `u32div`. +#! +#! Invocation: exec +proc get_current_window_start_timestamp + exec.tx::get_block_timestamp + # => [current_timestamp] + + dup + # => [current_timestamp, current_timestamp] + + exec.get_spending_window + # => [spending_window, current_timestamp, current_timestamp] + + # floor(current_timestamp / spending_window) -> current window index + u32div + # => [current_window_index, current_timestamp] + + # u32div consumes spending_window, so reload it for index * spending_window + exec.get_spending_window + # => [spending_window, current_window_index, current_timestamp] + + # map index back to concrete window boundary timestamp + mul + # => [current_window_start_timestamp, current_timestamp] + + swap + # => [current_timestamp, current_window_start_timestamp] + + drop + # => [current_window_start_timestamp] +end + +#! Loads the spending tracker word from `SPENDING_TRACKER_SLOT` (account initial state). +#! +#! Inputs: [] +#! Outputs: [amount_spent_in_window, window_start_timestamp, 0, 0] +#! +#! Where: +#! - amount_spent_in_window is the cumulative USD value recorded for the stored window. +#! - window_start_timestamp is the window boundary associated with that total (or 0 if unset). +#! - The trailing two felts are reserved and read as zero for the stored layout. +#! +#! Invocation: exec +proc get_spending_tracker + push.SPENDING_TRACKER_SLOT[0..2] + # => [slot_prefix, slot_suffix] + + exec.active_account::get_initial_item + # => [amount_spent_in_window, window_start_timestamp, 0, 0] +end + +#! Reads the amount spent in the last recorded spending window from `SPENDING_TRACKER_SLOT`. +#! +#! Inputs: [] +#! Outputs: [amount_spent_in_window] +#! +#! Where: +#! - amount_spent_in_window is the total USD value spent during the last recorded spending window. +#! +#! Invocation: exec +proc get_amount_spent_in_window + exec.get_spending_tracker + # => [amount_spent_in_window, window_start_timestamp, 0, 0] + + movdn.3 drop drop drop + # => [amount_spent_in_window] +end + +#! Reads the start timestamp of the last recorded spending window from `SPENDING_TRACKER_SLOT`. +#! +#! This is the window start at the time spending was last committed. Compare with +#! `get_current_window_start_timestamp` to determine whether the tracker is still active. +#! +#! Inputs: [] +#! Outputs: [window_start_timestamp] +#! +#! Where: +#! - window_start_timestamp is the start of the window in which spending was last recorded, +#! or 0 if no spending has been tracked yet. +#! +#! Invocation: exec +proc get_window_start_timestamp + exec.get_spending_tracker + # => [amount_spent_in_window, window_start_timestamp, 0, 0] + + drop movdn.2 drop drop + # => [window_start_timestamp] +end + +#! Returns whether we are still within the active spending window. +#! +#! Semantics: +#! - empty tracker => returns 0 +#! - if current_window_start_timestamp == window_start_timestamp => returns 1 +#! - if current_window_start_timestamp > window_start_timestamp => returns 0 +#! - if current_window_start_timestamp < window_start_timestamp => error +#! +#! Inputs: [] +#! Outputs: [within_spending_window] +#! +#! Where: +#! - within_spending_window is 1 if the tracker targets the active window, 0 if the tracker +#! is empty or belongs to a past window. +#! +#! Panics if: +#! - the tracker is non-empty and its window start is after the current window start +#! (ERR_LAST_WINDOW_START_GT_CURRENT). +#! +#! Invocation: exec +proc within_spending_window + exec.get_spending_tracker + # => [amount_spent_in_window, window_start_timestamp, 0, 0] + + exec.word::eqz + # => [is_empty_tracker] + + if.true + push.0 + # => [within_spending_window=0] + else + exec.get_window_start_timestamp + # => [window_start_timestamp] + + exec.get_current_window_start_timestamp + # => [current_window_start_timestamp, window_start_timestamp] + + dup.1 dup.1 + # => [current_window_start_timestamp, window_start_timestamp, current_window_start_timestamp, window_start_timestamp] + + eq + # => [same_window, current_window_start_timestamp, window_start_timestamp] + + if.true + drop drop + # => [] + + push.1 + # => [within_spending_window=1] + else + u32lt + # => [window_start_is_before_current] + + if.true + push.0 + # => [within_spending_window=0] + else + push.0 assert.err=ERR_LAST_WINDOW_START_GT_CURRENT + end + end + end +end + +#! Computes the total spending amount for all output notes (USD terms). +#! +#! Behavior: +#! - iterates over output notes: tx::get_num_output_notes +#! - for each note: output_note::get_assets(dest_ptr, note_index) writes assets into memory +#! - iterates loaded assets as [ASSET_KEY, ASSET_VALUE] +#! - if the asset is from a fungible faucet, extracts [faucet_id_suffix, faucet_id_prefix, amount] +#! - calls a get_price-style reader (`oracle::get_price`) and folds USD into a running sum +#! +#! `is_tracked` (oracle / foreign reader output): +#! - When `is_tracked` is non-zero, `amount_in_usd` is added to the running total. +#! - When `is_tracked` is zero, `oracle::apply_get_price_untracked_policy` runs (see `oracle.masm`): +#! mode **0** omits USD; mode **1** rejects the transaction. +#! +#! Inputs: [] +#! Outputs: [amount_in_usd] +#! +#! Assumes get_price-style interface: +#! Inputs: [faucet_id_suffix, faucet_id_prefix, amount] +#! Outputs: [is_tracked, amount_in_usd] +#! +#! Locals: +#! +#! 0: running_sum +#! 1: dest_ptr +#! 4..7: current ASSET_VALUE +#! 8..11: current ASSET_KEY +#! +#! Invocation: exec +@locals(12) +proc compute_spending_amount + push.0 loc_store.0 + # => [] + + exec.tx::get_num_output_notes + # => [num_notes] + + dup neq.0 + # => [should_continue, num_notes] + + while.true + # => [num_notes] + + sub.1 + # => [note_index] + + dup + # => [note_index_arg, note_index_keep] + + # Save the destination pointer in local 1; the protocol's `output_note::get_assets` + # only returns `[num_assets]`, so we cannot recover the pointer from its outputs. + push.TMP_ASSETS_PTR dup loc_store.1 + # => [dest_ptr, note_index_arg, note_index_keep] + + exec.output_note::get_assets + # => [num_assets, note_index_keep] + + dup neq.0 + # => [should_continue, num_assets, note_index_keep] + + while.true + # => [num_assets, note_index_keep] + + sub.1 + # => [remaining, note_index_keep] + + loc_load.1 + # => [ptr, remaining, note_index_keep] + + padw movup.4 mem_loadw_le + # => [ASSET_KEY, remaining, note_index_keep] + + loc_load.1 add.4 + # => [ptr+4, ASSET_KEY, remaining, note_index_keep] + + padw movup.4 mem_loadw_le + # => [ASSET_VALUE, ASSET_KEY, remaining, note_index_keep] + + loc_load.1 add.8 loc_store.1 + # => [ASSET_VALUE, ASSET_KEY, remaining, note_index_keep] + + # Duplicate ASSET_KEY (positions 4..=7), extract its composition, then drop the + # duplicate copy so we keep the original ASSET_KEY in place for downstream code. + dupw.1 exec.asset::key_to_composition movdn.4 dropw + # => [composition, ASSET_VALUE, ASSET_KEY, remaining, note_index_keep] + + eq.COMPOSITION_FUNGIBLE + # => [is_fungible, ASSET_VALUE, ASSET_KEY, remaining, note_index_keep] + + if.true + # Store the loaded asset words in locals so we can prepare the get_price-style + # inputs without disturbing the outer loop state. + loc_storew_le.4 dropw + # => [ASSET_KEY, remaining, note_index_keep] + + loc_storew_le.8 dropw + # => [remaining, note_index_keep] + + padw loc_loadw_le.8 + # => [ASSET_KEY, remaining, note_index_keep] + + exec.asset::key_into_faucet_id + # => [faucet_id_suffix, faucet_id_prefix, remaining, note_index_keep] + + padw loc_loadw_le.4 + # => [ASSET_VALUE, faucet_id_suffix, faucet_id_prefix, remaining, note_index_keep] + + exec.asset::fungible_value_into_amount + # => [amount, faucet_id_suffix, faucet_id_prefix, remaining, note_index_keep] + + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, amount, remaining, note_index_keep] + + exec.oracle::get_price + # => [is_tracked, amount_in_usd, remaining, note_index_keep] + + if.true + loc_load.0 add loc_store.0 + # => [remaining, note_index_keep] + else + exec.oracle::apply_get_price_untracked_policy + # => [remaining, note_index_keep] + end + else + dropw dropw + # => [remaining, note_index_keep] + end + # => [remaining, note_index_keep] + + dup neq.0 + # => [should_continue, remaining, note_index_keep] + + end + + drop + # => [note_index_keep] + + dup neq.0 + # => [should_continue, note_index_keep] + + end + + drop + # => [] + + loc_load.0 + # => [amount_in_usd] +end + +#! Maps a computed tier index (0..3) to the required approvals threshold. +#! +#! Inputs: [tier_index] +#! Outputs: [tier_threshold] +#! +#! Where: +#! - tier_index is the spending tier computed from the transaction amount (0..3). +#! - tier_threshold is the minimum number of approvals required for that tier. +#! +#! Panics if: +#! - tier_index is outside 0..3 (ERR_INVALID_TIER_INDEX). +#! +#! Invocation: exec +proc get_tier_threshold_for_index + push.TIER_THRESHOLD_CONFIG_SLOT[0..2] + # => [slot_prefix, slot_suffix, tier_index] + + exec.active_account::get_initial_item + # => [tier_0, tier_1, tier_2, tier_3, tier_index] + + movup.4 + # => [tier_index, tier_0, tier_1, tier_2, tier_3] + + dup eq.0 + # => [is_tier_0, tier_index, tier_0, tier_1, tier_2, tier_3] + + if.true + drop + # => [tier_0, tier_1, tier_2, tier_3] + + movdn.3 drop drop drop + # => [tier_0] + else + dup eq.1 + # => [is_tier_1, tier_index, tier_0, tier_1, tier_2, tier_3] + + if.true + drop + # => [tier_0, tier_1, tier_2, tier_3] + + drop movdn.2 drop drop + # => [tier_1] + else + dup eq.2 + # => [is_tier_2, tier_index, tier_0, tier_1, tier_2, tier_3] + + if.true + drop + # => [tier_0, tier_1, tier_2, tier_3] + + drop drop swap drop + # => [tier_2] + else + eq.3 assert.err=ERR_INVALID_TIER_INDEX + # => [tier_0, tier_1, tier_2, tier_3] + + drop drop drop + # => [tier_3] + end + end + end + # => [tier_threshold] +end + + +#! Writes the spending tracker to `SPENDING_TRACKER_SLOT`. +#! +#! Uses the current block timestamp to derive the active window start via +#! `get_current_window_start_timestamp`. The caller is responsible for passing +#! the correct `total_spent_amount`: if within an existing window, it should +#! include the previously accumulated amount; if a new window has started, +#! it should contain only the current transaction's amount. +#! +#! Inputs: [total_spent_amount] +#! Outputs: [] +#! +#! Where: +#! - total_spent_amount is the cumulative USD value spent in the active window. +#! +#! Side effects: +#! - Writes SPENDING_TRACKER_SLOT with +#! [total_spent_amount, current_window_start_timestamp, 0, 0]. +#! +#! Invocation: exec +proc set_spending_tracker + exec.get_current_window_start_timestamp + # => [current_window_start_timestamp, total_spent_amount] + + push.0 push.0 movup.3 movup.3 + # => [current_window_start_timestamp, total_spent_amount, 0, 0] + + swap + # => [total_spent_amount, current_window_start_timestamp, 0, 0] + + push.SPENDING_TRACKER_SLOT[0..2] + # => [slot_prefix, slot_suffix, total_spent_amount, current_window_start_timestamp, 0, 0] + + exec.native_account::set_item + # => [OLD_TRACKER_WORD] + + dropw + # => [] +end + +#! Computes required approvals for the current transaction's spending amount. +#! +#! Inputs: [currently_spending_amount] +#! Outputs: [total_spent_amount_in_window, threshold_tier, requires_delay] +#! +#! Where: +#! - currently_spending_amount is the USD value of this transaction. +#! - total_spent_amount_in_window is the cumulative USD value used for tier/delay rules. If +#! `within_spending_window` is 1, it is prior window spend plus this transaction; if 0, it +#! is only `currently_spending_amount` (tracker empty or not in the active window). +#! - threshold_tier is the minimum number of approvals required for the resolved tier. +#! - requires_delay is 1 if a timelock delay is required, 0 otherwise. +#! +#! Invocation: exec +proc get_num_required_signatures + exec.within_spending_window + # => [is_within_spending_window, currently_spending_amount] + + if.true + exec.get_amount_spent_in_window + # => [amount_spent_in_window, currently_spending_amount] + + add + # => [total_amount] + end + # => [total_amount] + + push.AMOUNT_LIMITS_SLOT[0..2] + # => [slot_prefix, slot_suffix, total] + + exec.active_account::get_initial_item + # => [l0, l1, l2, delay_trigger_amount, total] + + movup.4 + # => [total, l0, l1, l2, delay_trigger_amount] + + # ------ requires_delay = total > delay_trigger_amount ------ + dup dup.5 u32gt + # => [requires_delay, total, l0, l1, l2, delay_trigger_amount] + + swap + # => [total, requires_delay, l0, l1, l2, delay_trigger_amount] + + # ------ tier_index from cumulative total vs limits ------ + dup dup.3 u32lt + # => [total_lt_l0, total, requires_delay, l0, l1, l2, delay_trigger_amount] + if.true + push.0 + # => [tier_index=0, total, requires_delay, l0, l1, l2, delay_trigger_amount] + else + dup dup.4 u32lt + # => [total_lt_l1, total, requires_delay, l0, l1, l2, delay_trigger_amount] + if.true + push.1 + # => [tier_index=1, total, requires_delay, l0, l1, l2, delay_trigger_amount] + else + dup dup.5 u32lt + # => [total_lt_l2, total, requires_delay, l0, l1, l2, delay_trigger_amount] + if.true + push.2 + # => [tier_index=2, total, requires_delay, l0, l1, l2, delay_trigger_amount] + else + push.3 + # => [tier_index=3, total, requires_delay, l0, l1, l2, delay_trigger_amount] + end + end + end + # => [tier_index, total, requires_delay, l0, l1, l2, delay_trigger_amount] + + exec.get_tier_threshold_for_index + # => [tier_threshold, total, requires_delay, l0, l1, l2, delay_trigger_amount] + + swap + # => [total, tier_threshold, requires_delay, l0, l1, l2, delay_trigger_amount] + + movup.3 drop movup.3 drop movup.3 drop movup.3 drop + # => [total, tier_threshold, requires_delay] +end + +#! Computes spending policy for the active transaction and optionally persists the tracker. +#! +#! Flow: `compute_spending_amount` sums priced fungible output value; if zero, returns no +#! approvals and no delay. Otherwise `get_num_required_signatures` applies window totals, +#! amount limits, and tier thresholds, then `set_spending_tracker` stores the cumulative window +#! total when spending is non-zero. +#! +#! Inputs: [] +#! Outputs: [threshold_tier, requires_delay] +#! +#! Where: +#! - threshold_tier is the required approval count for the resolved tier (0 when spend is 0). +#! - requires_delay is the delay flag from amount-limit rules (0 when spend is 0). +#! +#! Side effects: +#! - If spending_amount > 0: writes SPENDING_TRACKER_SLOT via `set_spending_tracker`. +#! +#! Invocation: exec +pub proc compute_spending_policy + exec.compute_spending_amount + # => [spending_amount] + + dup eq.0 + # => [is_zero_spend, spending_amount] + + if.true + drop + # => [] + + push.0 push.0 + # => [threshold_tier=0, requires_delay=0] + else + exec.get_num_required_signatures + # => [total_spent_amount_in_window, threshold_tier, requires_delay] + + exec.set_spending_tracker + # => [threshold_tier, requires_delay] + end +end diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index e6caf01607..ea1234acf1 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -11,7 +11,7 @@ mod multisig; pub use multisig::{AuthMultisig, AuthMultisigConfig}; pub mod multisig_smart; -pub use multisig_smart::{AuthMultisigSmart, AuthMultisigSmartConfig}; +pub use multisig_smart::{AuthMultisigSmart, AuthMultisigSmartConfig, AuthMultisigSmartPresets}; mod guarded_multisig; pub use guarded_multisig::{AuthGuardedMultisig, AuthGuardedMultisigConfig, GuardianConfig}; diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs index 1e1b9e8a79..9d0b72d572 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -1,10 +1,10 @@ use alloc::vec::Vec; -use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ AccountComponentCode, AccountComponentMetadata, + FeltSchema, SchemaType, StorageSchema, StorageSlotSchema, @@ -18,33 +18,103 @@ use miden_protocol::account::{ }; use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; -// Slots and schemas reused from `AuthMultisig` to keep the storage layout in sync. The statics -// are exposed as `pub(super)` in the sibling `multisig` module; we reference them directly so -// the sharing is visible at the use site rather than hidden behind delegating methods. -use super::super::multisig::{ - APPROVER_PUBKEYS_SLOT_NAME, - APPROVER_SCHEME_ID_SLOT_NAME, - EXECUTED_TRANSACTIONS_SLOT_NAME, - THRESHOLD_CONFIG_SLOT_NAME, -}; use super::ProcedurePolicy; +use super::config::{DelayedExecutionPolicy, OracleReaderConfig, SpendingPolicy}; +use super::types::{AmountLimits, OracleId, TierThresholds}; use crate::account::account_component_code; -use crate::account::auth::AuthMultisig; account_component_code!(MULTISIG_SMART_CODE, "auth/multisig_smart.masl"); // CONSTANTS // ================================================================================================ -// Only the smart-specific procedure_policies slot needs its own constant here. The other four -// slots (threshold config, approver public keys, approver scheme ids, executed transactions) are -// reused from `AuthMultisig` via the imports above. +static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::threshold_config") + .expect("storage slot name should be valid") +}); + +static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys") + .expect("storage slot name should be valid") +}); + +static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::approver_schemes") + .expect("storage slot name should be valid") +}); + +static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") + .expect("storage slot name should be valid") +}); + static PROCEDURE_POLICIES_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig_smart::procedure_policies") .expect("storage slot name should be valid") }); +static DELAYED_EXECUTION_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::delayed_execution") + .expect("storage slot name should be valid") +}); + +static SPENDING_WINDOW_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::spending_window") + .expect("storage slot name should be valid") +}); + +static AMOUNT_LIMITS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::amount_limits") + .expect("storage slot name should be valid") +}); + +static SPENDING_TRACKER_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::spending_tracker") + .expect("storage slot name should be valid") +}); + +static TIER_THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::tier_threshold_config") + .expect("storage slot name should be valid") +}); + +static ORACLE_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::oracle_config") + .expect("storage slot name should be valid") +}); + +static GET_PRICE_PROC_ROOT_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::get_price_proc_root") + .expect("storage slot name should be valid") +}); + +static GET_PRICE_UNTRACKED_POLICY_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::get_price_untracked_policy") + .expect("storage slot name should be valid") +}); + +static TX_PROPOSALS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::tx_proposals") + .expect("storage slot name should be valid") +}); + +static PENDING_PROPOSE_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::pending_propose") + .expect("storage slot name should be valid") +}); + +static PENDING_CANCEL_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::pending_cancel") + .expect("storage slot name should be valid") +}); + +static PENDING_EXECUTE_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::pending_execute") + .expect("storage slot name should be valid") +}); + // MULTISIG SMART AUTHENTICATION COMPONENT // ================================================================================================ @@ -54,6 +124,9 @@ pub struct AuthMultisigSmartConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, procedure_policies: Vec<(Word, ProcedurePolicy)>, + spending_policy: SpendingPolicy, + delayed_execution: DelayedExecutionPolicy, + oracle_reader: OracleReaderConfig, } impl AuthMultisigSmartConfig { @@ -83,6 +156,9 @@ impl AuthMultisigSmartConfig { approvers, default_threshold, procedure_policies: vec![], + spending_policy: SpendingPolicy::default(), + delayed_execution: DelayedExecutionPolicy::default(), + oracle_reader: OracleReaderConfig::default(), }) } @@ -96,6 +172,21 @@ impl AuthMultisigSmartConfig { Ok(self) } + pub fn with_spending(mut self, spending_policy: SpendingPolicy) -> Self { + self.spending_policy = spending_policy; + self + } + + pub fn with_delayed_execution(mut self, delayed_execution: DelayedExecutionPolicy) -> Self { + self.delayed_execution = delayed_execution; + self + } + + pub fn with_oracle_reader(mut self, oracle_reader: OracleReaderConfig) -> Self { + self.oracle_reader = oracle_reader; + self + } + pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { &self.approvers } @@ -107,23 +198,71 @@ impl AuthMultisigSmartConfig { pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { &self.procedure_policies } + + pub fn spending_policy(&self) -> SpendingPolicy { + self.spending_policy + } + + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { + self.delayed_execution + } + + pub fn oracle_reader(&self) -> OracleReaderConfig { + self.oracle_reader + } + + pub fn spending_window(&self) -> u32 { + self.spending_policy.spending_window() + } + + pub fn min_delay(&self) -> u32 { + self.delayed_execution.min_delay() + } + + pub fn propose_expiration_delta(&self) -> u16 { + self.delayed_execution.propose_expiration_delta() + } + + pub fn amount_limits(&self) -> AmountLimits { + self.spending_policy.amount_limits() + } + + pub fn tier_thresholds(&self) -> TierThresholds { + self.spending_policy.tier_thresholds() + } + + pub fn oracle_id(&self) -> Option { + self.oracle_reader.oracle_id() + } + + pub fn get_price_proc_root(&self) -> Word { + self.oracle_reader.get_price_proc_root() + } } -fn validate_proc_policies( +fn validate_tier_thresholds( num_approvers: u32, - proc_policies: &[(Word, ProcedurePolicy)], + tier_thresholds: TierThresholds, ) -> Result<(), AccountError> { - // Reject duplicate procedure roots. Catching it here turns the failure into a regular - // `AccountError` returned from `with_proc_policies` / `AuthMultisigSmart::new`. - let mut policy_roots = alloc::collections::BTreeSet::new(); - for (proc_root, _) in proc_policies { - if !policy_roots.insert(*proc_root) { - return Err(AccountError::other( - "duplicate procedure roots are not allowed in the procedure policy map", - )); - } + let [tier_0, tier_1, tier_2, tier_3] = tier_thresholds.as_array(); + + if tier_0 == 0 { + return Err(AccountError::other("tier_0 must be > 0")); + } + if tier_0 > tier_1 || tier_1 > tier_2 || tier_2 > tier_3 { + return Err(AccountError::other("tier config is invalid")); + } + if tier_3 > num_approvers { + return Err(AccountError::other("tier_3 must be <= num_approvers")); } + Ok(()) +} + +fn validate_proc_policies( + num_approvers: u32, + proc_policies: &[(Word, ProcedurePolicy)], +) -> Result<(), AccountError> { for (_, policy) in proc_policies { if let Some(immediate_threshold) = policy.immediate_threshold() && immediate_threshold > num_approvers @@ -154,6 +293,15 @@ impl AuthMultisigSmart { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::multisig_smart"; + pub const UPDATE_SIGNERS_AND_THRESHOLD_PROC_NAME: &'static str = "update_signers_and_threshold"; + pub const UPDATE_THRESHOLD_CONFIG_PROC_NAME: &'static str = "update_threshold_config"; + pub const UPDATE_SPENDING_LIMITS_PROC_NAME: &'static str = "update_spending_limits"; + pub const UPDATE_ORACLE_CONFIG_PROC_NAME: &'static str = "update_oracle_config_and_proc_root"; + pub const UPDATE_GET_PRICE_UNTRACKED_POLICY_PROC_NAME: &'static str = + "update_get_price_untracked_policy"; + pub const UPDATE_DELAYED_EXECUTION_POLICY_PROC_NAME: &'static str = + "update_delayed_execution_policy"; + /// Returns the [`AccountComponentCode`] of this component. pub fn code() -> &'static AccountComponentCode { &MULTISIG_SMART_CODE @@ -161,6 +309,33 @@ impl AuthMultisigSmart { /// Creates a new [`AuthMultisigSmart`] component from the provided configuration. pub fn new(config: AuthMultisigSmartConfig) -> Result { + if config + .spending_policy() + .amount_limits() + .as_array() + .iter() + .any(|v| *v > u32::MAX as u64) + { + return Err(AccountError::other("amount limits must fit into u32")); + } + if config.spending_policy().spending_window() == 0 { + return Err(AccountError::other("spending window must be non-zero")); + } + if config.delayed_execution().min_delay() == 0 { + return Err(AccountError::other("min delay must be non-zero")); + } + if config.delayed_execution().propose_expiration_delta() == 0 { + return Err(AccountError::other("propose expiration delta must be non-zero")); + } + if config.oracle_reader().untracked_price_policy() > 1 { + return Err(AccountError::other( + "oracle untracked price policy must be 0 (omit) or 1 (reject)", + )); + } + validate_tier_thresholds( + config.approvers().len() as u32, + config.spending_policy().tier_thresholds(), + )?; validate_proc_policies(config.approvers().len() as u32, config.procedure_policies())?; Ok(Self { config }) } @@ -185,20 +360,100 @@ impl AuthMultisigSmart { &PROCEDURE_POLICIES_SLOT_NAME } + pub fn delayed_execution_slot() -> &'static StorageSlotName { + &DELAYED_EXECUTION_SLOT_NAME + } + + pub fn spending_window_slot() -> &'static StorageSlotName { + &SPENDING_WINDOW_SLOT_NAME + } + + pub fn amount_limits_slot() -> &'static StorageSlotName { + &AMOUNT_LIMITS_SLOT_NAME + } + + pub fn spending_tracker_slot() -> &'static StorageSlotName { + &SPENDING_TRACKER_SLOT_NAME + } + + pub fn tier_threshold_config_slot() -> &'static StorageSlotName { + &TIER_THRESHOLD_CONFIG_SLOT_NAME + } + + pub fn oracle_config_slot() -> &'static StorageSlotName { + &ORACLE_CONFIG_SLOT_NAME + } + + pub fn get_price_proc_root_slot() -> &'static StorageSlotName { + &GET_PRICE_PROC_ROOT_SLOT_NAME + } + + pub fn get_price_untracked_policy_slot() -> &'static StorageSlotName { + &GET_PRICE_UNTRACKED_POLICY_SLOT_NAME + } + + pub fn tx_proposals_slot() -> &'static StorageSlotName { + &TX_PROPOSALS_SLOT_NAME + } + + pub fn pending_propose_slot() -> &'static StorageSlotName { + &PENDING_PROPOSE_SLOT_NAME + } + + pub fn pending_cancel_slot() -> &'static StorageSlotName { + &PENDING_CANCEL_SLOT_NAME + } + + pub fn pending_execute_slot() -> &'static StorageSlotName { + &PENDING_EXECUTE_SLOT_NAME + } + pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - AuthMultisig::threshold_config_slot_schema() + ( + Self::threshold_config_slot().clone(), + StorageSlotSchema::value( + "Threshold configuration", + [ + FeltSchema::u32("threshold"), + FeltSchema::u32("num_approvers"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) } pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - AuthMultisig::approver_public_keys_slot_schema() + ( + Self::approver_public_keys_slot().clone(), + StorageSlotSchema::map( + "Approver public keys", + SchemaType::u32(), + SchemaType::pub_key(), + ), + ) } pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - AuthMultisig::approver_auth_scheme_slot_schema() + ( + Self::approver_scheme_ids_slot().clone(), + StorageSlotSchema::map( + "Approver scheme IDs", + SchemaType::u32(), + SchemaType::auth_scheme(), + ), + ) } pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - AuthMultisig::executed_transactions_slot_schema() + ( + Self::executed_transactions_slot().clone(), + StorageSlotSchema::map( + "Executed transactions", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) } pub fn procedure_policies_slot_schema() -> (StorageSlotName, StorageSlotSchema) { @@ -211,11 +466,155 @@ impl AuthMultisigSmart { ), ) } + + pub fn delayed_execution_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::delayed_execution_slot().clone(), + StorageSlotSchema::value( + "Timelock controller", + [ + FeltSchema::u32("min_delay"), + FeltSchema::u16("propose_expiration_delta"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn spending_window_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::spending_window_slot().clone(), + StorageSlotSchema::value( + "Spending window policy", + [ + FeltSchema::u32("spending_window"), + FeltSchema::new_void(), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn amount_limits_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::amount_limits_slot().clone(), + StorageSlotSchema::value( + "Amount limits", + [ + FeltSchema::u32("limit_0"), + FeltSchema::u32("limit_1"), + FeltSchema::u32("limit_2"), + FeltSchema::u32("delay_trigger_amount"), + ], + ), + ) + } + + pub fn spending_tracker_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::spending_tracker_slot().clone(), + StorageSlotSchema::value( + "Spending tracker", + [ + FeltSchema::u32("amount_spent_in_window"), + FeltSchema::u32("window_start_timestamp"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn tier_threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::tier_threshold_config_slot().clone(), + StorageSlotSchema::value( + "Tier threshold configuration", + [ + FeltSchema::u32("tier_0"), + FeltSchema::u32("tier_1"), + FeltSchema::u32("tier_2"), + FeltSchema::u32("tier_3"), + ], + ), + ) + } + + pub fn oracle_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::oracle_config_slot().clone(), + StorageSlotSchema::value( + "Oracle configuration", + [ + FeltSchema::felt("oracle_id_prefix"), + FeltSchema::felt("oracle_id_suffix"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn get_price_proc_root_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::get_price_proc_root_slot().clone(), + StorageSlotSchema::value("Price procedure root", SchemaType::native_word()), + ) + } + + pub fn get_price_untracked_policy_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::get_price_untracked_policy_slot().clone(), + StorageSlotSchema::value( + "get_price untracked policy", + [ + FeltSchema::u32("policy_mode"), + FeltSchema::new_void(), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn tx_proposals_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::tx_proposals_slot().clone(), + StorageSlotSchema::map( + "Transaction proposals", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + pub fn pending_propose_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_propose_slot().clone(), + StorageSlotSchema::value("Pending propose", SchemaType::native_word()), + ) + } + + pub fn pending_cancel_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_cancel_slot().clone(), + StorageSlotSchema::value("Pending cancel", SchemaType::native_word()), + ) + } + + pub fn pending_execute_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_execute_slot().clone(), + StorageSlotSchema::value("Pending execute", SchemaType::native_word()), + ) + } } impl From for AccountComponent { fn from(multisig: AuthMultisigSmart) -> Self { - let mut storage_slots = Vec::with_capacity(5); + let mut storage_slots = Vec::with_capacity(16); // Threshold config slot (value: [threshold, num_approvers, 0, 0]) let num_approvers = multisig.config.approvers().len() as u32; @@ -261,12 +660,100 @@ impl From for AccountComponent { procedure_policies, )); + // Smart policy slots + let delayed_execution = multisig.config.delayed_execution(); + let spending_policy = multisig.config.spending_policy(); + let amount_limits = spending_policy.amount_limits(); + let tier_thresholds = spending_policy.tier_thresholds(); + let oracle_reader = multisig.config.oracle_reader(); + let oracle_id = oracle_reader.oracle_id(); + + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::delayed_execution_slot().clone(), + Word::from([ + delayed_execution.min_delay(), + delayed_execution.propose_expiration_delta() as u32, + 0u32, + 0u32, + ]), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::spending_window_slot().clone(), + Word::from([spending_policy.spending_window(), 0u32, 0u32, 0u32]), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::spending_tracker_slot().clone(), + Word::empty(), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::amount_limits_slot().clone(), + Word::from([ + amount_limits.limit_0() as u32, + amount_limits.limit_1() as u32, + amount_limits.limit_2() as u32, + amount_limits.delay_trigger_amount() as u32, + ]), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::tier_threshold_config_slot().clone(), + Word::from([ + tier_thresholds.tier_0(), + tier_thresholds.tier_1(), + tier_thresholds.tier_2(), + tier_thresholds.tier_3(), + ]), + )); + let oracle_id_word = match oracle_id { + Some(id) => Word::from([id.prefix(), id.suffix(), Felt::ZERO, Felt::ZERO]), + None => Word::empty(), + }; + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::oracle_config_slot().clone(), + oracle_id_word, + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::get_price_proc_root_slot().clone(), + oracle_reader.get_price_proc_root(), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::get_price_untracked_policy_slot().clone(), + Word::from([oracle_reader.untracked_price_policy(), 0u32, 0u32, 0u32]), + )); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::tx_proposals_slot().clone(), + StorageMap::default(), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::pending_propose_slot().clone(), + Word::empty(), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::pending_cancel_slot().clone(), + Word::empty(), + )); + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::pending_execute_slot().clone(), + Word::empty(), + )); + let storage_schema = StorageSchema::new(vec![ AuthMultisigSmart::threshold_config_slot_schema(), AuthMultisigSmart::approver_public_keys_slot_schema(), AuthMultisigSmart::approver_auth_scheme_slot_schema(), AuthMultisigSmart::executed_transactions_slot_schema(), AuthMultisigSmart::procedure_policies_slot_schema(), + AuthMultisigSmart::delayed_execution_slot_schema(), + AuthMultisigSmart::spending_window_slot_schema(), + AuthMultisigSmart::spending_tracker_slot_schema(), + AuthMultisigSmart::amount_limits_slot_schema(), + AuthMultisigSmart::tier_threshold_config_slot_schema(), + AuthMultisigSmart::oracle_config_slot_schema(), + AuthMultisigSmart::get_price_proc_root_slot_schema(), + AuthMultisigSmart::get_price_untracked_policy_slot_schema(), + AuthMultisigSmart::tx_proposals_slot_schema(), + AuthMultisigSmart::pending_propose_slot_schema(), + AuthMultisigSmart::pending_cancel_slot_schema(), + AuthMultisigSmart::pending_execute_slot_schema(), ]) .expect("storage schema should be valid"); @@ -284,10 +771,20 @@ impl From for AccountComponent { mod tests { use alloc::string::ToString; - use miden_protocol::account::AccountBuilder; use miden_protocol::account::auth::AuthSecretKey; + use miden_protocol::account::{AccountBuilder, AccountId}; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; use super::*; + use crate::account::auth::multisig_smart::{ + AmountLimits, + DelayedExecutionPolicy, + OracleId, + OracleReaderConfig, + ProcedurePolicyNoteRestriction, + SpendingPolicy, + TierThresholds, + }; use crate::account::wallets::BasicWallet; #[test] @@ -298,18 +795,25 @@ mod tests { (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let num_approvers = approvers.len() as u32; - let default_threshold = 2u32; - let receive_asset_immediate_threshold = 1u32; - let config = AuthMultisigSmartConfig::new(approvers.clone(), default_threshold) + let config = AuthMultisigSmartConfig::new(approvers.clone(), 2) .expect("invalid multisig smart config") .with_proc_policies(vec![( BasicWallet::receive_asset_root().as_word(), - ProcedurePolicy::with_immediate_threshold(receive_asset_immediate_threshold) + ProcedurePolicy::with_immediate_threshold(1) .expect("procedure policy should be valid"), )]) - .expect("procedure policy config should be valid"); + .expect("procedure policy config should be valid") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 2), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)) + .with_oracle_reader(OracleReaderConfig::new( + OracleId::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET).unwrap()), + Word::from([7u32, 8, 9, 10]), + )); let component = AuthMultisigSmart::new(config).expect("multisig smart component creation failed"); @@ -324,7 +828,25 @@ mod tests { .storage() .get_item(AuthMultisigSmart::threshold_config_slot()) .expect("threshold config should be present"); - assert_eq!(threshold_config, Word::from([default_threshold, num_approvers, 0, 0])); + assert_eq!(threshold_config, Word::from([2u32, 2u32, 0, 0])); + + let delayed_execution = account + .storage() + .get_item(AuthMultisigSmart::delayed_execution_slot()) + .expect("timelock controller should be present"); + assert_eq!(delayed_execution, Word::from([30u32, 3u32, 0u32, 0u32])); + + let spending_window = account + .storage() + .get_item(AuthMultisigSmart::spending_window_slot()) + .expect("spending window should be present"); + assert_eq!(spending_window, Word::from([100u32, 0u32, 0u32, 0u32])); + + let amount_limits = account + .storage() + .get_item(AuthMultisigSmart::amount_limits_slot()) + .expect("amount limits should be present"); + assert_eq!(amount_limits, Word::from([500u32, 1000, 2000, 1500])); let receive_asset_policy = account .storage() @@ -333,10 +855,13 @@ mod tests { BasicWallet::receive_asset_root().as_word(), ) .expect("receive_asset policy should be present"); - assert_eq!( - receive_asset_policy, - Word::from([receive_asset_immediate_threshold, 0u32, 0u32, 0u32]) - ); + assert_eq!(receive_asset_policy, Word::from([1u32, 0u32, 0u32, 0u32])); + + let untracked_policy = account + .storage() + .get_item(AuthMultisigSmart::get_price_untracked_policy_slot()) + .expect("get_price untracked policy slot should be present"); + assert_eq!(untracked_policy, Word::from([0u32, 0u32, 0u32, 0u32])); } #[test] @@ -347,43 +872,99 @@ mod tests { let result = AuthMultisigSmartConfig::new(approvers.clone(), 0); assert!(result.unwrap_err().to_string().contains("threshold must be at least 1")); - let result = AuthMultisigSmartConfig::new(approvers, 2); + let result = AuthMultisigSmartConfig::new(approvers.clone(), 2); assert!( result .unwrap_err() .to_string() .contains("threshold cannot be greater than number of approvers") ); - } - #[test] - fn test_multisig_smart_component_rejects_duplicate_procedure_roots() { - let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let config = AuthMultisigSmartConfig::new(approvers, 1) + .expect("config should be valid") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(u32::MAX as u64 + 1, 0, 0, 0), + TierThresholds::new(1, 2, 2, 2), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)); + let result = AuthMultisigSmart::new(config); + assert!(result.unwrap_err().to_string().contains("amount limits must fit into u32")); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); let approvers = vec![ - (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key.public_key().to_commitment(), sec_key.auth_scheme()), (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let receive_asset_root = BasicWallet::receive_asset_root().as_word(); - let policy_one = - ProcedurePolicy::with_immediate_threshold(1).expect("procedure policy should be valid"); - let policy_two = - ProcedurePolicy::with_immediate_threshold(2).expect("procedure policy should be valid"); - - let result = AuthMultisigSmartConfig::new(approvers, 2) - .expect("base config should be valid") - .with_proc_policies(vec![ - (receive_asset_root, policy_one), - (receive_asset_root, policy_two), - ]); + let result = AuthMultisigSmartConfig::new(approvers.clone(), 2).and_then(|cfg| { + let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2)?; + cfg.with_proc_policies(vec![(Word::from([1u32, 2, 3, 4]), policy)]) + }); + assert!( + result + .unwrap_err() + .to_string() + .contains("delay threshold cannot exceed immediate threshold") + ); + let result = AuthMultisigSmartConfig::new(approvers, 2).and_then(|cfg| { + let policy = ProcedurePolicy::with_immediate_threshold(0)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); + cfg.with_proc_policies(vec![(Word::from([4u32, 3, 2, 1]), policy)]) + }); assert!( result .unwrap_err() .to_string() - .contains("duplicate procedure roots are not allowed in the procedure policy map") + .contains("procedure policy immediate threshold must be at least 1") + ); + } + + #[test] + fn test_multisig_smart_component_rejects_invalid_tier_thresholds() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let result = AuthMultisigSmart::new( + AuthMultisigSmartConfig::new(approvers.clone(), 2) + .expect("config should be valid") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(0, 2, 2, 2), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)), + ); + assert!(result.unwrap_err().to_string().contains("tier_0 must be > 0")); + + let result = AuthMultisigSmart::new( + AuthMultisigSmartConfig::new(approvers.clone(), 2) + .expect("config should be valid") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 1, 2), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)), + ); + assert!(result.unwrap_err().to_string().contains("tier config is invalid")); + + let result = AuthMultisigSmart::new( + AuthMultisigSmartConfig::new(approvers, 2) + .expect("config should be valid") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 3), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)), ); + assert!(result.unwrap_err().to_string().contains("tier_3 must be <= num_approvers")); } #[test] diff --git a/crates/miden-standards/src/account/auth/multisig_smart/config.rs b/crates/miden-standards/src/account/auth/multisig_smart/config.rs new file mode 100644 index 0000000000..3d2f2970b4 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/config.rs @@ -0,0 +1,126 @@ +use miden_protocol::Word; + +use super::types::{AmountLimits, OracleId, TierThresholds}; + +/// Configures the spending-based approval escalation rules used by smart multisig accounts. +/// +/// This bundles the tracked spending window, the amount breakpoints used to derive a spending +/// tier, and the approval threshold required for each tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SpendingPolicy { + /// Number of blocks over which spending is accumulated before the tracker resets. + spending_window: u32, + /// Spending breakpoints that map accumulated spend into tiers `0..=3`. + amount_limits: AmountLimits, + /// Signature thresholds required for each spending tier. + tier_thresholds: TierThresholds, +} + +impl SpendingPolicy { + pub const fn new( + spending_window: u32, + amount_limits: AmountLimits, + tier_thresholds: TierThresholds, + ) -> Self { + Self { + spending_window, + amount_limits, + tier_thresholds, + } + } + + pub const fn spending_window(&self) -> u32 { + self.spending_window + } + + pub const fn amount_limits(&self) -> AmountLimits { + self.amount_limits + } + + pub const fn tier_thresholds(&self) -> TierThresholds { + self.tier_thresholds + } +} + +/// Configures the proposal delay rules used by smart multisig timelock flows. +/// +/// `min_delay` defines how long a proposal must wait before execution, while +/// `propose_expiration_delta` controls the transaction expiration delta applied to proposal +/// transactions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DelayedExecutionPolicy { + min_delay: u32, + propose_expiration_delta: u16, +} + +impl DelayedExecutionPolicy { + pub const fn new(min_delay: u32, propose_expiration_delta: u16) -> Self { + Self { min_delay, propose_expiration_delta } + } + + pub const fn min_delay(&self) -> u32 { + self.min_delay + } + + pub const fn propose_expiration_delta(&self) -> u16 { + self.propose_expiration_delta + } +} + +/// When `get_price` returns `is_tracked = 0`, `compute_spending_amount` uses this policy (stored +/// in the `get_price_untracked_policy` storage slot). +pub const GET_PRICE_UNTRACKED_OMIT: u32 = 0; +/// Reject authentication if any fungible output note is untracked by the oracle reader. +pub const GET_PRICE_UNTRACKED_REJECT: u32 = 1; + +/// Configures the oracle reader used to normalize asset values during spending-policy checks. +/// +/// `oracle_id` selects the logical oracle feed, and `get_price_proc_root` identifies the foreign +/// procedure that should be invoked to fetch a price for a tracked asset. +/// +/// `untracked_price_policy` controls behavior when that procedure returns `is_tracked = 0` for a +/// fungible output (see [`GET_PRICE_UNTRACKED_OMIT`] and [`GET_PRICE_UNTRACKED_REJECT`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OracleReaderConfig { + oracle_id: Option, + get_price_proc_root: Word, + untracked_price_policy: u32, +} + +impl OracleReaderConfig { + pub const fn new(oracle_id: OracleId, get_price_proc_root: Word) -> Self { + Self { + oracle_id: Some(oracle_id), + get_price_proc_root, + untracked_price_policy: GET_PRICE_UNTRACKED_OMIT, + } + } + + /// Returns the configured oracle id, or `None` when no oracle is attached (default). + pub const fn oracle_id(&self) -> Option { + self.oracle_id + } + + pub const fn get_price_proc_root(&self) -> Word { + self.get_price_proc_root + } + + pub const fn untracked_price_policy(&self) -> u32 { + self.untracked_price_policy + } + + pub fn with_untracked_price_policy(mut self, untracked_price_policy: u32) -> Self { + self.untracked_price_policy = untracked_price_policy; + self + } +} + +impl Default for OracleReaderConfig { + fn default() -> Self { + Self { + oracle_id: None, + get_price_proc_root: Word::empty(), + untracked_price_policy: GET_PRICE_UNTRACKED_OMIT, + } + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_smart/mod.rs b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs index 8a3b7e705b..d97d00604e 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/mod.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs @@ -1,9 +1,21 @@ mod component; +mod config; +mod presets; mod procedure_policies; +mod types; pub use component::{AuthMultisigSmart, AuthMultisigSmartConfig}; +pub use config::{ + DelayedExecutionPolicy, + GET_PRICE_UNTRACKED_OMIT, + GET_PRICE_UNTRACKED_REJECT, + OracleReaderConfig, + SpendingPolicy, +}; +pub use presets::AuthMultisigSmartPresets; pub use procedure_policies::{ ProcedurePolicy, ProcedurePolicyExecutionMode, ProcedurePolicyNoteRestriction, }; +pub use types::{AmountLimits, OracleId, TierThresholds}; diff --git a/crates/miden-standards/src/account/auth/multisig_smart/presets.rs b/crates/miden-standards/src/account/auth/multisig_smart/presets.rs new file mode 100644 index 0000000000..db5e5716d1 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/presets.rs @@ -0,0 +1,209 @@ +use alloc::vec::Vec; + +use miden_protocol::Word; + +use super::{AuthMultisigSmart, ProcedurePolicy, ProcedurePolicyNoteRestriction}; +use crate::procedure_root; + +procedure_root!( + MULTISIG_SMART_UPDATE_SIGNERS_AND_THRESHOLD, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_SIGNERS_AND_THRESHOLD_PROC_NAME, + AuthMultisigSmart::code() +); + +procedure_root!( + MULTISIG_SMART_UPDATE_THRESHOLD_CONFIG, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_THRESHOLD_CONFIG_PROC_NAME, + AuthMultisigSmart::code() +); + +procedure_root!( + MULTISIG_SMART_UPDATE_SPENDING_LIMITS, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_SPENDING_LIMITS_PROC_NAME, + AuthMultisigSmart::code() +); + +procedure_root!( + MULTISIG_SMART_UPDATE_ORACLE_CONFIG, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_ORACLE_CONFIG_PROC_NAME, + AuthMultisigSmart::code() +); + +procedure_root!( + MULTISIG_SMART_UPDATE_GET_PRICE_UNTRACKED_POLICY, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_GET_PRICE_UNTRACKED_POLICY_PROC_NAME, + AuthMultisigSmart::code() +); + +procedure_root!( + MULTISIG_SMART_UPDATE_DELAYED_EXECUTION_POLICY, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_DELAYED_EXECUTION_POLICY_PROC_NAME, + AuthMultisigSmart::code() +); + +/// Opinionated smart-multisig policy presets. +pub struct AuthMultisigSmartPresets; + +impl AuthMultisigSmartPresets { + pub fn single_user_1_of_2() -> Vec<(Word, ProcedurePolicy)> { + vec![ + ( + Self::update_signers_and_threshold(), + ProcedurePolicy::with_delay_threshold(1) + .expect("preset policy should be valid") + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes), + ), + ( + Self::update_threshold_config(), + ProcedurePolicy::with_immediate_and_delay_thresholds(2, 1) + .expect("preset policy should be valid"), + ), + ( + Self::update_spending_limits(), + ProcedurePolicy::with_immediate_and_delay_thresholds(2, 1) + .expect("preset policy should be valid"), + ), + ( + Self::update_oracle_config_and_proc_root(), + ProcedurePolicy::with_immediate_and_delay_thresholds(2, 1) + .expect("preset policy should be valid"), + ), + ( + Self::update_get_price_untracked_policy(), + ProcedurePolicy::with_immediate_and_delay_thresholds(2, 1) + .expect("preset policy should be valid"), + ), + ( + Self::update_delayed_execution_policy(), + ProcedurePolicy::with_immediate_and_delay_thresholds(2, 1) + .expect("preset policy should be valid"), + ), + ] + } + + pub fn multisig_3_of_5() -> Vec<(Word, ProcedurePolicy)> { + vec![ + ( + Self::update_signers_and_threshold(), + ProcedurePolicy::with_delay_threshold(3) + .expect("preset policy should be valid") + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes), + ), + ( + Self::update_threshold_config(), + ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .expect("preset policy should be valid"), + ), + ( + Self::update_spending_limits(), + ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .expect("preset policy should be valid"), + ), + ( + Self::update_oracle_config_and_proc_root(), + ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .expect("preset policy should be valid"), + ), + ( + Self::update_get_price_untracked_policy(), + ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .expect("preset policy should be valid"), + ), + ( + Self::update_delayed_execution_policy(), + ProcedurePolicy::with_immediate_and_delay_thresholds(5, 4) + .expect("preset policy should be valid"), + ), + ] + } + + pub fn update_signers_and_threshold() -> Word { + MULTISIG_SMART_UPDATE_SIGNERS_AND_THRESHOLD.as_word() + } + + pub fn update_threshold_config() -> Word { + MULTISIG_SMART_UPDATE_THRESHOLD_CONFIG.as_word() + } + + pub fn update_spending_limits() -> Word { + MULTISIG_SMART_UPDATE_SPENDING_LIMITS.as_word() + } + + pub fn update_oracle_config_and_proc_root() -> Word { + MULTISIG_SMART_UPDATE_ORACLE_CONFIG.as_word() + } + + pub fn update_get_price_untracked_policy() -> Word { + MULTISIG_SMART_UPDATE_GET_PRICE_UNTRACKED_POLICY.as_word() + } + + pub fn update_delayed_execution_policy() -> Word { + MULTISIG_SMART_UPDATE_DELAYED_EXECUTION_POLICY.as_word() + } +} + +#[cfg(test)] +mod tests { + use miden_protocol::account::auth::AuthSecretKey; + + use super::AuthMultisigSmartPresets; + use crate::account::auth::multisig_smart::{ + AmountLimits, + AuthMultisigSmart, + AuthMultisigSmartConfig, + DelayedExecutionPolicy, + SpendingPolicy, + TierThresholds, + }; + + #[test] + fn presets_smoke_test_with_component_configs() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_3 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_4 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_5 = AuthSecretKey::new_ecdsa_k256_keccak(); + + let one_of_two_approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + let three_of_five_approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + (sec_key_3.public_key().to_commitment(), sec_key_3.auth_scheme()), + (sec_key_4.public_key().to_commitment(), sec_key_4.auth_scheme()), + (sec_key_5.public_key().to_commitment(), sec_key_5.auth_scheme()), + ]; + + let one_of_two = AuthMultisigSmartConfig::new(one_of_two_approvers, 1) + .expect("config should be valid") + .with_proc_policies(AuthMultisigSmartPresets::single_user_1_of_2()) + .expect("preset policies should validate") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 1, 1, 1), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)); + AuthMultisigSmart::new(one_of_two).expect("preset component should build"); + + let three_of_five = AuthMultisigSmartConfig::new(three_of_five_approvers, 3) + .expect("config should be valid") + .with_proc_policies(AuthMultisigSmartPresets::multisig_3_of_5()) + .expect("preset policies should validate") + .with_spending(SpendingPolicy::new( + 100, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 3, 5), + )) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 3)); + AuthMultisigSmart::new(three_of_five).expect("preset component should build"); + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_smart/types.rs b/crates/miden-standards/src/account/auth/multisig_smart/types.rs new file mode 100644 index 0000000000..9d0c5a7ed5 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/types.rs @@ -0,0 +1,120 @@ +use miden_protocol::Felt; +use miden_protocol::account::AccountId; + +/// Defines the spending breakpoints used by smart multisig spending-policy evaluation. +/// +/// `limit_0`, `limit_1`, and `limit_2` partition the tracked spending amount into four approval +/// tiers. `delay_trigger_amount` is checked separately and marks a transaction as requiring the +/// delayed execution lane when the tracked spending amount exceeds it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AmountLimits { + limit_0: u64, + limit_1: u64, + limit_2: u64, + delay_trigger_amount: u64, +} + +impl AmountLimits { + pub const fn new(limit_0: u64, limit_1: u64, limit_2: u64, delay_trigger_amount: u64) -> Self { + Self { + limit_0, + limit_1, + limit_2, + delay_trigger_amount, + } + } + + pub const fn limit_0(&self) -> u64 { + self.limit_0 + } + + pub const fn limit_1(&self) -> u64 { + self.limit_1 + } + + pub const fn limit_2(&self) -> u64 { + self.limit_2 + } + + pub const fn delay_trigger_amount(&self) -> u64 { + self.delay_trigger_amount + } + + pub const fn as_array(&self) -> [u64; 4] { + [self.limit_0, self.limit_1, self.limit_2, self.delay_trigger_amount] + } +} + +/// Maps each spending tier index to the number of approver signatures required. +/// +/// Tier indices `0..=3` are derived from [`AmountLimits`]. These thresholds are expected to be +/// monotonic and must satisfy `tier_0 > 0` and `tier_0 <= tier_1 <= tier_2 <= tier_3`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TierThresholds { + tier_0: u32, + tier_1: u32, + tier_2: u32, + tier_3: u32, +} + +impl TierThresholds { + pub const fn new(tier_0: u32, tier_1: u32, tier_2: u32, tier_3: u32) -> Self { + Self { tier_0, tier_1, tier_2, tier_3 } + } + + pub const fn tier_0(&self) -> u32 { + self.tier_0 + } + + pub const fn tier_1(&self) -> u32 { + self.tier_1 + } + + pub const fn tier_2(&self) -> u32 { + self.tier_2 + } + + pub const fn tier_3(&self) -> u32 { + self.tier_3 + } + + pub const fn as_array(&self) -> [u32; 4] { + [self.tier_0, self.tier_1, self.tier_2, self.tier_3] + } +} + +/// Identifies the oracle account used by smart multisig price lookups. +/// +/// Wrapping [`AccountId`] keeps the felt layout the same as any other account-id reference at the +/// contract level (prefix-suffix word) while preventing arbitrary `(Felt, Felt)` pairs from being +/// passed to the spending-policy pipeline at the Rust level. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OracleId(AccountId); + +impl OracleId { + pub const fn new(account_id: AccountId) -> Self { + Self(account_id) + } + + pub fn account_id(&self) -> AccountId { + self.0 + } + + pub fn prefix(&self) -> Felt { + self.0.prefix().as_felt() + } + + pub fn suffix(&self) -> Felt { + self.0.suffix() + } + + pub fn as_felt_pair(&self) -> [Felt; 2] { + [self.prefix(), self.suffix()] + } +} + +impl From for OracleId { + fn from(account_id: AccountId) -> Self { + Self(account_id) + } +} diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 732188d3ca..214182437f 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -8,7 +8,12 @@ use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKeyCommitme use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; use miden_protocol::note::NoteScriptRoot; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; -use miden_standards::account::auth::multisig_smart::ProcedurePolicy; +use miden_standards::account::auth::multisig_smart::{ + DelayedExecutionPolicy, + OracleReaderConfig, + ProcedurePolicy, + SpendingPolicy, +}; use miden_standards::account::auth::{ AuthGuardedMultisig, AuthGuardedMultisigConfig, @@ -52,11 +57,15 @@ pub enum Auth { proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, }, - /// Multisig with smart per-procedure policy configuration. + /// Multisig with smart per-procedure policy configuration plus optional spending limits, + /// timelock controller, and oracle reader. MultisigSmart { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, proc_policy_map: Vec<(Word, ProcedurePolicy)>, + spending_policy: SpendingPolicy, + timelock: DelayedExecutionPolicy, + oracle_reader: OracleReaderConfig, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to @@ -131,10 +140,20 @@ impl Auth { (component, None) }, - Auth::MultisigSmart { threshold, approvers, proc_policy_map } => { + Auth::MultisigSmart { + threshold, + approvers, + proc_policy_map, + spending_policy, + timelock, + oracle_reader, + } => { let config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) - .expect("invalid multisig smart config"); + .expect("invalid multisig smart config") + .with_spending(*spending_policy) + .with_delayed_execution(*timelock) + .with_oracle_reader(*oracle_reader); let component = AuthMultisigSmart::new(config) .expect("multisig smart component creation failed") diff --git a/crates/miden-testing/tests/agglayer/network_account_regression.rs b/crates/miden-testing/tests/agglayer/network_account_regression.rs index a3bfa23ac0..468b6f7c61 100644 --- a/crates/miden-testing/tests/agglayer/network_account_regression.rs +++ b/crates/miden-testing/tests/agglayer/network_account_regression.rs @@ -145,7 +145,7 @@ async fn faucet_rejects_tx_script() -> anyhow::Result<()> { builder.rng_mut().draw_word(), "TEST", 8, - Felt::new(1_000_000).unwrap(), + Felt::from(1_000_000u32), Felt::ZERO, bridge_admin.id(), ); @@ -185,7 +185,7 @@ async fn faucet_rejects_non_allowlisted_input_note() -> anyhow::Result<()> { builder.rng_mut().draw_word(), "TEST", 8, - Felt::new(1_000_000).unwrap(), + Felt::from(1_000_000u32), Felt::ZERO, bridge_admin.id(), ); diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index da5f38ad50..35f54d74fc 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -1,109 +1,835 @@ use miden_processor::advice::AdviceInputs; -use miden_protocol::account::auth::{AuthScheme, PublicKey}; -use miden_protocol::account::{Account, AccountBuilder, AccountId, AccountType}; +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; +use miden_protocol::account::component::AccountComponentMetadata; +use miden_protocol::account::{Account, AccountBuilder, AccountComponent, AccountId, AccountType}; use miden_protocol::asset::FungibleAsset; -use miden_protocol::note::NoteType; -use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; -use miden_protocol::transaction::TransactionScript; +use miden_protocol::block::account_tree::AccountWitness; +use miden_protocol::note::{NoteType, PartialNote}; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, +}; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote, TransactionScript}; use miden_protocol::vm::AdviceMap; use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::multisig_smart::{ + AmountLimits, + DelayedExecutionPolicy, + OracleId, + OracleReaderConfig, ProcedurePolicy, ProcedurePolicyNoteRestriction, + SpendingPolicy, + TierThresholds, +}; +use miden_standards::account::auth::{ + AuthMultisigSmart, + AuthMultisigSmartConfig, + AuthMultisigSmartPresets, }; -use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig}; +use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, + ERR_CANCEL_INSUFFICIENT_SIGNATURES, + ERR_INVALID_AMOUNT_LIMITS, + ERR_INVALID_TIER_CONFIG, + ERR_PENDING_ALREADY_SET, + ERR_TIER0_MUST_BE_POSITIVE, + ERR_TIER3_TOO_HIGH, + ERR_TX_ALREADY_EXECUTED, + ERR_TX_STILL_TIMELOCKED, }; -use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; -use miden_tx::auth::{SigningInputs, TransactionAuthenticator}; +use miden_standards::note::P2idNote; +use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; +use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; use rstest::rstest; -use super::multisig::{ - build_update_signers_config_vector, - setup_keys_and_authenticators_with_scheme, -}; - // ================================================================================================ // HELPER FUNCTIONS // ================================================================================================ -/// Builds a multisig smart account with the given approvers, threshold, starting balance, and -/// procedure policy map. Uses `BasicWallet` so the account exposes `receive_asset` and friends. -fn create_multisig_smart_account( +type MultisigTestSetupWithSchemes = + (Vec, Vec, Vec, Vec); + +#[derive(Clone, Copy)] +enum TestOracleMode { + OneToOneTracked, + FixedPrice(u64), + Untracked, +} + +struct TestOracleFixture { + account: Account, + get_price_proc_root: Word, +} + +impl TestOracleFixture { + fn oracle_id(&self) -> OracleId { + OracleId::from(self.account.id()) + } +} + +/// Sets up secret keys, auth schemes, public keys, and authenticators for a specific scheme. +fn setup_keys_and_authenticators_with_scheme( + num_approvers: usize, + threshold: usize, + auth_scheme: AuthScheme, +) -> anyhow::Result { + let seed: [u8; 32] = rand::random(); + let mut rng = ChaCha20Rng::from_seed(seed); + + let mut secret_keys = Vec::new(); + let mut auth_schemes = Vec::new(); + let mut public_keys = Vec::new(); + let mut authenticators = Vec::new(); + + for _ in 0..num_approvers { + let sec_key = match auth_scheme { + AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng), + AuthScheme::Falcon512Poseidon2 => { + AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng) + }, + _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), + }; + let pub_key = sec_key.public_key(); + + secret_keys.push(sec_key); + auth_schemes.push(auth_scheme); + public_keys.push(pub_key); + } + + for secret_key in secret_keys.iter().take(threshold) { + authenticators.push(BasicAuthenticator::new(core::slice::from_ref(secret_key))); + } + + Ok((secret_keys, auth_schemes, public_keys, authenticators)) +} + +fn build_update_signers_config_vector( + threshold: u64, + num_of_approvers: u64, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, +) -> Vec { + let mut config_and_pubkeys_vector = Vec::new(); + config_and_pubkeys_vector.extend_from_slice(&[ + Felt::from(threshold as u32), + Felt::from(num_of_approvers as u32), + Felt::ZERO, + Felt::ZERO, + ]); + + let scheme_word = [Felt::from(auth_scheme as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO]; + + for public_key in public_keys.iter().rev() { + let key_word: Word = public_key.to_commitment().into(); + config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); + config_and_pubkeys_vector.extend_from_slice(&scheme_word); + } + + config_and_pubkeys_vector +} + +fn build_test_oracle_fixture(mode: TestOracleMode) -> anyhow::Result { + let oracle_code_source = match mode { + TestOracleMode::OneToOneTracked => " + use miden::core::sys + + pub proc get_median + drop drop + push.1 + exec.sys::truncate_stack + end + " + .to_string(), + TestOracleMode::FixedPrice(fixed_price) => format!( + " + use miden::core::sys + + pub proc get_median + drop drop drop + push.{fixed_price} + push.1 + exec.sys::truncate_stack + end + " + ), + TestOracleMode::Untracked => " + use miden::core::sys + + pub proc get_median + drop drop drop + push.0 + push.0 + exec.sys::truncate_stack + end + " + .to_string(), + }; + + let oracle_component = AccountComponent::new( + CodeBuilder::default().compile_component_code("test::oracle", oracle_code_source)?, + vec![], + AccountComponentMetadata::mock("test::oracle"), + )?; + let get_price_proc_root = oracle_component + .get_procedure_root_by_path("test::oracle::get_median") + .expect("test oracle component should export get_median"); + + let account = AccountBuilder::new(rand::random()) + .with_auth_component(Auth::IncrNonce) + .with_component(oracle_component) + .account_type(AccountType::Public) + .build_existing()?; + + Ok(TestOracleFixture { + account, + get_price_proc_root: get_price_proc_root.as_word(), + }) +} + +fn test_oracle_foreign_account_inputs( + mock_chain: &MockChain, + oracle_fixture: &TestOracleFixture, +) -> anyhow::Result> { + Ok(vec![mock_chain.get_foreign_account_inputs(oracle_fixture.account.id())?]) +} + +fn create_multisig_smart_account_with_assets_and_optional_oracle( threshold: u32, public_keys: &[PublicKey], auth_scheme: AuthScheme, - starting_balance: u64, + assets: Vec, + amount_limits: AmountLimits, + tier_thresholds: TierThresholds, proc_policy_map: Vec<(Word, ProcedurePolicy)>, + oracle_config: Option<(OracleId, Word)>, ) -> anyhow::Result { let approvers: Vec<_> = public_keys.iter().map(|pk| (pk.to_commitment(), auth_scheme)).collect(); - let config = - AuthMultisigSmartConfig::new(approvers, threshold)?.with_proc_policies(proc_policy_map)?; + let mut config = AuthMultisigSmartConfig::new(approvers, threshold)? + .with_proc_policies(proc_policy_map)? + .with_spending(SpendingPolicy::new(100, amount_limits, tier_thresholds)) + .with_delayed_execution(DelayedExecutionPolicy::new(30, 2)); - let asset = FungibleAsset::new( - AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, - starting_balance, - )?; + if let Some((oracle_id, get_price_proc_root)) = oracle_config { + config = config.with_oracle_reader(OracleReaderConfig::new(oracle_id, get_price_proc_root)); + } let multisig_account = AccountBuilder::new([0; 32]) .with_auth_component(AuthMultisigSmart::new(config)?) .with_component(BasicWallet) .account_type(AccountType::Public) - .with_assets(core::iter::once(asset.into())) + .with_assets(assets.into_iter().map(|a| a.into())) .build_existing()?; Ok(multisig_account) } -/// Compiles a transaction script that links against the multisig smart library so it can `call.` -/// the wrapper-exported procedures. +fn create_multisig_smart_account_with_assets( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + assets: Vec, + amount_limits: AmountLimits, + tier_thresholds: TierThresholds, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + create_multisig_smart_account_with_assets_and_optional_oracle( + threshold, + public_keys, + auth_scheme, + assets, + amount_limits, + tier_thresholds, + proc_policy_map, + None, + ) +} + +fn create_multisig_smart_account_with_assets_and_oracle( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + assets: Vec, + amount_limits: AmountLimits, + tier_thresholds: TierThresholds, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, + oracle_fixture: &TestOracleFixture, +) -> anyhow::Result { + create_multisig_smart_account_with_assets_and_optional_oracle( + threshold, + public_keys, + auth_scheme, + assets, + amount_limits, + tier_thresholds, + proc_policy_map, + Some((oracle_fixture.oracle_id(), oracle_fixture.get_price_proc_root)), + ) +} + +fn create_multisig_smart_with_fixed_test_configuration_and_oracle( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, + oracle_fixture: &TestOracleFixture, +) -> anyhow::Result { + let multisig_starting_assets = vec![ + (AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 10000u64), + (AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2)?, 20000u64), + (AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3)?, 30000u64), + ]; + + create_multisig_smart_account_with_assets_and_oracle( + threshold, + public_keys, + auth_scheme, + multisig_starting_assets + .into_iter() + .map(|(account_id, amount)| FungibleAsset::new(account_id, amount).unwrap()) + .collect(), + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 3, 4), + proc_policy_map, + oracle_fixture, + ) +} + +fn create_assets_for_output_notes( + amount_asset_1: u64, + amount_asset_2: u64, + amount_asset_3: u64, +) -> (FungibleAsset, FungibleAsset, FungibleAsset) { + let output_note_asset_1 = FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1).unwrap(), + amount_asset_1, + ) + .unwrap(); + + let output_note_asset_2 = FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2).unwrap(), + amount_asset_2, + ) + .unwrap(); + + let output_note_asset_3 = FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_3).unwrap(), + amount_asset_3, + ) + .unwrap(); + + (output_note_asset_1, output_note_asset_2, output_note_asset_3) +} + +fn create_multisig_account( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + starting_balance: u64, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + let approvers = public_keys.iter().map(|pk| (pk.clone(), auth_scheme)).collect::>(); + + create_multisig_account_with_schemes(threshold, &approvers, starting_balance, proc_policy_map) +} + +fn create_multisig_account_with_schemes( + threshold: u32, + approvers: &[(PublicKey, AuthScheme)], + starting_balance: u64, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + let num_approvers = approvers.len() as u32; + let public_keys = approvers.iter().map(|(pk, _)| pk.clone()).collect::>(); + let auth_scheme = approvers + .first() + .map(|(_, scheme)| *scheme) + .expect("smart multisig tests require at least one approver"); + + create_multisig_smart_account_with_assets( + threshold, + &public_keys, + auth_scheme, + vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + starting_balance, + )?], + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2u32.min(num_approvers), 3u32.min(num_approvers), num_approvers), + proc_policy_map, + ) +} + fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result { Ok(CodeBuilder::default() - .with_dynamically_linked_library(AuthMultisigSmart::code())? + .with_dynamically_linked_library(AuthMultisigSmart::code().as_library())? .compile_tx_script(script.as_ref())?) } +fn compile_timelocked_send_note_script( + output_note: &PartialNote, +) -> anyhow::Result { + let mut script = format!( + " + begin + call.::miden::standards::components::auth::multisig_smart::execute_proposed_transaction + + push.{recipient} + push.{note_type} + push.{tag} + exec.::miden::protocol::output_note::create + ", + recipient = output_note.recipient_digest(), + note_type = Felt::from(output_note.metadata().note_type()), + tag = Felt::from(output_note.metadata().tag()), + ); + + for asset in output_note.assets().iter() { + script.push_str(&format!( + " + padw push.0 push.0 push.0 dup.7 + push.{asset_value} + push.{asset_key} + call.::miden::standards::wallets::basic::move_asset_to_note + dropw dropw dropw dropw + ", + asset_key = asset.to_key_word(), + asset_value = asset.to_value_word(), + )); + } + + // Close out the output_note builder. Attachment-setup omitted post-upstream merge: the + // single-`set_attachment` API was replaced by a multi-attachment header model + // (`add_attachment` / `add_word_attachment`) that this helper does not yet thread through. + // Tests that exercise attachment semantics need to be reworked against the new API. + script.push_str( + " + dropw + end + ", + ); + + compile_multisig_smart_tx_script(script) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_script_with_signers( + mock_chain: &MockChain, + account_id: AccountId, + tx_script: TransactionScript, + salt: Word, + signer_indices: &[usize], + public_keys: &[PublicKey], + authenticators: &[BasicAuthenticator], + tx_script_args: Option, + advice_inputs: Option, + foreign_accounts: Option>, +) -> anyhow::Result> { + let mut tx_context_init_builder = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(tx_script.clone()) + .auth_args(salt); + + if let Some(tx_script_args) = tx_script_args { + tx_context_init_builder = tx_context_init_builder.tx_script_args(tx_script_args); + } + + if let Some(advice_inputs) = advice_inputs.clone() { + tx_context_init_builder = tx_context_init_builder.extend_advice_inputs(advice_inputs); + } + + if let Some(foreign_accounts) = foreign_accounts.clone() { + tx_context_init_builder = tx_context_init_builder.foreign_accounts(foreign_accounts); + } + + let tx_summary = tx_context_init_builder + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + let mut tx_context_signed_builder = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(tx_script) + .auth_args(salt); + + if let Some(tx_script_args) = tx_script_args { + tx_context_signed_builder = tx_context_signed_builder.tx_script_args(tx_script_args); + } + + if let Some(advice_inputs) = advice_inputs { + tx_context_signed_builder = tx_context_signed_builder.extend_advice_inputs(advice_inputs); + } + + if let Some(foreign_accounts) = foreign_accounts { + tx_context_signed_builder = tx_context_signed_builder.foreign_accounts(foreign_accounts); + } + + for signer_idx in signer_indices { + let sig = authenticators[*signer_idx] + .get_signature(public_keys[*signer_idx].to_commitment(), &tx_summary) + .await?; + + tx_context_signed_builder = tx_context_signed_builder.add_signature( + public_keys[*signer_idx].to_commitment(), + msg, + sig, + ); + } + + Ok(tx_context_signed_builder.build()?.execute().await) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_script_with_signers_at( + mock_chain: &MockChain, + reference_block: u32, + account_id: AccountId, + tx_script: TransactionScript, + salt: Word, + signer_indices: &[usize], + public_keys: &[PublicKey], + authenticators: &[BasicAuthenticator], + tx_script_args: Option, + advice_inputs: Option, + foreign_accounts: Option>, +) -> anyhow::Result> { + let mut tx_context_init_builder = mock_chain + .build_tx_context_at(reference_block, account_id, &[], &[])? + .tx_script(tx_script.clone()) + .auth_args(salt); + + if let Some(tx_script_args) = tx_script_args { + tx_context_init_builder = tx_context_init_builder.tx_script_args(tx_script_args); + } + + if let Some(advice_inputs) = advice_inputs.clone() { + tx_context_init_builder = tx_context_init_builder.extend_advice_inputs(advice_inputs); + } + + if let Some(foreign_accounts) = foreign_accounts.clone() { + tx_context_init_builder = tx_context_init_builder.foreign_accounts(foreign_accounts); + } + + let tx_summary = tx_context_init_builder + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + let mut tx_context_signed_builder = mock_chain + .build_tx_context_at(reference_block, account_id, &[], &[])? + .tx_script(tx_script) + .auth_args(salt); + + if let Some(tx_script_args) = tx_script_args { + tx_context_signed_builder = tx_context_signed_builder.tx_script_args(tx_script_args); + } + + if let Some(advice_inputs) = advice_inputs { + tx_context_signed_builder = tx_context_signed_builder.extend_advice_inputs(advice_inputs); + } + + if let Some(foreign_accounts) = foreign_accounts { + tx_context_signed_builder = tx_context_signed_builder.foreign_accounts(foreign_accounts); + } + + for signer_idx in signer_indices { + let sig = authenticators[*signer_idx] + .get_signature(public_keys[*signer_idx].to_commitment(), &tx_summary) + .await?; + + tx_context_signed_builder = tx_context_signed_builder.add_signature( + public_keys[*signer_idx].to_commitment(), + msg, + sig, + ); + } + + Ok(tx_context_signed_builder.build()?.execute().await) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_script_with_signers_at_and_outputs( + mock_chain: &MockChain, + reference_block: u32, + account_id: AccountId, + tx_script: TransactionScript, + expected_output_notes: Vec, + salt: Word, + signer_indices: &[usize], + public_keys: &[PublicKey], + authenticators: &[BasicAuthenticator], + foreign_accounts: Option>, +) -> anyhow::Result> { + let mut tx_context_init_builder = mock_chain + .build_tx_context_at(reference_block, account_id, &[], &[])? + .extend_expected_output_notes(expected_output_notes.clone()) + .tx_script(tx_script.clone()) + .auth_args(salt); + + if let Some(foreign_accounts) = foreign_accounts.clone() { + tx_context_init_builder = tx_context_init_builder.foreign_accounts(foreign_accounts); + } + + let tx_summary = tx_context_init_builder + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + let mut tx_context_signed_builder = mock_chain + .build_tx_context_at(reference_block, account_id, &[], &[])? + .extend_expected_output_notes(expected_output_notes) + .tx_script(tx_script) + .auth_args(salt); + + if let Some(foreign_accounts) = foreign_accounts { + tx_context_signed_builder = tx_context_signed_builder.foreign_accounts(foreign_accounts); + } + + for signer_idx in signer_indices { + let sig = authenticators[*signer_idx] + .get_signature(public_keys[*signer_idx].to_commitment(), &tx_summary) + .await?; + + tx_context_signed_builder = tx_context_signed_builder.add_signature( + public_keys[*signer_idx].to_commitment(), + msg, + sig, + ); + } + + Ok(tx_context_signed_builder.build()?.execute().await) +} + // ================================================================================================ // TESTS // ================================================================================================ +/// Tests basic 3-of-5 multisig functionality with note creation. +/// +/// This test verifies that a multisig account with 5 approvers and threshold 3 +/// can successfully execute a transaction that creates an output note when all +/// required signatures are provided. +/// +/// Spends 3 different assets from 3 different faucets and ensures spending limits are enforced. +/// +/// Spending 700 in total (limit 500) requires 1 signature. +/// +/// **Roles:** +/// - 5 Approvers (multisig signers) +/// - 1 Multisig Contract +/// - 3 Fungible Asset Faucets in Output Notes +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_spending_tiers_require_expected_signature_counts( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + // Each tx calls `move_asset_to_note` on the wallet, which has no per-procedure policy and + // therefore contributes the `default_threshold` (3) to the threshold accumulator. The + // effective required-signature count is therefore `max(tier_threshold, default_threshold)` + // — so tier1 (which would resolve to 2 on its own) is raised to 3 by the default. + let cases = [ + ("tier1", [500, 100, 100], 3usize), + ("tier2", [1000, 100, 100], 3usize), + ("tier3", [2000, 100, 100], 4usize), + ]; + + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?; + + for (case_name, [amount_asset_1, amount_asset_2, amount_asset_3], required_signatures) in cases + { + let mut multisig_account = create_multisig_smart_with_fixed_test_configuration_and_oracle( + 3, + &public_keys, + auth_scheme, + vec![], + &oracle_fixture, + )?; + + let mut mock_chain_builder = MockChainBuilder::with_accounts([ + multisig_account.clone(), + oracle_fixture.account.clone(), + ]) + .unwrap(); + + let (output_note_asset_1, output_note_asset_2, output_note_asset_3) = + create_assets_for_output_notes(amount_asset_1, amount_asset_2, amount_asset_3); + + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + output_note_asset_1.into(), + output_note_asset_2.into(), + output_note_asset_3.into(), + ], + NoteType::Public, + )?; + + let send_note_transaction_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + + let salt = Word::from([Felt::from(required_signatures as u32); 4]); + let mut mock_chain = mock_chain_builder.build()?; + + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_note_transaction_script) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + let mut signed_context = tx_context_builder; + + for signer_idx in 0..required_signatures { + let signature = authenticators[signer_idx] + .get_signature(public_keys[signer_idx].to_commitment(), &tx_summary) + .await?; + signed_context = signed_context.add_signature( + public_keys[signer_idx].to_commitment(), + msg, + signature, + ); + } + + let result = signed_context.build()?.execute().await; + assert!( + result.is_ok(), + "case {case_name:?} with {auth_scheme:?} should succeed with {required_signatures} signatures" + ); + + let result = result.unwrap(); + multisig_account.apply_delta(result.account_delta())?; + mock_chain.add_pending_executed_transaction(&result)?; + mock_chain.prove_next_block()?; + } + + Ok(()) +} + +#[test] +fn test_multisig_smart_oracle_config_is_stored_and_used() -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + + let multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 2, + &public_keys, + AuthScheme::EcdsaK256Keccak, + vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + 100, + )?], + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 2), + vec![], + &oracle_fixture, + )?; + + let [oracle_prefix, oracle_suffix] = oracle_fixture.oracle_id().as_felt_pair(); + assert_eq!( + multisig_account.storage().get_item(AuthMultisigSmart::oracle_config_slot())?, + Word::from([oracle_prefix, oracle_suffix, Felt::ZERO, Felt::ZERO]) + ); + assert_eq!( + multisig_account + .storage() + .get_item(AuthMultisigSmart::get_price_proc_root_slot())?, + oracle_fixture.get_price_proc_root + ); + + Ok(()) +} -/// A 3-of-3 multisig with a `receive_asset` procedure policy that lowers the threshold to 1 -/// should let a single-signature transaction that only calls `receive_asset` succeed. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_three_to_one_signature( +async fn test_multisig_smart_oracle_pricing_uses_foreign_value_instead_of_raw_amount( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::FixedPrice(1_200))?; let (_secret_keys, _auth_schemes, public_keys, authenticators) = setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; - let receive_asset_one_signature_policy = ProcedurePolicy::with_immediate_threshold(1)?; - let proc_policy_map = - vec![(BasicWallet::receive_asset_root().as_word(), receive_asset_one_signature_policy)]; - - let mut multisig_account = - create_multisig_smart_account(3, &public_keys, auth_scheme, 10, proc_policy_map)?; + let assets = vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 100, + )?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 1, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 3, 3), + vec![], + &oracle_fixture, + )?; let mut mock_chain_builder = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); - let note = mock_chain_builder.add_p2id_note( - multisig_account.id(), + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( multisig_account.id(), - &[FungibleAsset::mock(1)], + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 1)? + .into(), + ], NoteType::Public, )?; - let mut mock_chain = mock_chain_builder.build()?; - let salt = Word::from([Felt::new_unchecked(1); 4]); + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let salt = Word::from([Felt::from(77u32); 4]); + + let mock_chain = mock_chain_builder.build()?; let tx_context_builder = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) .auth_args(salt); let tx_summary = tx_context_builder @@ -115,54 +841,472 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr .unwrap_unauthorized_err(); let msg = tx_summary.as_ref().to_commitment(); - let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); - let one_signature = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary) .await?; - let tx_result = tx_context_builder - .add_signature(public_keys[0].to_commitment(), msg, one_signature) + let two_sig_result = tx_context_builder + .clone() + .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) + .add_signature(public_keys[1].to_commitment(), msg, sig_1.clone()) .build()? .execute() .await; - assert!( - tx_result.is_ok(), - "receive_asset policy threshold=1 should override the default 3-of-3 requirement" + matches!(two_sig_result, Err(TransactionExecutorError::Unauthorized(_))), + "oracle fixed-price response should escalate a raw amount of 1 into tier_2" ); - multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; - mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; - mock_chain.prove_next_block()?; + let sig_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary) + .await?; + let three_sig_result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, sig_0) + .add_signature(public_keys[1].to_commitment(), msg, sig_1) + .add_signature(public_keys[2].to_commitment(), msg, sig_2) + .build()? + .execute() + .await?; + + multisig_account.apply_delta(three_sig_result.account_delta())?; Ok(()) } -/// `enforce_note_restrictions` must abort transactions whose note layout violates the configured -/// policy bit set. The receive_asset proc policy carries each restriction variant and the tx -/// consumes a P2ID note (calls receive_asset). The test checks every variant against the -/// "tx has input notes" axis. #[rstest] -#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] -#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] -#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] -#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( - #[case] restriction: ProcedurePolicyNoteRestriction, +async fn test_multisig_smart_oracle_untracked_assets_do_not_raise_spending_tier( + #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::Untracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 2, auth_scheme)?; + + let assets = vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 5_000, + )?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 1, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(2, 2, 2, 2), + vec![], + &oracle_fixture, + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 2_500)? + .into(), + ], + NoteType::Public, + )?; + + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let salt = Word::from([Felt::from(78u32); 4]); + + let mock_chain = mock_chain_builder.build()?; + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + let sig = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary) + .await?; + + let result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, sig) + .build()? + .execute() + .await?; + + multisig_account.apply_delta(result.account_delta())?; + + Ok(()) +} + +#[tokio::test] +async fn test_multisig_smart_oracle_pricing_missing_foreign_account_fails() -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; let (_secret_keys, _auth_schemes, public_keys, _authenticators) = setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; - let multisig_account = create_multisig_smart_account( + let multisig_account = create_multisig_smart_account_with_assets_and_oracle( 2, &public_keys, AuthScheme::EcdsaK256Keccak, - 100, - vec![( - BasicWallet::receive_asset_root().as_word(), - ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), - )], + vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 1_000, + )?], + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 2), + vec![], + &oracle_fixture, + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + )?; + + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let mock_chain = mock_chain_builder.build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) + .auth_args(Word::from([Felt::from(79u32); 4])) + .build()? + .execute() + .await; + + assert!( + matches!(result, Err(TransactionExecutorError::TransactionProgramExecutionFailed(_))), + "oracle-priced spending should fail when the configured foreign oracle account is not provided" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_multisig_smart_oracle_pricing_wrong_proc_root_fails() -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + + let multisig_account = create_multisig_smart_account_with_assets_and_optional_oracle( + 2, + &public_keys, + AuthScheme::EcdsaK256Keccak, + vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 1_000, + )?], + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 2), + vec![], + Some(( + oracle_fixture.oracle_id(), + Word::from([ + Felt::from(999u32), + Felt::from(998u32), + Felt::from(997u32), + Felt::from(996u32), + ]), + )), + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + )?; + + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let mock_chain = mock_chain_builder.build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) + .auth_args(Word::from([Felt::from(80u32); 4])) + .build()? + .execute() + .await; + + assert!( + matches!(result, Err(TransactionExecutorError::TransactionProgramExecutionFailed(_))), + "oracle-priced spending should fail when the configured proc root does not exist" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_high_spending_escalates_above_default_threshold( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?; + + let assets = vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 5_000, + )?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 2, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 3, 5), + vec![], + &oracle_fixture, + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 2_500)? + .into(), + ], + NoteType::Public, + )?; + + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let salt = Word::from([Felt::from(11u32); 4]); + + let mut mock_chain = mock_chain_builder.build()?; + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary) + .await?; + let sig_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary) + .await?; + let sig_3 = authenticators[3] + .get_signature(public_keys[3].to_commitment(), &tx_summary) + .await?; + + let four_sig_result = tx_context_builder + .clone() + .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) + .add_signature(public_keys[1].to_commitment(), msg, sig_1.clone()) + .add_signature(public_keys[2].to_commitment(), msg, sig_2.clone()) + .add_signature(public_keys[3].to_commitment(), msg, sig_3.clone()) + .build()? + .execute() + .await; + assert!( + matches!(four_sig_result, Err(TransactionExecutorError::Unauthorized(_))), + "high spending should require the tier-3 threshold of 5 signatures" + ); + + let sig_4 = authenticators[4] + .get_signature(public_keys[4].to_commitment(), &tx_summary) + .await?; + let five_sig_result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, sig_0) + .add_signature(public_keys[1].to_commitment(), msg, sig_1) + .add_signature(public_keys[2].to_commitment(), msg, sig_2) + .add_signature(public_keys[3].to_commitment(), msg, sig_3) + .add_signature(public_keys[4].to_commitment(), msg, sig_4) + .build()? + .execute() + .await?; + + multisig_account.apply_delta(five_sig_result.account_delta())?; + mock_chain.add_pending_executed_transaction(&five_sig_result)?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_low_spending_uses_tier_threshold_instead_of_high_default( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?; + + let assets = vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, + 1_000, + )?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 4, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(2, 3, 4, 5), + vec![], + &oracle_fixture, + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap(); + let output_note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + &[ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 300)? + .into(), + ], + NoteType::Public, + )?; + + let tx_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + let salt = Word::from([Felt::from(12u32); 4]); + + let mut mock_chain = mock_chain_builder.build()?; + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(tx_script) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + + // With `default_threshold = 4`, the unpolicied `move_asset_to_note` call contributes the + // default to the threshold accumulator. The spending tier (2 for tier-0 amounts) is therefore + // floored by the default, so `default_threshold` signatures are still required. + let mut signatures = Vec::new(); + for idx in 0..4 { + signatures.push(( + public_keys[idx].to_commitment(), + authenticators[idx] + .get_signature(public_keys[idx].to_commitment(), &tx_summary) + .await?, + )); + } + + let three_sig_result = tx_context_builder + .clone() + .add_signature(signatures[0].0, msg, signatures[0].1.clone()) + .add_signature(signatures[1].0, msg, signatures[1].1.clone()) + .add_signature(signatures[2].0, msg, signatures[2].1.clone()) + .build()? + .execute() + .await; + assert!( + matches!(three_sig_result, Err(TransactionExecutorError::Unauthorized(_))), + "three signatures should remain insufficient when default_threshold=4 floors the tier" + ); + + let mut signed_context = tx_context_builder; + for (pk, sig) in &signatures { + signed_context = signed_context.add_signature(*pk, msg, sig.clone()); + } + let four_sig_result = signed_context.build()?.execute().await?; + + multisig_account.apply_delta(four_sig_result.account_delta())?; + mock_chain.add_pending_executed_transaction(&four_sig_result)?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +/// Mirrors the base multisig override behavior for smart multisig: even if the account default +/// threshold is 3-of-3, a `receive_asset` immediate-only policy with threshold 1 must allow the +/// note-consumption path to execute with a single signature. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_three_to_one_signature( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + + let receive_asset_one_signature_policy = ProcedurePolicy::with_immediate_threshold(1)?; + let proc_policy_map = + vec![(BasicWallet::receive_asset_root().as_word(), receive_asset_one_signature_policy)]; + + let assets = + vec![FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 10)?]; + let mut multisig_account = create_multisig_smart_account_with_assets( + 3, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(3, 3, 3, 3), + proc_policy_map, )?; let mut mock_chain_builder = @@ -173,165 +1317,1492 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( &[FungibleAsset::mock(1)], NoteType::Public, )?; - let mock_chain = mock_chain_builder.build()?; + let mut mock_chain = mock_chain_builder.build()?; + + let salt = Word::from([Felt::new_unchecked(11); 4]); + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); + let one_signature = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + + let tx_result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, one_signature) + .build()? + .execute() + .await; + + assert!( + tx_result.is_ok(), + "receive_asset policy threshold=1 should override the default 3-of-3 requirement" + ); + + multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; + mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 2, auth_scheme)?; + let multisig_account = create_multisig_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmartPresets::update_delayed_execution_policy(), + ProcedurePolicy::with_delay_threshold(1)?, + )], + )?; + let account_id = multisig_account.id(); + let mock_chain = MockChainBuilder::with_accounts([multisig_account]).unwrap().build()?; + + let update_timelock_script = compile_multisig_smart_tx_script( + " + begin + push.2 + push.40 + call.::miden::standards::components::auth::multisig_smart::update_delayed_execution_policy + drop + drop + end + ", + )?; + + let blind_inputs = SigningInputs::Blind(Word::from([Felt::from(900u32); 4])); + let blind_msg = blind_inputs.to_commitment(); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &blind_inputs) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &blind_inputs) + .await?; + + let result = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(update_timelock_script) + .auth_args(Word::from([Felt::from(901u32); 4])) + .add_signature(public_keys[0].to_commitment(), blind_msg, sig_0) + .add_signature(public_keys[1].to_commitment(), blind_msg, sig_1) + .build()? + .execute() + .await; + + match result { + Err(TransactionExecutorError::TransactionProgramExecutionFailed(_)) => {}, + Err(err) => panic!("expected transaction program failure, got: {err}"), + Ok(_) => panic!("execution was unexpectedly successful"), + } + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_delayed_only_execute_lane_still_returns_tx_summary_on_dry_run( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let multisig_account = create_multisig_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmartPresets::update_delayed_execution_policy(), + ProcedurePolicy::with_delay_threshold(1)?, + )], + )?; + let account_id = multisig_account.id(); + let mock_chain = MockChainBuilder::with_accounts([multisig_account]).unwrap().build()?; + + let execute_update_timelock_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::execute_proposed_transaction + push.2 + push.40 + call.::miden::standards::components::auth::multisig_smart::update_delayed_execution_policy + drop + drop + end + ", + )?; + + let result = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(execute_update_timelock_script) + .auth_args(Word::from([Felt::from(902u32); 4])) + .build()? + .execute() + .await; + + match result { + Err(TransactionExecutorError::Unauthorized(_)) => Ok(()), + error => panic!("expected unauthorized dry-run with tx summary, got: {error:?}"), + } +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let multisig_account = create_multisig_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(1)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes), + )], + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = mock_chain_builder.build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(Word::from([Felt::from(903u32); 4])) + .build()? + .execute() + .await; + + // Foundation refactor split the combined input-or-output assertion into two separate + // assert procs; the input check fires first when an input note is present. + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_low_spending_send_note_uses_tier_threshold_over_default( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + + let assets = + vec![FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 10)?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 2, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 2), + vec![], + &oracle_fixture, + )?; + + let output_note = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![FungibleAsset::mock(5)], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(42u32); 4])), + )?; + let send_note_transaction_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap() + .build()?; + let salt = Word::from([Felt::from(2u32); 4]); + + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_note_transaction_script) + .auth_args(salt); + + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + // With `default_threshold = 2`, the unpolicied `move_asset_to_note` contributes the default + // to the threshold accumulator, so the effective requirement is `max(tier_0=1, default=2)=2`. + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary) + .await?; + + let result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, sig_0) + .add_signature(public_keys[1].to_commitment(), msg, sig_1) + .build()? + .execute() + .await; + + assert!(result.is_ok(), "two signatures should satisfy max(tier_0=1, default=2)"); + + multisig_account.apply_delta(result.as_ref().unwrap().account_delta())?; + mock_chain.add_pending_executed_transaction(&result.unwrap())?; + mock_chain.prove_next_block()?; + + assert_eq!( + multisig_account.vault().get_balance(FungibleAsset::mock(0).vault_key())?, + miden_protocol::asset::AssetAmount::new(5).unwrap() + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_spending_window_boundary_resets_spending_tracker( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?; + let mut multisig_account = create_multisig_smart_with_fixed_test_configuration_and_oracle( + 3, + &public_keys, + auth_scheme, + vec![], + &oracle_fixture, + )?; + + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap() + .build()?; + + let output_note_1 = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(101u32); 4])), + )?; + let script_1 = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note_1.clone().into()], None)?; + let salt_1 = Word::from([Felt::from(201u32); 4]); + + let tx_summary_1 = match mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_1.clone())]) + .tx_script(script_1.clone()) + .auth_args(salt_1) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let msg_1 = tx_summary_1.as_ref().to_commitment(); + let tx_summary_1 = SigningInputs::TransactionSummary(tx_summary_1); + // `move_asset_to_note` has no per-procedure policy, so the unpolicied default contributes + // `default_threshold` (3) to the threshold accumulator. The tier-1 spending threshold (2) is + // therefore floored by the default — three signatures are required even for the first send. + let sig_1_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_1) + .await?; + let sig_1_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_1) + .await?; + let sig_1_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary_1) + .await?; + + let tx_1 = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_1)]) + .tx_script(script_1) + .auth_args(salt_1) + .add_signature(public_keys[0].to_commitment(), msg_1, sig_1_0) + .add_signature(public_keys[1].to_commitment(), msg_1, sig_1_1) + .add_signature(public_keys[2].to_commitment(), msg_1, sig_1_2) + .build()? + .execute() + .await?; + multisig_account.apply_delta(tx_1.account_delta())?; + mock_chain.add_pending_executed_transaction(&tx_1)?; + mock_chain.prove_next_block()?; + + let output_note_2 = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(102u32); 4])), + )?; + let script_2 = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note_2.clone().into()], None)?; + let salt_2 = Word::from([Felt::from(202u32); 4]); + + let tx_summary_2 = match mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_2.clone())]) + .tx_script(script_2.clone()) + .auth_args(salt_2) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let msg_2 = tx_summary_2.as_ref().to_commitment(); + let tx_summary_2 = SigningInputs::TransactionSummary(tx_summary_2); + let sig_2_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_2) + .await?; + let sig_2_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_2) + .await?; + + let tx_2_with_two_sigs = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_2)]) + .tx_script(script_2) + .auth_args(salt_2) + .add_signature(public_keys[0].to_commitment(), msg_2, sig_2_0) + .add_signature(public_keys[1].to_commitment(), msg_2, sig_2_1) + .build()? + .execute() + .await; + assert!( + matches!(tx_2_with_two_sigs, Err(TransactionExecutorError::Unauthorized(_))), + "second transfer in the same spending window should require a higher tier" + ); + + for _ in 0..11 { + mock_chain.prove_next_block()?; + } + + let output_note_3 = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(103u32); 4])), + )?; + let script_3 = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note_3.clone().into()], None)?; + let salt_3 = Word::from([Felt::from(203u32); 4]); + + let tx_summary_3 = match mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_3.clone())]) + .tx_script(script_3.clone()) + .auth_args(salt_3) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let msg_3 = tx_summary_3.as_ref().to_commitment(); + let tx_summary_3 = SigningInputs::TransactionSummary(tx_summary_3); + // After the spending window reset the tracker shows tier-1 spending again; the default + // threshold still floors the required sigs to three (same as tx_1 above). + let sig_3_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_3) + .await?; + let sig_3_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_3) + .await?; + let sig_3_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary_3) + .await?; + + let tx_3 = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note_3)]) + .tx_script(script_3) + .auth_args(salt_3) + .add_signature(public_keys[0].to_commitment(), msg_3, sig_3_0) + .add_signature(public_keys[1].to_commitment(), msg_3, sig_3_1) + .add_signature(public_keys[2].to_commitment(), msg_3, sig_3_2) + .build()? + .execute() + .await?; + multisig_account.apply_delta(tx_3.account_delta())?; + + let spending_tracker = multisig_account + .storage() + .get_item(AuthMultisigSmart::spending_tracker_slot())?; + assert_eq!( + spending_tracker[0], + Felt::from(700u32), + "amount_spent_in_window should restart from the transaction amount after a window reset" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_update_spending_window_policy_resets_tracker( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 2, auth_scheme)?; + let mut multisig_account = create_multisig_smart_with_fixed_test_configuration_and_oracle( + 2, + &public_keys, + auth_scheme, + vec![], + &oracle_fixture, + )?; + + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap() + .build()?; + + // Seed spending tracker with a successful transfer. + let output_note = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![ + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1)?, 700)? + .into(), + ], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(111u32); 4])), + )?; + let send_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + + let send_salt = Word::from([Felt::from(211u32); 4]); + let send_tx_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + .tx_script(send_script.clone()) + .auth_args(send_salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let send_msg = send_tx_summary.as_ref().to_commitment(); + let send_tx_summary = SigningInputs::TransactionSummary(send_tx_summary); + let send_sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &send_tx_summary) + .await?; + let send_sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &send_tx_summary) + .await?; + + let send_tx = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_script) + .auth_args(send_salt) + .add_signature(public_keys[0].to_commitment(), send_msg, send_sig_0) + .add_signature(public_keys[1].to_commitment(), send_msg, send_sig_1) + .build()? + .execute() + .await?; + multisig_account.apply_delta(send_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&send_tx)?; + mock_chain.prove_next_block()?; + + let spending_tracker_before = multisig_account + .storage() + .get_item(AuthMultisigSmart::spending_tracker_slot())?; + assert_eq!( + spending_tracker_before[0], + Felt::from(700u32), + "seed transfer should update tracker before policy update", + ); + + let update_window_script = compile_multisig_smart_tx_script( + " + begin + push.60 + call.::miden::standards::components::auth::multisig_smart::update_spending_window_policy + dropw dropw dropw dropw dropw + end + ", + )?; + + let update_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + update_window_script, + Word::from([Felt::from(212u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("window update should succeed"); + multisig_account.apply_delta(update_tx.account_delta())?; + + let spending_window = + multisig_account.storage().get_item(AuthMultisigSmart::spending_window_slot())?; + assert_eq!(spending_window, Word::from([60u32, 0, 0, 0])); + + let spending_tracker_after = multisig_account + .storage() + .get_item(AuthMultisigSmart::spending_tracker_slot())?; + assert_eq!( + spending_tracker_after, + Word::empty(), + "spending tracker must be reset when spending window policy changes", + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_pending_actions_are_mutually_exclusive( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let mut multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let pending_propose_hash = + Word::from([Felt::from(11u32), Felt::from(22u32), Felt::from(33u32), Felt::from(44u32)]); + let pending_cancel_hash = + Word::from([Felt::from(55u32), Felt::from(66u32), Felt::from(77u32), Felt::from(88u32)]); + + let propose_twice_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_propose_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + push.{pending_propose_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(propose_twice_script) + .auth_args(Word::from([Felt::from(301u32); 4])) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_PENDING_ALREADY_SET); + + let propose_once_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_cancel_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + propose_once_script, + Word::from([Felt::from(302u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("proposal setup transaction should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + let cancel_twice_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_cancel_hash} + call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal + push.{pending_cancel_hash} + call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal + dropw dropw dropw dropw dropw + end + " + ))?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(cancel_twice_script) + .auth_args(Word::from([Felt::from(303u32); 4])) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_PENDING_ALREADY_SET); + + let execute_twice_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::execute_proposed_transaction + call.::miden::standards::components::auth::multisig_smart::execute_proposed_transaction + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(execute_twice_script) + .auth_args(Word::from([Felt::from(304u32); 4])) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_PENDING_ALREADY_SET); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_proposal_stores_unlock_timestamp_and_enforces_min_delay( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let oracle_fixture = build_test_oracle_fixture(TestOracleMode::OneToOneTracked)?; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + + let assets = vec![FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + 5_000, + )?]; + let mut multisig_account = create_multisig_smart_account_with_assets_and_oracle( + 2, + &public_keys, + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 2, 3), + vec![], + &oracle_fixture, + )?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone(), oracle_fixture.account.clone()]) + .unwrap() + .build()?; + + let output_note = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![FungibleAsset::mock(1_600)], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::from(420u32); 4])), + )?; + let partial_output_note: PartialNote = output_note.clone().into(); + let execute_script = compile_timelocked_send_note_script(&partial_output_note)?; + let execute_salt = Word::from([Felt::from(421u32); 4]); + + let proposal_reference_block = mock_chain.latest_block_header().block_num().as_u32(); + let proposal_reference_timestamp = + mock_chain.block_header(proposal_reference_block as usize).timestamp(); + + let execute_summary = match mock_chain + .build_tx_context_at(proposal_reference_block, multisig_account.id(), &[], &[])? + .foreign_accounts(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?) + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note.clone())]) + .tx_script(execute_script.clone()) + .auth_args(execute_salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let execute_tx_hash = execute_summary.as_ref().to_commitment(); + + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{execute_tx_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers_at( + &mock_chain, + proposal_reference_block, + multisig_account.id(), + propose_script, + Word::from([Felt::from(422u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("proposal transaction should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + let proposal_entry = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), execute_tx_hash)?; + assert_eq!( + proposal_entry, + Word::from([proposal_reference_timestamp + 30, proposal_reference_timestamp, 2u32, 1u32,]), + "proposal entry should store [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set]" + ); + + let early_attempt_block = proposal_reference_block + 1; + let early_execute_err = execute_script_with_signers_at_and_outputs( + &mock_chain, + early_attempt_block, + multisig_account.id(), + execute_script.clone(), + vec![RawOutputNote::Full(output_note.clone())], + execute_salt, + &[0, 1], + &public_keys, + &authenticators, + Some(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?), + ) + .await?; + assert_transaction_executor_error!(early_execute_err, ERR_TX_STILL_TIMELOCKED); + + let unlock_offset_blocks = 30 / MockChain::TIMESTAMP_STEP_SECS; + let ready_block = proposal_reference_block + unlock_offset_blocks; + while mock_chain.latest_block_header().block_num().as_u32() < ready_block { + mock_chain.prove_next_block()?; + } + + let execute_tx = execute_script_with_signers_at_and_outputs( + &mock_chain, + ready_block, + multisig_account.id(), + execute_script, + vec![RawOutputNote::Full(output_note.clone())], + execute_salt, + &[0, 1], + &public_keys, + &authenticators, + Some(test_oracle_foreign_account_inputs(&mock_chain, &oracle_fixture)?), + ) + .await? + .expect("execute transaction should succeed once unlock timestamp is reached"); + multisig_account.apply_delta(execute_tx.account_delta())?; + + let proposal_entry_after = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), execute_tx_hash)?; + assert_eq!( + proposal_entry_after, + Word::empty(), + "proposal should be removed after successful execution" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_stale_proposal_reference_expires_before_inclusion( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + let multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + for _ in 0..5 { + mock_chain.prove_next_block()?; + } + + let stale_reference_block = 1u32; + let proposal_hash = + Word::from([Felt::from(71u32), Felt::from(72u32), Felt::from(73u32), Felt::from(74u32)]); + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{proposal_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + + let proposal_tx = execute_script_with_signers_at( + &mock_chain, + stale_reference_block, + multisig_account.id(), + propose_script, + Word::from([Felt::from(423u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("proposal transaction should still execute against its stale reference block"); + + mock_chain.add_pending_executed_transaction(&proposal_tx)?; + let prove_err = mock_chain.prove_next_block().unwrap_err(); + assert!( + prove_err.to_string().contains("expires at block number"), + "proposal with a reference block older than propose_expiration_delta should expire before inclusion: {prove_err}" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_execute_proposal_without_timelock_requirement( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + let mut multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let execute_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::execute_proposed_transaction + dropw dropw dropw dropw dropw + end + ", + )?; + let execute_salt = Word::from([Felt::from(401u32); 4]); + + let execute_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(execute_script.clone()) + .auth_args(execute_salt) + .build()? + .execute() + .await + .unwrap_err() + { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => panic!("expected abort with tx effects: {error:?}"), + }; + let execute_tx_hash = execute_summary.as_ref().to_commitment(); + + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{execute_tx_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + propose_script, + Word::from([Felt::from(402u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("proposal transaction should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + let execute_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + execute_script, + execute_salt, + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("execute transaction should succeed without timelock enforcement"); + multisig_account.apply_delta(execute_tx.account_delta())?; + + let pending_execute = + multisig_account.storage().get_item(AuthMultisigSmart::pending_execute_slot())?; + assert_eq!(pending_execute, Word::empty(), "pending_execute should be cleared"); + + let proposal_entry = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), execute_tx_hash)?; + assert_eq!( + proposal_entry, + Word::empty(), + "proposal should be removed when execute flow finalizes" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_cancel_requires_min_cancel_signatures_exact_boundary( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let mut multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let proposal_hash = + Word::from([Felt::from(91u32), Felt::from(92u32), Felt::from(93u32), Felt::from(94u32)]); + + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{proposal_hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + propose_script, + Word::from([Felt::from(501u32); 4]), + &[0, 1, 2], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("proposal transaction should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + let cancel_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{proposal_hash} + call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal + dropw dropw dropw dropw dropw + end + " + ))?; + + let insufficient_cancel = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + cancel_script.clone(), + Word::from([Felt::from(502u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await?; + assert_transaction_executor_error!(insufficient_cancel, ERR_CANCEL_INSUFFICIENT_SIGNATURES); + + let exact_cancel = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + cancel_script, + Word::from([Felt::from(503u32); 4]), + &[0, 1, 2], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("cancel should succeed when num_verified == min_cancel_sigs"); + multisig_account.apply_delta(exact_cancel.account_delta())?; + + let proposal_entry = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), proposal_hash)?; + assert_eq!( + proposal_entry, + Word::empty(), + "proposal should be deleted after successful cancel" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_update_signers_shrinks_and_re_expands_with_scheme_map_integrity( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_initial_secret_keys, initial_schemes, initial_public_keys, initial_authenticators) = + setup_keys_and_authenticators_with_scheme(5, 5, auth_scheme)?; + + let initial_approvers = initial_public_keys + .iter() + .zip(initial_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let mut multisig_account = + create_multisig_account_with_schemes(4, &initial_approvers, 10, vec![])?; + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let update_signers_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::update_signers_and_threshold + end + ", + )?; + + let shrink_threshold = 2u64; + let shrink_num_approvers = 2u64; + let shrink_keys = &initial_public_keys[0..2]; + let shrink_data = build_update_signers_config_vector( + shrink_threshold, + shrink_num_approvers, + shrink_keys, + auth_scheme, + ); + let shrink_hash = Hasher::hash_elements(&shrink_data); + let mut shrink_advice_map = AdviceMap::default(); + shrink_advice_map.insert(shrink_hash, shrink_data); + let shrink_advice_inputs = AdviceInputs { + map: shrink_advice_map, + ..Default::default() + }; + + let shrink_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + update_signers_script.clone(), + Word::from([Felt::from(601u32); 4]), + &[0, 1, 2, 3], + &initial_public_keys, + &initial_authenticators, + Some(shrink_hash), + Some(shrink_advice_inputs), + None, + ) + .await? + .expect("shrink update should succeed"); + multisig_account.apply_delta(shrink_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&shrink_tx)?; + mock_chain.prove_next_block()?; + + let initial_scheme_word = + Word::from([Felt::from(auth_scheme as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + for (idx, expected_key) in shrink_keys.iter().enumerate() { + let storage_key = [Felt::from(idx as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let expected_key_word: Word = expected_key.to_commitment().into(); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key)?, + expected_key_word + ); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_scheme_ids_slot(), storage_key)?, + initial_scheme_word + ); + } + for idx in 2..5 { + let storage_key = [Felt::from(idx as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key)?, + Word::empty(), + "public key slot {idx} should be cleared after shrink" + ); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_scheme_ids_slot(), storage_key)?, + Word::empty(), + "scheme slot {idx} should be cleared after shrink" + ); + } + + let new_scheme = match auth_scheme { + AuthScheme::EcdsaK256Keccak => AuthScheme::Falcon512Poseidon2, + AuthScheme::Falcon512Poseidon2 => AuthScheme::EcdsaK256Keccak, + _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), + }; + + let (_new_secret_keys, _new_schemes, expanded_public_keys, _new_authenticators) = + setup_keys_and_authenticators_with_scheme(4, 0, new_scheme)?; + let expand_threshold = 3u64; + let expand_num_approvers = 4u64; + let expand_data = build_update_signers_config_vector( + expand_threshold, + expand_num_approvers, + &expanded_public_keys, + new_scheme, + ); + let expand_hash = Hasher::hash_elements(&expand_data); + let mut expand_advice_map = AdviceMap::default(); + expand_advice_map.insert(expand_hash, expand_data); + let expand_advice_inputs = AdviceInputs { + map: expand_advice_map, + ..Default::default() + }; + + let expand_tx = execute_script_with_signers( + &mock_chain, + multisig_account.id(), + update_signers_script, + Word::from([Felt::from(602u32); 4]), + &[0, 1], + &initial_public_keys, + &initial_authenticators, + Some(expand_hash), + Some(expand_advice_inputs), + None, + ) + .await? + .expect("expand update should succeed"); + multisig_account.apply_delta(expand_tx.account_delta())?; + + let expanded_scheme_word = + Word::from([Felt::from(new_scheme as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO]); + for (idx, expected_key) in expanded_public_keys.iter().enumerate() { + let storage_key = [Felt::from(idx as u32), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + let expected_key_word: Word = expected_key.to_commitment().into(); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key)?, + expected_key_word + ); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_scheme_ids_slot(), storage_key)?, + expanded_scheme_word + ); + } + + let stale_index_key = [Felt::from(4u32), Felt::ZERO, Felt::ZERO, Felt::ZERO].into(); + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), stale_index_key)?, + Word::empty(), + "old stale key index should remain empty after re-expansion" + ); + + assert_eq!( + multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_scheme_ids_slot(), stale_index_key)?, + Word::empty(), + "old stale scheme index should remain empty after re-expansion" + ); + + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_update_signers_uses_current_num_approvers_for_policy_validation( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let multisig_account = create_multisig_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(2)?, + )], + )?; + let account_id = multisig_account.id(); + let mock_chain = MockChainBuilder::with_accounts([multisig_account]).unwrap().build()?; + + let update_signers_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::update_signers_and_threshold + end + ", + )?; + + let shrink_data = build_update_signers_config_vector(1, 1, &public_keys[0..1], auth_scheme); + let shrink_hash = Hasher::hash_elements(&shrink_data); + let mut advice_map = AdviceMap::default(); + advice_map.insert(shrink_hash, shrink_data); + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; + + let blind_inputs = SigningInputs::Blind(Word::from([Felt::from(906u32); 4])); + let blind_msg = blind_inputs.to_commitment(); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &blind_inputs) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &blind_inputs) + .await?; let result = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(Word::from([Felt::new_unchecked(2); 4])) + .build_tx_context(account_id, &[], &[])? + .tx_script(update_signers_script) + .tx_script_args(shrink_hash) + .auth_args(Word::from([Felt::from(905u32); 4])) + .extend_advice_inputs(advice_inputs) + .add_signature(public_keys[0].to_commitment(), blind_msg, sig_0) + .add_signature(public_keys[1].to_commitment(), blind_msg, sig_1) .build()? .execute() .await; - // For restrictions that include the input bit (1, 3), enforce_note_restrictions panics with - // the input-notes error before signatures are even checked. For the other variants the input - // bit is unset, so the tx falls through to signature verification and aborts there - // (no signatures were provided). The output bit (2) does not trigger because the tx has no - // output notes. - match restriction { - ProcedurePolicyNoteRestriction::NoInputNotes - | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { - assert_transaction_executor_error!( - result, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES - ); - }, - ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoOutputNotes => { - result.unwrap_err().unwrap_unauthorized_err(); - }, + match result { + Err(TransactionExecutorError::TransactionProgramExecutionFailed(_)) => {}, + Err(err) => panic!("expected transaction program failure, got: {err}"), + Ok(_) => panic!("execution was unexpectedly successful"), } Ok(()) } -/// Mirror of the input-notes test for the output-notes axis. The policy lives on -/// `move_asset_to_note` (the BasicWallet proc invoked when sending notes) and the tx creates a -/// P2ID output note rather than consuming one. #[rstest] -#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] -#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] -#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] -#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( - #[case] restriction: ProcedurePolicyNoteRestriction, +async fn test_multisig_smart_proc_threshold_override_dominates_spending_tier( + #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { - use miden_processor::crypto::random::RandomCoin; - use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; - use miden_protocol::transaction::RawOutputNote; - use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; - use miden_standards::note::P2idNote; + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let proc_policy_map = vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(4)?, + )]; - let (_secret_keys, _auth_schemes, public_keys, _authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + let assets = + vec![FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, 10)?]; - let multisig_account = create_multisig_smart_account( + let multisig_account = create_multisig_smart_account_with_assets( 2, &public_keys, - AuthScheme::EcdsaK256Keccak, - 100, - vec![( - BasicWallet::move_asset_to_note_root().as_word(), - ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), - )], + auth_scheme, + assets, + AmountLimits::new(500, 1000, 2000, 1500), + TierThresholds::new(1, 2, 3, 4), + proc_policy_map, )?; - let output_note = P2idNote::create( + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( multisig_account.id(), - ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), - vec![FungibleAsset::mock(5)], + multisig_account.id(), + &[FungibleAsset::mock(1)], NoteType::Public, - Default::default(), - &mut RandomCoin::new(Word::from([Felt::new_unchecked(7); 4])), )?; + let mock_chain = mock_chain_builder.build()?; - let send_note_script = AccountInterface::from_account(&multisig_account) - .build_send_notes_script(&[output_note.clone().into()], None)?; + let salt = Word::from([Felt::new_unchecked(701); 4]); + let tx_context_builder = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(salt); - let mock_chain = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + let tx_summary = tx_context_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); - let result = mock_chain - .build_tx_context(multisig_account.id(), &[], &[])? - .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) - .tx_script(send_note_script) - .auth_args(Word::from([Felt::new_unchecked(2); 4])) + let msg = tx_summary.as_ref().to_commitment(); + let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary.clone()); + + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &tx_summary_signing) + .await?; + let sig_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary_signing) + .await?; + + let three_sig_result = tx_context_builder + .clone() + .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) + .add_signature(public_keys[1].to_commitment(), msg, sig_1.clone()) + .add_signature(public_keys[2].to_commitment(), msg, sig_2.clone()) .build()? .execute() .await; + assert!( + matches!(three_sig_result, Err(TransactionExecutorError::Unauthorized(_))), + "proc threshold override should dominate and reject 3 signatures when override is 4" + ); - // For restrictions that include the output bit (2, 3), enforce_note_restrictions panics with - // the output-notes error after the input check passes. For the other variants neither check - // trips and the tx falls through to signature verification (no signatures were provided). - match restriction { - ProcedurePolicyNoteRestriction::NoOutputNotes - | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { - assert_transaction_executor_error!( - result, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES - ); - }, - ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoInputNotes => { - result.unwrap_err().unwrap_unauthorized_err(); - }, - } + let sig_3 = authenticators[3] + .get_signature(public_keys[3].to_commitment(), &tx_summary_signing) + .await?; + let four_sig_result = tx_context_builder + .add_signature(public_keys[0].to_commitment(), msg, sig_0) + .add_signature(public_keys[1].to_commitment(), msg, sig_1) + .add_signature(public_keys[2].to_commitment(), msg, sig_2) + .add_signature(public_keys[3].to_commitment(), msg, sig_3) + .build()? + .execute() + .await; + assert!( + four_sig_result.is_ok(), + "transaction should succeed with 4 signatures due to proc override" + ); Ok(()) } -/// Tests `update_signers_and_threshold`: a 2-of-2 multisig is rotated to a 4-of-3 -/// signer set with new public keys. The new threshold and signers are persisted in storage. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_update_signers_and_thresholds( +async fn test_multisig_smart_zero_output_notes_do_not_update_spending_tracker( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + let mut multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + + let initial_tracker = multisig_account + .storage() + .get_item(AuthMultisigSmart::spending_tracker_slot())?; - let mut multisig_account = - create_multisig_smart_account(2, &public_keys, auth_scheme, 10, vec![])?; - let account_id = multisig_account.id(); let mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - // Generate a fresh 4-signer set; rotate the multisig to 4-of-3 (threshold=3, num_approvers=4). - let (_new_secret_keys, _new_auth_schemes, new_public_keys, _new_authenticators) = - setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; - - let new_threshold: u64 = 3; - let new_num_approvers: u64 = 4; - let multisig_config_data = build_update_signers_config_vector( - new_threshold, - new_num_approvers, - &new_public_keys, - auth_scheme, - ); - let multisig_config_hash = Hasher::hash_elements(&multisig_config_data); - - let mut advice_map = AdviceMap::default(); - advice_map.insert(multisig_config_hash, multisig_config_data); - let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; - - let update_signers_script = compile_multisig_smart_tx_script( - " - begin - call.::miden::standards::components::auth::multisig_smart::update_signers_and_threshold - end - ", - )?; - - let salt = Word::from([Felt::new_unchecked(3); 4]); - - let tx_context_builder = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(update_signers_script) - .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs) - .auth_args(salt); + let salt = Word::from([Felt::from(801u32); 4]); + let tx_context_builder = + mock_chain.build_tx_context(multisig_account.id(), &[], &[])?.auth_args(salt); - // Dry-run to obtain the tx summary that the current approvers must sign. let tx_summary = tx_context_builder .clone() .build()? @@ -339,241 +2810,278 @@ async fn test_multisig_smart_update_signers_and_thresholds( .await .unwrap_err() .unwrap_unauthorized_err(); - let msg = tx_summary.as_ref().to_commitment(); - let signing_inputs = SigningInputs::TransactionSummary(tx_summary); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .get_signature(public_keys[0].to_commitment(), &tx_summary) .await?; let sig_1 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &signing_inputs) + .get_signature(public_keys[1].to_commitment(), &tx_summary) .await?; - let executed_tx = tx_context_builder + let tx = tx_context_builder .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? .execute() .await?; + multisig_account.apply_delta(tx.account_delta())?; - multisig_account.apply_delta(executed_tx.account_delta())?; - - // Verify the new threshold/num_approvers config is persisted. - let threshold_config = multisig_account + let tracker_after = multisig_account .storage() - .get_item(AuthMultisigSmart::threshold_config_slot()) - .expect("threshold config slot should be present"); - assert_eq!(threshold_config[0], Felt::new_unchecked(new_threshold)); - assert_eq!(threshold_config[1], Felt::new_unchecked(new_num_approvers)); - - // Verify each new public key is stored at its expected map index. - for (i, expected_key) in new_public_keys.iter().enumerate() { - let storage_key = Word::from([i as u32, 0, 0, 0]); - let stored_pub_key = multisig_account - .storage() - .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key) - .expect("approver public key map item should be present"); - let expected_word: Word = expected_key.to_commitment().into(); - assert_eq!(stored_pub_key, expected_word, "public key at index {i} mismatch"); - } + .get_item(AuthMultisigSmart::spending_tracker_slot())?; + assert_eq!( + tracker_after, initial_tracker, + "spending tracker must remain unchanged for zero-output transactions" + ); Ok(()) } -/// `set_procedure_policy` invoked from a transaction script must persist the policy to the -/// `procedure_policies` storage map so subsequent transactions see the new policy. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_set_procedure_policy( +async fn test_multisig_smart_replay_protection_same_tx_different_signer_subset( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; - // Account starts with no procedure policies configured. - let mut multisig_account = - create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; - let account_id = multisig_account.id(); - let mock_chain = + let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - let receive_asset_root = BasicWallet::receive_asset_root().as_word(); - let immediate_threshold = 1u32; - let delayed_threshold = 0u32; - let note_restrictions = ProcedurePolicyNoteRestriction::NoInputNotes; - // `call.` does not consume operand-stack inputs (the procedure sees a snapshot, the caller's - // stack is preserved across the boundary), so we must manually drop the 7 elements we pushed. - let set_policy_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{root} - push.{note_restrictions} - push.{delayed_threshold} - push.{immediate_threshold} - call.::miden::standards::components::auth::multisig_smart::set_procedure_policy - drop drop drop # immediate, delayed, note_restrictions - dropw # PROC_ROOT - end - ", - root = receive_asset_root, - note_restrictions = note_restrictions as u8, - delayed_threshold = delayed_threshold, - immediate_threshold = immediate_threshold, - ))?; - - let salt = Word::from([Felt::new_unchecked(4); 4]); - - let tx_context_builder = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(set_policy_script) - .auth_args(salt); - - // Dry-run to obtain the tx summary that the approvers must sign. - let tx_summary = tx_context_builder - .clone() + let salt = Word::from([Felt::from(901u32); 4]); + let tx_summary = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .auth_args(salt) .build()? .execute() .await .unwrap_err() .unwrap_unauthorized_err(); - let msg = tx_summary.as_ref().to_commitment(); - let signing_inputs = SigningInputs::TransactionSummary(tx_summary); + let tx_summary = SigningInputs::TransactionSummary(tx_summary); + let sig_0 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .get_signature(public_keys[0].to_commitment(), &tx_summary) .await?; let sig_1 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &signing_inputs) + .get_signature(public_keys[1].to_commitment(), &tx_summary) + .await?; + let sig_2 = authenticators[2] + .get_signature(public_keys[2].to_commitment(), &tx_summary) + .await?; + let sig_3 = authenticators[3] + .get_signature(public_keys[3].to_commitment(), &tx_summary) .await?; - let executed_tx = tx_context_builder + let first_execution = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .auth_args(salt) .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? .execute() - .await?; + .await + .expect("first execution should succeed"); - multisig_account.apply_delta(executed_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&first_execution)?; + mock_chain.prove_next_block()?; - // Policy word layout: [immediate, delayed, note_restrictions, 0] - let stored_policy = multisig_account - .storage() - .get_map_item(AuthMultisigSmart::procedure_policies_slot(), receive_asset_root) - .expect("procedure policies slot should be present"); - assert_eq!( - stored_policy, - Word::from([immediate_threshold, delayed_threshold, note_restrictions as u32, 0]) - ); + let replay_result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .auth_args(salt) + .add_signature(public_keys[2].to_commitment(), msg, sig_2) + .add_signature(public_keys[3].to_commitment(), msg, sig_3) + .build()? + .execute() + .await; + assert_transaction_executor_error!(replay_result, ERR_TX_ALREADY_EXECUTED); Ok(()) } -/// Regression test for the per-procedure contribution semantic of `compute_called_proc_policy`: -/// a transaction that mixes a low-policy procedure (receive_asset = 1) with an unpolicied -/// procedure (set_procedure_policy) must require `max(policy, default) = default` signatures, -/// not just the low policy threshold. Without per-proc-contribute this is a privilege escalation -/// — the unpolicied call would be silently authorized at the receive_asset threshold of 1. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() -> anyhow::Result<()> -{ - let auth_scheme = AuthScheme::EcdsaK256Keccak; - let default_threshold = 3u32; +async fn test_multisig_smart_equal_tier_config_is_accepted_by_update_threshold_config( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme( - default_threshold as usize, - default_threshold as usize, - auth_scheme, - )?; - - // receive_asset configured with a low policy (1 sig), update_signers and - // set_procedure_policy intentionally left unpolicied. - let receive_policy = ProcedurePolicy::with_immediate_threshold(1)?; - let proc_policy_map = vec![(BasicWallet::receive_asset_root().as_word(), receive_policy)]; - let multisig_account = create_multisig_smart_account( - default_threshold, - &public_keys, - auth_scheme, - 10, - proc_policy_map, - )?; + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let mut multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - // Tx-script calls the unpolicied `set_procedure_policy` proc. The tx also consumes a P2ID - // note (which calls the policied receive_asset). With per-proc-contribute, set_procedure_policy - // contributes `default_threshold` to the max. - let target_root = BasicWallet::move_asset_to_note_root().as_word(); - let set_policy_script = compile_multisig_smart_tx_script(format!( + let update_script = compile_multisig_smart_tx_script( " begin - push.{root} - push.0 # note_restrictions - push.0 # delayed_threshold - push.1 # immediate_threshold - call.::miden::standards::components::auth::multisig_smart::set_procedure_policy - drop drop drop - dropw + push.2 + push.2 + push.2 + push.1 + call.::miden::standards::components::auth::multisig_smart::update_threshold_config + dropw dropw dropw dropw dropw end ", - root = target_root, - ))?; + )?; - let mut chain_builder = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); - let note = chain_builder.add_p2id_note( - multisig_account.id(), + let update_tx = execute_script_with_signers( + &mock_chain, multisig_account.id(), - &[FungibleAsset::mock(1)], - NoteType::Public, - )?; - let mock_chain = chain_builder.build()?; + update_script, + Word::from([Felt::from(1000u32); 4]), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + None, + ) + .await? + .expect("equal tier config update should succeed"); + multisig_account.apply_delta(update_tx.account_delta())?; - let salt = Word::from([Felt::new_unchecked(42); 4]); + Ok(()) +} - let tx_context_builder = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script) - .auth_args(salt); +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_invalid_tier_config_rejected_by_update_threshold_config( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - // Dry-run to capture the tx summary. - let tx_summary = tx_context_builder - .clone() + let zero_tier0_script = compile_multisig_smart_tx_script( + " + begin + push.3 + push.2 + push.1 + push.0 + call.::miden::standards::components::auth::multisig_smart::update_threshold_config + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(zero_tier0_script) + .auth_args(Word::from([Felt::from(1001u32); 4])) .build()? .execute() - .await - .unwrap_err() - .unwrap_unauthorized_err(); + .await; + assert_transaction_executor_error!(result, ERR_TIER0_MUST_BE_POSITIVE); - let msg = tx_summary.as_ref().to_commitment(); - let signing = SigningInputs::TransactionSummary(tx_summary); - let sig_0 = authenticators[0] - .get_signature(public_keys[0].to_commitment(), &signing) - .await?; - let sig_1 = authenticators[1] - .get_signature(public_keys[1].to_commitment(), &signing) - .await?; - let sig_2 = authenticators[2] - .get_signature(public_keys[2].to_commitment(), &signing) - .await?; + let non_monotonic_script = compile_multisig_smart_tx_script( + " + begin + push.3 + push.2 + push.3 + push.1 + call.::miden::standards::components::auth::multisig_smart::update_threshold_config + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(non_monotonic_script) + .auth_args(Word::from([Felt::from(1002u32); 4])) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_INVALID_TIER_CONFIG); - // With only 1 signature (matching the low receive_asset policy), the tx must fail because - // the unpolicied set_procedure_policy call contributes `default_threshold = 3`. - let one_sig_result = tx_context_builder - .clone() - .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) + let tier3_too_high_script = compile_multisig_smart_tx_script( + " + begin + push.5 + push.3 + push.2 + push.1 + call.::miden::standards::components::auth::multisig_smart::update_threshold_config + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(tier3_too_high_script) + .auth_args(Word::from([Felt::from(1003u32); 4])) .build()? .execute() .await; - one_sig_result.unwrap_err().unwrap_unauthorized_err(); + assert_transaction_executor_error!(result, ERR_TIER3_TOO_HIGH); - // With all 3 signatures the unpolicied default contribution is met and the tx succeeds. - let three_sig_result = tx_context_builder - .add_signature(public_keys[0].to_commitment(), msg, sig_0) - .add_signature(public_keys[1].to_commitment(), msg, sig_1) - .add_signature(public_keys[2].to_commitment(), msg, sig_2) + Ok(()) +} + +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_invalid_spending_limits_rejected( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + let multisig_account = create_multisig_account(2, &public_keys, auth_scheme, 100, vec![])?; + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let invalid_limit0_script = compile_multisig_smart_tx_script( + " + begin + push.0 + push.300 + push.100 + push.200 + call.::miden::standards::components::auth::multisig_smart::update_spending_limits + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(invalid_limit0_script) + .auth_args(Word::from([Felt::from(1101u32); 4])) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_INVALID_AMOUNT_LIMITS); + + let invalid_limit1_script = compile_multisig_smart_tx_script( + " + begin + push.0 + push.200 + push.300 + push.100 + call.::miden::standards::components::auth::multisig_smart::update_spending_limits + dropw dropw dropw dropw dropw + end + ", + )?; + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .tx_script(invalid_limit1_script) + .auth_args(Word::from([Felt::from(1102u32); 4])) .build()? .execute() .await; - three_sig_result.expect("3 signatures should satisfy the default-threshold contribution"); + assert_transaction_executor_error!(result, ERR_INVALID_AMOUNT_LIMITS); Ok(()) }