diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f36b987e..acadbb7a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,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` ([#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). diff --git a/Cargo.lock b/Cargo.lock index 86f3e4c58d..20bfae9ca1 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 294c15af92..60fe4f2a05 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-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/account_components/auth/multisig_smart.masm b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm index af05afa295..6ebd450f46 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,16 @@ 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 #! 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 new file mode 100644 index 0000000000..2e0523b79e --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/delayed_execution.masm @@ -0,0 +1,500 @@ +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 +# ================================================================================================= + +# [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, 0] +const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_proposals") + +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" +const ERR_TX_NOT_PROPOSED = "tx not proposed" +const ERR_TX_STILL_TIMELOCKED = "tx still timelocked" +const ERR_PROPOSE_INSUFFICIENT_SIGNATURES = "insufficient signatures for propose" +const ERR_CANCEL_INSUFFICIENT_SIGNATURES = "insufficient signatures for cancel" + +# CONFIGURATION +# ================================================================================================= + +#! Sets the delayed execution policy used by proposal lifecycle checks. +#! +#! Inputs: [min_delay, propose_expiration_delta] +#! Outputs: [] +#! +#! 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`). +#! - `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`). +#! - `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 + 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] + + # 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] + + 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, min_delay, propose_expiration_delta, 0, 0] + + exec.native_account::set_item + # => [OLD_WORD] + + dropw + # => [] +end + +#! Loads the delayed execution policy configuration from `DELAYED_EXECUTION_SLOT`. +#! +#! Inputs: [] +#! Outputs: [min_delay, propose_expiration_delta] +#! +#! Where: +#! - min_delay is the minimum number of seconds that must elapse between a `propose_transaction` +#! 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. +#! +#! Invocation: exec +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 `min_delay` from [`DELAYED_EXECUTION_SLOT`] +#! +#! Inputs: [] +#! Outputs: [min_delay] +#! +#! Invocation: exec +proc get_min_delay + exec.get_delayed_execution_config + # => [min_delay, propose_expiration_delta] + + swap drop + # => [min_delay] +end + +#! Returns `propose_expiration_delta` from [`DELAYED_EXECUTION_SLOT`]. +#! +#! 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] +#! +#! Invocation: exec +proc get_propose_expiration_delta + exec.get_delayed_execution_config + # => [min_delay, propose_expiration_delta] + + drop + # => [propose_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] +#! +#! 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. +#! +#! Invocation: exec +proc get_tx_proposal(tx_summary_commitment: word) + push.TX_PROPOSALS_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT] + + exec.active_account::get_map_item + # => [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. +#! +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [is_proposed] +#! +#! Where: +#! - 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 +pub proc is_tx_proposed(tx_summary_commitment: word) -> u32 + exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] + + drop swap drop neq.0 + # => [is_proposed] +end + +#! Deletes the proposal entry for `TX_SUMMARY_COMMITMENT` by writing `EMPTY_WORD`. +#! +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [] +#! +#! Where: +#! - TX_SUMMARY_COMMITMENT is the transaction summary hash used as the proposal map key. +#! +#! Invocation: exec +proc remove_tx_proposal(tx_summary_commitment: word) + push.EMPTY_WORD + # => [EMPTY_WORD, TX_SUMMARY_COMMITMENT] + + swapw + # => [TX_SUMMARY_COMMITMENT, EMPTY_WORD] + + push.TX_PROPOSALS_SLOT[0..2] + # => [slot_prefix, slot_suffix, TX_SUMMARY_COMMITMENT, EMPTY_WORD] + + exec.native_account::set_map_item + # => [OLD_PROPOSAL_WORD] + + dropw + # => [] +end + +#! Writes the proposal entry for `TX_SUMMARY_COMMITMENT` unconditionally. +#! +#! 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_SUMMARY_COMMITMENT is the transaction summary commitment used as the proposal map key. +#! +#! Invocation: exec +proc write_tx_proposal + push.0 + # => [0, unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] + + movdn.3 + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0, TX_SUMMARY_COMMITMENT] + + swapw + # => [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, 0] + + 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`. +#! +#! 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 + # => [proposal_timestamp] + + dup exec.get_min_delay + # => [min_delay, proposal_timestamp, proposal_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. +#! +#! Inputs: [unlock_timestamp] +#! Outputs: [] +#! +#! Where: +#! - unlock_timestamp is the earliest timestamp at which execution is allowed. +#! +#! Panics if: +#! - unlock_timestamp or the current block timestamp is not a valid `u32` field element +#! (`ERR_TIMESTAMPS_NOT_U32`). +#! - 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] + + u32gte assert.err=ERR_TX_STILL_TIMELOCKED + # => [] +end + +#! Enforces that `TX_SUMMARY_COMMITMENT` is proposed and that its unlock timestamp has been reached. +#! +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [min_cancel_sigs] +#! +#! Where: +#! - 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_SUMMARY_COMMITMENT` (`ERR_TX_NOT_PROPOSED`). +#! - `assert_unlock_reached` fails. +#! +#! Invocation: exec +proc enforce_tx_timelock + exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] + + dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs] + + swap drop + # => [unlock_timestamp, min_cancel_sigs] + + exec.assert_unlock_reached + # => [min_cancel_sigs] +end + +# DISPATCH +# ================================================================================================= + +#! Returns 1 if the current transaction only invoked a delay-action procedure +#! (`propose_transaction` or `cancel_transaction_proposal`) and nothing else. +#! +#! 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: [is_delay_action_only] +#! +#! Where: +#! - is_delay_action_only is 1 if `propose_transaction` or `cancel_transaction_proposal` was the +#! single non-auth procedure called, otherwise 0. +#! +#! Panics if: +#! - a delay-action procedure was called together with another non-auth account procedure +#! (`assert_only_one_non_auth_procedure_called`). +#! +#! Invocation: exec +pub proc is_delay_action_only + procref.propose_transaction + exec.native_account::was_procedure_called + # => [propose_called] + + procref.cancel_transaction_proposal + exec.native_account::was_procedure_called + # => [cancel_called, propose_called] + + or + # => [is_delay_action_only] + + dup + # => [is_delay_action_only, is_delay_action_only] + + 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 +# ================================================================================================= + +#! Records a new proposal for `TX_SUMMARY_COMMITMENT`. +#! +#! 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 commitment of the target transaction to +#! propose. +#! +#! Panics if: +#! - a proposal already exists for `TX_SUMMARY_COMMITMENT` (`ERR_TX_ALREADY_PROPOSED`). +#! - 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: call +pub proc propose_transaction + # => [TX_SUMMARY_COMMITMENT] + dupw exec.is_tx_proposed assertz.err=ERR_TX_ALREADY_PROPOSED + # => [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] + + movdn.5 + # => [num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] + + exec.multisig::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, default_threshold] + + 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 + # => [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] + + exec.write_tx_proposal + # => [] +end + +#! Cancels an existing proposal for `TX_SUMMARY_COMMITMENT`. +#! +#! 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. +#! +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [] +#! +#! Where: +#! - TX_SUMMARY_COMMITMENT is the transaction summary commitment of the proposal to cancel. +#! +#! Panics if: +#! - 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: call +pub proc cancel_transaction_proposal + # => [TX_SUMMARY_COMMITMENT] + dupw exec.get_tx_proposal + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] + + dup.1 neq.0 assert.err=ERR_TX_NOT_PROPOSED + # => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, TX_SUMMARY_COMMITMENT] + + drop drop movdn.4 + # => [TX_SUMMARY_COMMITMENT, min_cancel_sigs] + + # ---- 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.multisig::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, min_cancel_sigs] + + movup.5 u32gte assert.err=ERR_CANCEL_INSUFFICIENT_SIGNATURES + # => [TX_SUMMARY_COMMITMENT] + + exec.remove_tx_proposal + # => [] +end + +#! Enforces the timelock for a delayed execution and removes the consumed proposal. +#! +#! 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: [TX_SUMMARY_COMMITMENT] +#! Outputs: [TX_SUMMARY_COMMITMENT] +#! +#! Where: +#! - TX_SUMMARY_COMMITMENT is the current transaction summary commitment. +#! +#! Panics if: +#! - 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 handle_execute + # => [TX_SUMMARY_COMMITMENT] + dupw exec.enforce_tx_timelock drop + # => [TX_SUMMARY_COMMITMENT] + + 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 33aa44eeb6..fd29727df3 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 @@ -47,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" @@ -63,7 +64,7 @@ const ERR_INSUFFICIENT_SIGNATURES = "insufficient number of signatures" # ================================================================================================= 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) @@ -75,22 +76,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. @@ -405,42 +406,106 @@ 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] +#! 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. #! -#! Invocation: exec +#! Panics if: +#! - any called procedure's policy does not support `execution_mode`. #! -#! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature lands, the -#! hardcoded IMMEDIATE_EXECUTION_MODE push will be replaced with a call to -#! `timelock_controller::execution_mode`, and the procedure will also expose -#! `policy_requires_delay` for downstream enforcement. -proc enforce_procedure_policy(default_threshold: u32) - push.IMMEDIATE_EXECUTION_MODE - # => [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] - # 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_requires_delay] + + swap drop # => [policy_threshold] end @@ -477,23 +542,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 @@ -515,29 +580,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 @@ -555,9 +620,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 @@ -577,8 +642,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; @@ -587,24 +652,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] @@ -745,23 +810,23 @@ end #! Operand stack: [TX_SUMMARY_COMMITMENT] #! #! Locals: -#! 0: policy_threshold +#! 0: execution_mode (1 = delayed, 0 = immediate) #! 1: default_threshold +#! 2: policy_threshold +#! +#! Flow: +#! 1. Build the tx summary commitment used for signing and timelock proposals. +#! 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 -#! -#! 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(3) pub proc auth_tx(salt: word) exec.native_account::incr_nonce drop # => [SALT] @@ -776,43 +841,71 @@ 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] + # ------ 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] - # 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] + # 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 ------ + exec.compute_requires_delay + # => [execution_mode, TX_SUMMARY_COMMITMENT] - # ------ Enforcing procedure policy (consumes default_threshold) ------ - exec.enforce_procedure_policy - # => [policy_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + loc_store.0 + # => [TX_SUMMARY_COMMITMENT] - loc_store.0 - # => [num_of_approvers, 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] - # ------ 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] + 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 + # ------ Verifying approver signatures ------ + exec.multisig::verify_signatures + # => [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. + loc_load.2 + loc_load.1 + # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + exec.compute_tx_threshold + # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + u32assert2 u32lt + # => [is_unauthorized, TX_SUMMARY_COMMITMENT] + + if.true + emit.AUTH_UNAUTHORIZED_EVENT + push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES + end + # => [TX_SUMMARY_COMMITMENT] + + # ------ Delayed execution: enforce timelock and consume the proposal ------ + loc_load.0 + # => [execution_mode, TX_SUMMARY_COMMITMENT] + + 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 79344533d0..c260ff78ce 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,44 @@ 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") +}); + // MULTISIG SMART AUTHENTICATION COMPONENT // ================================================================================================ @@ -54,15 +77,20 @@ pub struct AuthMultisigSmartConfig { approvers: Vec<(PublicKeyCommitment, AuthScheme)>, default_threshold: u32, procedure_policies: Vec<(Word, ProcedurePolicy)>, + 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")); @@ -83,6 +111,7 @@ impl AuthMultisigSmartConfig { approvers, default_threshold, procedure_policies: vec![], + delayed_execution, }) } @@ -107,6 +136,10 @@ impl AuthMultisigSmartConfig { pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { &self.procedure_policies } + + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { + self.delayed_execution + } } fn validate_proc_policies( @@ -145,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, @@ -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 @@ -165,6 +226,26 @@ 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. + pub fn delayed_execution(&self) -> DelayedExecutionPolicy { + self.config.delayed_execution() + } + pub fn threshold_config_slot() -> &'static StorageSlotName { &THRESHOLD_CONFIG_SLOT_NAME } @@ -185,6 +266,19 @@ 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 + } + + /// 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 +305,32 @@ 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_summary_commitment => [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 0]", + SchemaType::native_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(7); // Threshold config slot (value: [threshold, num_approvers, 0, 0]) let num_approvers = multisig.config.approvers().len() as u32; @@ -261,12 +376,33 @@ 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_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(), + )); + 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(), ]) .expect("storage schema should be valid"); @@ -290,6 +426,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(); @@ -302,14 +442,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"); @@ -344,10 +488,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() @@ -371,7 +516,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), @@ -397,7 +542,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-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..a89761a8d1 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/config.rs @@ -0,0 +1,66 @@ +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. +/// +/// 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 { + /// 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 { + self.min_delay + } + + pub const fn propose_expiration_delta(&self) -> u16 { + 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/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-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 b067e70c20..2673f5f1d2 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -9,7 +9,7 @@ use miden_protocol::account::{AccountComponent, AccountProcedureRoot}; use miden_protocol::note::NoteScriptRoot; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; use miden_protocol::transaction::TransactionScriptRoot; -use miden_standards::account::auth::multisig_smart::ProcedurePolicy; +use miden_standards::account::auth::multisig_smart::{DelayedExecutionPolicy, ProcedurePolicy}; use miden_standards::account::auth::{ AuthGuardedMultisig, AuthGuardedMultisigConfig, @@ -53,11 +53,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 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: DelayedExecutionPolicy, }, /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to @@ -142,10 +144,16 @@ impl Auth { (component, None) }, - Auth::MultisigSmart { threshold, approvers, proc_policy_map } => { - let config = AuthMultisigSmartConfig::new(approvers.clone(), *threshold) - .and_then(|cfg| cfg.with_proc_policies(proc_policy_map.clone())) - .expect("invalid multisig smart config"); + Auth::MultisigSmart { + threshold, + approvers, + proc_policy_map, + delayed_execution, + } => { + 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 965dace99a..27dc793a86 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; @@ -43,7 +45,8 @@ fn create_multisig_smart_account( 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)?; + 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)?, @@ -68,6 +71,13 @@ fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result Word { + Word::from([Felt::from(seed); 4]) +} + // ================================================================================================ // TESTS // ================================================================================================ @@ -101,18 +111,18 @@ 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 salt = salt(1); + 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 +130,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; @@ -177,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; @@ -196,7 +208,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:?}"), + } }, } @@ -256,7 +271,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; @@ -273,7 +288,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,23 +342,23 @@ async fn test_multisig_smart_update_signers_and_thresholds( ", )?; - let salt = Word::from([Felt::new_unchecked(3); 4]); + let salt = salt(3); - 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); @@ -351,7 +369,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()? @@ -403,7 +426,7 @@ async fn test_multisig_smart_set_procedure_policy( let receive_asset_root = StorageMapKey::from_raw(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. @@ -412,7 +435,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 @@ -421,25 +444,25 @@ 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 tx_context_builder = mock_chain - .build_tx_context(account_id, &[], &[])? - .tx_script(set_policy_script) - .auth_args(salt); + let salt = salt(4); // 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); @@ -450,7 +473,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()? @@ -466,7 +492,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(()) @@ -510,7 +536,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 @@ -529,21 +555,21 @@ 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 tx_context_builder = mock_chain - .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .tx_script(set_policy_script) - .auth_args(salt); + let salt = salt(42); // 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); @@ -559,16 +585,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) @@ -579,3 +615,745 @@ 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_CANCEL_INSUFFICIENT_SIGNATURES, + ERR_TX_ALREADY_PROPOSED, + ERR_TX_NOT_PROPOSED, + ERR_TX_STILL_TIMELOCKED, +}; +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, + 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`) 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_direct_path_without_proposal( + #[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 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 + ", + )?; + + // 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?; + + assert_transaction_executor_error!(result, ERR_TX_NOT_PROPOSED); + + Ok(()) +} + +/// 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_proc_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 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 result = mock_chain + .build_tx_context(account_id, &[], &[])? + .tx_script(update_timelock_script) + .auth_args(salt(902)) + .build()? + .execute() + .await; + + match result { + Err(TransactionExecutorError::Unauthorized(_)) => Ok(()), + error => panic!("expected unauthorized dry-run with tx summary, got: {error:?}"), + } +} + +/// 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_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(2, 2, auth_scheme)?; + let multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mock_chain = MockChainBuilder::with_accounts([multisig_account]).unwrap().build()?; + + let bundled_commitment = Word::from([Felt::from(11u32); 4]); + let bundled_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{bundled_commitment} + call.::miden::standards::components::auth::multisig_smart::propose_transaction + 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(account_id, &[], &[])? + .tx_script(bundled_script) + .auth_args(salt(305)) + .build()? + .execute() + .await; + + 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, + account_id, + "propose_transaction", + commitment, + salt(310), + &[0, 1], + &public_keys, + &authenticators, + ) + .await? + .expect("first propose should succeed"); + multisig_account.apply_patch(propose_tx.account_patch())?; + mock_chain.add_pending_executed_transaction(&propose_tx)?; + mock_chain.prove_next_block()?; + + 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(()) +} + +/// 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 delayed execution 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 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 + push.2 + push.40 + call.::miden::standards::components::auth::multisig_smart::update_delayed_execution_policy + drop + drop + end + ", + )?; + + // 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()) + .auth_args(salt(500)) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + // Propose the target summary (2 sigs over the target commitment, default threshold). + let propose_tx = delay_action_with_signers( + &mock_chain, + account_id, + "propose_transaction", + tx_summary.as_ref().to_commitment(), + salt(501), + &[0, 1], + &public_keys, + &authenticators, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_patch(propose_tx.account_patch())?; + 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. + 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 + 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_summary_commitment_word = tx_summary.as_ref().to_commitment(); + + let propose_tx = delay_action_with_signers( + &mock_chain, + account_id, + "propose_transaction", + tx_summary_commitment_word, + salt(601), + &[0, 1], + &public_keys, + &authenticators, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_patch(propose_tx.account_patch())?; + 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(), + StorageMapKey::from_raw(tx_summary_commitment_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_patch(executed_tx.account_patch())?; + + // Proposal entry should be cleared after execute. + let stored_after = multisig_account + .storage() + .get_map_item( + AuthMultisigSmart::tx_proposals_slot(), + StorageMapKey::from_raw(tx_summary_commitment_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 commitment = Word::from([Felt::from(701u32); 4]); + + // 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_transaction", + commitment, + salt(702), + &[0, 1, 2, 3], + &public_keys, + &authenticators, + ) + .await? + .expect("propose tx with 4 sigs should succeed"); + multisig_account.apply_patch(propose_tx.account_patch())?; + 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 result = delay_action_with_signers( + &mock_chain, + account_id, + "cancel_transaction_proposal", + commitment, + salt(703), + &[0, 1], + &public_keys, + &authenticators, + ) + .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_patch(rotate_tx.account_patch())?; + 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 commitment and verify + // `unlock_timestamp - proposal_timestamp == new_min_delay`. + let target_commitment = Word::from([Felt::from(801u32); 4]); + let propose_tx = delay_action_with_signers( + &mock_chain, + account_id, + "propose_transaction", + target_commitment, + salt(802), + &[0, 1], + &public_keys, + &authenticators, + ) + .await? + .expect("propose tx should succeed after rotation"); + multisig_account.apply_patch(propose_tx.account_patch())?; + + let proposal_entry = multisig_account + .storage() + .get_map_item( + AuthMultisigSmart::tx_proposals_slot(), + StorageMapKey::from_raw(target_commitment), + ) + .expect("tx proposals slot should exist"); + // 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(); + 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 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, + "propose_transaction", + commitment, + salt(propose_salt), + &[0, 1], + &public_keys, + &authenticators, + ) + .await? + .expect("propose tx should succeed"); + multisig_account.apply_patch(tx.account_patch())?; + mock_chain.add_pending_executed_transaction(&tx)?; + mock_chain.prove_next_block()?; + } + + // Both proposals must exist in storage. + for commitment in [commitment_a, commitment_b] { + let entry = multisig_account + .storage() + .get_map_item( + AuthMultisigSmart::tx_proposals_slot(), + StorageMapKey::from_raw(commitment), + ) + .expect("tx proposals slot should exist"); + assert_ne!(entry, Word::empty(), "proposal entry must be present in storage"); + } + + Ok(()) +} 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 cb601de491..899bef39b5 100644 --- a/crates/miden-tx/src/executor/exec_host.rs +++ b/crates/miden-tx/src/executor/exec_host.rs @@ -3,6 +3,7 @@ use alloc::collections::{BTreeMap, BTreeSet}; 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; @@ -578,15 +579,12 @@ where .on_note_before_add_attachment(note_idx, attachment) .map(|_| Vec::new()), - TransactionEvent::AuthRequest { - pub_key_commitment, - tx_summary, - signature, - } => { - if let Some(signature) = signature { - Ok(self.base_host.on_auth_requested(signature)) - } else { - self.on_auth_requested(pub_key_commitment, tx_summary).await + TransactionEvent::AuthRequest { pub_key_commitment, 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_commitment, tx_summary).await + }, } }, diff --git a/crates/miden-tx/src/host/tx_event.rs b/crates/miden-tx/src/host/tx_event.rs index 366dc8719d..cc59eef79f 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; @@ -145,10 +146,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_commitment: PublicKeyCommitment, - tx_summary: TransactionSummary, - signature: Option>, + signature_or_summary: Either, TransactionSummary>, }, Unauthorized { @@ -499,13 +503,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_commitment, - tx_summary, - signature, - }) + Some(TransactionEvent::AuthRequest { pub_key_commitment, 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 4d96d7e013..4392032ae6 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; @@ -185,13 +186,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", - )) + )), } },