Skip to content

feat(standards): Add DelayedExecution (Timelocked Accounts) to AuthMultisigSmart#3044

Open
onurinanc wants to merge 21 commits into
nextfrom
onur-multisig-smart-delayed-execution
Open

feat(standards): Add DelayedExecution (Timelocked Accounts) to AuthMultisigSmart#3044
onurinanc wants to merge 21 commits into
nextfrom
onur-multisig-smart-delayed-execution

Conversation

@onurinanc

Copy link
Copy Markdown
Collaborator

Closes: #3043.

Previously opened a draft PR implementing all features regarding Smart Multisig here: #2973. That PR still relatively big, and we separate the delayed execution logic into this PR.

@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.

I left a few questions/comments/nits. Overall, I'm having a hard time wrapping my head around this PR. In particular:

  • it seems we have multiple names for the same concept - would be great to unify.
  • I find the use of the pending slots hard to follow, and I'm not sure I understand why we need these. I left a question/suggestion to use auth args instead.
  • I don't understand why we need the cancel_and_propose functionality when we already have the individual cancel and propose.

Comment on lines +12 to +13
# Map entries: [TX_HASH] -> [unlock_timestamp, proposal_timestamp, min_cancel_sigs, 1]
const TX_PROPOSALS_SLOT = word("miden::standards::auth::multisig_smart::tx_proposals")

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'd call this TX_SUMMARY_COMMITMENT instead of TX_HASH for accuracy (everywhere it is used in this PR).

Comment on lines +48 to +54
#! Inputs: [min_delay, propose_expiration_delta]
#! Outputs: []
#!
#! Side effects:
#! - Writes DELAYED_EXECUTION_SLOT with word
#! `[min_delay, propose_expiration_delta, 0, 0]` (see `get_delayed_execution`).
#!

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 we should rather add a Where: clause explaining the inputs rather than the side effect which can be checked by looking at the code.

Comment on lines +88 to +91
#! Loads the delayed execution policy configuration from `DELAYED_EXECUTION_SLOT`.
#!
#! Inputs: []
#! Outputs: [min_delay, propose_expiration_delta, 0, 0]

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: Returning the zeros is unnecessary.

Comment on lines +155 to +157
proc get_propose_expiration_delta
exec.get_delayed_execution
# => [min_delay, propose_expiration_delta, 0, 0]

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
proc get_propose_expiration_delta
exec.get_delayed_execution
# => [min_delay, propose_expiration_delta, 0, 0]
proc get_propose_expiration_delta
exec.get_delayed_execution_config
# => [min_delay, propose_expiration_delta, 0, 0]

Nit: Maybe the current procedure name could be a bit clearer.

Comment on lines +178 to +193
proc apply_expiration_delta
dup neq.0 assert.err=ERR_EXPIRATION_DELTA_ZERO
# => [expiration_delta]

exec.tx::update_expiration_block_delta
# => []

exec.tx::get_expiration_block_delta
# => [tx_expiration_delta]

dup neq.0 assert.err=ERR_TX_EXPIRATION_DELTA_NOT_SET
# => [tx_expiration_delta]

drop
# => []
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.

I don't understand why we set and then get the delta - this seems redundant. The neq.0 check is already done in tx::update_expiration_block_delta (but it is not documented in a Panics if section - if you don't mind, we could add it in this PR).

So, I think the whole procedure can be removed in favor of calling tx::update_expiration_block_delta directly.

Comment on lines +892 to +895
#! Finalizes a pending cancel action for the current transaction.
#!
#! Inputs: [num_verified_signatures, TX_HASH]
#! Outputs: [num_verified_signatures, TX_HASH]

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.

Does this procedure use TX_HASH? If not, can we remove it from the inputs/outputs? I'd also pass in num_verified_signatures but not return it. Caller should dup it.

Comment on lines +810 to +822
#! Marks that this tx intends to execute a proposed action.
#!
#! Inputs: []
#! Outputs: []
#!
#! Panics if:
#! - `PENDING_EXECUTE_SLOT` is already non-empty (`ERR_PENDING_ALREADY_SET`).
#!
#! Side effects:
#! - Writes `PENDING_EXECUTE_FLAG` to `PENDING_EXECUTE_SLOT`.
#!
#! Invocation: exec
pub proc execute_proposed_transaction

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.

Is the use of this procedure to communicate to the auth procedure that the current transaction executed a proposed action? It seems like this is intended to be called from a tx script, which is arbitrarily provided by the tx executor.

If so, can we not achieve the same by passing a flag as AUTH_ARGS? And if so, can we get rid of all the pending slots by using this pattern?

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 think the same pattern needs to stay for propose/cancel, so it might be good keeping execute on the same pattern for symmetry. I'm not sure if removing all the pending slots by using the AUTH_ARGS pattern works.

Comment on lines +477 to +487
#! Returns the pending execute transaction summary hash, or `EMPTY_WORD` if none is set.
#!
#! Inputs: []
#! Outputs: [PENDING_EXECUTE_HASH]
#!
#! Where:
#! - PENDING_EXECUTE_HASH is the pending execute transaction summary hash, or
#! `EMPTY_WORD` if no execute action is pending.
#!
#! Invocation: exec
proc get_pending_execute

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.

Is this procedure description accurate? In set_pending_execute we write PENDING_EXECUTE_FLAG and not a HASH, so there is a mismatch.

#! - Writes `NEW_TX_HASH` to `PENDING_PROPOSE_SLOT`.
#!
#! Invocation: exec
pub proc cancel_and_propose_new_transaction

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.

Is the functionality of this procedure not the same as calling cancel and then propose? Why do we need a single procedure for this?

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.

Commented here now: #3043 (comment)

Comment on lines +851 to +852
exec.delayed_execution::finalize_timelock_proposals
# => [TX_SUMMARY_COMMITMENT]

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.

Given that we clear the pending slots as the last step, the tx summary we computed earlier contains the changes to the pending slots. This works in principle, but feels a bit unclean. Mentioning in case this was not intended.

@mmagician mmagician requested a review from Fumuran June 8, 2026 16:18
Comment on lines +110 to +113
#! Panics if:
#! - block_height_delta is not a valid `u32` (`ERR_TX_INVALID_EXPIRATION_DELTA`).
#! - block_height_delta is zero (`ERR_TX_INVALID_EXPIRATION_DELTA`).
#! - block_height_delta is greater than 0xFFFF (`ERR_TX_INVALID_EXPIRATION_DELTA`).

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: We usually don't mention the exact error variants.

Comment on lines +599 to +611
pub proc cancel_transaction_proposal
dupw
# => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT]

exec.is_tx_proposed
# => [is_proposed, TX_SUMMARY_COMMITMENT]

assert.err=ERR_TX_NOT_PROPOSED
# => [TX_SUMMARY_COMMITMENT]

exec.set_pending_cancel
# => []
end

@PhilippGackstatter PhilippGackstatter Jun 10, 2026

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.

Afaict, propose_transaction and cancel_transaction_proposal are both essentially setters for their provided arguments; writing into a temporary storage slot. The arguments come from a tx script, which is constructed by a user. The main work is done in the auth procedure, which reads the slots, enforces delay constraints and updates the maps.

If we do not need these procedures to be called from a note, which I assume is not the case, we could reduce complexity by a lot by removing these procedures and setting AUTH_ARGS to the hash of the following data:

[delay_action, CANCEL_TX_SUMMARY_COMMITIMENT, PROPOSE_TX_SUMMARY_COMMITIMENT, SALT]

where delay_action is one of {execute, propose, cancel}. We then dispatch based on delay_action.

That way, we could handle all logic in one flow within the auth procedure rather than splitting it across a pre-auth and a post-auth phase, and avoid having three temporary storage slots.

Whether the user of the multisig constructs a tx script and calls these APIs or whether they construct AUTH_ARGS in the above way should be essentially equivalent.

Any thoughts? Would this work?

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.

If we do not need these procedures to be called from a note, which I assume is not the case, we could reduce complexity by a lot by removing these procedures and setting AUTH_ARGS to the hash of the following data:

I was thinking that we might somehow extend delayed execution logic into note-based auth, however, as I explore more about this I've seen that it's not possible to use the same logic as this design requires num_signatures, and this is not the case for owner controlled and rbac.

So, although I believe the current design is easier to read, your proposed way of constructing AUTH_ARGS reduces the complexity, and API calls are easier to use. So, it seems it is better to switch to your proposed design.

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.

Since this is a bigger refactor, I'd wait for @bobbinth or @mmagician's input before executing on this.

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.

Any updates on the possible refactoring item? @bobbinth @mmagician

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 haven't gotten into the review yet - but @onurinanc - what's the best place to get the overall design approach from? I remember we had a long discussion a while back, but I've forgotten some important aspects since then, and other aspects have probably changed. So, it may be helpful to have a summary of how the overall mechanism is supposed to work.

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.

If the proposal is canceled once, it can be canceled again w/o any extra work by any of the users who signed the original cancelation.

I think this would work, but it also assumes that someone (user, machine) always need to monitor the contract to check for proposals that re-appear and cancel them before their timelock expires, and they become executable. This seems risky when this task is outsourced to humans (who could easily miss something like this) or it requires extra logic that a service would need to have to store all cancelled signatures and check for reappearing proposals to cancel them automatically. So, an in-contract solution would be a bit nicer, I think, but if we go with this out-of-contract solution, we should document this due to its subtlety.

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.

Agreed that monitoring is required - but I think that's the case anyways. Even if prohibit re-proposing canceled proposals, the attacker could modify the proposal slightly (e.g., use a different salt, or maybe make some other trivial change to the proposal) and make a new proposal which would have a different commitment but would be semantically the same. So, AFAICT, there isn't really a way to get away from the need to monitor.

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 think proposed design doesn't work since propose_transaction would need to verify the signatures over the proposed transaction's commitment, but signature verification can only run against the current transaction.

When verify_signatures runs, it goes through the AuthRequest event, and the kernel reconstructs the transaction summary from the current transaction (from account delta, input/output notes) and rejects the message if those commitments don't match. So the signed message is always pinned to the current transaction.

This breaks the design in two ways:

  1. The proposed transaction's delta (its future storage changes) differs from the proposing transaction's delta (just the proposals-map write), so the kernel's summary check fails, so you can't verify signatures over the proposed transaction from inside the proposing one.

  2. The account delta is only final at the end of the transaction, in the auth procedure. Calling verify_signatures mid-transaction inside propose_transaction sees an incomplete delta. Signature verification structurally has to happen in auth, after all state changes.

So having the proposal verify the proposed transaction isn't possible by reusing the current signature verification. Since signature verification has to run in auth (where the delta is final), it ends up signing the current transaction, which is exactly why the "pending & finalizer" and "auth_args" designs sign the proposing transaction.

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 think proposed design doesn't work since propose_transaction would need to verify the signatures over the proposed transaction's commitment, but signature verification can only run against the current transaction.

Ah - good point! I think this is an unfortunate limitation of the current approach - but we may be able to modify this relatively easily.

Refreshing my memory a bit, it seems like we require tx summary to be built even if the signature is found in the advice provider. This shouldn't be strictly needed: if the signature is already in the advice provider, AFAICT, there is no real need to build the tx summary.

So, the change may be as simple as modifying the TransactionEvent::AuthRequest variant to look like:

AuthRequest {
    pub_key_commitment: PublicKeyCommitment,
    tx_summary: Option<TransactionSummary>,
    signature: Option<Vec<Felt>>,
}

Or maybe something more "strongly typed" as we really can have either tx_summary or signature - but don't really need to have both.

@PhilippGackstatter - what do you think?

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.

Agreed, that should work.

Or maybe something more "strongly typed" as we really can have either tx_summary or signature - but don't really need to have both.

We can add the either crate so we can write this as:

AuthRequest {
    pub_key_commitment: PublicKeyCommitment,
    signature_or_summary: Either<Vec<Felt>, TransactionSummary>,
}

We'd only call extract_tx_summary if the signature is None.

@partylikeits1983 partylikeits1983 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 great! Left two comments below

Comment on lines +41 to +42
#! High-impact action.
#!

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:

Suggested change
#! High-impact action.
#!

Comment on lines +62 to +69
pub proc update_delayed_execution_policy
u32assert.err=ERR_MIN_DELAY_NOT_U32
dup eq.0 assertz.err=ERR_MIN_DELAY_ZERO
# => [min_delay, propose_expiration_delta]

swap u32assert.err=ERR_PROPOSE_EXPIRATION_DELTA_NOT_U32
dup eq.0 assertz.err=ERR_PROPOSE_EXPIRATION_DELTA_ZERO
# => [propose_expiration_delta, min_delay]

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.

We should add an assertion here that propose_expiration_delta is a valid u16.

This procedure only checks that propose_expiration_delta is a u32 and non-zero, so it accepts any value up to 4294967295.

But the value set here is later passed into the kernel's tx::update_expiration_block_delta, which only accepts deltas in the range 1 to 65535. Every propose_transaction (and cancel_and_propose_new_transaction) calls that kernel proc, so:

  1. It's possible to set the policy with propose_expiration_delta >= 65536; this succeeds and writes the value to storage.
  2. From then on, every propose_transaction panics in the kernel with ERR_TX_INVALID_EXPIRATION_DELTA, because the stored delta is above 65535.

The result is that the whole propose flow would be bricked in this case until someone calls update_delayed_execution_policy again with a valid value (this proc never touches the kernel, so it's recoverable, not permanently stuck).

This isn't really an issue because the rust DelayedExecutionPolicy stores the field as a u16 (max 65535), so an out-of-range value can only get in via a direct MASM call.

Comment on lines +289 to +291
add
# => [unlock_timestamp, proposal_timestamp]
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.

We should have a check here which asserts the resulting unlock_timestamp is a u32

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.

Add DelayedExecution (Timelocked Accounts) to AuthMultisigSmart

4 participants