diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a772a595..ff2fa81dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,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 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..45ea4f3b18 --- /dev/null +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -0,0 +1,846 @@ +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 +#! +#! 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] + + 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 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 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 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 +@locals(1) +proc compute_called_proc_policy(is_execute_path: u32) + loc_store.0 + # => [] + + # [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] + + dup neq.0 + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + while.true + sub.1 + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + dup exec.active_account::get_procedure_root + # => [PROC_ROOT, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + + 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, 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. + # 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] + + dup eq.0 + # => [is_selected_zero, selected, immediate, delayed, proc_index, threshold_acc, requires_delay_acc, new_restrictions] + + 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. + 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_LANE + # => [proc_index, threshold_acc, requires_delay_acc, new_restrictions] + else + # 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 + movup.2 drop push.1 movdn.2 + # => [proc_index, new_threshold, 1, new_restrictions] + end + end + else + dropw + # => [proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + end + + dup neq.0 + # => [should_continue, proc_index, threshold_acc, requires_delay_acc, restrictions_acc] + end + + drop + # => [threshold_acc, requires_delay_acc, restrictions_acc] +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 +#! +#! 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] + + 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 +#! +#! 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 + # => [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/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_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs new file mode 100644 index 0000000000..47483849fb --- /dev/null +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -0,0 +1,424 @@ +use alloc::vec::Vec; + +use miden_protocol::Word; +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::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/procedure_policies.rs b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs similarity index 100% rename from crates/miden-standards/src/account/auth/multisig/procedure_policies.rs rename to crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index dbf374c771..0a56a39244 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 = @@ -181,6 +190,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() @@ -199,6 +213,7 @@ pub enum StandardAccountComponent { AuthSingleSig, AuthSingleSigAcl, AuthMultisig, + AuthMultisigSmart, AuthGuardedMultisig, AuthNoAuth, } @@ -213,7 +228,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(), }; @@ -273,6 +290,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) }, @@ -292,6 +312,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 f810f80e24..94fc7ee536 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 @@ -37,6 +43,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. @@ -70,6 +79,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) => { @@ -92,6 +102,7 @@ impl AccountComponentInterface { AccountComponentInterface::AuthSingleSig | AccountComponentInterface::AuthSingleSigAcl | AccountComponentInterface::AuthMultisig + | AccountComponentInterface::AuthMultisigSmart | AccountComponentInterface::AuthGuardedMultisig | AccountComponentInterface::AuthNoAuth ) @@ -126,6 +137,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 52c3f51cae..ae77f7d9f2 100644 --- a/crates/miden-standards/src/account/interface/extension.rs +++ b/crates/miden-standards/src/account/interface/extension.rs @@ -16,6 +16,7 @@ use crate::account::components::{ fungible_token_metadata_library, guarded_multisig_library, multisig_library, + multisig_smart_library, network_fungible_faucet_library, no_auth_library, singlesig_acl_library, @@ -123,6 +124,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..16dafb1aa9 --- /dev/null +++ b/crates/miden-testing/tests/auth/multisig_smart.rs @@ -0,0 +1,218 @@ +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, Word}; +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(()) +}