Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
- Fixed the fungible and non-fungible MINT note scripts assuming their `exec` callers provide blank stack slot ([#3668](https://github.com/0xMiden/protocol/pull/3668)).
- [BREAKING] Bounded the multisig approver set to 64 signers, enforced both by `ApproverSet::MAX_APPROVERS` at account creation and by `MAX_NUM_APPROVERS` in the `multisig` and `multisig_smart` `update_signers_and_threshold` procedures ([#3723](https://github.com/0xMiden/protocol/pull/3723)).
- The PSWAP note script now rejects a `PswapAttachment` that does not consist of exactly one word, instead of letting the attachment write past the four locals of `get_current_depth` ([#3761](https://github.com/0xMiden/protocol/pull/3761)).
- [BREAKING] `multisig_smart` now rejects delay-only procedure policies ([#3781](https://github.com/0xMiden/protocol/pull/3781)).
- Role symbol validation now computes its upper bound with `exp.u4`, whose unique exponent decomposition stops a prover from widening the bound and slipping a non-canonical symbol through ([#3774](https://github.com/0xMiden/protocol/pull/3774)).

## v0.16.0 (2026-08-17)
Expand Down
28 changes: 17 additions & 11 deletions crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ const ERR_PROC_POLICY_INVALID_MODE = "called procedures do not support the selec

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_DELAY_ONLY_POLICY_UNSUPPORTED = "delayed threshold requires an immediate threshold"

const ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD = "procedure policy note restrictions require an immediate threshold"

const ERR_NUM_APPROVERS_OR_PROC_THRESHOLD_NOT_U32 = "number of approvers and procedure threshold must be u32"

Expand Down Expand Up @@ -89,16 +91,22 @@ const INIT_NUM_OF_APPROVERS_LOC = 1
#!
#! 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.
#! - delayed_threshold is the threshold for delayed execution, or 0 when disabled. It requires a
#! non-zero immediate_threshold: delayed execution is not implemented yet, so a delay-only
#! policy would make the procedure permanently uncallable.
#! - 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.
#!
#! Passing a zero immediate_threshold together with a zero delayed_threshold and no note
#! restrictions clears the procedure's policy.
#!
#! Panics if:
#! - immediate_threshold or delayed_threshold is not a u32 value.
#! - note_restrictions is outside the supported range.
#! - either threshold exceeds the current number of approvers.
#! - delayed_threshold exceeds immediate_threshold when immediate_threshold is non-zero.
#! - note_restrictions is non-zero while both thresholds are zero.
#! - delayed_threshold is non-zero while immediate_threshold is zero.
#! - note_restrictions is non-zero while immediate_threshold is zero.
Comment on lines +108 to +109

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#! - delayed_threshold is non-zero while immediate_threshold is zero.
#! - note_restrictions is non-zero while immediate_threshold is zero.
#! - delayed_threshold or note_restrictions is non-zero while immediate_threshold is zero.

nit: We could merge these to reduce redundancy

#! - PROC_ROOT is not one of the account's procedures.
#!
#! Invocation: call
Expand Down Expand Up @@ -146,15 +154,13 @@ pub proc set_procedure_policy
# => [is_immediate_threshold_zero, PROC_ROOT]

if.true
# immediate is zero. If delayed is also zero, note_restrictions must be zero, otherwise
# the policy would forbid notes for a procedure that has no threshold to authorize them.
loc_load.DELAYED_THRESHOLD_LOC eq.0
# => [is_delayed_threshold_zero, PROC_ROOT]
# immediate is zero, so the entry can only clear the policy.
loc_load.DELAYED_THRESHOLD_LOC eq.0 assert.err=ERR_DELAY_ONLY_POLICY_UNSUPPORTED
# => [PROC_ROOT]

if.true
loc_load.NOTE_RESTRICTIONS_LOC eq.0 assert.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD
# => [PROC_ROOT]
end
# both thresholds are zero, so note_restrictions must be zero as well.
loc_load.NOTE_RESTRICTIONS_LOC eq.0 assert.err=ERR_NOTE_RESTRICTIONS_REQUIRE_THRESHOLD
# => [PROC_ROOT]
else
# immediate is non-zero. Validate delayed_threshold <= immediate_threshold.
loc_load.DELAYED_THRESHOLD_LOC loc_load.IMMEDIATE_THRESHOLD_LOC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use miden_protocol::account::component::{
};
use miden_protocol::account::{
AccountComponent,
AccountProcedureRoot,
StorageMap,
StorageMapKey,
StorageSlot,
Expand All @@ -30,9 +31,26 @@ use super::super::multisig::{
use super::ProcedurePolicy;
use crate::account::account_component_code;
use crate::account::auth::{Approver, ApproverSet, AuthMultisig};
use crate::procedure_root;

account_component_code!(MULTISIG_SMART_CODE, "miden-standards-auth-multisig-smart.masp");

// PROCEDURE ROOTS
// ================================================================================================

/// MASL library namespace used for procedure-root lookups. Distinct from
/// [`AuthMultisigSmart::NAME`], which mirrors the standards-side MASM module path.
const MULTISIG_SMART_LIBRARY_PATH: &str = "miden::standards::components::auth::multisig_smart";

// Initialize the procedure root of the `set_procedure_policy` procedure only once. It is the only
// procedure that writes the policy map, so callers configuring policies commonly need its root.
procedure_root!(
MULTISIG_SMART_SET_PROCEDURE_POLICY,
MULTISIG_SMART_LIBRARY_PATH,
AuthMultisigSmart::SET_PROCEDURE_POLICY_PROC_NAME,
AuthMultisigSmart::code()
);

// CONSTANTS
// ================================================================================================

Expand Down Expand Up @@ -106,9 +124,7 @@ fn validate_proc_policies(
}

for (_, policy) in proc_policies {
if let Some(immediate_threshold) = policy.immediate_threshold()
&& immediate_threshold > num_approvers
{
if policy.immediate_threshold() > num_approvers {
return Err(AccountError::other(
"procedure policy immediate threshold cannot exceed number of approvers",
));
Expand Down Expand Up @@ -140,11 +156,19 @@ impl AuthMultisigSmart {
/// The name of the component.
pub const NAME: &'static str = "miden::standards::auth::multisig_smart";

/// The name of the procedure that edits per-procedure policies.
const SET_PROCEDURE_POLICY_PROC_NAME: &'static str = "set_procedure_policy";

/// Returns the [`AccountComponentCode`] of this component.
pub fn code() -> &'static AccountComponentCode {
&MULTISIG_SMART_CODE
}

/// Returns the procedure root of the `set_procedure_policy` account procedure.
pub fn set_procedure_policy_root() -> AccountProcedureRoot {
*MULTISIG_SMART_SET_PROCEDURE_POLICY
}

/// Creates a new [`AuthMultisigSmart`] component from the provided configuration.
pub fn new(config: AuthMultisigSmartConfig) -> Result<Self, AccountError> {
validate_proc_policies(config.approvers().len() as u32, config.procedure_policies())?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ 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.
/// A procedure can require the immediate threshold only, or support both the immediate and the
/// delayed threshold. There is deliberately no delay-only mode: delayed execution is not
/// implemented yet and policy enforcement always runs in immediate mode, so a delay-only policy
/// would make its procedure permanently uncallable. A delay-only mode can be added once the
/// delayed execution path exists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcedurePolicyExecutionMode {
ImmediateOnly {
immediate_threshold: u32,
},
DelayOnly {
delay_threshold: u32,
},
ImmediateOrDelay {
immediate_threshold: u32,
delay_threshold: u32,
Expand All @@ -35,8 +36,8 @@ pub enum ProcedurePolicyNoteRestriction {
///
/// 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
/// - an execution mode, which determines whether the procedure can be executed immediately only, or
/// immediately and after a delay
/// - note restrictions, which limit whether a transaction invoking the procedure may consume input
/// notes or create output notes
///
Expand All @@ -49,7 +50,8 @@ pub enum ProcedurePolicyNoteRestriction {
/// - 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 thresholds for immediate and delayed execution may differ. Every policy must define an
/// immediate threshold, see [`ProcedurePolicyExecutionMode`].
///
/// The policy is encoded into the procedure-policy storage word as:
/// `[immediate_threshold, delayed_threshold, note_restrictions, 0]`.
Expand Down Expand Up @@ -79,13 +81,6 @@ impl ProcedurePolicy {
)
}

pub fn with_delay_threshold(delay_threshold: u32) -> Result<Self, AccountError> {
Self::new(
ProcedurePolicyExecutionMode::DelayOnly { delay_threshold },
ProcedurePolicyNoteRestriction::None,
)
}

pub fn with_immediate_and_delay_thresholds(
immediate_threshold: u32,
delay_threshold: u32,
Expand All @@ -112,22 +107,20 @@ impl ProcedurePolicy {
self.note_restrictions
}

pub const fn immediate_threshold(&self) -> Option<u32> {
pub const fn immediate_threshold(&self) -> u32 {
match self.execution_mode {
ProcedurePolicyExecutionMode::ImmediateOnly { immediate_threshold } => {
Some(immediate_threshold)
immediate_threshold
},
ProcedurePolicyExecutionMode::DelayOnly { .. } => None,
ProcedurePolicyExecutionMode::ImmediateOrDelay { immediate_threshold, .. } => {
Some(immediate_threshold)
immediate_threshold
},
}
}

pub const fn delay_threshold(&self) -> Option<u32> {
match self.execution_mode {
ProcedurePolicyExecutionMode::ImmediateOnly { .. } => None,
ProcedurePolicyExecutionMode::DelayOnly { delay_threshold } => Some(delay_threshold),
ProcedurePolicyExecutionMode::ImmediateOrDelay { delay_threshold, .. } => {
Some(delay_threshold)
},
Expand All @@ -145,13 +138,6 @@ impl ProcedurePolicy {
));
}
},
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,
Expand All @@ -177,7 +163,7 @@ impl ProcedurePolicy {
}

pub fn to_word(self) -> Word {
let immediate_threshold = self.immediate_threshold().unwrap_or(0);
let immediate_threshold = self.immediate_threshold();
let delay_threshold = self.delay_threshold().unwrap_or(0);

Word::from([immediate_threshold, delay_threshold, self.note_restrictions as u32, 0])
Expand Down Expand Up @@ -221,14 +207,27 @@ mod tests {
.to_string()
.contains("delay threshold cannot exceed immediate threshold")
);

// A delay-only policy would be enforced in immediate mode and brick its procedure.
assert!(
ProcedurePolicy::with_immediate_and_delay_thresholds(0, 2)
.unwrap_err()
.to_string()
.contains("immediate and delayed thresholds must both be at least 1")
);
}

#[test]
fn procedure_policy_thresholds_are_exposed_with_getters() {
let procedure_policy = ProcedurePolicy::with_delay_threshold(2).unwrap();
let procedure_policy = ProcedurePolicy::with_immediate_and_delay_thresholds(3, 2).unwrap();

assert_eq!(procedure_policy.immediate_threshold(), None);
assert_eq!(procedure_policy.immediate_threshold(), 3);
assert_eq!(procedure_policy.delay_threshold(), Some(2));

let immediate_only_policy = ProcedurePolicy::with_immediate_threshold(3).unwrap();

assert_eq!(immediate_only_policy.immediate_threshold(), 3);
assert_eq!(immediate_only_policy.delay_threshold(), None);
}

#[test]
Expand Down
46 changes: 46 additions & 0 deletions crates/miden-testing/tests/auth/multisig_smart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use miden_standards::code_builder::CodeBuilder;
use miden_standards::errors::standards::{
ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_INPUT_NOTES,
ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES,
ERR_DELAY_ONLY_POLICY_UNSUPPORTED,
ERR_DUPLICATE_APPROVER_PUBLIC_KEY,
ERR_MULTISIG_APPROVAL_EXPIRED,
ERR_PROC_ROOT_NOT_IN_ACCOUNT,
Expand Down Expand Up @@ -646,6 +647,51 @@ async fn test_multisig_smart_set_procedure_policy_rejects_foreign_root() -> anyh
Ok(())
}

/// `set_procedure_policy` must reject a delay-only policy.
#[tokio::test]
async fn test_multisig_smart_set_procedure_policy_rejects_delay_only_policy() -> 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 multisig_account = create_multisig_smart_account(2, &public_keys, 100, vec![])?;
let mock_chain =
MockChainBuilder::with_accounts([multisig_account.clone()]).unwrap().build()?;

let set_policy_root = AuthMultisigSmart::set_procedure_policy_root().as_word();

let set_policy_script = compile_multisig_smart_tx_script(format!(
"
@transaction_script
pub proc main
push.{root}
push.0 # note_restrictions
push.1 # delayed_threshold
push.0 # immediate_threshold
call.::miden::standards::components::auth::multisig_smart::set_procedure_policy
end
",
root = set_policy_root,
))?;

let salt = Word::from([Felt::new_unchecked(8); 4]);
let result = mock_chain
.build_transaction(multisig_account.id())
.tx_script(set_policy_script)
.multisig_auth_args(MultisigAuthArgs::new(
mock_chain.latest_block_header().block_num(),
salt,
))
.build()?
.execute()
.await;

assert_transaction_executor_error!(result, ERR_DELAY_ONLY_POLICY_UNSUPPORTED);

Ok(())
}

/// Regression test for the per-procedure contribution semantic of `compute_called_proc_policy`:
/// a transaction that mixes a low-policy procedure (receive_asset = 1) with an unpolicied
/// procedure (set_procedure_policy) must require `max(policy, default) = default` signatures,
Expand Down
Loading