diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccb6aaedc..8588e56af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Changes +- [BREAKING] Added a `serial_number_block` argument to `fee::pay_fee` and `fee::create_and_fund_fee_note`. Multisigs use the signed proposal block to keep fee-note serial numbers stable across execution reference blocks; other standard auth components use the execution reference block ([#3836](https://github.com/0xMiden/protocol/issues/3836)). - Added a check that the guardian public key is not one of the approver public keys ([#3764](https://github.com/0xMiden/protocol/pull/3764)). - [BREAKING] Updated the Miden VM and crypto crate family to v0.31.0 and `midenc-hir-type` to v0.13.0. Execution proofs now include a format version and compatible VM and PVM verifier roots, and protocol deserialization rejects unversioned proof bytes from earlier releases. Verifier outcomes now report separate VM and precompile security parameters ([#3806](https://github.com/0xMiden/protocol/pull/3806)). - [BREAKING] Updated the Miden VM and crypto crate family to v0.30.0 and `midenc-hir-type` to v0.12.0. `LocalTransactionProver::new` now takes `miden_prover::Prover`, `CoreLibrary` exposes one merged package, and `TransactionVerifier::verify` now returns `VerificationOutcome` so callers can handle outstanding precompile work ([#3782](https://github.com/0xMiden/protocol/pull/3782)). diff --git a/crates/miden-standards/asm/components/auth/multisig/multisig.masm b/crates/miden-standards/asm/components/auth/multisig/multisig.masm index 6acc40cfe7..8a3f98c7f3 100644 --- a/crates/miden-standards/asm/components/auth/multisig/multisig.masm +++ b/crates/miden-standards/asm/components/auth/multisig/multisig.masm @@ -20,6 +20,8 @@ pub use {is_signer} from miden::standards::auth::multisig #! 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. +#! The fee note's serial number uses the block bound by the summary, so it stays unchanged at a +#! newer execution reference block when the account nonce is unchanged. #! #! Inputs: #! Operand stack: [AUTH_ARGS] @@ -48,6 +50,10 @@ pub proc auth_tx_multisig(auth_args: word) exec.signature::estimate_multisig_authentication_cycles # => [num_extra_cycles, CONVERSION_INFO, block_number, SALT] + # Use the signed block for the fee note and keep it for summary creation. + dup.5 movdn.5 + # => [num_extra_cycles, CONVERSION_INFO, block_number, block_number, SALT] + exec.fee::pay_fee drop # => [block_number, SALT] diff --git a/crates/miden-standards/asm/components/auth/network_account/network_account.masm b/crates/miden-standards/asm/components/auth/network_account/network_account.masm index e001d37716..26aaa9f61e 100644 --- a/crates/miden-standards/asm/components/auth/network_account/network_account.masm +++ b/crates/miden-standards/asm/components/auth/network_account/network_account.masm @@ -166,6 +166,10 @@ pub proc auth_network_transaction(auth_args: word) # => [num_extra_cycles, CONVERSION_INFO, collected_fee_amount, is_fee_asset_id_match, # sponsor_unlimited, pad(16)] + exec.tx::get_reference_block_number movdn.5 + # => [num_extra_cycles, CONVERSION_INFO, serial_number_block, collected_fee_amount, + # is_fee_asset_id_match, sponsor_unlimited, pad(16)] + exec.fee::pay_fee # => [sponsored_fee_amount, collected_fee_amount, is_fee_asset_id_match, sponsor_unlimited, pad(16)] diff --git a/crates/miden-standards/asm/components/auth/no_auth/no_auth.masm b/crates/miden-standards/asm/components/auth/no_auth/no_auth.masm index bbe650fce5..b906674b6a 100644 --- a/crates/miden-standards/asm/components/auth/no_auth/no_auth.masm +++ b/crates/miden-standards/asm/components/auth/no_auth/no_auth.masm @@ -1,5 +1,6 @@ use miden::protocol::active_account use miden::protocol::native_account +use miden::protocol::tx use miden::core::word use miden::standards::fee @@ -41,6 +42,9 @@ pub proc auth_no_auth push.NO_AUTH_POST_FEE_CYCLES # => [num_extra_cycles, CONVERSION_INFO, pad(16)] + exec.tx::get_reference_block_number movdn.5 + # => [num_extra_cycles, CONVERSION_INFO, serial_number_block, pad(16)] + exec.fee::pay_fee drop # => [pad(16)] diff --git a/crates/miden-standards/asm/components/auth/singlesig/singlesig.masm b/crates/miden-standards/asm/components/auth/singlesig/singlesig.masm index 0e54801963..854dca4487 100644 --- a/crates/miden-standards/asm/components/auth/singlesig/singlesig.masm +++ b/crates/miden-standards/asm/components/auth/singlesig/singlesig.masm @@ -5,6 +5,7 @@ use miden::standards::auth::signature use miden::standards::fee use miden::protocol::native_account +use miden::protocol::tx # CONSTANTS # ================================================================================================= @@ -71,6 +72,9 @@ pub proc auth_tx(auth_args: word) swap movdn.5 # => [num_extra_cycles, CONVERSION_INFO, scheme_id, pad(12)] + exec.tx::get_reference_block_number movdn.5 + # => [num_extra_cycles, CONVERSION_INFO, serial_number_block, scheme_id, pad(12)] + exec.fee::pay_fee drop # => [scheme_id, pad(12)] diff --git a/crates/miden-standards/asm/standards/fee/mod.masm b/crates/miden-standards/asm/standards/fee/mod.masm index 66fdd28a61..37dea46329 100644 --- a/crates/miden-standards/asm/standards/fee/mod.masm +++ b/crates/miden-standards/asm/standards/fee/mod.masm @@ -7,7 +7,7 @@ use miden::protocol::asset use miden::protocol::native_account use miden::protocol::output_note use miden::protocol::tx -use {Asset} from miden::protocol::types +use {Asset, BlockNumber} from miden::protocol::types use miden::standards::assets::fungible_asset use miden::standards::fees use miden::standards::notes::tx_fee @@ -52,7 +52,7 @@ const MAX_CONVERTED_AMOUNT_HI_LIMB = 0x80000000 const U32_LIMB_MULTIPLIER = 0x0000000100000000 # Domain-separation tag for the fee note's serial number ("fee" in hex): the serial number is -# derived as hash(FEE_DOMAIN || [ref_block_num, initial_nonce, account_id_suffix, +# derived as hash(FEE_DOMAIN || [serial_number_block, initial_nonce, account_id_suffix, # account_id_prefix]) with FEE_DOMAIN = [FEE_DOMAIN_TAG, 0, 0, 0], so a fee note's serial number # cannot collide with serial numbers derived from similar tuples in other contexts. const FEE_DOMAIN_TAG = 0xFEE @@ -263,35 +263,40 @@ end #! Creates a new TX_FEE output note and funds it with the provided asset from the #! account's vault. #! -#! The note's serial number is derived as hash(FEE_DOMAIN || [ref_block_num, initial_nonce, +#! The note's serial number is derived as hash(FEE_DOMAIN || [serial_number_block, initial_nonce, #! account_id_suffix, account_id_prefix]). #! -#! Inputs: [ASSET_ID, ASSET_VALUE] +#! Inputs: [serial_number_block, ASSET_ID, ASSET_VALUE] #! Outputs: [] #! #! Where: +#! - serial_number_block is the block number used to derive the note's serial number. #! - ASSET_ID is the asset ID of the fee payment asset. #! - ASSET_VALUE is the value of the fee payment asset. #! #! Panics if: +#! - serial_number_block is not a u32. #! - 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 create_and_fund_fee_note(asset: Asset) +pub proc create_and_fund_fee_note(serial_number_block: BlockNumber, asset: Asset) + u32assert + # => [serial_number_block, ASSET_ID, ASSET_VALUE] + # derive the note's serial number exec.native_account::get_id - # => [account_id_suffix, account_id_prefix, ASSET_ID, ASSET_VALUE] + # => [account_id_suffix, account_id_prefix, serial_number_block, ASSET_ID, ASSET_VALUE] exec.active_account::get_nonce - # => [initial_nonce, account_id_suffix, account_id_prefix, ASSET_ID, ASSET_VALUE] + # => [initial_nonce, account_id_suffix, account_id_prefix, serial_number_block, ASSET_ID, ASSET_VALUE] - exec.tx::get_reference_block_number - # => [ref_block_num, initial_nonce, account_id_suffix, account_id_prefix, ASSET_ID, ASSET_VALUE] + movup.3 + # => [serial_number_block, initial_nonce, account_id_suffix, account_id_prefix, ASSET_ID, ASSET_VALUE] # domain-separate the serial number by hashing the tuple with the FEE domain tag push.0.0.0 push.FEE_DOMAIN_TAG - # => [FEE_DOMAIN, [ref_block_num, initial_nonce, account_id_suffix, account_id_prefix], + # => [FEE_DOMAIN, [serial_number_block, initial_nonce, account_id_suffix, account_id_prefix], # ASSET_ID, ASSET_VALUE] exec.poseidon2::merge @@ -346,7 +351,7 @@ end #! in the cycle estimate and the fee. This prices each network note through its target account's #! fee policy via an FPI call, which requires that target to be provisioned as a foreign account. #! -#! Inputs: [num_extra_cycles, CONVERSION_INFO] +#! Inputs: [num_extra_cycles, CONVERSION_INFO, serial_number_block] #! Outputs: [total_sponsored_fee_amount] #! #! Where: @@ -354,6 +359,7 @@ end #! the end of the authentication procedure. #! - 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 the computed fee is zero). +#! - serial_number_block is the block number used to derive the fee note's serial number. #! - total_sponsored_fee_amount is the total amount the sponsorship step moved into sponsorship #! notes. #! @@ -365,68 +371,72 @@ end #! maximum cycle count. #! - the computed fee is non-zero and CONVERSION_INFO is the empty word. #! - the computed fee is non-zero and CONVERSION_INFO is not the native conversion info. +#! - the computed fee is non-zero and serial_number_block is not a u32. #! - the account vault holds less of the fee asset than the amount to be paid. #! - the maximum number of output notes is exceeded. #! #! Invocation: exec -pub proc pay_fee(num_extra_cycles: felt, conversion_info: ConversionInfo) -> felt +pub proc pay_fee(num_extra_cycles: felt, conversion_info: ConversionInfo, serial_number_block: BlockNumber) -> felt # sponsor any network output notes before the fee is computed, so the sponsorship notes # this creates are counted by both the cycle estimate and compute_fee. exec.tx::get_fee_asset_id - # => [FEE_ASSET_ID, num_extra_cycles, CONVERSION_INFO] + # => [FEE_ASSET_ID, num_extra_cycles, CONVERSION_INFO, serial_number_block] exec.fees::create_network_note_sponsorships - # => [total_sponsored_fee_amount, num_extra_cycles, CONVERSION_INFO] + # => [total_sponsored_fee_amount, num_extra_cycles, CONVERSION_INFO, serial_number_block] - movdn.5 - # => [num_extra_cycles, CONVERSION_INFO, total_sponsored_fee_amount] + movdn.6 + # => [num_extra_cycles, CONVERSION_INFO, serial_number_block, total_sponsored_fee_amount] exec.apply_cycle_margins - # => [num_estimated_extra_cycles, CONVERSION_INFO, total_sponsored_fee_amount] + # => [num_estimated_extra_cycles, CONVERSION_INFO, serial_number_block, total_sponsored_fee_amount] # no output notes are excluded from the fee computation padw movup.4 # => [num_estimated_extra_cycles, EXCLUDE_NOTES_COMMITMENT, CONVERSION_INFO, - # total_sponsored_fee_amount] + # serial_number_block, total_sponsored_fee_amount] exec.tx::compute_fee - # => [fee_amount, CONVERSION_INFO, total_sponsored_fee_amount] + # => [fee_amount, CONVERSION_INFO, serial_number_block, total_sponsored_fee_amount] dup eq.0 if.true # a zero fee requires no fee note - drop dropw + drop dropw drop # => [total_sponsored_fee_amount] else movdn.4 - # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] + # => [CONVERSION_INFO, fee_amount, serial_number_block, 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] + # => [CONVERSION_INFO, fee_amount, serial_number_block, total_sponsored_fee_amount] # pin the payment to the native fee asset at 1-to-1 rate dupw exec.native_conversion_info assert_eqw.err=ERR_FEE_CONVERSION_INFO_NOT_NATIVE - # => [CONVERSION_INFO, fee_amount, total_sponsored_fee_amount] + # => [CONVERSION_INFO, fee_amount, serial_number_block, 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] + # serial_number_block, total_sponsored_fee_amount] movup.2 swap # => [fee_amount, rate_num, rate_den, faucet_id_suffix, faucet_id_prefix, - # total_sponsored_fee_amount] + # serial_number_block, total_sponsored_fee_amount] exec.convert_amount - # => [payment_amount, faucet_id_suffix, faucet_id_prefix, total_sponsored_fee_amount] + # => [payment_amount, faucet_id_suffix, faucet_id_prefix, serial_number_block, total_sponsored_fee_amount] movdn.2 - # => [faucet_id_suffix, faucet_id_prefix, payment_amount, total_sponsored_fee_amount] + # => [faucet_id_suffix, faucet_id_prefix, payment_amount, serial_number_block, total_sponsored_fee_amount] exec.fungible_asset::create - # => [ASSET_ID, ASSET_VALUE, total_sponsored_fee_amount] + # => [ASSET_ID, ASSET_VALUE, serial_number_block, total_sponsored_fee_amount] + + movup.8 + # => [serial_number_block, ASSET_ID, ASSET_VALUE, total_sponsored_fee_amount] exec.create_and_fund_fee_note # => [total_sponsored_fee_amount] diff --git a/crates/miden-standards/src/note/tx_fee.rs b/crates/miden-standards/src/note/tx_fee.rs index ee2fceb0b6..9c1732cd75 100644 --- a/crates/miden-standards/src/note/tx_fee.rs +++ b/crates/miden-standards/src/note/tx_fee.rs @@ -139,23 +139,26 @@ impl TxFeeNote { /// Derives the serial number that `miden::standards::fee::pay_fee` uses for /// the TX_FEE note it creates during a transaction. /// - /// The serial number is `hash(FEE_DOMAIN || [ref_block_num, initial_nonce, + /// The serial number is `hash(FEE_DOMAIN || [serial_number_block, initial_nonce, /// account_id_suffix, account_id_prefix])` with the FEE domain tag `[0xFEE, 0, 0, 0]`. It is /// unique per (account, nonce) pair and lets clients precompute the note's recipient before /// executing the transaction, while the domain tag separates it from serial numbers derived /// from similar tuples in other contexts. /// + /// For multisigs, `serial_number_block` is the block bound by the signed summary. Other + /// standard auth components use the execution reference block. + /// /// This derivation must be kept in sync with `create_and_fund_fee_note` in the /// `miden::standards::fee` MASM module. pub fn derive_serial_number( sender: AccountId, initial_nonce: Felt, - ref_block_num: BlockNumber, + serial_number_block: BlockNumber, ) -> Word { // Domain-separation tag for the fee note's serial number ("fee" in hex). let fee_domain = Word::from([Felt::from(Self::TAG_ID), Felt::ZERO, Felt::ZERO, Felt::ZERO]); let tuple = Word::from([ - Felt::from(ref_block_num.as_u32()), + Felt::from(serial_number_block.as_u32()), initial_nonce, sender.suffix(), sender.prefix().as_felt(), diff --git a/crates/miden-testing/tests/auth/fee_payment/multisig.rs b/crates/miden-testing/tests/auth/fee_payment/multisig.rs index 1d0e93cc15..318bc6a986 100644 --- a/crates/miden-testing/tests/auth/fee_payment/multisig.rs +++ b/crates/miden-testing/tests/auth/fee_payment/multisig.rs @@ -1,10 +1,16 @@ +use core::num::NonZeroU16; + 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::transaction::{ExecutedTransaction, TransactionSummary}; use miden_protocol::{Word, ZERO}; use miden_standards::account::auth::{Approver, ApproverSet, FeeConversionInfo, MultisigAuthArgs}; -use miden_testing::{Auth, MockChain}; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::ERR_MULTISIG_APPROVAL_EXPIRED; +use miden_standards::note::TxFeeNote; +use miden_standards::tx_script::ExpirationTransactionScript; +use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::TransactionExecutorError; use miden_tx::auth::{BasicAuthenticator, SigningInputs, TransactionAuthenticator}; use rstest::rstest; @@ -147,13 +153,245 @@ async fn multisig_pays_fee_note(#[case] auth_scheme: AuthScheme) -> anyhow::Resu Ok(()) } +/// The transaction summary and fee note remain unchanged at a later reference block when the +/// account nonce, fee amount, and other signed effects are unchanged. The original signatures +/// remain valid until the approval expires. This test sets the expiration to 10 blocks after the +/// signed block. Execution succeeds at an offset of 9 blocks and fails at offsets of 10 and 11 +/// blocks. +#[rstest] +#[case::zero_fee(AuthScheme::EcdsaK256Keccak, 0, 9)] +#[case::falcon_before_expiration(AuthScheme::Falcon512Poseidon2, VERIFICATION_BASE_FEE, 9)] +#[case::ecdsa_before_expiration(AuthScheme::EcdsaK256Keccak, VERIFICATION_BASE_FEE, 9)] +#[case::ecdsa_at_expiration(AuthScheme::EcdsaK256Keccak, VERIFICATION_BASE_FEE, 10)] +#[case::ecdsa_after_expiration(AuthScheme::EcdsaK256Keccak, VERIFICATION_BASE_FEE, 11)] +#[tokio::test] +async fn multisig_fee_note_is_stable_across_reference_blocks( + #[case] auth_scheme: AuthScheme, + #[case] base_fee: u32, + #[case] blocks_advanced: u32, +) -> anyhow::Result<()> { + const APPROVAL_EXPIRATION_DELTA: u16 = 10; + + let (approver_set, signers) = multisig_fixture(2, 2, auth_scheme)?; + let fee_asset = FungibleAsset::new(ACCOUNT_ID_FEE_FAUCET.try_into()?, 1_000_000)?; + let mut builder = MockChain::builder().verification_base_fee(base_fee); + let account = builder.add_existing_wallet_with_assets( + Auth::Multisig { approver_set, proc_threshold_map: vec![] }, + [fee_asset.into()], + )?; + let mut mock_chain = builder.build()?; + let signed_block = mock_chain.latest_block_header().block_num(); + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([17u32, 18, 19, 20]))?; + let expiration_script = + ExpirationTransactionScript::new(NonZeroU16::new(APPROVAL_EXPIRATION_DELTA).unwrap()); + + let original_summary = mock_chain + .build_transaction(account.id()) + .tx_script(expiration_script.into()) + .tx_script_args(expiration_script.tx_script_args()) + .multisig_auth_args(auth_args) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let msg = original_summary.to_commitment(); + let signing_inputs = SigningInputs::TransactionSummary(original_summary); + let mut signatures = Vec::new(); + for (public_key, authenticator) in &signers { + let signature = + authenticator.get_signature(public_key.to_commitment(), &signing_inputs).await?; + signatures.push((public_key.to_commitment(), signature)); + } + + let mut original_builder = mock_chain + .build_transaction(account.id()) + .tx_script(expiration_script.into()) + .tx_script_args(expiration_script.tx_script_args()) + .multisig_auth_args(auth_args); + for (key, signature) in &signatures { + original_builder = original_builder.add_signature(*key, msg, signature.clone()); + } + let original_tx = original_builder.build()?.execute().await?; + + mock_chain.prove_until_block(signed_block + blocks_advanced)?; + + let mut later_builder = mock_chain + .build_transaction(account.id()) + .tx_script(expiration_script.into()) + .tx_script_args(expiration_script.tx_script_args()) + .multisig_auth_args(auth_args); + for (key, signature) in &signatures { + later_builder = later_builder.add_signature(*key, msg, signature.clone()); + } + if blocks_advanced >= u32::from(APPROVAL_EXPIRATION_DELTA) { + let result = later_builder.build()?.execute().await; + assert_transaction_executor_error!(result, ERR_MULTISIG_APPROVAL_EXPIRED); + return Ok(()); + } + + let later_summary = mock_chain + .build_transaction(account.id()) + .tx_script(expiration_script.into()) + .tx_script_args(expiration_script.tx_script_args()) + .multisig_auth_args(auth_args) + .add_signature(signatures[0].0, msg, signatures[0].1.clone()) + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + assert_eq!(later_summary.to_commitment(), msg); + + let later_tx = later_builder.build()?.execute().await?; + + assert_eq!(later_tx.block_header().block_num(), signed_block + blocks_advanced); + assert_eq!( + later_tx.expiration_block_num(), + signed_block + u32::from(APPROVAL_EXPIRATION_DELTA) + ); + assert_eq!(original_tx.output_notes().commitment(), later_tx.output_notes().commitment()); + if base_fee == 0 { + assert_eq!(later_tx.output_notes().num_notes(), 0); + } else { + assert_eq!(assert_single_fee_note(&original_tx)?, assert_single_fee_note(&later_tx)?); + let expected_serial = + TxFeeNote::derive_serial_number(account.id(), account.nonce(), signed_block); + let fee_note = later_tx.output_notes().get_note(0); + assert_eq!(fee_note.recipient().unwrap().serial_num(), expected_serial); + } + + Ok(()) +} + +/// This test uses a transaction script that executes a 65,536-iteration loop only when the +/// execution reference block differs from the signed block. The additional cycles increase the +/// fee without changing the signed block or account nonce. The fee note's recipient remains +/// unchanged, but its asset amount and the vault withdrawal increase, so execution requires new +/// signatures. +#[rstest] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] +#[tokio::test] +async fn multisig_rejects_original_signatures_when_fee_changes( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (approver_set, signers) = multisig_fixture(2, 2, auth_scheme)?; + let fee_asset = FungibleAsset::new(ACCOUNT_ID_FEE_FAUCET.try_into()?, 1_000_000)?; + 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.into()], + )?; + let mut mock_chain = builder.build()?; + let signed_block = mock_chain.latest_block_header().block_num(); + let auth_args = fee_paying_auth_args(&mock_chain, Word::from([21u32, 22, 23, 24]))?; + + // The conditional loop increases the cycle count enough to reach a higher fee cycle bucket. + // The script itself does not create notes or modify account state. + let tx_script = CodeBuilder::default().compile_tx_script(format!( + " + use miden::protocol::tx + + @transaction_script + pub proc main + exec.tx::get_reference_block_number push.{signed_block} neq + if.true + push.65536 + dup neq.0 + while.true + sub.1 dup neq.0 + end + drop + end + end + " + ))?; + let original_builder = mock_chain + .build_transaction(account.id()) + .tx_script(tx_script.clone()) + .multisig_auth_args(auth_args); + let original_summary = original_builder + .clone() + .build()? + .execute() + .await + .unwrap_err() + .unwrap_unauthorized_err(); + let original_msg = original_summary.to_commitment(); + let signing_inputs = SigningInputs::TransactionSummary(original_summary.clone()); + let mut signatures = Vec::new(); + let mut signed_builder = original_builder; + for (key, authenticator) in &signers { + let signature = authenticator.get_signature(key.to_commitment(), &signing_inputs).await?; + signed_builder = + signed_builder.add_signature(key.to_commitment(), original_msg, signature.clone()); + signatures.push((key.to_commitment(), signature)); + } + let original_tx = signed_builder.build()?.execute().await?; + let original_fee = assert_single_fee_note(&original_tx)?; + + // Do not apply the original transaction: the account and its nonce must remain unchanged. + mock_chain.prove_until_block(signed_block + 5)?; + let later_builder = mock_chain + .build_transaction(account.id()) + .tx_script(tx_script) + .multisig_auth_args(auth_args); + let mut stale_builder = later_builder.clone(); + for (key, signature) in signatures { + stale_builder = stale_builder.add_signature(key, original_msg, signature); + } + let later_summary = + stale_builder.build()?.execute().await.unwrap_err().unwrap_unauthorized_err(); + + assert_eq!(later_summary.metadata(), original_summary.metadata()); + assert_eq!(later_summary.block_commitment(), original_summary.block_commitment()); + assert_eq!(later_summary.user_params(), original_summary.user_params()); + assert_eq!(later_summary.input_notes(), original_summary.input_notes()); + assert_eq!( + later_summary.account_delta().storage(), + original_summary.account_delta().storage() + ); + assert_eq!( + later_summary.account_delta().nonce_delta(), + original_summary.account_delta().nonce_delta() + ); + assert_ne!(later_summary.account_delta().vault(), original_summary.account_delta().vault()); + let later_msg = later_summary.to_commitment(); + assert_ne!(later_msg, original_msg); + + // Fresh approvals must succeed, ruling out an unrelated execution failure. + let signing_inputs = SigningInputs::TransactionSummary(later_summary); + let mut signed_builder = later_builder; + for (key, authenticator) in signers { + let signature = authenticator.get_signature(key.to_commitment(), &signing_inputs).await?; + signed_builder = signed_builder.add_signature(key.to_commitment(), later_msg, signature); + } + let later_tx = signed_builder.build()?.execute().await?; + let later_fee = assert_single_fee_note(&later_tx)?; + assert!(later_fee.amount() > original_fee.amount()); + assert_eq!(later_tx.initial_account().nonce(), original_tx.initial_account().nonce()); + assert_eq!( + later_tx.output_notes().get_note(0).recipient(), + original_tx.output_notes().get_note(0).recipient() + ); + assert_ne!(later_tx.output_notes().commitment(), original_tx.output_notes().commitment()); + + Ok(()) +} + /// On a fee-charging chain, replaying a signed multisig transaction (same auth args and /// signatures) is rejected: after the first execution the account nonce advances, so the replayed /// transaction's fee note serial number and thus its summary commitment differ from the signed /// one, and the stale signatures fail verification. +#[rstest] +#[case::falcon(AuthScheme::Falcon512Poseidon2)] +#[case::ecdsa(AuthScheme::EcdsaK256Keccak)] #[tokio::test] -async fn multisig_fee_payment_preserves_replay_protection() -> anyhow::Result<()> { - let (approver_set, signers) = multisig_fixture(2, 2, AuthScheme::Falcon512Poseidon2)?; +async fn multisig_fee_payment_preserves_replay_protection( + #[case] auth_scheme: AuthScheme, +) -> anyhow::Result<()> { + let (approver_set, signers) = multisig_fixture(2, 2, auth_scheme)?; let fee_faucet_id = ACCOUNT_ID_FEE_FAUCET.try_into()?; let fee_asset: Asset = FungibleAsset::new(fee_faucet_id, 1_000_000)?.into(); diff --git a/docs/src/fees.md b/docs/src/fees.md index a298469193..fee4ef0718 100644 --- a/docs/src/fees.md +++ b/docs/src/fees.md @@ -21,6 +21,8 @@ There are two distinct quantities involved in paying a fee: Nothing at the protocol level validates the fee note. The authentication procedure (via `pay_fee`) is currently the only place the paid amount is checked against the computed fee. +`pay_fee` takes a `serial_number_block` argument and passes it to `create_and_fund_fee_note` to derive the fee note's serial number. Multisig accounts pass the block bound by the signed summary; other standard auth components pass the execution reference block. This lets multisig approvals execute against a newer reference block when the account nonce, fee amount, and other signed effects remain unchanged. Fee computation and foreign account reads still use the execution reference block. + ## How fees are paid - The account’s authentication procedure computes the fee via `compute_fee` and creates a TX_FEE note funded from the account’s vault with the native fee asset, before the transaction summary is created - so the fee note and the vault withdrawal are covered by the transaction signature. Standard auth components do this automatically via the pay_fee procedures in the `miden::standards::fee` module.