From 1345a8f9b4e9b03b1484177671ed11c53306adb4 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 09:23:29 +0000 Subject: [PATCH 01/12] feat(standards): pay the transaction fee in guarded and smart multisig auth Port of #3786 to next. Both components run estimate_fee, the sponsorship payment, resolve_payment_info, assert_fee_bound (2/1, native fee asset) and pay_estimated_fee between multisig::resolve_auth_args and the summary, and pass the number of notes the payment created to guardian::verify_signature and multisig_smart::auth_tx, whose note restrictions exclude them. tx_policy::assert_no_output_notes reads the verified count itself. Adaptations to next: the conversion info comes from resolve_auth_args (MultisigAuthArgs) rather than load_conversion_info, so the auth args no longer double as the summary salt; multisig_smart::auth_tx takes (num_own_output_notes, block_number, SALT); the fee asset is read via tx::get_fee_asset_id; the sponsorship wrapper dropped in the fee-split port is spelled out at the call sites; pay_fee reads the fee asset once via an estimate_fee_for_asset helper. Tests build MultisigAuthArgs, share the multisig fixture and signing helpers, and drop the salt-binding assertions. Cost tables and bench-tx.json regenerated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- CHANGELOG.md | 1 + .../guarded_multisig/guarded_multisig.masm | 97 +++- .../auth/multisig_smart/multisig_smart.masm | 84 ++- .../asm/standards/auth/guardian.masm | 23 +- .../standards/auth/multisig_smart/mod.masm | 69 ++- .../asm/standards/auth/tx_policy.masm | 18 +- .../asm/standards/fee/mod.masm | 163 ++++-- .../miden-standards/src/account/auth/fee.rs | 5 + .../src/account/auth/guarded_multisig.rs | 18 + .../account/auth/multisig_smart/component.rs | 19 + .../auth/multisig_smart/procedure_policies.rs | 8 +- .../auth/fee_payment/guarded_multisig.rs | 475 +++++++++++++++++ .../tests/auth/fee_payment/mod.rs | 2 + .../tests/auth/fee_payment/multisig.rs | 38 +- .../tests/auth/fee_payment/multisig_smart.rs | 492 ++++++++++++++++++ .../tests/auth/fee_payment/sponsorship.rs | 8 +- .../tests/auth/guarded_multisig.rs | 2 +- crates/miden-testing/tests/auth/mod.rs | 2 + crates/miden-testing/tests/auth/tx_policy.rs | 76 +++ docs/src/fees.md | 4 +- 20 files changed, 1505 insertions(+), 99 deletions(-) create mode 100644 crates/miden-testing/tests/auth/fee_payment/guarded_multisig.rs create mode 100644 crates/miden-testing/tests/auth/fee_payment/multisig_smart.rs create mode 100644 crates/miden-testing/tests/auth/tx_policy.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 42efabed75..ed6b922cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- [BREAKING] The `AuthGuardedMultisig` and `AuthMultisigSmart` components now pay the transaction fee, bounded via `fee::assert_fee_bound` to the native fee asset at at most twice the computed fee; `tx_policy::assert_no_output_notes` takes the number of output notes the caller created itself ([#3786](https://github.com/0xMiden/protocol/pull/3786)). - Added `active_note::get_storage_info` and `active_note::get_bounded_storage`, and switched the standard and agglayer note scripts with a bounded storage layout over to the latter ([#3563](https://github.com/0xMiden/protocol/pull/3563)). - [BREAKING] AggLayer bridge and faucet accounts now map note repricing to an initial `FEE_MNGR` role instead of the built-in `ADMIN` role ([#3571](https://github.com/0xMiden/protocol/issues/3571)). - [BREAKING] AggLayer bridge accounts now map emergency pause to an initial `PAUSER` role, while unpause remains restricted to `ADMIN` ([#3572](https://github.com/0xMiden/protocol/issues/3572)). diff --git a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm index 13311e149e..5c7f5e32fc 100644 --- a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm +++ b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm @@ -2,8 +2,12 @@ # # See the `AuthGuardedMultisig` Rust type's documentation for more details. -use miden::standards::auth::multisig +use miden::protocol::tx use miden::standards::auth::guardian +use miden::standards::auth::multisig +use miden::standards::auth::signature +use miden::standards::fee +use miden::standards::fees pub use {update_signers_and_threshold} from miden::standards::auth::multisig pub use {get_threshold_and_num_approvers} from miden::standards::auth::multisig @@ -13,7 +17,29 @@ pub use {is_signer} from miden::standards::auth::multisig pub use {update_guardian_public_key} from miden::standards::auth::guardian -#! Authenticate a transaction with multi-signature support and optional guardian verification. +# CONSTANTS +# ================================================================================================= + +# The largest fee payment this component accepts, as the fraction FEE_BOUND_NUM / FEE_BOUND_DEN of +# the computed fee. Guardian key rotation authenticates without a guardian signature and can be +# thresholded below the account's spending quorum, so an unbounded host-supplied rate would drain +# the vault through the fee note. The margin covers a fee rising while signatures are collected. +const FEE_BOUND_NUM = 2 +const FEE_BOUND_DEN = 1 + +#! Authenticate a transaction with multi-signature support and guardian verification, paying the +#! transaction fee in the process. +#! +#! The fee is paid by creating and funding a public TX_FEE note in the asset and at the rate of +#! the CONVERSION_INFO committed to by the AUTH_ARGS. The payment is bounded to at most +#! FEE_BOUND_NUM / FEE_BOUND_DEN of the computed fee and pinned to the native fee asset (see +#! fee::assert_fee_bound). On chains with a zero verification base fee no note is created. The fee +#! is paid before the transaction summary is created, so the fee note and the vault withdrawal +#! funding it are covered by the approver and guardian signatures. +#! +#! The guardian signature is verified in addition to the approvers' (see +#! guardian::verify_signature), except on the guardian key rotation path, which instead requires +#! that the transaction create no notes beyond the ones the fee payment creates. #! #! The guardian must not be an approver, otherwise a single signature would satisfy both the #! multisig and the guardian check, so this procedure re-checks the invariant that @@ -28,6 +54,8 @@ pub use {update_guardian_public_key} from miden::standards::auth::guardian #! Operand stack: [] #! #! Panics if: +#! - the auth args cannot be resolved, see `multisig::resolve_auth_args`. +#! - the fee payment is not in the native fee asset, exceeds the bound, or cannot be funded. #! - insufficient number of valid approver or guardian signatures. #! - the approval window ended at or before the transaction reference block, see #! `multisig::auth_tx`. @@ -36,18 +64,71 @@ pub use {update_guardian_public_key} from miden::standards::auth::guardian #! Invocation: call @auth_script pub proc auth_tx_guarded_multisig(auth_args: word) + # read the output-note count before the fee payment so the notes it creates can be counted + exec.tx::get_num_output_notes movdn.4 + # => [AUTH_ARGS, num_output_notes_before_fee] + exec.multisig::resolve_auth_args - # => [CONVERSION_INFO, block_number, SALT] + # => [CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + # Pay the transaction fee before the summary is created so that the TX_FEE note and the vault + # withdrawal funding it are covered by the approver and guardian signatures. + # --------------------------------------------------------------------------------------------- + + exec.multisig::get_initial_threshold_and_num_approvers drop + # => [num_of_approvers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + # one slot beyond the approvers, for the guardian signature. It is unconditional because the + # rotation path verifies no guardian signature but scans every account procedure instead, which + # the slot also covers. + add.1 + # => [num_of_signers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + exec.signature::estimate_multisig_authentication_cycles + # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - # this component does not pay the transaction fee yet, so the conversion info is unused - dropw - # => [block_number, SALT] + exec.fee::estimate_fee + # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + # settle the sponsorship obligation first, in pay_fee's order; the bound below guards the + # host-supplied rate, which the sponsorship amounts do not depend on + exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop + # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + dup movdn.5 + # => [fee_amount, CONVERSION_INFO, fee_amount, block_number, SALT, num_output_notes_before_fee] + + exec.fee::resolve_payment_info + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, fee_amount, + # block_number, SALT, num_output_notes_before_fee] + + push.FEE_BOUND_DEN push.FEE_BOUND_NUM + # => [bound_num, bound_den, payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, + # fee_amount, block_number, SALT, num_output_notes_before_fee] + + exec.fee::assert_fee_bound + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, block_number, SALT, + # num_output_notes_before_fee] + + exec.fee::pay_estimated_fee + # => [block_number, SALT, num_output_notes_before_fee] + + # the notes the fee payment created: the TX_FEE note and one FEE_SPONSORSHIP note per network + # output note. The rotation path excludes them from its no-output-notes check. + exec.tx::get_num_output_notes movup.6 sub movdn.5 + # => [block_number, SALT, num_own_output_notes] + + # Authenticate the transaction and record it for replay protection. + # --------------------------------------------------------------------------------------------- exec.multisig::auth_tx - # => [TX_SUMMARY_COMMITMENT] + # => [TX_SUMMARY_COMMITMENT, num_own_output_notes] dupw - # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] + # => [TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT, num_own_output_notes] + + movup.8 + # => [num_own_output_notes, TX_SUMMARY_COMMITMENT, TX_SUMMARY_COMMITMENT] exec.guardian::verify_signature # => [TX_SUMMARY_COMMITMENT] diff --git a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm index a6c7a6e43d..d31e0c4241 100644 --- a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm +++ b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm @@ -2,8 +2,12 @@ # # See the `AuthMultisigSmart` Rust type's documentation for more details. +use miden::protocol::tx use miden::standards::auth::multisig use miden::standards::auth::multisig_smart +use miden::standards::auth::signature +use miden::standards::fee +use miden::standards::fees pub use {get_threshold_and_num_approvers} from miden::standards::auth::multisig pub use {get_signer_at} from miden::standards::auth::multisig @@ -11,7 +15,28 @@ pub use {is_signer} from miden::standards::auth::multisig pub use {set_procedure_policy} from miden::standards::auth::multisig_smart pub use {update_signers_and_threshold} from miden::standards::auth::multisig_smart -#! Authenticate a transaction using multisig smart-policy rules. +# CONSTANTS +# ================================================================================================= + +# The largest fee payment this component accepts, as the fraction FEE_BOUND_NUM / FEE_BOUND_DEN of +# the computed fee. A per-procedure policy can authorize a transaction below the account's default +# threshold, so an unbounded host-supplied rate would let such a transaction drain the vault +# through the fee note. The margin covers a fee rising while signatures are collected. +const FEE_BOUND_NUM = 2 +const FEE_BOUND_DEN = 1 + +#! Authenticate a transaction using multisig smart-policy rules, paying the transaction fee in the +#! process. +#! +#! The fee is paid by creating and funding a public TX_FEE note in the asset and at the rate of +#! the CONVERSION_INFO committed to by the AUTH_ARGS. The payment is bounded to at most +#! FEE_BOUND_NUM / FEE_BOUND_DEN of the computed fee and pinned to the native fee asset (see +#! fee::assert_fee_bound). On chains with a zero verification base fee no note is created. The fee +#! is paid before the transaction summary is created, so the fee note and the vault withdrawal +#! funding it are covered by the approver signatures. +#! +#! The notes this procedure creates to pay the fee are excluded from the procedure policies' note +#! restrictions, so a policy forbidding output notes stays satisfiable on a fee-charging chain. #! #! Inputs: #! Operand stack: [AUTH_ARGS] @@ -20,19 +45,66 @@ pub use {update_signers_and_threshold} from miden::standards::auth::multisig_sma #! Operand stack: [] #! #! Panics if: -#! - insufficient number of valid signatures (below threshold). +#! - the auth args cannot be resolved, see `multisig::resolve_auth_args`. +#! - the fee payment is not in the native fee asset, exceeds the bound, or cannot be funded. +#! - a called procedure's policy forbids the transaction's input or output notes, or the number of +#! valid signatures is below the threshold, see `multisig_smart::auth_tx`. #! - the approval window ended at or before the transaction reference block, see #! `multisig_smart::auth_tx`. #! #! Invocation: call @auth_script pub proc auth_tx_multisig_smart(auth_args: word) + # read the output-note count before the fee payment so the notes it creates can be counted + exec.tx::get_num_output_notes movdn.4 + # => [AUTH_ARGS, num_output_notes_before_fee] + exec.multisig::resolve_auth_args - # => [CONVERSION_INFO, block_number, SALT] + # => [CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + # Pay the transaction fee before the summary is created so that the TX_FEE note and the vault + # withdrawal funding it are covered by the approver signatures. + # --------------------------------------------------------------------------------------------- + + exec.multisig::get_initial_threshold_and_num_approvers drop + # => [num_of_approvers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + exec.signature::estimate_multisig_authentication_cycles + # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + exec.fee::estimate_fee + # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + # settle the sponsorship obligation first, in pay_fee's order; the bound below guards the + # host-supplied rate, which the sponsorship amounts do not depend on + exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop + # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + + dup movdn.5 + # => [fee_amount, CONVERSION_INFO, fee_amount, block_number, SALT, num_output_notes_before_fee] + + exec.fee::resolve_payment_info + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, fee_amount, + # block_number, SALT, num_output_notes_before_fee] + + push.FEE_BOUND_DEN push.FEE_BOUND_NUM + # => [bound_num, bound_den, payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, + # fee_amount, block_number, SALT, num_output_notes_before_fee] + + exec.fee::assert_fee_bound + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, block_number, SALT, + # num_output_notes_before_fee] + + exec.fee::pay_estimated_fee + # => [block_number, SALT, num_output_notes_before_fee] + + # the notes the fee payment created: the TX_FEE note and one FEE_SPONSORSHIP note per network + # output note. The procedure policies' note restrictions exclude them. + exec.tx::get_num_output_notes movup.6 sub + # => [num_own_output_notes, block_number, SALT] - # this component does not pay the transaction fee yet, so the conversion info is unused - dropw - # => [block_number, SALT] + # Authenticate the transaction and record it for replay protection. + # --------------------------------------------------------------------------------------------- exec.multisig_smart::auth_tx # => [TX_SUMMARY_COMMITMENT] diff --git a/crates/miden-standards/asm/standards/auth/guardian.masm b/crates/miden-standards/asm/standards/auth/guardian.masm index 7ff41338be..a603e7f6d3 100644 --- a/crates/miden-standards/asm/standards/auth/guardian.masm +++ b/crates/miden-standards/asm/standards/auth/guardian.masm @@ -135,27 +135,37 @@ end #! Conditionally verifies a guardian signature. #! -#! Inputs: [MSG] +#! Inputs: [num_own_output_notes, MSG] #! Outputs: [] #! +#! Where: +#! - num_own_output_notes is the number of output notes the caller itself created, e.g. to pay the +#! transaction fee (see `tx_policy::assert_no_output_notes`). It is only consulted on the +#! guardian key rotation path. +#! - MSG is the message the guardian signs, i.e. the transaction summary commitment. +#! #! Panics if: #! - `update_guardian_public_key` is called together with another non-auth account procedure. +#! - `update_guardian_public_key` was called and the transaction consumes input notes or created +#! output notes the caller did not create itself. #! - `update_guardian_public_key` was not called and a valid guardian signature is missing or #! invalid. #! #! Invocation: exec -pub proc verify_signature(msg: word) +pub proc verify_signature(num_own_output_notes: u16, msg: word) procref.update_guardian_public_key - # => [UPDATE_GUARDIAN_PUBLIC_KEY_ROOT, MSG] - + # => [UPDATE_GUARDIAN_PUBLIC_KEY_ROOT, num_own_output_notes, MSG] + exec.native_account::was_procedure_called - # => [was_update_guardian_public_key_called, MSG] + # => [was_update_guardian_public_key_called, num_own_output_notes, MSG] if.true # Check the notes first: creating an output note requires calling `create_note`, which # would also trip `assert_only_one_non_auth_procedure_called`. Running that check last lets # the more specific input/output-note errors surface when notes are present. exec.tx_policy::assert_no_input_notes + # => [num_own_output_notes, MSG] + exec.tx_policy::assert_no_output_notes # => [MSG] @@ -165,6 +175,9 @@ pub proc verify_signature(msg: word) dropw # => [] else + drop + # => [MSG] + push.1 # => [1, MSG] diff --git a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm index fe02cfeeda..876abde954 100644 --- a/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm +++ b/crates/miden-standards/asm/standards/auth/multisig_smart/mod.masm @@ -75,6 +75,18 @@ const NOTE_RESTRICTIONS_LOC = 2 const EXECUTION_MODE_LOC = 0 const DEFAULT_THRESHOLD_LOC = 1 +# LOCAL ADDRESSES (enforce_procedure_policy) +# ================================================================================================= + +const ENFORCE_POLICY_NUM_OWN_OUTPUT_NOTES_LOC = 0 + +# LOCAL ADDRESSES (auth_tx) +# ================================================================================================= + +const AUTH_TX_POLICY_THRESHOLD_LOC = 0 +const AUTH_TX_DEFAULT_THRESHOLD_LOC = 1 +const AUTH_TX_NUM_OWN_OUTPUT_NOTES_LOC = 2 + # LOCAL ADDRESSES (update_signers_and_threshold) # ================================================================================================= @@ -368,24 +380,33 @@ end #! - bit 0 (mask 1) → forbid input notes #! - bit 1 (mask 2) → forbid output notes #! -#! Inputs: [note_restrictions] +#! Inputs: [num_own_output_notes, note_restrictions] #! Outputs: [] #! +#! Where: +#! - num_own_output_notes is the number of output notes the authentication procedure created +#! itself, e.g. to pay the transaction fee. See `tx_policy::assert_no_output_notes`. +#! #! Invocation: exec -pub proc enforce_note_restrictions +pub proc enforce_note_restrictions(num_own_output_notes: u16, note_restrictions: u32) + swap + # => [note_restrictions, num_own_output_notes] + dup u32and.NOTE_RESTRICTION_INPUT_NOTES_MASK eq.NOTE_RESTRICTION_INPUT_NOTES_MASK - # => [has_input_note_restriction, note_restrictions] + # => [has_input_note_restriction, note_restrictions, num_own_output_notes] if.true exec.tx_policy::assert_no_input_notes end - # => [note_restrictions] + # => [note_restrictions, num_own_output_notes] u32and.NOTE_RESTRICTION_OUTPUT_NOTES_MASK eq.NOTE_RESTRICTION_OUTPUT_NOTES_MASK - # => [has_output_note_restriction] + # => [has_output_note_restriction, num_own_output_notes] if.true exec.tx_policy::assert_no_output_notes + else + drop end # => [] end @@ -393,16 +414,19 @@ end #! Authenticate a transaction using multisig smart-policy rules. #! #! Inputs: -#! Operand stack: [block_number, SALT] +#! Operand stack: [num_own_output_notes, block_number, SALT] #! Outputs: #! Operand stack: [TX_SUMMARY_COMMITMENT] #! #! Where: +#! - num_own_output_notes is the number of output notes the authentication procedure created +#! itself; it is forwarded to the procedure policies' note restrictions. #! - block_number and SALT are as described in `miden::standards::auth::multisig::auth_tx`. #! #! Locals: #! 0: policy_threshold #! 1: default_threshold +#! 2: num_own_output_notes #! #! Invocation: exec #! @@ -417,8 +441,11 @@ end #! `ERR_EXECUTE_PATH_MISMATCH`), then call #! `exec.timelock_controller::finalize_timelock_proposals` to advance any pending #! propose/cancel/execute state. -@locals(2) -pub proc auth_tx(block_number: BlockNumber, salt: word) +@locals(3) +pub proc auth_tx(num_own_output_notes: u16, block_number: BlockNumber, salt: word) + loc_store.AUTH_TX_NUM_OWN_OUTPUT_NOTES_LOC + # => [block_number, SALT] + exec.native_account::incr_nonce drop # => [block_number, SALT] @@ -451,14 +478,17 @@ pub proc auth_tx(block_number: BlockNumber, salt: word) # Save default_threshold for the procedure-policy enforcement and the final tx-threshold # fallback below. - dup loc_store.1 + dup loc_store.AUTH_TX_DEFAULT_THRESHOLD_LOC # => [default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] # ------ Enforcing procedure policy (consumes default_threshold) ------ + loc_load.AUTH_TX_NUM_OWN_OUTPUT_NOTES_LOC + # => [num_own_output_notes, default_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] + exec.enforce_procedure_policy # => [policy_threshold, num_of_approvers, TX_SUMMARY_COMMITMENT] - loc_store.0 + loc_store.AUTH_TX_POLICY_THRESHOLD_LOC # => [num_of_approvers, TX_SUMMARY_COMMITMENT] # ------ Verifying approver signatures ------ @@ -470,8 +500,8 @@ pub proc auth_tx(block_number: BlockNumber, salt: word) # ------ Computing final transaction threshold ------ # If no non-auth procedure was called, `policy_threshold` is 0 and `compute_tx_threshold` # falls back to `default_threshold`; otherwise it returns the policy max directly. - loc_load.0 - loc_load.1 + loc_load.AUTH_TX_POLICY_THRESHOLD_LOC + loc_load.AUTH_TX_DEFAULT_THRESHOLD_LOC # => [default_threshold, policy_threshold, num_verified_signatures, TX_SUMMARY_COMMITMENT] exec.compute_tx_threshold @@ -778,20 +808,28 @@ end #! Always uses IMMEDIATE_EXECUTION_MODE; procedures whose policies require the delayed mode #! panic via [`compute_called_proc_policy`] because this component has no timelock. #! -#! Inputs: [default_threshold] +#! Inputs: [num_own_output_notes, default_threshold] #! Outputs: [policy_threshold] #! #! Where: +#! - num_own_output_notes is forwarded to [`enforce_note_restrictions`]. #! - default_threshold is forwarded to [`compute_called_proc_policy`] as the per-procedure #! contribution for any called procedure without an explicit policy. #! +#! Locals: +#! 0: num_own_output_notes +#! #! Invocation: exec #! #! NOTE: This procedure is a temporary form. Once the TimelockedAccount feature lands, the #! hardcoded IMMEDIATE_EXECUTION_MODE push will be replaced with a call to #! `timelock_controller::execution_mode`, and the procedure will also expose #! `policy_requires_delay` for downstream enforcement. -proc enforce_procedure_policy(default_threshold: u32) +@locals(1) +proc enforce_procedure_policy(num_own_output_notes: u16, default_threshold: u32) + loc_store.ENFORCE_POLICY_NUM_OWN_OUTPUT_NOTES_LOC + # => [default_threshold] + push.IMMEDIATE_EXECUTION_MODE # => [execution_mode, default_threshold] @@ -805,6 +843,9 @@ proc enforce_procedure_policy(default_threshold: u32) swap # => [note_restrictions, policy_threshold] + loc_load.ENFORCE_POLICY_NUM_OWN_OUTPUT_NOTES_LOC + # => [num_own_output_notes, note_restrictions, policy_threshold] + exec.enforce_note_restrictions # => [policy_threshold] end diff --git a/crates/miden-standards/asm/standards/auth/tx_policy.masm b/crates/miden-standards/asm/standards/auth/tx_policy.masm index 96b1c9136b..39a004f58a 100644 --- a/crates/miden-standards/asm/standards/auth/tx_policy.masm +++ b/crates/miden-standards/asm/standards/auth/tx_policy.masm @@ -70,17 +70,25 @@ pub proc assert_no_input_notes # => [] end -#! Asserts that the current transaction does not create output notes. +#! Asserts that the current transaction created no output notes beyond those the caller created +#! itself. #! -#! Inputs: [] +#! Inputs: [num_own_output_notes] #! Outputs: [] #! +#! Where: +#! - num_own_output_notes is the number of output notes the caller itself created, e.g. the notes +#! a `fee::pay_fee` flow creates to pay the transaction fee. +#! +#! Panics if: +#! - the transaction created an output note the caller did not create itself. +#! #! Invocation: exec -pub proc assert_no_output_notes +pub proc assert_no_output_notes(num_own_output_notes: u16) exec.tx::get_num_output_notes - # => [num_output_notes] + # => [num_output_notes, num_own_output_notes] - assertz.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + eq assert.err=ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES # => [] end diff --git a/crates/miden-standards/asm/standards/fee/mod.masm b/crates/miden-standards/asm/standards/fee/mod.masm index a3675fdf06..da136a177e 100644 --- a/crates/miden-standards/asm/standards/fee/mod.masm +++ b/crates/miden-standards/asm/standards/fee/mod.masm @@ -31,8 +31,9 @@ pub type ConversionInfo = word # ================================================================================================= # Estimated upper bound on the number of cycles the fee note payment spends after the kernel's -# compute_fee call returns: rate conversion, fee-asset construction, serial-number derivation, -# recipient computation, note creation and funding. Guarded by the fee-payment regression tests. +# compute_fee call returns: rate conversion, any bound the caller applies, fee-asset construction, +# serial-number derivation, recipient computation, note creation and funding. Guarded by the +# fee-payment regression tests. const PAY_FEE_CYCLES = 8192 # Estimated upper bounds on the number of cycles the sponsorship payment spends after the @@ -418,7 +419,8 @@ end #! Estimates the fee of the current transaction. #! #! The fee covers the notes that will be created: the TX_FEE note, and one -#! FEE_SPONSORSHIP note per network output note. +#! FEE_SPONSORSHIP note per network output note. Pricing the latter is an FPI call into each +#! target's fee policy, so those targets must be provisioned as foreign accounts. #! #! num_extra_cycles only needs to cover the number of cycles the caller spends after the fee flow #! returns (e.g. transaction summary creation, hashing and signature verification, see @@ -464,16 +466,109 @@ pub proc estimate_fee(fee_asset_id: AssetId, price_table_ptr: u32, num_extra_cyc # => [fee_amount] end +#! Resolves the asset payment for a computed fee. +#! +#! The payment is ceil(fee_amount * rate_num / rate_den) of the asset issued by the faucet in the +#! conversion info. A zero fee resolves to a zero amount of the native fee asset, so the payment is +#! well-formed on zero-fee chains and needs no committed conversion info there. +#! +#! Inputs: [fee_amount, CONVERSION_INFO] +#! Outputs: [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount] +#! +#! Where: +#! - fee_amount is the fee computed by estimate_fee. +#! - CONVERSION_INFO is [faucet_id_suffix, faucet_id_prefix, rate_num, rate_den], or the empty +#! word if no conversion info was committed (only accepted when fee_amount is zero). +#! - payment_faucet_id_{suffix,prefix} identify the faucet issuing the payment asset. +#! - payment_amount is the amount to pay. +#! +#! Panics if: +#! - fee_amount is non-zero and CONVERSION_INFO is the empty word. +#! - the conversion rate is malformed or the converted amount overflows. +#! +#! Invocation: exec +pub proc resolve_payment_info( + fee_amount: felt, + conversion_info: ConversionInfo, +) -> (payment_faucet_id: AccountId, payment_amount: felt) + dup eq.0 + if.true + # a zero fee is paid with a zero amount of the native fee asset + drop dropw + # => [] + + exec.tx::get_fee_asset_id exec.asset::id_into_faucet_id push.0 movdn.2 + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, 0] + else + movdn.4 + # => [CONVERSION_INFO, fee_amount] + + # a non-zero fee requires committed conversion info + exec.word::testz assertz.err=ERR_FEE_CONVERSION_INFO_MISSING + # => [CONVERSION_INFO, fee_amount] + + # convert the fee amount into the payment asset's amount + # (CONVERSION_INFO reads [faucet_id_suffix, faucet_id_prefix, rate_num, rate_den]) + movup.2 movup.3 movup.4 + # => [fee_amount, rate_den, rate_num, faucet_id_suffix, faucet_id_prefix] + + movup.2 swap + # => [fee_amount, rate_num, rate_den, faucet_id_suffix, faucet_id_prefix] + + exec.convert_amount + # => [payment_amount, payment_faucet_id_suffix, payment_faucet_id_prefix] + + movdn.2 + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount] + end +end + +#! Pays a resolved fee payment by creating and funding a public TX_FEE note. +#! +#! Callers that bound the payment (see assert_fee_bound) invoke this instead of pay_fee, so the +#! bound sits between the resolved payment and the vault withdrawal that honours it. +#! +#! Inputs: [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount] +#! Outputs: [] +#! +#! Where: +#! - payment_faucet_id_{suffix,prefix} identify the faucet issuing the payment asset. +#! - payment_amount is the amount to pay; a zero amount creates no note. +#! +#! Panics if: +#! - the account vault holds less of the payment asset than the amount to be paid. +#! - the maximum number of output notes is exceeded. +#! +#! Invocation: exec +pub proc pay_estimated_fee(payment_faucet_id: AccountId, payment_amount: felt) + dup.2 eq.0 + if.true + # a zero payment requires no fee note + drop drop drop + # => [] + else + exec.fungible_asset::create + # => [ASSET_ID, ASSET_VALUE] + + exec.create_and_fund_fee_note + # => [] + end +end + #! Computes the transaction fee and pays it by creating and funding a public TX_FEE note. #! -#! This is the fee payment flow for all account types: the caller supplies the decoded -#! conversion info and invokes this procedure from the authentication procedure BEFORE any -#! transaction summary is created, so that the TX_FEE note and the vault withdrawal funding -#! it are covered by a signature where one exists. Signature-based components (e.g. singlesig, -#! multisig) obtain the conversion info from the auth args via load_conversion_info, so the -#! signer authorizes the payment asset and rate; network accounts pay in the native fee asset -#! and obtain the conversion info via native_conversion_info, which needs no commitment since -#! it is read from the reference block. +#! This is the unbounded fee payment flow: the caller supplies the decoded conversion info and +#! invokes this procedure from the authentication procedure BEFORE any transaction summary is +#! created, so that the TX_FEE note and the vault withdrawal funding it are covered by a signature +#! where one exists. Signature-based components (e.g. singlesig, multisig) obtain the conversion +#! info from the auth args via load_conversion_info, so the signer authorizes the payment asset and +#! rate; network accounts pay in the native fee asset and obtain the conversion info via +#! native_conversion_info, which needs no commitment since it is read from the reference block. +#! +#! A component whose authentication can be satisfied by less authority than an ordinary note +#! transfer would need must not use this procedure, since the rate is host-supplied and unbounded +#! here. Such a component composes this procedure's steps itself, inserting assert_fee_bound +#! between resolve_payment_info and pay_estimated_fee. #! #! The fee is computed by estimate_fee, which prices the network output notes once and records the #! prices in this procedure's local memory. The payment then settles both of the transaction's fee @@ -496,11 +591,8 @@ end #! - total_sponsored_fee_amount is the total amount moved into the sponsorship notes. #! #! Panics if: -#! - estimate_fee or fees::create_network_note_sponsorships panics. -#! - the computed fee is non-zero and CONVERSION_INFO is the empty word. -#! - the conversion rate is malformed or the converted amount overflows. -#! - the account vault holds less of the payment asset than the amount to be paid. -#! - the maximum number of output notes is exceeded. +#! - estimate_fee, fees::create_network_note_sponsorships, resolve_payment_info or +#! pay_estimated_fee panics. #! #! Invocation: exec @locals(4096) @@ -530,39 +622,10 @@ pub proc pay_fee(num_extra_cycles: felt, conversion_info: ConversionInfo) -> fel movdn.5 # => [fee_amount, CONVERSION_INFO, total_sponsored_fee_amount] - dup eq.0 - if.true - # a zero fee requires no fee note - drop dropw - # => [total_sponsored_fee_amount] - else - movdn.4 - # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] - - # a non-zero fee requires committed conversion info - exec.word::testz assertz.err=ERR_FEE_CONVERSION_INFO_MISSING - # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] - - # convert the fee amount into the payment asset's amount - # (CONVERSION_INFO reads [faucet_id_suffix, faucet_id_prefix, rate_num, rate_den]) - movup.2 movup.3 movup.4 - # => [fee_amount, rate_den, rate_num, faucet_id_suffix, faucet_id_prefix, - # total_sponsored_fee_amount] + exec.resolve_payment_info + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, + # total_sponsored_fee_amount] - movup.2 swap - # => [fee_amount, rate_num, rate_den, faucet_id_suffix, faucet_id_prefix, - # total_sponsored_fee_amount] - - exec.convert_amount - # => [payment_amount, faucet_id_suffix, faucet_id_prefix, total_sponsored_fee_amount] - - movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, payment_amount, total_sponsored_fee_amount] - - exec.fungible_asset::create - # => [ASSET_ID, ASSET_VALUE, total_sponsored_fee_amount] - - exec.create_and_fund_fee_note - # => [total_sponsored_fee_amount] - end + exec.pay_estimated_fee + # => [total_sponsored_fee_amount] end diff --git a/crates/miden-standards/src/account/auth/fee.rs b/crates/miden-standards/src/account/auth/fee.rs index 721f06eef8..2b20eeeb29 100644 --- a/crates/miden-standards/src/account/auth/fee.rs +++ b/crates/miden-standards/src/account/auth/fee.rs @@ -14,6 +14,11 @@ use miden_protocol::{Felt, Hasher, Word}; /// `pay_fee` pays `ceil(fee_amount * rate_num / rate_den)` of the asset issued by `faucet_id`. /// To pay in an asset 1-to-1 (e.g. the native fee asset itself), use [`Self::one_to_one`]. /// +/// Components whose authorization can fall below the account's full spending quorum bound what +/// they accept here, since the rate reaches the VM from the host: the guarded and smart multisig +/// components require `faucet_id` to be the native fee faucet and cap the paid amount at twice the +/// computed fee, aborting the transaction otherwise. +/// /// For signature-based authentication components the conversion info is typically committed to /// via the transaction's auth args (see [`commit_fee_conversion_info`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/miden-standards/src/account/auth/guarded_multisig.rs b/crates/miden-standards/src/account/auth/guarded_multisig.rs index 837b6b14f5..f17dda7939 100644 --- a/crates/miden-standards/src/account/auth/guarded_multisig.rs +++ b/crates/miden-standards/src/account/auth/guarded_multisig.rs @@ -203,6 +203,24 @@ impl AuthGuardedMultisigConfig { /// The transaction's auth args are the commitment to /// [`MultisigAuthArgs`](crate::account::auth::MultisigAuthArgs). /// +/// # Fees +/// +/// Before authenticating, `auth_tx_guarded_multisig` pays the transaction fee by creating a public +/// TX_FEE note (see [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, in the +/// asset and at the rate of the auth args' [`FeeConversionInfo`](super::FeeConversionInfo). On +/// chains with a zero verification base fee no note is created. The fee note is created before the +/// transaction summary, so the approver and guardian signatures cover it. +/// +/// Guardian key rotation authenticates without a guardian signature and can be thresholded below +/// the account's spending quorum, while the conversion rate is host-supplied. The payment is +/// therefore bounded (`fee::assert_fee_bound`): it must be in the native fee asset and at most +/// twice the computed fee, so a rotation can at most overpay the fee, not move arbitrary value out +/// of the account. +/// +/// Rotation forbids input notes and any output notes beyond the ones the fee payment creates, so +/// the vault must already hold enough of the native fee asset to fund the fee: a lost guardian key +/// combined with an unfunded vault cannot be recovered. +/// /// # Privacy /// /// Approvers and the guardian using [`AuthScheme::EcdsaK256Keccak`][scheme] disclose their public diff --git a/crates/miden-standards/src/account/auth/multisig_smart/component.rs b/crates/miden-standards/src/account/auth/multisig_smart/component.rs index 6950b226dc..576bb78ea8 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/component.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/component.rs @@ -147,6 +147,25 @@ fn validate_proc_policies( /// /// The transaction's auth args are the commitment to /// [`MultisigAuthArgs`](crate::account::auth::MultisigAuthArgs). +/// +/// # Fees +/// +/// Before authenticating, `auth_tx_multisig_smart` pays the transaction fee by creating a public +/// TX_FEE note (see [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, in the +/// asset and at the rate of the auth args' +/// [`FeeConversionInfo`](crate::account::auth::FeeConversionInfo). On chains with a zero +/// verification base fee no note is created. The fee note is created before the transaction +/// summary, so the approver signatures cover it. Every network output note is also sponsored +/// through its target's fee policy, which requires that target to be provisioned as a foreign +/// account. +/// +/// The notes the authentication procedure creates to pay the fee do not count against a +/// [`ProcedurePolicyNoteRestriction`](super::ProcedurePolicyNoteRestriction), so +/// [`NoOutputNotes`](super::ProcedurePolicyNoteRestriction::NoOutputNotes) stays satisfiable on a +/// fee-charging chain. Because a per-procedure policy can authorize a transaction below the +/// account's default threshold while the conversion rate is host-supplied, the payment is bounded +/// (`fee::assert_fee_bound`): it must be in the native fee asset and at most twice the computed +/// fee. #[derive(Debug)] pub struct AuthMultisigSmart { config: AuthMultisigSmartConfig, diff --git a/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs index d8a1ff05dc..2b63692786 100644 --- a/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs +++ b/crates/miden-standards/src/account/auth/multisig_smart/procedure_policies.rs @@ -22,6 +22,11 @@ pub enum ProcedurePolicyExecutionMode { /// Note Restrictions on whether transactions that call a procedure may consume input notes /// or create output notes. +/// +/// The output note restrictions count only the transaction's own notes; the notes the +/// authentication procedure creates to pay the transaction fee are exempt, so a restriction stays +/// satisfiable on a fee-charging chain. See the `# Fees` section on +/// [`AuthMultisigSmart`](super::AuthMultisigSmart). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[repr(u8)] pub enum ProcedurePolicyNoteRestriction { @@ -39,7 +44,8 @@ pub enum ProcedurePolicyNoteRestriction { /// - 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 +/// notes or create output notes of its own; the notes the authentication procedure creates to pay +/// the fee are exempt, see [`ProcedurePolicyNoteRestriction`] /// /// Execution modes: /// - Immediate execution: the action is authorized and executed within the current transaction. diff --git a/crates/miden-testing/tests/auth/fee_payment/guarded_multisig.rs b/crates/miden-testing/tests/auth/fee_payment/guarded_multisig.rs new file mode 100644 index 0000000000..7bde84b41e --- /dev/null +++ b/crates/miden-testing/tests/auth/fee_payment/guarded_multisig.rs @@ -0,0 +1,475 @@ +use core::iter; + +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::Word; +use miden_protocol::account::auth::{AuthScheme, AuthSecretKey, PublicKey}; +use miden_protocol::account::{Account, AccountProcedureRoot, StorageMapKey}; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::errors::tx_kernel::ERR_VAULT_FUNGIBLE_ASSET_AMOUNT_LESS_THAN_AMOUNT_TO_WITHDRAW; +use miden_protocol::note::{ + Note, + NoteAssets, + NoteRecipient, + NoteStorage, + NoteTag, + NoteType, + PartialNote, + PartialNoteMetadata, +}; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_FEE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, +}; +use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote, TransactionScript}; +use miden_standards::account::auth::{ + Approver, + ApproverSet, + AuthGuardedMultisig, + FeeConversionInfo, + GuardianConfig, + MultisigAuthArgs, + SponsorshipPolicy, +}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::{ + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, + ERR_FEE_PAYMENT_ASSET_NOT_NATIVE, + ERR_FEE_PAYMENT_EXCEEDS_BOUND, +}; +use miden_standards::note::{FeeSponsorshipNote, P2idNote, TxFeeNote}; +use miden_standards::tx_script::SendNotesTransactionScript; +use miden_testing::{Auth, MockChain, MockTransactionBuilder, assert_transaction_executor_error}; +use miden_tx::auth::BasicAuthenticator; +use rstest::rstest; + +use super::super::guarded_multisig::build_update_guardian_script_source; +use super::super::multisig::MultisigAuthArgsExt; +use super::multisig::{ + fee_paying_auth_args, + multisig_auth_estimate, + multisig_fixture, + sign_with_all, +}; +use super::sponsorship::{FEE_AMOUNT, fee_asset, network_account, p2id_network_note}; +use super::{VERIFICATION_BASE_FEE, assert_single_fee_note}; + +// CONSTANTS +// ================================================================================================ + +/// Amount of the fee asset the fixtures fund the account with. +const FEE_ASSET_AMOUNT: u64 = 1_000_000; + +// HELPER FUNCTIONS +// ================================================================================================ + +/// A guarded multisig fixture: `num_approvers` Falcon approvers with the threshold set to all of +/// them, plus a separate guardian using `guardian_scheme`. +struct GuardedFixture { + approver_set: ApproverSet, + guardian_config: GuardianConfig, + approvers: Vec<(PublicKey, BasicAuthenticator)>, + guardian: (PublicKey, BasicAuthenticator), +} + +impl GuardedFixture { + fn new(num_approvers: usize, guardian_scheme: AuthScheme) -> anyhow::Result { + let (approver_set, approvers) = + multisig_fixture(num_approvers, num_approvers, AuthScheme::Falcon512Poseidon2)?; + + let guardian_secret_key = match guardian_scheme { + AuthScheme::EcdsaK256Keccak => AuthSecretKey::new_ecdsa_k256_keccak(), + AuthScheme::Falcon512Poseidon2 => AuthSecretKey::new_falcon512_poseidon2(), + _ => anyhow::bail!("unsupported guardian auth scheme: {guardian_scheme:?}"), + }; + let guardian_public_key = guardian_secret_key.public_key(); + let guardian_authenticator = + BasicAuthenticator::new(core::slice::from_ref(&guardian_secret_key)); + let guardian_config = GuardianConfig::new(Approver::new( + guardian_public_key.to_commitment(), + guardian_scheme, + )); + + Ok(Self { + approver_set, + guardian_config, + approvers, + guardian: (guardian_public_key, guardian_authenticator), + }) + } + + fn auth(&self, proc_threshold_map: Vec<(AccountProcedureRoot, u32)>) -> Auth { + Auth::GuardedMultisig { + approver_set: self.approver_set.clone(), + guardian_config: self.guardian_config, + proc_threshold_map, + } + } + + /// The approvers followed by the guardian. + fn all_signers(&self) -> impl Iterator { + self.approvers.iter().chain(iter::once(&self.guardian)) + } +} + +/// Executes an empty transaction against a funded guarded multisig wallet on a fee-charging chain, +/// signed by every approver and the guardian. +async fn execute_fee_paying_guarded_multisig_tx( + num_approvers: usize, + guardian_scheme: AuthScheme, +) -> anyhow::Result { + let fixture = GuardedFixture::new(num_approvers, guardian_scheme)?; + + let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE); + let account = builder + .add_existing_wallet_with_assets(fixture.auth(vec![]), [fee_asset(FEE_ASSET_AMOUNT)?])?; + let mock_chain = builder.build()?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([9u32, 10, 11, 12]))?; + let mock_tx_builder = mock_chain.build_transaction(account.id()).multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, fixture.all_signers()).await?; + + Ok(signed_builder.build()?.execute().await?) +} + +/// A guardian key rotation against a guarded multisig account on a fee-charging chain. +struct RotationSetup { + mock_chain: MockChain, + account: Account, + fixture: GuardedFixture, + new_guardian_key: PublicKey, + /// A no-op output note the rotation script creates first, when the test asks for one. + output_note: Option, + script: TransactionScript, +} + +impl RotationSetup { + /// Sets up a guarded multisig account holding `vault_assets` and a tx script rotating its + /// guardian key to a fresh Falcon key, after creating a no-op output note if asked to. + fn new( + vault_assets: Vec, + proc_threshold_map: Vec<(AccountProcedureRoot, u32)>, + include_output_note: bool, + ) -> anyhow::Result { + let fixture = GuardedFixture::new(2, AuthScheme::EcdsaK256Keccak)?; + + let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE); + let account = builder + .add_existing_wallet_with_assets(fixture.auth(proc_threshold_map), vault_assets)?; + let mock_chain = builder.build()?; + + let output_note = include_output_note + .then(|| -> anyhow::Result { + let recipient = NoteRecipient::new( + Word::from([1u32, 2, 3, 4]), + CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?, + NoteStorage::default(), + ); + Ok(Note::new( + NoteAssets::new(vec![])?, + PartialNoteMetadata::new(account.id(), NoteType::Public), + recipient, + )) + }) + .transpose()?; + + let new_guardian_key = AuthSecretKey::new_falcon512_poseidon2().public_key(); + let script = CodeBuilder::new() + .with_dynamically_linked_package(AuthGuardedMultisig::code())? + .compile_tx_script(build_update_guardian_script_source( + new_guardian_key.to_commitment().into(), + AuthScheme::Falcon512Poseidon2 as u32, + output_note.as_ref(), + ))?; + + Ok(Self { + mock_chain, + account, + fixture, + new_guardian_key, + output_note, + script, + }) + } + + /// Builds the rotation transaction, paying the fee as `conversion_info` says. + fn transaction( + &self, + conversion_info: FeeConversionInfo, + salt: Word, + ) -> anyhow::Result> { + let auth_args = + MultisigAuthArgs::new(self.mock_chain.latest_block_header().block_num(), salt) + .with_conversion_info(conversion_info); + + let mut mock_tx_builder = self + .mock_chain + .build_transaction(self.account.id()) + .tx_script(self.script.clone()) + .multisig_auth_args(auth_args); + if let Some(note) = &self.output_note { + mock_tx_builder = + mock_tx_builder.expected_output_note(RawOutputNote::Full(note.clone())); + } + + Ok(mock_tx_builder) + } +} + +// TESTS +// ================================================================================================ + +/// The guarded multisig auth procedure pays the transaction fee, within the cycle estimate it +/// hands to the fee flow. +/// +/// The estimate counts one signer more than there are approvers, for the guardian. Only the +/// `single_falcon_approver` case straddles a fee bucket edge, so it alone under-pays if the extra +/// slot is dropped. +#[rstest] +#[case::ecdsa_guardian(AuthScheme::EcdsaK256Keccak, 2)] +#[case::falcon_guardian(AuthScheme::Falcon512Poseidon2, 2)] +#[case::single_falcon_approver(AuthScheme::Falcon512Poseidon2, 1)] +#[tokio::test] +async fn guarded_multisig_pays_fee_note_within_the_cycle_estimate( + #[case] guardian_scheme: AuthScheme, + #[case] num_approvers: usize, +) -> anyhow::Result<()> { + let executed_transaction = + execute_fee_paying_guarded_multisig_tx(num_approvers, guardian_scheme).await?; + + let fee_asset = assert_single_fee_note(&executed_transaction)?; + + // the overshoot is bounded: the estimate should not overpay by more than a few base fee units + let required_fee = executed_transaction.compute_fee(); + let max_overpayment = u64::from(3 * VERIFICATION_BASE_FEE); + assert!( + fee_asset.amount().as_u64() <= required_fee.as_u64() + max_overpayment, + "paid fee {} should not exceed the required fee {required_fee} by more than \ + {max_overpayment}", + fee_asset.amount() + ); + + let auth_estimate = multisig_auth_estimate(num_approvers + 1); + let measurements = executed_transaction.measurements(); + assert!( + measurements.auth_procedure <= auth_estimate, + "guarded multisig auth took {} cycles, exceeding the estimate of {auth_estimate}", + measurements.auth_procedure, + ); + + Ok(()) +} + +/// Guardian key rotation works on a fee-charging chain, since the rotation path excludes the +/// notes the fee payment creates from its no-output-notes check, and still rejects a transaction +/// creating an output note of its own. +#[rstest] +#[case::without_output_note(false)] +#[case::with_output_note(true)] +#[tokio::test] +async fn guarded_multisig_rotates_guardian_key_while_paying_the_fee( + #[case] include_output_note: bool, +) -> anyhow::Result<()> { + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let setup = + RotationSetup::new(vec![fee_asset(FEE_ASSET_AMOUNT)?], vec![], include_output_note)?; + + let mock_tx_builder = setup.transaction( + FeeConversionInfo::one_to_one(fee_faucet_id), + Word::from([21u32, 22, 23, 24]), + )?; + // rotation intentionally skips the guardian signature + let signed_builder = sign_with_all(mock_tx_builder, &setup.fixture.approvers).await?; + let result = signed_builder.build()?.execute().await; + + if include_output_note { + assert_transaction_executor_error!( + result, + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES + ); + return Ok(()); + } + + let executed_transaction = result?; + assert_single_fee_note(&executed_transaction)?; + + // the new guardian key landed in storage + let mut rotated_account = setup.account.clone(); + rotated_account.apply_patch(executed_transaction.account_patch())?; + assert_eq!( + rotated_account.storage().get_map_item( + AuthGuardedMultisig::guardian_public_key_slot(), + StorageMapKey::empty() + )?, + Word::from(setup.new_guardian_key.to_commitment()) + ); + assert_eq!( + rotated_account + .storage() + .get_map_item(AuthGuardedMultisig::guardian_scheme_id_slot(), StorageMapKey::empty())?, + Word::from([AuthScheme::Falcon512Poseidon2 as u32, 0, 0, 0]) + ); + + Ok(()) +} + +/// Guardian key rotation cannot outrun the fee: an unfunded vault fails on the withdrawal. Since +/// rotation forbids input notes, funding the vault takes a separate transaction, which needs a +/// guardian signature. +#[tokio::test] +async fn guarded_multisig_rotation_fails_when_the_vault_cannot_fund_the_fee() -> anyhow::Result<()> +{ + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let setup = RotationSetup::new(vec![], vec![], false)?; + + let result = setup + .transaction(FeeConversionInfo::one_to_one(fee_faucet_id), Word::from([31u32, 32, 33, 34]))? + .build()? + .execute() + .await; + + assert_transaction_executor_error!( + result, + ERR_VAULT_FUNGIBLE_ASSET_AMOUNT_LESS_THAN_AMOUNT_TO_WITHDRAW + ); + + Ok(()) +} + +/// A reduced-quorum guardian rotation cannot drain the vault through an inflated fee conversion +/// rate: the payment is bounded before the summary is created, so the transaction aborts before +/// any signature is verified. +#[tokio::test] +async fn guarded_multisig_rotation_cannot_drain_the_vault_via_the_fee_rate() -> anyhow::Result<()> { + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + + let update_guardian_root = AuthGuardedMultisig::code() + .get_procedure_root_by_path( + "miden::standards::components::auth::guarded_multisig::update_guardian_public_key", + ) + .expect("guarded multisig should export update_guardian_public_key"); + let setup = RotationSetup::new( + vec![fee_asset(FEE_ASSET_AMOUNT)?], + vec![(update_guardian_root, 1)], + false, + )?; + + let result = setup + .transaction( + FeeConversionInfo::new(fee_faucet_id, 1_000_000, 1)?, + Word::from([81u32, 82, 83, 84]), + )? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_FEE_PAYMENT_EXCEEDS_BOUND); + + Ok(()) +} + +/// The same path cannot pay the fee in a foreign asset either: the bound is only meaningful +/// against the native fee asset, so the payment asset is pinned to it. +#[tokio::test] +async fn guarded_multisig_rotation_cannot_pay_the_fee_in_a_foreign_asset() -> anyhow::Result<()> { + let payment_faucet_id = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into()?; + let setup = RotationSetup::new(vec![fee_asset(FEE_ASSET_AMOUNT)?], vec![], false)?; + + let result = setup + .transaction( + FeeConversionInfo::one_to_one(payment_faucet_id), + Word::from([91u32, 92, 93, 94]), + )? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_FEE_PAYMENT_ASSET_NOT_NATIVE); + + Ok(()) +} + +/// A guarded multisig that creates a network output note sponsors it, funding a FEE_SPONSORSHIP +/// note from its own vault alongside its TX_FEE note. +#[tokio::test] +async fn guarded_multisig_sponsors_its_network_output_note() -> anyhow::Result<()> { + let mut rng = RandomCoin::new(Word::from([81u32, 82, 83, 84])); + // the payload the network note carries, issued by a faucet other than the fee faucet so the + // sponsorship's fee-asset funding is unambiguous + let payload_asset: Asset = FungibleAsset::mock(50); + + let fixture = GuardedFixture::new(2, AuthScheme::EcdsaK256Keccak)?; + + let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE); + let account = builder.add_existing_wallet_with_assets( + fixture.auth(vec![]), + [fee_asset(FEE_ASSET_AMOUNT)?, payload_asset], + )?; + + // the target network account prices the P2ID script root, which is what the sponsorship pays + let target = network_account( + [4; 32], + [P2idNote::script_root(), FeeSponsorshipNote::script_root()], + &[(P2idNote::script_root(), FEE_AMOUNT)], + [], + SponsorshipPolicy::default(), + )?; + builder.add_account(target.clone())?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let network_note = p2id_network_note(account.id(), target.id(), payload_asset, &mut rng)?; + let send_notes_script = SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(network_note.clone())], + )?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([85u32, 86, 87, 88]))?; + let foreign_target = mock_chain.get_foreign_account_inputs(target.id())?; + let mock_tx_builder = mock_chain + .build_transaction(account.id()) + .foreign_accounts([foreign_target]) + .expected_output_note(RawOutputNote::Full(network_note.clone())) + .send_notes_script(&send_notes_script) + .multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, fixture.all_signers()).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + // the network note, its sponsorship note and the account's own fee note + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 3); + + let sponsorship = output_notes + .iter() + .find(|note| { + note.recipient().is_some_and(|recipient| { + recipient.script().root() == FeeSponsorshipNote::script_root() + }) + }) + .expect("the guarded multisig should sponsor the network note it created"); + let sponsorship_assets: Vec = sponsorship.assets().iter().copied().collect(); + assert_eq!(sponsorship_assets, vec![fee_asset(FEE_AMOUNT)?]); + assert_eq!(sponsorship.metadata().tag(), NoteTag::with_account_target(target.id())); + + // the account still pays its own fee, and it still covers what the transaction cost + let fee_note = output_notes + .iter() + .find(|note| note.metadata().tag() == TxFeeNote::TAG) + .expect("the guarded multisig should pay its own fee note"); + let paid = fee_note + .assets() + .iter() + .next() + .expect("the fee note should carry an asset") + .unwrap_fungible(); + assert!( + paid.amount() >= executed_transaction.compute_fee(), + "paid fee {} should cover the required fee {}", + paid.amount(), + executed_transaction.compute_fee(), + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/fee_payment/mod.rs b/crates/miden-testing/tests/auth/fee_payment/mod.rs index 09f3d9c652..07b9ec5c7c 100644 --- a/crates/miden-testing/tests/auth/fee_payment/mod.rs +++ b/crates/miden-testing/tests/auth/fee_payment/mod.rs @@ -5,7 +5,9 @@ use miden_protocol::transaction::ExecutedTransaction; use miden_standards::note::TxFeeNote; mod bound; +mod guarded_multisig; mod multisig; +mod multisig_smart; mod network; mod no_auth; mod singlesig; diff --git a/crates/miden-testing/tests/auth/fee_payment/multisig.rs b/crates/miden-testing/tests/auth/fee_payment/multisig.rs index 1d0e93cc15..58a0ffe6e4 100644 --- a/crates/miden-testing/tests/auth/fee_payment/multisig.rs +++ b/crates/miden-testing/tests/auth/fee_payment/multisig.rs @@ -4,7 +4,7 @@ use miden_protocol::testing::account_id::ACCOUNT_ID_FEE_FAUCET; use miden_protocol::transaction::{ExecutedTransaction, TransactionSummary}; use miden_protocol::{Word, ZERO}; use miden_standards::account::auth::{Approver, ApproverSet, FeeConversionInfo, MultisigAuthArgs}; -use miden_testing::{Auth, MockChain}; +use miden_testing::{Auth, MockChain, MockTransactionBuilder}; use miden_tx::TransactionExecutorError; use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; use rstest::rstest; @@ -24,13 +24,13 @@ use super::{ /// The cycle estimate the multisig auth component passes to `pay_fee` for the given number of /// signers, plus pay_fee's own tail margin. Used as the upper bound for the measured auth /// procedure cycles. -fn multisig_auth_estimate(num_signers: usize) -> usize { +pub(super) fn multisig_auth_estimate(num_signers: usize) -> usize { num_signers * FALCON_512_POSEIDON2_AUTH_CYCLES + MULTISIG_AUTH_BASE_CYCLES + PAY_FEE_CYCLES } /// Builds an [`ApproverSet`] of `num_approvers` signers of the given scheme with the given /// threshold, along with the (public key, authenticator) pairs of the first `threshold` signers. -fn multisig_fixture( +pub(super) fn multisig_fixture( num_approvers: usize, threshold: usize, auth_scheme: AuthScheme, @@ -61,13 +61,43 @@ fn assert_salt_bound_as_user_params(tx_summary: &TransactionSummary, salt: Word) /// Builds the auth args of a fee-paying multisig transaction: the given salt and a one-to-one /// conversion of the fee asset, bound to the chain's latest block. -fn fee_paying_auth_args(mock_chain: &MockChain, salt: Word) -> anyhow::Result { +pub(super) fn fee_paying_auth_args( + mock_chain: &MockChain, + salt: Word, +) -> anyhow::Result { let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; Ok(MultisigAuthArgs::new(mock_chain.latest_block_header().block_num(), salt) .with_conversion_info(FeeConversionInfo::one_to_one(fee_faucet_id))) } +/// Executes the transaction once unsigned to obtain the summary the signers must sign, then adds +/// every signer's signature over it to the builder. +pub(super) async fn sign_with_all<'a, 's>( + mock_tx_builder: MockTransactionBuilder<'a>, + signers: impl IntoIterator, +) -> anyhow::Result> { + let tx_summary = mock_tx_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + let msg = tx_summary.as_ref().to_commitment(); + let signing_inputs = SigningInputs::TransactionSummary(tx_summary); + + let mut signed_builder = mock_tx_builder; + for (public_key, authenticator) in signers { + let signature = + authenticator.get_signature(public_key.to_commitment(), &signing_inputs).await?; + signed_builder = signed_builder.add_signature(public_key.to_commitment(), msg, signature); + } + + Ok(signed_builder) +} + /// Executes an empty transaction against a wallet with the multisig auth component on a /// fee-charging mock chain: runs once without signatures to obtain the transaction summary, /// asserts the salt is bound as the trailing word of the summary's user params, signs the diff --git a/crates/miden-testing/tests/auth/fee_payment/multisig_smart.rs b/crates/miden-testing/tests/auth/fee_payment/multisig_smart.rs new file mode 100644 index 0000000000..65964c5663 --- /dev/null +++ b/crates/miden-testing/tests/auth/fee_payment/multisig_smart.rs @@ -0,0 +1,492 @@ +use miden_processor::crypto::random::RandomCoin; +use miden_protocol::Word; +use miden_protocol::account::Account; +use miden_protocol::account::auth::{AuthScheme, PublicKey}; +use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::note::{Note, NoteTag, NoteType, PartialNote}; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_FEE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, + ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, +}; +use miden_protocol::transaction::{ExecutedTransaction, RawOutputNote}; +use miden_standards::account::auth::multisig_smart::{ + ProcedurePolicy, + ProcedurePolicyNoteRestriction, +}; +use miden_standards::account::auth::{FeeConversionInfo, MultisigAuthArgs, SponsorshipPolicy}; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::errors::standards::{ + ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES, + ERR_FEE_CONVERSION_INFO_MISSING, + ERR_FEE_PAYMENT_ASSET_NOT_NATIVE, + ERR_FEE_PAYMENT_EXCEEDS_BOUND, +}; +use miden_standards::note::{FeeSponsorshipNote, P2idNote, TxFeeNote}; +use miden_standards::tx_script::SendNotesTransactionScript; +use miden_testing::{Auth, MockChain, MockChainBuilder, assert_transaction_executor_error}; +use miden_tx::auth::BasicAuthenticator; +use rstest::rstest; + +use super::super::multisig::MultisigAuthArgsExt; +use super::multisig::{ + fee_paying_auth_args, + multisig_auth_estimate, + multisig_fixture, + sign_with_all, +}; +use super::sponsorship::{FEE_AMOUNT, fee_asset, network_account, p2id_network_note}; +use super::{VERIFICATION_BASE_FEE, assert_single_fee_note}; + +// CONSTANTS +// ================================================================================================ + +/// Amount of the fee asset the fixture funds the account with. +const FEE_ASSET_AMOUNT: u64 = 1_000_000; + +// HELPER FUNCTIONS +// ================================================================================================ + +/// A smart multisig wallet funded with the fee asset, with the keys needed to sign for it. +/// +/// The chain is left unbuilt so a test can add input notes to it; call `builder.build()` to finish. +struct MultisigSmartFixture { + builder: MockChainBuilder, + account: Account, + signers: Vec<(PublicKey, BasicAuthenticator)>, +} + +/// Builds a wallet with the smart multisig auth component and the given procedure policies, on a +/// mock chain charging `verification_base_fee`, funded with enough of the fee asset to pay the fee. +/// +/// The approver set is `num_approvers` Falcon signers with the threshold set to the full set. +fn multisig_smart_fixture( + num_approvers: usize, + verification_base_fee: u32, + proc_policy_map: Vec<(Word, ProcedurePolicy)>, +) -> anyhow::Result { + let (approver_set, signers) = + multisig_fixture(num_approvers, num_approvers, AuthScheme::Falcon512Poseidon2)?; + + let mut builder = MockChain::builder().verification_base_fee(verification_base_fee); + let account = builder.add_existing_wallet_with_assets( + Auth::MultisigSmart { approver_set, proc_policy_map }, + [fee_asset(FEE_ASSET_AMOUNT)?], + )?; + + Ok(MultisigSmartFixture { builder, account, signers }) +} + +/// Executes an empty transaction against a wallet with the multisig smart auth component on a +/// fee-charging mock chain, signing the summary with every approver. +async fn execute_fee_paying_multisig_smart_tx( + num_approvers: usize, +) -> anyhow::Result { + let MultisigSmartFixture { builder, account, signers } = + multisig_smart_fixture(num_approvers, VERIFICATION_BASE_FEE, vec![])?; + let mock_chain = builder.build()?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([9u32, 10, 11, 12]))?; + let mock_tx_builder = mock_chain.build_transaction(account.id()).multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + Ok(signed_builder.build()?.execute().await?) +} + +// TESTS +// ================================================================================================ + +/// The multisig smart auth procedure pays the transaction fee, exactly as the plain multisig +/// component does. +#[tokio::test] +async fn multisig_smart_pays_fee_note() -> anyhow::Result<()> { + let executed_transaction = execute_fee_paying_multisig_smart_tx(2).await?; + + assert_single_fee_note(&executed_transaction)?; + + Ok(()) +} + +/// The cycle estimate the component passes to the fee flow stays an upper bound on the cycles +/// actually spent authenticating, and bills every approver. +#[tokio::test] +async fn multisig_smart_auth_cycles_stay_within_the_estimate() -> anyhow::Result<()> { + let num_approvers = 2; + let executed_transaction = execute_fee_paying_multisig_smart_tx(num_approvers).await?; + + let auth_estimate = multisig_auth_estimate(num_approvers); + let measured_cycles = executed_transaction.measurements().auth_procedure; + assert!( + measured_cycles <= auth_estimate, + "measured auth cycles {measured_cycles} should stay within the estimate {auth_estimate}", + ); + + // A fee floor for an estimate that bills one approver too few. The fee flow adds its own + // margins on top of the estimate, so the paid amount exceeding the floor shows every approver + // was billed. + let estimate_one_signer_short = multisig_auth_estimate(num_approvers - 1); + let fee_floor_one_signer_short = + u64::from(VERIFICATION_BASE_FEE) * u64::from(estimate_one_signer_short.ilog2() + 1); + + let fee_asset = assert_single_fee_note(&executed_transaction)?; + assert!( + fee_asset.amount().as_u64() > fee_floor_one_signer_short, + "paid fee {} should exceed the {fee_floor_one_signer_short} floor for an estimate billing \ + only {} of the {num_approvers} approvers", + fee_asset.amount().as_u64(), + num_approvers - 1, + ); + + Ok(()) +} + +/// A transaction with no note restrictions pays the fee and creates its own output note in the +/// same transaction. +#[tokio::test] +async fn multisig_smart_pays_fee_alongside_a_user_output_note() -> anyhow::Result<()> { + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let sent_asset = FungibleAsset::new(fee_faucet_id, 7)?; + + let MultisigSmartFixture { builder, account, signers } = + multisig_smart_fixture(2, VERIFICATION_BASE_FEE, vec![])?; + let mock_chain = builder.build()?; + + let output_note: Note = P2idNote::builder() + .sender(account.id()) + .target(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into()?) + .asset(sent_asset) + .note_type(NoteType::Public) + .generate_serial_number(&mut RandomCoin::new(Word::from([57u32, 58, 59, 60]))) + .build()? + .into(); + + let send_note_script = + SendNotesTransactionScript::new(&account.code_interface(), &[output_note.clone().into()])?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([61u32, 62, 63, 64]))?; + let mock_tx_builder = mock_chain + .build_transaction(account.id()) + .expected_output_note(RawOutputNote::Full(output_note)) + .send_notes_script(&send_note_script) + .multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + // both notes are present, and the fee still covers what the transaction actually cost + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 2); + let fee_note = output_notes + .iter() + .find(|note| note.metadata().tag() == TxFeeNote::TAG) + .expect("the transaction should create a fee note alongside the user's note"); + let paid = fee_note + .assets() + .iter() + .next() + .expect("the fee note should carry an asset") + .unwrap_fungible(); + assert!( + paid.amount() >= executed_transaction.compute_fee(), + "paid fee {} should cover the required fee {}", + paid.amount(), + executed_transaction.compute_fee(), + ); + + Ok(()) +} + +/// Omitting the conversion info on a fee-charging chain fails before any signature is verified. +#[tokio::test] +async fn multisig_smart_fee_payment_fails_without_conversion_info() -> anyhow::Result<()> { + let MultisigSmartFixture { builder, account, .. } = + multisig_smart_fixture(2, VERIFICATION_BASE_FEE, vec![])?; + let mock_chain = builder.build()?; + + let auth_args = MultisigAuthArgs::new( + mock_chain.latest_block_header().block_num(), + Word::from([49u32, 50, 51, 52]), + ); + let result = mock_chain + .build_transaction(account.id()) + .multisig_auth_args(auth_args) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_FEE_CONVERSION_INFO_MISSING); + + Ok(()) +} + +/// The fee payment is inside what the signers sign: signatures over the summary of a transaction +/// paying at rate 1/1 do not authorize the same transaction paying at rate 3/2, since the larger +/// payment changes the fee note and the vault withdrawal. Only the rate differs between the two +/// runs, and it stays inside the payment bound, so the replay fails on the signatures. +#[tokio::test] +async fn multisig_smart_fee_payment_is_covered_by_the_signatures() -> anyhow::Result<()> { + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + + let MultisigSmartFixture { builder, account, signers } = + multisig_smart_fixture(2, VERIFICATION_BASE_FEE, vec![])?; + let mock_chain = builder.build()?; + + let block_number = mock_chain.latest_block_header().block_num(); + let salt = Word::from([41u32, 42, 43, 44]); + let auth_args_at_rate = + |rate_num: u64, rate_den: u64| -> anyhow::Result { + Ok(MultisigAuthArgs::new(block_number, salt) + .with_conversion_info(FeeConversionInfo::new(fee_faucet_id, rate_num, rate_den)?)) + }; + + // sign the summary of the transaction that pays at rate 1/1 + let signed_builder = sign_with_all( + mock_chain + .build_transaction(account.id()) + .multisig_auth_args(auth_args_at_rate(1, 1)?), + &signers, + ) + .await?; + + // replay those signatures against the transaction that pays at rate 3/2 + signed_builder + .multisig_auth_args(auth_args_at_rate(3, 2)?) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + + Ok(()) +} + +/// On a chain with a zero verification base fee, no fee note is created and the caller need not +/// commit any conversion info. +#[tokio::test] +async fn multisig_smart_no_fee_note_on_zero_fee_chain() -> anyhow::Result<()> { + let MultisigSmartFixture { builder, account, signers } = multisig_smart_fixture(2, 0, vec![])?; + let mock_chain = builder.build()?; + + let auth_args = MultisigAuthArgs::new( + mock_chain.latest_block_header().block_num(), + Word::from([33u32, 34, 35, 36]), + ); + let mock_tx_builder = mock_chain.build_transaction(account.id()).multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + assert_eq!(executed_transaction.output_notes().num_notes(), 0); + assert!( + executed_transaction.account_patch().vault().is_empty(), + "a zero fee must leave the account vault untouched", + ); + + Ok(()) +} + +/// A procedure policy that forbids output notes still permits the transaction fee note: with no +/// user note, the live output-note count is non-zero only because of the fee note. +#[tokio::test] +async fn multisig_smart_honors_no_output_notes_policy_while_paying_the_fee() -> anyhow::Result<()> { + let no_output_notes_policy = ProcedurePolicy::with_immediate_threshold(1)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoOutputNotes); + + let MultisigSmartFixture { mut builder, account, signers } = multisig_smart_fixture( + 2, + VERIFICATION_BASE_FEE, + vec![(BasicWallet::receive_asset_root().as_word(), no_output_notes_policy)], + )?; + + // consuming a P2ID note invokes the policied `receive_asset` procedure without creating any + // output note of its own, so the TX_FEE note is the only output note the transaction has + let p2id_note = builder.add_p2id_note( + account.id(), + account.id(), + &[FungibleAsset::mock(1)], + NoteType::Public, + )?; + let mock_chain = builder.build()?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([17u32, 18, 19, 20]))?; + let mock_tx_builder = mock_chain + .build_transaction(account.id()) + .authenticated_input_note(p2id_note.id()) + .multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + assert_single_fee_note(&executed_transaction)?; + + Ok(()) +} + +/// A procedure policy that forbids output notes still rejects a transaction creating an output +/// note of its own on a fee-charging chain: tolerating the fee note does not extend to user notes. +#[tokio::test] +async fn multisig_smart_rejects_user_output_note_under_no_output_notes_policy() -> anyhow::Result<()> +{ + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let sent_asset = FungibleAsset::new(fee_faucet_id, 5)?; + + let no_output_notes_policy = ProcedurePolicy::with_immediate_threshold(1)? + .with_note_restriction(ProcedurePolicyNoteRestriction::NoOutputNotes); + + let MultisigSmartFixture { builder, account, .. } = multisig_smart_fixture( + 2, + VERIFICATION_BASE_FEE, + vec![(BasicWallet::move_asset_to_note_root().as_word(), no_output_notes_policy)], + )?; + let mock_chain = builder.build()?; + + let output_note: Note = P2idNote::builder() + .sender(account.id()) + .target(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE.try_into()?) + .asset(sent_asset) + .note_type(NoteType::Public) + .generate_serial_number(&mut RandomCoin::new(Word::from([21u32, 22, 23, 24]))) + .build()? + .into(); + + let send_note_script = + SendNotesTransactionScript::new(&account.code_interface(), &[output_note.clone().into()])?; + + // the policy check runs before signature verification, so an unsigned transaction surfaces + // the output-note error rather than an unauthorized error + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([25u32, 26, 27, 28]))?; + let result = mock_chain + .build_transaction(account.id()) + .expected_output_note(RawOutputNote::Full(output_note)) + .send_notes_script(&send_note_script) + .multisig_auth_args(auth_args) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES); + + Ok(()) +} + +/// The smart component bounds its fee payment, since a per-procedure policy can authorize a +/// transaction below the account's default threshold while the conversion rate is host-supplied: +/// an inflated rate in the native asset and a foreign asset at an acceptable rate both abort +/// before any signature is verified. +#[rstest] +#[case::inflated_rate( + FeeConversionInfo::new(ACCOUNT_ID_FEE_FAUCET.try_into().unwrap(), 1_000_000, 1).unwrap(), + ERR_FEE_PAYMENT_EXCEEDS_BOUND +)] +#[case::foreign_asset( + FeeConversionInfo::one_to_one(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap()), + ERR_FEE_PAYMENT_ASSET_NOT_NATIVE +)] +#[tokio::test] +async fn multisig_smart_cannot_drain_the_vault_via_the_fee_payment( + #[case] conversion_info: FeeConversionInfo, + #[case] expected_error: miden_protocol::errors::MasmError, +) -> anyhow::Result<()> { + let drain_policy = ProcedurePolicy::with_immediate_threshold(1)?; + let MultisigSmartFixture { builder, account, .. } = multisig_smart_fixture( + 2, + VERIFICATION_BASE_FEE, + vec![(BasicWallet::receive_asset_root().as_word(), drain_policy)], + )?; + let mock_chain = builder.build()?; + + let auth_args = MultisigAuthArgs::new( + mock_chain.latest_block_header().block_num(), + Word::from([81u32, 82, 83, 84]), + ) + .with_conversion_info(conversion_info); + let result = mock_chain + .build_transaction(account.id()) + .multisig_auth_args(auth_args) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, expected_error); + + Ok(()) +} + +/// A smart multisig that creates a network output note sponsors it, funding a FEE_SPONSORSHIP +/// note from its own vault alongside its TX_FEE note. +#[tokio::test] +async fn multisig_smart_sponsors_its_network_output_note() -> anyhow::Result<()> { + let mut rng = RandomCoin::new(Word::from([91u32, 92, 93, 94])); + // the fixture funds the account with the fee asset only, so the network note carries some of it + let payload_asset = fee_asset(7)?; + + let MultisigSmartFixture { mut builder, account, signers } = + multisig_smart_fixture(2, VERIFICATION_BASE_FEE, vec![])?; + + // the target network account prices the P2ID script root, which is what the sponsorship pays + let target = network_account( + [5; 32], + [P2idNote::script_root(), FeeSponsorshipNote::script_root()], + &[(P2idNote::script_root(), FEE_AMOUNT)], + [], + SponsorshipPolicy::default(), + )?; + builder.add_account(target.clone())?; + + let mut mock_chain = builder.build()?; + mock_chain.prove_next_block()?; + + let network_note = p2id_network_note(account.id(), target.id(), payload_asset, &mut rng)?; + let send_notes_script = SendNotesTransactionScript::new( + &account.code_interface(), + &[PartialNote::from(network_note.clone())], + )?; + + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([95u32, 96, 97, 98]))?; + let foreign_target = mock_chain.get_foreign_account_inputs(target.id())?; + let mock_tx_builder = mock_chain + .build_transaction(account.id()) + .foreign_accounts([foreign_target]) + .expected_output_note(RawOutputNote::Full(network_note.clone())) + .send_notes_script(&send_notes_script) + .multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + // the network note, its sponsorship note and the account's own fee note + let output_notes = executed_transaction.output_notes(); + assert_eq!(output_notes.num_notes(), 3); + + let sponsorship = output_notes + .iter() + .find(|note| { + note.recipient().is_some_and(|recipient| { + recipient.script().root() == FeeSponsorshipNote::script_root() + }) + }) + .expect("the smart multisig should sponsor the network note it created"); + let sponsorship_assets: Vec = sponsorship.assets().iter().copied().collect(); + assert_eq!(sponsorship_assets, vec![fee_asset(FEE_AMOUNT)?]); + assert_eq!(sponsorship.metadata().tag(), NoteTag::with_account_target(target.id())); + + // the account still pays its own fee, and it still covers what the transaction cost + let fee_note = output_notes + .iter() + .find(|note| note.metadata().tag() == TxFeeNote::TAG) + .expect("the smart multisig should pay its own fee note"); + let paid = fee_note + .assets() + .iter() + .next() + .expect("the fee note should carry an asset") + .unwrap_fungible(); + assert!( + paid.amount() >= executed_transaction.compute_fee(), + "paid fee {} should cover the required fee {}", + paid.amount(), + executed_transaction.compute_fee(), + ); + + Ok(()) +} diff --git a/crates/miden-testing/tests/auth/fee_payment/sponsorship.rs b/crates/miden-testing/tests/auth/fee_payment/sponsorship.rs index 191c3b14dc..9532c74b4e 100644 --- a/crates/miden-testing/tests/auth/fee_payment/sponsorship.rs +++ b/crates/miden-testing/tests/auth/fee_payment/sponsorship.rs @@ -38,7 +38,7 @@ use super::{ // ================================================================================================ /// The fee a network account's fee policy charges for a sponsored feature note. -const FEE_AMOUNT: u64 = 500; +pub(super) const FEE_AMOUNT: u64 = 500; // HELPERS // ================================================================================================ @@ -49,7 +49,7 @@ fn fee_faucet_id() -> anyhow::Result { } /// A fungible asset of `amount` units of the native fee asset. -fn fee_asset(amount: u64) -> anyhow::Result { +pub(super) fn fee_asset(amount: u64) -> anyhow::Result { Ok(FungibleAsset::new(fee_faucet_id()?, amount)?.into()) } @@ -57,7 +57,7 @@ fn fee_asset(amount: u64) -> anyhow::Result { /// `FeePolicyManager`) that allowlists `allowed_notes`, prices each `(root, amount)` in `priced` /// through its active `BasicConstantFeePolicy`, holds `assets` in its vault, and bounds its /// sponsorship spending by `sponsorship_policy`. -fn network_account( +pub(super) fn network_account( seed: [u8; 32], allowed_notes: impl IntoIterator, priced: &[(NoteScriptRoot, u64)], @@ -94,7 +94,7 @@ fn native_conversion_info() -> (Word, Vec) { /// Builds a public P2ID network note (a P2ID note carrying a `NetworkAccountTarget` attachment) /// sent by `sender`, targeting and routed to `target`, and carrying `asset`. -fn p2id_network_note( +pub(super) fn p2id_network_note( sender: AccountId, target: AccountId, asset: Asset, diff --git a/crates/miden-testing/tests/auth/guarded_multisig.rs b/crates/miden-testing/tests/auth/guarded_multisig.rs index 77f488a174..ff8b8d2f51 100644 --- a/crates/miden-testing/tests/auth/guarded_multisig.rs +++ b/crates/miden-testing/tests/auth/guarded_multisig.rs @@ -93,7 +93,7 @@ fn setup_keys_and_authenticators_with_scheme( /// Builds the source for a tx-script that calls `update_guardian_public_key`. When `output_note` /// is `Some`, the script also creates that note before the guardian update so a single call /// exercises both `assert_no_input_notes` and `assert_no_output_notes` paths. -fn build_update_guardian_script_source( +pub(super) fn build_update_guardian_script_source( new_guardian_key_word: Word, new_guardian_scheme_id: u32, output_note: Option<&Note>, diff --git a/crates/miden-testing/tests/auth/mod.rs b/crates/miden-testing/tests/auth/mod.rs index cf57660914..d765bf71f6 100644 --- a/crates/miden-testing/tests/auth/mod.rs +++ b/crates/miden-testing/tests/auth/mod.rs @@ -1,5 +1,7 @@ mod fee_payment; +mod tx_policy; + mod singlesig; mod multisig; diff --git a/crates/miden-testing/tests/auth/tx_policy.rs b/crates/miden-testing/tests/auth/tx_policy.rs new file mode 100644 index 0000000000..5ffe6d15e5 --- /dev/null +++ b/crates/miden-testing/tests/auth/tx_policy.rs @@ -0,0 +1,76 @@ +use miden_protocol::note::{ + Note, + NoteAssets, + NoteRecipient, + NoteStorage, + NoteType, + PartialNoteMetadata, +}; +use miden_protocol::testing::note::DEFAULT_NOTE_SCRIPT; +use miden_protocol::transaction::RawOutputNote; +use miden_protocol::{Felt, Word}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; + +/// `assert_no_output_notes` reads the live output-note count itself, so a caller that understates +/// its own share tightens the check rather than weakening it. +/// +/// This pins the direction that regressed when the count was supplied rather than read: the +/// procedure asserted on the caller's number and never touched the kernel, so a caller passing +/// zero — the value a naive caller passes — disabled the check entirely. The dangerous value is +/// now the large one, which a caller has to state deliberately. +/// +/// The procedure still cannot detect a caller that overstates, since only the caller knows what it +/// created. What changed is what the caller is trusted about: a fact about its own code rather +/// than a fact about transaction state it had to sample at the right moment. +#[tokio::test] +async fn assert_no_output_notes_rejects_an_understated_own_count() -> anyhow::Result<()> { + let mut builder = MockChain::builder(); + let account = builder.add_existing_wallet(Auth::IncrNonce)?; + + let note_script = CodeBuilder::default().compile_note_script(DEFAULT_NOTE_SCRIPT)?; + let output_note = Note::new( + NoteAssets::new(vec![])?, + PartialNoteMetadata::new(account.id(), NoteType::Public), + NoteRecipient::new(Word::from([1u32, 2, 3, 4]), note_script, NoteStorage::default()), + ); + + let script = CodeBuilder::new().compile_tx_script(format!( + " + use miden::standards::auth::tx_policy + + @transaction_script + pub proc main + push.{recipient} + push.{note_type} + push.{tag} + call.::miden::standards::note::note_creator::create_note + movdn.15 dropw dropw dropw drop drop drop + swapdw + dropw + dropw + + # this caller created none of the transaction's output notes + push.0 + exec.tx_policy::assert_no_output_notes + end + ", + recipient = output_note.recipient().digest(), + note_type = NoteType::Public as u8, + tag = Felt::from(output_note.metadata().tag()), + ))?; + + let mock_chain = builder.build()?; + let result = mock_chain + .build_transaction(account.id()) + .tx_script(script) + .expected_output_note(RawOutputNote::Full(output_note)) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_AUTH_TRANSACTION_MUST_NOT_INCLUDE_OUTPUT_NOTES); + + Ok(()) +} diff --git a/docs/src/fees.md b/docs/src/fees.md index 90a525379e..8574da99c4 100644 --- a/docs/src/fees.md +++ b/docs/src/fees.md @@ -19,7 +19,9 @@ There are two distinct quantities involved in paying a fee: - **The computed fee**: what the `compute_fee` kernel procedure returns. It is always denominated in the chain’s native fee asset, defined by the protocol config that the current reference block commits to. The native asset is chosen once as part of the genesis block and then copied to every newly created block, which means it stays consistent for a given network. - **The paid amount**: what actually ends up in the TX_FEE note. The transaction can pay in any asset the batch builder accepts - the payment asset and its conversion rate to the native fee asset are user-supplied, committed to via the transaction’s auth args (the auth args are the hash of the conversion info - a fungible faucet ID and a rate - together with a salt, with the preimage in the advice map). The paid amount is the computed fee converted at that rate; paying in the native asset itself means committing to the native fee faucet at rate 1/1. -The client software is responsible for choosing an asset and rate the intended batch builder accepts. Nothing at the protocol level validates the conversion: enforcement happens at the batch builder, which rejects transactions whose fee note underpays it. +Auth components whose authorization can fall below the account’s full spending quorum bound what they will pay, since the rate reaches the VM from the host: the guarded and smart multisig components require the payment to be in the native fee asset and cap it at twice the computed fee. Without that bound, an authorization cheaper than an ordinary spend - guardian key rotation, or a procedure with a reduced per-procedure threshold - could move an arbitrary amount out of the vault as a fee note. + +Otherwise the client software is responsible for choosing an asset and rate the intended batch builder accepts. Nothing else at the protocol level validates the conversion: enforcement happens at the batch builder, which rejects transactions whose fee note underpays it. ## How fees are paid From df2c1e1cf6592761625979858f9eda16d56e7498 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 10:45:53 +0000 Subject: [PATCH 02/12] docs(standards): shorten the guardian-slot comment in the guarded multisig auth Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../components/auth/guarded_multisig/guarded_multisig.masm | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm index 5c7f5e32fc..c7a884b8fe 100644 --- a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm +++ b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm @@ -78,9 +78,7 @@ pub proc auth_tx_guarded_multisig(auth_args: word) exec.multisig::get_initial_threshold_and_num_approvers drop # => [num_of_approvers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - # one slot beyond the approvers, for the guardian signature. It is unconditional because the - # rotation path verifies no guardian signature but scans every account procedure instead, which - # the slot also covers. + # one slot beyond the approvers, for the guardian signature. add.1 # => [num_of_signers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] From 651f5ef41cc4acc411b1f294145ff3011c6d4a73 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 10:45:54 +0000 Subject: [PATCH 03/12] docs(standards): shorten the sponsorship comment in the guarded multisig auth Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../asm/components/auth/guarded_multisig/guarded_multisig.masm | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm index c7a884b8fe..b8fa8c3e26 100644 --- a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm +++ b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm @@ -88,8 +88,7 @@ pub proc auth_tx_guarded_multisig(auth_args: word) exec.fee::estimate_fee # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - # settle the sponsorship obligation first, in pay_fee's order; the bound below guards the - # host-supplied rate, which the sponsorship amounts do not depend on + # settle the sponsorship obligation first exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] From 2e9331d5d37b261cc43da859d6a77e718f58fb80 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 10:45:54 +0000 Subject: [PATCH 04/12] docs(standards): shorten the sponsorship comment in the smart multisig auth Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../asm/components/auth/multisig_smart/multisig_smart.masm | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm index d31e0c4241..80838b2cac 100644 --- a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm +++ b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm @@ -75,8 +75,7 @@ pub proc auth_tx_multisig_smart(auth_args: word) exec.fee::estimate_fee # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - # settle the sponsorship obligation first, in pay_fee's order; the bound below guards the - # host-supplied rate, which the sponsorship amounts do not depend on + # settle the sponsorship obligation first exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] From 97de4eba581ea40b99ebe356bace45a4cab8ee14 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 10:45:55 +0000 Subject: [PATCH 05/12] docs(testing): drop the "sample" wording from the tx_policy test doc Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- crates/miden-testing/tests/auth/tx_policy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/miden-testing/tests/auth/tx_policy.rs b/crates/miden-testing/tests/auth/tx_policy.rs index 5ffe6d15e5..804253aa6f 100644 --- a/crates/miden-testing/tests/auth/tx_policy.rs +++ b/crates/miden-testing/tests/auth/tx_policy.rs @@ -23,7 +23,7 @@ use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; /// /// The procedure still cannot detect a caller that overstates, since only the caller knows what it /// created. What changed is what the caller is trusted about: a fact about its own code rather -/// than a fact about transaction state it had to sample at the right moment. +/// than a fact about transaction state it had to read at the right moment. #[tokio::test] async fn assert_no_output_notes_rejects_an_understated_own_count() -> anyhow::Result<()> { let mut builder = MockChain::builder(); From 3f73ad7d17170175da25653f45239b6504647f8d Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 10:48:51 +0000 Subject: [PATCH 06/12] refactor(standards): share the bounded fee payment between the multisig components Pull the fee-paying block of the guarded and smart multisig auth procedures into multisig::pay_bounded_fee, which takes the number of signers and the conversion info and returns the number of notes the payment created. The fee bound constants and the drain rationale live there once; the components keep only the signer count that differs. Written against fee::estimate_fee taking the fee asset ID, so the fee asset is read once for the estimate and the sponsorship notes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../guarded_multisig/guarded_multisig.masm | 69 +++-------------- .../auth/multisig_smart/multisig_smart.masm | 70 +++-------------- .../asm/standards/auth/multisig.masm | 77 +++++++++++++++++++ 3 files changed, 97 insertions(+), 119 deletions(-) diff --git a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm index b8fa8c3e26..21cde9207b 100644 --- a/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm +++ b/crates/miden-standards/asm/components/auth/guarded_multisig/guarded_multisig.masm @@ -2,12 +2,8 @@ # # See the `AuthGuardedMultisig` Rust type's documentation for more details. -use miden::protocol::tx use miden::standards::auth::guardian use miden::standards::auth::multisig -use miden::standards::auth::signature -use miden::standards::fee -use miden::standards::fees pub use {update_signers_and_threshold} from miden::standards::auth::multisig pub use {get_threshold_and_num_approvers} from miden::standards::auth::multisig @@ -17,25 +13,12 @@ pub use {is_signer} from miden::standards::auth::multisig pub use {update_guardian_public_key} from miden::standards::auth::guardian -# CONSTANTS -# ================================================================================================= - -# The largest fee payment this component accepts, as the fraction FEE_BOUND_NUM / FEE_BOUND_DEN of -# the computed fee. Guardian key rotation authenticates without a guardian signature and can be -# thresholded below the account's spending quorum, so an unbounded host-supplied rate would drain -# the vault through the fee note. The margin covers a fee rising while signatures are collected. -const FEE_BOUND_NUM = 2 -const FEE_BOUND_DEN = 1 - #! Authenticate a transaction with multi-signature support and guardian verification, paying the #! transaction fee in the process. #! -#! The fee is paid by creating and funding a public TX_FEE note in the asset and at the rate of -#! the CONVERSION_INFO committed to by the AUTH_ARGS. The payment is bounded to at most -#! FEE_BOUND_NUM / FEE_BOUND_DEN of the computed fee and pinned to the native fee asset (see -#! fee::assert_fee_bound). On chains with a zero verification base fee no note is created. The fee -#! is paid before the transaction summary is created, so the fee note and the vault withdrawal -#! funding it are covered by the approver and guardian signatures. +#! The fee is paid before the transaction summary is created, bounded and in the native fee asset +#! (see multisig::pay_bounded_fee), so the fee note and the vault withdrawal funding it are covered +#! by the approver and guardian signatures. #! #! The guardian signature is verified in addition to the approvers' (see #! guardian::verify_signature), except on the guardian key rotation path, which instead requires @@ -55,7 +38,7 @@ const FEE_BOUND_DEN = 1 #! #! Panics if: #! - the auth args cannot be resolved, see `multisig::resolve_auth_args`. -#! - the fee payment is not in the native fee asset, exceeds the bound, or cannot be funded. +#! - the fee payment fails, see `multisig::pay_bounded_fee`. #! - insufficient number of valid approver or guardian signatures. #! - the approval window ended at or before the transaction reference block, see #! `multisig::auth_tx`. @@ -64,55 +47,25 @@ const FEE_BOUND_DEN = 1 #! Invocation: call @auth_script pub proc auth_tx_guarded_multisig(auth_args: word) - # read the output-note count before the fee payment so the notes it creates can be counted - exec.tx::get_num_output_notes movdn.4 - # => [AUTH_ARGS, num_output_notes_before_fee] - exec.multisig::resolve_auth_args - # => [CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + # => [CONVERSION_INFO, block_number, SALT] # Pay the transaction fee before the summary is created so that the TX_FEE note and the vault # withdrawal funding it are covered by the approver and guardian signatures. # --------------------------------------------------------------------------------------------- exec.multisig::get_initial_threshold_and_num_approvers drop - # => [num_of_approvers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + # => [num_of_approvers, CONVERSION_INFO, block_number, SALT] # one slot beyond the approvers, for the guardian signature. add.1 - # => [num_of_signers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - exec.signature::estimate_multisig_authentication_cycles - # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - exec.fee::estimate_fee - # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - # settle the sponsorship obligation first - exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop - # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - dup movdn.5 - # => [fee_amount, CONVERSION_INFO, fee_amount, block_number, SALT, num_output_notes_before_fee] - - exec.fee::resolve_payment_info - # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, fee_amount, - # block_number, SALT, num_output_notes_before_fee] - - push.FEE_BOUND_DEN push.FEE_BOUND_NUM - # => [bound_num, bound_den, payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, - # fee_amount, block_number, SALT, num_output_notes_before_fee] - - exec.fee::assert_fee_bound - # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, block_number, SALT, - # num_output_notes_before_fee] + # => [num_of_signers, CONVERSION_INFO, block_number, SALT] - exec.fee::pay_estimated_fee - # => [block_number, SALT, num_output_notes_before_fee] + exec.multisig::pay_bounded_fee + # => [num_own_output_notes, block_number, SALT] - # the notes the fee payment created: the TX_FEE note and one FEE_SPONSORSHIP note per network - # output note. The rotation path excludes them from its no-output-notes check. - exec.tx::get_num_output_notes movup.6 sub movdn.5 + # the rotation path excludes the notes the payment created from its no-output-notes check + movdn.5 # => [block_number, SALT, num_own_output_notes] # Authenticate the transaction and record it for replay protection. diff --git a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm index 80838b2cac..387f06681b 100644 --- a/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm +++ b/crates/miden-standards/asm/components/auth/multisig_smart/multisig_smart.masm @@ -2,12 +2,8 @@ # # See the `AuthMultisigSmart` Rust type's documentation for more details. -use miden::protocol::tx use miden::standards::auth::multisig use miden::standards::auth::multisig_smart -use miden::standards::auth::signature -use miden::standards::fee -use miden::standards::fees pub use {get_threshold_and_num_approvers} from miden::standards::auth::multisig pub use {get_signer_at} from miden::standards::auth::multisig @@ -15,28 +11,14 @@ pub use {is_signer} from miden::standards::auth::multisig pub use {set_procedure_policy} from miden::standards::auth::multisig_smart pub use {update_signers_and_threshold} from miden::standards::auth::multisig_smart -# CONSTANTS -# ================================================================================================= - -# The largest fee payment this component accepts, as the fraction FEE_BOUND_NUM / FEE_BOUND_DEN of -# the computed fee. A per-procedure policy can authorize a transaction below the account's default -# threshold, so an unbounded host-supplied rate would let such a transaction drain the vault -# through the fee note. The margin covers a fee rising while signatures are collected. -const FEE_BOUND_NUM = 2 -const FEE_BOUND_DEN = 1 - #! Authenticate a transaction using multisig smart-policy rules, paying the transaction fee in the #! process. #! -#! The fee is paid by creating and funding a public TX_FEE note in the asset and at the rate of -#! the CONVERSION_INFO committed to by the AUTH_ARGS. The payment is bounded to at most -#! FEE_BOUND_NUM / FEE_BOUND_DEN of the computed fee and pinned to the native fee asset (see -#! fee::assert_fee_bound). On chains with a zero verification base fee no note is created. The fee -#! is paid before the transaction summary is created, so the fee note and the vault withdrawal -#! funding it are covered by the approver signatures. -#! -#! The notes this procedure creates to pay the fee are excluded from the procedure policies' note -#! restrictions, so a policy forbidding output notes stays satisfiable on a fee-charging chain. +#! The fee is paid before the transaction summary is created, bounded and in the native fee asset +#! (see multisig::pay_bounded_fee), so the fee note and the vault withdrawal funding it are covered +#! by the approver signatures. The notes the payment creates are excluded from the procedure +#! policies' note restrictions, so a policy forbidding output notes stays satisfiable on a +#! fee-charging chain. #! #! Inputs: #! Operand stack: [AUTH_ARGS] @@ -46,7 +28,7 @@ const FEE_BOUND_DEN = 1 #! #! Panics if: #! - the auth args cannot be resolved, see `multisig::resolve_auth_args`. -#! - the fee payment is not in the native fee asset, exceeds the bound, or cannot be funded. +#! - the fee payment fails, see `multisig::pay_bounded_fee`. #! - a called procedure's policy forbids the transaction's input or output notes, or the number of #! valid signatures is below the threshold, see `multisig_smart::auth_tx`. #! - the approval window ended at or before the transaction reference block, see @@ -55,51 +37,17 @@ const FEE_BOUND_DEN = 1 #! Invocation: call @auth_script pub proc auth_tx_multisig_smart(auth_args: word) - # read the output-note count before the fee payment so the notes it creates can be counted - exec.tx::get_num_output_notes movdn.4 - # => [AUTH_ARGS, num_output_notes_before_fee] - exec.multisig::resolve_auth_args - # => [CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] + # => [CONVERSION_INFO, block_number, SALT] # Pay the transaction fee before the summary is created so that the TX_FEE note and the vault # withdrawal funding it are covered by the approver signatures. # --------------------------------------------------------------------------------------------- exec.multisig::get_initial_threshold_and_num_approvers drop - # => [num_of_approvers, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - exec.signature::estimate_multisig_authentication_cycles - # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - exec.fee::estimate_fee - # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - # settle the sponsorship obligation first - exec.tx::get_fee_asset_id exec.fees::create_network_note_sponsorships drop - # => [fee_amount, CONVERSION_INFO, block_number, SALT, num_output_notes_before_fee] - - dup movdn.5 - # => [fee_amount, CONVERSION_INFO, fee_amount, block_number, SALT, num_output_notes_before_fee] - - exec.fee::resolve_payment_info - # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, fee_amount, - # block_number, SALT, num_output_notes_before_fee] - - push.FEE_BOUND_DEN push.FEE_BOUND_NUM - # => [bound_num, bound_den, payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, - # fee_amount, block_number, SALT, num_output_notes_before_fee] - - exec.fee::assert_fee_bound - # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, block_number, SALT, - # num_output_notes_before_fee] - - exec.fee::pay_estimated_fee - # => [block_number, SALT, num_output_notes_before_fee] + # => [num_of_approvers, CONVERSION_INFO, block_number, SALT] - # the notes the fee payment created: the TX_FEE note and one FEE_SPONSORSHIP note per network - # output note. The procedure policies' note restrictions exclude them. - exec.tx::get_num_output_notes movup.6 sub + exec.multisig::pay_bounded_fee # => [num_own_output_notes, block_number, SALT] # Authenticate the transaction and record it for replay protection. diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index a9bb5f960a..a875fa0c0d 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -9,7 +9,9 @@ use miden::protocol::tx use {BlockNumber} from miden::protocol::types use miden::standards::auth use miden::standards::auth::signature +use miden::standards::fee use {ConversionInfo} from miden::standards::fee +use miden::standards::fees use miden::core::mem use miden::core::word use {ONE_WORD} from miden::standards::utils @@ -85,6 +87,11 @@ const MAX_NUM_APPROVERS = 64 # Number of words in the auth args preimage. const AUTH_ARGS_NUM_WORDS = 3 +# The largest fee payment the multisig components accept, as the fraction FEE_BOUND_NUM / +# FEE_BOUND_DEN of the computed fee. The margin covers a fee rising while signatures are collected. +const FEE_BOUND_NUM = 2 +const FEE_BOUND_DEN = 1 + # ERRORS # ================================================================================================= @@ -827,6 +834,76 @@ pub proc resolve_auth_args(auth_args: word) -> (ConversionInfo, BlockNumber, wor # => [CONVERSION_INFO, block_number, SALT] end +#! Pays the transaction fee of a multisig authentication, bounded. +#! +#! A multisig can authenticate a transaction below its spending quorum (a guardian key rotation, +#! a reduced per-procedure threshold) while the conversion rate is host-supplied, so an unbounded +#! payment would let such a transaction drain the vault through the fee note. The payment is +#! therefore pinned to the native fee asset and capped at FEE_BOUND_NUM / FEE_BOUND_DEN of the +#! computed fee (see fee::assert_fee_bound). On chains with a zero verification base fee no note is +#! created. Must run before the transaction summary is created, so the signatures cover the fee +#! note and the vault withdrawal funding it. +#! +#! Inputs: [num_of_signers, CONVERSION_INFO] +#! Outputs: [num_own_output_notes] +#! +#! Where: +#! - num_of_signers is the maximum number of signature verifications the authentication performs, +#! see signature::estimate_multisig_authentication_cycles. +#! - CONVERSION_INFO is as described in fee::pay_fee. +#! - num_own_output_notes is the number of output notes the payment created: the TX_FEE note and +#! one FEE_SPONSORSHIP note per network output note. +#! +#! Panics if: +#! - the fee payment is not in the native fee asset, exceeds the bound, or cannot be funded. +#! - fee::estimate_fee or fees::create_network_note_sponsorships panics. +#! +#! Invocation: exec +pub proc pay_bounded_fee(num_of_signers: u32, conversion_info: ConversionInfo) -> u16 + # read the output-note count before the payment so the notes it creates can be counted + exec.tx::get_num_output_notes movdn.5 + # => [num_of_signers, CONVERSION_INFO, num_output_notes_before_fee] + + exec.signature::estimate_multisig_authentication_cycles + # => [num_extra_cycles, CONVERSION_INFO, num_output_notes_before_fee] + + # the native fee asset funds the sponsorship notes, and is read once for both passes + exec.tx::get_fee_asset_id + # => [FEE_ASSET_ID, num_extra_cycles, CONVERSION_INFO, num_output_notes_before_fee] + + dupw movup.8 movdn.4 + # => [FEE_ASSET_ID, num_extra_cycles, FEE_ASSET_ID, CONVERSION_INFO, num_output_notes_before_fee] + + exec.fee::estimate_fee + # => [fee_amount, FEE_ASSET_ID, CONVERSION_INFO, num_output_notes_before_fee] + + # settle the sponsorship obligation first + movdn.4 exec.fees::create_network_note_sponsorships drop + # => [fee_amount, CONVERSION_INFO, num_output_notes_before_fee] + + dup movdn.5 + # => [fee_amount, CONVERSION_INFO, fee_amount, num_output_notes_before_fee] + + exec.fee::resolve_payment_info + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, fee_amount, + # num_output_notes_before_fee] + + push.FEE_BOUND_DEN push.FEE_BOUND_NUM + # => [bound_num, bound_den, payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, + # fee_amount, num_output_notes_before_fee] + + exec.fee::assert_fee_bound + # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, + # num_output_notes_before_fee] + + exec.fee::pay_estimated_fee + # => [num_output_notes_before_fee] + + # the notes the payment created + exec.tx::get_num_output_notes swap sub + # => [num_own_output_notes] +end + #! Rebases the transaction's expiration to the block bound by the transaction summary. #! #! The kernel computes the expiration block based on the transaction's reference block. Since From 9cb10c5fb2baf650b248f3622e9e9375ccb08892 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 12:31:51 +0000 Subject: [PATCH 07/12] refactor(standards): let pay_bounded_fee reuse the sponsorship prices The estimate records every network note's sponsorship price in a local table, which the sponsorship payment reads back instead of pricing the notes through FPI a second time, mirroring fee::pay_fee. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../asm/standards/auth/multisig.masm | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index a875fa0c0d..39f07e9c64 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -33,6 +33,10 @@ const DEFAULT_THRESHOLD_LOC=0 const UNIQUE_OUTER_INDEX_LOC=0 const UNIQUE_INNER_INDEX_LOC=1 +# pay_bounded_fee locals: the sponsorship price table, fees::SPONSORSHIP_PRICE_TABLE_NUM_ENTRIES +# entries indexed by output note index. +const PAY_BOUNDED_FEE_PRICE_TABLE_LOC=0 + # CONSTANTS # ================================================================================================= @@ -844,6 +848,8 @@ end #! created. Must run before the transaction summary is created, so the signatures cover the fee #! note and the vault withdrawal funding it. #! +#! The estimate records the sponsorship prices in local memory and the payment reuses them. +#! #! Inputs: [num_of_signers, CONVERSION_INFO] #! Outputs: [num_own_output_notes] #! @@ -859,6 +865,7 @@ end #! - fee::estimate_fee or fees::create_network_note_sponsorships panics. #! #! Invocation: exec +@locals(1024) pub proc pay_bounded_fee(num_of_signers: u32, conversion_info: ConversionInfo) -> u16 # read the output-note count before the payment so the notes it creates can be counted exec.tx::get_num_output_notes movdn.5 @@ -867,18 +874,29 @@ pub proc pay_bounded_fee(num_of_signers: u32, conversion_info: ConversionInfo) - exec.signature::estimate_multisig_authentication_cycles # => [num_extra_cycles, CONVERSION_INFO, num_output_notes_before_fee] + # the price table the estimate fills and the payment reads back + locaddr.PAY_BOUNDED_FEE_PRICE_TABLE_LOC dup + # => [price_table_ptr, price_table_ptr, num_extra_cycles, CONVERSION_INFO, + # num_output_notes_before_fee] + # the native fee asset funds the sponsorship notes, and is read once for both passes exec.tx::get_fee_asset_id - # => [FEE_ASSET_ID, num_extra_cycles, CONVERSION_INFO, num_output_notes_before_fee] + # => [FEE_ASSET_ID, price_table_ptr, price_table_ptr, num_extra_cycles, CONVERSION_INFO, + # num_output_notes_before_fee] dupw movup.8 movdn.4 - # => [FEE_ASSET_ID, num_extra_cycles, FEE_ASSET_ID, CONVERSION_INFO, num_output_notes_before_fee] + # => [FEE_ASSET_ID, price_table_ptr, FEE_ASSET_ID, price_table_ptr, num_extra_cycles, + # CONVERSION_INFO, num_output_notes_before_fee] + + movup.10 movdn.5 + # => [FEE_ASSET_ID, price_table_ptr, num_extra_cycles, FEE_ASSET_ID, price_table_ptr, + # CONVERSION_INFO, num_output_notes_before_fee] exec.fee::estimate_fee - # => [fee_amount, FEE_ASSET_ID, CONVERSION_INFO, num_output_notes_before_fee] + # => [fee_amount, FEE_ASSET_ID, price_table_ptr, CONVERSION_INFO, num_output_notes_before_fee] # settle the sponsorship obligation first - movdn.4 exec.fees::create_network_note_sponsorships drop + movdn.5 exec.fees::create_network_note_sponsorships drop # => [fee_amount, CONVERSION_INFO, num_output_notes_before_fee] dup movdn.5 From abc43eae68ed50382c83e671c7ad495ae2f02a21 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 12:31:51 +0000 Subject: [PATCH 08/12] feat(standards): bound the fee payment of the plain multisig component AuthMultisig now pays its fee through multisig::pay_bounded_fee, so the host-supplied conversion rate can move at most twice the computed fee out of the vault, in the native fee asset, closing the drain of #3763. The bound's rationale is reworded to cover every multisig component. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- CHANGELOG.md | 1 + .../components/auth/multisig/multisig.masm | 17 ++-- .../asm/standards/auth/multisig.masm | 13 ++- .../miden-standards/src/account/auth/fee.rs | 7 +- .../src/account/auth/multisig.rs | 16 ++-- .../tests/auth/fee_payment/multisig.rs | 88 ++++++++++++++++++- docs/src/fees.md | 2 +- 7 files changed, 112 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6b922cf9..42c1e47087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- [BREAKING] `AuthMultisig` now bounds its fee payment via `fee::assert_fee_bound` to the native fee asset at at most twice the computed fee, closing the fee drain of [#3763](https://github.com/0xMiden/protocol/issues/3763); its code commitment changes. - [BREAKING] The `AuthGuardedMultisig` and `AuthMultisigSmart` components now pay the transaction fee, bounded via `fee::assert_fee_bound` to the native fee asset at at most twice the computed fee; `tx_policy::assert_no_output_notes` takes the number of output notes the caller created itself ([#3786](https://github.com/0xMiden/protocol/pull/3786)). - Added `active_note::get_storage_info` and `active_note::get_bounded_storage`, and switched the standard and agglayer note scripts with a bounded storage layout over to the latter ([#3563](https://github.com/0xMiden/protocol/pull/3563)). - [BREAKING] AggLayer bridge and faucet accounts now map note repricing to an initial `FEE_MNGR` role instead of the built-in `ADMIN` role ([#3571](https://github.com/0xMiden/protocol/issues/3571)). diff --git a/crates/miden-standards/asm/components/auth/multisig/multisig.masm b/crates/miden-standards/asm/components/auth/multisig/multisig.masm index 6acc40cfe7..58af7de0cd 100644 --- a/crates/miden-standards/asm/components/auth/multisig/multisig.masm +++ b/crates/miden-standards/asm/components/auth/multisig/multisig.masm @@ -3,8 +3,6 @@ # See the `AuthMultisig` Rust type's documentation for more details. use miden::standards::auth::multisig -use miden::standards::auth::signature -use miden::standards::fee pub use {update_signers_and_threshold} from miden::standards::auth::multisig pub use {get_threshold_and_num_approvers} from miden::standards::auth::multisig @@ -16,10 +14,9 @@ pub use {is_signer} from miden::standards::auth::multisig #! process. #! #! It resolves the AUTH_ARGS into the block the summary binds, the summary salt and the fee -#! conversion info, then pays the transaction fee by creating a public TX_FEE note. On chains with -#! a zero verification base fee no note is created. Because the fee is paid before the transaction -#! summary is created, the fee note and the vault withdrawal funding it are covered by the approver -#! signatures. +#! conversion info, then pays the transaction fee before the summary is created, bounded and in +#! the native fee asset (see multisig::pay_bounded_fee), so the fee note and the vault withdrawal +#! funding it are covered by the approver signatures. #! #! Inputs: #! Operand stack: [AUTH_ARGS] @@ -28,6 +25,8 @@ pub use {is_signer} from miden::standards::auth::multisig #! Operand stack: [] #! #! Panics if: +#! - the auth args cannot be resolved, see `multisig::resolve_auth_args`. +#! - the fee payment fails, see `multisig::pay_bounded_fee`. #! - insufficient number of valid signatures (below threshold). #! - the approval window ended at or before the transaction reference block, see #! `multisig::auth_tx`. @@ -45,10 +44,8 @@ pub proc auth_tx_multisig(auth_args: word) exec.multisig::get_initial_threshold_and_num_approvers drop # => [num_of_approvers, CONVERSION_INFO, block_number, SALT] - exec.signature::estimate_multisig_authentication_cycles - # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT] - - exec.fee::pay_fee drop + # the notes the payment created need no accounting here + exec.multisig::pay_bounded_fee drop # => [block_number, SALT] # Authenticate the transaction and record it for replay protection. diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index 39f07e9c64..2551fb3910 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -840,13 +840,12 @@ end #! Pays the transaction fee of a multisig authentication, bounded. #! -#! A multisig can authenticate a transaction below its spending quorum (a guardian key rotation, -#! a reduced per-procedure threshold) while the conversion rate is host-supplied, so an unbounded -#! payment would let such a transaction drain the vault through the fee note. The payment is -#! therefore pinned to the native fee asset and capped at FEE_BOUND_NUM / FEE_BOUND_DEN of the -#! computed fee (see fee::assert_fee_bound). On chains with a zero verification base fee no note is -#! created. Must run before the transaction summary is created, so the signatures cover the fee -#! note and the vault withdrawal funding it. +#! The conversion rate is host-supplied, so the payment is pinned to the native fee asset and +#! capped at FEE_BOUND_NUM / FEE_BOUND_DEN of the computed fee (see fee::assert_fee_bound): +#! whatever quorum signed, a rate cannot move more than that margin over the fee owed out of the +#! vault through the fee note. On chains with a zero verification base fee no note is created. +#! Must run before the transaction summary is created, so the signatures cover the fee note and +#! the vault withdrawal funding it. #! #! The estimate records the sponsorship prices in local memory and the payment reuses them. #! diff --git a/crates/miden-standards/src/account/auth/fee.rs b/crates/miden-standards/src/account/auth/fee.rs index 2b20eeeb29..2d93ae90f6 100644 --- a/crates/miden-standards/src/account/auth/fee.rs +++ b/crates/miden-standards/src/account/auth/fee.rs @@ -14,10 +14,9 @@ use miden_protocol::{Felt, Hasher, Word}; /// `pay_fee` pays `ceil(fee_amount * rate_num / rate_den)` of the asset issued by `faucet_id`. /// To pay in an asset 1-to-1 (e.g. the native fee asset itself), use [`Self::one_to_one`]. /// -/// Components whose authorization can fall below the account's full spending quorum bound what -/// they accept here, since the rate reaches the VM from the host: the guarded and smart multisig -/// components require `faucet_id` to be the native fee faucet and cap the paid amount at twice the -/// computed fee, aborting the transaction otherwise. +/// The multisig components bound what they accept here, since the rate reaches the VM from the +/// host: they require `faucet_id` to be the native fee faucet and cap the paid amount at twice +/// the computed fee, aborting the transaction otherwise. /// /// For signature-based authentication components the conversion info is typically committed to /// via the transaction's auth args (see [`commit_fee_conversion_info`]). diff --git a/crates/miden-standards/src/account/auth/multisig.rs b/crates/miden-standards/src/account/auth/multisig.rs index bee410acba..8283b360dd 100644 --- a/crates/miden-standards/src/account/auth/multisig.rs +++ b/crates/miden-standards/src/account/auth/multisig.rs @@ -152,14 +152,14 @@ impl AuthMultisigConfig { /// /// # Fees /// -/// Before authenticating, `auth_tx_multisig` pays the transaction fee via -/// `miden::standards::fee::pay_fee`: it creates a public TX_FEE note (see -/// [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, so on -/// fee-charging chains the account must hold a sufficient balance of the payment asset. The -/// payment asset and conversion rate come from the auth args (see -/// [`FeeConversionInfo`](super::FeeConversionInfo); native fee asset at rate 1/1 for plain native -/// payment). On chains with a zero verification base fee no note is created. The fee note is -/// created before the transaction summary, so it is covered by the approver signatures. +/// Before authenticating, `auth_tx_multisig` pays the transaction fee by creating a public TX_FEE +/// note (see [`TxFeeNote`](crate::note::TxFeeNote)) funded from the account's vault, at the rate +/// of the auth args' [`FeeConversionInfo`](super::FeeConversionInfo). On chains with a zero +/// verification base fee no note is created. The fee note is created before the transaction +/// summary, so the approver signatures cover it. +/// +/// The conversion rate is host-supplied, so the payment is bounded (`fee::assert_fee_bound`): it +/// must be in the native fee asset and at most twice the computed fee. /// /// # Expiration /// diff --git a/crates/miden-testing/tests/auth/fee_payment/multisig.rs b/crates/miden-testing/tests/auth/fee_payment/multisig.rs index 58a0ffe6e4..1b9a4b629d 100644 --- a/crates/miden-testing/tests/auth/fee_payment/multisig.rs +++ b/crates/miden-testing/tests/auth/fee_payment/multisig.rs @@ -1,10 +1,17 @@ use miden_protocol::account::auth::{AuthScheme, PublicKey}; use miden_protocol::asset::{Asset, FungibleAsset}; -use miden_protocol::testing::account_id::ACCOUNT_ID_FEE_FAUCET; +use miden_protocol::testing::account_id::{ + ACCOUNT_ID_FEE_FAUCET, + ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2, +}; use miden_protocol::transaction::{ExecutedTransaction, TransactionSummary}; use miden_protocol::{Word, ZERO}; use miden_standards::account::auth::{Approver, ApproverSet, FeeConversionInfo, MultisigAuthArgs}; -use miden_testing::{Auth, MockChain, MockTransactionBuilder}; +use miden_standards::errors::standards::{ + ERR_FEE_PAYMENT_ASSET_NOT_NATIVE, + ERR_FEE_PAYMENT_EXCEEDS_BOUND, +}; +use miden_testing::{Auth, MockChain, MockTransactionBuilder, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; use rstest::rstest; @@ -244,3 +251,80 @@ async fn multisig_fee_payment_preserves_replay_protection() -> anyhow::Result<() Ok(()) } + +/// The multisig component bounds its fee payment, since the conversion rate is host-supplied: an +/// inflated rate in the native asset and a foreign asset at an acceptable rate both abort before +/// any signature is verified. +#[rstest] +#[case::inflated_rate( + FeeConversionInfo::new(ACCOUNT_ID_FEE_FAUCET.try_into().unwrap(), 1_000_000, 1).unwrap(), + ERR_FEE_PAYMENT_EXCEEDS_BOUND +)] +#[case::foreign_asset( + FeeConversionInfo::one_to_one(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_2.try_into().unwrap()), + ERR_FEE_PAYMENT_ASSET_NOT_NATIVE +)] +#[tokio::test] +async fn multisig_cannot_drain_the_vault_via_the_fee_payment( + #[case] conversion_info: FeeConversionInfo, + #[case] expected_error: miden_protocol::errors::MasmError, +) -> anyhow::Result<()> { + let (approver_set, _signers) = multisig_fixture(2, 2, AuthScheme::Falcon512Poseidon2)?; + + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let fee_asset: Asset = FungibleAsset::new(fee_faucet_id, 1_000_000)?.into(); + + let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE); + let account = builder.add_existing_wallet_with_assets( + Auth::Multisig { approver_set, proc_threshold_map: vec![] }, + [fee_asset], + )?; + let mock_chain = builder.build()?; + + let auth_args = MultisigAuthArgs::new( + mock_chain.latest_block_header().block_num(), + Word::from([81u32, 82, 83, 84]), + ) + .with_conversion_info(conversion_info); + let result = mock_chain + .build_transaction(account.id()) + .multisig_auth_args(auth_args) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, expected_error); + + Ok(()) +} + +/// A rate exactly at the bound is accepted: the payment is twice the computed fee. +#[tokio::test] +async fn multisig_pays_fee_at_the_bound() -> anyhow::Result<()> { + let (approver_set, signers) = multisig_fixture(2, 2, AuthScheme::Falcon512Poseidon2)?; + + let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; + let fee_asset: Asset = FungibleAsset::new(fee_faucet_id, 1_000_000)?.into(); + + let mut builder = MockChain::builder().verification_base_fee(VERIFICATION_BASE_FEE); + let account = builder.add_existing_wallet_with_assets( + Auth::Multisig { approver_set, proc_threshold_map: vec![] }, + [fee_asset], + )?; + let mock_chain = builder.build()?; + + let auth_args = MultisigAuthArgs::new( + mock_chain.latest_block_header().block_num(), + Word::from([85u32, 86, 87, 88]), + ) + .with_conversion_info(FeeConversionInfo::new(fee_faucet_id, 2, 1)?); + let mock_tx_builder = mock_chain.build_transaction(account.id()).multisig_auth_args(auth_args); + let signed_builder = sign_with_all(mock_tx_builder, &signers).await?; + + let executed_transaction = signed_builder.build()?.execute().await?; + + let paid = assert_single_fee_note(&executed_transaction)?; + assert_eq!(paid.amount().as_u64(), 2 * executed_transaction.compute_fee().as_u64()); + + Ok(()) +} diff --git a/docs/src/fees.md b/docs/src/fees.md index 8574da99c4..44631df42a 100644 --- a/docs/src/fees.md +++ b/docs/src/fees.md @@ -19,7 +19,7 @@ There are two distinct quantities involved in paying a fee: - **The computed fee**: what the `compute_fee` kernel procedure returns. It is always denominated in the chain’s native fee asset, defined by the protocol config that the current reference block commits to. The native asset is chosen once as part of the genesis block and then copied to every newly created block, which means it stays consistent for a given network. - **The paid amount**: what actually ends up in the TX_FEE note. The transaction can pay in any asset the batch builder accepts - the payment asset and its conversion rate to the native fee asset are user-supplied, committed to via the transaction’s auth args (the auth args are the hash of the conversion info - a fungible faucet ID and a rate - together with a salt, with the preimage in the advice map). The paid amount is the computed fee converted at that rate; paying in the native asset itself means committing to the native fee faucet at rate 1/1. -Auth components whose authorization can fall below the account’s full spending quorum bound what they will pay, since the rate reaches the VM from the host: the guarded and smart multisig components require the payment to be in the native fee asset and cap it at twice the computed fee. Without that bound, an authorization cheaper than an ordinary spend - guardian key rotation, or a procedure with a reduced per-procedure threshold - could move an arbitrary amount out of the vault as a fee note. +The multisig components bound what they will pay, since the rate reaches the VM from the host: they require the payment to be in the native fee asset and cap it at twice the computed fee. Without that bound, a rate could move an arbitrary amount out of the vault as a fee note - whatever quorum signed, and in particular one cheaper than an ordinary spend, such as a guardian key rotation or a procedure with a reduced per-procedure threshold. Otherwise the client software is responsible for choosing an asset and rate the intended batch builder accepts. Nothing else at the protocol level validates the conversion: enforcement happens at the batch builder, which rejects transactions whose fee note underpays it. From c4c4f8cfdcc6ef43cfd824c861f30b25cdb5d333 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 13:16:09 +0000 Subject: [PATCH 09/12] refactor(standards): keep pay_fee's payment tail inline Composing pay_fee from resolve_payment_info and pay_estimated_fee cost every plain fee payer 121 auth cycles and pushed the two-P2ID ECDSA consumption into the next padded-trace bracket. Only the bounded multisig payment composes the two procedures; pay_fee keeps its inline tail. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- .../asm/standards/fee/mod.masm | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/miden-standards/asm/standards/fee/mod.masm b/crates/miden-standards/asm/standards/fee/mod.masm index da136a177e..7bad34590a 100644 --- a/crates/miden-standards/asm/standards/fee/mod.masm +++ b/crates/miden-standards/asm/standards/fee/mod.masm @@ -557,18 +557,14 @@ end #! Computes the transaction fee and pays it by creating and funding a public TX_FEE note. #! -#! This is the unbounded fee payment flow: the caller supplies the decoded conversion info and -#! invokes this procedure from the authentication procedure BEFORE any transaction summary is -#! created, so that the TX_FEE note and the vault withdrawal funding it are covered by a signature -#! where one exists. Signature-based components (e.g. singlesig, multisig) obtain the conversion -#! info from the auth args via load_conversion_info, so the signer authorizes the payment asset and -#! rate; network accounts pay in the native fee asset and obtain the conversion info via -#! native_conversion_info, which needs no commitment since it is read from the reference block. -#! -#! A component whose authentication can be satisfied by less authority than an ordinary note -#! transfer would need must not use this procedure, since the rate is host-supplied and unbounded -#! here. Such a component composes this procedure's steps itself, inserting assert_fee_bound -#! between resolve_payment_info and pay_estimated_fee. +#! This is the fee payment flow for all account types: the caller supplies the decoded +#! conversion info and invokes this procedure from the authentication procedure BEFORE any +#! transaction summary is created, so that the TX_FEE note and the vault withdrawal funding +#! it are covered by a signature where one exists. Signature-based components (e.g. singlesig, +#! multisig) obtain the conversion info from the auth args via load_conversion_info, so the +#! signer authorizes the payment asset and rate; network accounts pay in the native fee asset +#! and obtain the conversion info via native_conversion_info, which needs no commitment since +#! it is read from the reference block. #! #! The fee is computed by estimate_fee, which prices the network output notes once and records the #! prices in this procedure's local memory. The payment then settles both of the transaction's fee @@ -580,6 +576,9 @@ end #! fee note is created and no conversion info is required (the empty word is accepted). The #! sponsorship notes are created regardless. #! +#! Callers that bound the payment compose resolve_payment_info and pay_estimated_fee instead; +#! the payment tail stays inline here so plain fee payers pay no extra procedure-call cycles. +#! #! Inputs: [num_extra_cycles, CONVERSION_INFO] #! Outputs: [total_sponsored_fee_amount] #! @@ -591,8 +590,11 @@ end #! - total_sponsored_fee_amount is the total amount moved into the sponsorship notes. #! #! Panics if: -#! - estimate_fee, fees::create_network_note_sponsorships, resolve_payment_info or -#! pay_estimated_fee panics. +#! - estimate_fee or fees::create_network_note_sponsorships panics. +#! - the computed fee is non-zero and CONVERSION_INFO is the empty word. +#! - the conversion rate is malformed or the converted amount overflows. +#! - the account vault holds less of the payment asset than the amount to be paid. +#! - the maximum number of output notes is exceeded. #! #! Invocation: exec @locals(4096) @@ -622,10 +624,39 @@ pub proc pay_fee(num_extra_cycles: felt, conversion_info: ConversionInfo) -> fel movdn.5 # => [fee_amount, CONVERSION_INFO, total_sponsored_fee_amount] - exec.resolve_payment_info - # => [payment_faucet_id_suffix, payment_faucet_id_prefix, payment_amount, - # total_sponsored_fee_amount] + dup eq.0 + if.true + # a zero fee requires no fee note + drop dropw + # => [total_sponsored_fee_amount] + else + movdn.4 + # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] + + # a non-zero fee requires committed conversion info + exec.word::testz assertz.err=ERR_FEE_CONVERSION_INFO_MISSING + # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] + + # convert the fee amount into the payment asset's amount + # (CONVERSION_INFO reads [faucet_id_suffix, faucet_id_prefix, rate_num, rate_den]) + movup.2 movup.3 movup.4 + # => [fee_amount, rate_den, rate_num, faucet_id_suffix, faucet_id_prefix, + # total_sponsored_fee_amount] + + movup.2 swap + # => [fee_amount, rate_num, rate_den, faucet_id_suffix, faucet_id_prefix, + # total_sponsored_fee_amount] + + exec.convert_amount + # => [payment_amount, faucet_id_suffix, faucet_id_prefix, total_sponsored_fee_amount] - exec.pay_estimated_fee - # => [total_sponsored_fee_amount] + movdn.2 + # => [faucet_id_suffix, faucet_id_prefix, payment_amount, total_sponsored_fee_amount] + + exec.fungible_asset::create + # => [ASSET_ID, ASSET_VALUE, total_sponsored_fee_amount] + + exec.create_and_fund_fee_note + # => [total_sponsored_fee_amount] + end end From 27ea15af6120bb013fe75d34f2fa834fafba6b33 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 14:04:19 +0000 Subject: [PATCH 10/12] fix(standards): size pay_bounded_fee's price table for one word per note The sponsorship price table now holds one fee asset value word per output note, so the local table grows to fees::SPONSORSHIP_PRICE_TABLE_NUM_ELEMENTS as in fee::pay_fee. The pointer plumbing is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- crates/miden-standards/asm/standards/auth/multisig.masm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/miden-standards/asm/standards/auth/multisig.masm b/crates/miden-standards/asm/standards/auth/multisig.masm index 2551fb3910..7fdcae5f08 100644 --- a/crates/miden-standards/asm/standards/auth/multisig.masm +++ b/crates/miden-standards/asm/standards/auth/multisig.masm @@ -33,8 +33,8 @@ const DEFAULT_THRESHOLD_LOC=0 const UNIQUE_OUTER_INDEX_LOC=0 const UNIQUE_INNER_INDEX_LOC=1 -# pay_bounded_fee locals: the sponsorship price table, fees::SPONSORSHIP_PRICE_TABLE_NUM_ENTRIES -# entries indexed by output note index. +# pay_bounded_fee locals: the sponsorship price table, fees::SPONSORSHIP_PRICE_TABLE_NUM_ELEMENTS +# elements holding one fee asset value word per output note. const PAY_BOUNDED_FEE_PRICE_TABLE_LOC=0 # CONSTANTS @@ -864,7 +864,7 @@ end #! - fee::estimate_fee or fees::create_network_note_sponsorships panics. #! #! Invocation: exec -@locals(1024) +@locals(4096) pub proc pay_bounded_fee(num_of_signers: u32, conversion_info: ConversionInfo) -> u16 # read the output-note count before the payment so the notes it creates can be counted exec.tx::get_num_output_notes movdn.5 From bfe2ae7bf913d61683925382aed1b2e8f90df665 Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 14:24:10 +0000 Subject: [PATCH 11/12] chore: regenerate cost tables Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- bin/bench-transaction/bench-tx.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/bench-transaction/bench-tx.json b/bin/bench-transaction/bench-tx.json index ea12dfcaa1..9a2b44758e 100644 --- a/bin/bench-transaction/bench-tx.json +++ b/bin/bench-transaction/bench-tx.json @@ -14,7 +14,7 @@ "core_rows": 81445, "chiplets_rows": 11504, "poseidon2_permutation_rows": 55392, - "range_rows": 20645, + "range_rows": 20669, "chiplets_shape": { "hasher_rows": 8656, "bitwise_rows": 656, @@ -39,7 +39,7 @@ "core_rows": 13682, "chiplets_rows": 5872, "poseidon2_permutation_rows": 19888, - "range_rows": 1969, + "range_rows": 1999, "chiplets_shape": { "hasher_rows": 4224, "bitwise_rows": 848, @@ -65,7 +65,7 @@ "core_rows": 84045, "chiplets_rows": 13403, "poseidon2_permutation_rows": 55648, - "range_rows": 20515, + "range_rows": 20245, "chiplets_shape": { "hasher_rows": 10072, "bitwise_rows": 1032, @@ -91,7 +91,7 @@ "core_rows": 16282, "chiplets_rows": 7771, "poseidon2_permutation_rows": 20144, - "range_rows": 1413, + "range_rows": 1407, "chiplets_shape": { "hasher_rows": 5640, "bitwise_rows": 1224, @@ -114,7 +114,7 @@ "core_rows": 79635, "chiplets_rows": 10916, "poseidon2_permutation_rows": 53280, - "range_rows": 20351, + "range_rows": 20429, "chiplets_shape": { "hasher_rows": 8240, "bitwise_rows": 616, @@ -137,7 +137,7 @@ "core_rows": 11872, "chiplets_rows": 5284, "poseidon2_permutation_rows": 17728, - "range_rows": 1237, + "range_rows": 1215, "chiplets_shape": { "hasher_rows": 3808, "bitwise_rows": 808, @@ -863,7 +863,7 @@ "core_rows": 17359, "chiplets_rows": 7868, "poseidon2_permutation_rows": 23776, - "range_rows": 1517, + "range_rows": 1543, "chiplets_shape": { "hasher_rows": 5824, "bitwise_rows": 1160, From 78fe2df968edefb4fcbbff31cdf6229e818963ac Mon Sep 17 00:00:00 2001 From: "Claude (Opus)" Date: Thu, 3 Sep 2026 15:05:14 +0000 Subject: [PATCH 12/12] chore: regenerate cost tables Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KkQzFtRjyDwQ7iVtRbsHsn --- bin/bench-transaction/bench-tx.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bin/bench-transaction/bench-tx.json b/bin/bench-transaction/bench-tx.json index 9a2b44758e..463937438f 100644 --- a/bin/bench-transaction/bench-tx.json +++ b/bin/bench-transaction/bench-tx.json @@ -14,7 +14,7 @@ "core_rows": 81445, "chiplets_rows": 11504, "poseidon2_permutation_rows": 55392, - "range_rows": 20669, + "range_rows": 20537, "chiplets_shape": { "hasher_rows": 8656, "bitwise_rows": 656, @@ -39,7 +39,7 @@ "core_rows": 13682, "chiplets_rows": 5872, "poseidon2_permutation_rows": 19888, - "range_rows": 1999, + "range_rows": 1989, "chiplets_shape": { "hasher_rows": 4224, "bitwise_rows": 848, @@ -65,7 +65,7 @@ "core_rows": 84045, "chiplets_rows": 13403, "poseidon2_permutation_rows": 55648, - "range_rows": 20245, + "range_rows": 20265, "chiplets_shape": { "hasher_rows": 10072, "bitwise_rows": 1032, @@ -91,7 +91,7 @@ "core_rows": 16282, "chiplets_rows": 7771, "poseidon2_permutation_rows": 20144, - "range_rows": 1407, + "range_rows": 1427, "chiplets_shape": { "hasher_rows": 5640, "bitwise_rows": 1224, @@ -114,7 +114,7 @@ "core_rows": 79635, "chiplets_rows": 10916, "poseidon2_permutation_rows": 53280, - "range_rows": 20429, + "range_rows": 20457, "chiplets_shape": { "hasher_rows": 8240, "bitwise_rows": 616, @@ -137,7 +137,7 @@ "core_rows": 11872, "chiplets_rows": 5284, "poseidon2_permutation_rows": 17728, - "range_rows": 1215, + "range_rows": 1217, "chiplets_shape": { "hasher_rows": 3808, "bitwise_rows": 808, @@ -863,7 +863,7 @@ "core_rows": 17359, "chiplets_rows": 7868, "poseidon2_permutation_rows": 23776, - "range_rows": 1543, + "range_rows": 1529, "chiplets_shape": { "hasher_rows": 5824, "bitwise_rows": 1160,