refactor(standards): Multisig Auth Component cycle-cost optimizations, comment fixes, and PROC_ROOT validation#3211
Conversation
PhilippGackstatter
left a comment
There was a problem hiding this comment.
Looks good, left a few nits.
| #! 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. | ||
| #! |
| #! 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. | ||
| #! |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
| 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
| # => [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)] |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
| /// 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 | ||
| "; |
There was a problem hiding this comment.
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
"#
);| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is why most tests have exec.sys::truncate_stack at the end to fix this conveniently.
|
@PhilippGackstatter Could you review this again as I've added more fixes after the approval? |
PhilippGackstatter
left a comment
There was a problem hiding this comment.
Thanks for the updates! I left some questions about the multisig code changes.
| # 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] |
There was a problem hiding this comment.
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
multisigupdate procedure - adding a dedicated
assert_threshold_is_reachablethat 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.
| # 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 |
There was a problem hiding this comment.
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.
| # 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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
The main motivation is this issue: #3227
| @@ -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 | |||
There was a problem hiding this comment.
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.
|
@PhilippGackstatter Is it possible that we merge this today? (so that auditors can review it one more round) |
PhilippGackstatter
left a comment
There was a problem hiding this comment.
LGTM! I think we can merge with one approval as the changes are not too involved.
This PR cleans up and improves the multisig auth component:
dup neq.0loop guards inassert_proc_thresholds_lte_num_approvers,compute_transaction_threshold, andupdate_signers_and_threshold, entering each loop unconditionally withpush.1since the counters are guaranteed non-zero.scheme_idinget_signer_atwith a singlemovdn.7, dropping the redundant trailingmovdn.4.num_approversinupdate_signers_and_thresholdfrom the intactMULTISIG_CONFIGon the stack viadup.1instead of reloading it from local memory.assert_new_txcomments to reflect thatIS_EXECUTED_FLAGis[1, 0, 0, 0](flag at position 0), matching the layoutmovdn.3 drop drop droprelies on for replay protection.update_signers_and_thresholdadvice-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.get_signer_atslot-push comments to show both felts (..._slot_suffix, ..._slot_prefix) placed bypush.SLOT[0..2], matching the convention used elsewhere in the file.update_signers_and_thresholdprocedures and in theAuthMultisigcomponent 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 viaset_procedure_thresholdin the same transaction.active_account::has_procedurecheck inset_procedure_threshold(newERR_PROC_ROOT_NOT_IN_ACCOUNTerror) so a threshold override cannot be silently stored under aPROC_ROOTthe account does not have.