Skip to content

refactor(standards): Multisig Auth Component cycle-cost optimizations, comment fixes, and PROC_ROOT validation#3211

Merged
PhilippGackstatter merged 21 commits into
nextfrom
refactor-multisig
Jul 8, 2026
Merged

refactor(standards): Multisig Auth Component cycle-cost optimizations, comment fixes, and PROC_ROOT validation#3211
PhilippGackstatter merged 21 commits into
nextfrom
refactor-multisig

Conversation

@onurinanc

Copy link
Copy Markdown
Collaborator

This PR cleans up and improves the multisig auth component:

  • Removes the always-true initial dup neq.0 loop guards in assert_proc_thresholds_lte_num_approvers, compute_transaction_threshold, and update_signers_and_threshold, entering each loop unconditionally with push.1 since the counters are guaranteed non-zero.
  • Isolates scheme_id in get_signer_at with a single movdn.7, dropping the redundant trailing movdn.4.
  • Reads num_approvers in update_signers_and_threshold from the intact MULTISIG_CONFIG on the stack via dup.1 instead of reloading it from local memory.
  • Fixes the assert_new_tx comments to reflect that IS_EXECUTED_FLAG is [1, 0, 0, 0] (flag at position 0), matching the layout movdn.3 drop drop drop relies on for replay protection.
  • Corrects the update_signers_and_threshold advice-map doc comment to show the interleaved per-signer layout (PUB_KEY_i, SCHEME_ID_i) that the consuming loop actually reads, instead of a grouped layout.
  • Expands the get_signer_at slot-push comments to show both felts (..._slot_suffix, ..._slot_prefix) placed by push.SLOT[0..2], matching the convention used elsewhere in the file.
  • Documents on both the standard and smart update_signers_and_threshold procedures and in the AuthMultisig component docs that growing the signer set does not re-scale existing per-procedure overrides (they stay absolute counts), advising operators to re-evaluate and raise them via set_procedure_threshold in the same transaction.
  • Adds an active_account::has_procedure check in set_procedure_threshold (new ERR_PROC_ROOT_NOT_IN_ACCOUNT error) so a threshold override cannot be silently stored under a PROC_ROOT the account does not have.

@onurinanc onurinanc requested review from PhilippGackstatter, bobbinth and mmagician and removed request for mmagician July 3, 2026 15:09

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, left a few nits.

Comment on lines +637 to +643
#! Note: like [`multisig::update_signers_and_threshold`], this procedure does NOT re-scale existing
#! smart procedure policies. Their immediate/delayed thresholds are absolute signature counts, not
#! ratios, and `assert_proc_policies_lte_num_approvers` only enforces the reachability bound
#! (`threshold <= num_approvers`). Growing the signer set silently lowers the effective signing ratio
#! of every policy; operators must re-evaluate and, where appropriate, raise the affected policies in
#! the same transaction that grows the signer set.
#!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: same comment as here: #3208 (comment)

Comment on lines +232 to +239
#! Note: per-procedure threshold overrides in `PROC_THRESHOLD_ROOTS_SLOT` are NOT re-scaled by this
#! procedure. They are absolute signature counts, not ratios, and the only cross-check is
#! `assert_proc_thresholds_lte_num_approvers`, which enforces `override <= num_approvers`. Growing
#! the signer set therefore leaves every override unchanged and silently lowers its effective signing
#! ratio (e.g. a 2-of-2 override becomes 2-of-N). To preserve the intended security level, operators
#! must re-evaluate and, where appropriate, raise the affected overrides via `set_procedure_threshold`
#! in the same transaction that grows the signer set; this procedure neither performs nor prompts it.
#!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: same comment as above


const ERR_PROC_THRESHOLD_EXCEEDS_NUM_APPROVERS = "procedure threshold exceeds new number of approvers"

const ERR_PROC_ROOT_NOT_IN_ACCOUNT = "procedure root is not one of the account's procedures"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const ERR_PROC_ROOT_NOT_IN_ACCOUNT = "procedure root is not one of the account's procedures"
const ERR_PROC_ROOT_NOT_IN_ACCOUNT = "cannot set procedure threshold for procedure root that is not one of the account's procedures"

nit: I'd provide a bit more context

Comment on lines +622 to +626
# => [SCHEME_ID_WORD, PUB_KEY]

movdn.3 drop drop drop
# => [scheme_id, PUB_KEY]
# move scheme_id below the whole PUB_KEY word in one step, then drop the zero padding
movdn.7 drop drop drop
# => [PUB_KEY, scheme_id, pad(15)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this would be clearer if we wrote the initial stack state as:

exec.active_account::get_initial_map_item
# => [[scheme_id, 0, 0, 0], PUB_KEY]

# => [is_signer]
# => [is_signer, pad(16)]

# restore the call ABI stack depth of 16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the usual phrase we have for this is "truncate the stack" so I'd stick to that here and in the rest of the PR

Comment on lines +1656 to +1670
/// The index `0` is supplied via `tx_script_args`. The script asserts the returned scheme id is
/// `1` (`EcdsaK256Keccak`), exercising the getter's five-felt output through the 16-felt `call`
/// ABI.
#[tokio::test]
async fn test_get_signer_at_call_abi() -> anyhow::Result<()> {
let script_code = "
begin
call.::miden::standards::components::auth::multisig::get_signer_at
# => [PUB_KEY, scheme_id, pad(11)]
movup.4 push.1 eq assert
# => [PUB_KEY, pad(11)]
dropw
# => [pad(12)]
end
";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Instead of documenting the scheme ID in the docs I'd remove those low level details and instead document them by using the type:

let expected_scheme_id = AuthScheme::EcdsaK256Keccak.as_u8();
let script_code = format!(
    r#"
    begin
        call.::miden::standards::components::auth::multisig::get_signer_at
        # => [PUB_KEY, scheme_id, pad(11)]
        movup.4 eq.{expected_scheme_id} assert.err="expected scheme ID {expected_scheme_id}"
        # => [PUB_KEY, pad(11)]
        dropw
        # => [pad(12)]
    end
"#
);

Comment on lines +1682 to +1692
let script_code = "
begin
call.::miden::standards::components::auth::multisig::is_signer
# => [is_signer, pad(15)]
assert
# => [pad(15)]
end
";

execute_multisig_getter_call(script_code, |public_keys| public_keys[0].to_commitment().into())
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's the reason we need to provide the pub key as tx script args rather than pushing it directly in the script? If these are equivalent, consider using the push-approach to make the test easier to follow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've tried it, but it's not equivalent. These getters truncate their output to depth 16 assuming a 16-deep input frame. Pushing the key in script grows the stack to 20, so after the getter's truncation main returns at depth 19 (stack depth must be 16, but was 19). tx_script_args fills the frame exactly, so I kept it and added a comment explaining why.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is why most tests have exec.sys::truncate_stack at the end to fix this conveniently.

@onurinanc

Copy link
Copy Markdown
Collaborator Author

@PhilippGackstatter Could you review this again as I've added more fixes after the approval?

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the updates! I left some questions about the multisig code changes.

Comment thread crates/miden-standards/asm/components/auth/no_auth/no_auth.masm
Comment on lines +114 to +119
# assert the stored threshold is reachable with the new signer count (threshold <= new count)
dup dup.4
# => [new_num_of_approvers, threshold, threshold, num_approvers, init, new]
u32assert2.err=ERR_CLEANUP_THRESHOLD_CONFIG_NOT_RECONCILED
u32gt assertz.err=ERR_CLEANUP_THRESHOLD_CONFIG_NOT_RECONCILED
# => [threshold, num_approvers, init, new]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This check is duplicate with the threshold <= new_num_approvers check in multisig::update_signers_and_threshold. It seems unintuitive to me that we do this important check in a "cleanup" procedure rather than in the "update" procedure.

I assume the motivation is to add this missing check to multisig_smart::update_signers_and_threshold? If so, the other options would be:

  • adding it in that procedure
  • keeping it here and removing it from the multisig update procedure
  • adding a dedicated assert_threshold_is_reachable that is called from both update procedures.

I think the second option is my least favorite and the other two are both fine, but I'd suggest the third option.

Comment on lines +110 to +118
# Guard against callers that shrink the approver maps without reconciling THRESHOLD_CONFIG_SLOT.
exec.get_current_threshold_and_num_approvers
# => [threshold, num_approvers, init_num_of_approvers, new_num_of_approvers]

# assert the stored threshold is reachable with the new signer count (threshold <= new count)
dup dup.4
# => [new_num_of_approvers, threshold, threshold, num_approvers, init, new]
u32assert2.err=ERR_CLEANUP_THRESHOLD_CONFIG_NOT_RECONCILED
u32gt assertz.err=ERR_CLEANUP_THRESHOLD_CONFIG_NOT_RECONCILED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I think the word "reconcile" is a bit unclear here. Do we mean that num approvers could no longer reach the threshold? If so, I'd say this more explicitly.

Comment on lines +121 to +123
# assert the stored approver count already reflects the reduced signer set
dup.1 dup.4 eq assert.err=ERR_CLEANUP_THRESHOLD_CONFIG_NOT_RECONCILED
# => [threshold, num_approvers, init, new]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand why we need this check: We call cleanup_pubkey_and_scheme_id_mapping right after we have updated the threshold in storage, so fetching the threshold from storage and comparing it against the value that we have just set seems unnecessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The main motivation is this issue: #3227

Comment on lines 34 to 45
@@ -43,9 +42,6 @@ pub proc assert_only_one_non_auth_procedure_called
loc_load.0 add.1 loc_store.0
# => [proc_index]
end
else
dropw
# => [proc_index]
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Probably in a different PR, we may want to refactor assert_only_one_non_auth_procedure_called to change the loop condition to be neq.1 so we naturally exclude the auth procedure and can simplify the branch here. We also currently use neq.0 as the condition for entering the loop, but we can also unconditionally enter it (because num procedures is always >= 2), like we did somewhere else recently.

There may be some other similar occurrences in the codebase, as we have quite a few loops over account procedures.

@onurinanc

Copy link
Copy Markdown
Collaborator Author

@PhilippGackstatter Is it possible that we merge this today? (so that auditors can review it one more round)

@onurinanc onurinanc requested a review from mmagician July 8, 2026 09:07

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! I think we can merge with one approval as the changes are not too involved.

@PhilippGackstatter PhilippGackstatter added this pull request to the merge queue Jul 8, 2026
Merged via the queue into next with commit 498a696 Jul 8, 2026
19 checks passed
@PhilippGackstatter PhilippGackstatter deleted the refactor-multisig branch July 8, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants