From 34640dd60e6cbcd9977a85fdb71fd724de9cdc83 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 21 Apr 2026 14:25:12 +0200 Subject: [PATCH 01/10] add core multisig smart --- .../auth/multisig_smart.masm | 30 + .../asm/standards/auth/multisig.masm | 29 +- .../standards/auth/multisig_smart/mod.masm | 834 ++++++++++++++++++ .../asm/standards/auth/signature.masm | 3 +- .../miden-standards/src/account/auth/mod.rs | 3 + .../account/auth/multisig_smart/component.rs | 424 +++++++++ .../src/account/auth/multisig_smart/mod.rs | 9 + .../auth/multisig_smart/procedure_policies.rs | 246 ++++++ .../src/account/components/mod.rs | 21 + .../src/account/interface/component.rs | 21 +- .../src/account/interface/extension.rs | 5 + crates/miden-testing/src/mock_chain/auth.rs | 21 + crates/miden-testing/tests/auth/mod.rs | 2 + .../tests/auth/multisig_smart.rs | 217 +++++ 14 files changed, 1843 insertions(+), 22 deletions(-) create mode 100644 crates/miden-standards/asm/account_components/auth/multisig_smart.masm create mode 100644 crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm create mode 100644 crates/miden-standards/src/account/auth/multisig_smart/component.rs create mode 100644 crates/miden-standards/src/account/auth/multisig_smart/mod.rs create mode 100644 crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs create mode 100644 crates/miden-testing/tests/auth/multisig_smart.rs diff --git a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm new file mode 100644 index 0000000000..045a825637 --- /dev/null +++ b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm @@ -0,0 +1,30 @@ +# The MASM code of the Multi-Signature Smart Authentication Component. +# +# See the `AuthMultisigSmart` Rust type's documentation for more details. + +use miden::standards::auth::multisig +use miden::standards::auth::multisig_smart + +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 + + +#! Authenticate a transaction using multisig smart-policy rules. +#! +#! Inputs: +#! Operand stack: [SALT] +#! Outputs: +#! Operand stack: [] +#! +#! Invocation: call +@auth_script +pub proc auth_tx_multisig_smart(salt: word) + exec.multisig_smart::auth_tx + # => [TX_SUMMARY_COMMITMENT] + + exec.multisig::assert_new_tx + # => [] +end diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index b8d43b4dc5..ae0c7f5dbf 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -6,6 +6,7 @@ use miden::protocol::active_account use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT use miden::protocol::native_account use miden::standards::auth +use miden::standards::auth::signature use miden::core::word # Local Memory Addresses @@ -114,7 +115,7 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of # => [i-1, new_num_of_approvers] # clear scheme id at APPROVER_MAP_KEY(i-1) - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, new_num_of_approvers] padw swapw @@ -130,7 +131,7 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of # => [i-1, new_num_of_approvers] # clear public key at APPROVER_MAP_KEY(i-1) - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, new_num_of_approvers] padw swapw @@ -153,18 +154,8 @@ proc cleanup_pubkey_and_scheme_id_mapping(init_num_of_approvers: u32, new_num_of drop drop end -#! Builds the storage map key for a signer index. -#! -#! Inputs: [key_index] -#! Outputs: [APPROVER_MAP_KEY] -proc create_approver_map_key - push.0.0.0 movup.3 - # => [[key_index, 0, 0, 0]] - # => [APPROVER_MAP_KEY] -end - -#! Asserts that all configured per-procedure threshold overrides are less than or equal to -#! number of approvers +#! Asserts that all configured per-procedure threshold overrides are less than or equal to +#! number of approvers. #! #! Inputs: [num_approvers] #! Outputs: [] @@ -289,7 +280,7 @@ pub proc update_signers_and_threshold(multisig_config_hash: word) sub.1 # => [i-1, pad(12)] - dup exec.create_approver_map_key + dup exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, i-1, pad(12)] padw adv_loadw @@ -312,7 +303,7 @@ pub proc update_signers_and_threshold(multisig_config_hash: word) exec.auth::signature::assert_supported_scheme_word # => [SCHEME_ID_WORD, i-1, pad(12)] - dup.4 exec.create_approver_map_key + dup.4 exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] push.APPROVER_SCHEME_ID_SLOT[0..2] @@ -511,7 +502,7 @@ pub proc get_signer_at dup # => [index, index] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, index] push.APPROVER_PUBLIC_KEYS_SLOT[0..2] @@ -523,7 +514,7 @@ pub proc get_signer_at movup.4 # => [index, PUB_KEY] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, PUB_KEY] push.APPROVER_SCHEME_ID_SLOT[0..2] @@ -574,7 +565,7 @@ pub proc is_signer(pub_key: word) -> felt dup loc_store.CURRENT_SIGNER_INDEX_LOC # => [i-1, PUB_KEY] - exec.create_approver_map_key + exec.signature::create_approver_map_key # => [APPROVER_MAP_KEY, PUB_KEY] push.APPROVER_PUBLIC_KEYS_SLOT[0..2] diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm new file mode 100644 index 0000000000..c38b828a46 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -0,0 +1,834 @@ +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::auth::AUTH_UNAUTHORIZED_EVENT +use miden::standards::auth +use miden::standards::auth::multisig +use miden::standards::auth::signature +use miden::standards::auth::tx_policy + +# CONSTANTS +# ================================================================================================= + +# The slot in this component's storage layout where the public keys map is stored. +# Map entries: [key_index, 0, 0, 0] => APPROVER_PUBLIC_KEY +const APPROVER_PUBLIC_KEYS_SLOT = word("miden::standards::auth::multisig::approver_public_keys") + +# The slot in this component's storage layout where signature schemes are stored. +# Map entries: [key_index, 0, 0, 0] => [scheme_id, 0, 0, 0] +const APPROVER_SCHEME_ID_SLOT = word("miden::standards::auth::multisig::approver_schemes") + +# STORAGE SLOTS +# ================================================================================================= + +# [default_threshold, num_approvers, 0, 0] — shared with [`multisig`] threshold config. +const THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig::threshold_config") + +# Map: PROC_ROOT => smart per-procedure policy word. +const PROCEDURE_POLICIES_SLOT = word("miden::standards::auth::multisig_smart::procedure_policies") + +# ERRORS +# ================================================================================================= + +const ERR_MALFORMED_MULTISIG_CONFIG = "number of approvers must be equal to or greater than threshold" + +const ERR_ZERO_IN_MULTISIG_CONFIG = "number of approvers or threshold must not be zero" + +const ERR_PROC_POLICY_INVALID_LANE = "called procedures do not support the selected execution lane" + +const ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE = "delayed threshold cannot exceed immediate threshold" + +const ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD = "procedure policy note restrictions require an immediate or delayed threshold" + +const ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 = "number of approvers and procedure threshold must be u32" + +const ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS = "procedure threshold exceeds new number of approvers" + +const ERR_INVALID_NOTE_RESTRICTIONS = "procedure policy note restrictions must be between 0 and 3" + +#! Gets the procedure policy entry for PROC_ROOT from the account's initial state. +#! +#! Inputs: [PROC_ROOT] +#! Outputs: [immediate_threshold, delayed_threshold, note_restrictions, 0] +#! +#! 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. +#! - note_restrictions is the note restriction enum value in the 0..=3 range. +#! +#! Invocation: exec +pub proc get_procedure_policy + push.PROCEDURE_POLICIES_SLOT[0..2] + exec.active_account::get_initial_map_item +end + +#! Validates that note_restrictions is within the supported 0..=3 range. +#! +#! Inputs: [note_restrictions] +#! Outputs: [] +#! +#! Where: +#! - note_restrictions is the policy enum value to validate. +#! +#! Panics if: +#! - note_restrictions is not a u32 value. +#! - note_restrictions is greater than 3. +#! +#! Invocation: exec +proc assert_valid_note_restrictions + dup + # => [note_restrictions, note_restrictions] + + u32assert.err=ERR_INVALID_NOTE_RESTRICTIONS + # => [note_restrictions] + + dup u32lte.3 + # => [is_valid_note_restrictions, note_restrictions] + + assert.err=ERR_INVALID_NOTE_RESTRICTIONS + # => [note_restrictions] + + drop + # => [] +end + +# HELPER PROCEDURES +# ================================================================================================= + +#! Returns the current number of approvers after any in-transaction signer update has been applied. +#! +#! Inputs: [] +#! Outputs: [num_approvers] +#! +#! Where: +#! - num_approvers is the current number of signers configured in the threshold config. +#! +#! Invocation: exec +proc get_current_num_approvers + push.THRESHOLD_CONFIG_SLOT[0..2] + exec.active_account::get_item + # => [threshold, num_approvers, 0, 0] + + movup.2 drop movup.2 drop + # => [threshold, num_approvers] + + drop + # => [num_approvers] +end + +#! Computes the effective transaction threshold for multisig smart policy. +#! +#! Uses policy_threshold when non-zero; otherwise falls back to default_threshold. +#! +#! Inputs: [policy_threshold, default_threshold] +#! Outputs: [transaction_threshold] +#! +#! Where: +#! - policy_threshold is the threshold derived from the called procedure policies. +#! - default_threshold is the account's configured default multisig threshold. +#! - transaction_threshold is the effective minimum number of signatures required. +#! +#! Invocation: exec +proc compute_tx_threshold_smart(policy_threshold: u32, default_threshold: u32) -> u32 + swap + # => [default_threshold, policy_threshold] + + dup.1 eq.0 + # => [is_policy_zero, default_threshold, policy_threshold] + + cdrop + # => [effective_transaction_threshold] +end + +#! Returns the greater of two u32 threshold values. +#! +#! Inputs: [lhs, rhs] +#! Outputs: [max(lhs, rhs)] +#! +#! Where: +#! - lhs is the left-hand threshold value. +#! - rhs is the right-hand threshold value. +#! - max(lhs, rhs) is the greater of the two input thresholds. +#! +#! Invocation: exec +proc max_threshold_pair + dup.1 dup.1 + # => [lhs, rhs, lhs, rhs] + + swap + # => [rhs, lhs, lhs, rhs] + + u32gt + # => [is_rhs_gt, lhs, rhs] + + cdrop + # => [max(lhs, rhs)] +end + +#! Computes the effective per-procedure policy for all called procedures. +#! +#! Iterates over all account procedures and accumulates the highest required threshold and +#! most restrictive note restrictions across all procedures that were called in this transaction. +#! +#! Inputs: [is_execute_path] +#! Outputs: [policy_threshold, policy_requires_delay, note_restrictions] +#! +#! Where: +#! - is_execute_path is 1 when the transaction uses the delayed execution (execute) path. +#! - policy_threshold is the highest threshold required by any called procedure policy. +#! - policy_requires_delay is 1 when any called procedure requires the delayed execution path. +#! - note_restrictions is the combined note restriction enum across all called procedures. +#! +#! Panics if: +#! - any called procedure's policy does not support the active execution lane. +#! +#! Invocation: exec +#! +#! Locals: +#! 0: is_execute_path +#! 1: policy_threshold (accumulated) +#! 2: policy_requires_delay (accumulated) +#! 3: note_restrictions (accumulated) +#! 4: immediate_threshold of the current procedure +#! 5: delayed_threshold of the current procedure +#! 6: note_restrictions of the current procedure +@locals(7) +proc compute_called_proc_policy(is_execute_path: u32) + loc_store.0 + # => [] + + push.0 loc_store.1 + push.0 loc_store.2 + push.0 loc_store.3 + # => [] + + exec.active_account::get_num_procedures + # => [num_procedures] + + dup neq.0 + # => [should_continue, num_procedures] + while.true + sub.1 dup + # => [proc_index, proc_index] + + exec.active_account::get_procedure_root dupw + # => [PROC_ROOT, PROC_ROOT, proc_index] + + exec.native_account::was_procedure_called + # => [was_called, PROC_ROOT, proc_index] + + if.true + exec.get_procedure_policy + # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index] + + loc_store.4 + loc_store.5 + loc_store.6 + drop + # => [proc_index] + + # combine this procedure's note_restrictions with the accumulated value + loc_load.6 + dup eq.0 + if.true + # note_restrictions is none (0) — no change to accumulated value + drop + # => [proc_index] + else + drop + # => [proc_index] + + loc_load.3 eq.3 + if.true + # accumulated restriction is already maximally restrictive (3) — skip + # => [proc_index] + else + loc_load.6 + loc_load.3 + eq + if.true + # restrictions match — no change needed + # => [proc_index] + else + loc_load.3 eq.0 + if.true + # accumulated restriction is none — adopt this procedure's restriction + loc_load.6 + loc_store.3 + # => [proc_index] + else + # restrictions differ — use most restrictive value (3) + push.3 + loc_store.3 + # => [proc_index] + end + end + end + end + + # check execution-path compatibility and accumulate threshold + loc_load.0 eq.1 + if.true + # on execute path: select delayed_threshold; immediate must also be non-zero + loc_load.5 + dup eq.0 + if.true + # delayed_threshold is zero — procedure does not support execute path + drop + loc_load.4 eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE + # => [proc_index] + else + drop + loc_load.1 + loc_load.5 + exec.max_threshold_pair + loc_store.1 + # => [proc_index] + + push.1 + loc_store.2 + # => [proc_index] + end + else + # on immediate path: select immediate_threshold; delayed must be zero if not + loc_load.4 + dup eq.0 + if.true + # immediate_threshold is zero — procedure does not support immediate path + drop + loc_load.5 eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE + # => [proc_index] + else + drop + loc_load.1 + loc_load.4 + exec.max_threshold_pair + loc_store.1 + # => [proc_index] + end + end + else + dropw + # => [proc_index] + end + + dup neq.0 + # => [should_continue, proc_index] + end + + drop + # => [] + + loc_load.3 + loc_load.2 + loc_load.1 + # => [policy_threshold, policy_requires_delay, note_restrictions] +end + +#! Enforces note_restrictions against the current transaction. +#! +#! Inputs: [note_restrictions] +#! Outputs: [] +#! +#! Where: +#! - note_restrictions is the policy enum: +#! 0 => none +#! 1 => no input notes +#! 2 => no output notes +#! 3 => no input or output notes +#! +#! Invocation: exec +pub proc enforce_note_restrictions + dup eq.0 + # => [is_none, note_restrictions] + + if.true + drop + # => [] + else + dup eq.1 + # => [is_no_input_notes, note_restrictions] + + if.true + drop + # => [] + + exec.tx_policy::assert_no_input_notes + # => [] + else + dup eq.2 + # => [is_no_output_notes, note_restrictions] + + if.true + drop + # => [] + + exec.tx_policy::assert_no_output_notes + # => [] + else + drop + # => [] + + exec.tx_policy::assert_no_input_or_output_notes + # => [] + end + end + end +end + +#! Computes the effective procedure-policy context for the current transaction and enforces any +#! procedure-level note restrictions immediately. +#! +#! Always uses the immediate execution path; procedures whose policies require the delayed +#! path panic via [`compute_called_proc_policy`] because this component has no timelock. +#! +#! Inputs: [TX_SUMMARY_COMMITMENT] +#! Outputs: [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] +#! +#! Where: +#! - TX_SUMMARY_COMMITMENT is the commitment over the transaction summary fields. +#! - policy_threshold is the effective threshold required by called procedure policies. +#! - note_restrictions is the combined note restriction enum value. +#! +#! Invocation: exec +proc compute_procedure_policy_context(tx_summary_commitment: word) + push.0 + # => [is_execute_path=0, TX_SUMMARY_COMMITMENT] + + exec.compute_called_proc_policy + # => [policy_threshold, policy_requires_delay, note_restrictions, TX_SUMMARY_COMMITMENT] + + # policy_requires_delay is always 0 on the immediate path, so it carries no signal. + swap drop + # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + + dup.1 + # => [note_restrictions, policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + + exec.enforce_note_restrictions + # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] +end + +# PUBLIC INTERFACE +# ================================================================================================= + +#! Asserts that all configured smart per-procedure policies are valid for num_approvers. +#! +#! Inputs: [num_approvers] +#! Outputs: [] +#! +#! Where: +#! - num_approvers is the number of approvers that all stored policies must remain reachable with. +#! +#! Panics if: +#! - any stored immediate or delayed threshold is not a u32 value. +#! - any stored immediate or delayed threshold exceeds num_approvers. +#! - any stored note_restrictions value is outside the supported 0..=3 range. +#! - any stored delayed threshold exceeds the stored immediate threshold when the immediate +#! threshold is non-zero. +#! - any stored note_restrictions value is non-zero while both thresholds are zero. +#! +#! Invocation: exec +@locals(3) +pub proc assert_proc_policies_lte_num_approvers + exec.active_account::get_num_procedures + # => [num_procedures, num_approvers] + + dup neq.0 + # => [should_continue, num_procedures, num_approvers] + while.true + sub.1 dup + # => [proc_index, proc_index, num_approvers] + + exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, num_approvers] + + push.PROCEDURE_POLICIES_SLOT[0..2] + # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, + # proc_index, num_approvers] + + exec.active_account::get_map_item + # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index, num_approvers] + + loc_store.0 + # => [delayed_threshold, note_restrictions, 0, proc_index, num_approvers] + + loc_store.1 + # => [note_restrictions, 0, proc_index, num_approvers] + + loc_store.2 + # => [0, proc_index, num_approvers] + + drop + # => [proc_index, num_approvers] + + dup.1 + loc_load.0 + swap + # => [num_approvers, immediate_threshold, proc_index, num_approvers] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [proc_index, num_approvers] + + dup.1 + loc_load.1 + swap + # => [num_approvers, delayed_threshold, proc_index, num_approvers] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [proc_index, num_approvers] + + loc_load.2 + exec.assert_valid_note_restrictions + # => [proc_index, num_approvers] + + loc_load.0 eq.0 + # => [is_immediate_threshold_zero, proc_index, num_approvers] + + if.true + # => [proc_index, num_approvers] + + loc_load.1 eq.0 + # => [is_delayed_threshold_zero, proc_index, num_approvers] + + if.true + # => [proc_index, num_approvers] + + loc_load.2 assertz.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD + # => [proc_index, num_approvers] + else + # => [proc_index, num_approvers] + end + else + # => [proc_index, num_approvers] + + loc_load.1 + loc_load.0 + # => [immediate_threshold, delayed_threshold, proc_index, num_approvers] + + dup.1 swap + # => [delayed_threshold, immediate_threshold, proc_index, num_approvers] + + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE + # => [proc_index, num_approvers] + end + + dup neq.0 + # => [should_continue, proc_index, num_approvers] + end + + drop drop + # => [] +end + +#! Sets or clears a smart per-procedure policy. +#! +#! Inputs: [immediate_threshold, delayed_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. +#! - note_restrictions is the note restriction enum value in the 0..=3 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. +#! - note_restrictions is not in the 0..=3 range. +#! - either threshold exceeds the current number of approvers. +#! - delayed_threshold exceeds immediate_threshold when immediate_threshold is non-zero. +#! - note_restrictions is non-zero while both thresholds are zero. +#! +#! Invocation: exec +@locals(3) +pub proc set_procedure_policy + loc_store.0 + # => [delayed_threshold, note_restrictions, PROC_ROOT] + + loc_store.1 + # => [note_restrictions, PROC_ROOT] + + loc_store.2 + # => [PROC_ROOT] + + exec.get_current_num_approvers + # => [num_approvers, PROC_ROOT] + + loc_load.0 + swap + # => [num_approvers, immediate_threshold, PROC_ROOT] + + dup.1 swap + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [immediate_threshold, PROC_ROOT] + + drop + # => [PROC_ROOT] + + exec.get_current_num_approvers + # => [num_approvers, PROC_ROOT] + + loc_load.1 + swap + # => [num_approvers, delayed_threshold, PROC_ROOT] + + dup.1 swap + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS + # => [delayed_threshold, PROC_ROOT] + + drop + # => [PROC_ROOT] + + loc_load.2 + exec.assert_valid_note_restrictions + # => [PROC_ROOT] + + loc_load.0 eq.0 + # => [is_immediate_threshold_zero, PROC_ROOT] + + if.true + drop + # => [PROC_ROOT] + + loc_load.1 eq.0 + # => [is_delayed_threshold_zero, PROC_ROOT] + + if.true + drop + # => [PROC_ROOT] + + loc_load.2 assertz.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD + # => [PROC_ROOT] + else + drop + # => [PROC_ROOT] + end + else + drop + # => [PROC_ROOT] + + loc_load.1 + loc_load.0 + # => [immediate_threshold, delayed_threshold, PROC_ROOT] + + dup.1 swap + u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 + u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE + # => [delayed_threshold, PROC_ROOT] + + drop + # => [PROC_ROOT] + end + + push.0 + loc_load.2 + loc_load.1 + loc_load.0 + # => [immediate_threshold, delayed_threshold, note_restrictions, 0, PROC_ROOT] + + swapw + # => [PROC_ROOT, immediate_threshold, delayed_threshold, note_restrictions, 0] + + push.PROCEDURE_POLICIES_SLOT[0..2] + # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, POLICY_WORD] + + exec.native_account::set_map_item + # => [OLD_POLICY_WORD] + + dropw + # => [] +end + +#! Updates threshold config, approvers, and approver scheme ids for smart multisig accounts. +#! +#! Same advice map and config layout as [`multisig::update_signers_and_threshold`]. Differs by +#! validating smart procedure policies ([`assert_proc_policies_lte_num_approvers`]) instead +#! of per-procedure threshold overrides. +#! +#! Inputs: +#! Operand stack: [MULTISIG_CONFIG_HASH, pad(12)] +#! Outputs: +#! Operand stack: [] +#! +#! Panics if: +#! - the new threshold exceeds the new number of approvers. +#! - the new threshold or number of approvers is zero. +#! - any existing smart procedure policy becomes unreachable under the new number of approvers. +#! - any provided scheme identifier word is malformed. +#! +#! Locals: +#! 0: new_num_of_approvers +#! 1: init_num_of_approvers +#! +#! Invocation: call +@locals(2) +pub proc update_signers_and_threshold(multisig_config_hash: word) + adv.push_mapval + # => [MULTISIG_CONFIG_HASH, pad(12)] + + adv_loadw + # => [MULTISIG_CONFIG, pad(12)] + + dup.1 loc_store.0 + # => [MULTISIG_CONFIG, pad(12)] + + dup dup.2 + # => [num_approvers, threshold, MULTISIG_CONFIG, pad(12)] + + u32assert2.err=ERR_MALFORMED_MULTISIG_CONFIG + u32gt assertz.err=ERR_MALFORMED_MULTISIG_CONFIG + # => [MULTISIG_CONFIG, pad(12)] + + dup dup.2 + # => [num_approvers, threshold, MULTISIG_CONFIG, pad(12)] + + eq.0 assertz.err=ERR_ZERO_IN_MULTISIG_CONFIG + eq.0 assertz.err=ERR_ZERO_IN_MULTISIG_CONFIG + # => [MULTISIG_CONFIG, pad(12)] + + loc_load.0 + # => [num_approvers, MULTISIG_CONFIG, pad(12)] + + exec.assert_proc_policies_lte_num_approvers + # => [MULTISIG_CONFIG, pad(12)] + + push.THRESHOLD_CONFIG_SLOT[0..2] + # => [config_slot_suffix, config_slot_prefix, MULTISIG_CONFIG, pad(12)] + + exec.native_account::set_item + # => [OLD_THRESHOLD_CONFIG, pad(12)] + + drop loc_store.1 drop drop + # => [pad(12)] + + loc_load.0 + # => [num_approvers] + + dup neq.0 + while.true + sub.1 + # => [i-1, pad(12)] + + dup exec.signature::create_approver_map_key + # => [APPROVER_MAP_KEY, i-1, pad(12)] + + padw adv_loadw + # => [PUB_KEY, APPROVER_MAP_KEY, i-1, pad(12)] + + swapw + # => [APPROVER_MAP_KEY, PUB_KEY, i-1, pad(12)] + + push.APPROVER_PUBLIC_KEYS_SLOT[0..2] + # => [pub_key_slot_suffix, pub_key_slot_prefix, APPROVER_MAP_KEY, PUB_KEY, i-1, pad(12)] + + exec.native_account::set_map_item + # => [OLD_VALUE, i-1, pad(12)] + + adv_loadw + # => [SCHEME_ID_WORD, i-1, pad(12)] + + exec.auth::signature::assert_supported_scheme_word + # => [SCHEME_ID_WORD, i-1, pad(12)] + + dup.4 exec.signature::create_approver_map_key + # => [APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] + + push.APPROVER_SCHEME_ID_SLOT[0..2] + # => [scheme_id_slot_id_suffix, scheme_id_slot_id_prefix, APPROVER_MAP_KEY, SCHEME_ID_WORD, i-1, pad(12)] + + exec.native_account::set_map_item + # => [OLD_VALUE, i-1, pad(12)] + + dropw + # => [i-1, pad(12)] + + dup neq.0 + # => [is_non_zero, i-1, pad(12)] + end + # => [pad(13)] + + drop + # => [pad(12)] + + loc_load.0 loc_load.1 + # => [init_num_of_approvers, new_num_of_approvers, pad(12)] + + exec.multisig::cleanup_pubkey_and_scheme_id_mapping + # => [pad(12)] +end + +#! Authenticate a transaction using multisig smart-policy rules. +#! +#! Inputs: +#! Operand stack: [SALT] +#! Outputs: +#! Operand stack: [TX_SUMMARY_COMMITMENT] +#! +#! Locals: +#! 0: policy_threshold +#! +#! Invocation: call +@locals(1) +pub proc auth_tx(salt: word) + exec.native_account::incr_nonce drop + # => [SALT] + + # ------ Computing transaction summary ------ + exec.auth::create_tx_summary + # => [SALT, OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, ACCOUNT_DELTA_COMMITMENT] + + adv.insert_hqword + # => [SALT, OUTPUT_NOTES_COMMITMENT, INPUT_NOTES_COMMITMENT, ACCOUNT_DELTA_COMMITMENT] + + exec.auth::hash_tx_summary + # => [TX_SUMMARY_COMMITMENT] + + # ------ Computing procedure policy ------ + exec.compute_procedure_policy_context + # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + + loc_store.0 + # => [note_restrictions, TX_SUMMARY_COMMITMENT] + + # note_restrictions are already enforced inside compute_procedure_policy_context. + drop + # => [TX_SUMMARY_COMMITMENT] + + # ------ Verifying approver signatures ------ + exec.multisig::get_threshold_and_num_approvers + # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + + movdn.5 + # => [num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] + + push.APPROVER_PUBLIC_KEYS_SLOT[0..2] + # => [pub_key_slot_prefix, pub_key_slot_suffix, num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] + + push.APPROVER_SCHEME_ID_SLOT[0..2] + # => [scheme_slot_prefix, scheme_slot_suffix, pub_key_slot_prefix, pub_key_slot_suffix, num_of_approvers, TX_SUMMARY_COMMITMENT, default_threshold] + + exec.::miden::standards::auth::signature::verify_signatures + # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, default_threshold] + + movup.5 + # => [default_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + loc_load.0 + # => [policy_threshold, default_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + exec.compute_tx_threshold_smart + # => [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="insufficient number of signatures" + end +end diff --git a/crates/miden-standards/asm/standards/auth/signature.masm b/crates/miden-standards/asm/standards/auth/signature.masm index 49cec90b17..dd72eec9db 100644 --- a/crates/miden-standards/asm/standards/auth/signature.masm +++ b/crates/miden-standards/asm/standards/auth/signature.masm @@ -320,8 +320,7 @@ end #! #! Inputs: [key_index] #! Outputs: [APPROVER_MAP_KEY] -proc create_approver_map_key +pub proc create_approver_map_key push.0.0.0 movup.3 - # => [[key_index, 0, 0, 0]] # => [APPROVER_MAP_KEY] end diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index 4a526bb77d..fdce623a77 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -10,5 +10,8 @@ pub use singlesig_acl::{AuthSingleSigAcl, AuthSingleSigAclConfig}; mod multisig; pub use multisig::{AuthMultisig, AuthMultisigConfig}; +pub mod multisig_smart; +pub use multisig_smart::{AuthMultisigSmart, AuthMultisigSmartConfig}; + mod guarded_multisig; pub use guarded_multisig::{AuthGuardedMultisig, AuthGuardedMultisigConfig, GuardianConfig}; diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs new file mode 100644 index 0000000000..18c46bbaa2 --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -0,0 +1,424 @@ +use alloc::vec::Vec; + +use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; +use miden_protocol::account::component::{ + AccountComponentMetadata, + FeltSchema, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountType, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::Word; +use miden_protocol::errors::AccountError; +use miden_protocol::utils::sync::LazyLock; + +use super::ProcedurePolicy; +use crate::account::components::multisig_smart_library; + +// CONSTANTS +// ================================================================================================ + +static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::threshold_config") + .expect("storage slot name should be valid") +}); + +static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys") + .expect("storage slot name should be valid") +}); + +static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::approver_schemes") + .expect("storage slot name should be valid") +}); + +static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") + .expect("storage slot name should be valid") +}); + +static PROCEDURE_POLICIES_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig_smart::procedure_policies") + .expect("storage slot name should be valid") +}); + +// MULTISIG SMART AUTHENTICATION COMPONENT +// ================================================================================================ + +/// Configuration for [`AuthMultisigSmart`] component. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthMultisigSmartConfig { + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + default_threshold: u32, + procedure_policies: Vec<(Word, ProcedurePolicy)>, +} + +impl AuthMultisigSmartConfig { + /// Creates a new configuration with the given approvers and a default threshold. + /// + /// The `default_threshold` must be at least 1 and at most the number of approvers. + pub fn new( + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + default_threshold: u32, + ) -> Result { + if default_threshold == 0 { + return Err(AccountError::other("threshold must be at least 1")); + } + if default_threshold > approvers.len() as u32 { + return Err(AccountError::other( + "threshold cannot be greater than number of approvers", + )); + } + + let unique_approvers: alloc::collections::BTreeSet<_> = + approvers.iter().map(|(pk, _)| pk).collect(); + if unique_approvers.len() != approvers.len() { + return Err(AccountError::other("duplicate approver public keys are not allowed")); + } + + Ok(Self { + approvers, + default_threshold, + procedure_policies: vec![], + }) + } + + /// Attaches a per-procedure smart policy map. + pub fn with_proc_policies( + mut self, + proc_policies: Vec<(Word, ProcedurePolicy)>, + ) -> Result { + validate_proc_policies(self.approvers.len() as u32, &proc_policies)?; + self.procedure_policies = proc_policies; + Ok(self) + } + + pub fn approvers(&self) -> &[(PublicKeyCommitment, AuthScheme)] { + &self.approvers + } + + pub fn default_threshold(&self) -> u32 { + self.default_threshold + } + + pub fn procedure_policies(&self) -> &[(Word, ProcedurePolicy)] { + &self.procedure_policies + } +} + +fn validate_proc_policies( + num_approvers: u32, + proc_policies: &[(Word, ProcedurePolicy)], +) -> Result<(), AccountError> { + for (_, policy) in proc_policies { + if let Some(immediate_threshold) = policy.immediate_threshold() + && immediate_threshold > num_approvers + { + return Err(AccountError::other( + "procedure policy immediate threshold cannot exceed number of approvers", + )); + } + if let Some(delay_threshold) = policy.delay_threshold() + && delay_threshold > num_approvers + { + return Err(AccountError::other( + "procedure policy delay threshold cannot exceed number of approvers", + )); + } + } + + Ok(()) +} + +/// An [`AccountComponent`] implementing a multisig auth component with smart-policy slots. +#[derive(Debug)] +pub struct AuthMultisigSmart { + config: AuthMultisigSmartConfig, +} + +impl AuthMultisigSmart { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::components::auth::multisig_smart"; + + /// Creates a new [`AuthMultisigSmart`] component from the provided configuration. + pub fn new(config: AuthMultisigSmartConfig) -> Result { + validate_proc_policies(config.approvers().len() as u32, config.procedure_policies())?; + Ok(Self { config }) + } + + pub fn threshold_config_slot() -> &'static StorageSlotName { + &THRESHOLD_CONFIG_SLOT_NAME + } + + pub fn approver_public_keys_slot() -> &'static StorageSlotName { + &APPROVER_PUBKEYS_SLOT_NAME + } + + pub fn approver_scheme_ids_slot() -> &'static StorageSlotName { + &APPROVER_SCHEME_ID_SLOT_NAME + } + + pub fn executed_transactions_slot() -> &'static StorageSlotName { + &EXECUTED_TRANSACTIONS_SLOT_NAME + } + + pub fn procedure_policies_slot() -> &'static StorageSlotName { + &PROCEDURE_POLICIES_SLOT_NAME + } + + pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::threshold_config_slot().clone(), + StorageSlotSchema::value( + "Threshold configuration", + [ + FeltSchema::u32("threshold"), + FeltSchema::u32("num_approvers"), + FeltSchema::new_void(), + FeltSchema::new_void(), + ], + ), + ) + } + + pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::approver_public_keys_slot().clone(), + StorageSlotSchema::map( + "Approver public keys", + SchemaType::u32(), + SchemaType::pub_key(), + ), + ) + } + + pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::approver_scheme_ids_slot().clone(), + StorageSlotSchema::map( + "Approver scheme IDs", + SchemaType::u32(), + SchemaType::auth_scheme(), + ), + ) + } + + pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::executed_transactions_slot().clone(), + StorageSlotSchema::map( + "Executed transactions", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + pub fn procedure_policies_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::procedure_policies_slot().clone(), + StorageSlotSchema::map( + "Procedure policies", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } +} + +impl From for AccountComponent { + fn from(multisig: AuthMultisigSmart) -> Self { + let mut storage_slots = Vec::with_capacity(5); + + // Threshold config slot (value: [threshold, num_approvers, 0, 0]) + let num_approvers = multisig.config.approvers().len() as u32; + storage_slots.push(StorageSlot::with_value( + AuthMultisigSmart::threshold_config_slot().clone(), + Word::from([multisig.config.default_threshold(), num_approvers, 0, 0]), + )); + + // Approver public keys slot (map) + let map_entries = + multisig.config.approvers().iter().enumerate().map(|(i, (pub_key, _))| { + (StorageMapKey::from_index(i as u32), Word::from(*pub_key)) + }); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::approver_public_keys_slot().clone(), + StorageMap::with_entries(map_entries).unwrap(), + )); + + // Approver scheme IDs slot + let scheme_id_entries = + multisig.config.approvers().iter().enumerate().map(|(i, (_, auth_scheme))| { + (StorageMapKey::from_index(i as u32), Word::from([*auth_scheme as u32, 0, 0, 0])) + }); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::approver_scheme_ids_slot().clone(), + StorageMap::with_entries(scheme_id_entries).unwrap(), + )); + + // Executed transactions slot (map) + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::executed_transactions_slot().clone(), + StorageMap::default(), + )); + + // Procedure policies slot (map) + let procedure_policies = + StorageMap::with_entries(multisig.config.procedure_policies().iter().map( + |(proc_root, policy)| (StorageMapKey::from_raw(*proc_root), policy.to_word()), + )) + .unwrap(); + storage_slots.push(StorageSlot::with_map( + AuthMultisigSmart::procedure_policies_slot().clone(), + procedure_policies, + )); + + 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(), + ]) + .expect("storage schema should be valid"); + + let metadata = AccountComponentMetadata::new(AuthMultisigSmart::NAME, AccountType::all()) + .with_description("Multisig smart authentication component") + .with_storage_schema(storage_schema); + + AccountComponent::new(multisig_smart_library(), storage_slots, metadata).expect( + "multisig smart component should satisfy the requirements of a valid account component", + ) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use miden_protocol::account::AccountBuilder; + use miden_protocol::account::auth::AuthSecretKey; + + use super::*; + use crate::account::auth::multisig_smart::ProcedurePolicyNoteRestriction; + use crate::account::wallets::BasicWallet; + + #[test] + fn test_multisig_smart_component_setup() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let config = AuthMultisigSmartConfig::new(approvers.clone(), 2) + .expect("invalid multisig smart config") + .with_proc_policies(vec![( + BasicWallet::receive_asset_digest(), + ProcedurePolicy::with_immediate_threshold(1) + .expect("procedure policy should be valid"), + )]) + .expect("procedure policy config should be valid"); + + let component = + AuthMultisigSmart::new(config).expect("multisig smart component creation failed"); + + let account = AccountBuilder::new([0; 32]) + .with_auth_component(component) + .with_component(BasicWallet) + .build() + .expect("account building failed"); + + let threshold_config = account + .storage() + .get_item(AuthMultisigSmart::threshold_config_slot()) + .expect("threshold config should be present"); + assert_eq!(threshold_config, Word::from([2u32, 2u32, 0, 0])); + + let receive_asset_policy = account + .storage() + .get_map_item( + AuthMultisigSmart::procedure_policies_slot(), + BasicWallet::receive_asset_digest(), + ) + .expect("receive_asset policy should be present"); + assert_eq!(receive_asset_policy, Word::from([1u32, 0u32, 0u32, 0u32])); + } + + #[test] + fn test_multisig_smart_component_error_cases() { + 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); + assert!(result.unwrap_err().to_string().contains("threshold must be at least 1")); + + let result = AuthMultisigSmartConfig::new(approvers.clone(), 2); + assert!( + result + .unwrap_err() + .to_string() + .contains("threshold cannot be greater than number of approvers") + ); + + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + let approvers = vec![ + (sec_key.public_key().to_commitment(), sec_key.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let result = AuthMultisigSmartConfig::new(approvers.clone(), 2).and_then(|cfg| { + let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2)?; + cfg.with_proc_policies(vec![(Word::from([1u32, 2, 3, 4]), policy)]) + }); + assert!( + result + .unwrap_err() + .to_string() + .contains("delay threshold cannot exceed immediate threshold") + ); + + let result = AuthMultisigSmartConfig::new(approvers, 2).and_then(|cfg| { + let policy = ProcedurePolicy::with_immediate_threshold(0)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); + cfg.with_proc_policies(vec![(Word::from([4u32, 3, 2, 1]), policy)]) + }); + assert!( + result + .unwrap_err() + .to_string() + .contains("procedure policy immediate threshold must be at least 1") + ); + } + + #[test] + fn test_multisig_smart_component_duplicate_approvers() { + let sec_key_1 = AuthSecretKey::new_ecdsa_k256_keccak(); + let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); + + let approvers = vec![ + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), + (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), + ]; + + let result = AuthMultisigSmartConfig::new(approvers, 2); + assert!( + result + .unwrap_err() + .to_string() + .contains("duplicate approver public keys are not allowed") + ); + } +} diff --git a/crates/miden-standards/src/account/auth/multisig_smart/mod.rs b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs new file mode 100644 index 0000000000..8a3b7e705b --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/mod.rs @@ -0,0 +1,9 @@ +mod component; +mod procedure_policies; + +pub use component::{AuthMultisigSmart, AuthMultisigSmartConfig}; +pub use procedure_policies::{ + ProcedurePolicy, + ProcedurePolicyExecutionMode, + ProcedurePolicyNoteRestriction, +}; 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 new file mode 100644 index 0000000000..c83245cc0a --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs @@ -0,0 +1,246 @@ +use miden_protocol::Word; +use miden_protocol::errors::AccountError; + +/// Defines which execution modes a procedure policy supports and the corresponding threshold +/// values for each mode. +/// +/// A procedure can require the immediate threshold, the delayed threshold, or support both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcedurePolicyExecutionMode { + ImmediateOnly { + immediate_threshold: u32, + }, + DelayOnly { + delay_threshold: u32, + }, + ImmediateOrDelay { + immediate_threshold: u32, + delay_threshold: u32, + }, +} + +/// Note Restrictions on whether transactions that call a procedure may consume input notes +/// or create output notes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum ProcedurePolicyNoteRestriction { + #[default] + None = 0, + NoInputNotes = 1, + NoOutputNotes = 2, + NoInputOrOutputNotes = 3, +} + +/// Defines a per-procedure multisig policy. +/// +/// A procedure policy can override the default multisig requirements for a specific procedure. +/// It specifies: +/// - an execution mode, which determines whether the procedure can be executed immediately, after a +/// delay, or both +/// - note restrictions, which limit whether a transaction invoking the procedure may consume input +/// notes or create output notes +/// +/// Execution modes: +/// - Immediate execution: the action is authorized and executed within the current transaction. +/// - Delayed execution: the action is proposed first, and can only be executed after a required +/// time delay has elapsed. +/// +/// Thresholds: +/// - Immediate threshold: the number of signatures required to authorize immediate execution. +/// - Delayed threshold: the number of signatures required to authorize a delayed action. +/// +/// 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]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcedurePolicy { + execution_mode: ProcedurePolicyExecutionMode, + note_restrictions: ProcedurePolicyNoteRestriction, +} + +impl ProcedurePolicy { + /// Creates an explicit procedure policy from an execution mode and note restriction pair. + /// + /// Common multisig cases should generally prefer the `with_*_threshold...` helpers and + /// configure note restrictions afterwards via [`ProcedurePolicy::with_note_restriction`]. + pub fn new( + execution_mode: ProcedurePolicyExecutionMode, + note_restrictions: ProcedurePolicyNoteRestriction, + ) -> Result { + Self::validate_execution_mode(execution_mode)?; + Ok(Self { execution_mode, note_restrictions }) + } + + pub fn with_immediate_threshold(immediate_threshold: u32) -> Result { + Self::new( + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub fn with_delay_threshold(delay_threshold: u32) -> Result { + Self::new( + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub fn with_immediate_and_delay_thresholds( + immediate_threshold: u32, + delay_threshold: u32, + ) -> Result { + Self::new( + ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, delay_threshold }, + ProcedurePolicyNoteRestriction::None, + ) + } + + pub const fn with_note_restriction( + mut self, + note_restrictions: ProcedurePolicyNoteRestriction, + ) -> Self { + self.note_restrictions = note_restrictions; + self + } + + pub const fn execution_mode(&self) -> ProcedurePolicyExecutionMode { + self.execution_mode + } + + pub const fn note_restrictions(&self) -> ProcedurePolicyNoteRestriction { + self.note_restrictions + } + + pub const fn immediate_threshold(&self) -> Option { + match self.execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { + Some(immediate_threshold) + }, + ProcedurePolicyExecutionMode::DelayOnly { .. } => None, + ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, .. } => { + Some(immediate_threshold) + }, + } + } + + pub const fn delay_threshold(&self) -> Option { + match self.execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { .. } => None, + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => Some(delay_threshold), + ProcedurePolicyExecutionMode::ImmediateOrDelay { delay_threshold, .. } => { + Some(delay_threshold) + }, + } + } + + fn validate_execution_mode( + execution_mode: ProcedurePolicyExecutionMode, + ) -> Result<(), AccountError> { + match execution_mode { + ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { + if immediate_threshold == 0 { + return Err(AccountError::other( + "procedure policy immediate threshold must be at least 1", + )); + } + }, + ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => { + if delay_threshold == 0 { + return Err(AccountError::other( + "procedure policy delay threshold must be at least 1", + )); + } + }, + ProcedurePolicyExecutionMode::ImmediateOrDelay { + immediate_threshold, + delay_threshold, + } => { + if immediate_threshold == 0 || delay_threshold == 0 { + return Err(AccountError::other( + "immediate and delayed thresholds must both be at least 1", + )); + } + // Delayed execution is the lower-quorum option while immediate execution is + // higher-quorum path. If the delay threshold were greater than the + // immediate threshold, the "fast" path would be easier to satisfy + // than the delayed path, which contradicts that model. + if delay_threshold > immediate_threshold { + return Err(AccountError::other( + "delay threshold cannot exceed immediate threshold", + )); + } + }, + } + + Ok(()) + } + + pub fn to_word(self) -> Word { + let immediate_threshold = self.immediate_threshold().unwrap_or(0); + let delay_threshold = self.delay_threshold().unwrap_or(0); + + Word::from([immediate_threshold, delay_threshold, self.note_restrictions as u32, 0]) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use super::{ProcedurePolicy, ProcedurePolicyNoteRestriction}; + + #[test] + fn procedure_policy_word_encoding_matches_storage_layout() { + let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) + .unwrap() + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes); + + assert_eq!(policy.to_word(), [4u32, 3, 3, 0].into()); + } + + #[test] + fn procedure_policy_construction_rejects_invalid_combinations() { + assert!( + ProcedurePolicy::with_immediate_threshold(0) + .unwrap_err() + .to_string() + .contains("procedure policy immediate threshold must be at least 1") + ); + + assert!( + ProcedurePolicy::with_immediate_and_delay_thresholds(1, 0) + .unwrap_err() + .to_string() + .contains("immediate and delayed thresholds must both be at least 1") + ); + + assert!( + ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2) + .unwrap_err() + .to_string() + .contains("delay threshold cannot exceed immediate threshold") + ); + } + + #[test] + fn procedure_policy_thresholds_are_exposed_with_getters() { + let procedure_policy = ProcedurePolicy::with_delay_threshold(2).unwrap(); + + assert_eq!(procedure_policy.immediate_threshold(), None); + assert_eq!(procedure_policy.delay_threshold(), Some(2)); + } + + #[test] + fn procedure_policy_note_restrictions_are_exposed_with_getters() { + let procedure_policy = ProcedurePolicy::with_immediate_threshold(2) + .unwrap() + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); + + assert_eq!(ProcedurePolicyNoteRestriction::default(), ProcedurePolicyNoteRestriction::None); + assert_eq!( + procedure_policy.note_restrictions(), + ProcedurePolicyNoteRestriction::NoInputNotes + ); + } +} diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index 723cbf5d14..673a21a0be 100644 --- a/crates/miden-standards/src/account/components/mod.rs +++ b/crates/miden-standards/src/account/components/mod.rs @@ -69,6 +69,15 @@ static GUARDED_MULTISIG_LIBRARY: LazyLock = LazyLock::new(|| { Library::read_from_bytes(bytes).expect("Shipped Guarded Multisig library is well-formed") }); +/// Initialize the Multisig Smart library only once. +static MULTISIG_SMART_LIBRARY: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!( + env!("OUT_DIR"), + "/assets/account_components/auth/multisig_smart.masl" + )); + Library::read_from_bytes(bytes).expect("Shipped Multisig Smart library is well-formed") +}); + // Initialize the NoAuth library only once. static NO_AUTH_LIBRARY: LazyLock = LazyLock::new(|| { let bytes = @@ -170,6 +179,11 @@ pub fn guarded_multisig_library() -> Library { GUARDED_MULTISIG_LIBRARY.clone() } +/// Returns the Multisig Smart Library. +pub fn multisig_smart_library() -> Library { + MULTISIG_SMART_LIBRARY.clone() +} + /// Returns the NoAuth Library. pub fn no_auth_library() -> Library { NO_AUTH_LIBRARY.clone() @@ -187,6 +201,7 @@ pub enum StandardAccountComponent { AuthSingleSig, AuthSingleSigAcl, AuthMultisig, + AuthMultisigSmart, AuthGuardedMultisig, AuthNoAuth, } @@ -200,7 +215,9 @@ impl StandardAccountComponent { Self::NetworkFungibleFaucet => NETWORK_FUNGIBLE_FAUCET_LIBRARY.as_ref(), Self::AuthSingleSig => SINGLESIG_LIBRARY.as_ref(), Self::AuthSingleSigAcl => SINGLESIG_ACL_LIBRARY.as_ref(), + Self::AuthMultisig => MULTISIG_LIBRARY.as_ref(), + Self::AuthMultisigSmart => MULTISIG_SMART_LIBRARY.as_ref(), Self::AuthGuardedMultisig => GUARDED_MULTISIG_LIBRARY.as_ref(), Self::AuthNoAuth => NO_AUTH_LIBRARY.as_ref(), }; @@ -257,6 +274,9 @@ impl StandardAccountComponent { Self::AuthGuardedMultisig => { component_interface_vec.push(AccountComponentInterface::AuthGuardedMultisig) }, + Self::AuthMultisigSmart => { + component_interface_vec.push(AccountComponentInterface::AuthMultisigSmart) + }, Self::AuthNoAuth => { component_interface_vec.push(AccountComponentInterface::AuthNoAuth) }, @@ -275,6 +295,7 @@ impl StandardAccountComponent { Self::NetworkFungibleFaucet.extract_component(procedures_set, component_interface_vec); Self::AuthSingleSig.extract_component(procedures_set, component_interface_vec); Self::AuthSingleSigAcl.extract_component(procedures_set, component_interface_vec); + Self::AuthMultisigSmart.extract_component(procedures_set, component_interface_vec); Self::AuthGuardedMultisig.extract_component(procedures_set, component_interface_vec); Self::AuthMultisig.extract_component(procedures_set, component_interface_vec); Self::AuthNoAuth.extract_component(procedures_set, component_interface_vec); diff --git a/crates/miden-standards/src/account/interface/component.rs b/crates/miden-standards/src/account/interface/component.rs index 6ce052af26..0606029102 100644 --- a/crates/miden-standards/src/account/interface/component.rs +++ b/crates/miden-standards/src/account/interface/component.rs @@ -7,7 +7,13 @@ use miden_protocol::note::PartialNote; use miden_protocol::{Felt, Word}; use crate::AuthMethod; -use crate::account::auth::{AuthGuardedMultisig, AuthMultisig, AuthSingleSig, AuthSingleSigAcl}; +use crate::account::auth::{ + AuthGuardedMultisig, + AuthMultisig, + AuthMultisigSmart, + AuthSingleSig, + AuthSingleSigAcl, +}; use crate::account::interface::AccountInterfaceError; // ACCOUNT COMPONENT INTERFACE @@ -34,6 +40,9 @@ pub enum AccountComponentInterface { /// [`AuthMultisig`][crate::account::auth::AuthMultisig] module. AuthMultisig, /// Exposes procedures from the + /// [`AuthMultisigSmart`][crate::account::auth::AuthMultisigSmart] module. + AuthMultisigSmart, + /// Exposes procedures from the /// [`AuthGuardedMultisig`][crate::account::auth::AuthGuardedMultisig] module. AuthGuardedMultisig, /// Exposes procedures from the [`NoAuth`][crate::account::auth::NoAuth] module. @@ -64,6 +73,7 @@ impl AccountComponentInterface { AccountComponentInterface::AuthSingleSig => "SingleSig".to_string(), AccountComponentInterface::AuthSingleSigAcl => "SingleSig ACL".to_string(), AccountComponentInterface::AuthMultisig => "Multisig".to_string(), + AccountComponentInterface::AuthMultisigSmart => "Multisig Smart".to_string(), AccountComponentInterface::AuthGuardedMultisig => "Guarded Multisig".to_string(), AccountComponentInterface::AuthNoAuth => "No Auth".to_string(), AccountComponentInterface::Custom(proc_root_vec) => { @@ -86,6 +96,7 @@ impl AccountComponentInterface { AccountComponentInterface::AuthSingleSig | AccountComponentInterface::AuthSingleSigAcl | AccountComponentInterface::AuthMultisig + | AccountComponentInterface::AuthMultisigSmart | AccountComponentInterface::AuthGuardedMultisig | AccountComponentInterface::AuthNoAuth ) @@ -120,6 +131,14 @@ impl AccountComponentInterface { AuthGuardedMultisig::approver_scheme_ids_slot(), )] }, + AccountComponentInterface::AuthMultisigSmart => { + vec![extract_multisig_auth_method( + storage, + AuthMultisigSmart::threshold_config_slot(), + AuthMultisigSmart::approver_public_keys_slot(), + AuthMultisigSmart::approver_scheme_ids_slot(), + )] + }, AccountComponentInterface::AuthNoAuth => vec![AuthMethod::NoAuth], _ => vec![], // Non-auth components return empty vector } diff --git a/crates/miden-standards/src/account/interface/extension.rs b/crates/miden-standards/src/account/interface/extension.rs index ebeac7355e..ed16891ec7 100644 --- a/crates/miden-standards/src/account/interface/extension.rs +++ b/crates/miden-standards/src/account/interface/extension.rs @@ -15,6 +15,7 @@ use crate::account::components::{ basic_wallet_library, guarded_multisig_library, multisig_library, + multisig_smart_library, network_fungible_faucet_library, no_auth_library, singlesig_acl_library, @@ -117,6 +118,10 @@ impl AccountInterfaceExt for AccountInterface { component_proc_digests .extend(guarded_multisig_library().mast_forest().procedure_digests()); }, + AccountComponentInterface::AuthMultisigSmart => { + component_proc_digests + .extend(multisig_smart_library().mast_forest().procedure_digests()); + }, AccountComponentInterface::AuthNoAuth => { component_proc_digests .extend(no_auth_library().mast_forest().procedure_digests()); diff --git a/crates/miden-testing/src/mock_chain/auth.rs b/crates/miden-testing/src/mock_chain/auth.rs index 566d0904c9..4d604c9b7c 100644 --- a/crates/miden-testing/src/mock_chain/auth.rs +++ b/crates/miden-testing/src/mock_chain/auth.rs @@ -6,11 +6,14 @@ use miden_protocol::Word; use miden_protocol::account::AccountComponent; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKeyCommitment}; use miden_protocol::testing::noop_auth_component::NoopAuthComponent; +use miden_standards::account::auth::multisig_smart::ProcedurePolicy; use miden_standards::account::auth::{ AuthGuardedMultisig, AuthGuardedMultisigConfig, AuthMultisig, AuthMultisigConfig, + AuthMultisigSmart, + AuthMultisigSmartConfig, AuthSingleSig, AuthSingleSigAcl, AuthSingleSigAclConfig, @@ -46,6 +49,13 @@ pub enum Auth { proc_threshold_map: Vec<(Word, u32)>, }, + /// Multisig with smart per-procedure policy configuration. + MultisigSmart { + threshold: u32, + approvers: Vec<(PublicKeyCommitment, AuthScheme)>, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, + }, + /// Creates a secret key for the account, and creates a [BasicAuthenticator] used to /// authenticate the account with [AuthSingleSigAcl]. Authentication will only be /// triggered if any of the procedures specified in the list are called during execution. @@ -112,6 +122,17 @@ 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"); + + let component = AuthMultisigSmart::new(config) + .expect("multisig smart component creation failed") + .into(); + + (component, None) + }, Auth::Acl { auth_trigger_procedures, allow_unauthorized_output_notes, diff --git a/crates/miden-testing/tests/auth/mod.rs b/crates/miden-testing/tests/auth/mod.rs index 752dfd9700..5fe047bfc6 100644 --- a/crates/miden-testing/tests/auth/mod.rs +++ b/crates/miden-testing/tests/auth/mod.rs @@ -4,4 +4,6 @@ mod multisig; mod hybrid_multisig; +mod multisig_smart; + mod guarded_multisig; diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs new file mode 100644 index 0000000000..70df1bf179 --- /dev/null +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -0,0 +1,217 @@ +use miden_protocol::Word; +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; +use miden_protocol::account::{ + Account, + AccountBuilder, + AccountId, + AccountStorageMode, + AccountType, +}; +use miden_protocol::asset::FungibleAsset; +use miden_protocol::note::NoteType; +use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; +use miden_protocol::{Felt}; +use miden_standards::account::auth::multisig_smart::{ + ProcedurePolicy, + ProcedurePolicyNoteRestriction, +}; +use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES; +use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; +use miden_tx::TransactionExecutorError; +use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use rstest::rstest; + +// ================================================================================================ +// HELPER FUNCTIONS +// ================================================================================================ + +type MultisigTestSetup = + (Vec, Vec, Vec, Vec); + +/// Sets up secret keys, auth schemes, public keys, and authenticators for a specific scheme. +fn setup_keys_and_authenticators_with_scheme( + num_approvers: usize, + threshold: usize, + auth_scheme: AuthScheme, +) -> anyhow::Result { + let seed: [u8; 32] = rand::random(); + let mut rng = ChaCha20Rng::from_seed(seed); + + let mut secret_keys = Vec::new(); + let mut auth_schemes = Vec::new(); + let mut public_keys = Vec::new(); + let mut authenticators = Vec::new(); + + for _ in 0..num_approvers { + let sec_key = match auth_scheme { + AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng), + AuthScheme::Falcon512Poseidon2 => { + AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng) + }, + _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), + }; + let pub_key = sec_key.public_key(); + + secret_keys.push(sec_key); + auth_schemes.push(auth_scheme); + public_keys.push(pub_key); + } + + for secret_key in secret_keys.iter().take(threshold) { + authenticators.push(BasicAuthenticator::new(core::slice::from_ref(secret_key))); + } + + Ok((secret_keys, auth_schemes, public_keys, authenticators)) +} + +/// Builds a multisig smart account with the given approvers, threshold, starting balance, and +/// procedure policy map. Uses `BasicWallet` so the account exposes `receive_asset` and friends. +fn create_multisig_smart_account( + threshold: u32, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, + starting_balance: u64, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + let approvers: Vec<_> = + public_keys.iter().map(|pk| (pk.to_commitment(), auth_scheme)).collect(); + let config = AuthMultisigSmartConfig::new(approvers, threshold)? + .with_proc_policies(proc_policy_map)?; + + let asset = + FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, starting_balance)?; + + let multisig_account = AccountBuilder::new([0; 32]) + .with_auth_component(AuthMultisigSmart::new(config)?) + .with_component(BasicWallet) + .account_type(AccountType::RegularAccountUpdatableCode) + .storage_mode(AccountStorageMode::Public) + .with_assets(core::iter::once(asset.into())) + .build_existing()?; + + Ok(multisig_account) +} + +// ================================================================================================ +// TESTS +// ================================================================================================ + +/// A 3-of-3 multisig with a `receive_asset` procedure policy that lowers the threshold to 1 +/// should let a single-signature transaction that only calls `receive_asset` succeed. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_three_to_one_signature( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(3, 3, auth_scheme)?; + + let receive_asset_one_signature_policy = ProcedurePolicy::with_immediate_threshold(1)?; + let proc_policy_map = + vec![(BasicWallet::receive_asset_digest(), receive_asset_one_signature_policy)]; + + let mut multisig_account = + create_multisig_smart_account(3, &public_keys, auth_scheme, 10, proc_policy_map)?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mut mock_chain = mock_chain_builder.build()?; + + let salt = Word::from([Felt::new(11); 4]); + let tx_summary = match mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_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); + let one_signature = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &tx_summary_signing) + .await?; + + 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; + + assert!( + tx_result.is_ok(), + "receive_asset policy threshold=1 should override the default 3-of-3 requirement" + ); + + multisig_account.apply_delta(tx_result.as_ref().unwrap().account_delta())?; + mock_chain.add_pending_executed_transaction(&tx_result.unwrap())?; + mock_chain.prove_next_block()?; + + Ok(()) +} + +/// A procedure policy with `NoInputOrOutputNotes` restriction must abort any transaction that +/// reaches that procedure while carrying input or output notes. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let multisig_account = create_multisig_smart_account( + 2, + &public_keys, + auth_scheme, + 100, + vec![( + BasicWallet::receive_asset_digest(), + ProcedurePolicy::with_immediate_threshold(1)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes), + )], + )?; + + let mut mock_chain_builder = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let note = mock_chain_builder.add_p2id_note( + multisig_account.id(), + multisig_account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = mock_chain_builder.build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[note.id()], &[])? + .auth_args(Word::from([Felt::new(903); 4])) + .build()? + .execute() + .await; + + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES + ); + + Ok(()) +} From 84d302ff1f77f4b188a435f122f9f0ea7f3c3b3d Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 21 Apr 2026 14:37:36 +0200 Subject: [PATCH 02/10] deduplicate multisig procedure_policies --- .../auth/{multisig/mod.rs => multisig.rs} | 3 - .../auth/multisig/procedure_policies.rs | 246 ------------------ 2 files changed, 249 deletions(-) rename crates/miden-standards/src/account/auth/{multisig/mod.rs => multisig.rs} (99%) delete mode 100644 crates/miden-standards/src/account/auth/multisig/procedure_policies.rs diff --git a/crates/miden-standards/src/account/auth/multisig/mod.rs b/crates/miden-standards/src/account/auth/multisig.rs similarity index 99% rename from crates/miden-standards/src/account/auth/multisig/mod.rs rename to crates/miden-standards/src/account/auth/multisig.rs index 31af0ca1e3..edcddf5709 100644 --- a/crates/miden-standards/src/account/auth/multisig/mod.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -1,6 +1,3 @@ -#[allow(dead_code)] -pub(crate) mod procedure_policies; - use alloc::collections::BTreeSet; use alloc::vec::Vec; diff --git a/crates/miden-standards/src/account/auth/multisig/procedure_policies.rs b/crates/miden-standards/src/account/auth/multisig/procedure_policies.rs deleted file mode 100644 index c83245cc0a..0000000000 --- a/crates/miden-standards/src/account/auth/multisig/procedure_policies.rs +++ /dev/null @@ -1,246 +0,0 @@ -use miden_protocol::Word; -use miden_protocol::errors::AccountError; - -/// Defines which execution modes a procedure policy supports and the corresponding threshold -/// values for each mode. -/// -/// A procedure can require the immediate threshold, the delayed threshold, or support both. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProcedurePolicyExecutionMode { - ImmediateOnly { - immediate_threshold: u32, - }, - DelayOnly { - delay_threshold: u32, - }, - ImmediateOrDelay { - immediate_threshold: u32, - delay_threshold: u32, - }, -} - -/// Note Restrictions on whether transactions that call a procedure may consume input notes -/// or create output notes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum ProcedurePolicyNoteRestriction { - #[default] - None = 0, - NoInputNotes = 1, - NoOutputNotes = 2, - NoInputOrOutputNotes = 3, -} - -/// Defines a per-procedure multisig policy. -/// -/// A procedure policy can override the default multisig requirements for a specific procedure. -/// It specifies: -/// - an execution mode, which determines whether the procedure can be executed immediately, after a -/// delay, or both -/// - note restrictions, which limit whether a transaction invoking the procedure may consume input -/// notes or create output notes -/// -/// Execution modes: -/// - Immediate execution: the action is authorized and executed within the current transaction. -/// - Delayed execution: the action is proposed first, and can only be executed after a required -/// time delay has elapsed. -/// -/// Thresholds: -/// - Immediate threshold: the number of signatures required to authorize immediate execution. -/// - Delayed threshold: the number of signatures required to authorize a delayed action. -/// -/// 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]`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProcedurePolicy { - execution_mode: ProcedurePolicyExecutionMode, - note_restrictions: ProcedurePolicyNoteRestriction, -} - -impl ProcedurePolicy { - /// Creates an explicit procedure policy from an execution mode and note restriction pair. - /// - /// Common multisig cases should generally prefer the `with_*_threshold...` helpers and - /// configure note restrictions afterwards via [`ProcedurePolicy::with_note_restriction`]. - pub fn new( - execution_mode: ProcedurePolicyExecutionMode, - note_restrictions: ProcedurePolicyNoteRestriction, - ) -> Result { - Self::validate_execution_mode(execution_mode)?; - Ok(Self { execution_mode, note_restrictions }) - } - - pub fn with_immediate_threshold(immediate_threshold: u32) -> Result { - Self::new( - ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold }, - ProcedurePolicyNoteRestriction::None, - ) - } - - pub fn with_delay_threshold(delay_threshold: u32) -> Result { - Self::new( - ProcedurePolicyExecutionMode::DelayOnly { delay_threshold }, - ProcedurePolicyNoteRestriction::None, - ) - } - - pub fn with_immediate_and_delay_thresholds( - immediate_threshold: u32, - delay_threshold: u32, - ) -> Result { - Self::new( - ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, delay_threshold }, - ProcedurePolicyNoteRestriction::None, - ) - } - - pub const fn with_note_restriction( - mut self, - note_restrictions: ProcedurePolicyNoteRestriction, - ) -> Self { - self.note_restrictions = note_restrictions; - self - } - - pub const fn execution_mode(&self) -> ProcedurePolicyExecutionMode { - self.execution_mode - } - - pub const fn note_restrictions(&self) -> ProcedurePolicyNoteRestriction { - self.note_restrictions - } - - pub const fn immediate_threshold(&self) -> Option { - match self.execution_mode { - ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { - Some(immediate_threshold) - }, - ProcedurePolicyExecutionMode::DelayOnly { .. } => None, - ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, .. } => { - Some(immediate_threshold) - }, - } - } - - pub const fn delay_threshold(&self) -> Option { - match self.execution_mode { - ProcedurePolicyExecutionMode::ImmediateOnly { .. } => None, - ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => Some(delay_threshold), - ProcedurePolicyExecutionMode::ImmediateOrDelay { delay_threshold, .. } => { - Some(delay_threshold) - }, - } - } - - fn validate_execution_mode( - execution_mode: ProcedurePolicyExecutionMode, - ) -> Result<(), AccountError> { - match execution_mode { - ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => { - if immediate_threshold == 0 { - return Err(AccountError::other( - "procedure policy immediate threshold must be at least 1", - )); - } - }, - ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => { - if delay_threshold == 0 { - return Err(AccountError::other( - "procedure policy delay threshold must be at least 1", - )); - } - }, - ProcedurePolicyExecutionMode::ImmediateOrDelay { - immediate_threshold, - delay_threshold, - } => { - if immediate_threshold == 0 || delay_threshold == 0 { - return Err(AccountError::other( - "immediate and delayed thresholds must both be at least 1", - )); - } - // Delayed execution is the lower-quorum option while immediate execution is - // higher-quorum path. If the delay threshold were greater than the - // immediate threshold, the "fast" path would be easier to satisfy - // than the delayed path, which contradicts that model. - if delay_threshold > immediate_threshold { - return Err(AccountError::other( - "delay threshold cannot exceed immediate threshold", - )); - } - }, - } - - Ok(()) - } - - pub fn to_word(self) -> Word { - let immediate_threshold = self.immediate_threshold().unwrap_or(0); - let delay_threshold = self.delay_threshold().unwrap_or(0); - - Word::from([immediate_threshold, delay_threshold, self.note_restrictions as u32, 0]) - } -} - -#[cfg(test)] -mod tests { - use alloc::string::ToString; - - use super::{ProcedurePolicy, ProcedurePolicyNoteRestriction}; - - #[test] - fn procedure_policy_word_encoding_matches_storage_layout() { - let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(4, 3) - .unwrap() - .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes); - - assert_eq!(policy.to_word(), [4u32, 3, 3, 0].into()); - } - - #[test] - fn procedure_policy_construction_rejects_invalid_combinations() { - assert!( - ProcedurePolicy::with_immediate_threshold(0) - .unwrap_err() - .to_string() - .contains("procedure policy immediate threshold must be at least 1") - ); - - assert!( - ProcedurePolicy::with_immediate_and_delay_thresholds(1, 0) - .unwrap_err() - .to_string() - .contains("immediate and delayed thresholds must both be at least 1") - ); - - assert!( - ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2) - .unwrap_err() - .to_string() - .contains("delay threshold cannot exceed immediate threshold") - ); - } - - #[test] - fn procedure_policy_thresholds_are_exposed_with_getters() { - let procedure_policy = ProcedurePolicy::with_delay_threshold(2).unwrap(); - - assert_eq!(procedure_policy.immediate_threshold(), None); - assert_eq!(procedure_policy.delay_threshold(), Some(2)); - } - - #[test] - fn procedure_policy_note_restrictions_are_exposed_with_getters() { - let procedure_policy = ProcedurePolicy::with_immediate_threshold(2) - .unwrap() - .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); - - assert_eq!(ProcedurePolicyNoteRestriction::default(), ProcedurePolicyNoteRestriction::None); - assert_eq!( - procedure_policy.note_restrictions(), - ProcedurePolicyNoteRestriction::NoInputNotes - ); - } -} From c26dfd90d698ffc39255e95ab93fed1942b52bea Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 21 Apr 2026 15:21:53 +0200 Subject: [PATCH 03/10] refactor compute_called_proc_policy --- .../standards/auth/multisig_smart/mod.masm | 230 +++++++++--------- 1 file changed, 121 insertions(+), 109 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm index c38b828a46..45ea4f3b18 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -129,6 +129,11 @@ end #! - transaction_threshold is the effective minimum number of signatures required. #! #! Invocation: exec +#! +#! NOTE: This procedure is a temporary form. Once the Spending Limits and +#! Amount-Based Thresholds features land, it will accept an additional `spending_threshold` +#! parameter and pick max(policy_threshold, spending_threshold) before falling back to +#! default_threshold when both are zero. proc compute_tx_threshold_smart(policy_threshold: u32, default_threshold: u32) -> u32 swap # => [default_threshold, policy_threshold] @@ -167,162 +172,152 @@ end #! Computes the effective per-procedure policy for all called procedures. #! -#! Iterates over all account procedures and accumulates the highest required threshold and -#! most restrictive note restrictions across all procedures that were called in this transaction. +#! Iterates over all account procedures, and for those that were called in this transaction +#! accumulates: +#! - the highest required threshold (max), +#! - the union of their note restrictions (so if one proc forbids input notes and another forbids +#! output notes, the transaction ends up forbidding both), +#! - whether any called procedure requires the delayed execution mode. #! #! Inputs: [is_execute_path] #! Outputs: [policy_threshold, policy_requires_delay, note_restrictions] #! #! Where: -#! - is_execute_path is 1 when the transaction uses the delayed execution (execute) path. +#! - is_execute_path is 1 when the transaction uses the delayed execution mode. #! - policy_threshold is the highest threshold required by any called procedure policy. -#! - policy_requires_delay is 1 when any called procedure requires the delayed execution path. -#! - note_restrictions is the combined note restriction enum across all called procedures. +#! - policy_requires_delay is 1 when any called procedure requires the delayed execution mode. +#! - note_restrictions is the combined (union) note restriction enum across all called procedures. #! #! Panics if: #! - any called procedure's policy does not support the active execution lane. #! +#! Example scenarios (account has policies configured for `receive_asset` and +#! `move_asset_to_note`, immediate execution mode): +#! +#! receive_asset → ProcedurePolicy { immediate=1, delayed=0, restrictions=NoInputNotes (1) } +#! move_asset_to_note → ProcedurePolicy { immediate=3, delayed=0, restrictions=NoOutputNotes (2) } +#! +#! A. Only `receive_asset` is called in the transaction: +#! - threshold_acc = max(0, 1) = 1 +#! - restrictions_acc = 0 | 1 = 1 (NoInputNotes) +#! - Result: 1 signature required; transaction must not consume input notes. +#! +#! B. Only `move_asset_to_note` is called: +#! - threshold_acc = max(0, 3) = 3 +#! - restrictions_acc = 0 | 2 = 2 (NoOutputNotes) +#! - Result: 3 signatures required; transaction must not create output notes. +#! +#! C. Both `receive_asset` and `move_asset_to_note` are called in the same transaction: +#! - after receive_asset: threshold=1, restrictions=0|1 = 1 +#! - after move_asset_to_note: threshold=max(1,3)=3, restrictions=1|2 = 3 (NoInputOrOutputNotes) +#! - Result: 3 signatures required; transaction must not consume input notes *and* must +#! not create output notes (both constraints apply simultaneously). +#! #! Invocation: exec #! #! Locals: #! 0: is_execute_path -#! 1: policy_threshold (accumulated) -#! 2: policy_requires_delay (accumulated) -#! 3: note_restrictions (accumulated) -#! 4: immediate_threshold of the current procedure -#! 5: delayed_threshold of the current procedure -#! 6: note_restrictions of the current procedure -@locals(7) +@locals(1) proc compute_called_proc_policy(is_execute_path: u32) loc_store.0 # => [] - push.0 loc_store.1 - push.0 loc_store.2 - push.0 loc_store.3 - # => [] + # [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + # proc_index starts at num_procedures and counts down to 0 in the loop. + push.0 push.0 push.0 + # => [0, 0, 0] exec.active_account::get_num_procedures - # => [num_procedures] + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] dup neq.0 - # => [should_continue, num_procedures] + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] while.true - sub.1 dup - # => [proc_index, proc_index] + sub.1 + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] - exec.active_account::get_procedure_root dupw - # => [PROC_ROOT, PROC_ROOT, proc_index] + dup exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] - exec.native_account::was_procedure_called - # => [was_called, PROC_ROOT, proc_index] + dupw exec.native_account::was_procedure_called + # => [was_called, PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] if.true + # => [PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + exec.get_procedure_policy - # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index] + # => [immediate, delayed, restr_proc, 0, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] - loc_store.4 - loc_store.5 - loc_store.6 - drop - # => [proc_index] + movup.3 drop + # => [immediate, delayed, restr_proc, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + # Fold per-proc note_restrictions into the accumulator via OR. + # ProcedurePolicyNoteRestriction is bit-encoded (0=None, 1=NoIn, 2=NoOut, 3=Both), + # so bitwise OR is the union of constraints: e.g. NoIn (0b01) ∪ NoOut (0b10) = Both (0b11). + movup.6 movup.3 u32or movdn.5 + # => [immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + dup.1 dup.1 swap + # => [delayed, immediate, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + loc_load.0 + # => [is_execute_path, delayed, immediate, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + cdrop + # delayed execution mode (c=1) → keeps delayed; immediate execution mode (c=0) → keeps immediate. + # => [selected, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - # combine this procedure's note_restrictions with the accumulated value - loc_load.6 dup eq.0 + # => [is_selected_zero, selected, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + if.true - # note_restrictions is none (0) — no change to accumulated value + # Selected threshold is zero — the procedure has no policy for this execution mode. + # The "other" threshold must also be zero. Otherwise the procedure would require + # an execution mode different from the one currently active, which is a misuse + # error. drop - # => [proc_index] - else - drop - # => [proc_index] + # => [immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - loc_load.3 eq.3 - if.true - # accumulated restriction is already maximally restrictive (3) — skip - # => [proc_index] - else - loc_load.6 - loc_load.3 - eq - if.true - # restrictions match — no change needed - # => [proc_index] - else - loc_load.3 eq.0 - if.true - # accumulated restriction is none — adopt this procedure's restriction - loc_load.6 - loc_store.3 - # => [proc_index] - else - # restrictions differ — use most restrictive value (3) - push.3 - loc_store.3 - # => [proc_index] - end - end - end - end + # Pick the "other" threshold + loc_load.0 + # => [is_execute_path, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - # check execution-path compatibility and accumulate threshold - loc_load.0 eq.1 - if.true - # on execute path: select delayed_threshold; immediate must also be non-zero - loc_load.5 - dup eq.0 - if.true - # delayed_threshold is zero — procedure does not support execute path - drop - loc_load.4 eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE - # => [proc_index] - else - drop - loc_load.1 - loc_load.5 - exec.max_threshold_pair - loc_store.1 - # => [proc_index] - - push.1 - loc_store.2 - # => [proc_index] - end + cdrop + # => [other, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE + # => [proc_index, threshold_acc, requires_delay_acc, new_restrictions] else - # on immediate path: select immediate_threshold; delayed must be zero if not - loc_load.4 - dup eq.0 + # Selected threshold is non-zero. + # => [selected, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + movdn.2 drop drop + # => [selected, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + movup.2 exec.max_threshold_pair + # => [new_threshold, proc_index, requires_delay_acc, new_restrictions] + + swap + # => [proc_index, new_threshold, requires_delay_acc, new_restrictions] + + # On the delayed execution mode, mark requires_delay_acc = 1 (replace position 2). + loc_load.0 if.true - # immediate_threshold is zero — procedure does not support immediate path - drop - loc_load.5 eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE - # => [proc_index] - else - drop - loc_load.1 - loc_load.4 - exec.max_threshold_pair - loc_store.1 - # => [proc_index] + movup.2 drop push.1 movdn.2 + # => [proc_index, new_threshold, 1, new_restrictions] end end else dropw - # => [proc_index] + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] end dup neq.0 - # => [should_continue, proc_index] + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] end drop - # => [] - - loc_load.3 - loc_load.2 - loc_load.1 - # => [policy_threshold, policy_requires_delay, note_restrictions] + # => [threshold_acc, requires_delay_acc, restrictions_acc] end #! Enforces note_restrictions against the current transaction. @@ -391,6 +386,11 @@ end #! - note_restrictions is the combined note restriction enum value. #! #! Invocation: exec +#! +#! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature +#! lands, the hardcoded `push.0` will be replaced with `exec.timelock_controller::is_execute_path` +#! so the caller can distinguish immediate vs. delayed execution paths, and the output stack +#! will also include `policy_requires_delay` and `is_execute_path` for downstream enforcement. proc compute_procedure_policy_context(tx_summary_commitment: word) push.0 # => [is_execute_path=0, TX_SUMMARY_COMMITMENT] @@ -773,6 +773,18 @@ end #! 0: policy_threshold #! #! 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 passes the spending threshold into +#! `compute_tx_threshold_smart`. +#! - 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(1) pub proc auth_tx(salt: word) exec.native_account::incr_nonce drop From 8b7b315b503701a4f2ba7372ac6ebeb87766cd26 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 21 Apr 2026 15:32:34 +0200 Subject: [PATCH 04/10] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5c8dc9ce..dff82b4779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - [BREAKING] Added cycle counts to notes returned by `NoteConsumptionInfo` and removed public fields from related types ([#2772](https://github.com/0xMiden/miden-base/issues/2772)). - [BREAKING] Removed unused `payback_attachment` from `SwapNoteStorage` and `attachment` from `MintNoteStorage` ([#2789](https://github.com/0xMiden/protocol/pull/2789)). - Automatically enable `concurrent` feature in `miden-tx` for `std` context ([#2791](https://github.com/0xMiden/protocol/pull/2791)). +- Add foundations for `AuthMultisigSmart` ([#2806])(https://github.com/0xMiden/protocol/pull/2806) ### Fixes From f42641b24915517bbdcf211d7c466f670d9b475b Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 21 Apr 2026 15:32:57 +0200 Subject: [PATCH 05/10] fmt --- .../src/account/auth/multisig_smart/component.rs | 2 +- crates/miden-testing/tests/auth/multisig_smart.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs index 18c46bbaa2..47483849fb 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -1,5 +1,6 @@ use alloc::vec::Vec; +use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ AccountComponentMetadata, @@ -16,7 +17,6 @@ use miden_protocol::account::{ StorageSlot, StorageSlotName, }; -use miden_protocol::Word; use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 70df1bf179..16dafb1aa9 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -1,4 +1,3 @@ -use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; use miden_protocol::account::{ Account, @@ -10,7 +9,7 @@ use miden_protocol::account::{ use miden_protocol::asset::FungibleAsset; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; -use miden_protocol::{Felt}; +use miden_protocol::{Felt, Word}; use miden_standards::account::auth::multisig_smart::{ ProcedurePolicy, ProcedurePolicyNoteRestriction, @@ -79,11 +78,13 @@ fn create_multisig_smart_account( ) -> anyhow::Result { let approvers: Vec<_> = public_keys.iter().map(|pk| (pk.to_commitment(), auth_scheme)).collect(); - let config = AuthMultisigSmartConfig::new(approvers, threshold)? - .with_proc_policies(proc_policy_map)?; + let config = + AuthMultisigSmartConfig::new(approvers, threshold)?.with_proc_policies(proc_policy_map)?; - let asset = - FungibleAsset::new(AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, starting_balance)?; + let asset = FungibleAsset::new( + AccountId::try_from(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET)?, + starting_balance, + )?; let multisig_account = AccountBuilder::new([0; 32]) .with_auth_component(AuthMultisigSmart::new(config)?) From 1e3fcedf4406b3db577f8d133934192c9d9543d2 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Mon, 4 May 2026 16:42:02 +0200 Subject: [PATCH 06/10] add necessary tests --- .../auth/multisig_smart.masm | 1 - .../standards/auth/multisig_smart/mod.masm | 27 +- .../tests/auth/multisig_smart.rs | 240 +++++++++++++++++- 3 files changed, 250 insertions(+), 18 deletions(-) diff --git a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm index 045a825637..af05afa295 100644 --- a/crates/miden-standards/asm/account_components/auth/multisig_smart.masm +++ b/crates/miden-standards/asm/account_components/auth/multisig_smart.masm @@ -11,7 +11,6 @@ pub use multisig::is_signer pub use multisig_smart::set_procedure_policy pub use multisig_smart::update_signers_and_threshold - #! Authenticate a transaction using multisig smart-policy rules. #! #! Inputs: 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 45ea4f3b18..f78b7df659 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -542,7 +542,7 @@ end #! - delayed_threshold exceeds immediate_threshold when immediate_threshold is non-zero. #! - note_restrictions is non-zero while both thresholds are zero. #! -#! Invocation: exec +#! Invocation: call @locals(3) pub proc set_procedure_policy loc_store.0 @@ -554,32 +554,31 @@ pub proc set_procedure_policy loc_store.2 # => [PROC_ROOT] + # Fetch num_approvers ONCE and reuse it for both threshold checks. exec.get_current_num_approvers # => [num_approvers, PROC_ROOT] - loc_load.0 + # Validate immediate_threshold <= num_approvers. + dup loc_load.0 + # => [immediate_threshold, num_approvers, num_approvers, PROC_ROOT] + swap - # => [num_approvers, immediate_threshold, PROC_ROOT] + # => [num_approvers, immediate_threshold, num_approvers, PROC_ROOT] - dup.1 swap u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS - # => [immediate_threshold, PROC_ROOT] - - drop - # => [PROC_ROOT] - - exec.get_current_num_approvers # => [num_approvers, PROC_ROOT] - loc_load.1 + # Validate delayed_threshold <= num_approvers. + dup loc_load.1 + # => [delayed_threshold, num_approvers, num_approvers, PROC_ROOT] + swap - # => [num_approvers, delayed_threshold, PROC_ROOT] + # => [num_approvers, delayed_threshold, num_approvers, PROC_ROOT] - dup.1 swap u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS - # => [delayed_threshold, PROC_ROOT] + # => [num_approvers, PROC_ROOT] drop # => [PROC_ROOT] diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 16dafb1aa9..92813ae6ce 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -1,3 +1,4 @@ +use miden_processor::advice::AdviceInputs; use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; use miden_protocol::account::{ Account, @@ -9,13 +10,17 @@ use miden_protocol::account::{ use miden_protocol::asset::FungibleAsset; use miden_protocol::note::NoteType; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET; -use miden_protocol::{Felt, Word}; +use miden_protocol::transaction::TransactionScript; +use miden_protocol::vm::AdviceMap; +use miden_protocol::{Felt, Hasher, Word}; use miden_standards::account::auth::multisig_smart::{ ProcedurePolicy, ProcedurePolicyNoteRestriction, }; use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig}; +use miden_standards::account::components::multisig_smart_library; use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; @@ -97,6 +102,42 @@ fn create_multisig_smart_account( Ok(multisig_account) } +/// Compiles a transaction script that links against the multisig smart library so it can `call.` +/// the wrapper-exported procedures. +fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result { + Ok(CodeBuilder::default() + .with_dynamically_linked_library(multisig_smart_library())? + .compile_tx_script(script.as_ref())?) +} + +/// Layout expected by `update_signers_and_threshold` when looking up the new multisig config in +/// the advice map: `[threshold, num_approvers, 0, 0, (PUB_KEY, SCHEME_WORD) for each approver]`. +/// Public keys are appended in reverse so the procedure pops them in ascending index order. +fn build_update_signers_config_vector( + threshold: u64, + num_of_approvers: u64, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, +) -> Vec { + let mut config_and_pubkeys_vector = Vec::new(); + config_and_pubkeys_vector.extend_from_slice(&[ + Felt::new(threshold), + Felt::new(num_of_approvers), + Felt::new(0), + Felt::new(0), + ]); + + let scheme_word = [Felt::new(auth_scheme as u64), Felt::new(0), Felt::new(0), Felt::new(0)]; + + for public_key in public_keys.iter().rev() { + let key_word: Word = public_key.to_commitment().into(); + config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); + config_and_pubkeys_vector.extend_from_slice(&scheme_word); + } + + config_and_pubkeys_vector +} + // ================================================================================================ // TESTS // ================================================================================================ @@ -130,7 +171,7 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr )?; let mut mock_chain = mock_chain_builder.build()?; - let salt = Word::from([Felt::new(11); 4]); + let salt = Word::from([Felt::new(1); 4]); let tx_summary = match mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? .auth_args(salt) @@ -202,9 +243,10 @@ async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( )?; let mock_chain = mock_chain_builder.build()?; + let salt = Word::from([Felt::new(2); 4]); let result = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(Word::from([Felt::new(903); 4])) + .auth_args(salt) .build()? .execute() .await; @@ -216,3 +258,195 @@ async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( Ok(()) } + +/// Tests `update_signers_and_threshold` happy path: a 2-of-2 multisig is rotated to a 4-of-3 +/// signer set with new public keys; the new threshold and signers are persisted in storage. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_update_signers_and_thresholds( + #[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, 10, vec![])?; + let account_id = multisig_account.id(); + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + // Generate a fresh 4-signer set; rotate the multisig to 4-of-3 (threshold=3, num_approvers=4). + let (_new_secret_keys, _new_auth_schemes, new_public_keys, _new_authenticators) = + setup_keys_and_authenticators_with_scheme(4, 4, auth_scheme)?; + + let new_threshold: u64 = 3; + let new_num_approvers: u64 = 4; + let multisig_config_data = build_update_signers_config_vector( + new_threshold, + new_num_approvers, + &new_public_keys, + auth_scheme, + ); + let multisig_config_hash = Hasher::hash_elements(&multisig_config_data); + + let mut advice_map = AdviceMap::default(); + advice_map.insert(multisig_config_hash, multisig_config_data); + let advice_inputs = AdviceInputs { map: advice_map, ..Default::default() }; + + let update_signers_script = compile_multisig_smart_tx_script( + " + begin + call.::miden::standards::components::auth::multisig_smart::update_signers_and_threshold + end + ", + )?; + + let salt = Word::from([Felt::new(3); 4]); + + // 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.clone()) + .tx_script_args(multisig_config_hash) + .extend_advice_inputs(advice_inputs.clone()) + .auth_args(salt) + .build()? + .execute() + .await + .unwrap_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); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &signing_inputs) + .await?; + + 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()? + .execute() + .await?; + + multisig_account.apply_delta(executed_tx.account_delta())?; + + // Verify the new threshold/num_approvers config is persisted. + let threshold_config = multisig_account + .storage() + .get_item(AuthMultisigSmart::threshold_config_slot()) + .expect("threshold config slot should be present"); + assert_eq!(threshold_config[0], Felt::new(new_threshold)); + assert_eq!(threshold_config[1], Felt::new(new_num_approvers)); + + // Verify each new public key is stored at its expected map index. + for (i, expected_key) in new_public_keys.iter().enumerate() { + let storage_key = + Word::from([Felt::new(i as u64), Felt::new(0), Felt::new(0), Felt::new(0)]); + let stored_pub_key = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::approver_public_keys_slot(), storage_key) + .expect("approver public key map item should be present"); + let expected_word: Word = expected_key.to_commitment().into(); + assert_eq!(stored_pub_key, expected_word, "public key at index {i} mismatch"); + } + + Ok(()) +} + +/// `set_procedure_policy` invoked from a transaction script must persist the policy to the +/// `procedure_policies` storage map so subsequent transactions see the new policy. +#[rstest] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[tokio::test] +async fn test_multisig_smart_set_procedure_policy( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (_secret_keys, _auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + + // Account starts with no procedure policies configured. + let mut multisig_account = + create_multisig_smart_account(2, &public_keys, auth_scheme, 100, vec![])?; + let account_id = multisig_account.id(); + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let receive_asset_root = BasicWallet::receive_asset_digest(); + // `call.` does not consume operand-stack inputs (the procedure sees a snapshot, the caller's + // stack is preserved across the boundary), so we must manually drop the 7 elements we pushed. + let set_policy_script = compile_multisig_smart_tx_script(format!( + " + begin + push.{root} + push.0 # note_restrictions + push.0 # delayed_threshold + push.1 # immediate_threshold + call.::miden::standards::components::auth::multisig_smart::set_procedure_policy + drop drop drop # immediate, delayed, note_restrictions + dropw # PROC_ROOT + end + ", + root = receive_asset_root, + ))?; + + let salt = Word::from([Felt::new(4); 4]); + + // Dry-run to obtain the tx summary that the approvers must sign. + 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() + { + 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); + let sig_0 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing_inputs) + .await?; + let sig_1 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &signing_inputs) + .await?; + + 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()? + .execute() + .await?; + + multisig_account.apply_delta(executed_tx.account_delta())?; + + // Policy word layout: [immediate, delayed, note_restrictions, 0] + let stored_policy = multisig_account + .storage() + .get_map_item(AuthMultisigSmart::procedure_policies_slot(), receive_asset_root) + .expect("procedure policies slot should be present"); + assert_eq!(stored_policy, Word::from([1u32, 0, 0, 0])); + + Ok(()) +} From 202cf9f71729f4f96110468d3d40e6596a18fee0 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Tue, 5 May 2026 10:38:37 +0200 Subject: [PATCH 07/10] add tests --- .../standards/auth/multisig_smart/mod.masm | 57 +++---------------- .../tests/auth/multisig_smart.rs | 4 +- 2 files changed, 11 insertions(+), 50 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm index f78b7df659..ad45172396 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -429,7 +429,7 @@ end #! - any stored note_restrictions value is non-zero while both thresholds are zero. #! #! Invocation: exec -@locals(3) +@locals(2) pub proc assert_proc_policies_lte_num_approvers exec.active_account::get_num_procedures # => [num_procedures, num_approvers] @@ -447,75 +447,36 @@ pub proc assert_proc_policies_lte_num_approvers # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, # proc_index, num_approvers] - exec.active_account::get_map_item + exec.active_account::get_initial_map_item # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index, num_approvers] + # Validate threshold ranges only. Policy consistency was enforced when the policy + # was set via `set_procedure_policy` and is preserved across signer updates. loc_store.0 # => [delayed_threshold, note_restrictions, 0, proc_index, num_approvers] loc_store.1 # => [note_restrictions, 0, proc_index, num_approvers] - loc_store.2 - # => [0, proc_index, num_approvers] - - drop + drop drop # => [proc_index, num_approvers] - dup.1 - loc_load.0 - swap + # immediate_threshold <= num_approvers + dup.1 loc_load.0 swap # => [num_approvers, immediate_threshold, proc_index, num_approvers] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS # => [proc_index, num_approvers] - dup.1 - loc_load.1 - swap + # delayed_threshold <= num_approvers + dup.1 loc_load.1 swap # => [num_approvers, delayed_threshold, proc_index, num_approvers] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS # => [proc_index, num_approvers] - loc_load.2 - exec.assert_valid_note_restrictions - # => [proc_index, num_approvers] - - loc_load.0 eq.0 - # => [is_immediate_threshold_zero, proc_index, num_approvers] - - if.true - # => [proc_index, num_approvers] - - loc_load.1 eq.0 - # => [is_delayed_threshold_zero, proc_index, num_approvers] - - if.true - # => [proc_index, num_approvers] - - loc_load.2 assertz.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD - # => [proc_index, num_approvers] - else - # => [proc_index, num_approvers] - end - else - # => [proc_index, num_approvers] - - loc_load.1 - loc_load.0 - # => [immediate_threshold, delayed_threshold, proc_index, num_approvers] - - dup.1 swap - # => [delayed_threshold, immediate_threshold, proc_index, num_approvers] - - u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 - u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE - # => [proc_index, num_approvers] - end - dup neq.0 # => [should_continue, proc_index, num_approvers] end diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 92813ae6ce..3b2894512b 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -259,8 +259,8 @@ async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( Ok(()) } -/// Tests `update_signers_and_threshold` happy path: a 2-of-2 multisig is rotated to a 4-of-3 -/// signer set with new public keys; the new threshold and signers are persisted in storage. +/// Tests `update_signers_and_threshold`: a 2-of-2 multisig is rotated to a 4-of-3 +/// signer set with new public keys. The new threshold and signers are persisted in storage. #[rstest] #[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[case::falcon(AuthScheme::Falcon512Poseidon2)] From 3b07075b0036ee89212b8d7d5d2fba359f7bbb8c Mon Sep 17 00:00:00 2001 From: onurinanc Date: Wed, 6 May 2026 16:43:03 +0200 Subject: [PATCH 08/10] apply review suggestions --- .../asm/standards/auth/multisig.masm | 5 +- .../standards/auth/multisig_smart/mod.masm | 421 ++++++++---------- .../tests/auth/multisig_smart.rs | 7 +- 3 files changed, 201 insertions(+), 232 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index 7743a0ce8e..b133aa607a 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -177,7 +177,10 @@ proc assert_proc_thresholds_lte_num_approvers(num_approvers: u32) push.PROC_THRESHOLD_ROOTS_SLOT[0..2] # => [proc_roots_slot_suffix, proc_roots_slot_prefix, PROC_ROOT, proc_index, num_approvers] - exec.active_account::get_initial_map_item + # Use the *current* policy state, not the initial one; otherwise a `set_proc_threshold` + # call earlier in the same transaction that raised a threshold above the new num_approvers + # would be missed and the multisig could end up with an unreachable threshold. + exec.active_account::get_map_item # => [[proc_threshold, 0, 0, 0], proc_index, num_approvers] movdn.3 drop drop drop 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 ad45172396..736893c0e8 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -26,6 +26,29 @@ const THRESHOLD_CONFIG_SLOT = word("miden::standards::auth::multisig::threshold_ # Map: PROC_ROOT => smart per-procedure policy word. const PROCEDURE_POLICIES_SLOT = word("miden::standards::auth::multisig_smart::procedure_policies") +# EXECUTION MODES +# ================================================================================================= + +# Execution mode value passed to procedure-policy helpers when the transaction runs the immediate +# (direct call) path. +const IMMEDIATE_EXECUTION_MODE = 0 + +# Execution mode value when the transaction runs the delayed (timelocked execute) path. +const DELAYED_EXECUTION_MODE = 1 + +# NOTE RESTRICTIONS +# ================================================================================================= + +# Bit-encoded ProcedurePolicyNoteRestriction enum: +# 0b00 = None, 0b01 = NoInputNotes, 0b10 = NoOutputNotes, 0b11 = NoInputOrOutputNotes. +# The bit-encoding lets us OR per-procedure restrictions together to compute their union. +const NOTE_RESTRICTION_INPUT_NOTES_MASK = 1 + +const NOTE_RESTRICTION_OUTPUT_NOTES_MASK = 2 + +# Highest valid note restriction enum value (NoInputOrOutputNotes = 0b11). +const NOTE_RESTRICTION_MAX = 3 + # ERRORS # ================================================================================================= @@ -33,7 +56,7 @@ const ERR_MALFORMED_MULTISIG_CONFIG = "number of approvers must be equal to or g const ERR_ZERO_IN_MULTISIG_CONFIG = "number of approvers or threshold must not be zero" -const ERR_PROC_POLICY_INVALID_LANE = "called procedures do not support the selected execution lane" +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" @@ -45,24 +68,37 @@ const ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS = "procedure threshold exceeds ne const ERR_INVALID_NOTE_RESTRICTIONS = "procedure policy note restrictions must be between 0 and 3" +const ERR_INSUFFICIENT_SIGNATURES = "insufficient number of signatures" + +# LOCAL ADDRESSES (set_procedure_policy) +# ================================================================================================= + +const IMMEDIATE_THRESHOLD_LOC = 0 +const DELAYED_THRESHOLD_LOC = 1 +const NOTE_RESTRICTIONS_LOC = 2 + #! Gets the procedure policy entry for PROC_ROOT from the account's initial state. #! #! Inputs: [PROC_ROOT] -#! Outputs: [immediate_threshold, delayed_threshold, note_restrictions, 0] +#! Outputs: [immediate_threshold, delayed_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. -#! - note_restrictions is the note restriction enum value in the 0..=3 range. +#! - 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] + + movup.3 drop + # => [immediate_threshold, delayed_threshold, note_restrictions] end -#! Validates that note_restrictions is within the supported 0..=3 range. +#! Validates that note_restrictions is within the supported range. #! #! Inputs: [note_restrictions] #! Outputs: [] @@ -72,23 +108,17 @@ end #! #! Panics if: #! - note_restrictions is not a u32 value. -#! - note_restrictions is greater than 3. +#! - note_restrictions is greater than NOTE_RESTRICTION_MAX. #! #! Invocation: exec proc assert_valid_note_restrictions - dup - # => [note_restrictions, note_restrictions] - u32assert.err=ERR_INVALID_NOTE_RESTRICTIONS # => [note_restrictions] - dup u32lte.3 - # => [is_valid_note_restrictions, note_restrictions] + u32lte.NOTE_RESTRICTION_MAX + # => [is_valid_note_restrictions] assert.err=ERR_INVALID_NOTE_RESTRICTIONS - # => [note_restrictions] - - drop # => [] end @@ -116,11 +146,28 @@ proc get_current_num_approvers # => [num_approvers] end -#! Computes the effective transaction threshold for multisig smart policy. +#! Extracts the `num_approvers` field out of a MULTISIG_CONFIG word. +#! +#! MULTISIG_CONFIG layout: [threshold, num_approvers, 0, 0]. +#! +#! Inputs: [MULTISIG_CONFIG] +#! Outputs: [num_approvers, MULTISIG_CONFIG] +#! +#! Where: +#! - MULTISIG_CONFIG is the multisig configuration word. +#! - num_approvers is the second felt of MULTISIG_CONFIG. +#! +#! Invocation: exec +proc multisig_config_to_num_approvers + dup.1 + # => [num_approvers, MULTISIG_CONFIG] +end + +#! Computes the effective transaction threshold. #! #! Uses policy_threshold when non-zero; otherwise falls back to default_threshold. #! -#! Inputs: [policy_threshold, default_threshold] +#! Inputs: [default_threshold, policy_threshold] #! Outputs: [transaction_threshold] #! #! Where: @@ -134,10 +181,7 @@ end #! Amount-Based Thresholds features land, it will accept an additional `spending_threshold` #! parameter and pick max(policy_threshold, spending_threshold) before falling back to #! default_threshold when both are zero. -proc compute_tx_threshold_smart(policy_threshold: u32, default_threshold: u32) -> u32 - swap - # => [default_threshold, policy_threshold] - +proc compute_tx_threshold(default_threshold: u32, policy_threshold: u32) -> u32 dup.1 eq.0 # => [is_policy_zero, default_threshold, policy_threshold] @@ -145,31 +189,6 @@ proc compute_tx_threshold_smart(policy_threshold: u32, default_threshold: u32) - # => [effective_transaction_threshold] end -#! Returns the greater of two u32 threshold values. -#! -#! Inputs: [lhs, rhs] -#! Outputs: [max(lhs, rhs)] -#! -#! Where: -#! - lhs is the left-hand threshold value. -#! - rhs is the right-hand threshold value. -#! - max(lhs, rhs) is the greater of the two input thresholds. -#! -#! Invocation: exec -proc max_threshold_pair - dup.1 dup.1 - # => [lhs, rhs, lhs, rhs] - - swap - # => [rhs, lhs, lhs, rhs] - - u32gt - # => [is_rhs_gt, lhs, rhs] - - cdrop - # => [max(lhs, rhs)] -end - #! Computes the effective per-procedure policy for all called procedures. #! #! Iterates over all account procedures, and for those that were called in this transaction @@ -179,36 +198,36 @@ end #! output notes, the transaction ends up forbidding both), #! - whether any called procedure requires the delayed execution mode. #! -#! Inputs: [is_execute_path] +#! Inputs: [execution_mode] #! Outputs: [policy_threshold, policy_requires_delay, note_restrictions] #! #! Where: -#! - is_execute_path is 1 when the transaction uses the delayed execution mode. +#! - execution_mode is IMMEDIATE_EXECUTION_MODE (0) or DELAYED_EXECUTION_MODE (1). #! - policy_threshold is the highest threshold required by any called procedure policy. #! - policy_requires_delay is 1 when any called procedure requires the delayed execution mode. #! - note_restrictions is the combined (union) note restriction enum across all called procedures. #! #! Panics if: -#! - any called procedure's policy does not support the active execution lane. +#! - any called procedure's policy does not support the active execution mode. #! #! Example scenarios (account has policies configured for `receive_asset` and #! `move_asset_to_note`, immediate execution mode): #! -#! receive_asset → ProcedurePolicy { immediate=1, delayed=0, restrictions=NoInputNotes (1) } -#! move_asset_to_note → ProcedurePolicy { immediate=3, delayed=0, restrictions=NoOutputNotes (2) } +#! receive_asset → ProcedurePolicy { immediate_threshold=1, delayed_threshold=0, restrictions=NoInputNotes (1) } +#! move_asset_to_note → ProcedurePolicy { immediate_threshold=3, delayed_threshold=0, restrictions=NoOutputNotes (2) } #! #! A. Only `receive_asset` is called in the transaction: -#! - threshold_acc = max(0, 1) = 1 -#! - restrictions_acc = 0 | 1 = 1 (NoInputNotes) +#! - threshold_acc = max(0, 1) = 1 +#! - restrictions_acc = 0 | 1 = 1 (NoInputNotes) #! - Result: 1 signature required; transaction must not consume input notes. #! #! B. Only `move_asset_to_note` is called: -#! - threshold_acc = max(0, 3) = 3 -#! - restrictions_acc = 0 | 2 = 2 (NoOutputNotes) +#! - threshold_acc = max(0, 3) = 3 +#! - restrictions_acc = 0 | 2 = 2 (NoOutputNotes) #! - Result: 3 signatures required; transaction must not create output notes. #! #! C. Both `receive_asset` and `move_asset_to_note` are called in the same transaction: -#! - after receive_asset: threshold=1, restrictions=0|1 = 1 +#! - after receive_asset: threshold=1, restrictions=0|1 = 1 #! - after move_asset_to_note: threshold=max(1,3)=3, restrictions=1|2 = 3 (NoInputOrOutputNotes) #! - Result: 3 signatures required; transaction must not consume input notes *and* must #! not create output notes (both constraints apply simultaneously). @@ -216,19 +235,18 @@ end #! Invocation: exec #! #! Locals: -#! 0: is_execute_path +#! 0: execution_mode @locals(1) -proc compute_called_proc_policy(is_execute_path: u32) +proc compute_called_proc_policy(execution_mode: u32) loc_store.0 # => [] + # Stack layout maintained across the loop is: # [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] # proc_index starts at num_procedures and counts down to 0 in the loop. push.0 push.0 push.0 - # => [0, 0, 0] - exec.active_account::get_num_procedures - # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + # => [proc_index, threshold_acc=0, requires_delay_acc=0, restrictions_acc=0] dup neq.0 # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] @@ -246,9 +264,6 @@ proc compute_called_proc_policy(is_execute_path: u32) # => [PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] exec.get_procedure_policy - # => [immediate, delayed, restr_proc, 0, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] - - movup.3 drop # => [immediate, delayed, restr_proc, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] # Fold per-proc note_restrictions into the accumulator via OR. @@ -257,14 +272,14 @@ proc compute_called_proc_policy(is_execute_path: u32) movup.6 movup.3 u32or movdn.5 # => [immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - dup.1 dup.1 swap + dup dup.2 # => [delayed, immediate, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] loc_load.0 - # => [is_execute_path, delayed, immediate, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + # => [execution_mode, delayed, immediate, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + # delayed mode (execution_mode=1) → keeps delayed; immediate mode (execution_mode=0) → keeps immediate. cdrop - # delayed execution mode (c=1) → keeps delayed; immediate execution mode (c=0) → keeps immediate. # => [selected, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] dup eq.0 @@ -272,20 +287,16 @@ proc compute_called_proc_policy(is_execute_path: u32) if.true # Selected threshold is zero — the procedure has no policy for this execution mode. - # The "other" threshold must also be zero. Otherwise the procedure would require - # an execution mode different from the one currently active, which is a misuse - # error. + # Assert both immediate and delayed are zero; if either was non-zero, the policy + # was meant for a different execution mode and using it here would silently apply + # this proc's note_restrictions without contributing to the threshold. drop # => [immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - # Pick the "other" threshold - loc_load.0 - # => [is_execute_path, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - - cdrop - # => [other, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + eq.0 assert.err=ERR_PROC_POLICY_INVALID_MODE + # => [delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - eq.0 assert.err=ERR_PROC_POLICY_INVALID_LANE + eq.0 assert.err=ERR_PROC_POLICY_INVALID_MODE # => [proc_index, threshold_acc, requires_delay_acc, new_restrictions] else # Selected threshold is non-zero. @@ -294,18 +305,22 @@ proc compute_called_proc_policy(is_execute_path: u32) movdn.2 drop drop # => [selected, proc_index, threshold_acc, requires_delay_acc, new_restrictions] - movup.2 exec.max_threshold_pair + movup.2 u32max # => [new_threshold, proc_index, requires_delay_acc, new_restrictions] swap # => [proc_index, new_threshold, requires_delay_acc, new_restrictions] - # On the delayed execution mode, mark requires_delay_acc = 1 (replace position 2). + # requires_delay_acc = requires_delay_acc OR execution_mode + # (execution_mode is 1 for delayed mode, 0 for immediate mode). loc_load.0 - if.true - movup.2 drop push.1 movdn.2 - # => [proc_index, new_threshold, 1, new_restrictions] - end + # => [execution_mode, proc_index, new_threshold, requires_delay_acc, new_restrictions] + + movup.3 or + # => [new_requires_delay, proc_index, new_threshold, new_restrictions] + + movdn.2 + # => [proc_index, new_threshold, new_requires_delay, new_restrictions] end else dropw @@ -316,97 +331,72 @@ proc compute_called_proc_policy(is_execute_path: u32) # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] end + # proc_index is 0 here; drop it to leave only the accumulators. drop # => [threshold_acc, requires_delay_acc, restrictions_acc] end #! Enforces note_restrictions against the current transaction. #! +#! `note_restrictions` is treated as a bit set (see `NOTE_RESTRICTION_*_MASK`): +#! - bit 0 (mask 1) → forbid input notes +#! - bit 1 (mask 2) → forbid output notes +#! #! Inputs: [note_restrictions] #! Outputs: [] #! -#! Where: -#! - note_restrictions is the policy enum: -#! 0 => none -#! 1 => no input notes -#! 2 => no output notes -#! 3 => no input or output notes -#! #! Invocation: exec pub proc enforce_note_restrictions - dup eq.0 - # => [is_none, note_restrictions] + dup u32and.NOTE_RESTRICTION_INPUT_NOTES_MASK eq.NOTE_RESTRICTION_INPUT_NOTES_MASK + # => [has_input_note_restriction, note_restrictions] if.true - drop - # => [] - else - dup eq.1 - # => [is_no_input_notes, note_restrictions] - - if.true - drop - # => [] - - exec.tx_policy::assert_no_input_notes - # => [] - else - dup eq.2 - # => [is_no_output_notes, note_restrictions] - - if.true - drop - # => [] + exec.tx_policy::assert_no_input_notes + end + # => [note_restrictions] - exec.tx_policy::assert_no_output_notes - # => [] - else - drop - # => [] + u32and.NOTE_RESTRICTION_OUTPUT_NOTES_MASK eq.NOTE_RESTRICTION_OUTPUT_NOTES_MASK + # => [has_output_note_restriction] - exec.tx_policy::assert_no_input_or_output_notes - # => [] - end - end + if.true + exec.tx_policy::assert_no_output_notes end + # => [] end -#! Computes the effective procedure-policy context for the current transaction and enforces any -#! procedure-level note restrictions immediately. -#! -#! Always uses the immediate execution path; procedures whose policies require the delayed -#! path panic via [`compute_called_proc_policy`] because this component has no timelock. +#! Enforces the procedure-policy for the current transaction: +#! - asserts each called procedure supports the active execution mode, +#! - asserts the union of note restrictions against the transaction's input/output notes, +#! - returns the effective threshold required by the called procedure policies. #! -#! Inputs: [TX_SUMMARY_COMMITMENT] -#! Outputs: [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] +#! Always uses IMMEDIATE_EXECUTION_MODE; procedures whose policies require the delayed mode +#! panic via [`compute_called_proc_policy`] because this component has no timelock. #! -#! Where: -#! - TX_SUMMARY_COMMITMENT is the commitment over the transaction summary fields. -#! - policy_threshold is the effective threshold required by called procedure policies. -#! - note_restrictions is the combined note restriction enum value. +#! Inputs: [] +#! Outputs: [policy_threshold] #! #! Invocation: exec #! -#! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature -#! lands, the hardcoded `push.0` will be replaced with `exec.timelock_controller::is_execute_path` -#! so the caller can distinguish immediate vs. delayed execution paths, and the output stack -#! will also include `policy_requires_delay` and `is_execute_path` for downstream enforcement. -proc compute_procedure_policy_context(tx_summary_commitment: word) - push.0 - # => [is_execute_path=0, TX_SUMMARY_COMMITMENT] +#! 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 + push.IMMEDIATE_EXECUTION_MODE + # => [execution_mode] exec.compute_called_proc_policy - # => [policy_threshold, policy_requires_delay, note_restrictions, TX_SUMMARY_COMMITMENT] + # => [policy_threshold, policy_requires_delay, note_restrictions] - # policy_requires_delay is always 0 on the immediate path, so it carries no signal. + # policy_requires_delay is always 0 on IMMEDIATE_EXECUTION_MODE; drop it. swap drop - # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + # => [policy_threshold, note_restrictions] - dup.1 - # => [note_restrictions, policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + swap + # => [note_restrictions, policy_threshold] exec.enforce_note_restrictions - # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + # => [policy_threshold] end # PUBLIC INTERFACE @@ -423,14 +413,9 @@ end #! Panics if: #! - any stored immediate or delayed threshold is not a u32 value. #! - any stored immediate or delayed threshold exceeds num_approvers. -#! - any stored note_restrictions value is outside the supported 0..=3 range. -#! - any stored delayed threshold exceeds the stored immediate threshold when the immediate -#! threshold is non-zero. -#! - any stored note_restrictions value is non-zero while both thresholds are zero. #! #! Invocation: exec -@locals(2) -pub proc assert_proc_policies_lte_num_approvers +proc assert_proc_policies_lte_num_approvers exec.active_account::get_num_procedures # => [num_procedures, num_approvers] @@ -444,37 +429,34 @@ pub proc assert_proc_policies_lte_num_approvers # => [PROC_ROOT, proc_index, num_approvers] push.PROCEDURE_POLICIES_SLOT[0..2] - # => [procedure_policies_slot_suffix, procedure_policies_slot_prefix, PROC_ROOT, - # proc_index, num_approvers] + # => [policy_slot_suffix, policy_slot_prefix, PROC_ROOT, proc_index, num_approvers] - exec.active_account::get_initial_map_item + # Use the *current* policy state, not the initial one. A `set_procedure_policy` earlier + # 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] - # Validate threshold ranges only. Policy consistency was enforced when the policy - # was set via `set_procedure_policy` and is preserved across signer updates. - loc_store.0 - # => [delayed_threshold, note_restrictions, 0, proc_index, num_approvers] - - loc_store.1 - # => [note_restrictions, 0, proc_index, num_approvers] - - drop drop - # => [proc_index, num_approvers] + drop + # => [immediate_threshold, delayed_threshold, note_restrictions, proc_index, num_approvers] # immediate_threshold <= num_approvers - dup.1 loc_load.0 swap - # => [num_approvers, immediate_threshold, proc_index, num_approvers] + dup.4 + # => [num_approvers, immediate_threshold, delayed_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 - # => [proc_index, num_approvers] + # => [delayed_threshold, note_restrictions, proc_index, num_approvers] # delayed_threshold <= num_approvers - dup.1 loc_load.1 swap - # => [num_approvers, delayed_threshold, proc_index, num_approvers] + dup.3 + # => [num_approvers, delayed_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 + # => [note_restrictions, proc_index, num_approvers] + + drop # => [proc_index, num_approvers] dup neq.0 @@ -493,12 +475,12 @@ end #! 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. -#! - note_restrictions is the note restriction enum value in the 0..=3 range. +#! - 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. -#! - note_restrictions is not in the 0..=3 range. +#! - 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. #! - note_restrictions is non-zero while both thresholds are zero. @@ -506,89 +488,73 @@ end #! Invocation: call @locals(3) pub proc set_procedure_policy - loc_store.0 + loc_store.IMMEDIATE_THRESHOLD_LOC # => [delayed_threshold, note_restrictions, PROC_ROOT] - loc_store.1 + loc_store.DELAYED_THRESHOLD_LOC # => [note_restrictions, PROC_ROOT] - loc_store.2 + loc_store.NOTE_RESTRICTIONS_LOC # => [PROC_ROOT] - # Fetch num_approvers ONCE and reuse it for both threshold checks. + # ----- Validate immediate_threshold <= num_approvers (preserving num_approvers for the + # delayed check that follows). ----- exec.get_current_num_approvers # => [num_approvers, PROC_ROOT] - # Validate immediate_threshold <= num_approvers. - dup loc_load.0 - # => [immediate_threshold, num_approvers, num_approvers, PROC_ROOT] - - swap + dup loc_load.IMMEDIATE_THRESHOLD_LOC swap # => [num_approvers, immediate_threshold, num_approvers, PROC_ROOT] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS # => [num_approvers, PROC_ROOT] - # Validate delayed_threshold <= num_approvers. - dup loc_load.1 - # => [delayed_threshold, num_approvers, num_approvers, PROC_ROOT] - - swap - # => [num_approvers, delayed_threshold, num_approvers, PROC_ROOT] + # ----- Validate delayed_threshold <= num_approvers (consumes num_approvers). ----- + loc_load.DELAYED_THRESHOLD_LOC swap + # => [num_approvers, delayed_threshold, PROC_ROOT] u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS - # => [num_approvers, PROC_ROOT] - - drop # => [PROC_ROOT] - loc_load.2 + # ----- Validate note_restrictions is in 0..=NOTE_RESTRICTION_MAX. ----- + loc_load.NOTE_RESTRICTIONS_LOC exec.assert_valid_note_restrictions # => [PROC_ROOT] - loc_load.0 eq.0 + # ----- Validate (immediate, delayed, note_restrictions) shape. ----- + # `if.true` consumes its boolean condition, so the body branches operate on `[PROC_ROOT]` + # without any leading `drop`. + loc_load.IMMEDIATE_THRESHOLD_LOC eq.0 # => [is_immediate_threshold_zero, PROC_ROOT] if.true - drop - # => [PROC_ROOT] - - loc_load.1 eq.0 + # 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] if.true - drop - # => [PROC_ROOT] - - loc_load.2 assertz.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD - # => [PROC_ROOT] - else - drop + # `eq.0 assert` produces the proper error when note_restrictions is non-zero; + # `assertz` would surface a generic "binary value expected" error for values 2 or 3. + loc_load.NOTE_RESTRICTIONS_LOC eq.0 assert.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD # => [PROC_ROOT] end else - drop - # => [PROC_ROOT] - - loc_load.1 - loc_load.0 + # 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] - dup.1 swap u32assert2.err=ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 u32gt assertz.err=ERR_DELAYED_THRESHOLD_EXCEEDS_IMMEDIATE - # => [delayed_threshold, PROC_ROOT] - - drop # => [PROC_ROOT] end + # ----- Write [immediate, delayed, note_restrictions, 0] to PROCEDURE_POLICIES_SLOT[PROC_ROOT]. push.0 - loc_load.2 - loc_load.1 - loc_load.0 + loc_load.NOTE_RESTRICTIONS_LOC + loc_load.DELAYED_THRESHOLD_LOC + loc_load.IMMEDIATE_THRESHOLD_LOC # => [immediate_threshold, delayed_threshold, note_restrictions, 0, PROC_ROOT] swapw @@ -611,7 +577,7 @@ end #! of per-procedure threshold overrides. #! #! Inputs: -#! Operand stack: [MULTISIG_CONFIG_HASH, pad(12)] +#! Operand stack: [MULTISIG_CONFIG_COMMITMENT, pad(12)] #! Outputs: #! Operand stack: [] #! @@ -627,14 +593,15 @@ end #! #! Invocation: call @locals(2) -pub proc update_signers_and_threshold(multisig_config_hash: word) +pub proc update_signers_and_threshold(multisig_config_commitment: word) adv.push_mapval - # => [MULTISIG_CONFIG_HASH, pad(12)] + # => [MULTISIG_CONFIG_COMMITMENT, pad(12)] adv_loadw # => [MULTISIG_CONFIG, pad(12)] - dup.1 loc_store.0 + # store new_num_of_approvers for later + exec.multisig_config_to_num_approvers loc_store.0 # => [MULTISIG_CONFIG, pad(12)] dup dup.2 @@ -663,11 +630,14 @@ pub proc update_signers_and_threshold(multisig_config_hash: word) exec.native_account::set_item # => [OLD_THRESHOLD_CONFIG, pad(12)] + # Save the old num_of_approvers for the post-loop scheme/pubkey cleanup, then drop the rest + # of OLD_THRESHOLD_CONFIG. The kernel auto-pads the consumed elements back, so the stack + # still ends with pad(16). drop loc_store.1 drop drop - # => [pad(12)] + # => [pad(16)] loc_load.0 - # => [num_approvers] + # => [num_approvers, pad(15)] dup neq.0 while.true @@ -760,15 +730,11 @@ pub proc auth_tx(salt: word) exec.auth::hash_tx_summary # => [TX_SUMMARY_COMMITMENT] - # ------ Computing procedure policy ------ - exec.compute_procedure_policy_context - # => [policy_threshold, note_restrictions, TX_SUMMARY_COMMITMENT] + # ------ Enforcing procedure policy ------ + exec.enforce_procedure_policy + # => [policy_threshold, TX_SUMMARY_COMMITMENT] loc_store.0 - # => [note_restrictions, TX_SUMMARY_COMMITMENT] - - # note_restrictions are already enforced inside compute_procedure_policy_context. - drop # => [TX_SUMMARY_COMMITMENT] # ------ Verifying approver signatures ------ @@ -793,7 +759,10 @@ pub proc auth_tx(salt: word) loc_load.0 # => [policy_threshold, default_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] - exec.compute_tx_threshold_smart + swap + # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + + exec.compute_tx_threshold # => [transaction_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] u32assert2 u32lt @@ -801,6 +770,6 @@ pub proc auth_tx(salt: word) if.true emit.AUTH_UNAUTHORIZED_EVENT - push.0 assert.err="insufficient number of signatures" + push.0 assert.err=ERR_INSUFFICIENT_SIGNATURES end end diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index 3b2894512b..cde1ee5073 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -21,7 +21,7 @@ use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig} use miden_standards::account::components::multisig_smart_library; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; -use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES; +use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; @@ -251,10 +251,7 @@ async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( .execute() .await; - assert_transaction_executor_error!( - result, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES - ); + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES); Ok(()) } From 0a9cad6a0c82940a6bb7d05c2cf60e4a79e4299b Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 8 May 2026 08:04:11 +0200 Subject: [PATCH 09/10] apply fixes --- .../src/account/auth/multisig.rs | 18 +- .../account/auth/multisig_smart/component.rs | 120 +++------- crates/miden-testing/tests/auth/multisig.rs | 138 +++++------ .../tests/auth/multisig_smart.rs | 226 +++++++++++------- 4 files changed, 230 insertions(+), 272 deletions(-) diff --git a/crates/miden-standards/src/account/auth/multisig.rs b/crates/miden-standards/src/account/auth/multisig.rs index edcddf5709..0e7742e680 100644 --- a/crates/miden-standards/src/account/auth/multisig.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -26,25 +26,29 @@ use crate::account::components::multisig_library; // CONSTANTS // ================================================================================================ -static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { +// The first four slots/schemas are shared with `AuthMultisigSmart` to keep the storage layout in +// sync; they are exposed as `pub(super)` so the sibling module can reference them directly +// (`AuthMultisigSmart::*_slot()` returns these same statics). +pub(super) static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::threshold_config") .expect("storage slot name should be valid") }); -static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { +pub(super) static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys") .expect("storage slot name should be valid") }); -static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { +pub(super) static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::approver_schemes") .expect("storage slot name should be valid") }); -static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") - .expect("storage slot name should be valid") -}); +pub(super) static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = + LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") + .expect("storage slot name should be valid") + }); static PROCEDURE_THRESHOLDS_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::procedure_thresholds") 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 47483849fb..886f8cdc1d 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -4,7 +4,6 @@ use miden_protocol::Word; use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment}; use miden_protocol::account::component::{ AccountComponentMetadata, - FeltSchema, SchemaType, StorageSchema, StorageSlotSchema, @@ -20,32 +19,25 @@ use miden_protocol::account::{ use miden_protocol::errors::AccountError; use miden_protocol::utils::sync::LazyLock; +// Slots and schemas reused from `AuthMultisig` to keep the storage layout in sync. The statics +// are exposed as `pub(super)` in the sibling `multisig` module; we reference them directly so +// the sharing is visible at the use site rather than hidden behind delegating methods. +use super::super::multisig::{ + APPROVER_PUBKEYS_SLOT_NAME, + APPROVER_SCHEME_ID_SLOT_NAME, + EXECUTED_TRANSACTIONS_SLOT_NAME, + THRESHOLD_CONFIG_SLOT_NAME, +}; use super::ProcedurePolicy; +use crate::account::auth::AuthMultisig; use crate::account::components::multisig_smart_library; // CONSTANTS // ================================================================================================ -static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::threshold_config") - .expect("storage slot name should be valid") -}); - -static APPROVER_PUBKEYS_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::approver_public_keys") - .expect("storage slot name should be valid") -}); - -static APPROVER_SCHEME_ID_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::approver_schemes") - .expect("storage slot name should be valid") -}); - -static EXECUTED_TRANSACTIONS_SLOT_NAME: LazyLock = LazyLock::new(|| { - StorageSlotName::new("miden::standards::auth::multisig::executed_transactions") - .expect("storage slot name should be valid") -}); - +// Only the smart-specific procedure_policies slot needs its own constant here. The other four +// slots (threshold config, approver public keys, approver scheme ids, executed transactions) are +// reused from `AuthMultisig` via the imports above. static PROCEDURE_POLICIES_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig_smart::procedure_policies") .expect("storage slot name should be valid") @@ -176,51 +168,19 @@ impl AuthMultisigSmart { } pub fn threshold_config_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::threshold_config_slot().clone(), - StorageSlotSchema::value( - "Threshold configuration", - [ - FeltSchema::u32("threshold"), - FeltSchema::u32("num_approvers"), - FeltSchema::new_void(), - FeltSchema::new_void(), - ], - ), - ) + AuthMultisig::threshold_config_slot_schema() } pub fn approver_public_keys_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::approver_public_keys_slot().clone(), - StorageSlotSchema::map( - "Approver public keys", - SchemaType::u32(), - SchemaType::pub_key(), - ), - ) + AuthMultisig::approver_public_keys_slot_schema() } pub fn approver_auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::approver_scheme_ids_slot().clone(), - StorageSlotSchema::map( - "Approver scheme IDs", - SchemaType::u32(), - SchemaType::auth_scheme(), - ), - ) + AuthMultisig::approver_auth_scheme_slot_schema() } pub fn executed_transactions_slot_schema() -> (StorageSlotName, StorageSlotSchema) { - ( - Self::executed_transactions_slot().clone(), - StorageSlotSchema::map( - "Executed transactions", - SchemaType::native_word(), - SchemaType::native_word(), - ), - ) + AuthMultisig::executed_transactions_slot_schema() } pub fn procedure_policies_slot_schema() -> (StorageSlotName, StorageSlotSchema) { @@ -310,7 +270,6 @@ mod tests { use miden_protocol::account::auth::AuthSecretKey; use super::*; - use crate::account::auth::multisig_smart::ProcedurePolicyNoteRestriction; use crate::account::wallets::BasicWallet; #[test] @@ -321,12 +280,15 @@ mod tests { (sec_key_1.public_key().to_commitment(), sec_key_1.auth_scheme()), (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), ]; + let num_approvers = approvers.len() as u32; + let default_threshold = 2u32; + let receive_asset_immediate_threshold = 1u32; - let config = AuthMultisigSmartConfig::new(approvers.clone(), 2) + let config = AuthMultisigSmartConfig::new(approvers.clone(), default_threshold) .expect("invalid multisig smart config") .with_proc_policies(vec![( BasicWallet::receive_asset_digest(), - ProcedurePolicy::with_immediate_threshold(1) + ProcedurePolicy::with_immediate_threshold(receive_asset_immediate_threshold) .expect("procedure policy should be valid"), )]) .expect("procedure policy config should be valid"); @@ -344,7 +306,7 @@ mod tests { .storage() .get_item(AuthMultisigSmart::threshold_config_slot()) .expect("threshold config should be present"); - assert_eq!(threshold_config, Word::from([2u32, 2u32, 0, 0])); + assert_eq!(threshold_config, Word::from([default_threshold, num_approvers, 0, 0])); let receive_asset_policy = account .storage() @@ -353,7 +315,10 @@ mod tests { BasicWallet::receive_asset_digest(), ) .expect("receive_asset policy should be present"); - assert_eq!(receive_asset_policy, Word::from([1u32, 0u32, 0u32, 0u32])); + assert_eq!( + receive_asset_policy, + Word::from([receive_asset_immediate_threshold, 0u32, 0u32, 0u32]) + ); } #[test] @@ -364,42 +329,13 @@ mod tests { let result = AuthMultisigSmartConfig::new(approvers.clone(), 0); assert!(result.unwrap_err().to_string().contains("threshold must be at least 1")); - let result = AuthMultisigSmartConfig::new(approvers.clone(), 2); + let result = AuthMultisigSmartConfig::new(approvers, 2); assert!( result .unwrap_err() .to_string() .contains("threshold cannot be greater than number of approvers") ); - - let sec_key_2 = AuthSecretKey::new_ecdsa_k256_keccak(); - let approvers = vec![ - (sec_key.public_key().to_commitment(), sec_key.auth_scheme()), - (sec_key_2.public_key().to_commitment(), sec_key_2.auth_scheme()), - ]; - - let result = AuthMultisigSmartConfig::new(approvers.clone(), 2).and_then(|cfg| { - let policy = ProcedurePolicy::with_immediate_and_delay_thresholds(1, 2)?; - cfg.with_proc_policies(vec![(Word::from([1u32, 2, 3, 4]), policy)]) - }); - assert!( - result - .unwrap_err() - .to_string() - .contains("delay threshold cannot exceed immediate threshold") - ); - - let result = AuthMultisigSmartConfig::new(approvers, 2).and_then(|cfg| { - let policy = ProcedurePolicy::with_immediate_threshold(0)? - .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputNotes); - cfg.with_proc_policies(vec![(Word::from([4u32, 3, 2, 1]), policy)]) - }); - assert!( - result - .unwrap_err() - .to_string() - .contains("procedure policy immediate threshold must be at least 1") - ); } #[test] diff --git a/crates/miden-testing/tests/auth/multisig.rs b/crates/miden-testing/tests/auth/multisig.rs index 904bbe1dcf..e886c15a8c 100644 --- a/crates/miden-testing/tests/auth/multisig.rs +++ b/crates/miden-testing/tests/auth/multisig.rs @@ -40,11 +40,11 @@ use rstest::rstest; // HELPER FUNCTIONS // ================================================================================================ -type MultisigTestSetup = +pub(super) type MultisigTestSetup = (Vec, Vec, Vec, Vec); /// Sets up secret keys, public keys, and authenticators for multisig testing for the given scheme. -fn setup_keys_and_authenticators_with_scheme( +pub(super) fn setup_keys_and_authenticators_with_scheme( num_approvers: usize, threshold: usize, auth_scheme: AuthScheme, @@ -81,6 +81,34 @@ fn setup_keys_and_authenticators_with_scheme( Ok((secret_keys, auth_schemes, public_keys, authenticators)) } +/// Layout expected by `update_signers_and_threshold` when looking up the new multisig config in +/// the advice map: `[threshold, num_approvers, 0, 0, (PUB_KEY, SCHEME_WORD) for each approver]`. +/// Public keys are appended in reverse so the procedure pops them in ascending index order. +pub(super) fn build_update_signers_config_vector( + threshold: u64, + num_of_approvers: u64, + public_keys: &[PublicKey], + auth_scheme: AuthScheme, +) -> Vec { + let mut config_and_pubkeys_vector = Vec::new(); + config_and_pubkeys_vector.extend_from_slice(&[ + Felt::new(threshold), + Felt::new(num_of_approvers), + Felt::ZERO, + Felt::ZERO, + ]); + + let scheme_word = [Felt::from(auth_scheme.as_u8()), Felt::ZERO, Felt::ZERO, Felt::ZERO]; + + for public_key in public_keys.iter().rev() { + let key_word: Word = public_key.to_commitment().into(); + config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); + config_and_pubkeys_vector.extend_from_slice(&scheme_word); + } + + config_and_pubkeys_vector +} + /// Creates a multisig account with the specified configuration fn create_multisig_account( threshold: u32, @@ -438,32 +466,13 @@ async fn test_multisig_update_signers(#[case] auth_scheme: AuthScheme) -> anyhow let threshold = 3u64; let num_of_approvers = 4u64; - // Create vector with threshold config and public keys (4 field elements each) - let mut config_and_pubkeys_vector = Vec::new(); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), - ]); - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Hash the vector to create config hash + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + &new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); - - // Insert config and public keys into advice map advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); // Create a transaction script that calls the update_signers procedure @@ -708,24 +717,12 @@ async fn test_multisig_update_signers_remove_owner( let threshold = 1u64; let num_of_approvers = 2u64; - // Create multisig config vector - let mut config_and_pubkeys_vector = - vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)]; - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Create config hash and advice map + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); let mut advice_map = AdviceMap::default(); advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); @@ -899,20 +896,12 @@ async fn test_multisig_update_signers_rejects_unreachable_proc_thresholds( let threshold = 2u64; let num_of_approvers = 2u64; - let mut config_and_pubkeys_vector = - vec![Felt::new(threshold), Felt::new(num_of_approvers), Felt::new(0), Felt::new(0)]; - - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); let mut advice_map = AdviceMap::default(); advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); @@ -991,32 +980,13 @@ async fn test_multisig_new_approvers_cannot_sign_before_update( let threshold = 3u64; let num_of_approvers = 4u64; - // Create vector with threshold config and public keys (4 field elements each) - let mut config_and_pubkeys_vector = Vec::new(); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), - ]); - - // Add each public key to the vector - for public_key in new_public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(auth_scheme as u64), - Felt::new(0), - Felt::new(0), - Felt::new(0), - ]); - } - - // Hash the vector to create config hash + let config_and_pubkeys_vector = build_update_signers_config_vector( + threshold, + num_of_approvers, + &new_public_keys, + auth_scheme, + ); let multisig_config_hash = Hasher::hash_elements(&config_and_pubkeys_vector); - - // Insert config and public keys into advice map advice_map.insert(multisig_config_hash, config_and_pubkeys_vector); // Create a transaction script that calls the update_signers procedure diff --git a/crates/miden-testing/tests/auth/multisig_smart.rs b/crates/miden-testing/tests/auth/multisig_smart.rs index cde1ee5073..979fded89d 100644 --- a/crates/miden-testing/tests/auth/multisig_smart.rs +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -1,5 +1,5 @@ use miden_processor::advice::AdviceInputs; -use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; +use miden_protocol::account::auth::{AuthScheme, PublicKey}; use miden_protocol::account::{ Account, AccountBuilder, @@ -21,57 +21,24 @@ use miden_standards::account::auth::{AuthMultisigSmart, AuthMultisigSmartConfig} use miden_standards::account::components::multisig_smart_library; use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; -use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES; +use miden_standards::errors::standards::{ + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, +}; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; -use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; -use rand::SeedableRng; -use rand_chacha::ChaCha20Rng; +use miden_tx::auth::{SigningInputs, TransactionAuthenticator}; use rstest::rstest; +use super::multisig::{ + build_update_signers_config_vector, + setup_keys_and_authenticators_with_scheme, +}; + // ================================================================================================ // HELPER FUNCTIONS // ================================================================================================ -type MultisigTestSetup = - (Vec, Vec, Vec, Vec); - -/// Sets up secret keys, auth schemes, public keys, and authenticators for a specific scheme. -fn setup_keys_and_authenticators_with_scheme( - num_approvers: usize, - threshold: usize, - auth_scheme: AuthScheme, -) -> anyhow::Result { - let seed: [u8; 32] = rand::random(); - let mut rng = ChaCha20Rng::from_seed(seed); - - let mut secret_keys = Vec::new(); - let mut auth_schemes = Vec::new(); - let mut public_keys = Vec::new(); - let mut authenticators = Vec::new(); - - for _ in 0..num_approvers { - let sec_key = match auth_scheme { - AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak_with_rng(&mut rng), - AuthScheme::Falcon512Poseidon2 => { - AuthSecretKey::new_falcon512_poseidon2_with_rng(&mut rng) - }, - _ => anyhow::bail!("unsupported auth scheme for this test: {auth_scheme:?}"), - }; - let pub_key = sec_key.public_key(); - - secret_keys.push(sec_key); - auth_schemes.push(auth_scheme); - public_keys.push(pub_key); - } - - for secret_key in secret_keys.iter().take(threshold) { - authenticators.push(BasicAuthenticator::new(core::slice::from_ref(secret_key))); - } - - Ok((secret_keys, auth_schemes, public_keys, authenticators)) -} - /// Builds a multisig smart account with the given approvers, threshold, starting balance, and /// procedure policy map. Uses `BasicWallet` so the account exposes `receive_asset` and friends. fn create_multisig_smart_account( @@ -110,34 +77,6 @@ fn compile_multisig_smart_tx_script(script: impl AsRef) -> anyhow::Result Vec { - let mut config_and_pubkeys_vector = Vec::new(); - config_and_pubkeys_vector.extend_from_slice(&[ - Felt::new(threshold), - Felt::new(num_of_approvers), - Felt::new(0), - Felt::new(0), - ]); - - let scheme_word = [Felt::new(auth_scheme as u64), Felt::new(0), Felt::new(0), Felt::new(0)]; - - for public_key in public_keys.iter().rev() { - let key_word: Word = public_key.to_commitment().into(); - config_and_pubkeys_vector.extend_from_slice(key_word.as_elements()); - config_and_pubkeys_vector.extend_from_slice(&scheme_word); - } - - config_and_pubkeys_vector -} - // ================================================================================================ // TESTS // ================================================================================================ @@ -210,26 +149,30 @@ async fn test_multisig_smart_receive_asset_policy_overrides_default_three_of_thr Ok(()) } -/// A procedure policy with `NoInputOrOutputNotes` restriction must abort any transaction that -/// reaches that procedure while carrying input or output notes. +/// `enforce_note_restrictions` must abort transactions whose note layout violates the configured +/// policy bit set. The receive_asset proc policy carries each restriction variant and the tx +/// consumes a P2ID note (calls receive_asset). The test checks every variant against the +/// "tx has input notes" axis. #[rstest] -#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] -#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] +#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] +#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] +#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] #[tokio::test] -async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( - #[case] auth_scheme: AuthScheme, +async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_input_notes( + #[case] restriction: ProcedurePolicyNoteRestriction, ) -> anyhow::Result<()> { let (_secret_keys, _auth_schemes, public_keys, _authenticators) = - setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + let multisig_account = create_multisig_smart_account( 2, &public_keys, - auth_scheme, + AuthScheme::EcdsaK256Keccak, 100, vec![( BasicWallet::receive_asset_digest(), - ProcedurePolicy::with_immediate_threshold(1)? - .with_note_restriction(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes), + ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), )], )?; @@ -243,15 +186,111 @@ async fn test_multisig_smart_proc_policy_no_notes_constraint_is_enforced( )?; let mock_chain = mock_chain_builder.build()?; - let salt = Word::from([Felt::new(2); 4]); let result = mock_chain .build_tx_context(multisig_account.id(), &[note.id()], &[])? - .auth_args(salt) + .auth_args(Word::from([Felt::new(2); 4])) + .build()? + .execute() + .await; + + // For restrictions that include the input bit (1, 3), enforce_note_restrictions panics with + // the input-notes error before signatures are even checked. For the other variants the input + // bit is unset, so the tx falls through to signature verification and aborts there + // (no signatures were provided). The output bit (2) does not trigger because the tx has no + // output notes. + match restriction { + ProcedurePolicyNoteRestriction::NoInputNotes + | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES + ); + }, + ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoOutputNotes => { + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } + }, + } + + Ok(()) +} + +/// Mirror of the input-notes test for the output-notes axis. The policy lives on +/// `move_asset_to_note` (the BasicWallet proc invoked when sending notes) and the tx creates a +/// P2ID output note rather than consuming one. +#[rstest] +#[case::no_restriction(ProcedurePolicyNoteRestriction::None)] +#[case::no_input_notes(ProcedurePolicyNoteRestriction::NoInputNotes)] +#[case::no_output_notes(ProcedurePolicyNoteRestriction::NoOutputNotes)] +#[case::no_input_or_output_notes(ProcedurePolicyNoteRestriction::NoInputOrOutputNotes)] +#[tokio::test] +async fn test_multisig_smart_enforces_note_restrictions_on_tx_with_output_notes( + #[case] restriction: ProcedurePolicyNoteRestriction, +) -> anyhow::Result<()> { + use miden_processor::crypto::random::RandomCoin; + use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE; + use miden_protocol::transaction::RawOutputNote; + use miden_standards::account::interface::{AccountInterface, AccountInterfaceExt}; + use miden_standards::note::P2idNote; + + let (_secret_keys, _auth_schemes, public_keys, _authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, AuthScheme::EcdsaK256Keccak)?; + + let multisig_account = create_multisig_smart_account( + 2, + &public_keys, + AuthScheme::EcdsaK256Keccak, + 100, + vec![( + BasicWallet::move_asset_to_note_digest(), + ProcedurePolicy::with_immediate_threshold(1)?.with_note_restriction(restriction), + )], + )?; + + let output_note = P2idNote::create( + multisig_account.id(), + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into().unwrap(), + vec![FungibleAsset::mock(5)], + NoteType::Public, + Default::default(), + &mut RandomCoin::new(Word::from([Felt::new(7); 4])), + )?; + + let send_note_script = AccountInterface::from_account(&multisig_account) + .build_send_notes_script(&[output_note.clone().into()], None)?; + + let mock_chain = + MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; + + let result = mock_chain + .build_tx_context(multisig_account.id(), &[], &[])? + .extend_expected_output_notes(vec![RawOutputNote::Full(output_note)]) + .tx_script(send_note_script) + .auth_args(Word::from([Felt::new(2); 4])) .build()? .execute() .await; - assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES); + // For restrictions that include the output bit (2, 3), enforce_note_restrictions panics with + // the output-notes error after the input check passes. For the other variants neither check + // trips and the tx falls through to signature verification (no signatures were provided). + match restriction { + ProcedurePolicyNoteRestriction::NoOutputNotes + | ProcedurePolicyNoteRestriction::NoInputOrOutputNotes => { + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + ); + }, + ProcedurePolicyNoteRestriction::None | ProcedurePolicyNoteRestriction::NoInputNotes => { + match result { + Err(TransactionExecutorError::Unauthorized(_)) => {}, + other => panic!("expected Unauthorized (no signatures provided), got: {other:?}"), + } + }, + } Ok(()) } @@ -384,21 +423,27 @@ async fn test_multisig_smart_set_procedure_policy( MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?; let receive_asset_root = BasicWallet::receive_asset_digest(); + let immediate_threshold = 1u32; + let delayed_threshold = 0u32; + let note_restrictions = ProcedurePolicyNoteRestriction::NoInputNotes; // `call.` does not consume operand-stack inputs (the procedure sees a snapshot, the caller's // stack is preserved across the boundary), so we must manually drop the 7 elements we pushed. let set_policy_script = compile_multisig_smart_tx_script(format!( " begin push.{root} - push.0 # note_restrictions - push.0 # delayed_threshold - push.1 # immediate_threshold + push.{note_restrictions} + push.{delayed_threshold} + push.{immediate_threshold} call.::miden::standards::components::auth::multisig_smart::set_procedure_policy drop drop drop # immediate, delayed, note_restrictions dropw # PROC_ROOT end ", root = receive_asset_root, + note_restrictions = note_restrictions as u8, + delayed_threshold = delayed_threshold, + immediate_threshold = immediate_threshold, ))?; let salt = Word::from([Felt::new(4); 4]); @@ -443,7 +488,10 @@ async fn test_multisig_smart_set_procedure_policy( .storage() .get_map_item(AuthMultisigSmart::procedure_policies_slot(), receive_asset_root) .expect("procedure policies slot should be present"); - assert_eq!(stored_policy, Word::from([1u32, 0, 0, 0])); + assert_eq!( + stored_policy, + Word::from([immediate_threshold, delayed_threshold, note_restrictions as u32, 0]) + ); Ok(()) } From 014d5e3d4c0a8f86e0bae9ca8b2fb70249328664 Mon Sep 17 00:00:00 2001 From: onurinanc Date: Fri, 8 May 2026 16:38:05 +0200 Subject: [PATCH 10/10] apply PR fixes --- .../asm/standards/auth/guardian.masm | 3 +- .../standards/auth/multisig_smart/mod.masm | 17 +- .../asm/standards/auth/tx_policy.masm | 20 --- .../src/account/auth/multisig.rs | 3 - .../tests/auth/guarded_multisig.rs | 169 +++++++++++++++++- 5 files changed, 173 insertions(+), 39 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/guardian.masm b/crates/miden-standards/asm/standards/auth/guardian.masm index 73f2322b73..3f4ba0bc41 100644 --- a/crates/miden-standards/asm/standards/auth/guardian.masm +++ b/crates/miden-standards/asm/standards/auth/guardian.masm @@ -128,7 +128,8 @@ pub proc verify_signature(msg: word) exec.tx_policy::assert_only_one_non_auth_procedure_called # => [MSG] - exec.tx_policy::assert_no_input_or_output_notes + exec.tx_policy::assert_no_input_notes + exec.tx_policy::assert_no_output_notes # => [MSG] dropw 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 736893c0e8..04fca926c3 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -437,7 +437,8 @@ proc assert_proc_policies_lte_num_approvers exec.active_account::get_map_item # => [immediate_threshold, delayed_threshold, note_restrictions, 0, proc_index, num_approvers] - drop + # 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 <= num_approvers @@ -631,13 +632,12 @@ pub proc update_signers_and_threshold(multisig_config_commitment: word) # => [OLD_THRESHOLD_CONFIG, pad(12)] # Save the old num_of_approvers for the post-loop scheme/pubkey cleanup, then drop the rest - # of OLD_THRESHOLD_CONFIG. The kernel auto-pads the consumed elements back, so the stack - # still ends with pad(16). + # of OLD_THRESHOLD_CONFIG. drop loc_store.1 drop drop - # => [pad(16)] + # => [pad(12)] loc_load.0 - # => [num_approvers, pad(15)] + # => [num_approvers, pad(12)] dup neq.0 while.true @@ -753,13 +753,10 @@ pub proc auth_tx(salt: word) exec.::miden::standards::auth::signature::verify_signatures # => [num_verified_signatures, TX_SUMMARY_COMMITMENT, default_threshold] - movup.5 - # => [default_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] - loc_load.0 - # => [policy_threshold, default_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] + # => [policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT, default_threshold] - swap + movup.6 # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] exec.compute_tx_threshold diff --git a/crates/miden-standards/asm/standards/auth/tx_policy.masm b/crates/miden-standards/asm/standards/auth/tx_policy.masm index 8ad6264183..4201e44a86 100644 --- a/crates/miden-standards/asm/standards/auth/tx_policy.masm +++ b/crates/miden-standards/asm/standards/auth/tx_policy.masm @@ -5,7 +5,6 @@ use miden::protocol::tx const ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE = "procedure must be called alone" const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES = "transaction must not include input notes" const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES = "transaction must not include output notes" -const ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES = "transaction must not include input or output notes" #! Asserts that exactly one non-auth account procedure was called in the current transaction. #! @@ -89,22 +88,3 @@ pub proc assert_no_output_notes # => [] end -#! Asserts that the current transaction does not consume input notes or create output notes. -#! -#! Inputs: [] -#! Outputs: [] -#! -#! Invocation: exec -pub proc assert_no_input_or_output_notes - exec.tx::get_num_input_notes - # => [num_input_notes] - - assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES - # => [] - - exec.tx::get_num_output_notes - # => [num_output_notes] - - assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES - # => [] -end diff --git a/crates/miden-standards/src/account/auth/multisig.rs b/crates/miden-standards/src/account/auth/multisig.rs index 0e7742e680..a952628e22 100644 --- a/crates/miden-standards/src/account/auth/multisig.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -26,9 +26,6 @@ use crate::account::components::multisig_library; // CONSTANTS // ================================================================================================ -// The first four slots/schemas are shared with `AuthMultisigSmart` to keep the storage layout in -// sync; they are exposed as `pub(super)` so the sibling module can reference them directly -// (`AuthMultisigSmart::*_slot()` returns these same statics). pub(super) static THRESHOLD_CONFIG_SLOT_NAME: LazyLock = LazyLock::new(|| { StorageSlotName::new("miden::standards::auth::multisig::threshold_config") .expect("storage slot name should be valid") diff --git a/crates/miden-testing/tests/auth/guarded_multisig.rs b/crates/miden-testing/tests/auth/guarded_multisig.rs index 6bb37f9e3d..84808afa62 100644 --- a/crates/miden-testing/tests/auth/guarded_multisig.rs +++ b/crates/miden-testing/tests/auth/guarded_multisig.rs @@ -25,7 +25,8 @@ use miden_standards::account::wallets::BasicWallet; use miden_standards::code_builder::CodeBuilder; use miden_standards::errors::standards::{ ERR_AUTH_PROCEDURE_MUST_BE_CALLED_ALONE, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, }; use miden_testing::{MockChainBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; @@ -544,10 +545,168 @@ async fn test_guarded_multisig_update_guardian_public_key_must_be_called_alone( .execute() .await; - assert_transaction_executor_error!( - result, - ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_OR_OUTPUT_NOTES - ); + // The transaction creates an output note (no input notes), so after the input check passes + // the output check fires. + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES); + + Ok(()) +} + +/// `update_guardian_public_key` rejects every transaction that consumes input notes or creates +/// output notes. Parametrized over the (input, output) tx layout so each path through the two +/// separate `assert_no_*_notes` calls in `guardian.masm` is exercised — and the input check +/// firing before the output check is verified explicitly via the (true, true) case. +#[rstest] +#[case::no_notes(false, false)] +#[case::input_only(true, false)] +#[case::output_only(false, true)] +#[case::both(true, true)] +#[tokio::test] +async fn test_guarded_multisig_update_guardian_enforces_no_notes( + #[case] include_input_note: bool, + #[case] include_output_note: bool, +) -> anyhow::Result<()> { + let auth_scheme = AuthScheme::EcdsaK256Keccak; + let (_secret_keys, auth_schemes, public_keys, authenticators) = + setup_keys_and_authenticators_with_scheme(2, 2, auth_scheme)?; + let approvers = public_keys + .iter() + .zip(auth_schemes.iter()) + .map(|(pk, scheme)| (pk.clone(), *scheme)) + .collect::>(); + + let old_guardian_secret_key = AuthSecretKey::new_ecdsa_k256_keccak(); + let old_guardian_public_key = old_guardian_secret_key.public_key(); + let old_guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&old_guardian_secret_key)); + + let new_guardian_secret_key = AuthSecretKey::new_falcon512_poseidon2(); + let new_guardian_public_key = new_guardian_secret_key.public_key(); + let new_guardian_auth_scheme = new_guardian_secret_key.auth_scheme(); + + let multisig_account = create_guarded_multisig_account( + 2, + &approvers, + GuardianConfig::new(old_guardian_public_key.to_commitment(), AuthScheme::EcdsaK256Keccak), + 10, + vec![], + )?; + + let new_guardian_key_word: Word = new_guardian_public_key.to_commitment().into(); + let new_guardian_scheme_id = new_guardian_auth_scheme as u32; + + // Optional output note (no-op script — doesn't trigger any account procedure). + let output_note = if include_output_note { + let serial = Word::from([Felt::new(1), Felt::new(2), Felt::new(3), Felt::new(4)]); + let recipient = NoteRecipient::new( + serial, + CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?, + NoteStorage::default(), + ); + Some(Note::new( + NoteAssets::new(vec![])?, + NoteMetadata::new(multisig_account.id(), NoteType::Public), + recipient, + )) + } else { + None + }; + + // Compile the tx-script: bare update_guardian, or one that also creates the output note. + let update_guardian_script = if let Some(ref out) = output_note { + CodeBuilder::new() + .with_dynamically_linked_library(guarded_multisig_library())? + .compile_tx_script(format!( + "use miden::protocol::output_note\nbegin\n push.{recipient}\n push.{note_type}\n push.{tag}\n exec.output_note::create\n swapdw\n dropw\n dropw\n push.{new_guardian_key_word}\n push.{new_guardian_scheme_id}\n call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key\n drop\n dropw\nend", + recipient = out.recipient().digest(), + note_type = NoteType::Public as u8, + tag = Felt::from(out.metadata().tag()), + ))? + } else { + CodeBuilder::new() + .with_dynamically_linked_library(guarded_multisig_library())? + .compile_tx_script(format!( + "begin\n push.{new_guardian_key_word}\n push.{new_guardian_scheme_id}\n call.::miden::standards::components::auth::guarded_multisig::update_guardian_public_key\n drop\n dropw\nend" + ))? + }; + + // Optional no-op input note seeded into the chain so the multisig account can consume it + // without invoking any non-auth procedure (DEFAULT_NOTE_SCRIPT is a single `nop`). + let mut chain_builder = MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap(); + let input_note = if include_input_note { + let serial = Word::from([Felt::new(5), Felt::new(6), Felt::new(7), Felt::new(8)]); + let recipient = NoteRecipient::new( + serial, + CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?, + NoteStorage::default(), + ); + let note = Note::new( + NoteAssets::new(vec![])?, + NoteMetadata::new(multisig_account.id(), NoteType::Public), + recipient, + ); + chain_builder.add_output_note(RawOutputNote::Full(note.clone())); + Some(note) + } else { + None + }; + let mock_chain = chain_builder.build()?; + + let input_ids: Vec<_> = input_note.as_ref().map(|n| vec![n.id()]).unwrap_or_default(); + let salt = Word::from([Felt::new(995); 4]); + + // Dry-run to obtain the tx summary the signers must sign. + let mut init_ctx = mock_chain + .build_tx_context(multisig_account.id(), &input_ids, &[])? + .tx_script(update_guardian_script.clone()) + .auth_args(salt); + if let Some(ref out) = output_note { + init_ctx = init_ctx.extend_expected_output_notes(vec![RawOutputNote::Full(out.clone())]); + } + let tx_summary = match init_ctx.build()?.execute().await.unwrap_err() { + TransactionExecutorError::Unauthorized(tx_effects) => tx_effects, + error => anyhow::bail!("expected dry-run abort with tx effects: {error}"), + }; + + let msg = tx_summary.as_ref().to_commitment(); + let signing = SigningInputs::TransactionSummary(tx_summary); + let sig_1 = authenticators[0] + .get_signature(public_keys[0].to_commitment(), &signing) + .await?; + let sig_2 = authenticators[1] + .get_signature(public_keys[1].to_commitment(), &signing) + .await?; + let guardian_sig = old_guardian_authenticator + .get_signature(old_guardian_public_key.to_commitment(), &signing) + .await?; + + let mut signed_ctx = mock_chain + .build_tx_context(multisig_account.id(), &input_ids, &[])? + .tx_script(update_guardian_script) + .auth_args(salt) + .add_signature(public_keys[0].to_commitment(), msg, sig_1) + .add_signature(public_keys[1].to_commitment(), msg, sig_2) + .add_signature(old_guardian_public_key.to_commitment(), msg, guardian_sig); + if let Some(ref out) = output_note { + signed_ctx = + signed_ctx.extend_expected_output_notes(vec![RawOutputNote::Full(out.clone())]); + } + let result = signed_ctx.build()?.execute().await; + + // Input check fires first, output check fires only when no input notes are present. + match (include_input_note, include_output_note) { + (false, false) => { + result.expect("tx must succeed when neither input nor output notes are present"); + }, + (true, _) => assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES + ), + (false, true) => assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + ), + } Ok(()) }