From 40bbb1d7b2373021c61f6bc563efb21cc3972d0b Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 12:09:35 +0200 Subject: [PATCH 01/15] add delayed execution logic --- CHANGELOG.md | 1 + .../auth/multisig_smart.masm | 6 + .../multisig_smart/delayed_execution.masm | 1036 +++++++++++++++++ .../standards/auth/multisig_smart/mod.masm | 87 +- .../account/auth/multisig_smart/component.rs | 176 ++- .../src/account/auth/multisig_smart/config.rs | 24 + .../src/account/auth/multisig_smart/mod.rs | 2 + crates/miden-testing/src/mock_chain/auth.rs | 16 +- .../tests/auth/multisig_smart.rs | 417 ++++++- 9 files changed, 1689 insertions(+), 76 deletions(-) create mode 100644 crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm create mode 100644 crates/miden-standards/src/account/auth/multisig_smart/config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7249447db9..870afcd107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - 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)). - Added foundations for `AuthMultisigSmart` ([#2806](https://github.com/0xMiden/protocol/pull/2806)). +- Extended `AuthMultisigSmart` with a `DelayedExecutionPolicy` and a `delayed_execution` module that exposes a timelock-controlled propose/cancel/execute flow, surfaced through `update_delayed_execution_policy`, `propose_transaction`, `cancel_transaction_proposal`, `cancel_and_propose_new_transaction`, and `execute_proposed_transaction`. - Added `tx::get_tx_script_root` kernel procedure returning the root of the executed transaction script (empty word if no script was executed) ([#2816](https://github.com/0xMiden/protocol/pull/2816)). - 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 basic blocklist transfer policy with owner-managed admin (`block_account`/`unblock_account`) and runtime policy switching via the protocol-reserved asset callback slots ([#2820])(https://github.com/0xMiden/protocol/pull/2820). 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..5348a8777b 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,18 @@ use miden::standards::auth::multisig use miden::standards::auth::multisig_smart +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 #! 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..828ddde156 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,7 @@ 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::delayed_execution use miden::standards::auth::signature use miden::standards::auth::tx_policy @@ -59,6 +60,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) # ================================================================================================= @@ -422,26 +425,24 @@ end #! #! 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. +#! The active execution mode is read from [`delayed_execution::is_execute_path`]: when the +#! delayed-execution module'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). +#! +#! `policy_requires_delay` is surfaced so the caller can enforce execute-path consistency +#! after signature verification (see [`ERR_EXECUTE_PATH_MISMATCH`]). 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,21 +748,23 @@ end #! Locals: #! 0: policy_threshold #! 1: default_threshold +#! 2: policy_requires_delay +#! 3: num_verified_signatures +#! +#! Flow: +#! 1. Build the tx summary commitment used for signing and timelock proposals. +#! 2. Enforce per-procedure policy (note restrictions + policy threshold + delay flag). +#! 3. Verify approver signatures and check the threshold. +#! 4. Enforce execute-path consistency: if any policy required the delayed mode the tx must be +#! on the execute path, and conversely the immediate path is only valid when no consumed +#! policy required a delay. This check runs after signature verification so a caller can +#! still obtain the TX_SUMMARY_COMMITMENT needed for a propose/execute round-trip from an +#! unauthorized dry-run. +#! 5. Finalize any pending timelock propose/cancel/execute slots against the verified sig count +#! and the policy-derived `requires_delay` flag. #! #! 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(4) pub proc auth_tx(salt: word) exec.native_account::incr_nonce drop # => [SALT] @@ -787,9 +790,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,6 +804,9 @@ pub proc auth_tx(salt: word) exec.::miden::standards::auth::signature::verify_signatures # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + dup loc_store.3 + # => [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. @@ -815,4 +824,30 @@ pub proc auth_tx(salt: word) emit.AUTH_UNAUTHORIZED_EVENT push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES end + + # ------ Enforcing execute-path consistency ------ + # The immediate path is only valid when no consumed policy required a delay. Conversely, + # the delayed-execute path is only valid when the tx actually requires a delay. + 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 ------ + loc_load.2 + # => [policy_requires_delay, TX_SUMMARY_COMMITMENT] + + loc_load.3 + # => [num_verified_signatures, policy_requires_delay, TX_SUMMARY_COMMITMENT] + + exec.delayed_execution::finalize_timelock_proposals + # => [TX_SUMMARY_COMMITMENT] end 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..3d0112bb7c 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -11,6 +11,7 @@ use miden_protocol::account::component::{ }; use miden_protocol::account::{ AccountComponent, + AccountProcedureRoot, StorageMap, StorageMapKey, StorageSlot, @@ -29,22 +30,59 @@ use super::super::multisig::{ THRESHOLD_CONFIG_SLOT_NAME, }; use super::ProcedurePolicy; +use super::config::DelayedExecutionPolicy; use crate::account::account_component_code; use crate::account::auth::AuthMultisig; +use crate::procedure_root; account_component_code!(MULTISIG_SMART_CODE, "auth/multisig_smart.masl"); +// Procedure-root statics for the delayed-execution control-plane procedures. Tests and callers +// can use these to look up the on-chain procedure roots without re-deriving them from the +// component code. +procedure_root!( + MULTISIG_SMART_UPDATE_DELAYED_EXECUTION_POLICY, + AuthMultisigSmart::NAME, + AuthMultisigSmart::UPDATE_DELAYED_EXECUTION_POLICY_PROC_NAME, + AuthMultisigSmart::code() +); + // 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. +// Only the smart-specific slots need their own constants here. The four slots reused from +// `AuthMultisig` (threshold config, approver public keys, approver scheme ids, executed +// transactions) are imported from that sibling module above. 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 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 +92,7 @@ pub struct AuthMultisigSmartConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, procedure_policies: Vec<(Word, ProcedurePolicy)>, + delayed_execution: DelayedExecutionPolicy, } impl AuthMultisigSmartConfig { @@ -83,6 +122,7 @@ impl AuthMultisigSmartConfig { approvers, default_threshold, procedure_policies: vec![], + delayed_execution: DelayedExecutionPolicy::default(), }) } @@ -96,6 +136,12 @@ impl AuthMultisigSmartConfig { Ok(self) } + /// Sets the delayed-execution policy (min delay + propose expiration delta). + pub fn with_delayed_execution(mut self, delayed_execution: DelayedExecutionPolicy) -> Self { + self.delayed_execution = delayed_execution; + self + } + pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { &self.approvers } @@ -107,6 +153,18 @@ impl AuthMultisigSmartConfig { pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { &self.procedure_policies } + + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { + self.delayed_execution + } + + pub fn min_delay(&self) -> u32 { + self.delayed_execution.min_delay() + } + + pub fn propose_expiration_delta(&self) -> u16 { + self.delayed_execution.propose_expiration_delta() + } } fn validate_proc_policies( @@ -154,6 +212,9 @@ impl AuthMultisigSmart { /// The name of the component. pub const NAME: &'static str = "miden::standards::components::auth::multisig_smart"; + 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 @@ -185,6 +246,31 @@ impl AuthMultisigSmart { &PROCEDURE_POLICIES_SLOT_NAME } + pub fn delayed_execution_slot() -> &'static StorageSlotName { + &DELAYED_EXECUTION_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 + } + + /// Returns the [`AccountProcedureRoot`] of the `update_delayed_execution_policy` procedure. + pub fn update_delayed_execution_policy_root() -> AccountProcedureRoot { + *MULTISIG_SMART_UPDATE_DELAYED_EXECUTION_POLICY + } + pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { AuthMultisig::threshold_config_slot_schema() } @@ -211,11 +297,56 @@ impl AuthMultisigSmart { ), ) } + + pub fn delayed_execution_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::delayed_execution_slot().clone(), + StorageSlotSchema::value( + "Delayed-execution policy: [min_delay, propose_expiration_delta, 0, 0]", + SchemaType::native_word(), + ), + ) + } + + pub fn tx_proposals_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::tx_proposals_slot().clone(), + StorageSlotSchema::map( + "Active tx proposals: tx_hash => [unlock_timestamp, expiration_height, 0, 0]", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + pub fn pending_propose_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_propose_slot().clone(), + StorageSlotSchema::value("Pending propose: TX_HASH", SchemaType::native_word()), + ) + } + + pub fn pending_cancel_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_cancel_slot().clone(), + StorageSlotSchema::value("Pending cancel: TX_HASH", SchemaType::native_word()), + ) + } + + pub fn pending_execute_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::pending_execute_slot().clone(), + StorageSlotSchema::value( + "Pending execute marker (any non-zero word)", + 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(10); // Threshold config slot (value: [threshold, num_approvers, 0, 0]) let num_approvers = multisig.config.approvers().len() as u32; @@ -261,12 +392,49 @@ impl From for AccountComponent { procedure_policies, )); + // Delayed-execution policy slot (value: [min_delay, propose_expiration_delta, 0, 0]) + let delayed_execution = multisig.config.delayed_execution(); + 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, + ]), + )); + + // Tx-proposals map slot (TX_HASH => [unlock_timestamp, expiration_height, 0, 0]) + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::tx_proposals_slot().clone(), + StorageMap::default(), + )); + + // Pending propose / cancel / execute scratch slots — empty by 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::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"); 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..9595747f06 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/config.rs @@ -0,0 +1,24 @@ +/// 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 + } +} 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..b3b2aaf598 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/mod.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs @@ -1,7 +1,9 @@ mod component; +mod config; mod procedure_policies; pub use component::{AuthMultisigSmart, AuthMultisigSmartConfig}; +pub use config::DelayedExecutionPolicy; pub use procedure_policies::{ ProcedurePolicy, ProcedurePolicyExecutionMode, diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 732188d3ca..a577c22cb1 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -8,7 +8,7 @@ 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, ProcedurePolicy}; use miden_standards::account::auth::{ AuthGuardedMultisig, AuthGuardedMultisigConfig, @@ -52,11 +52,13 @@ pub enum Auth { proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, }, - /// Multisig with smart per-procedure policy configuration. + /// Multisig with smart per-procedure policy configuration and an optional delayed-execution + /// policy controlling propose/cancel/execute timelock flows. MultisigSmart { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, proc_policy_map: Vec<(Word, ProcedurePolicy)>, + delayed_execution: DelayedExecutionPolicy, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to @@ -131,10 +133,16 @@ impl Auth { (component, None) }, - Auth::MultisigSmart { threshold, approvers, proc_policy_map } => { + Auth::MultisigSmart { + threshold, + approvers, + proc_policy_map, + delayed_execution, + } => { 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_delayed_execution(*delayed_execution); let component = AuthMultisigSmart::new(config) .expect("multisig smart component creation failed") diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index da5f38ad50..ce11ab22c4 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -8,6 +8,7 @@ use miden_protocol::transaction::TransactionScript; use miden_protocol::vm::AdviceMap; use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::multisig_smart::{ + DelayedExecutionPolicy, ProcedurePolicy, ProcedurePolicyNoteRestriction, }; @@ -19,6 +20,7 @@ use miden_standards::errors::standards::{ ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, }; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; use miden_tx::auth::{SigningInputs, TransactionAuthenticator}; use rstest::rstest; @@ -42,8 +44,9 @@ fn create_multisig_smart_account( ) -> 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 config = AuthMultisigSmartConfig::new(approvers, threshold)? + .with_proc_policies(proc_policy_map)? + .with_delayed_execution(DelayedExecutionPolicy::new(30, 2)); let asset = FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, @@ -102,17 +105,17 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr let mut mock_chain = mock_chain_builder.build()?; let salt = Word::from([Felt::new_unchecked(1); 4]); - let tx_context_builder = mock_chain + let tx_summary = match mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(salt); - - let tx_summary = tx_context_builder - .clone() + .auth_args(salt) .build()? .execute() .await .unwrap_err() - .unwrap_unauthorized_err(); + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; let msg = tx_summary.as_ref().to_commitment(); let tx_summary_signing = SigningInputs::TransactionSummary(tx_summary); @@ -120,8 +123,10 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) .await?; - let tx_result = tx_context_builder + let tx_result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? .add_signature(public_keys[0].to_commitment(), msg, one_signature) + .auth_args(salt) .build()? .execute() .await; @@ -196,7 +201,10 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( ); }, ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoOutputNotes => { - result.unwrap_err().unwrap_unauthorized_err(); + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } }, } @@ -271,7 +279,10 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( ); }, ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoInputNotes => { - result.unwrap_err().unwrap_unauthorized_err(); + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } }, } @@ -324,21 +335,21 @@ async fn test_multisig_smart_update_signers_and_thresholds( let salt = Word::from([Felt::new_unchecked(3); 4]); - let tx_context_builder = mock_chain + // Dry-run to obtain the tx summary that the current approvers must sign. + let tx_summary = match mock_chain .build_tx_context(account_id, &[], &[])? - .tx_script(update_signers_script) + .tx_script(update_signers_script.clone()) .tx_script_args(multisig_config_hash) - .extend_advice_inputs(advice_inputs) - .auth_args(salt); - - // Dry-run to obtain the tx summary that the current approvers must sign. - let tx_summary = tx_context_builder - .clone() + .extend_advice_inputs(advice_inputs.clone()) + .auth_args(salt) .build()? .execute() .await .unwrap_err() - .unwrap_unauthorized_err(); + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; let msg = tx_summary.as_ref().to_commitment(); let signing_inputs = SigningInputs::TransactionSummary(tx_summary); @@ -349,7 +360,12 @@ async fn test_multisig_smart_update_signers_and_thresholds( .get_signature(public_keys[1].to_commitment(), &signing_inputs) .await?; - let executed_tx = tx_context_builder + let executed_tx = 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) .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? @@ -425,19 +441,19 @@ async fn test_multisig_smart_set_procedure_policy( 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 tx_summary = match mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(set_policy_script.clone()) + .auth_args(salt) .build()? .execute() .await .unwrap_err() - .unwrap_unauthorized_err(); + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected abort with tx summary: {error:?}"), + }; let msg = tx_summary.as_ref().to_commitment(); let signing_inputs = SigningInputs::TransactionSummary(tx_summary); @@ -448,7 +464,10 @@ async fn test_multisig_smart_set_procedure_policy( .get_signature(public_keys[1].to_commitment(), &signing_inputs) .await?; - let executed_tx = tx_context_builder + let executed_tx = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(set_policy_script) + .auth_args(salt) .add_signature(public_keys[0].to_commitment(), msg, sig_0) .add_signature(public_keys[1].to_commitment(), msg, sig_1) .build()? @@ -529,19 +548,19 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - let salt = Word::from([Felt::new_unchecked(42); 4]); - let tx_context_builder = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script) - .auth_args(salt); - // Dry-run to capture the tx summary. - let tx_summary = tx_context_builder - .clone() + let tx_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .tx_script(set_policy_script.clone()) + .auth_args(salt) .build()? .execute() .await .unwrap_err() - .unwrap_unauthorized_err(); + { + TransactionExecutorError::Unauthorized(tx_summary) => tx_summary, + error => panic!("expected dry-run abort with tx summary: {error:?}"), + }; let msg = tx_summary.as_ref().to_commitment(); let signing = SigningInputs::TransactionSummary(tx_summary); @@ -557,16 +576,26 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - // 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() + let one_sig_result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .tx_script(set_policy_script.clone()) + .auth_args(salt) .add_signature(public_keys[0].to_commitment(), msg, sig_0.clone()) .build()? .execute() .await; - one_sig_result.unwrap_err().unwrap_unauthorized_err(); + match one_sig_result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => { + panic!("expected Unauthorized with 1 sig (escalation would let it pass): {other:?}") + }, + } // With all 3 signatures the unpolicied default contribution is met and the tx succeeds. - let three_sig_result = tx_context_builder + let three_sig_result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .tx_script(set_policy_script) + .auth_args(salt) .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) @@ -577,3 +606,307 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - Ok(()) } + +// ================================================================================================ +// DELAYED-EXECUTION HELPERS +// ================================================================================================ + +use miden_protocol::transaction::ExecutedTransaction; +use miden_standards::errors::standards::ERR_PENDING_ALREADY_SET; +use miden_testing::MockChain; +use miden_tx::auth::BasicAuthenticator; + +#[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, +) -> 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); + } + + 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); + } + + 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) +} + +// ================================================================================================ +// DELAYED-EXECUTION TESTS +// ================================================================================================ + +/// A procedure whose policy only declares a `delay_threshold` (no `immediate_threshold`) must not +/// be executable on the immediate path: providing the would-be required signatures and calling it +/// directly should fail at the procedure-policy enforcement layer with +/// `ERR_PROC_POLICY_INVALID_MODE`. +#[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_smart_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmart::update_delayed_execution_policy_root().as_word(), + 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(()) +} + +/// The execute lane should still produce a `TX_SUMMARY_COMMITMENT` (via the unauthorized dry-run) +/// even when the tx only touches a delayed-only procedure: the auth path runs to threshold check, +/// fails without signatures, but emits the summary needed to drive the propose/execute round-trip. +#[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_smart_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmart::update_delayed_execution_policy_root().as_word(), + 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:?}"), + } +} + +/// The `PENDING_PROPOSE` / `PENDING_CANCEL` / `PENDING_EXECUTE` scratch slots must be mutually +/// exclusive within a single transaction. Calling `propose_transaction` twice, or +/// `cancel_transaction_proposal` twice, or `execute_proposed_transaction` twice should all panic +/// with `ERR_PENDING_ALREADY_SET`. +#[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_smart_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, + ) + .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(()) +} From 1b10a8ccae6b37b25b24ef89a788cf8fb1a2ff04 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 13:19:28 +0200 Subject: [PATCH 02/15] fix comments --- .../standards/auth/multisig_smart/delayed_execution.masm | 6 ++++-- crates/miden-testing/tests/auth/multisig_smart.rs | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) 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 index 17b0d16616..d9828613a5 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -802,7 +802,8 @@ end #! #! 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. +#! - requires_delay is 1 if the current transaction must run in delayed execution mode, +#! otherwise 0. #! - TX_HASH is the current transaction summary hash. #! #! Panics if: @@ -1009,7 +1010,8 @@ end #! #! 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. +#! - requires_delay is 1 if the current transaction must run in delayed execution mode, +#! otherwise 0. #! - TX_HASH is the current transaction summary hash. #! #! Panics if: diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index ce11ab22c4..5afef44ad5 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -749,14 +749,15 @@ async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( Ok(()) } -/// The execute lane should still produce a `TX_SUMMARY_COMMITMENT` (via the unauthorized dry-run) -/// even when the tx only touches a delayed-only procedure: the auth path runs to threshold check, -/// fails without signatures, but emits the summary needed to drive the propose/execute round-trip. +/// Calling `execute_proposed_transaction` should still produce a `TX_SUMMARY_COMMITMENT` (via the +/// unauthorized dry-run) even when the tx only touches a delayed-only procedure: the auth path +/// runs to threshold check, fails without signatures, but emits the summary needed to drive the +/// propose/execute round-trip. #[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( +async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry_run( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, _authenticators) = From 70bf8405184f7a83e11fc406d04926b23e5b0fc8 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 13:27:56 +0200 Subject: [PATCH 03/15] add PR to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 870afcd107..026260e81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ - 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)). - Added foundations for `AuthMultisigSmart` ([#2806](https://github.com/0xMiden/protocol/pull/2806)). -- Extended `AuthMultisigSmart` with a `DelayedExecutionPolicy` and a `delayed_execution` module that exposes a timelock-controlled propose/cancel/execute flow, surfaced through `update_delayed_execution_policy`, `propose_transaction`, `cancel_transaction_proposal`, `cancel_and_propose_new_transaction`, and `execute_proposed_transaction`. +- Extended `AuthMultisigSmart` with a `DelayedExecutionPolicy` and a `delayed_execution` module that exposes a timelock-controlled propose/cancel/execute flow, surfaced through `update_delayed_execution_policy`, `propose_transaction`, `cancel_transaction_proposal`, `cancel_and_propose_new_transaction`, and `execute_proposed_transaction` ([#3044](https://github.com/0xMiden/protocol/pull/3044)). - Added `tx::get_tx_script_root` kernel procedure returning the root of the executed transaction script (empty word if no script was executed) ([#2816](https://github.com/0xMiden/protocol/pull/2816)). - 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 basic blocklist transfer policy with owner-managed admin (`block_account`/`unblock_account`) and runtime policy switching via the protocol-reserved asset callback slots ([#2820])(https://github.com/0xMiden/protocol/pull/2820). From d1857a2392030cddc17829e84323755a087b6ace Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 14:20:31 +0200 Subject: [PATCH 04/15] fix missing comments --- .../multisig_smart/delayed_execution.masm | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 index d9828613a5..824add8b77 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -43,7 +43,7 @@ const ERR_TX_EXPIRATION_DELTA_NOT_SET = "tx expiration delta must be set" #! High-impact action. #! -#! Sets the timelock controller used by proposal lifecycle checks. +#! Sets the delayed execution policy used by proposal lifecycle checks. #! #! Inputs: [min_delay, propose_expiration_delta] #! Outputs: [] @@ -82,10 +82,18 @@ pub proc update_delayed_execution_policy # => [] end -#! Loads the timelock controller configuration. +#! Loads the delayed execution policy configuration from `DELAYED_EXECUTION_SLOT`. #! #! Inputs: [] #! Outputs: [min_delay, propose_expiration_delta, 0, 0] +#! +#! Where: +#! - min_delay is the minimum number of seconds that must elapse between a `propose_transaction` +#! call and the matching `execute_proposed_transaction` call. +#! - propose_expiration_delta is the block delta applied to proposal transactions so that +#! unsatisfied proposals expire after `propose_expiration_delta` blocks. +#! +#! Invocation: exec proc get_delayed_execution push.DELAYED_EXECUTION_SLOT[0..2] # => [slot_prefix, slot_suffix] @@ -182,8 +190,29 @@ pub proc apply_expiration_delta end +#! Applies the configured `propose_expiration_delta` to the current transaction. +#! +#! Reads `propose_expiration_delta` from the delayed execution policy and forwards it to +#! `apply_expiration_delta`, which writes the value through `tx::update_expiration_block_delta` +#! and asserts the resulting transaction expiration block delta is non-zero. This is the helper +#! used by `propose_transaction` and `cancel_and_propose_new_transaction` to make sure every +#! proposal-bearing transaction expires within the policy-defined window. +#! #! Inputs: [] #! Outputs: [] +#! +#! Panics if: +#! - `propose_expiration_delta` configured in `DELAYED_EXECUTION_SLOT` is zero +#! (`ERR_EXPIRATION_DELTA_ZERO`). +#! - `tx::update_expiration_block_delta` rejects the configured value. +#! - the stored transaction expiration delta is zero after the update +#! (`ERR_TX_EXPIRATION_DELTA_NOT_SET`). +#! +#! Side effects: +#! - Calls `tx::update_expiration_block_delta` to overwrite the current transaction's +#! expiration block delta with the configured `propose_expiration_delta`. +#! +#! Invocation: exec proc apply_propose_expiration_delta exec.get_propose_expiration_delta # => [propose_expiration_delta] From 136bea305a9a7e8746ab056c81a378b8f05258b8 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 16:40:02 +0200 Subject: [PATCH 05/15] add tests --- .../multisig_smart/delayed_execution.masm | 11 +- .../standards/auth/multisig_smart/mod.masm | 60 +-- .../account/auth/multisig_smart/component.rs | 63 ++- .../src/account/auth/multisig_smart/config.rs | 48 +- .../auth/multisig_smart/procedure_policies.rs | 2 +- crates/miden-testing/src/mock_chain/auth.rs | 10 +- .../tests/auth/multisig_smart.rs | 507 +++++++++++++++++- 7 files changed, 619 insertions(+), 82 deletions(-) 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 index 824add8b77..7d2df48738 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -69,11 +69,14 @@ pub proc update_delayed_execution_policy 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] + swap + # => [min_delay, propose_expiration_delta] + + push.0 push.0 movdn.3 movdn.3 + # => [min_delay, propose_expiration_delta, 0, 0] push.DELAYED_EXECUTION_SLOT[0..2] - # => [slot_prefix, slot_suffix, 0, 0, propose_expiration_delta, min_delay] + # => [slot_prefix, slot_suffix, min_delay, propose_expiration_delta, 0, 0] exec.native_account::set_item # => [OLD_WORD] @@ -172,7 +175,7 @@ end #! (`ERR_TX_EXPIRATION_DELTA_NOT_SET`). #! #! Invocation: exec -pub proc apply_expiration_delta +proc apply_expiration_delta dup neq.0 assert.err=ERR_EXPIRATION_DELTA_ZERO # => [expiration_delta] 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 828ddde156..7f37650b48 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -48,7 +48,7 @@ const ERR_ZERO_IN_MULTISIG_CONFIG = "number of approvers or threshold must not b const ERR_PROC_POLICY_INVALID_MODE = "called procedures do not support the selected execution mode" -const ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE = "delayed threshold cannot exceed immediate threshold" +const ERR_DELAY_THRESHOLD_EXCEEDS_IMMEDIATE = "delay threshold cannot exceed immediate threshold" const ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD = "procedure policy note restrictions require an immediate or delayed threshold" @@ -66,7 +66,7 @@ const ERR_EXECUTE_PATH_MISMATCH = "execute path must match delay requirement" # ================================================================================================= const IMMEDIATE_THRESHOLD_LOC = 0 -const DELAYED_THRESHOLD_LOC = 1 +const DELAY_THRESHOLD_LOC = 1 const NOTE_RESTRICTIONS_LOC = 2 # LOCAL ADDRESSES (compute_called_proc_policy) @@ -78,22 +78,22 @@ const DEFAULT_THRESHOLD_LOC = 1 #! Gets the procedure policy entry for PROC_ROOT from the account's initial state. #! #! Inputs: [PROC_ROOT] -#! Outputs: [immediate_threshold, delayed_threshold, note_restrictions] +#! Outputs: [immediate_threshold, delay_threshold, note_restrictions] #! #! Where: #! - PROC_ROOT is the root of the account procedure whose smart policy is being read. #! - immediate_threshold is the threshold for direct execution, or 0 when disabled. -#! - delayed_threshold is the threshold for delayed execution, or 0 when disabled. +#! - delay_threshold is the threshold for delayed execution, or 0 when disabled. #! - note_restrictions is the note restriction enum value in the 0..=NOTE_RESTRICTION_MAX range. #! #! Invocation: exec pub proc get_procedure_policy push.PROCEDURE_POLICIES_SLOT[0..2] exec.active_account::get_initial_map_item - # => [immediate_threshold, delayed_threshold, note_restrictions, 0] + # => [immediate_threshold, delay_threshold, note_restrictions, 0] movup.3 drop - # => [immediate_threshold, delayed_threshold, note_restrictions] + # => [immediate_threshold, delay_threshold, note_restrictions] end #! Validates that note_restrictions is within the supported range. @@ -478,23 +478,23 @@ proc assert_proc_policies_lte_num_approvers # in this transaction that raised a threshold above the new num_approvers would otherwise # be missed and the multisig could end up with an unreachable threshold. exec.active_account::get_map_item - # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index, num_approvers] + # => [immediate_threshold, delay_threshold, note_restrictions, 0, proc_index, num_approvers] # Drop the trailing 0 (depth 3) without disturbing the three policy fields above it. movup.3 drop - # => [immediate_threshold, delayed_threshold, note_restrictions, proc_index, num_approvers] + # => [immediate_threshold, delay_threshold, note_restrictions, proc_index, num_approvers] # immediate_threshold <= num_approvers dup.4 - # => [num_approvers, immediate_threshold, delayed_threshold, note_restrictions, proc_index, num_approvers] + # => [num_approvers, immediate_threshold, delay_threshold, note_restrictions, proc_index, num_approvers] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS - # => [delayed_threshold, note_restrictions, proc_index, num_approvers] + # => [delay_threshold, note_restrictions, proc_index, num_approvers] - # delayed_threshold <= num_approvers + # delay_threshold <= num_approvers dup.3 - # => [num_approvers, delayed_threshold, note_restrictions, proc_index, num_approvers] + # => [num_approvers, delay_threshold, note_restrictions, proc_index, num_approvers] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS @@ -516,29 +516,29 @@ end #! Sets or clears a smart per-procedure policy. #! -#! Inputs: [immediate_threshold, delayed_threshold, note_restrictions, PROC_ROOT] +#! Inputs: [immediate_threshold, delay_threshold, note_restrictions, PROC_ROOT] #! Outputs: [] #! #! Where: #! - immediate_threshold is the threshold for direct execution, or 0 when disabled. -#! - delayed_threshold is the threshold for delayed execution, or 0 when disabled. +#! - delay_threshold is the threshold for delayed execution, or 0 when disabled. #! - note_restrictions is the note restriction enum value in the 0..=NOTE_RESTRICTION_MAX range. #! - PROC_ROOT is the root of the account procedure whose policy is being updated. #! #! Panics if: -#! - immediate_threshold or delayed_threshold is not a u32 value. +#! - immediate_threshold or delay_threshold is not a u32 value. #! - note_restrictions is outside the supported range. #! - either threshold exceeds the current number of approvers. -#! - delayed_threshold exceeds immediate_threshold when immediate_threshold is non-zero. +#! - delay_threshold exceeds immediate_threshold when immediate_threshold is non-zero. #! - note_restrictions is non-zero while both thresholds are zero. #! #! Invocation: call @locals(3) pub proc set_procedure_policy loc_store.IMMEDIATE_THRESHOLD_LOC - # => [delayed_threshold, note_restrictions, PROC_ROOT] + # => [delay_threshold, note_restrictions, PROC_ROOT] - loc_store.DELAYED_THRESHOLD_LOC + loc_store.DELAY_THRESHOLD_LOC # => [note_restrictions, PROC_ROOT] loc_store.NOTE_RESTRICTIONS_LOC @@ -556,9 +556,9 @@ pub proc set_procedure_policy u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS # => [num_approvers, PROC_ROOT] - # ----- Validate delayed_threshold <= num_approvers (consumes num_approvers). ----- - loc_load.DELAYED_THRESHOLD_LOC swap - # => [num_approvers, delayed_threshold, PROC_ROOT] + # ----- Validate delay_threshold <= num_approvers (consumes num_approvers). ----- + loc_load.DELAY_THRESHOLD_LOC swap + # => [num_approvers, delay_threshold, PROC_ROOT] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS @@ -578,8 +578,8 @@ pub proc set_procedure_policy if.true # immediate is zero. If delayed is also zero, note_restrictions must be zero, otherwise # the policy would forbid notes for a procedure that has no threshold to authorize them. - loc_load.DELAYED_THRESHOLD_LOC eq.0 - # => [is_delayed_threshold_zero, PROC_ROOT] + loc_load.DELAY_THRESHOLD_LOC eq.0 + # => [is_delay_threshold_zero, PROC_ROOT] if.true # `eq.0 assert` produces the proper error when note_restrictions is non-zero; @@ -588,24 +588,24 @@ pub proc set_procedure_policy # => [PROC_ROOT] end else - # immediate is non-zero. Validate delayed_threshold <= immediate_threshold. - loc_load.DELAYED_THRESHOLD_LOC loc_load.IMMEDIATE_THRESHOLD_LOC - # => [immediate_threshold, delayed_threshold, PROC_ROOT] + # immediate is non-zero. Validate delay_threshold <= immediate_threshold. + loc_load.DELAY_THRESHOLD_LOC loc_load.IMMEDIATE_THRESHOLD_LOC + # => [immediate_threshold, delay_threshold, PROC_ROOT] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 - u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE + u32gt assertz.err=ERR_DELAY_THRESHOLD_EXCEEDS_IMMEDIATE # => [PROC_ROOT] end # ----- Write [immediate, delayed, note_restrictions, 0] to PROCEDURE_POLICIES_SLOT[PROC_ROOT]. push.0 loc_load.NOTE_RESTRICTIONS_LOC - loc_load.DELAYED_THRESHOLD_LOC + loc_load.DELAY_THRESHOLD_LOC loc_load.IMMEDIATE_THRESHOLD_LOC - # => [immediate_threshold, delayed_threshold, note_restrictions, 0, PROC_ROOT] + # => [immediate_threshold, delay_threshold, note_restrictions, 0, PROC_ROOT] swapw - # => [PROC_ROOT, immediate_threshold, delayed_threshold, note_restrictions, 0] + # => [PROC_ROOT, immediate_threshold, delay_threshold, note_restrictions, 0] push.PROCEDURE_POLICIES_SLOT[0..2] # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, POLICY_WORD] 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 3d0112bb7c..70842f7d22 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -92,7 +92,7 @@ pub struct AuthMultisigSmartConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, procedure_policies: Vec<(Word, ProcedurePolicy)>, - delayed_execution: DelayedExecutionPolicy, + delayed_execution: Option, } impl AuthMultisigSmartConfig { @@ -122,7 +122,7 @@ impl AuthMultisigSmartConfig { approvers, default_threshold, procedure_policies: vec![], - delayed_execution: DelayedExecutionPolicy::default(), + delayed_execution: None, }) } @@ -136,9 +136,13 @@ impl AuthMultisigSmartConfig { Ok(self) } - /// Sets the delayed-execution policy (min delay + propose expiration delta). + /// Enables delayed execution with the given policy (min delay + propose expiration delta). + /// + /// When unset, the on-chain `DELAYED_EXECUTION_SLOT` is initialized to `[0, 0, 0, 0]` and any + /// attempt to call `propose_transaction` / `execute_proposed_transaction` will panic at + /// runtime — the feature is opt-in via this builder. pub fn with_delayed_execution(mut self, delayed_execution: DelayedExecutionPolicy) -> Self { - self.delayed_execution = delayed_execution; + self.delayed_execution = Some(delayed_execution); self } @@ -154,17 +158,9 @@ impl AuthMultisigSmartConfig { &self.procedure_policies } - pub fn delayed_execution(&self) -> DelayedExecutionPolicy { + pub fn delayed_execution(&self) -> Option { self.delayed_execution } - - pub fn min_delay(&self) -> u32 { - self.delayed_execution.min_delay() - } - - pub fn propose_expiration_delta(&self) -> u16 { - self.delayed_execution.propose_expiration_delta() - } } fn validate_proc_policies( @@ -226,6 +222,27 @@ impl AuthMultisigSmart { Ok(Self { config }) } + /// Returns the approver list configured for this component. + pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { + self.config.approvers() + } + + /// Returns the default approver threshold. + pub fn default_threshold(&self) -> u32 { + self.config.default_threshold() + } + + /// Returns the per-procedure smart policy map. + pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { + self.config.procedure_policies() + } + + /// Returns the configured delayed-execution policy, or `None` if delayed execution is + /// disabled for this account. + pub fn delayed_execution(&self) -> Option { + self.config.delayed_execution() + } + pub fn threshold_config_slot() -> &'static StorageSlotName { &THRESHOLD_CONFIG_SLOT_NAME } @@ -392,16 +409,22 @@ impl From for AccountComponent { procedure_policies, )); - // Delayed-execution policy slot (value: [min_delay, propose_expiration_delta, 0, 0]) - let delayed_execution = multisig.config.delayed_execution(); - storage_slots.push(StorageSlot::with_value( - AuthMultisigSmart::delayed_execution_slot().clone(), - Word::from([ - delayed_execution.min_delay(), - delayed_execution.propose_expiration_delta() as u32, + // Delayed-execution policy slot (value: [min_delay, propose_expiration_delta, 0, 0]). + // When the feature is not enabled, the slot is initialized to the empty word so that + // any attempt to call `propose_transaction` / `execute_proposed_transaction` fails at + // runtime via the MASM zero-checks. + let delayed_execution_word = match multisig.config.delayed_execution() { + Some(policy) => Word::from([ + policy.min_delay(), + policy.propose_expiration_delta() as u32, 0u32, 0u32, ]), + None => Word::empty(), + }; + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::delayed_execution_slot().clone(), + delayed_execution_word, )); // Tx-proposals map slot (TX_HASH => [unlock_timestamp, expiration_height, 0, 0]) diff --git a/crates/miden-standards/src/account/auth/multisig_smart/config.rs b/crates/miden-standards/src/account/auth/multisig_smart/config.rs index 9595747f06..a89761a8d1 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/config.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/config.rs @@ -1,17 +1,32 @@ +use miden_protocol::errors::AccountError; + /// 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)] +/// +/// Both fields must be non-zero. The MASM `update_delayed_execution_policy` procedure enforces +/// the same constraint at runtime via `ERR_MIN_DELAY_ZERO` / `ERR_PROPOSE_EXPIRATION_DELTA_ZERO`; +/// Rust-side validation here is the primary check, with the MASM kept as a safety net. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] 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 } + /// Creates a new policy after validating that both fields are non-zero. + pub fn new(min_delay: u32, propose_expiration_delta: u16) -> Result { + if min_delay == 0 { + return Err(AccountError::other("delayed execution min_delay must be non-zero")); + } + if propose_expiration_delta == 0 { + return Err(AccountError::other( + "delayed execution propose_expiration_delta must be non-zero", + )); + } + Ok(Self { min_delay, propose_expiration_delta }) } pub const fn min_delay(&self) -> u32 { @@ -22,3 +37,30 @@ impl DelayedExecutionPolicy { self.propose_expiration_delta } } + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use super::DelayedExecutionPolicy; + + #[test] + fn delayed_execution_policy_rejects_zero_min_delay() { + let err = DelayedExecutionPolicy::new(0, 5).unwrap_err(); + assert!(err.to_string().contains("min_delay must be non-zero")); + } + + #[test] + fn delayed_execution_policy_rejects_zero_propose_expiration_delta() { + let err = DelayedExecutionPolicy::new(30, 0).unwrap_err(); + assert!(err.to_string().contains("propose_expiration_delta must be non-zero")); + } + + #[test] + fn delayed_execution_policy_accepts_valid_values() { + let policy = + DelayedExecutionPolicy::new(30, 2).expect("non-zero arguments should be accepted"); + assert_eq!(policy.min_delay(), 30); + assert_eq!(policy.propose_expiration_delta(), 2); + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs index c83245cc0a..2533f86819 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs @@ -52,7 +52,7 @@ pub enum ProcedurePolicyNoteRestriction { /// The thresholds for immediate and delayed execution may differ. /// /// The policy is encoded into the procedure-policy storage word as: -/// `[immediate_threshold, delayed_threshold, note_restrictions, 0]`. +/// `[immediate_threshold, delay_threshold, note_restrictions, 0]`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProcedurePolicy { execution_mode: ProcedurePolicyExecutionMode, diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index a577c22cb1..8dd0266b99 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -58,7 +58,7 @@ pub enum Auth { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, proc_policy_map: Vec<(Word, ProcedurePolicy)>, - delayed_execution: DelayedExecutionPolicy, + delayed_execution: Option, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to @@ -139,10 +139,12 @@ impl Auth { proc_policy_map, delayed_execution, } => { - let config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) + let mut config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) - .expect("invalid multisig smart config") - .with_delayed_execution(*delayed_execution); + .expect("invalid multisig smart config"); + if let Some(de) = delayed_execution { + config = config.with_delayed_execution(*de); + } let component = AuthMultisigSmart::new(config) .expect("multisig smart component creation failed") diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 5afef44ad5..e873d5fe70 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -46,7 +46,7 @@ fn create_multisig_smart_account( public_keys.iter().map(|pk| (pk.to_commitment(), auth_scheme)).collect(); let config = AuthMultisigSmartConfig::new(approvers, threshold)? .with_proc_policies(proc_policy_map)? - .with_delayed_execution(DelayedExecutionPolicy::new(30, 2)); + .with_delayed_execution(DelayedExecutionPolicy::new(30, 2)?); let asset = FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, @@ -71,6 +71,13 @@ fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result Word { + Word::from([Felt::from(seed); 4]) +} + // ================================================================================================ // TESTS // ================================================================================================ @@ -104,7 +111,7 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr )?; let mut mock_chain = mock_chain_builder.build()?; - let salt = Word::from([Felt::new_unchecked(1); 4]); + let salt = salt(1); let tx_summary = match mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? .auth_args(salt) @@ -182,7 +189,7 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( let result = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(Word::from([Felt::new_unchecked(2); 4])) + .auth_args(salt(2)) .build()? .execute() .await; @@ -262,7 +269,7 @@ async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( .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])) + .auth_args(salt(2)) .build()? .execute() .await; @@ -333,7 +340,7 @@ async fn test_multisig_smart_update_signers_and_thresholds( ", )?; - let salt = Word::from([Felt::new_unchecked(3); 4]); + let salt = salt(3); // Dry-run to obtain the tx summary that the current approvers must sign. let tx_summary = match mock_chain @@ -417,7 +424,7 @@ async fn test_multisig_smart_set_procedure_policy( let receive_asset_root = BasicWallet::receive_asset_root().as_word(); let immediate_threshold = 1u32; - let delayed_threshold = 0u32; + let delay_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. @@ -426,7 +433,7 @@ async fn test_multisig_smart_set_procedure_policy( begin push.{root} push.{note_restrictions} - push.{delayed_threshold} + push.{delay_threshold} push.{immediate_threshold} call.::miden::standards::components::auth::multisig_smart::set_procedure_policy drop drop drop # immediate, delayed, note_restrictions @@ -435,11 +442,11 @@ async fn test_multisig_smart_set_procedure_policy( ", root = receive_asset_root, note_restrictions = note_restrictions as u8, - delayed_threshold = delayed_threshold, + delay_threshold = delay_threshold, immediate_threshold = immediate_threshold, ))?; - let salt = Word::from([Felt::new_unchecked(4); 4]); + let salt = salt(4); // Dry-run to obtain the tx summary that the approvers must sign. let tx_summary = match mock_chain @@ -483,7 +490,7 @@ async fn test_multisig_smart_set_procedure_policy( .expect("procedure policies slot should be present"); assert_eq!( stored_policy, - Word::from([immediate_threshold, delayed_threshold, note_restrictions as u32, 0]) + Word::from([immediate_threshold, delay_threshold, note_restrictions as u32, 0]) ); Ok(()) @@ -527,7 +534,7 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - begin push.{root} push.0 # note_restrictions - push.0 # delayed_threshold + push.0 # delay_threshold push.1 # immediate_threshold call.::miden::standards::components::auth::multisig_smart::set_procedure_policy drop drop drop @@ -546,7 +553,7 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - )?; let mock_chain = chain_builder.build()?; - let salt = Word::from([Felt::new_unchecked(42); 4]); + let salt = salt(42); // Dry-run to capture the tx summary. let tx_summary = match mock_chain @@ -612,7 +619,11 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - // ================================================================================================ use miden_protocol::transaction::ExecutedTransaction; -use miden_standards::errors::standards::ERR_PENDING_ALREADY_SET; +use miden_standards::errors::standards::{ + ERR_CANCEL_INSUFFICIENT_SIGNATURES, + ERR_PENDING_ALREADY_SET, + ERR_TX_STILL_TIMELOCKED, +}; use miden_testing::MockChain; use miden_tx::auth::BasicAuthenticator; @@ -721,7 +732,7 @@ async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( ", )?; - let blind_inputs = SigningInputs::Blind(Word::from([Felt::from(900u32); 4])); + let blind_inputs = SigningInputs::Blind(salt(900)); let blind_msg = blind_inputs.to_commitment(); let sig_0 = authenticators[0] .get_signature(public_keys[0].to_commitment(), &blind_inputs) @@ -733,7 +744,7 @@ async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( let result = mock_chain .build_tx_context(account_id, &[], &[])? .tx_script(update_timelock_script) - .auth_args(Word::from([Felt::from(901u32); 4])) + .auth_args(salt(901)) .add_signature(public_keys[0].to_commitment(), blind_msg, sig_0) .add_signature(public_keys[1].to_commitment(), blind_msg, sig_1) .build()? @@ -791,7 +802,7 @@ async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry let result = mock_chain .build_tx_context(account_id, &[], &[])? .tx_script(execute_update_timelock_script) - .auth_args(Word::from([Felt::from(902u32); 4])) + .auth_args(salt(902)) .build()? .execute() .await; @@ -839,7 +850,7 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let result = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? .tx_script(propose_twice_script) - .auth_args(Word::from([Felt::from(301u32); 4])) + .auth_args(salt(301)) .build()? .execute() .await; @@ -858,7 +869,7 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( &mock_chain, multisig_account.id(), propose_once_script, - Word::from([Felt::from(302u32); 4]), + salt(302), &[0, 1], &public_keys, &authenticators, @@ -885,7 +896,7 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let result = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? .tx_script(cancel_twice_script) - .auth_args(Word::from([Felt::from(303u32); 4])) + .auth_args(salt(303)) .build()? .execute() .await; @@ -903,7 +914,7 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let result = mock_chain .build_tx_context(multisig_account.id(), &[], &[])? .tx_script(execute_twice_script) - .auth_args(Word::from([Felt::from(304u32); 4])) + .auth_args(salt(304)) .build()? .execute() .await; @@ -911,3 +922,459 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( Ok(()) } + +/// A successfully recorded proposal must not be executable before its `unlock_timestamp` has been +/// reached. Propose a delayed action, then immediately try to execute it on the next block (only +/// `TIMESTAMP_STEP_SECS` after the propose) — far short of the configured `min_delay` of 30 +/// seconds. The execute path's `enforce_tx_timelock` should fail with `ERR_TX_STILL_TIMELOCKED`. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_execute_before_min_delay_fails( + #[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 mut multisig_account = create_multisig_smart_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmart::update_delayed_execution_policy_root().as_word(), + ProcedurePolicy::with_delay_threshold(1)?, + )], + )?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + // The execute script that the proposal is for. + let execute_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 + ", + )?; + + // Simulate the execute tx to obtain the tx-summary commitment that will be staged. + let tx_summary = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(execute_script.clone()) + .auth_args(salt(500)) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let tx_hash_word = tx_summary.as_ref().to_commitment(); + + // Propose the tx hash (2 sigs, default threshold). The propose script signs over its own + // tx-summary; only the propose-tx itself needs sigs, not the proposed action. + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{tx_hash_word} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers( + &mock_chain, + account_id, + propose_script, + salt(501), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + // Immediately try to execute — only one block step (~10s) has passed; min_delay is 30s. + // Execute threshold is `max(default=2, delay=1) = 2`, so sign with both keys. + let result = execute_script_with_signers( + &mock_chain, + account_id, + execute_script, + salt(500), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await?; + assert_transaction_executor_error!(result, ERR_TX_STILL_TIMELOCKED); + + Ok(()) +} + +/// End-to-end happy-path round-trip: propose a delayed action, advance the chain past `min_delay`, +/// then execute it. After execution the proposal entry must be removed from `TX_PROPOSALS_SLOT`. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_full_propose_wait_execute_lifecycle( + #[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 mut multisig_account = create_multisig_smart_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + AuthMultisigSmart::update_delayed_execution_policy_root().as_word(), + ProcedurePolicy::with_delay_threshold(1)?, + )], + )?; + let account_id = multisig_account.id(); + 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 + push.2 + push.40 + call.::miden::standards::components::auth::multisig_smart::update_delayed_execution_policy + drop + drop + end + ", + )?; + + let tx_summary = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(execute_script.clone()) + .auth_args(salt(600)) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let tx_hash_word = tx_summary.as_ref().to_commitment(); + + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{tx_hash_word} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let propose_tx = execute_script_with_signers( + &mock_chain, + account_id, + propose_script, + salt(601), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + // After propose, the proposal entry is present. + let stored_before = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_hash_word) + .expect("tx proposals slot should exist"); + assert_ne!(stored_before, Word::empty(), "proposal must be written to storage"); + + // Fast-forward past `min_delay` (30s). `prove_next_block_at` writes the next block with the + // given timestamp, so we move the chain to well past unlock time in a single step. + let target_timestamp = mock_chain.latest_block_header().timestamp() + 60; + mock_chain.prove_next_block_at(target_timestamp)?; + + // Execute. Threshold = `max(default=2, delay=1) = 2`, so both keys sign. + let executed_tx = execute_script_with_signers( + &mock_chain, + account_id, + execute_script, + salt(600), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("execute tx should succeed after min_delay elapses"); + multisig_account.apply_delta(executed_tx.account_delta())?; + + // Proposal entry should be cleared after execute. + let stored_after = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_hash_word) + .expect("tx proposals slot should still exist"); + assert_eq!( + stored_after, + Word::empty(), + "proposal must be removed from storage after successful execute" + ); + + Ok(()) +} + +/// `min_cancel_sigs` is recorded as the number of signatures verified at propose time. Cancelling +/// later requires at least as many signatures. A propose tx signed by 4 keys must not be +/// cancellable by a tx signed by only 2 keys, even though 2 sigs meets the account's default +/// threshold. The cancel finalizer should panic with `ERR_CANCEL_INSUFFICIENT_SIGNATURES`. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_cancel_with_insufficient_signatures_fails( + #[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_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let pending_hash = Word::from([Felt::from(701u32); 4]); + + // Propose the mock hash with 4 sigs — this stamps min_cancel_sigs = 4 onto the entry. + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_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, + account_id, + propose_script, + salt(702), + &[0, 1, 2, 3], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx with 4 sigs should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + // Cancel with only 2 sigs — meets default_threshold but below min_cancel_sigs (4). + let cancel_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_hash} + call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal + dropw dropw dropw dropw dropw + end + " + ))?; + let result = execute_script_with_signers( + &mock_chain, + account_id, + cancel_script, + salt(703), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await?; + assert_transaction_executor_error!(result, ERR_CANCEL_INSUFFICIENT_SIGNATURES); + + Ok(()) +} + +/// After `update_delayed_execution_policy` rotates `min_delay`, subsequent proposals must compute +/// `unlock_timestamp` using the new `min_delay`, not the previous one. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_policy_rotation_applies_to_new_proposals( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + // No proc policy on `update_delayed_execution_policy` — it runs on the immediate path under + // the default threshold, which makes the rotation a single round-trip. + let mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let new_min_delay = 90u32; + let new_expiration_delta = 5u32; + let rotate_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{new_expiration_delta} + push.{new_min_delay} + call.::miden::standards::components::auth::multisig_smart::update_delayed_execution_policy + drop + drop + end + " + ))?; + let rotate_tx = execute_script_with_signers( + &mock_chain, + account_id, + rotate_script, + salt(800), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("policy rotation tx should succeed"); + multisig_account.apply_delta(rotate_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&rotate_tx)?; + mock_chain.prove_next_block()?; + + // Stored policy reflects the new values. + let stored_policy = multisig_account + .storage() + .get_item(AuthMultisigSmart::delayed_execution_slot()) + .expect("delayed-execution slot should exist"); + assert_eq!( + stored_policy, + Word::from([new_min_delay, new_expiration_delta, 0, 0]), + "stored DelayedExecutionPolicy must reflect the rotated values" + ); + + // A subsequent proposal uses the new `min_delay`. Propose a mock hash and verify + // `unlock_timestamp - proposal_timestamp == new_min_delay`. + let pending_hash = Word::from([Felt::from(801u32); 4]); + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{pending_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, + account_id, + propose_script, + salt(802), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx should succeed after rotation"); + multisig_account.apply_delta(propose_tx.account_delta())?; + + let proposal_entry = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), pending_hash) + .expect("tx proposals slot should exist"); + // Entry layout: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + let elements: &[Felt] = proposal_entry.as_ref(); + let unlock_ts = elements[0].as_canonical_u64(); + let proposal_ts = elements[1].as_canonical_u64(); + assert_eq!( + unlock_ts - proposal_ts, + new_min_delay as u64, + "post-rotation propose must use the new min_delay" + ); + + Ok(()) +} + +/// Two distinct proposals must be storable side-by-side in `TX_PROPOSALS_SLOT`. After two +/// independent propose tx's complete, both entries must remain in the map. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_multiple_concurrent_proposals_coexist( + #[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 mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let hash_a = Word::from([Felt::from(901u32); 4]); + let hash_b = Word::from([Felt::from(902u32); 4]); + + for (hash, salt_seed) in [(hash_a, 903u32), (hash_b, 904u32)] { + let script = compile_multisig_smart_tx_script(format!( + " + begin + push.{hash} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let tx = execute_script_with_signers( + &mock_chain, + account_id, + script, + salt(salt_seed), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_delta(tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&tx)?; + mock_chain.prove_next_block()?; + } + + // Both proposals must exist in storage. + for hash in [hash_a, hash_b] { + let entry = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), hash) + .expect("tx proposals slot should exist"); + assert_ne!(entry, Word::empty(), "proposal entry must be present in storage"); + } + + Ok(()) +} From d4bb726680ee8df0931904f064557d898f4dad8b Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 16:52:20 +0200 Subject: [PATCH 06/15] add more tests --- .../tests/auth/multisig_smart.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index e873d5fe70..49721b01ba 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -622,6 +622,8 @@ use miden_protocol::transaction::ExecutedTransaction; use miden_standards::errors::standards::{ ERR_CANCEL_INSUFFICIENT_SIGNATURES, ERR_PENDING_ALREADY_SET, + ERR_TX_ALREADY_PROPOSED, + ERR_TX_NOT_PROPOSED, ERR_TX_STILL_TIMELOCKED, }; use miden_testing::MockChain; @@ -1378,3 +1380,106 @@ async fn test_multisig_smart_multiple_concurrent_proposals_coexist( Ok(()) } + +/// `cancel_and_propose_new_transaction` has two failure branches that fire during the +/// user-script phase (before any signature verification): +/// - The OLD tx hash must already be proposed, otherwise `ERR_TX_NOT_PROPOSED`. +/// - The NEW tx hash must NOT already be proposed, otherwise `ERR_TX_ALREADY_PROPOSED`. +/// +/// Both branches panic via `assert.err=...` deep inside the proc, so the tx never reaches the +/// auth finalizer and signatures are irrelevant — we execute without signers. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_cancel_and_propose_failure_modes( + #[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 mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let hash_a = Word::from([Felt::from(1001u32); 4]); + let hash_b = Word::from([Felt::from(1002u32); 4]); + let hash_never_proposed = Word::from([Felt::from(1003u32); 4]); + + // ----- Branch 1: OLD_TX_HASH was never proposed → ERR_TX_NOT_PROPOSED. + // + // MASM stack convention: `cancel_and_propose_new_transaction` consumes [OLD_TX_HASH, + // NEW_TX_HASH] (top → bottom). The script pushes NEW first so it lands below OLD. + let old_not_proposed_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{hash_b} + push.{hash_never_proposed} + call.::miden::standards::components::auth::multisig_smart::cancel_and_propose_new_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let result = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(old_not_proposed_script) + .auth_args(salt(1010)) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_TX_NOT_PROPOSED); + + // Pre-propose `hash_a` and `hash_b` so that branch 2 can fail on the NEW side. + for (hash, seed) in [(hash_a, 1011u32), (hash_b, 1012u32)] { + let propose_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{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, + account_id, + propose_script, + salt(seed), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_delta(propose_tx.account_delta())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + } + + // ----- Branch 2: NEW_TX_HASH is already proposed → ERR_TX_ALREADY_PROPOSED. + // + // OLD = hash_a (valid existing proposal), NEW = hash_b (also already exists). + let new_already_proposed_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{hash_b} + push.{hash_a} + call.::miden::standards::components::auth::multisig_smart::cancel_and_propose_new_transaction + dropw dropw dropw dropw dropw + end + " + ))?; + let result = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(new_already_proposed_script) + .auth_args(salt(1013)) + .build()? + .execute() + .await; + assert_transaction_executor_error!(result, ERR_TX_ALREADY_PROPOSED); + + Ok(()) +} From 216f8f46a8aa921bddd22281bd86e5ed16a70e9b Mon Sep 17 00:00:00 2001 From: onurinanc Date: Thu, 4 Jun 2026 17:10:33 +0200 Subject: [PATCH 07/15] remove optional delayed execution --- .../account/auth/multisig_smart/component.rs | 78 +++++++++---------- crates/miden-testing/src/mock_chain/auth.rs | 16 ++-- .../tests/auth/multisig_smart.rs | 6 +- 3 files changed, 47 insertions(+), 53 deletions(-) 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 70842f7d22..232a9a5174 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -92,16 +92,20 @@ pub struct AuthMultisigSmartConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, procedure_policies: Vec<(Word, ProcedurePolicy)>, - delayed_execution: Option, + delayed_execution: DelayedExecutionPolicy, } impl AuthMultisigSmartConfig { - /// Creates a new configuration with the given approvers and a default threshold. + /// Creates a new configuration with the given approvers, default threshold, and delayed + /// execution policy. /// /// The `default_threshold` must be at least 1 and at most the number of approvers. + /// `delayed_execution` is required — `AuthMultisigSmart` always runs with a configured + /// timelock policy; see [`DelayedExecutionPolicy::new`] for its validation rules. pub fn new( approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, + delayed_execution: DelayedExecutionPolicy, ) -> Result { if default_threshold == 0 { return Err(AccountError::other("threshold must be at least 1")); @@ -122,7 +126,7 @@ impl AuthMultisigSmartConfig { approvers, default_threshold, procedure_policies: vec![], - delayed_execution: None, + delayed_execution, }) } @@ -136,16 +140,6 @@ impl AuthMultisigSmartConfig { Ok(self) } - /// Enables delayed execution with the given policy (min delay + propose expiration delta). - /// - /// When unset, the on-chain `DELAYED_EXECUTION_SLOT` is initialized to `[0, 0, 0, 0]` and any - /// attempt to call `propose_transaction` / `execute_proposed_transaction` will panic at - /// runtime — the feature is opt-in via this builder. - pub fn with_delayed_execution(mut self, delayed_execution: DelayedExecutionPolicy) -> Self { - self.delayed_execution = Some(delayed_execution); - self - } - pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { &self.approvers } @@ -158,7 +152,7 @@ impl AuthMultisigSmartConfig { &self.procedure_policies } - pub fn delayed_execution(&self) -> Option { + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { self.delayed_execution } } @@ -237,9 +231,8 @@ impl AuthMultisigSmart { self.config.procedure_policies() } - /// Returns the configured delayed-execution policy, or `None` if delayed execution is - /// disabled for this account. - pub fn delayed_execution(&self) -> Option { + /// Returns the configured delayed-execution policy. + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { self.config.delayed_execution() } @@ -410,21 +403,15 @@ impl From for AccountComponent { )); // Delayed-execution policy slot (value: [min_delay, propose_expiration_delta, 0, 0]). - // When the feature is not enabled, the slot is initialized to the empty word so that - // any attempt to call `propose_transaction` / `execute_proposed_transaction` fails at - // runtime via the MASM zero-checks. - let delayed_execution_word = match multisig.config.delayed_execution() { - Some(policy) => Word::from([ - policy.min_delay(), - policy.propose_expiration_delta() as u32, + let delayed_execution = multisig.config.delayed_execution(); + 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, ]), - None => Word::empty(), - }; - storage_slots.push(StorageSlot::with_value( - AuthMultisigSmart::delayed_execution_slot().clone(), - delayed_execution_word, )); // Tx-proposals map slot (TX_HASH => [unlock_timestamp, expiration_height, 0, 0]) @@ -481,6 +468,10 @@ mod tests { use super::*; use crate::account::wallets::BasicWallet; + fn default_delayed_execution_policy() -> DelayedExecutionPolicy { + DelayedExecutionPolicy::new(30, 2).expect("default test policy must be valid") + } + #[test] fn test_multisig_smart_component_setup() { let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); @@ -493,14 +484,18 @@ mod tests { let default_threshold = 2u32; let receive_asset_immediate_threshold = 1u32; - let config = AuthMultisigSmartConfig::new(approvers.clone(), default_threshold) - .expect("invalid multisig smart config") - .with_proc_policies(vec![( - BasicWallet::receive_asset_root().as_word(), - ProcedurePolicy::with_immediate_threshold(receive_asset_immediate_threshold) - .expect("procedure policy should be valid"), - )]) - .expect("procedure policy config should be valid"); + let config = AuthMultisigSmartConfig::new( + approvers.clone(), + default_threshold, + default_delayed_execution_policy(), + ) + .expect("invalid multisig smart config") + .with_proc_policies(vec![( + BasicWallet::receive_asset_root().as_word(), + ProcedurePolicy::with_immediate_threshold(receive_asset_immediate_threshold) + .expect("procedure policy should be valid"), + )]) + .expect("procedure policy config should be valid"); let component = AuthMultisigSmart::new(config).expect("multisig smart component creation failed"); @@ -535,10 +530,11 @@ mod tests { let sec_key = AuthSecretKey::new_ecdsa_k256_keccak(); let approvers = vec![(sec_key.public_key().to_commitment(), sec_key.auth_scheme())]; - let result = AuthMultisigSmartConfig::new(approvers.clone(), 0); + let policy = default_delayed_execution_policy(); + let result = AuthMultisigSmartConfig::new(approvers.clone(), 0, policy); assert!(result.unwrap_err().to_string().contains("threshold must be at least 1")); - let result = AuthMultisigSmartConfig::new(approvers, 2); + let result = AuthMultisigSmartConfig::new(approvers, 2, policy); assert!( result .unwrap_err() @@ -562,7 +558,7 @@ mod tests { let policy_two = ProcedurePolicy::with_immediate_threshold(2).expect("procedure policy should be valid"); - let result = AuthMultisigSmartConfig::new(approvers, 2) + let result = AuthMultisigSmartConfig::new(approvers, 2, default_delayed_execution_policy()) .expect("base config should be valid") .with_proc_policies(vec![ (receive_asset_root, policy_one), @@ -588,7 +584,7 @@ mod tests { (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; - let result = AuthMultisigSmartConfig::new(approvers, 2); + let result = AuthMultisigSmartConfig::new(approvers, 2, default_delayed_execution_policy()); assert!( result .unwrap_err() diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 8dd0266b99..84dc43a486 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -52,13 +52,13 @@ pub enum Auth { proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, }, - /// Multisig with smart per-procedure policy configuration and an optional delayed-execution - /// policy controlling propose/cancel/execute timelock flows. + /// Multisig with smart per-procedure policy configuration and a delayed-execution policy + /// controlling propose/cancel/execute timelock flows. MultisigSmart { threshold: u32, approvers: Vec<(PublicKeyCommitment, AuthScheme)>, proc_policy_map: Vec<(Word, ProcedurePolicy)>, - delayed_execution: Option, + delayed_execution: DelayedExecutionPolicy, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to @@ -139,12 +139,10 @@ impl Auth { proc_policy_map, delayed_execution, } => { - let mut config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) - .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) - .expect("invalid multisig smart config"); - if let Some(de) = delayed_execution { - config = config.with_delayed_execution(*de); - } + let config = + AuthMultisigSmartConfig::new(approvers.clone(), *threshold, *delayed_execution) + .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) + .expect("invalid multisig smart config"); let component = AuthMultisigSmart::new(config) .expect("multisig smart component creation failed") diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 49721b01ba..1aff2dc8be 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -44,9 +44,9 @@ fn create_multisig_smart_account( ) -> 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)? - .with_delayed_execution(DelayedExecutionPolicy::new(30, 2)?); + let config = + AuthMultisigSmartConfig::new(approvers, threshold, DelayedExecutionPolicy::new(30, 2)?)? + .with_proc_policies(proc_policy_map)?; let asset = FungibleAsset::new( AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, From 4210460d71e7073fe0343b6db5873a7c1ab47eb9 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 9 Jun 2026 15:44:44 +0200 Subject: [PATCH 08/15] nits --- .../multisig_smart/delayed_execution.masm | 488 +++++++----------- .../standards/auth/multisig_smart/mod.masm | 7 +- .../account/auth/multisig_smart/component.rs | 15 +- .../tests/auth/multisig_smart.rs | 35 +- 4 files changed, 223 insertions(+), 322 deletions(-) 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 index 7d2df48738..8330921ede 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -9,7 +9,7 @@ use miden::protocol::tx # [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] +# Map entries: [TX_SUMMARY_COMMITMENT] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_proposals") # Pending action slots @@ -34,7 +34,6 @@ 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" @@ -48,9 +47,11 @@ const ERR_TX_EXPIRATION_DELTA_NOT_SET = "tx expiration delta must be set" #! 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`). +#! Where: +#! - min_delay is the minimum number of seconds that must elapse between a proposal being +#! recorded and the matching execute call. +#! - propose_expiration_delta is the block delta applied to proposal transactions so that +#! unsatisfied proposals expire after that many blocks. #! #! Panics if: #! - `min_delay` is not a valid `u32` field element (`ERR_MIN_DELAY_NOT_U32`). @@ -88,7 +89,7 @@ end #! Loads the delayed execution policy configuration from `DELAYED_EXECUTION_SLOT`. #! #! Inputs: [] -#! Outputs: [min_delay, propose_expiration_delta, 0, 0] +#! Outputs: [min_delay, propose_expiration_delta] #! #! Where: #! - min_delay is the minimum number of seconds that must elapse between a `propose_transaction` @@ -97,12 +98,15 @@ end #! unsatisfied proposals expire after `propose_expiration_delta` blocks. #! #! Invocation: exec -proc get_delayed_execution +proc get_delayed_execution_config push.DELAYED_EXECUTION_SLOT[0..2] # => [slot_prefix, slot_suffix] exec.active_account::get_initial_item # => [min_delay, propose_expiration_delta, 0, 0] + + movup.3 drop movup.2 drop + # => [min_delay, propose_expiration_delta] end #! Returns 1 if `execute_proposed_transaction` was called in the current transaction, 0 otherwise. @@ -136,10 +140,10 @@ end #! #! Invocation: exec proc get_min_delay - exec.get_delayed_execution - # => [min_delay, propose_expiration_delta, 0, 0] + exec.get_delayed_execution_config + # => [min_delay, propose_expiration_delta] - movdn.3 drop drop drop + swap drop # => [min_delay] end @@ -153,10 +157,10 @@ end #! #! Invocation: exec proc get_propose_expiration_delta - exec.get_delayed_execution - # => [min_delay, propose_expiration_delta, 0, 0] + exec.get_delayed_execution_config + # => [min_delay, propose_expiration_delta] - drop movdn.2 drop drop + drop # => [propose_expiration_delta] end @@ -195,12 +199,6 @@ end #! Applies the configured `propose_expiration_delta` to the current transaction. #! -#! Reads `propose_expiration_delta` from the delayed execution policy and forwards it to -#! `apply_expiration_delta`, which writes the value through `tx::update_expiration_block_delta` -#! and asserts the resulting transaction expiration block delta is non-zero. This is the helper -#! used by `propose_transaction` and `cancel_and_propose_new_transaction` to make sure every -#! proposal-bearing transaction expires within the policy-defined window. -#! #! Inputs: [] #! Outputs: [] #! @@ -211,10 +209,6 @@ end #! - the stored transaction expiration delta is zero after the update #! (`ERR_TX_EXPIRATION_DELTA_NOT_SET`). #! -#! Side effects: -#! - Calls `tx::update_expiration_block_delta` to overwrite the current transaction's -#! expiration block delta with the configured `propose_expiration_delta`. -#! #! Invocation: exec proc apply_propose_expiration_delta exec.get_propose_expiration_delta @@ -227,43 +221,40 @@ end # TIMELOCK + PROPOSALS HELPERS # ================================================================================================= -#! Returns the proposal entry stored for `TX_HASH`. +#! Returns the proposal entry stored for `TX_SUMMARY_COMMITMENT`. #! -#! Inputs: [TX_HASH] -#! Outputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] #! #! 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. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment 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. +#! - proposal_exists is 1 if a proposal exists for `TX_SUMMARY_COMMITMENT`, otherwise 0. #! #! Invocation: exec -proc get_tx_proposal(tx_hash: word) +proc get_tx_proposal(tx_summary_commitment: word) push.TX_PROPOSALS_SLOT[0..2] - # => [slot_prefix, slot_suffix, TX_HASH] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] exec.active_account::get_map_item - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] end -#! Returns 1 if a proposal exists for `TX_HASH`, 0 otherwise. +#! Returns 1 if a proposal exists for `TX_SUMMARY_COMMITMENT`, 0 otherwise. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [is_proposed] #! #! Where: -#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! - TX_SUMMARY_COMMITMENT 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 +proc is_tx_proposed(tx_summary_commitment: word) -> u32 exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] exec.word::eqz # => [is_proposal_empty] @@ -273,27 +264,24 @@ proc is_tx_proposed(tx_hash: word) -> u32 end -#! Deletes the proposal entry for `TX_HASH` by writing `EMPTY_WORD`. +#! Deletes the proposal entry for `TX_SUMMARY_COMMITMENT` by writing `EMPTY_WORD`. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! 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]`. +#! - TX_SUMMARY_COMMITMENT is the transaction summary hash used as the proposal map key. #! #! Invocation: exec -proc remove_tx_proposal(tx_hash: word) +proc remove_tx_proposal(tx_summary_commitment: word) push.EMPTY_WORD - # => [EMPTY_WORD, TX_HASH] + # => [EMPTY_WORD, TX_SUMMARY_COMMITMENT] swapw - # => [TX_HASH, EMPTY_WORD] + # => [TX_SUMMARY_COMMITMENT, EMPTY_WORD] push.TX_PROPOSALS_SLOT[0..2] - # => [slot_prefix, slot_suffix, TX_HASH, EMPTY_WORD] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT, EMPTY_WORD] exec.native_account::set_map_item # => [OLD_PROPOSAL_WORD] @@ -303,37 +291,33 @@ proc remove_tx_proposal(tx_hash: word) end -#! Writes the proposal entry for `TX_HASH` unconditionally. +#! Writes the proposal entry for `TX_SUMMARY_COMMITMENT` unconditionally. #! -#! The `is_set` flag is always written as `1`. The caller is responsible for ensuring +#! The `proposal_exists` 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] +#! Inputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] #! 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]`. +#! - TX_SUMMARY_COMMITMENT is the transaction summary hash used as the proposal map key. #! #! Invocation: exec proc write_tx_proposal push.1 - # => [1, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_HASH] + # => [1, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] movdn.3 - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1, TX_HASH] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1, TX_SUMMARY_COMMITMENT] swapw - # => [TX_HASH, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + # => [TX_SUMMARY_COMMITMENT, 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] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] exec.native_account::set_map_item # => [OLD_PROPOSAL_WORD] @@ -367,68 +351,58 @@ end #! Asserts that the proposal unlock timestamp has been reached. #! -#! Inputs: [current_timestamp, unlock_timestamp] +#! Inputs: [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 +#! - unlock_timestamp or the current block timestamp is not a valid `u32` field element #! (`ERR_TIMESTAMPS_NOT_U32`). -#! - current_timestamp is less than unlock_timestamp (`ERR_TX_STILL_TIMELOCKED`). +#! - the current block timestamp is less than unlock_timestamp (`ERR_TX_STILL_TIMELOCKED`). #! #! Invocation: exec proc assert_unlock_reached + exec.tx::get_block_timestamp + # => [current_timestamp, unlock_timestamp] + u32assert2.err=ERR_TIMESTAMPS_NOT_U32 # => [current_timestamp, unlock_timestamp] swap # => [unlock_timestamp, current_timestamp] - u32lt assertz.err=ERR_TX_STILL_TIMELOCKED + u32gte assert.err=ERR_TX_STILL_TIMELOCKED # => [] end -#! Enforces that `TX_HASH` is proposed and that its unlock timestamp has been reached. +#! Enforces that `TX_SUMMARY_COMMITMENT` is proposed and that its unlock timestamp has been reached. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [min_cancel_sigs] #! #! Where: -#! - TX_HASH is the transaction summary hash used as the proposal map key. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment 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`). +#! - no proposal exists for `TX_SUMMARY_COMMITMENT` (`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] + exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] - dropw - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + movup.3 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] - exec.tx::get_block_timestamp - # => [current_timestamp, unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set] + swap drop + # => [unlock_timestamp, min_cancel_sigs] 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 @@ -439,10 +413,10 @@ end #! Returns the pending propose transaction summary hash, or `EMPTY_WORD` if none is set. #! #! Inputs: [] -#! Outputs: [PENDING_PROPOSE_HASH] +#! Outputs: [PENDING_PROPOSE_COMMITMENT] #! #! Where: -#! - PENDING_PROPOSE_HASH is the pending propose transaction summary hash, or +#! - PENDING_PROPOSE_COMMITMENT is the pending propose transaction summary hash, or #! `EMPTY_WORD` if no propose action is pending. #! #! Invocation: exec @@ -451,17 +425,17 @@ proc get_pending_propose # => [slot_prefix, slot_suffix] exec.active_account::get_item - # => [PENDING_PROPOSE_HASH] + # => [PENDING_PROPOSE_COMMITMENT] end #! Returns the pending cancel transaction summary hash, or `EMPTY_WORD` if none is set. #! #! Inputs: [] -#! Outputs: [PENDING_CANCEL_HASH] +#! Outputs: [PENDING_CANCEL_COMMITMENT] #! #! Where: -#! - PENDING_CANCEL_HASH is the pending cancel transaction summary hash, or +#! - PENDING_CANCEL_COMMITMENT is the pending cancel transaction summary hash, or #! `EMPTY_WORD` if no cancel action is pending. #! #! Invocation: exec @@ -470,18 +444,18 @@ proc get_pending_cancel # => [slot_prefix, slot_suffix] exec.active_account::get_item - # => [PENDING_CANCEL_HASH] + # => [PENDING_CANCEL_COMMITMENT] end -#! Returns the pending execute transaction summary hash, or `EMPTY_WORD` if none is set. +#! Returns the pending execute marker word, or `EMPTY_WORD` if no execute action is pending. #! #! Inputs: [] -#! Outputs: [PENDING_EXECUTE_HASH] +#! Outputs: [PENDING_EXECUTE_WORD] #! #! Where: -#! - PENDING_EXECUTE_HASH is the pending execute transaction summary hash, or -#! `EMPTY_WORD` if no execute action is pending. +#! - PENDING_EXECUTE_WORD is `PENDING_EXECUTE_FLAG` if an execute action is pending, +#! or `EMPTY_WORD` otherwise. #! #! Invocation: exec proc get_pending_execute @@ -489,43 +463,31 @@ proc get_pending_execute # => [slot_prefix, slot_suffix] exec.active_account::get_item - # => [PENDING_EXECUTE_HASH] + # => [PENDING_EXECUTE_WORD] end -#! Sets the pending propose slot to `TX_HASH`. +#! Sets the pending propose slot to `TX_SUMMARY_COMMITMENT`. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - TX_HASH is the transaction summary hash to stage for proposal. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment 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] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] exec.native_account::set_item - # => [old_pending_propose_word] + # => [OLD_PENDING_PROPOSE_WORD] - dropw + exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET # => [] end @@ -534,9 +496,6 @@ end #! Inputs: [] #! Outputs: [] #! -#! Side effects: -#! - Writes `EMPTY_WORD` to `PENDING_PROPOSE_SLOT`. -#! #! Invocation: exec proc clear_pending_propose push.EMPTY_WORD @@ -546,45 +505,33 @@ proc clear_pending_propose # => [slot_prefix, slot_suffix, EMPTY_WORD] exec.native_account::set_item - # => [OLD_PENDING_PROPOSE_HASH] + # => [OLD_PENDING_PROPOSE_COMMITMENT] dropw # => [] end -#! Sets the pending cancel slot to `TX_HASH`. +#! Sets the pending cancel slot to `TX_SUMMARY_COMMITMENT`. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - TX_HASH is the transaction summary hash to stage for cancellation. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment 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] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] exec.native_account::set_item - # => [old_pending_cancel_word] + # => [OLD_PENDING_CANCEL_WORD] - dropw + exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET # => [] end @@ -593,9 +540,6 @@ end #! Inputs: [] #! Outputs: [] #! -#! Side effects: -#! - Writes `EMPTY_WORD` to `PENDING_CANCEL_SLOT`. -#! #! Invocation: exec proc clear_pending_cancel push.EMPTY_WORD @@ -605,7 +549,7 @@ proc clear_pending_cancel # => [slot_prefix, slot_suffix, EMPTY_WORD] exec.native_account::set_item - # => [OLD_PENDING_CANCEL_HASH] + # => [OLD_PENDING_CANCEL_COMMITMENT] dropw # => [] @@ -620,20 +564,8 @@ end #! 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] @@ -641,9 +573,9 @@ proc set_pending_execute # => [slot_prefix, slot_suffix, PENDING_EXECUTE_FLAG] exec.native_account::set_item - # => [old_pending_execute_word] + # => [OLD_PENDING_EXECUTE_WORD] - dropw + exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET # => [] end @@ -653,9 +585,6 @@ end #! Inputs: [] #! Outputs: [] #! -#! Side effects: -#! - Writes `EMPTY_WORD` to `PENDING_EXECUTE_SLOT`. -#! #! Invocation: exec proc clear_pending_execute push.EMPTY_WORD @@ -675,39 +604,35 @@ end # TX ACTIONS API # ================================================================================================= -#! Stages a new proposal for `TX_HASH`. +#! Stages a new proposal for `TX_SUMMARY_COMMITMENT`. #! #! Any signer can call this procedure. The proposal word itself is written later in the auth #! finalizer after signature verification completes. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - TX_HASH is the transaction summary hash of the proposal to stage. +#! - TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to stage. #! #! Panics if: -#! - a proposal already exists for `TX_HASH` (`ERR_TX_ALREADY_PROPOSED`). +#! - a proposal already exists for `TX_SUMMARY_COMMITMENT` (`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] + # => [TX_SUMMARY_COMMITMENT] dupw - # => [TX_HASH, TX_HASH] + # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.is_tx_proposed - # => [is_proposed, TX_HASH] + # => [is_proposed, TX_SUMMARY_COMMITMENT] assertz.err=ERR_TX_ALREADY_PROPOSED - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] exec.set_pending_propose # => [] @@ -718,29 +643,26 @@ end #! #! The proposal is not removed immediately, the actual delete happens in the auth finalizer. #! -#! Inputs: [TX_HASH] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - TX_HASH is the transaction summary hash of the proposal to cancel. +#! - TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to cancel. #! #! Panics if: -#! - no proposal exists for `TX_HASH` (`ERR_TX_NOT_PROPOSED`). +#! - no proposal exists for `TX_SUMMARY_COMMITMENT` (`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] + # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.is_tx_proposed - # => [is_proposed, TX_HASH] + # => [is_proposed, TX_SUMMARY_COMMITMENT] assert.err=ERR_TX_NOT_PROPOSED - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] exec.set_pending_cancel # => [] @@ -752,55 +674,50 @@ end #! 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] +#! Inputs: [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] #! 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. +#! - OLD_TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to cancel. +#! - NEW_TX_SUMMARY_COMMITMENT 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`). +#! - no proposal exists for `OLD_TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). +#! - a proposal already exists for `NEW_TX_SUMMARY_COMMITMENT` (`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] + # => [OLD_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] exec.is_tx_proposed - # => [is_old_tx_proposed, OLD_TX_HASH, NEW_TX_HASH] + # => [is_old_tx_proposed, OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] assert.err=ERR_TX_NOT_PROPOSED - # => [OLD_TX_HASH, NEW_TX_HASH] + # => [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] swapw - # => [NEW_TX_HASH, OLD_TX_HASH] + # => [NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] dupw - # => [NEW_TX_HASH, NEW_TX_HASH, OLD_TX_HASH] + # => [NEW_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] exec.is_tx_proposed - # => [is_new_tx_proposed, NEW_TX_HASH, OLD_TX_HASH] + # => [is_new_tx_proposed, NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] assertz.err=ERR_TX_ALREADY_PROPOSED - # => [NEW_TX_HASH, OLD_TX_HASH] + # => [NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] swapw - # => [OLD_TX_HASH, NEW_TX_HASH] + # => [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] exec.set_pending_cancel - # => [NEW_TX_HASH] + # => [NEW_TX_SUMMARY_COMMITMENT] exec.apply_propose_expiration_delta - # => [NEW_TX_HASH] + # => [NEW_TX_SUMMARY_COMMITMENT] exec.set_pending_propose # => [] @@ -815,9 +732,6 @@ end #! 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 @@ -829,22 +743,18 @@ end #! Finalizes a pending execute action for the current transaction. #! -#! Inputs: [num_verified_signatures, requires_delay, TX_HASH] -#! Outputs: [num_verified_signatures, TX_HASH] +#! Inputs: [num_verified_signatures, requires_delay, TX_SUMMARY_COMMITMENT] +#! Outputs: [num_verified_signatures, TX_SUMMARY_COMMITMENT] #! #! Where: #! - num_verified_signatures is the number of signatures verified for the current transaction. #! - requires_delay is 1 if the current transaction must run in delayed execution mode, #! otherwise 0. -#! - TX_HASH is the current transaction summary hash. +#! - TX_SUMMARY_COMMITMENT 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 #! @@ -852,130 +762,108 @@ end @locals(1) proc finalize_pending_execute loc_store.0 - # => [requires_delay, TX_HASH] + # => [requires_delay, TX_SUMMARY_COMMITMENT] exec.get_pending_execute - # => [PENDING_EXECUTE_HASH, requires_delay, TX_HASH] + # => [PENDING_EXECUTE_HASH, requires_delay, TX_SUMMARY_COMMITMENT] exec.word::eqz - # => [is_pending_execute_empty, requires_delay, TX_HASH] + # => [is_pending_execute_empty, requires_delay, TX_SUMMARY_COMMITMENT] if.true drop - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] else if.true dupw - # => [TX_HASH, TX_HASH] + # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.enforce_tx_timelock - # => [min_cancel_sigs, TX_HASH] + # => [min_cancel_sigs, TX_SUMMARY_COMMITMENT] drop - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] end dupw - # => [TX_HASH, TX_HASH] + # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.remove_tx_proposal - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] end exec.clear_pending_execute - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] loc_load.0 - # => [num_verified_signatures, TX_HASH] + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] end #! Finalizes a pending cancel action for the current transaction. #! -#! Inputs: [num_verified_signatures, TX_HASH] -#! Outputs: [num_verified_signatures, TX_HASH] +#! Inputs: [num_verified_signatures] +#! Outputs: [] #! #! 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`). +#! - the pending cancel commitment 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] + # => [PENDING_CANCEL_COMMITMENT, num_verified_signatures] exec.word::eqz - # => [is_pending_cancel_empty, TX_HASH] + # => [is_pending_cancel_empty, num_verified_signatures] - if.false + if.true + # No pending cancel; drop the unused num_verified_signatures. + drop + else exec.get_pending_cancel - # => [PENDING_CANCEL_HASH, TX_HASH] + # => [PENDING_CANCEL_COMMITMENT, num_verified_signatures] - dupw - # => [PENDING_CANCEL_HASH, PENDING_CANCEL_HASH, TX_HASH] + dupw exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, is_set, PENDING_CANCEL_HASH, TX_HASH] + movup.3 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - 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 swap drop + # => [min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - drop drop swap drop - # => [min_cancel_sigs, PENDING_CANCEL_HASH, TX_HASH] + movup.5 swap + # => [min_cancel_sigs, num_verified_signatures, PENDING_CANCEL_COMMITMENT] - 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] + u32gte assert.err=ERR_CANCEL_INSUFFICIENT_SIGNATURES + # => [PENDING_CANCEL_COMMITMENT] 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] +#! Inputs: [num_verified_signatures, TX_SUMMARY_COMMITMENT] +#! Outputs: [TX_SUMMARY_COMMITMENT] #! #! Where: #! - num_verified_signatures is the number of signatures verified for the current transaction. -#! - TX_HASH is the current transaction summary hash. +#! - TX_SUMMARY_COMMITMENT 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 #! @@ -983,51 +871,51 @@ end @locals(1) proc finalize_pending_propose loc_store.0 - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] exec.get_pending_propose - # => [PENDING_PROPOSE_HASH, TX_HASH] + # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.word::eqz - # => [is_pending_propose_empty, TX_HASH] + # => [is_pending_propose_empty, TX_SUMMARY_COMMITMENT] if.false exec.get_pending_propose - # => [PENDING_PROPOSE_HASH, TX_HASH] + # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] dupw - # => [PENDING_PROPOSE_HASH, PENDING_PROPOSE_HASH, TX_HASH] + # => [PENDING_PROPOSE_COMMITMENT, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.is_tx_proposed - # => [is_proposed, PENDING_PROPOSE_HASH, TX_HASH] + # => [is_proposed, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] assertz.err=ERR_TX_ALREADY_PROPOSED - # => [PENDING_PROPOSE_HASH, TX_HASH] + # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] loc_load.0 - # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] dup - # => [num_verified_signatures, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [num_verified_signatures, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] eq.0 - # => [num_verified_eq_0, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [num_verified_eq_0, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] assertz.err=ERR_PROPOSE_ZERO_SIGNATURES - # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.apply_propose_expiration_delta - # => [num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.compute_unlock_timestamp - # => [unlock_timestamp, proposal_timestamp, num_verified_signatures, PENDING_PROPOSE_HASH, TX_HASH] + # => [unlock_timestamp, proposal_timestamp, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.write_tx_proposal - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] end exec.clear_pending_propose - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] end #! Finalizes all staged timelock actions for the current transaction. @@ -1037,34 +925,36 @@ end #! 2. cancel #! 3. propose #! -#! Inputs: [num_verified_signatures, requires_delay, TX_HASH] -#! Outputs: [TX_HASH] +#! Inputs: [num_verified_signatures, requires_delay, TX_SUMMARY_COMMITMENT] +#! Outputs: [TX_SUMMARY_COMMITMENT] #! #! Where: #! - num_verified_signatures is the number of signatures verified for the current transaction. #! - requires_delay is 1 if the current transaction must run in delayed execution mode, #! otherwise 0. -#! - TX_HASH is the current transaction summary hash. +#! - TX_SUMMARY_COMMITMENT 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] + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + + # `finalize_pending_cancel` consumes `num_verified_signatures`; dup it so + # `finalize_pending_propose` still has the value available below. + dup movdn.5 + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, num_verified_signatures] exec.finalize_pending_cancel - # => [num_verified_signatures, TX_HASH] + # => [TX_SUMMARY_COMMITMENT, num_verified_signatures] + + movup.4 + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] exec.finalize_pending_propose - # => [TX_HASH] + # => [TX_SUMMARY_COMMITMENT] 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 7f37650b48..57fd0324eb 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -60,7 +60,7 @@ 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" +const ERR_EXECUTE_PATH_MISMATCH = "execute_proposed_transaction must be called iff a called procedure requires delayed execution" # LOCAL ADDRESSES (set_procedure_policy) # ================================================================================================= @@ -417,11 +417,14 @@ end #! panic via [`compute_called_proc_policy`] because this component has no timelock. #! #! 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_threshold is the effective threshold required by the called procedure policies. +#! - policy_requires_delay is 1 when any called procedure's policy explicitly requires the +#! delayed execution mode, 0 otherwise. #! #! Invocation: exec #! 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 232a9a5174..a53278ce4b 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -322,7 +322,7 @@ impl AuthMultisigSmart { ( Self::tx_proposals_slot().clone(), StorageSlotSchema::map( - "Active tx proposals: tx_hash => [unlock_timestamp, expiration_height, 0, 0]", + "Active tx proposals: tx_summary_commitment => [unlock_timestamp, expiration_height, 0, 0]", SchemaType::native_word(), SchemaType::native_word(), ), @@ -332,14 +332,20 @@ impl AuthMultisigSmart { pub fn pending_propose_slot_schema() -> (StorageSlotName, StorageSlotSchema) { ( Self::pending_propose_slot().clone(), - StorageSlotSchema::value("Pending propose: TX_HASH", SchemaType::native_word()), + StorageSlotSchema::value( + "Pending propose: TX_SUMMARY_COMMITMENT", + SchemaType::native_word(), + ), ) } pub fn pending_cancel_slot_schema() -> (StorageSlotName, StorageSlotSchema) { ( Self::pending_cancel_slot().clone(), - StorageSlotSchema::value("Pending cancel: TX_HASH", SchemaType::native_word()), + StorageSlotSchema::value( + "Pending cancel: TX_SUMMARY_COMMITMENT", + SchemaType::native_word(), + ), ) } @@ -414,7 +420,8 @@ impl From for AccountComponent { ]), )); - // Tx-proposals map slot (TX_HASH => [unlock_timestamp, expiration_height, 0, 0]) + // Tx-proposals map slot (TX_SUMMARY_COMMITMENT => [unlock_timestamp, expiration_height, 0, + // 0]) storage_slots.push(StorageSlot::with_map( AuthMultisigSmart::tx_proposals_slot().clone(), StorageMap::default(), diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 1aff2dc8be..e340af2138 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -833,17 +833,17 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - let pending_propose_hash = + let pending_propose_commitment = Word::from([Felt::from(11u32), Felt::from(22u32), Felt::from(33u32), Felt::from(44u32)]); - let pending_cancel_hash = + let pending_cancel_commitment = 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} + push.{pending_propose_commitment} call.::miden::standards::components::auth::multisig_smart::propose_transaction - push.{pending_propose_hash} + push.{pending_propose_commitment} call.::miden::standards::components::auth::multisig_smart::propose_transaction dropw dropw dropw dropw dropw end @@ -861,7 +861,7 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let propose_once_script = compile_multisig_smart_tx_script(format!( " begin - push.{pending_cancel_hash} + push.{pending_cancel_commitment} call.::miden::standards::components::auth::multisig_smart::propose_transaction dropw dropw dropw dropw dropw end @@ -887,9 +887,9 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( let cancel_twice_script = compile_multisig_smart_tx_script(format!( " begin - push.{pending_cancel_hash} + push.{pending_cancel_commitment} call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal - push.{pending_cancel_hash} + push.{pending_cancel_commitment} call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal dropw dropw dropw dropw dropw end @@ -976,14 +976,14 @@ async fn test_multisig_smart_execute_before_min_delay_fails( .await .unwrap_err() .unwrap_unauthorized_err(); - let tx_hash_word = tx_summary.as_ref().to_commitment(); + let tx_summary_commitment_word = tx_summary.as_ref().to_commitment(); // Propose the tx hash (2 sigs, default threshold). The propose script signs over its own // tx-summary; only the propose-tx itself needs sigs, not the proposed action. let propose_script = compile_multisig_smart_tx_script(format!( " begin - push.{tx_hash_word} + push.{tx_summary_commitment_word} call.::miden::standards::components::auth::multisig_smart::propose_transaction dropw dropw dropw dropw dropw end @@ -1072,12 +1072,12 @@ async fn test_multisig_smart_full_propose_wait_execute_lifecycle( .await .unwrap_err() .unwrap_unauthorized_err(); - let tx_hash_word = tx_summary.as_ref().to_commitment(); + let tx_summary_commitment_word = tx_summary.as_ref().to_commitment(); let propose_script = compile_multisig_smart_tx_script(format!( " begin - push.{tx_hash_word} + push.{tx_summary_commitment_word} call.::miden::standards::components::auth::multisig_smart::propose_transaction dropw dropw dropw dropw dropw end @@ -1103,7 +1103,7 @@ async fn test_multisig_smart_full_propose_wait_execute_lifecycle( // After propose, the proposal entry is present. let stored_before = multisig_account .storage() - .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_hash_word) + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_summary_commitment_word) .expect("tx proposals slot should exist"); assert_ne!(stored_before, Word::empty(), "proposal must be written to storage"); @@ -1131,7 +1131,7 @@ async fn test_multisig_smart_full_propose_wait_execute_lifecycle( // Proposal entry should be cleared after execute. let stored_after = multisig_account .storage() - .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_hash_word) + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), tx_summary_commitment_word) .expect("tx proposals slot should still exist"); assert_eq!( stored_after, @@ -1407,10 +1407,11 @@ async fn test_multisig_smart_cancel_and_propose_failure_modes( let hash_b = Word::from([Felt::from(1002u32); 4]); let hash_never_proposed = Word::from([Felt::from(1003u32); 4]); - // ----- Branch 1: OLD_TX_HASH was never proposed → ERR_TX_NOT_PROPOSED. + // ----- Branch 1: OLD_TX_SUMMARY_COMMITMENT was never proposed → ERR_TX_NOT_PROPOSED. // - // MASM stack convention: `cancel_and_propose_new_transaction` consumes [OLD_TX_HASH, - // NEW_TX_HASH] (top → bottom). The script pushes NEW first so it lands below OLD. + // MASM stack convention: `cancel_and_propose_new_transaction` consumes + // [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] (top → bottom). The script pushes + // NEW first so it lands below OLD. let old_not_proposed_script = compile_multisig_smart_tx_script(format!( " begin @@ -1459,7 +1460,7 @@ async fn test_multisig_smart_cancel_and_propose_failure_modes( mock_chain.prove_next_block()?; } - // ----- Branch 2: NEW_TX_HASH is already proposed → ERR_TX_ALREADY_PROPOSED. + // ----- Branch 2: NEW_TX_SUMMARY_COMMITMENT is already proposed → ERR_TX_ALREADY_PROPOSED. // // OLD = hash_a (valid existing proposal), NEW = hash_b (also already exists). let new_already_proposed_script = compile_multisig_smart_tx_script(format!( From 30787c3553b13da6f85313026e059d0a7db263f4 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 9 Jun 2026 16:20:54 +0200 Subject: [PATCH 09/15] remove apply_propose_expiration_delta --- .../asm/kernels/transaction/lib/tx.masm | 5 + crates/miden-protocol/asm/protocol/tx.masm | 5 + .../multisig_smart/delayed_execution.masm | 128 +++++------------- .../standards/auth/multisig_smart/mod.masm | 22 +-- .../account/auth/multisig_smart/component.rs | 6 +- .../tests/auth/multisig_smart.rs | 3 +- 6 files changed, 61 insertions(+), 108 deletions(-) diff --git a/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm b/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm index fe49d70823..7ac679e035 100644 --- a/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm +++ b/crates/miden-protocol/asm/kernels/transaction/lib/tx.masm @@ -106,6 +106,11 @@ pub use memory::get_tx_script_root #! #! Where: #! - block_height_delta is the desired expiration time delta (1 to 0xFFFF). +#! +#! Panics if: +#! - block_height_delta is not a valid `u32` (`ERR_TX_INVALID_EXPIRATION_DELTA`). +#! - block_height_delta is zero (`ERR_TX_INVALID_EXPIRATION_DELTA`). +#! - block_height_delta is greater than 0xFFFF (`ERR_TX_INVALID_EXPIRATION_DELTA`). pub proc update_expiration_block_delta # Ensure block_height_delta is between 1 and 0xFFFF (inclusive) dup neq.0 assert.err=ERR_TX_INVALID_EXPIRATION_DELTA diff --git a/crates/miden-protocol/asm/protocol/tx.masm b/crates/miden-protocol/asm/protocol/tx.masm index 06df0413df..c89b5e7a3f 100644 --- a/crates/miden-protocol/asm/protocol/tx.masm +++ b/crates/miden-protocol/asm/protocol/tx.masm @@ -281,6 +281,11 @@ end #! Where: #! - block_height_delta is the desired expiration time delta (1 to 0xFFFF). #! +#! Panics if: +#! - block_height_delta is not a valid `u32`. +#! - block_height_delta is zero. +#! - block_height_delta is greater than 0xFFFF. +#! #! Annotation hint: is not used anywhere pub proc update_expiration_block_delta push.TX_UPDATE_EXPIRATION_BLOCK_DELTA_OFFSET 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 index 8330921ede..b6ed59cf29 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -9,7 +9,7 @@ use miden::protocol::tx # [min_delay, propose_expiration_delta, 0, 0] const DELAYED_EXECUTION_SLOT = word("miden::standards::auth::multisig_smart::delayed_execution") -# Map entries: [TX_SUMMARY_COMMITMENT] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] +# Map entries: [TX_SUMMARY_COMMITMENT] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_proposals") # Pending action slots @@ -34,8 +34,6 @@ 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_EXPIRATION_DELTA_ZERO = "expiration_delta must be non-zero" -const ERR_TX_EXPIRATION_DELTA_NOT_SET = "tx expiration delta must be set" # CONFIGURATION # ================================================================================================= @@ -112,13 +110,13 @@ end #! Returns 1 if `execute_proposed_transaction` was called in the current transaction, 0 otherwise. #! #! Inputs: [] -#! Outputs: [is_execute_path] +#! Outputs: [is_delayed_execution_path] #! #! Where: -#! - is_execute_path is 1 if `PENDING_EXECUTE_SLOT` is non-empty, otherwise 0. +#! - is_delayed_execution_path is 1 if `PENDING_EXECUTE_SLOT` is non-empty, otherwise 0. #! #! Invocation: exec -pub proc is_execute_path +pub proc is_delayed_execution_path push.PENDING_EXECUTE_SLOT[0..2] # => [slot_prefix, slot_suffix] @@ -129,7 +127,7 @@ pub proc is_execute_path # => [is_pending_execute_empty] not - # => [is_execute_path] + # => [is_delayed_execution_path] end @@ -147,10 +145,11 @@ proc get_min_delay # => [min_delay] end -#! Returns `propose_expiration_delta` from [`DELAYED_EXECUTION_SLOT`] +#! 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`). +#! Callers stage a proposal by piping the returned delta through +#! `tx::update_expiration_block_delta` so that proposal-bearing transactions expire within the +#! policy-defined window. #! #! Inputs: [] #! Outputs: [propose_expiration_delta] @@ -164,74 +163,19 @@ proc get_propose_expiration_delta # => [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 -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 - - -#! Applies the configured `propose_expiration_delta` to the current transaction. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Panics if: -#! - `propose_expiration_delta` configured in `DELAYED_EXECUTION_SLOT` is zero -#! (`ERR_EXPIRATION_DELTA_ZERO`). -#! - `tx::update_expiration_block_delta` rejects the configured value. -#! - the stored transaction expiration delta is zero after the update -#! (`ERR_TX_EXPIRATION_DELTA_NOT_SET`). -#! -#! Invocation: exec -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_SUMMARY_COMMITMENT`. #! #! Inputs: [TX_SUMMARY_COMMITMENT] -#! Outputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] +#! Outputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs] #! #! Where: #! - TX_SUMMARY_COMMITMENT is the transaction summary commitment 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. -#! - proposal_exists is 1 if a proposal exists for `TX_SUMMARY_COMMITMENT`, otherwise 0. #! #! Invocation: exec proc get_tx_proposal(tx_summary_commitment: word) @@ -239,7 +183,10 @@ proc get_tx_proposal(tx_summary_commitment: word) # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] exec.active_account::get_map_item - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] + + movup.3 drop + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] end #! Returns 1 if a proposal exists for `TX_SUMMARY_COMMITMENT`, 0 otherwise. @@ -248,18 +195,16 @@ end #! Outputs: [is_proposed] #! #! Where: -#! - TX_SUMMARY_COMMITMENT 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. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment used as the proposal map key. +#! - is_proposed is 1 if `proposal_timestamp` is non-zero (i.e. a proposal has been recorded +#! for the commitment), otherwise 0. #! #! Invocation: exec proc is_tx_proposed(tx_summary_commitment: word) -> u32 exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] - - exec.word::eqz - # => [is_proposal_empty] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] - not + drop swap drop neq.0 # => [is_proposed] end @@ -293,9 +238,6 @@ end #! Writes the proposal entry for `TX_SUMMARY_COMMITMENT` unconditionally. #! -#! The `proposal_exists` 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_SUMMARY_COMMITMENT] #! Outputs: [] #! @@ -303,21 +245,21 @@ end #! - 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_SUMMARY_COMMITMENT is the transaction summary hash used as the proposal map key. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment used as the proposal map key. #! #! Invocation: exec proc write_tx_proposal - push.1 - # => [1, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] + push.0 + # => [0, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] movdn.3 - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1, TX_SUMMARY_COMMITMENT] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0, TX_SUMMARY_COMMITMENT] swapw - # => [TX_SUMMARY_COMMITMENT, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + # => [TX_SUMMARY_COMMITMENT, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] push.TX_PROPOSALS_SLOT[0..2] - # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT, unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] exec.native_account::set_map_item # => [OLD_PROPOSAL_WORD] @@ -394,9 +336,9 @@ end #! Invocation: exec proc enforce_tx_timelock exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] - movup.3 assert.err=ERR_TX_NOT_PROPOSED + dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] swap drop @@ -618,11 +560,11 @@ end #! Panics if: #! - a proposal already exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_ALREADY_PROPOSED`). #! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). -#! - `apply_propose_expiration_delta` fails. +#! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. #! #! Invocation: exec pub proc propose_transaction - exec.apply_propose_expiration_delta + exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta # => [TX_SUMMARY_COMMITMENT] dupw @@ -685,7 +627,7 @@ end #! - no proposal exists for `OLD_TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). #! - a proposal already exists for `NEW_TX_SUMMARY_COMMITMENT` (`ERR_TX_ALREADY_PROPOSED`). #! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). -#! - `apply_propose_expiration_delta` fails. +#! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. #! #! Invocation: exec pub proc cancel_and_propose_new_transaction @@ -716,7 +658,7 @@ pub proc cancel_and_propose_new_transaction exec.set_pending_cancel # => [NEW_TX_SUMMARY_COMMITMENT] - exec.apply_propose_expiration_delta + exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta # => [NEW_TX_SUMMARY_COMMITMENT] exec.set_pending_propose @@ -828,9 +770,9 @@ proc finalize_pending_cancel # => [PENDING_CANCEL_COMMITMENT, num_verified_signatures] dupw exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, proposal_exists, PENDING_CANCEL_COMMITMENT, num_verified_signatures] + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - movup.3 assert.err=ERR_TX_NOT_PROPOSED + dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] drop swap drop @@ -862,7 +804,7 @@ end #! 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. +#! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. #! #! Locals: #! 0: num_verified_signatures @@ -904,7 +846,7 @@ proc finalize_pending_propose assertz.err=ERR_PROPOSE_ZERO_SIGNATURES # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - exec.apply_propose_expiration_delta + exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.compute_unlock_timestamp 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 57fd0324eb..a95b4fc648 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -60,7 +60,7 @@ 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_proposed_transaction must be called iff a called procedure requires delayed execution" +const ERR_DELAYED_EXECUTION_PATH_MISMATCH = "execute_proposed_transaction must be called iff a called procedure requires delayed execution" # LOCAL ADDRESSES (set_procedure_policy) # ================================================================================================= @@ -428,14 +428,14 @@ end #! #! Invocation: exec #! -#! The active execution mode is read from [`delayed_execution::is_execute_path`]: when the +#! The active execution mode is read from [`delayed_execution::is_delayed_execution_path`]: when the #! delayed-execution module'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). +#! delayed execution path (mode = 1); otherwise it is on the immediate path (mode = 0). #! #! `policy_requires_delay` is surfaced so the caller can enforce execute-path consistency -#! after signature verification (see [`ERR_EXECUTE_PATH_MISMATCH`]). +#! after signature verification (see [`ERR_DELAYED_EXECUTION_PATH_MISMATCH`]). proc enforce_procedure_policy(default_threshold: u32) - exec.delayed_execution::is_execute_path + exec.delayed_execution::is_delayed_execution_path # => [execution_mode, default_threshold] exec.compute_called_proc_policy @@ -759,7 +759,7 @@ end #! 2. Enforce per-procedure policy (note restrictions + policy threshold + delay flag). #! 3. Verify approver signatures and check the threshold. #! 4. Enforce execute-path consistency: if any policy required the delayed mode the tx must be -#! on the execute path, and conversely the immediate path is only valid when no consumed +#! on the delayed execution path, and conversely the immediate path is only valid when no consumed #! policy required a delay. This check runs after signature verification so a caller can #! still obtain the TX_SUMMARY_COMMITMENT needed for a propose/execute round-trip from an #! unauthorized dry-run. @@ -830,18 +830,18 @@ pub proc auth_tx(salt: word) # ------ Enforcing execute-path consistency ------ # The immediate path is only valid when no consumed policy required a delay. Conversely, - # the delayed-execute path is only valid when the tx actually requires a delay. + # the delayed execution path is only valid when the tx actually requires a delay. loc_load.2 # => [policy_requires_delay, TX_SUMMARY_COMMITMENT] - exec.delayed_execution::is_execute_path - # => [is_execute_path, policy_requires_delay, TX_SUMMARY_COMMITMENT] + exec.delayed_execution::is_delayed_execution_path + # => [is_delayed_execution_path, policy_requires_delay, TX_SUMMARY_COMMITMENT] - # is_valid = NOT(policy_requires_delay) OR is_execute_path + # is_valid = NOT(policy_requires_delay) OR is_delayed_execution_path swap not or # => [is_valid_execute_path, TX_SUMMARY_COMMITMENT] - assert.err=ERR_EXECUTE_PATH_MISMATCH + assert.err=ERR_DELAYED_EXECUTION_PATH_MISMATCH # => [TX_SUMMARY_COMMITMENT] # ------ Finalizing timelock proposals ------ 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 a53278ce4b..543c46ef18 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -322,7 +322,7 @@ impl AuthMultisigSmart { ( Self::tx_proposals_slot().clone(), StorageSlotSchema::map( - "Active tx proposals: tx_summary_commitment => [unlock_timestamp, expiration_height, 0, 0]", + "Active tx proposals: tx_summary_commitment => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0]", SchemaType::native_word(), SchemaType::native_word(), ), @@ -420,8 +420,8 @@ impl From for AccountComponent { ]), )); - // Tx-proposals map slot (TX_SUMMARY_COMMITMENT => [unlock_timestamp, expiration_height, 0, - // 0]) + // Tx-proposals map slot (TX_SUMMARY_COMMITMENT => [unlock_timestamp, proposal_timestamp, + // min_cancel_sigs, 0]). Existence is encoded by `proposal_timestamp != 0`. storage_slots.push(StorageSlot::with_map( AuthMultisigSmart::tx_proposals_slot().clone(), StorageMap::default(), diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index e340af2138..98db419057 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -928,7 +928,8 @@ async fn test_multisig_smart_pending_actions_are_mutually_exclusive( /// A successfully recorded proposal must not be executable before its `unlock_timestamp` has been /// reached. Propose a delayed action, then immediately try to execute it on the next block (only /// `TIMESTAMP_STEP_SECS` after the propose) — far short of the configured `min_delay` of 30 -/// seconds. The execute path's `enforce_tx_timelock` should fail with `ERR_TX_STILL_TIMELOCKED`. +/// seconds. The delayed execution path's `enforce_tx_timelock` should fail with +/// `ERR_TX_STILL_TIMELOCKED`. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] From 58a265e37d01c849d14e3dfb1cbe0cd1b423c931 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 9 Jun 2026 17:13:30 +0200 Subject: [PATCH 10/15] update comments --- .../standards/auth/multisig_smart/delayed_execution.masm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index b6ed59cf29..012e24076e 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -668,6 +668,10 @@ end #! Marks that this tx intends to execute a proposed action. #! +#! Called from the user's tx script. The auth procedure later reads `PENDING_EXECUTE_SLOT` +#! through `is_delayed_execution_path` to drive the execute-path consistency check and the +#! timelock enforcement in `finalize_pending_execute`. +#! #! Inputs: [] #! Outputs: [] #! @@ -874,7 +878,7 @@ end #! - num_verified_signatures is the number of signatures verified for the current transaction. #! - requires_delay is 1 if the current transaction must run in delayed execution mode, #! otherwise 0. -#! - TX_SUMMARY_COMMITMENT is the current transaction summary hash. +#! - TX_SUMMARY_COMMITMENT is the current transaction summary commitment. #! #! Panics if: #! - `finalize_pending_execute` fails. From a99bb0baa52cde76b61166da0c5c9d4bb8c3ceb2 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 26 Jun 2026 14:46:57 +0200 Subject: [PATCH 11/15] skip tx-summary reconstruction in AuthRequest when a signature is present --- Cargo.lock | 1 + Cargo.toml | 1 + crates/miden-tx/Cargo.toml | 1 + crates/miden-tx/src/executor/exec_host.rs | 13 ++++++++----- crates/miden-tx/src/host/tx_event.rs | 19 +++++++++++++++---- crates/miden-tx/src/prover/prover_host.rs | 12 ++++++------ 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 734f959135..6e172d2e85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,6 +2091,7 @@ dependencies = [ name = "miden-tx" version = "0.16.0" dependencies = [ + "either", "miden-processor", "miden-protocol", "miden-prover", diff --git a/Cargo.toml b/Cargo.toml index 40aa8ddec1..a7f3e95377 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ anyhow = { default-features = false, features = ["backtrace", "std"], v assert_matches = { default-features = false, version = "1.5" } bon = { default-features = false, version = "3" } criterion = { default-features = false, version = "0.5" } +either = { default-features = false, version = "1.16" } fs-err = { default-features = false, version = "3" } primitive-types = { default-features = false, version = "0.14" } rand = { default-features = false, version = "0.9" } diff --git a/crates/miden-tx/Cargo.toml b/crates/miden-tx/Cargo.toml index dff2035d72..19621d55dc 100644 --- a/crates/miden-tx/Cargo.toml +++ b/crates/miden-tx/Cargo.toml @@ -31,6 +31,7 @@ miden-processor = { workspace = true } miden-prover = { workspace = true } # External dependencies +either = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/miden-tx/src/executor/exec_host.rs b/crates/miden-tx/src/executor/exec_host.rs index 7da40979f6..18dd5308de 100644 --- a/crates/miden-tx/src/executor/exec_host.rs +++ b/crates/miden-tx/src/executor/exec_host.rs @@ -1,6 +1,8 @@ use alloc::boxed::Box; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::sync::Arc; + +use either::Either; use alloc::vec::Vec; use miden_processor::advice::AdviceMutation; @@ -632,11 +634,12 @@ where .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), - TransactionEvent::AuthRequest { pub_key_hash, tx_summary, signature } => { - if let Some(signature) = signature { - Ok(self.base_host.on_auth_requested(signature)) - } else { - self.on_auth_requested(pub_key_hash, tx_summary).await + TransactionEvent::AuthRequest { pub_key_hash, signature_or_summary } => { + match signature_or_summary { + Either::Left(signature) => Ok(self.base_host.on_auth_requested(signature)), + Either::Right(tx_summary) => { + self.on_auth_requested(pub_key_hash, tx_summary).await + }, } }, diff --git a/crates/miden-tx/src/host/tx_event.rs b/crates/miden-tx/src/host/tx_event.rs index adf3869cb3..b44aa3e67a 100644 --- a/crates/miden-tx/src/host/tx_event.rs +++ b/crates/miden-tx/src/host/tx_event.rs @@ -1,5 +1,6 @@ use alloc::vec::Vec; +use either::Either; use miden_processor::ProcessorState; use miden_processor::advice::{AdviceMutation, AdviceProvider}; use miden_processor::trace::RowIndex; @@ -141,10 +142,13 @@ pub(crate) enum TransactionEvent { }, /// The data necessary to handle an auth request. + /// + /// Carries either the signature already present in the advice provider (`Left`), or the + /// transaction summary to be signed when no signature is available yet (`Right`). Only one is + /// ever needed, so the summary is only reconstructed when a signature is absent. AuthRequest { pub_key_hash: Word, - tx_summary: TransactionSummary, - signature: Option>, + signature_or_summary: Either, TransactionSummary>, }, Unauthorized { @@ -495,9 +499,16 @@ impl TransactionEvent { .get_mapped_values(&signature_key) .map(|slice| slice.to_vec()); - let tx_summary = extract_tx_summary(base_host, process, message)?; + // The transaction summary only needs to be reconstructed when a signature must be + // produced (none is available yet). When a signature is already present, it is used + // directly and the message is not required to be the current transaction's summary + // (this lets a procedure verify a signature over a provided commitment). + let signature_or_summary = match signature { + Some(signature) => Either::Left(signature), + None => Either::Right(extract_tx_summary(base_host, process, message)?), + }; - Some(TransactionEvent::AuthRequest { pub_key_hash, tx_summary, signature }) + Some(TransactionEvent::AuthRequest { pub_key_hash, signature_or_summary }) }, TransactionEventId::Unauthorized => { diff --git a/crates/miden-tx/src/prover/prover_host.rs b/crates/miden-tx/src/prover/prover_host.rs index 1dd590efdf..cb50ad3b45 100644 --- a/crates/miden-tx/src/prover/prover_host.rs +++ b/crates/miden-tx/src/prover/prover_host.rs @@ -1,6 +1,7 @@ use alloc::sync::Arc; use alloc::vec::Vec; +use either::Either; use miden_processor::advice::AdviceMutation; use miden_processor::event::EventError; use miden_processor::mast::MastForest; @@ -180,13 +181,12 @@ where .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), - TransactionEvent::AuthRequest { signature, .. } => { - if let Some(signature) = signature { - Ok(self.base_host.on_auth_requested(signature)) - } else { - Err(TransactionKernelError::other( + TransactionEvent::AuthRequest { signature_or_summary, .. } => { + match signature_or_summary { + Either::Left(signature) => Ok(self.base_host.on_auth_requested(signature)), + Either::Right(_) => Err(TransactionKernelError::other( "signatures should be in the advice provider at proving time", - )) + )), } }, From 7bb36587bfc5c37f08a80c65fdead2997781ae15 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 26 Jun 2026 15:45:34 +0200 Subject: [PATCH 12/15] refactor delayed execution proposals logic --- .../auth/multisig_smart.masm | 2 - .../asm/standards/auth/multisig.masm | 23 + .../multisig_smart/delayed_execution.masm | 618 +++--------------- .../standards/auth/multisig_smart/mod.masm | 243 ++++--- .../account/auth/multisig_smart/component.rs | 101 +-- .../tests/auth/multisig_smart.rs | 496 +++++--------- crates/miden-tx/src/executor/exec_host.rs | 3 +- 7 files changed, 478 insertions(+), 1008 deletions(-) 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 5348a8777b..6ebd450f46 100644 --- a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm +++ b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm @@ -14,8 +14,6 @@ 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 #! Authenticate a transaction using multisig smart-policy rules. #! diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index 26b9f67abf..ca6e9f7389 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -486,6 +486,29 @@ pub proc get_threshold_and_num_approvers # => [default_threshold, num_approvers] end +#! Verifies the configured approver signatures over `MSG` and returns how many of them are valid. +#! +#! Thin wrapper over [`signature::verify_signatures`] that supplies this component's approver +#! public-key and scheme-id storage slots, so callers don't need to know the slot layout. It does +#! not enforce any threshold, the caller compares the returned count against the required threshold. +#! +#! Inputs: [num_of_approvers, MSG] +#! Outputs: [num_verified_signatures, MSG] +#! +#! Invocation: exec +pub proc verify_signatures + # => [num_of_approvers, MSG] + + push.APPROVER_PUBLIC_KEYS_SLOT[0..2] + # => [pub_key_slot_suffix, pub_key_slot_prefix, num_of_approvers, MSG] + + push.APPROVER_SCHEME_ID_SLOT[0..2] + # => [scheme_id_slot_suffix, scheme_id_slot_prefix, pub_key_slot_suffix, pub_key_slot_prefix, num_of_approvers, MSG] + + exec.::miden::standards::auth::signature::verify_signatures + # => [num_verified_signatures, MSG] +end + #! Sets or clears a per-procedure threshold override. #! #! Inputs: [proc_threshold, PROC_ROOT] 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 index 012e24076e..5390b7d723 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -1,7 +1,8 @@ -use miden::core::word use miden::protocol::active_account use miden::protocol::native_account use miden::protocol::tx +use miden::standards::auth::multisig +use miden::standards::auth::tx_policy # CONSTANTS # ================================================================================================= @@ -12,13 +13,7 @@ const DELAYED_EXECUTION_SLOT = word("miden::standards::auth::multisig_smart::del # Map entries: [TX_SUMMARY_COMMITMENT] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] 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 # ================================================================================================= @@ -31,8 +26,7 @@ const ERR_TIMESTAMPS_NOT_U32 = "current_timestamp and unlock_timestamp must be u 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_PROPOSE_INSUFFICIENT_SIGNATURES = "insufficient signatures for propose" const ERR_CANCEL_INSUFFICIENT_SIGNATURES = "insufficient signatures for cancel" # CONFIGURATION @@ -91,7 +85,7 @@ end #! #! Where: #! - min_delay is the minimum number of seconds that must elapse between a `propose_transaction` -#! call and the matching `execute_proposed_transaction` call. +#! call and the matching execute transaction. #! - propose_expiration_delta is the block delta applied to proposal transactions so that #! unsatisfied proposals expire after `propose_expiration_delta` blocks. #! @@ -107,30 +101,6 @@ proc get_delayed_execution_config # => [min_delay, propose_expiration_delta] end -#! Returns 1 if `execute_proposed_transaction` was called in the current transaction, 0 otherwise. -#! -#! Inputs: [] -#! Outputs: [is_delayed_execution_path] -#! -#! Where: -#! - is_delayed_execution_path is 1 if `PENDING_EXECUTE_SLOT` is non-empty, otherwise 0. -#! -#! Invocation: exec -pub proc is_delayed_execution_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_delayed_execution_path] -end - - #! Returns `min_delay` from [`DELAYED_EXECUTION_SLOT`] #! #! Inputs: [] @@ -200,7 +170,7 @@ end #! for the commitment), otherwise 0. #! #! Invocation: exec -proc is_tx_proposed(tx_summary_commitment: word) -> u32 +pub proc is_tx_proposed(tx_summary_commitment: word) -> u32 exec.get_tx_proposal # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] @@ -208,7 +178,6 @@ proc is_tx_proposed(tx_summary_commitment: word) -> u32 # => [is_proposed] end - #! Deletes the proposal entry for `TX_SUMMARY_COMMITMENT` by writing `EMPTY_WORD`. #! #! Inputs: [TX_SUMMARY_COMMITMENT] @@ -235,7 +204,6 @@ proc remove_tx_proposal(tx_summary_commitment: word) # => [] end - #! Writes the proposal entry for `TX_SUMMARY_COMMITMENT` unconditionally. #! #! Inputs: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] @@ -268,7 +236,6 @@ proc write_tx_proposal # => [] end - #! Computes the unlock timestamp for the current proposal. #! #! Inputs: [] @@ -290,7 +257,6 @@ proc compute_unlock_timestamp # => [unlock_timestamp, proposal_timestamp] end - #! Asserts that the proposal unlock timestamp has been reached. #! #! Inputs: [unlock_timestamp] @@ -319,7 +285,6 @@ proc assert_unlock_reached # => [] end - #! Enforces that `TX_SUMMARY_COMMITMENT` is proposed and that its unlock timestamp has been reached. #! #! Inputs: [TX_SUMMARY_COMMITMENT] @@ -348,559 +313,174 @@ proc enforce_tx_timelock # => [min_cancel_sigs] end - -# PENDING SLOT MANAGEMENT HELPERS +# DISPATCH # ================================================================================================= -#! Returns the pending propose transaction summary hash, or `EMPTY_WORD` if none is set. +#! Returns 1 if the current transaction only invoked a delay-action procedure +#! (`propose_transaction` or `cancel_transaction_proposal`) and nothing else. #! -#! Inputs: [] -#! Outputs: [PENDING_PROPOSE_COMMITMENT] -#! -#! Where: -#! - PENDING_PROPOSE_COMMITMENT 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_COMMITMENT] -end - - -#! Returns the pending cancel transaction summary hash, or `EMPTY_WORD` if none is set. +#! When a delay-action procedure was called it verifies its own signatures internally, so the auth +#! procedure has no additional work to do for such transactions. Any other transaction is treated +#! as an execution and runs the full signature/threshold/timelock checks in the auth procedure. #! #! Inputs: [] -#! Outputs: [PENDING_CANCEL_COMMITMENT] +#! Outputs: [is_delay_action_only] #! #! Where: -#! - PENDING_CANCEL_COMMITMENT 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_COMMITMENT] -end - - -#! Returns the pending execute marker word, or `EMPTY_WORD` if no execute action is pending. -#! -#! Inputs: [] -#! Outputs: [PENDING_EXECUTE_WORD] -#! -#! Where: -#! - PENDING_EXECUTE_WORD is `PENDING_EXECUTE_FLAG` if an execute action is pending, -#! or `EMPTY_WORD` otherwise. -#! -#! Invocation: exec -proc get_pending_execute - push.PENDING_EXECUTE_SLOT[0..2] - # => [slot_prefix, slot_suffix] - - exec.active_account::get_item - # => [PENDING_EXECUTE_WORD] -end - - - -#! Sets the pending propose slot to `TX_SUMMARY_COMMITMENT`. -#! -#! Inputs: [TX_SUMMARY_COMMITMENT] -#! Outputs: [] -#! -#! Where: -#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment to stage for proposal. +#! - is_delay_action_only is 1 if `propose_transaction` or `cancel_transaction_proposal` was the +#! single non-auth procedure called, otherwise 0. #! #! Panics if: -#! - `PENDING_PROPOSE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). +#! - a delay-action procedure was called together with another non-auth account procedure +#! (`assert_only_one_non_auth_procedure_called`). #! #! Invocation: exec -proc set_pending_propose - push.PENDING_PROPOSE_SLOT[0..2] - # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] - - exec.native_account::set_item - # => [OLD_PENDING_PROPOSE_WORD] - - exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET - # => [] -end +pub proc is_delay_action_only + procref.propose_transaction + exec.native_account::was_procedure_called + # => [propose_called] -#! Clears the pending propose transaction summary hash. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Invocation: exec -proc clear_pending_propose - push.EMPTY_WORD - # => [EMPTY_WORD] + procref.cancel_transaction_proposal + exec.native_account::was_procedure_called + # => [cancel_called, propose_called] - push.PENDING_PROPOSE_SLOT[0..2] - # => [slot_prefix, slot_suffix, EMPTY_WORD] + or + # => [is_delay_action_only] - exec.native_account::set_item - # => [OLD_PENDING_PROPOSE_COMMITMENT] + dup + # => [is_delay_action_only, is_delay_action_only] - dropw - # => [] -end - - -#! Sets the pending cancel slot to `TX_SUMMARY_COMMITMENT`. -#! -#! Inputs: [TX_SUMMARY_COMMITMENT] -#! Outputs: [] -#! -#! Where: -#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment to stage for cancellation. -#! -#! Panics if: -#! - `PENDING_CANCEL_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). -#! -#! Invocation: exec -proc set_pending_cancel - push.PENDING_CANCEL_SLOT[0..2] - # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] - - exec.native_account::set_item - # => [OLD_PENDING_CANCEL_WORD] - - exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET - # => [] -end - -#! Clears the pending cancel transaction summary hash. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! 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_COMMITMENT] - - 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`). -#! -#! Invocation: exec -proc set_pending_execute - 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] - - exec.word::eqz assert.err=ERR_PENDING_ALREADY_SET - # => [] -end - - -#! Clears the pending execute transaction summary hash. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! 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 - # => [] + if.true + # A delay action must be the only non-auth procedure called, otherwise it could be bundled + # with a state-mutating procedure to bypass the timelock. + exec.tx_policy::assert_only_one_non_auth_procedure_called + end + # => [is_delay_action_only] end - # TX ACTIONS API # ================================================================================================= -#! Stages a new proposal for `TX_SUMMARY_COMMITMENT`. +#! Records a new proposal for `TX_SUMMARY_COMMITMENT`. #! -#! Any signer can call this procedure. The proposal word itself is written later in the auth -#! finalizer after signature verification completes. +#! Verifies the proposal signatures over `TX_SUMMARY_COMMITMENT` against the approver set and +#! requires at least `default_threshold` valid signatures. The proposal is written with +#! `min_cancel_sigs` set to the number of verified signatures, and the current (proposal) +#! transaction is given the configured `propose_expiration_delta`. #! #! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to stage. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment of the target transaction to +#! propose. #! #! Panics if: #! - a proposal already exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_ALREADY_PROPOSED`). -#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). +#! - fewer than `default_threshold` signatures verify over `TX_SUMMARY_COMMITMENT` +#! (`ERR_PROPOSE_INSUFFICIENT_SIGNATURES`). #! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. #! -#! Invocation: exec +#! Invocation: call pub proc propose_transaction - exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta # => [TX_SUMMARY_COMMITMENT] - - dupw - # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.is_tx_proposed - # => [is_proposed, TX_SUMMARY_COMMITMENT] - - assertz.err=ERR_TX_ALREADY_PROPOSED + dupw exec.is_tx_proposed assertz.err=ERR_TX_ALREADY_PROPOSED # => [TX_SUMMARY_COMMITMENT] - 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_SUMMARY_COMMITMENT] -#! Outputs: [] -#! -#! Where: -#! - TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to cancel. -#! -#! Panics if: -#! - no proposal exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). -#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). -#! -#! Invocation: exec -pub proc cancel_transaction_proposal - dupw - # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.is_tx_proposed - # => [is_proposed, TX_SUMMARY_COMMITMENT] - - assert.err=ERR_TX_NOT_PROPOSED - # => [TX_SUMMARY_COMMITMENT] - - 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_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] -#! Outputs: [] -#! -#! Where: -#! - OLD_TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to cancel. -#! - NEW_TX_SUMMARY_COMMITMENT is the transaction summary hash of the proposal to stage. -#! -#! Panics if: -#! - no proposal exists for `OLD_TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). -#! - a proposal already exists for `NEW_TX_SUMMARY_COMMITMENT` (`ERR_TX_ALREADY_PROPOSED`). -#! - a pending action slot is already set (`ERR_PENDING_ALREADY_SET`). -#! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. -#! -#! Invocation: exec -pub proc cancel_and_propose_new_transaction - dupw - # => [OLD_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] - - exec.is_tx_proposed - # => [is_old_tx_proposed, OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] + # ---- Verify proposal signatures over the target commitment. ---- + exec.multisig::get_initial_threshold_and_num_approvers + # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - assert.err=ERR_TX_NOT_PROPOSED - # => [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] - - swapw - # => [NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] + # Stash default_threshold below the message so it survives signature verification. + movdn.5 + # => [num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] - dupw - # => [NEW_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] + exec.multisig::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, default_threshold] - exec.is_tx_proposed - # => [is_new_tx_proposed, NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] - - assertz.err=ERR_TX_ALREADY_PROPOSED - # => [NEW_TX_SUMMARY_COMMITMENT, OLD_TX_SUMMARY_COMMITMENT] - - swapw - # => [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] - - exec.set_pending_cancel - # => [NEW_TX_SUMMARY_COMMITMENT] + dup movup.6 u32gte assert.err=ERR_PROPOSE_INSUFFICIENT_SIGNATURES + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + # ---- Bound the proposal transaction's validity window. ---- exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta - # => [NEW_TX_SUMMARY_COMMITMENT] - - exec.set_pending_propose - # => [] -end + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + # ---- Write the proposal with min_cancel_sigs = num_verified_signatures. ---- + exec.compute_unlock_timestamp + # => [unlock_timestamp, proposal_timestamp, num_verified_signatures, TX_SUMMARY_COMMITMENT] -#! Marks that this tx intends to execute a proposed action. -#! -#! Called from the user's tx script. The auth procedure later reads `PENDING_EXECUTE_SLOT` -#! through `is_delayed_execution_path` to drive the execute-path consistency check and the -#! timelock enforcement in `finalize_pending_execute`. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Panics if: -#! - `PENDING_EXECUTE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`). -#! -#! Invocation: exec -pub proc execute_proposed_transaction - exec.set_pending_execute + exec.write_tx_proposal # => [] end -# TX FINALIZERS -# ================================================================================================= - -#! Finalizes a pending execute action for the current transaction. -#! -#! Inputs: [num_verified_signatures, requires_delay, TX_SUMMARY_COMMITMENT] -#! Outputs: [num_verified_signatures, TX_SUMMARY_COMMITMENT] -#! -#! Where: -#! - num_verified_signatures is the number of signatures verified for the current transaction. -#! - requires_delay is 1 if the current transaction must run in delayed execution mode, -#! otherwise 0. -#! - TX_SUMMARY_COMMITMENT is the current transaction summary hash. -#! -#! Panics if: -#! - `enforce_tx_timelock` fails. +#! Cancels an existing proposal for `TX_SUMMARY_COMMITMENT`. #! -#! Locals: -#! 0: num_verified_signatures +#! Verifies the cancellation signatures over `TX_SUMMARY_COMMITMENT` and requires at least the +#! proposal's `min_cancel_sigs` valid signatures, then deletes the proposal entry. #! -#! Invocation: exec -@locals(1) -proc finalize_pending_execute - loc_store.0 - # => [requires_delay, TX_SUMMARY_COMMITMENT] - - exec.get_pending_execute - # => [PENDING_EXECUTE_HASH, requires_delay, TX_SUMMARY_COMMITMENT] - - exec.word::eqz - # => [is_pending_execute_empty, requires_delay, TX_SUMMARY_COMMITMENT] - - if.true - drop - # => [TX_SUMMARY_COMMITMENT] - else - if.true - dupw - # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.enforce_tx_timelock - # => [min_cancel_sigs, TX_SUMMARY_COMMITMENT] - - drop - # => [TX_SUMMARY_COMMITMENT] - end - - dupw - # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.remove_tx_proposal - # => [TX_SUMMARY_COMMITMENT] - end - - exec.clear_pending_execute - # => [TX_SUMMARY_COMMITMENT] - - loc_load.0 - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] -end - -#! Finalizes a pending cancel action for the current transaction. -#! -#! Inputs: [num_verified_signatures] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [] #! #! Where: -#! - num_verified_signatures is the number of signatures verified for the current transaction. +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment of the proposal to cancel. #! #! Panics if: -#! - the pending cancel commitment is not currently proposed (`ERR_TX_NOT_PROPOSED`). -#! - `num_verified_signatures` is less than `min_cancel_sigs` +#! - no proposal exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). +#! - fewer than `min_cancel_sigs` signatures verify over `TX_SUMMARY_COMMITMENT` #! (`ERR_CANCEL_INSUFFICIENT_SIGNATURES`). #! -#! Invocation: exec -proc finalize_pending_cancel - exec.get_pending_cancel - # => [PENDING_CANCEL_COMMITMENT, num_verified_signatures] - - exec.word::eqz - # => [is_pending_cancel_empty, num_verified_signatures] - - if.true - # No pending cancel; drop the unused num_verified_signatures. - drop - else - exec.get_pending_cancel - # => [PENDING_CANCEL_COMMITMENT, num_verified_signatures] - - dupw exec.get_tx_proposal - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - - dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED - # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - - drop swap drop - # => [min_cancel_sigs, PENDING_CANCEL_COMMITMENT, num_verified_signatures] - - movup.5 swap - # => [min_cancel_sigs, num_verified_signatures, PENDING_CANCEL_COMMITMENT] - - u32gte assert.err=ERR_CANCEL_INSUFFICIENT_SIGNATURES - # => [PENDING_CANCEL_COMMITMENT] - - exec.remove_tx_proposal - # => [] - end - - exec.clear_pending_cancel - # => [] -end - -#! Finalizes a pending propose action for the current transaction. -#! -#! Inputs: [num_verified_signatures, TX_SUMMARY_COMMITMENT] -#! Outputs: [TX_SUMMARY_COMMITMENT] -#! -#! Where: -#! - num_verified_signatures is the number of signatures verified for the current transaction. -#! - TX_SUMMARY_COMMITMENT 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`). -#! - `tx::update_expiration_block_delta` rejects the configured `propose_expiration_delta`. -#! -#! Locals: -#! 0: num_verified_signatures -#! -#! Invocation: exec -@locals(1) -proc finalize_pending_propose - loc_store.0 +#! Invocation: call +pub proc cancel_transaction_proposal # => [TX_SUMMARY_COMMITMENT] + dupw exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] - exec.get_pending_propose - # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.word::eqz - # => [is_pending_propose_empty, TX_SUMMARY_COMMITMENT] - - if.false - exec.get_pending_propose - # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - dupw - # => [PENDING_PROPOSE_COMMITMENT, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - exec.is_tx_proposed - # => [is_proposed, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - assertz.err=ERR_TX_ALREADY_PROPOSED - # => [PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - loc_load.0 - # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - dup - # => [num_verified_signatures, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] - - eq.0 - # => [num_verified_eq_0, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] + dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] - assertz.err=ERR_PROPOSE_ZERO_SIGNATURES - # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] + drop drop + # => [min_cancel_sigs, TX_SUMMARY_COMMITMENT] - exec.get_propose_expiration_delta exec.tx::update_expiration_block_delta - # => [num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] + # Stash min_cancel_sigs below the message so it survives signature verification. + movdn.4 + # => [TX_SUMMARY_COMMITMENT, min_cancel_sigs] - exec.compute_unlock_timestamp - # => [unlock_timestamp, proposal_timestamp, num_verified_signatures, PENDING_PROPOSE_COMMITMENT, TX_SUMMARY_COMMITMENT] + # ---- Verify cancellation signatures over the target commitment. ---- + exec.multisig::get_initial_threshold_and_num_approvers drop + # => [num_of_approvers, TX_SUMMARY_COMMITMENT, min_cancel_sigs] - exec.write_tx_proposal - # => [TX_SUMMARY_COMMITMENT] - end + exec.multisig::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, min_cancel_sigs] - exec.clear_pending_propose + movup.5 u32gte assert.err=ERR_CANCEL_INSUFFICIENT_SIGNATURES # => [TX_SUMMARY_COMMITMENT] + + exec.remove_tx_proposal + # => [] end -#! Finalizes all staged timelock actions for the current transaction. +#! Enforces the timelock for a delayed execution and removes the consumed proposal. #! -#! Pending actions are processed in this order: -#! 1. execute -#! 2. cancel -#! 3. propose +#! Called by the auth procedure when the current transaction matches an existing proposal. Asserts +#! the proposal exists and its unlock timestamp has been reached, then deletes the proposal entry. +#! Replay protection against re-execution is provided by the multisig executed-transactions set. #! -#! Inputs: [num_verified_signatures, requires_delay, TX_SUMMARY_COMMITMENT] +#! Inputs: [TX_SUMMARY_COMMITMENT] #! Outputs: [TX_SUMMARY_COMMITMENT] #! #! Where: -#! - num_verified_signatures is the number of signatures verified for the current transaction. -#! - requires_delay is 1 if the current transaction must run in delayed execution mode, -#! otherwise 0. #! - TX_SUMMARY_COMMITMENT is the current transaction summary commitment. #! #! Panics if: -#! - `finalize_pending_execute` fails. -#! - `finalize_pending_cancel` fails. -#! - `finalize_pending_propose` fails. +#! - no proposal exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). +#! - the proposal unlock timestamp has not been reached (`ERR_TX_STILL_TIMELOCKED`). #! #! Invocation: exec -pub proc finalize_timelock_proposals - exec.finalize_pending_execute - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] - - # `finalize_pending_cancel` consumes `num_verified_signatures`; dup it so - # `finalize_pending_propose` still has the value available below. - dup movdn.5 - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, num_verified_signatures] - - exec.finalize_pending_cancel - # => [TX_SUMMARY_COMMITMENT, num_verified_signatures] - - movup.4 - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] +pub proc handle_execute + # => [TX_SUMMARY_COMMITMENT] + dupw exec.enforce_tx_timelock drop + # => [TX_SUMMARY_COMMITMENT] - exec.finalize_pending_propose + dupw exec.remove_tx_proposal # => [TX_SUMMARY_COMMITMENT] 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 a95b4fc648..8bd0c2c122 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -60,8 +60,6 @@ const ERR_INVALID_NOTE_RESTRICTIONS = "procedure policy note restrictions must b const ERR_INSUFFICIENT_SIGNATURES = "insufficient number of signatures" -const ERR_DELAYED_EXECUTION_PATH_MISMATCH = "execute_proposed_transaction must be called iff a called procedure requires delayed execution" - # LOCAL ADDRESSES (set_procedure_policy) # ================================================================================================= @@ -408,36 +406,96 @@ pub proc enforce_note_restrictions # => [] end -#! Enforces the procedure-policy for the current transaction: -#! - asserts each called procedure supports the active execution mode, +#! Returns 1 if any called (non-auth) procedure's policy requires delayed execution, otherwise 0. +#! +#! A procedure requires delayed execution when its policy is delay-only, i.e. its +#! `immediate_threshold` is zero and its `delay_threshold` is non-zero. This is used to derive the +#! transaction's execution mode from the per-procedure policies (rather than from whether a proposal +#! already exists), so that a delay-only procedure is always evaluated in the delayed mode. +#! +#! Inputs: [] +#! Outputs: [requires_delay] +#! +#! The auth procedure (procedure index 0) is excluded, consistent with `compute_called_proc_policy`. +#! +#! Invocation: exec +proc compute_requires_delay + push.0 exec.active_account::get_num_procedures + # => [num_procedures, requires_delay_acc] + + dup neq.0 + # => [should_continue, num_procedures, requires_delay_acc] + while.true + sub.1 + # => [proc_index, requires_delay_acc] + + dup neq.0 + # => [is_non_auth, proc_index, requires_delay_acc] + + dup.1 exec.active_account::get_procedure_root + # => [PROC_ROOT, is_non_auth, proc_index, requires_delay_acc] + + exec.native_account::was_procedure_called + # => [was_called, is_non_auth, proc_index, requires_delay_acc] + + and + # => [should_process, proc_index, requires_delay_acc] + + if.true + dup exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, requires_delay_acc] + + exec.get_procedure_policy + # => [immediate, delayed, note_restrictions, proc_index, requires_delay_acc] + + movup.2 drop + # => [immediate, delayed, proc_index, requires_delay_acc] + + eq.0 + # => [is_immediate_zero, delayed, proc_index, requires_delay_acc] + + swap neq.0 + # => [is_delayed_nonzero, is_immediate_zero, proc_index, requires_delay_acc] + + and + # => [proc_requires_delay, proc_index, requires_delay_acc] + + movup.2 or + # => [new_requires_delay_acc, proc_index] + + swap + # => [proc_index, new_requires_delay_acc] + end + # => [proc_index, requires_delay_acc] + + dup neq.0 + # => [should_continue, proc_index, requires_delay_acc] + end + + drop + # => [requires_delay_acc] +end + +#! Enforces the procedure-policy for the current transaction under the given execution mode: +#! - asserts each called procedure supports `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. #! -#! Always uses IMMEDIATE_EXECUTION_MODE; procedures whose policies require the delayed mode -#! panic via [`compute_called_proc_policy`] because this component has no timelock. -#! -#! Inputs: [default_threshold] -#! Outputs: [policy_threshold, policy_requires_delay] +#! Inputs: [execution_mode, default_threshold] +#! Outputs: [policy_threshold] #! #! Where: +#! - execution_mode is IMMEDIATE_EXECUTION_MODE (0) or DELAYED_EXECUTION_MODE (1); the caller +#! derives it from whether a matured proposal exists for the transaction commitment. #! - default_threshold is forwarded to [`compute_called_proc_policy`] as the per-procedure #! contribution for any called procedure without an explicit policy. #! - policy_threshold is the effective threshold required by the called procedure policies. -#! - policy_requires_delay is 1 when any called procedure's policy explicitly requires the -#! delayed execution mode, 0 otherwise. -#! -#! Invocation: exec #! -#! The active execution mode is read from [`delayed_execution::is_delayed_execution_path`]: when the -#! delayed-execution module's `PENDING_EXECUTE` slot is set this transaction is on the -#! delayed execution path (mode = 1); otherwise it is on the immediate path (mode = 0). +#! Panics if: +#! - any called procedure's policy does not support `execution_mode`. #! -#! `policy_requires_delay` is surfaced so the caller can enforce execute-path consistency -#! after signature verification (see [`ERR_DELAYED_EXECUTION_PATH_MISMATCH`]). -proc enforce_procedure_policy(default_threshold: u32) - exec.delayed_execution::is_delayed_execution_path - # => [execution_mode, default_threshold] - +#! Invocation: exec +proc enforce_procedure_policy(execution_mode: u32, default_threshold: u32) exec.compute_called_proc_policy # => [policy_threshold, policy_requires_delay, note_restrictions] @@ -446,6 +504,9 @@ proc enforce_procedure_policy(default_threshold: u32) exec.enforce_note_restrictions # => [policy_threshold, policy_requires_delay] + + swap drop + # => [policy_threshold] end #! Asserts that all configured smart per-procedure policies are valid for num_approvers. @@ -749,25 +810,23 @@ end #! Operand stack: [TX_SUMMARY_COMMITMENT] #! #! Locals: -#! 0: policy_threshold +#! 0: execution_mode (1 = delayed, 0 = immediate) #! 1: default_threshold -#! 2: policy_requires_delay -#! 3: num_verified_signatures +#! 2: policy_threshold #! #! Flow: #! 1. Build the tx summary commitment used for signing and timelock proposals. -#! 2. Enforce per-procedure policy (note restrictions + policy threshold + delay flag). -#! 3. Verify approver signatures and check the threshold. -#! 4. Enforce execute-path consistency: if any policy required the delayed mode the tx must be -#! on the delayed execution path, and conversely the immediate path is only valid when no consumed -#! policy required a delay. This check runs after signature verification so a caller can -#! still obtain the TX_SUMMARY_COMMITMENT needed for a propose/execute round-trip from an -#! unauthorized dry-run. -#! 5. Finalize any pending timelock propose/cancel/execute slots against the verified sig count -#! and the policy-derived `requires_delay` flag. +#! 2. Dispatch: a propose/cancel-only transaction already verified its signatures inside the +#! called procedure, so there is nothing else to do. Any other transaction is an execution. +#! 3. For an execution: derive the execution mode from the per-procedure policies (delayed if any +#! called procedure requires delay), enforce the per-procedure policy under that mode, verify +#! approver signatures and check the threshold. +#! 4. If the transaction matched a proposal (delayed mode), enforce the timelock and consume the +#! proposal entry. Re-execution replay protection is provided by the executed-transactions set +#! (see `assert_new_tx` in the component wrapper). #! #! Invocation: call -@locals(4) +@locals(3) pub proc auth_tx(salt: word) exec.native_account::incr_nonce drop # => [SALT] @@ -782,75 +841,77 @@ pub proc auth_tx(salt: word) exec.auth::hash_tx_summary # => [TX_SUMMARY_COMMITMENT] - # ------ Reading threshold config (default + num_approvers) ------ - exec.multisig::get_initial_threshold_and_num_approvers - # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - - # Save default_threshold for the procedure-policy enforcement and the final tx-threshold - # fallback below. - dup loc_store.1 - # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - - # ------ Enforcing procedure policy (consumes default_threshold) ------ - exec.enforce_procedure_policy - # => [policy_threshold, policy_requires_delay, num_of_approvers, TX_SUMMARY_COMMITMENT] + # ------ Dispatch ------ + # A propose/cancel transaction verifies its own signatures inside the called procedure, so the + # auth procedure has nothing else to do. Any other transaction is treated as an execution. + exec.delayed_execution::is_delay_action_only + not + # => [is_execution, TX_SUMMARY_COMMITMENT] - loc_store.0 - # => [policy_requires_delay, num_of_approvers, TX_SUMMARY_COMMITMENT] + # A propose/cancel-only transaction already verified its signatures inside the called procedure, + # so only an execution transaction needs the checks below. + if.true + # ------ Execution path ------ + # The execution mode (delayed vs immediate) is derived from the per-procedure policies: if + # any called procedure requires delayed execution the transaction runs in delayed mode + # (= DELAYED_EXECUTION_MODE = 1), otherwise immediate mode (= IMMEDIATE_EXECUTION_MODE = 0). + # Deriving the mode from policy (rather than from proposal existence) keeps a delay-only + # procedure in delayed mode even on an unauthorized dry-run, so a caller can still obtain the + # TX_SUMMARY_COMMITMENT needed to create the proposal. + exec.compute_requires_delay + # => [execution_mode, TX_SUMMARY_COMMITMENT] - loc_store.2 - # => [num_of_approvers, TX_SUMMARY_COMMITMENT] + loc_store.0 + # => [TX_SUMMARY_COMMITMENT] - # ------ Verifying approver signatures ------ - push.APPROVER_PUBLIC_KEYS_SLOT[0..2] - push.APPROVER_SCHEME_ID_SLOT[0..2] - exec.::miden::standards::auth::signature::verify_signatures - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + # ------ Reading threshold config (default + num_approvers) ------ + exec.multisig::get_initial_threshold_and_num_approvers + # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - dup loc_store.3 - # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] + dup loc_store.1 + # => [default_threshold, num_of_approvers, 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. - loc_load.0 - loc_load.1 - # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + # ------ Enforcing procedure policy (consumes execution_mode + default_threshold) ------ + loc_load.0 + # => [execution_mode, default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - exec.compute_tx_threshold - # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + exec.enforce_procedure_policy + # => [policy_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - u32assert2 u32lt - # => [is_unauthorized, TX_SUMMARY_COMMITMENT] + loc_store.2 + # => [num_of_approvers, TX_SUMMARY_COMMITMENT] - if.true - emit.AUTH_UNAUTHORIZED_EVENT - push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES - end + # ------ Verifying approver signatures ------ + exec.multisig::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT] - # ------ Enforcing execute-path consistency ------ - # The immediate path is only valid when no consumed policy required a delay. Conversely, - # the delayed execution path is only valid when the tx actually requires a delay. - loc_load.2 - # => [policy_requires_delay, 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. + loc_load.2 + loc_load.1 + # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] - exec.delayed_execution::is_delayed_execution_path - # => [is_delayed_execution_path, policy_requires_delay, TX_SUMMARY_COMMITMENT] + exec.compute_tx_threshold + # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] - # is_valid = NOT(policy_requires_delay) OR is_delayed_execution_path - swap not or - # => [is_valid_execute_path, TX_SUMMARY_COMMITMENT] + u32assert2 u32lt + # => [is_unauthorized, TX_SUMMARY_COMMITMENT] - assert.err=ERR_DELAYED_EXECUTION_PATH_MISMATCH - # => [TX_SUMMARY_COMMITMENT] - - # ------ Finalizing timelock proposals ------ - loc_load.2 - # => [policy_requires_delay, TX_SUMMARY_COMMITMENT] + if.true + emit.AUTH_UNAUTHORIZED_EVENT + push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES + end + # => [TX_SUMMARY_COMMITMENT] - loc_load.3 - # => [num_verified_signatures, policy_requires_delay, TX_SUMMARY_COMMITMENT] + # ------ Delayed execution: enforce timelock and consume the proposal ------ + loc_load.0 + # => [execution_mode, TX_SUMMARY_COMMITMENT] - exec.delayed_execution::finalize_timelock_proposals + if.true + exec.delayed_execution::handle_execute + end + # => [TX_SUMMARY_COMMITMENT] + end # => [TX_SUMMARY_COMMITMENT] end 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 543c46ef18..2d4ee2c236 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -68,21 +68,6 @@ static TX_PROPOSALS_SLOT_NAME: LazyLock = LazyLock::new(|| { .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 // ================================================================================================ @@ -193,6 +178,31 @@ fn validate_proc_policies( } /// An [`AccountComponent`] implementing a multisig auth component with smart-policy slots. +/// +/// Procedures whose policy requires delayed execution must first be proposed (recorded in the +/// proposals map) and can only execute once the timelock has elapsed. Both `propose_transaction` +/// and the eventual execution verify the approver signatures over the *proposed transaction's* +/// commitment, so approvers sign the actual transaction they intend to run. +/// +/// # Security considerations +/// +/// Two properties follow from verifying proposal signatures over the proposed transaction's +/// commitment, and callers/operators must account for them: +/// +/// - A proposal signature is bound to the proposed transaction's commitment, which is a stable, +/// replayable message. After a proposal is cancelled (its entry removed), the original proposal +/// signatures can be replayed to re-create the same proposal. There is intentionally no +/// in-contract protection against this; instead, the cancellation must be re-applied before the +/// timelock elapses. Re-cancelling is cheap (the cancel signatures are likewise replayable), but +/// it requires off-chain monitoring of the proposals map. Monitoring is required regardless, +/// because a semantically-equivalent proposal built with a different salt has a different +/// commitment and cannot be blocked on-chain either. +/// +/// - Proposing pre-authorizes execution. Because both proposing and executing verify signatures +/// over the same (proposed) commitment, an approver's proposal signature also counts toward the +/// execution threshold and can be reused for it. An approver who signs to propose a transaction +/// has therefore also contributed a signature usable to execute it; consent cannot be withdrawn +/// passively, only by cancelling. #[derive(Debug)] pub struct AuthMultisigSmart { config: AuthMultisigSmartConfig, @@ -264,18 +274,6 @@ impl AuthMultisigSmart { &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 - } - /// Returns the [`AccountProcedureRoot`] of the `update_delayed_execution_policy` procedure. pub fn update_delayed_execution_policy_root() -> AccountProcedureRoot { *MULTISIG_SMART_UPDATE_DELAYED_EXECUTION_POLICY @@ -328,41 +326,11 @@ impl AuthMultisigSmart { ), ) } - - pub fn pending_propose_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::pending_propose_slot().clone(), - StorageSlotSchema::value( - "Pending propose: TX_SUMMARY_COMMITMENT", - SchemaType::native_word(), - ), - ) - } - - pub fn pending_cancel_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::pending_cancel_slot().clone(), - StorageSlotSchema::value( - "Pending cancel: TX_SUMMARY_COMMITMENT", - SchemaType::native_word(), - ), - ) - } - - pub fn pending_execute_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::pending_execute_slot().clone(), - StorageSlotSchema::value( - "Pending execute marker (any non-zero word)", - SchemaType::native_word(), - ), - ) - } } impl From for AccountComponent { fn from(multisig: AuthMultisigSmart) -> Self { - let mut storage_slots = Vec::with_capacity(10); + let mut storage_slots = Vec::with_capacity(7); // Threshold config slot (value: [threshold, num_approvers, 0, 0]) let num_approvers = multisig.config.approvers().len() as u32; @@ -427,20 +395,6 @@ impl From for AccountComponent { StorageMap::default(), )); - // Pending propose / cancel / execute scratch slots — empty by 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(), @@ -449,9 +403,6 @@ impl From for AccountComponent { AuthMultisigSmart::procedure_policies_slot_schema(), AuthMultisigSmart::delayed_execution_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"); diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 98db419057..dfe4178330 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -621,7 +621,6 @@ async fn test_multisig_smart_unpolicied_proc_call_requires_default_threshold() - use miden_protocol::transaction::ExecutedTransaction; use miden_standards::errors::standards::{ ERR_CANCEL_INSUFFICIENT_SIGNATURES, - ERR_PENDING_ALREADY_SET, ERR_TX_ALREADY_PROPOSED, ERR_TX_NOT_PROPOSED, ERR_TX_STILL_TIMELOCKED, @@ -629,6 +628,51 @@ use miden_standards::errors::standards::{ use miden_testing::MockChain; use miden_tx::auth::BasicAuthenticator; +/// Drives a delay-action procedure (`propose_transaction` or `cancel_transaction_proposal`) which, +/// in the option-4 design, verifies its own signatures over the *target* transaction commitment. +/// The signers blind-sign that commitment directly. Because the kernel skips transaction-summary +/// reconstruction when a signature is already present, the target summary need not be supplied — +/// the commitment alone is enough — and there is no unauthorized dry-run round-trip as in +/// [`execute_script_with_signers`]. +#[allow(clippy::too_many_arguments)] +async fn delay_action_with_signers( + mock_chain: &MockChain, + account_id: AccountId, + proc_name: &str, + target_commitment: Word, + action_salt: Word, + signer_indices: &[usize], + public_keys: &[PublicKey], + authenticators: &[BasicAuthenticator], +) -> anyhow::Result> { + let script = compile_multisig_smart_tx_script(format!( + " + begin + push.{target_commitment} + call.::miden::standards::components::auth::multisig_smart::{proc_name} + dropw dropw dropw dropw dropw + end + " + ))?; + + let signing = SigningInputs::Blind(target_commitment); + + let mut builder = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(script) + .auth_args(action_salt); + + for signer_idx in signer_indices { + let sig = authenticators[*signer_idx] + .get_signature(public_keys[*signer_idx].to_commitment(), &signing) + .await?; + builder = + builder.add_signature(public_keys[*signer_idx].to_commitment(), target_commitment, sig); + } + + Ok(builder.build()?.execute().await) +} + #[allow(clippy::too_many_arguments)] async fn execute_script_with_signers( mock_chain: &MockChain, @@ -696,19 +740,19 @@ async fn execute_script_with_signers( // DELAYED-EXECUTION TESTS // ================================================================================================ -/// A procedure whose policy only declares a `delay_threshold` (no `immediate_threshold`) must not -/// be executable on the immediate path: providing the would-be required signatures and calling it -/// directly should fail at the procedure-policy enforcement layer with -/// `ERR_PROC_POLICY_INVALID_MODE`. +/// A procedure whose policy only declares a `delay_threshold` (no `immediate_threshold`) runs in +/// the delayed mode regardless of whether a proposal exists (the mode is derived from policy). With +/// no matching proposal, a fully-signed direct call must therefore fail the timelock check with +/// `ERR_TX_NOT_PROPOSED` rather than execute immediately. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( +async fn test_multisig_smart_delayed_only_proc_rejects_direct_path_without_proposal( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, authenticators) = - setup_keys_and_authenticators_with_scheme(4, 2, auth_scheme)?; + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; let multisig_account = create_multisig_smart_account( 2, &public_keys, @@ -734,43 +778,36 @@ async fn test_multisig_smart_delayed_only_proc_rejects_signed_direct_path( ", )?; - let blind_inputs = SigningInputs::Blind(salt(900)); - 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(salt(901)) - .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; + // Sign the actual transaction (delay-mode threshold is 1) and call it directly. The mode is + // delayed (policy-derived), so auth runs the timelock check and finds no proposal. + let result = execute_script_with_signers( + &mock_chain, + account_id, + update_timelock_script, + salt(901), + &[0, 1], + &public_keys, + &authenticators, + None, + None, + ) + .await?; - match result { - Err(TransactionExecutorError::TransactionProgramExecutionFailed(_)) => {}, - Err(err) => panic!("expected transaction program failure, got: {err}"), - Ok(_) => panic!("execution was unexpectedly successful"), - } + assert_transaction_executor_error!(result, ERR_TX_NOT_PROPOSED); Ok(()) } -/// Calling `execute_proposed_transaction` should still produce a `TX_SUMMARY_COMMITMENT` (via the -/// unauthorized dry-run) even when the tx only touches a delayed-only procedure: the auth path -/// runs to threshold check, fails without signatures, but emits the summary needed to drive the -/// propose/execute round-trip. +/// An unauthorized dry-run of a delay-only procedure must still yield its `TX_SUMMARY_COMMITMENT` +/// (so a caller can obtain the commitment to propose). Because the execution mode is derived from +/// policy, a delay-only procedure is evaluated in delayed mode even without a proposal, reaching +/// the signature/threshold check and aborting with `Unauthorized` (carrying the summary) rather +/// than panicking at the policy layer. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry_run( +async fn test_multisig_smart_delayed_only_proc_returns_tx_summary_on_dry_run( #[case] auth_scheme: AuthScheme, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, _authenticators) = @@ -788,10 +825,9 @@ async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry 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( + let 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 @@ -803,7 +839,7 @@ async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry let result = mock_chain .build_tx_context(account_id, &[], &[])? - .tx_script(execute_update_timelock_script) + .tx_script(update_timelock_script) .auth_args(salt(902)) .build()? .execute() @@ -815,112 +851,103 @@ async fn test_multisig_smart_delayed_only_execute_proc_returns_tx_summary_on_dry } } -/// The `PENDING_PROPOSE` / `PENDING_CANCEL` / `PENDING_EXECUTE` scratch slots must be mutually -/// exclusive within a single transaction. Calling `propose_transaction` twice, or -/// `cancel_transaction_proposal` twice, or `execute_proposed_transaction` twice should all panic -/// with `ERR_PENDING_ALREADY_SET`. +/// A delay-action procedure may not be bundled with another non-auth procedure in the same +/// transaction: `is_delay_action_only` calls `assert_only_one_non_auth_procedure_called`, which +/// aborts the program. Here a single transaction calls both `propose_transaction` and +/// `update_delayed_execution_policy`. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] #[tokio::test] -async fn test_multisig_smart_pending_actions_are_mutually_exclusive( +async fn test_multisig_smart_delay_action_cannot_be_bundled( #[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 = + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let multisig_account = create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; - let mut mock_chain = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - - let pending_propose_commitment = - Word::from([Felt::from(11u32), Felt::from(22u32), Felt::from(33u32), Felt::from(44u32)]); - let pending_cancel_commitment = - Word::from([Felt::from(55u32), Felt::from(66u32), Felt::from(77u32), Felt::from(88u32)]); + let account_id = multisig_account.id(); + let mock_chain = MockChainBuilder::with_accounts([multisig_account]).unwrap().build()?; - let propose_twice_script = compile_multisig_smart_tx_script(format!( + let bundled_commitment = Word::from([Felt::from(11u32); 4]); + let bundled_script = compile_multisig_smart_tx_script(format!( " begin - push.{pending_propose_commitment} + push.{bundled_commitment} call.::miden::standards::components::auth::multisig_smart::propose_transaction - push.{pending_propose_commitment} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw + dropw dropw dropw dropw + 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(multisig_account.id(), &[], &[])? - .tx_script(propose_twice_script) - .auth_args(salt(301)) + .build_tx_context(account_id, &[], &[])? + .tx_script(bundled_script) + .auth_args(salt(305)) .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_commitment} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let propose_tx = execute_script_with_signers( + match result { + Err(TransactionExecutorError::TransactionProgramExecutionFailed(_)) => {}, + Err(err) => panic!("expected transaction program failure, got: {err}"), + Ok(_) => panic!("bundling a delay action with another procedure must fail"), + } + + Ok(()) +} + +/// Proposing the same commitment twice must fail the second time with `ERR_TX_ALREADY_PROPOSED`. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_double_propose_fails( + #[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 mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mut mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let commitment = Word::from([Felt::from(44u32); 4]); + + let propose_tx = delay_action_with_signers( &mock_chain, - multisig_account.id(), - propose_once_script, - salt(302), + account_id, + "propose_transaction", + commitment, + salt(310), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await? - .expect("proposal setup transaction should succeed"); + .expect("first propose 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_commitment} - call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal - push.{pending_cancel_commitment} - 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(salt(303)) - .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(salt(304)) - .build()? - .execute() - .await; - assert_transaction_executor_error!(result, ERR_PENDING_ALREADY_SET); + let result = delay_action_with_signers( + &mock_chain, + account_id, + "propose_transaction", + commitment, + salt(311), + &[0, 1], + &public_keys, + &authenticators, + ) + .await?; + assert_transaction_executor_error!(result, ERR_TX_ALREADY_PROPOSED); Ok(()) } @@ -953,11 +980,11 @@ async fn test_multisig_smart_execute_before_min_delay_fails( let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - // The execute script that the proposal is for. + // The execute transaction the proposal is for: in option 4 it just calls the real procedure; + // auth detects it as an execution because it is not a propose/cancel-only transaction. let execute_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 @@ -967,7 +994,7 @@ async fn test_multisig_smart_execute_before_min_delay_fails( ", )?; - // Simulate the execute tx to obtain the tx-summary commitment that will be staged. + // Dry-run the execute tx to obtain its tx-summary commitment (the proposal target). let tx_summary = mock_chain .build_tx_context(account_id, &[], &[])? .tx_script(execute_script.clone()) @@ -977,29 +1004,16 @@ async fn test_multisig_smart_execute_before_min_delay_fails( .await .unwrap_err() .unwrap_unauthorized_err(); - let tx_summary_commitment_word = tx_summary.as_ref().to_commitment(); - - // Propose the tx hash (2 sigs, default threshold). The propose script signs over its own - // tx-summary; only the propose-tx itself needs sigs, not the proposed action. - let propose_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{tx_summary_commitment_word} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let propose_tx = execute_script_with_signers( + // Propose the target summary (2 sigs over the target commitment, default threshold). + let propose_tx = delay_action_with_signers( &mock_chain, account_id, - propose_script, + "propose_transaction", + tx_summary.as_ref().to_commitment(), salt(501), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await? .expect("propose tx should succeed"); @@ -1008,7 +1022,6 @@ async fn test_multisig_smart_execute_before_min_delay_fails( mock_chain.prove_next_block()?; // Immediately try to execute — only one block step (~10s) has passed; min_delay is 30s. - // Execute threshold is `max(default=2, delay=1) = 2`, so sign with both keys. let result = execute_script_with_signers( &mock_chain, account_id, @@ -1054,7 +1067,6 @@ async fn test_multisig_smart_full_propose_wait_execute_lifecycle( let execute_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 @@ -1075,25 +1087,15 @@ async fn test_multisig_smart_full_propose_wait_execute_lifecycle( .unwrap_unauthorized_err(); let tx_summary_commitment_word = tx_summary.as_ref().to_commitment(); - let propose_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{tx_summary_commitment_word} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let propose_tx = execute_script_with_signers( + let propose_tx = delay_action_with_signers( &mock_chain, account_id, - propose_script, + "propose_transaction", + tx_summary_commitment_word, salt(601), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await? .expect("propose tx should succeed"); @@ -1162,28 +1164,18 @@ async fn test_multisig_smart_cancel_with_insufficient_signatures_fails( let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - let pending_hash = Word::from([Felt::from(701u32); 4]); + let commitment = Word::from([Felt::from(701u32); 4]); - // Propose the mock hash with 4 sigs — this stamps min_cancel_sigs = 4 onto the entry. - let propose_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{pending_hash} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let propose_tx = execute_script_with_signers( + // Propose the commitment with 4 sigs — this stamps min_cancel_sigs = 4 onto the entry. + let propose_tx = delay_action_with_signers( &mock_chain, account_id, - propose_script, + "propose_transaction", + commitment, salt(702), &[0, 1, 2, 3], &public_keys, &authenticators, - None, - None, ) .await? .expect("propose tx with 4 sigs should succeed"); @@ -1192,25 +1184,15 @@ async fn test_multisig_smart_cancel_with_insufficient_signatures_fails( mock_chain.prove_next_block()?; // Cancel with only 2 sigs — meets default_threshold but below min_cancel_sigs (4). - let cancel_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{pending_hash} - call.::miden::standards::components::auth::multisig_smart::cancel_transaction_proposal - dropw dropw dropw dropw dropw - end - " - ))?; - let result = execute_script_with_signers( + let result = delay_action_with_signers( &mock_chain, account_id, - cancel_script, + "cancel_transaction_proposal", + commitment, salt(703), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await?; assert_transaction_executor_error!(result, ERR_CANCEL_INSUFFICIENT_SIGNATURES); @@ -1278,28 +1260,18 @@ async fn test_multisig_smart_policy_rotation_applies_to_new_proposals( "stored DelayedExecutionPolicy must reflect the rotated values" ); - // A subsequent proposal uses the new `min_delay`. Propose a mock hash and verify + // A subsequent proposal uses the new `min_delay`. Propose a commitment and verify // `unlock_timestamp - proposal_timestamp == new_min_delay`. - let pending_hash = Word::from([Felt::from(801u32); 4]); - let propose_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{pending_hash} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let propose_tx = execute_script_with_signers( + let target_commitment = Word::from([Felt::from(801u32); 4]); + let propose_tx = delay_action_with_signers( &mock_chain, account_id, - propose_script, + "propose_transaction", + target_commitment, salt(802), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await? .expect("propose tx should succeed after rotation"); @@ -1307,9 +1279,9 @@ async fn test_multisig_smart_policy_rotation_applies_to_new_proposals( let proposal_entry = multisig_account .storage() - .get_map_item(AuthMultisigSmart::tx_proposals_slot(), pending_hash) + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), target_commitment) .expect("tx proposals slot should exist"); - // Entry layout: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1] + // Entry layout: [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0] let elements: &[Felt] = proposal_entry.as_ref(); let unlock_ts = elements[0].as_canonical_u64(); let proposal_ts = elements[1].as_canonical_u64(); @@ -1339,29 +1311,19 @@ async fn test_multisig_smart_multiple_concurrent_proposals_coexist( let mut mock_chain = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - let hash_a = Word::from([Felt::from(901u32); 4]); - let hash_b = Word::from([Felt::from(902u32); 4]); - - for (hash, salt_seed) in [(hash_a, 903u32), (hash_b, 904u32)] { - let script = compile_multisig_smart_tx_script(format!( - " - begin - push.{hash} - call.::miden::standards::components::auth::multisig_smart::propose_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let tx = execute_script_with_signers( + let commitment_a = Word::from([Felt::from(901u32); 4]); + let commitment_b = Word::from([Felt::from(902u32); 4]); + + for (commitment, propose_salt) in [(commitment_a, 903u32), (commitment_b, 904u32)] { + let tx = delay_action_with_signers( &mock_chain, account_id, - script, - salt(salt_seed), + "propose_transaction", + commitment, + salt(propose_salt), &[0, 1], &public_keys, &authenticators, - None, - None, ) .await? .expect("propose tx should succeed"); @@ -1371,117 +1333,13 @@ async fn test_multisig_smart_multiple_concurrent_proposals_coexist( } // Both proposals must exist in storage. - for hash in [hash_a, hash_b] { + for commitment in [commitment_a, commitment_b] { let entry = multisig_account .storage() - .get_map_item(AuthMultisigSmart::tx_proposals_slot(), hash) + .get_map_item(AuthMultisigSmart::tx_proposals_slot(), commitment) .expect("tx proposals slot should exist"); assert_ne!(entry, Word::empty(), "proposal entry must be present in storage"); } Ok(()) } - -/// `cancel_and_propose_new_transaction` has two failure branches that fire during the -/// user-script phase (before any signature verification): -/// - The OLD tx hash must already be proposed, otherwise `ERR_TX_NOT_PROPOSED`. -/// - The NEW tx hash must NOT already be proposed, otherwise `ERR_TX_ALREADY_PROPOSED`. -/// -/// Both branches panic via `assert.err=...` deep inside the proc, so the tx never reaches the -/// auth finalizer and signatures are irrelevant — we execute without signers. -#[rstest] -#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] -#[case::falcon(AuthScheme::Falcon512Poseidon2)] -#[tokio::test] -async fn test_multisig_smart_cancel_and_propose_failure_modes( - #[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 mut multisig_account = - create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; - let account_id = multisig_account.id(); - let mut mock_chain = - MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; - - let hash_a = Word::from([Felt::from(1001u32); 4]); - let hash_b = Word::from([Felt::from(1002u32); 4]); - let hash_never_proposed = Word::from([Felt::from(1003u32); 4]); - - // ----- Branch 1: OLD_TX_SUMMARY_COMMITMENT was never proposed → ERR_TX_NOT_PROPOSED. - // - // MASM stack convention: `cancel_and_propose_new_transaction` consumes - // [OLD_TX_SUMMARY_COMMITMENT, NEW_TX_SUMMARY_COMMITMENT] (top → bottom). The script pushes - // NEW first so it lands below OLD. - let old_not_proposed_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{hash_b} - push.{hash_never_proposed} - call.::miden::standards::components::auth::multisig_smart::cancel_and_propose_new_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let result = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(old_not_proposed_script) - .auth_args(salt(1010)) - .build()? - .execute() - .await; - assert_transaction_executor_error!(result, ERR_TX_NOT_PROPOSED); - - // Pre-propose `hash_a` and `hash_b` so that branch 2 can fail on the NEW side. - for (hash, seed) in [(hash_a, 1011u32), (hash_b, 1012u32)] { - let propose_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{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, - account_id, - propose_script, - salt(seed), - &[0, 1], - &public_keys, - &authenticators, - None, - None, - ) - .await? - .expect("propose tx should succeed"); - multisig_account.apply_delta(propose_tx.account_delta())?; - mock_chain.add_pending_executed_transaction(&propose_tx)?; - mock_chain.prove_next_block()?; - } - - // ----- Branch 2: NEW_TX_SUMMARY_COMMITMENT is already proposed → ERR_TX_ALREADY_PROPOSED. - // - // OLD = hash_a (valid existing proposal), NEW = hash_b (also already exists). - let new_already_proposed_script = compile_multisig_smart_tx_script(format!( - " - begin - push.{hash_b} - push.{hash_a} - call.::miden::standards::components::auth::multisig_smart::cancel_and_propose_new_transaction - dropw dropw dropw dropw dropw - end - " - ))?; - let result = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(new_already_proposed_script) - .auth_args(salt(1013)) - .build()? - .execute() - .await; - assert_transaction_executor_error!(result, ERR_TX_ALREADY_PROPOSED); - - Ok(()) -} diff --git a/crates/miden-tx/src/executor/exec_host.rs b/crates/miden-tx/src/executor/exec_host.rs index 18dd5308de..05351cb12e 100644 --- a/crates/miden-tx/src/executor/exec_host.rs +++ b/crates/miden-tx/src/executor/exec_host.rs @@ -1,10 +1,9 @@ use alloc::boxed::Box; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::sync::Arc; - -use either::Either; use alloc::vec::Vec; +use either::Either; use miden_processor::advice::AdviceMutation; use miden_processor::event::EventError; use miden_processor::mast::MastForest; From 3bce0a47b8835cb3ccf87edaf41811ca06e89f93 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 26 Jun 2026 15:48:14 +0200 Subject: [PATCH 13/15] fix comments --- .../standards/auth/multisig_smart/delayed_execution.masm | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 index 5390b7d723..2b84739c56 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -391,7 +391,6 @@ pub proc propose_transaction exec.multisig::get_initial_threshold_and_num_approvers # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - # Stash default_threshold below the message so it survives signature verification. movdn.5 # => [num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] @@ -438,11 +437,7 @@ pub proc cancel_transaction_proposal dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] - drop drop - # => [min_cancel_sigs, TX_SUMMARY_COMMITMENT] - - # Stash min_cancel_sigs below the message so it survives signature verification. - movdn.4 + drop drop movdn.4 # => [TX_SUMMARY_COMMITMENT, min_cancel_sigs] # ---- Verify cancellation signatures over the target commitment. ---- From 60dba7de7285c59f9c176c2081a4cf282ba2feea Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 26 Jun 2026 15:50:25 +0200 Subject: [PATCH 14/15] fix comments --- .../asm/standards/auth/multisig_smart/mod.masm | 6 ------ 1 file changed, 6 deletions(-) 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 8bd0c2c122..fd29727df3 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -852,12 +852,6 @@ pub proc auth_tx(salt: word) # so only an execution transaction needs the checks below. if.true # ------ Execution path ------ - # The execution mode (delayed vs immediate) is derived from the per-procedure policies: if - # any called procedure requires delayed execution the transaction runs in delayed mode - # (= DELAYED_EXECUTION_MODE = 1), otherwise immediate mode (= IMMEDIATE_EXECUTION_MODE = 0). - # Deriving the mode from policy (rather than from proposal existence) keeps a delay-only - # procedure in delayed mode even on an unauthorized dry-run, so a caller can still obtain the - # TX_SUMMARY_COMMITMENT needed to create the proposal. exec.compute_requires_delay # => [execution_mode, TX_SUMMARY_COMMITMENT] From 79ce84ff7f067c1931558e48de3ae1111413edf2 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 26 Jun 2026 16:41:47 +0200 Subject: [PATCH 15/15] add timestamp assertions --- .../multisig_smart/delayed_execution.masm | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 index 2b84739c56..2e0523b79e 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -15,12 +15,17 @@ const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_propo const EMPTY_WORD = [0, 0, 0, 0] +# Maximum allowed `propose_expiration_delta`. The kernel's `tx::update_expiration_block_delta` +# accepts a `u16` block delta (1..=65535), so any larger value would brick the propose flow. +const MAX_PROPOSE_EXPIRATION_DELTA = 65535 + # 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_NOT_U16 = "propose_expiration_delta must be u16" 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" @@ -32,8 +37,6 @@ const ERR_CANCEL_INSUFFICIENT_SIGNATURES = "insufficient signatures for cancel" # CONFIGURATION # ================================================================================================= -#! High-impact action. -#! #! Sets the delayed execution policy used by proposal lifecycle checks. #! #! Inputs: [min_delay, propose_expiration_delta] @@ -51,6 +54,9 @@ const ERR_CANCEL_INSUFFICIENT_SIGNATURES = "insufficient signatures for cancel" #! (`ERR_PROPOSE_EXPIRATION_DELTA_NOT_U32`). #! - `min_delay` is zero (`ERR_MIN_DELAY_ZERO`). #! - `propose_expiration_delta` is zero (`ERR_PROPOSE_EXPIRATION_DELTA_ZERO`). +#! - `propose_expiration_delta` exceeds `MAX_PROPOSE_EXPIRATION_DELTA` (u16 max), since the kernel's +#! `tx::update_expiration_block_delta` only accepts a u16 delta +#! (`ERR_PROPOSE_EXPIRATION_DELTA_NOT_U16`). #! #! Invocation: exec pub proc update_delayed_execution_policy @@ -62,6 +68,11 @@ pub proc update_delayed_execution_policy dup eq.0 assertz.err=ERR_PROPOSE_EXPIRATION_DELTA_ZERO # => [propose_expiration_delta, min_delay] + # The kernel's `tx::update_expiration_block_delta` only accepts a u16 delta, so reject any + # value above `MAX_PROPOSE_EXPIRATION_DELTA` here rather than bricking the propose flow later. + dup u32lte.MAX_PROPOSE_EXPIRATION_DELTA assert.err=ERR_PROPOSE_EXPIRATION_DELTA_NOT_U16 + # => [propose_expiration_delta, min_delay] + swap # => [min_delay, propose_expiration_delta] @@ -245,6 +256,9 @@ end #! - proposal_timestamp is the current transaction reference block timestamp. #! - unlock_timestamp is `proposal_timestamp + min_delay`. #! +#! Panics if: +#! - `proposal_timestamp + min_delay` overflows the `u32` range (`ERR_TIMESTAMPS_NOT_U32`). +#! #! Invocation: exec proc compute_unlock_timestamp exec.tx::get_block_timestamp @@ -255,6 +269,11 @@ proc compute_unlock_timestamp add # => [unlock_timestamp, proposal_timestamp] + + # Ensure the sum did not overflow u32; otherwise the proposal would be recorded with an + # unlock_timestamp that can never satisfy the u32 timestamp comparison at execution time. + u32assert.err=ERR_TIMESTAMPS_NOT_U32 + # => [unlock_timestamp, proposal_timestamp] end #! Asserts that the proposal unlock timestamp has been reached.