From 4fd0defa542e13dbd77e1e1b7ba684611aa2e0c2 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:45:05 +0200 Subject: [PATCH 1/8] fix(rpc): require fee notes for all transactions --- .../block-producer/src/domain/transaction.rs | 52 +++----------- crates/block-producer/src/errors.rs | 2 +- crates/rpc/src/server/api.rs | 12 +++- crates/rpc/src/server/api/submit_auth_tx.rs | 10 +-- .../src/server/api/submit_auth_tx_batch.rs | 3 +- crates/rpc/src/server/api/submit_proven_tx.rs | 14 ++-- .../src/server/api/submit_proven_tx_batch.rs | 11 ++- crates/rpc/src/tests.rs | 68 +++++++------------ docs/external/src/rpc/errors-and-limits.md | 20 +++--- 9 files changed, 82 insertions(+), 110 deletions(-) diff --git a/crates/block-producer/src/domain/transaction.rs b/crates/block-producer/src/domain/transaction.rs index 55a2889864..67f6dc4bc3 100644 --- a/crates/block-producer/src/domain/transaction.rs +++ b/crates/block-producer/src/domain/transaction.rs @@ -1,33 +1,16 @@ -use miden_protocol::Word; -use miden_protocol::block::FeeParameters; use miden_protocol::transaction::ProvenTransaction; use miden_standards::note::TxFeeNote; use crate::errors::MempoolSubmissionError; -/// Ensures that a transaction pays a non-zero fee when fees are enabled. +/// Ensures that a transaction creates a canonical fee note. /// -/// A zero verification base fee disables this check. Otherwise, the transaction must contain a -/// canonical fee output note with at least one non-zero asset. Validating that the amount is -/// sufficient for the transaction's execution cost is handled separately. -pub fn ensure_transaction_has_fee( - tx: &ProvenTransaction, - fee_parameters: &FeeParameters, -) -> Result<(), MempoolSubmissionError> { - if fee_parameters.verification_base_fee() == 0 { - return Ok(()); - } - +/// This check does not validate that the fee is sufficient for the transaction execution cost. +pub fn ensure_transaction_has_fee(tx: &ProvenTransaction) -> Result<(), MempoolSubmissionError> { let fee_script_root = TxFeeNote::script_root(); let contains_fee = tx.output_notes().iter().any(|note| { - let has_fee_script = note - .recipient() - .is_some_and(|recipient| recipient.script().root() == fee_script_root); - let has_non_zero_asset = note.assets().is_some_and(|assets| { - assets.iter().any(|asset| asset.to_value_word() != Word::empty()) - }); - - has_fee_script && has_non_zero_asset + note.recipient() + .is_some_and(|recipient| recipient.script().root() == fee_script_root) }); if contains_fee { @@ -43,7 +26,6 @@ mod tests { use miden_node_proto::{BuildUnchecked, DecodeMessage}; use miden_protocol::Word; use miden_protocol::asset::FungibleAsset; - use miden_protocol::block::FeeParameters; use miden_protocol::transaction::{OutputNote, ProvenTransaction, PublicOutputNote}; use miden_standards::note::TxFeeNote; @@ -63,10 +45,6 @@ mod tests { assert_eq!(decoded, transaction); } - fn fee_parameters(verification_base_fee: u32) -> FeeParameters { - FeeParameters::new(verification_base_fee) - } - fn transaction_with_fee_amount(amount: u64) -> ProvenTransaction { let fee_note = TxFeeNote::builder() .sender(mock_account_id(1)) @@ -85,33 +63,23 @@ mod tests { fn transaction_fee_requires_the_canonical_note_script() { let tx = transaction_with_fee_amount(1); - ensure_transaction_has_fee(&tx, &fee_parameters(1)).unwrap(); + ensure_transaction_has_fee(&tx).unwrap(); } #[test] - fn transaction_without_fee_is_rejected_when_fees_are_enabled() { + fn transaction_without_fee_is_rejected() { let tx = MockProvenTxBuilder::with_account_index(1).build(); assert_matches!( - ensure_transaction_has_fee(&tx, &fee_parameters(1)), + ensure_transaction_has_fee(&tx), Err(MempoolSubmissionError::MissingFee { transaction_id }) if transaction_id == tx.id() ); } #[test] - fn transaction_with_zero_fee_asset_is_rejected_when_fees_are_enabled() { + fn transaction_with_zero_fee_asset_is_accepted() { let tx = transaction_with_fee_amount(0); - assert_matches!( - ensure_transaction_has_fee(&tx, &fee_parameters(1)), - Err(MempoolSubmissionError::MissingFee { transaction_id }) if transaction_id == tx.id() - ); - } - - #[test] - fn transaction_without_fee_is_accepted_when_fees_are_disabled() { - let tx = MockProvenTxBuilder::with_account_index(1).build(); - - ensure_transaction_has_fee(&tx, &fee_parameters(0)).unwrap(); + ensure_transaction_has_fee(&tx).unwrap(); } } diff --git a/crates/block-producer/src/errors.rs b/crates/block-producer/src/errors.rs index cb151b1d37..ce19b30dca 100644 --- a/crates/block-producer/src/errors.rs +++ b/crates/block-producer/src/errors.rs @@ -76,7 +76,7 @@ pub enum MempoolSubmissionError { #[error("the mempool is at capacity")] CapacityExceeded, - #[error("transaction {transaction_id} does not contain a non-zero TX_FEE output note")] + #[error("transaction {transaction_id} does not contain a canonical TX_FEE output note")] MissingFee { transaction_id: TransactionId }, #[error("transaction {transaction_id} consumes in-flight TX_FEE notes: {note_ids:?}")] diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index 5127f41a7c..f8620f1a77 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -2,7 +2,7 @@ use std::num::NonZeroUsize; use std::sync::{Arc, LazyLock}; use anyhow::Context as AnyhowContext; -use miden_node_block_producer::BlockProducerApi; +use miden_node_block_producer::{BlockProducerApi, ensure_transaction_has_fee}; use miden_node_proto::clients::NtxBuilderClient; use miden_node_proto::domain::block::InvalidBlockRange; use miden_node_proto::generated::rpc::MempoolStats as ProtoMempoolStats; @@ -23,6 +23,7 @@ use miden_node_utils::lru_cache::LruCache; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; +use miden_protocol::transaction::ProvenTransaction; use tokio::sync::Semaphore; use tonic::metadata::MetadataMap; use tonic::{Request, Status}; @@ -68,6 +69,15 @@ pub(crate) async fn submit_batch_to_validators( Ok(()) } +/// Rejects a submission if a transaction does not create a canonical fee note. +pub(crate) fn ensure_transactions_have_fee_notes<'a>( + transactions: impl IntoIterator, +) -> tonic::Result<()> { + transactions + .into_iter() + .try_for_each(|tx| ensure_transaction_has_fee(tx).map_err(Status::from)) +} + // API METHODS // ================================================================================================ diff --git a/crates/rpc/src/server/api/submit_auth_tx.rs b/crates/rpc/src/server/api/submit_auth_tx.rs index 46e97a2f2a..bb25f6c7a3 100644 --- a/crates/rpc/src/server/api/submit_auth_tx.rs +++ b/crates/rpc/src/server/api/submit_auth_tx.rs @@ -1,11 +1,14 @@ -use miden_node_block_producer::ensure_transaction_has_fee; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; use miden_node_proto::generated::server::sequencer_api; use miden_node_proto::{DecodeMessageExt, generated as proto}; use miden_node_tracing::ErrorReport; use tonic::Status; -use super::{SequencerInternalService, get_block_header_error_to_status}; +use super::{ + SequencerInternalService, + ensure_transactions_have_fee_notes, + get_block_header_error_to_status, +}; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { @@ -54,8 +57,7 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { ))); } - ensure_transaction_has_fee(tx.raw_proven_transaction(), reference_header.fee_parameters()) - .map_err(Status::from)?; + ensure_transactions_have_fee_notes([tx.raw_proven_transaction()])?; self.block_producer .submit_authenticated_tx(tx) diff --git a/crates/rpc/src/server/api/submit_auth_tx_batch.rs b/crates/rpc/src/server/api/submit_auth_tx_batch.rs index 01fbf04bb0..2a5e8d77ce 100644 --- a/crates/rpc/src/server/api/submit_auth_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_auth_tx_batch.rs @@ -3,7 +3,7 @@ use miden_node_proto::{DecodeMessageExt, generated as proto}; use miden_node_tracing::spawn::spawn_blocking_in_current_span; use tonic::Status; -use super::SequencerInternalService; +use super::{SequencerInternalService, ensure_transactions_have_fee_notes}; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { @@ -39,6 +39,7 @@ impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { for tx in batch.transactions() { self.account_admission.check(tx.account_update()).await?; } + ensure_transactions_have_fee_notes(batch.transactions().iter().map(AsRef::as_ref))?; self.block_producer .submit_authenticated_tx_batch(batch, inputs) diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index 1b06705d2f..7dd6aa7aef 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -1,4 +1,3 @@ -use miden_node_block_producer::ensure_transaction_has_fee; use miden_node_block_producer::store::get_tx_inputs; use miden_node_proto::clients::{SequencerClient, ValidatorClient}; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; @@ -15,7 +14,13 @@ use miden_protocol::transaction::{ }; use tonic::{Request, Status}; -use super::{COMPONENT, RpcBackend, RpcService, submit_tx_to_validators}; +use super::{ + COMPONENT, + RpcBackend, + RpcService, + ensure_transactions_have_fee_notes, + submit_tx_to_validators, +}; use crate::LOG_TARGET; #[tonic::async_trait] @@ -73,10 +78,9 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { } // Verify the reference block is actually part of the chain. - let reference_header = self - .verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) + self.verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) .await?; - ensure_transaction_has_fee(&tx, reference_header.fee_parameters()).map_err(Status::from)?; + ensure_transactions_have_fee_notes([&tx])?; // Rebuild a new ProvenTransaction with decorators removed from output notes let account_update = TxAccountUpdate::new( diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index 6efe93eb0f..57f45a3997 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -8,7 +8,12 @@ use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_tx_batch::BatchVerifier; use tonic::{Request, Status}; -use super::{RpcBackend, RpcService, submit_batch_to_validators}; +use super::{ + RpcBackend, + RpcService, + ensure_transactions_have_fee_notes, + submit_batch_to_validators, +}; use crate::{COMPONENT, LOG_TARGET}; #[tonic::async_trait] @@ -68,6 +73,10 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { batch.reference_block.commitment = proven_batch.reference_block_commitment() ); + ensure_transactions_have_fee_notes( + proposed_batch.transactions().iter().map(AsRef::as_ref), + )?; + debug!(target: LOG_TARGET, "Submitting transaction batch"); if let RpcBackend::Sequencer { account_admission, .. } = &self.backend { diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 039fd9dc76..7e9a99d1a4 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -60,7 +60,6 @@ use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::batch::ProposedBatch; use miden_protocol::block::{ BlockSignatures, - FeeParameters, ProvenBlock, SignedBlock, ValidatorConfig, @@ -91,7 +90,11 @@ use tonic::metadata::MetadataMap; use url::Url; use crate::server::RpcBackend; -use crate::server::api::{RpcService, SequencerInternalService}; +use crate::server::api::{ + RpcService, + SequencerInternalService, + ensure_transactions_have_fee_notes, +}; use crate::{AccountAdmission, PreAuthSubmission, Rpc, RpcMode, ValidatorClients}; mod allowlist; @@ -146,13 +149,8 @@ impl TestStore { } async fn start() -> Self { - Self::start_with_base_fee(0).await - } - - async fn start_with_base_fee(verification_base_fee: u32) -> Self { let data_directory = new_tempdir(); - let genesis_commitment = - Self::bootstrap_with_base_fee(&data_directory, verification_base_fee); + let genesis_commitment = Self::bootstrap(&data_directory); let (state, writer, ..) = State::for_tests(&data_directory).await; Self { state, @@ -179,18 +177,13 @@ impl TestStore { } fn bootstrap(path: &std::path::Path) -> Word { - Self::bootstrap_with_base_fee(path, 0) - } - - fn bootstrap_with_base_fee(path: &std::path::Path, verification_base_fee: u32) -> Word { let config = GenesisConfig::default(); let validator_key = miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey::read_from_bytes(&[7; 32]) .expect("test signing key should decode") .public_key(); let validator_config = ValidatorConfig::new(vec![validator_key], 1).unwrap(); - let (mut genesis_state, _) = config.into_state(validator_config).unwrap(); - genesis_state.fee_parameters = FeeParameters::new(verification_base_fee); + let (genesis_state, _) = config.into_state(validator_config).unwrap(); let genesis_block = genesis_state.clone().into_block().expect("genesis block should be created"); let genesis_commitment = genesis_block.inner().header().commitment(); @@ -598,7 +591,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { #[tokio::test] async fn rpc_server_rejects_proven_transactions_without_fees() { - let store = TestStore::start_with_base_fee(1).await; + let store = TestStore::start().await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, false); @@ -619,14 +612,27 @@ async fn rpc_server_rejects_proven_transactions_without_fees() { assert_eq!(status.code(), tonic::Code::InvalidArgument); assert_eq!(status.details(), &[4]); assert!( - status.message().contains("does not contain a non-zero TX_FEE output note"), + status.message().contains("does not contain a canonical TX_FEE output note"), "expected the missing-fee error, got: {status}" ); } +#[test] +fn rpc_fee_gate_rejects_a_feeless_transaction_in_a_batch() { + let (account, patch) = build_test_account([0; 32]); + let paid = build_test_proven_tx_with_fee(&account, &patch, Word::empty(), true); + let feeless = build_test_proven_tx_with_fee(&account, &patch, Word::empty(), false); + + let status = ensure_transactions_have_fee_notes([&paid, &feeless]).unwrap_err(); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert_eq!(status.details(), &[4]); + assert!(status.message().contains(&feeless.id().to_string())); +} + #[tokio::test] async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { - let store = TestStore::start_with_base_fee(1).await; + let store = TestStore::start().await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, false); @@ -660,34 +666,6 @@ async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { assert_eq!(block_producer.status().await.mempool_stats.uncommitted_transactions, 0); } -#[tokio::test] -async fn rpc_server_does_not_require_fees_when_the_base_fee_is_zero() { - let store = TestStore::start().await; - let genesis = store.genesis_commitment(); - let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, false); - let request = proto::submission::ProvenTransactionSubmission { - transaction: Some((&tx).into()), - sealed_transaction_inputs: Some(test_sealed_transaction_inputs()), - }; - - let service = RpcService::new( - Arc::clone(&store.state), - RpcBackend::full_node(source_rpc_client(), None), - None, - NonZeroUsize::new(1_000_000).unwrap(), - None, - ); - - // The dummy proof is rejected later, demonstrating that the transaction passed the fee gate. - let status = service.submit_proven_tx(Request::new(request)).await.unwrap_err(); - assert_ne!(status.details(), &[4]); - assert!( - status.message().contains("Invalid proof for transaction"), - "expected proof validation after the fee gate, got: {status}" - ); -} - #[tokio::test] async fn rpc_server_rejects_invalid_deferred_transaction_proofs() { let store = TestStore::start().await; diff --git a/docs/external/src/rpc/errors-and-limits.md b/docs/external/src/rpc/errors-and-limits.md index 8f172f5f55..ae9cf66770 100644 --- a/docs/external/src/rpc/errors-and-limits.md +++ b/docs/external/src/rpc/errors-and-limits.md @@ -37,13 +37,13 @@ If you are missing specific error information that could be useful, please open `SubmitProvenTx` and `SubmitProvenTxBatch` may return the following detail codes when a transaction or batch is rejected during submission validation or by the sequencer's mempool. -| Error | Value | gRPC status | Meaning | -| ------------------ | ----- | ------------------ | ------------------------------------ | -| `Internal` | `0` | `INTERNAL` | Internal submission failure | -| `Expired` | `1` | `INVALID_ARGUMENT` | Transaction expired | -| `StateConflict` | `2` | `INVALID_ARGUMENT` | State conflict | -| `CapacityExceeded` | `3` | `INVALID_ARGUMENT` | Mempool capacity exceeded | -| `MissingFee` | `4` | `INVALID_ARGUMENT` | Transaction has no non-zero fee note | +| Error | Value | gRPC status | Meaning | +| ------------------ | ----- | ------------------ | ------------------------------------- | +| `Internal` | `0` | `INTERNAL` | Internal submission failure | +| `Expired` | `1` | `INVALID_ARGUMENT` | Transaction expired | +| `StateConflict` | `2` | `INVALID_ARGUMENT` | State conflict | +| `CapacityExceeded` | `3` | `INVALID_ARGUMENT` | Mempool capacity exceeded | +| `MissingFee` | `4` | `INVALID_ARGUMENT` | Transaction has no canonical fee note | `Expired` means the transaction or batch has expired, or will expire too soon for the sequencer to consider accepting it. @@ -54,9 +54,9 @@ conflict, and use the detail byte when a client needs stable branching between b `CapacityExceeded` means the mempool capacity has been exhausted and is under load. -`MissingFee` is returned only by `SubmitProvenTx`. It means that the submitted transaction requires a fee but does not -contain an output note with the canonical `TX_FEE` script and a non-zero asset. Transactions that do not require a fee -remain valid without a fee note. This check does not establish that the fee amount is sufficient. +`MissingFee` means that a transaction submitted through `SubmitProvenTx` or `SubmitProvenTxBatch` does not contain an +output note with the canonical `TX_FEE` script. This check applies to all submitted transactions. It does not establish +that the fee amount is sufficient. A fee note can contain a zero-valued asset when the required fee is zero. ### Encrypted input errors From f613f7571dbc6b583444ffe4d2625de52e725959 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:27:47 +0200 Subject: [PATCH 2/8] test: preserve fee-note admission coverage with protocol 0.17 --- .../src/server/api/submit_auth_tx_batch.rs | 1 + .../src/server/api/submit_proven_tx_batch.rs | 10 +++---- crates/rpc/src/tests.rs | 13 ++++------ crates/rpc/src/tests/allowlist.rs | 26 +++++++++++++++---- crates/utils/src/testing.rs | 9 +++++-- 5 files changed, 39 insertions(+), 20 deletions(-) diff --git a/crates/rpc/src/server/api/submit_auth_tx_batch.rs b/crates/rpc/src/server/api/submit_auth_tx_batch.rs index 2a5e8d77ce..a4ca4872b4 100644 --- a/crates/rpc/src/server/api/submit_auth_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_auth_tx_batch.rs @@ -39,6 +39,7 @@ impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { for tx in batch.transactions() { self.account_admission.check(tx.account_update()).await?; } + ensure_transactions_have_fee_notes(batch.transactions().iter().map(AsRef::as_ref))?; self.block_producer diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index 57f45a3997..762d3d9835 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -73,10 +73,6 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { batch.reference_block.commitment = proven_batch.reference_block_commitment() ); - ensure_transactions_have_fee_notes( - proposed_batch.transactions().iter().map(AsRef::as_ref), - )?; - debug!(target: LOG_TARGET, "Submitting transaction batch"); if let RpcBackend::Sequencer { account_admission, .. } = &self.backend { @@ -85,7 +81,11 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { } } - // Verify the reference block is actually part of the chain. + ensure_transactions_have_fee_notes( + proposed_batch.transactions().iter().map(AsRef::as_ref), + )?; + + // Verify that the reference block is part of the chain. self.verify_reference_commitment( proven_batch.reference_block_num(), proven_batch.reference_block_commitment(), diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 7e9a99d1a4..336aa30d1c 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -58,12 +58,7 @@ use miden_protocol::account::{ }; use miden_protocol::asset::{Asset, FungibleAsset}; use miden_protocol::batch::ProposedBatch; -use miden_protocol::block::{ - BlockSignatures, - ProvenBlock, - SignedBlock, - ValidatorConfig, -}; +use miden_protocol::block::{BlockSignatures, ProvenBlock, SignedBlock, ValidatorConfig}; use miden_protocol::note::NoteType; use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; @@ -344,14 +339,16 @@ struct ValidBatchFixture { } async fn build_valid_batch_fixture() -> ValidBatchFixture { - let mut mock_chain_builder = MockChainBuilder::new(); + let mut mock_chain_builder = MockChainBuilder::new() + .fee_faucet_id(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()) + .verification_base_fee(1); let account = mock_chain_builder .add_existing_wallet(Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, }) .unwrap(); let asset: Asset = - FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 100) + FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 1_000_000) .unwrap() .into(); let note = mock_chain_builder diff --git a/crates/rpc/src/tests/allowlist.rs b/crates/rpc/src/tests/allowlist.rs index 88a371f631..2062d03f15 100644 --- a/crates/rpc/src/tests/allowlist.rs +++ b/crates/rpc/src/tests/allowlist.rs @@ -9,18 +9,34 @@ use super::*; impl TestStore { async fn with_account_creation_batch() -> (Self, ProposedBatch) { - let mut builder = MockChainBuilder::new(); + let mut builder = MockChainBuilder::new() + .fee_faucet_id(FungibleAsset::mock_issuer()) + .verification_base_fee(1); let accounts = [ - builder.create_new_wallet(Auth::IncrNonce).unwrap(), - builder.create_new_wallet(Auth::IncrNonce).unwrap(), + builder.create_new_wallet(Auth::basic_ecdsa()).unwrap(), + builder.create_new_wallet(Auth::basic_ecdsa()).unwrap(), ]; + let notes = accounts.each_ref().map(|account| { + builder + .add_p2id_note( + account.id(), + account.id(), + &[FungibleAsset::mock(1_000_000)], + NoteType::Private, + ) + .unwrap() + }); let chain = builder.build().unwrap(); let store = Self::start_from_mock_genesis(&chain.latest_block(), chain.protocol_config()).await; let mut transactions = Vec::new(); // Batch decoding verifies each transaction proof before the admission check. - for account in accounts { - let context = chain.build_transaction(account).build().unwrap(); + for (account, note) in accounts.into_iter().zip(notes) { + let context = chain + .build_transaction(account) + .authenticated_input_note(note.id()) + .build() + .unwrap(); let executed = Box::pin(context.execute()).await.unwrap(); let inputs = executed.tx_inputs().clone(); let proven = spawn_blocking_in_current_span(move || { diff --git a/crates/utils/src/testing.rs b/crates/utils/src/testing.rs index 58a87fd8bb..d761712945 100644 --- a/crates/utils/src/testing.rs +++ b/crates/utils/src/testing.rs @@ -3,6 +3,7 @@ use miden_processor::{ExecutionOptions, FastProcessor}; use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::account::AccountUpdateDetails; +use miden_protocol::asset::FungibleAsset; use miden_protocol::block::{BlockSignatures, SignedBlock}; use miden_protocol::note::NoteType; use miden_protocol::transaction::{ @@ -35,9 +36,13 @@ pub async fn deferred_transaction_fixture() -> &'static DeferredTransactionFixtu static FIXTURE: OnceCell = OnceCell::const_new(); FIXTURE .get_or_init(|| async { - let mut builder = MockChainBuilder::new().verification_base_fee(0); + let mut builder = MockChainBuilder::new() + .fee_faucet_id(FungibleAsset::mock_issuer()) + .verification_base_fee(1); let account = builder.add_existing_wallet(Auth::basic_ecdsa()).unwrap(); - let note = builder.add_p2any_note(account.id(), NoteType::Private, []).unwrap(); + let note = builder + .add_p2any_note(account.id(), NoteType::Private, [FungibleAsset::mock(1_000_000)]) + .unwrap(); let chain = builder.build().unwrap(); let (header, body, ..) = chain.latest_block().into_parts(); let genesis = From bc05d97474f937bec0fa3333c69996ae44263926 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:48:54 +0200 Subject: [PATCH 3/8] test: supply protocol fee conversion arguments in RPC fixtures --- crates/rpc/src/tests.rs | 8 ++++++++ crates/rpc/src/tests/allowlist.rs | 6 ++++++ crates/utils/src/testing.rs | 9 ++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 336aa30d1c..23ea272e29 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -71,6 +71,7 @@ use miden_protocol::transaction::{ }; use miden_protocol::utils::serde::Deserializable; use miden_protocol::vm::ExecutionProof; +use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info}; use miden_standards::account::wallets::BasicWallet; use miden_standards::note::TxFeeNote; use miden_testing::{Auth, MockChainBuilder}; @@ -363,9 +364,16 @@ async fn build_valid_batch_fixture() -> ValidBatchFixture { let genesis_block = mock_chain.latest_block(); let protocol_config = mock_chain.protocol_config().clone(); + let (auth_args, advice) = commit_fee_conversion_info( + FeeConversionInfo::one_to_one(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()), + Word::from([9u32, 10, 11, 12]), + ); + let tx_context = mock_chain .build_transaction(account.id()) .authenticated_input_note(note.id()) + .auth_args(auth_args) + .add_advice_map_entry(auth_args, advice) .build() .unwrap(); let executed_tx = Box::pin(tx_context.execute()).await.unwrap(); diff --git a/crates/rpc/src/tests/allowlist.rs b/crates/rpc/src/tests/allowlist.rs index 2062d03f15..2940d7c137 100644 --- a/crates/rpc/src/tests/allowlist.rs +++ b/crates/rpc/src/tests/allowlist.rs @@ -32,9 +32,15 @@ impl TestStore { let mut transactions = Vec::new(); // Batch decoding verifies each transaction proof before the admission check. for (account, note) in accounts.into_iter().zip(notes) { + let (auth_args, advice) = commit_fee_conversion_info( + FeeConversionInfo::one_to_one(FungibleAsset::mock_issuer()), + Word::from([9u32, 10, 11, 12]), + ); let context = chain .build_transaction(account) .authenticated_input_note(note.id()) + .auth_args(auth_args) + .add_advice_map_entry(auth_args, advice) .build() .unwrap(); let executed = Box::pin(context.execute()).await.unwrap(); diff --git a/crates/utils/src/testing.rs b/crates/utils/src/testing.rs index d761712945..a4cc45da6c 100644 --- a/crates/utils/src/testing.rs +++ b/crates/utils/src/testing.rs @@ -1,7 +1,6 @@ //! Real transaction proofs for submission tests. use miden_processor::{ExecutionOptions, FastProcessor}; -use miden_protocol::MIN_PROOF_SECURITY_LEVEL; use miden_protocol::account::AccountUpdateDetails; use miden_protocol::asset::FungibleAsset; use miden_protocol::block::{BlockSignatures, SignedBlock}; @@ -14,6 +13,8 @@ use miden_protocol::transaction::{ TxAccountUpdate, }; use miden_protocol::vm::{ExecutionProof, PrecompileStatus}; +use miden_protocol::{MIN_PROOF_SECURITY_LEVEL, Word}; +use miden_standards::account::auth::{FeeConversionInfo, commit_fee_conversion_info}; use miden_testing::{Auth, MockChainBuilder}; use miden_tx::{ AccountProcedureIndexMap, @@ -47,10 +48,16 @@ pub async fn deferred_transaction_fixture() -> &'static DeferredTransactionFixtu let (header, body, ..) = chain.latest_block().into_parts(); let genesis = SignedBlock::new_unchecked(header, body, BlockSignatures::new(Vec::new()).unwrap()); + let (auth_args, advice) = commit_fee_conversion_info( + FeeConversionInfo::one_to_one(FungibleAsset::mock_issuer()), + Word::from([9u32, 10, 11, 12]), + ); let executed = Box::pin( chain .build_transaction(account.id()) .authenticated_input_note(note.id()) + .auth_args(auth_args) + .add_advice_map_entry(auth_args, advice) .build() .unwrap() .execute(), From 468bb655530993c3d9676ab661de0be77502b4e1 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:50:14 +0200 Subject: [PATCH 4/8] test: enable standards for fee-paying proof fixtures --- Cargo.lock | 1 + crates/utils/Cargo.toml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f7061be748..79bf0dac56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4415,6 +4415,7 @@ dependencies = [ "miden-node-tracing", "miden-processor", "miden-protocol", + "miden-standards", "miden-testing", "miden-tx", "reqwest", diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 8d789d1f98..e1a3dbd59a 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -21,7 +21,7 @@ doctest = false rocksdb = ["dep:miden-crypto", "miden-crypto/rocksdb"] # Enables test-only utilities. testing = ["miden-protocol/testing"] -testing-prover = ["dep:miden-processor", "dep:miden-testing", "dep:miden-tx", "testing"] +testing-prover = ["dep:miden-processor", "dep:miden-standards", "dep:miden-testing", "dep:miden-tx", "testing"] [dependencies] anyhow = { workspace = true } @@ -35,6 +35,7 @@ lru = { workspace = true } miden-node-tracing = { workspace = true } miden-processor = { optional = true, workspace = true } miden-protocol = { workspace = true } +miden-standards = { optional = true, workspace = true } miden-testing = { optional = true, workspace = true } miden-tx = { optional = true, workspace = true } reqwest = { workspace = true } From 3c45823d0cfeb2959c4d70ac7f1c778e89c10ac2 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:54:31 +0200 Subject: [PATCH 5/8] test: use complete proofs for account admission fixtures --- crates/rpc/src/tests/allowlist.rs | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/rpc/src/tests/allowlist.rs b/crates/rpc/src/tests/allowlist.rs index 2940d7c137..92e266da85 100644 --- a/crates/rpc/src/tests/allowlist.rs +++ b/crates/rpc/src/tests/allowlist.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; -use miden_node_proto::generated::submission::SealedTransactionInputs; use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_standards::account::auth::NetworkAccount; use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager}; @@ -13,8 +12,16 @@ impl TestStore { .fee_faucet_id(FungibleAsset::mock_issuer()) .verification_base_fee(1); let accounts = [ - builder.create_new_wallet(Auth::basic_ecdsa()).unwrap(), - builder.create_new_wallet(Auth::basic_ecdsa()).unwrap(), + builder + .create_new_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }) + .unwrap(), + builder + .create_new_wallet(Auth::BasicAuth { + auth_scheme: AuthScheme::Falcon512Poseidon2, + }) + .unwrap(), ]; let notes = accounts.each_ref().map(|account| { builder @@ -53,11 +60,12 @@ impl TestStore { .unwrap(); transactions.push(Arc::new(proven)); } - let batch = ProposedBatch::new_unverified( + let batch = ProposedBatch::new( transactions, chain.latest_block_header(), chain.latest_partial_blockchain(), BTreeMap::new(), + miden_protocol::MIN_PROOF_SECURITY_LEVEL, ) .unwrap(); (store, batch) @@ -209,7 +217,7 @@ async fn account_admission_only_restricts_new_non_network_accounts() { assert!(!allowlist.contains_account(creation.account_id()).await.unwrap()); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn submission_endpoints_reject_unregistered_creation_without_partial_batch_admission() { let (store, batch) = TestStore::with_account_creation_batch().await; let transactions = batch.transactions(); @@ -258,29 +266,27 @@ async fn submission_endpoints_reject_unregistered_creation_without_partial_batch transaction: Some(transactions[1].as_ref().into()), ..Default::default() }; + let mut auth_inputs = Vec::new(); + for tx in transactions { + auth_inputs.push(get_tx_inputs(&store.state, tx).await.unwrap().into()); + } let authenticated_batch = proto::sequencer::AuthenticatedTransactionBatch { proposed_batch: Some((&batch).into()), - auth_inputs: transactions - .iter() - .map(|tx| proto::sequencer::AuthInputs { - account_id: Some(tx.account_id().into()), - ..Default::default() - }) - .collect(), + auth_inputs, }; for result in [ public .submit_proven_tx(Request::new(proto::submission::ProvenTransactionSubmission { transaction: Some(transactions[1].as_ref().into()), - sealed_transaction_inputs: Some(SealedTransactionInputs::default()), + sealed_transaction_inputs: Some(test_sealed_transaction_inputs()), })) .await, public .submit_proven_tx_batch(Request::new(proto::submission::TransactionBatch { batch: Some((&proven_batch).into()), proposed_batch: Some((&batch).into()), - sealed_transaction_inputs: vec![SealedTransactionInputs::default(); 2], + sealed_transaction_inputs: vec![test_sealed_transaction_inputs(); 2], })) .await, internal.submit_authenticated_tx(Request::new(tx)).await, From 8d6213f94dd1eeab3ea808b9066732d0bf5fdd65 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:48:34 +0200 Subject: [PATCH 6/8] fix(rpc): exempt user batches from fee-note requirements --- crates/rpc/src/server/api.rs | 12 +-- crates/rpc/src/server/api/submit_auth_tx.rs | 9 +- .../src/server/api/submit_auth_tx_batch.rs | 4 +- crates/rpc/src/server/api/submit_proven_tx.rs | 11 +-- .../src/server/api/submit_proven_tx_batch.rs | 11 +-- crates/rpc/src/tests.rs | 87 +++++++++++++------ docs/external/src/rpc/errors-and-limits.md | 5 +- 7 files changed, 73 insertions(+), 66 deletions(-) diff --git a/crates/rpc/src/server/api.rs b/crates/rpc/src/server/api.rs index f8620f1a77..5127f41a7c 100644 --- a/crates/rpc/src/server/api.rs +++ b/crates/rpc/src/server/api.rs @@ -2,7 +2,7 @@ use std::num::NonZeroUsize; use std::sync::{Arc, LazyLock}; use anyhow::Context as AnyhowContext; -use miden_node_block_producer::{BlockProducerApi, ensure_transaction_has_fee}; +use miden_node_block_producer::BlockProducerApi; use miden_node_proto::clients::NtxBuilderClient; use miden_node_proto::domain::block::InvalidBlockRange; use miden_node_proto::generated::rpc::MempoolStats as ProtoMempoolStats; @@ -23,7 +23,6 @@ use miden_node_utils::lru_cache::LruCache; use miden_protocol::Word; use miden_protocol::account::AccountId; use miden_protocol::block::{BlockHeader, BlockNumber}; -use miden_protocol::transaction::ProvenTransaction; use tokio::sync::Semaphore; use tonic::metadata::MetadataMap; use tonic::{Request, Status}; @@ -69,15 +68,6 @@ pub(crate) async fn submit_batch_to_validators( Ok(()) } -/// Rejects a submission if a transaction does not create a canonical fee note. -pub(crate) fn ensure_transactions_have_fee_notes<'a>( - transactions: impl IntoIterator, -) -> tonic::Result<()> { - transactions - .into_iter() - .try_for_each(|tx| ensure_transaction_has_fee(tx).map_err(Status::from)) -} - // API METHODS // ================================================================================================ diff --git a/crates/rpc/src/server/api/submit_auth_tx.rs b/crates/rpc/src/server/api/submit_auth_tx.rs index bb25f6c7a3..41f19c486b 100644 --- a/crates/rpc/src/server/api/submit_auth_tx.rs +++ b/crates/rpc/src/server/api/submit_auth_tx.rs @@ -1,14 +1,11 @@ +use miden_node_block_producer::ensure_transaction_has_fee; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; use miden_node_proto::generated::server::sequencer_api; use miden_node_proto::{DecodeMessageExt, generated as proto}; use miden_node_tracing::ErrorReport; use tonic::Status; -use super::{ - SequencerInternalService, - ensure_transactions_have_fee_notes, - get_block_header_error_to_status, -}; +use super::{SequencerInternalService, get_block_header_error_to_status}; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { @@ -57,7 +54,7 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { ))); } - ensure_transactions_have_fee_notes([tx.raw_proven_transaction()])?; + ensure_transaction_has_fee(tx.raw_proven_transaction()).map_err(Status::from)?; self.block_producer .submit_authenticated_tx(tx) diff --git a/crates/rpc/src/server/api/submit_auth_tx_batch.rs b/crates/rpc/src/server/api/submit_auth_tx_batch.rs index a4ca4872b4..01fbf04bb0 100644 --- a/crates/rpc/src/server/api/submit_auth_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_auth_tx_batch.rs @@ -3,7 +3,7 @@ use miden_node_proto::{DecodeMessageExt, generated as proto}; use miden_node_tracing::spawn::spawn_blocking_in_current_span; use tonic::Status; -use super::{SequencerInternalService, ensure_transactions_have_fee_notes}; +use super::SequencerInternalService; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { @@ -40,8 +40,6 @@ impl sequencer_api::SubmitAuthenticatedTxBatch for SequencerInternalService { self.account_admission.check(tx.account_update()).await?; } - ensure_transactions_have_fee_notes(batch.transactions().iter().map(AsRef::as_ref))?; - self.block_producer .submit_authenticated_tx_batch(batch, inputs) .await diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index 7dd6aa7aef..b6bf0e4125 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -1,4 +1,5 @@ use miden_node_block_producer::store::get_tx_inputs; +use miden_node_block_producer::ensure_transaction_has_fee; use miden_node_proto::clients::{SequencerClient, ValidatorClient}; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; use miden_node_proto::{DecodeMessageExt, generated as proto}; @@ -14,13 +15,7 @@ use miden_protocol::transaction::{ }; use tonic::{Request, Status}; -use super::{ - COMPONENT, - RpcBackend, - RpcService, - ensure_transactions_have_fee_notes, - submit_tx_to_validators, -}; +use super::{COMPONENT, RpcBackend, RpcService, submit_tx_to_validators}; use crate::LOG_TARGET; #[tonic::async_trait] @@ -80,7 +75,7 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { // Verify the reference block is actually part of the chain. self.verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) .await?; - ensure_transactions_have_fee_notes([&tx])?; + ensure_transaction_has_fee(&tx).map_err(Status::from)?; // Rebuild a new ProvenTransaction with decorators removed from output notes let account_update = TxAccountUpdate::new( diff --git a/crates/rpc/src/server/api/submit_proven_tx_batch.rs b/crates/rpc/src/server/api/submit_proven_tx_batch.rs index 762d3d9835..11afd6d86c 100644 --- a/crates/rpc/src/server/api/submit_proven_tx_batch.rs +++ b/crates/rpc/src/server/api/submit_proven_tx_batch.rs @@ -8,12 +8,7 @@ use miden_protocol::batch::{ProposedBatch, ProvenBatch}; use miden_tx_batch::BatchVerifier; use tonic::{Request, Status}; -use super::{ - RpcBackend, - RpcService, - ensure_transactions_have_fee_notes, - submit_batch_to_validators, -}; +use super::{RpcBackend, RpcService, submit_batch_to_validators}; use crate::{COMPONENT, LOG_TARGET}; #[tonic::async_trait] @@ -81,10 +76,6 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService { } } - ensure_transactions_have_fee_notes( - proposed_batch.transactions().iter().map(AsRef::as_ref), - )?; - // Verify that the reference block is part of the chain. self.verify_reference_commitment( proven_batch.reference_block_num(), diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index 23ea272e29..f20e9ee9d2 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -6,6 +6,7 @@ use std::time::Duration; use http::header::{ACCEPT, CONTENT_TYPE}; use http::{Extensions, HeaderMap, HeaderValue}; +use miden_node_block_producer::store::get_tx_inputs; use miden_node_block_producer::{BlockProducerApi, BlockProducerApiConfig}; use miden_node_proto::clients::{ Builder, @@ -86,11 +87,7 @@ use tonic::metadata::MetadataMap; use url::Url; use crate::server::RpcBackend; -use crate::server::api::{ - RpcService, - SequencerInternalService, - ensure_transactions_have_fee_notes, -}; +use crate::server::api::{RpcService, SequencerInternalService}; use crate::{AccountAdmission, PreAuthSubmission, Rpc, RpcMode, ValidatorClients}; mod allowlist; @@ -335,19 +332,23 @@ fn replace_transaction_proof( struct ValidBatchFixture { request: proto::submission::TransactionBatch, + proposed_batch: ProposedBatch, genesis_block: ProvenBlock, protocol_config: ProtocolConfig, } -async fn build_valid_batch_fixture() -> ValidBatchFixture { +async fn build_valid_batch_fixture(include_fee: bool) -> ValidBatchFixture { let mut mock_chain_builder = MockChainBuilder::new() .fee_faucet_id(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap()) .verification_base_fee(1); - let account = mock_chain_builder - .add_existing_wallet(Auth::BasicAuth { + let auth = if include_fee { + Auth::BasicAuth { auth_scheme: AuthScheme::Falcon512Poseidon2, - }) - .unwrap(); + } + } else { + Auth::IncrNonce + }; + let account = mock_chain_builder.add_existing_wallet(auth).unwrap(); let asset: Asset = FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET.try_into().unwrap(), 1_000_000) .unwrap() @@ -384,6 +385,10 @@ async fn build_valid_batch_fixture() -> ValidBatchFixture { .unwrap() .unwrap(); + if !include_fee { + assert!(proven_tx.output_notes().is_empty()); + } + let proposed_batch = ProposedBatch::new( vec![Arc::new(proven_tx)], mock_chain.latest_block_header(), @@ -409,7 +414,12 @@ async fn build_valid_batch_fixture() -> ValidBatchFixture { sealed_transaction_inputs: vec![test_sealed_transaction_inputs()], }; - ValidBatchFixture { request, genesis_block, protocol_config } + ValidBatchFixture { + request, + proposed_batch, + genesis_block, + protocol_config, + } } fn assert_beyond_tip(status: &tonic::Status, endpoint: &str) { @@ -622,19 +632,6 @@ async fn rpc_server_rejects_proven_transactions_without_fees() { ); } -#[test] -fn rpc_fee_gate_rejects_a_feeless_transaction_in_a_batch() { - let (account, patch) = build_test_account([0; 32]); - let paid = build_test_proven_tx_with_fee(&account, &patch, Word::empty(), true); - let feeless = build_test_proven_tx_with_fee(&account, &patch, Word::empty(), false); - - let status = ensure_transactions_have_fee_notes([&paid, &feeless]).unwrap_err(); - - assert_eq!(status.code(), tonic::Code::InvalidArgument); - assert_eq!(status.details(), &[4]); - assert!(status.message().contains(&feeless.id().to_string())); -} - #[tokio::test] async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { let store = TestStore::start().await; @@ -1427,9 +1424,12 @@ async fn full_node_preserves_original_accept_metadata_when_forwarding() { ); } +#[rstest::rstest] +#[case::with_fee_notes(true)] +#[case::without_fee_notes(false)] #[tokio::test(flavor = "multi_thread")] -async fn full_node_forwards_complete_transaction_batch_to_source_rpc() { - let fixture = build_valid_batch_fixture().await; +async fn full_node_forwards_complete_transaction_batch_to_source_rpc(#[case] include_fee: bool) { + let fixture = build_valid_batch_fixture(include_fee).await; let (validator, _validator_call_count, _last_accept, _validator_server) = start_validator(test_encryption_key(), None).await; let (source_rpc, _source_store, _source_server) = start_source_rpc_with_genesis( @@ -1466,6 +1466,41 @@ async fn full_node_forwards_complete_transaction_batch_to_source_rpc() { assert_eq!(response.block_num, 0); } +#[tokio::test(flavor = "multi_thread")] +async fn sequencer_authenticated_rpc_accepts_user_batch_without_fee_notes() { + let fixture = build_valid_batch_fixture(false).await; + let store = + TestStore::start_from_mock_genesis(&fixture.genesis_block, &fixture.protocol_config).await; + let guard = TestServerGuard(CancellationToken::new()); + let block_producer = BlockProducerApi::new( + Arc::clone(&store.state), + store.state.committed_tip(), + BlockProducerApiConfig::default(), + guard.0.clone(), + ); + let service = SequencerInternalService { + state: Arc::clone(&store.state), + block_producer, + account_admission: AccountAdmission::enabled(store.bootstrap_allowlist()), + }; + let mut auth_inputs = Vec::new(); + for tx in fixture.proposed_batch.transactions() { + auth_inputs.push(get_tx_inputs(&store.state, tx).await.unwrap().into()); + } + let request = proto::sequencer::AuthenticatedTransactionBatch { + proposed_batch: fixture.request.proposed_batch, + auth_inputs, + }; + + let response = service + .submit_authenticated_tx_batch(Request::new(request)) + .await + .expect("the sequencer should accept a user batch without fee output notes") + .into_inner(); + + assert_eq!(response.block_num, 0); +} + #[tokio::test] async fn authenticated_batch_defers_validation_to_async_handler() { let request = proto::sequencer::AuthenticatedTransactionBatch { diff --git a/docs/external/src/rpc/errors-and-limits.md b/docs/external/src/rpc/errors-and-limits.md index ae9cf66770..d048d32334 100644 --- a/docs/external/src/rpc/errors-and-limits.md +++ b/docs/external/src/rpc/errors-and-limits.md @@ -54,8 +54,9 @@ conflict, and use the detail byte when a client needs stable branching between b `CapacityExceeded` means the mempool capacity has been exhausted and is under load. -`MissingFee` means that a transaction submitted through `SubmitProvenTx` or `SubmitProvenTxBatch` does not contain an -output note with the canonical `TX_FEE` script. This check applies to all submitted transactions. It does not establish +`MissingFee` means that a standalone transaction submitted through `SubmitProvenTx` does not contain an output note with +the canonical `TX_FEE` script. The internal `SubmitAuthenticatedTx` endpoint applies the same check. Transactions within +user-submitted batches are exempt because those batches handle their own fee collection. This check does not establish that the fee amount is sufficient. A fee note can contain a zero-valued asset when the required fee is zero. ### Encrypted input errors From 6a8d3401332b9fa53f284e835c37b0f001df2bc7 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:12:10 +0200 Subject: [PATCH 7/8] fix(rpc): restrict transaction fees to the native asset --- .../block-producer/src/domain/transaction.rs | 109 +++++++++++++++--- crates/block-producer/src/errors.rs | 9 ++ crates/rpc/src/server/api/submit_auth_tx.rs | 6 +- crates/rpc/src/server/api/submit_proven_tx.rs | 10 +- crates/rpc/src/tests.rs | 91 +++++++++++---- docs/external/src/rpc/errors-and-limits.md | 23 ++-- 6 files changed, 193 insertions(+), 55 deletions(-) diff --git a/crates/block-producer/src/domain/transaction.rs b/crates/block-producer/src/domain/transaction.rs index 67f6dc4bc3..c4dc4a508c 100644 --- a/crates/block-producer/src/domain/transaction.rs +++ b/crates/block-producer/src/domain/transaction.rs @@ -1,17 +1,34 @@ -use miden_protocol::transaction::ProvenTransaction; +use miden_protocol::asset::AssetId; +use miden_protocol::transaction::{OutputNote, ProvenTransaction}; use miden_standards::note::TxFeeNote; use crate::errors::MempoolSubmissionError; -/// Ensures that a transaction creates a canonical fee note. +/// Ensures that a transaction creates a canonical fee note with the native asset. +/// All canonical fee notes must contain exactly one asset with the specified ID. /// /// This check does not validate that the fee is sufficient for the transaction execution cost. -pub fn ensure_transaction_has_fee(tx: &ProvenTransaction) -> Result<(), MempoolSubmissionError> { +pub fn ensure_transaction_has_fee( + tx: &ProvenTransaction, + fee_asset_id: AssetId, +) -> Result<(), MempoolSubmissionError> { let fee_script_root = TxFeeNote::script_root(); - let contains_fee = tx.output_notes().iter().any(|note| { - note.recipient() - .is_some_and(|recipient| recipient.script().root() == fee_script_root) - }); + let mut contains_fee = false; + for note in tx.output_notes().iter() { + let OutputNote::Public(note) = note else { + continue; + }; + if note.recipient().script().root() != fee_script_root { + continue; + } + if !matches!(note.assets().as_slice(), [asset] if asset.id() == fee_asset_id) { + return Err(MempoolSubmissionError::InvalidFeeAsset { + transaction_id: tx.id(), + fee_asset_id, + }); + } + contains_fee = true; + } if contains_fee { Ok(()) @@ -25,7 +42,9 @@ mod tests { use assert_matches::assert_matches; use miden_node_proto::{BuildUnchecked, DecodeMessage}; use miden_protocol::Word; - use miden_protocol::asset::FungibleAsset; + use miden_protocol::asset::{Asset, AssetId, FungibleAsset}; + use miden_protocol::note::{Note, NoteAssets}; + use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; use miden_protocol::transaction::{OutputNote, ProvenTransaction, PublicOutputNote}; use miden_standards::note::TxFeeNote; @@ -46,24 +65,39 @@ mod tests { } fn transaction_with_fee_amount(amount: u64) -> ProvenTransaction { - let fee_note = TxFeeNote::builder() + MockProvenTxBuilder::with_account_index(1) + .output_notes(vec![fee_output_note( + &[FungibleAsset::new(FungibleAsset::mock_issuer(), amount).unwrap().into()], + 1, + )]) + .build() + } + + fn fee_asset_id() -> AssetId { + AssetId::new_fungible(FungibleAsset::mock_issuer()) + } + + fn fee_output_note(assets: &[Asset], serial: u32) -> OutputNote { + let template: Note = TxFeeNote::builder() .sender(mock_account_id(1)) - .serial_number(Word::from([1u32, 2, 3, 4])) - .asset(FungibleAsset::new(FungibleAsset::mock_issuer(), amount).unwrap()) + .serial_number(Word::from([serial, 2, 3, 4])) + .asset(FungibleAsset::new(FungibleAsset::mock_issuer(), 1).unwrap()) .build() .unwrap() .into(); - - MockProvenTxBuilder::with_account_index(1) - .output_notes(vec![OutputNote::Public(PublicOutputNote::new(fee_note).unwrap())]) - .build() + let note = Note::new( + NoteAssets::new(assets.to_vec()).unwrap(), + *template.metadata().partial_metadata(), + template.recipient().clone(), + ); + OutputNote::Public(PublicOutputNote::new(note).unwrap()) } #[test] fn transaction_fee_requires_the_canonical_note_script() { let tx = transaction_with_fee_amount(1); - ensure_transaction_has_fee(&tx).unwrap(); + ensure_transaction_has_fee(&tx, fee_asset_id()).unwrap(); } #[test] @@ -71,7 +105,7 @@ mod tests { let tx = MockProvenTxBuilder::with_account_index(1).build(); assert_matches!( - ensure_transaction_has_fee(&tx), + ensure_transaction_has_fee(&tx, fee_asset_id()), Err(MempoolSubmissionError::MissingFee { transaction_id }) if transaction_id == tx.id() ); } @@ -80,6 +114,45 @@ mod tests { fn transaction_with_zero_fee_asset_is_accepted() { let tx = transaction_with_fee_amount(0); - ensure_transaction_has_fee(&tx).unwrap(); + ensure_transaction_has_fee(&tx, fee_asset_id()).unwrap(); + } + + #[test] + fn fee_notes_must_contain_only_the_native_asset() { + let native = FungibleAsset::new(FungibleAsset::mock_issuer(), 1).unwrap().into(); + let foreign_faucet = ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(); + let foreign = FungibleAsset::new(foreign_faucet, 1).unwrap().into(); + let zero_foreign = FungibleAsset::new(foreign_faucet, 0).unwrap().into(); + for assets in [vec![], vec![foreign], vec![zero_foreign], vec![native, foreign]] { + let tx = MockProvenTxBuilder::with_account_index(1) + .output_notes(vec![fee_output_note(&assets, 1)]) + .build(); + assert_matches!( + ensure_transaction_has_fee(&tx, fee_asset_id()), + Err(MempoolSubmissionError::InvalidFeeAsset { transaction_id, .. }) + if transaction_id == tx.id() + ); + } + } + + #[test] + fn native_fee_note_does_not_allow_other_fee_notes_with_foreign_assets() { + let native = FungibleAsset::new(FungibleAsset::mock_issuer(), 1).unwrap().into(); + let foreign = + FungibleAsset::new(ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1.try_into().unwrap(), 1) + .unwrap() + .into(); + for assets in [[native, foreign], [foreign, native]] { + let tx = MockProvenTxBuilder::with_account_index(1) + .output_notes(vec![ + fee_output_note(&assets[..1], 1), + fee_output_note(&assets[1..], 2), + ]) + .build(); + assert_matches!( + ensure_transaction_has_fee(&tx, fee_asset_id()), + Err(MempoolSubmissionError::InvalidFeeAsset { .. }) + ); + } } } diff --git a/crates/block-producer/src/errors.rs b/crates/block-producer/src/errors.rs index ce19b30dca..5b60e0e812 100644 --- a/crates/block-producer/src/errors.rs +++ b/crates/block-producer/src/errors.rs @@ -11,6 +11,7 @@ use miden_node_store::{ }; use miden_protocol::Word; use miden_protocol::account::AccountId; +use miden_protocol::asset::AssetId; use miden_protocol::block::BlockNumber; use miden_protocol::crypto::utils::DeserializationError; use miden_protocol::errors::{ProposedBatchError, ProposedBlockError, ProvenBatchError}; @@ -88,6 +89,14 @@ pub enum MempoolSubmissionError { #[error("mempool lock is poisoned")] #[grpc(internal)] MempoolPoisoned(#[source] MempoolPoisonError), + + #[error( + "transaction {transaction_id} must use only the native asset {fee_asset_id} in each TX_FEE output note" + )] + InvalidFeeAsset { + transaction_id: TransactionId, + fee_asset_id: AssetId, + }, } // Mempool submission conflicts with current state diff --git a/crates/rpc/src/server/api/submit_auth_tx.rs b/crates/rpc/src/server/api/submit_auth_tx.rs index 41f19c486b..5b1628fb3c 100644 --- a/crates/rpc/src/server/api/submit_auth_tx.rs +++ b/crates/rpc/src/server/api/submit_auth_tx.rs @@ -5,7 +5,7 @@ use miden_node_proto::{DecodeMessageExt, generated as proto}; use miden_node_tracing::ErrorReport; use tonic::Status; -use super::{SequencerInternalService, get_block_header_error_to_status}; +use super::{SequencerInternalService, get_block_header_error_to_status, load_protocol_config}; #[tonic::async_trait] impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { @@ -54,7 +54,9 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { ))); } - ensure_transaction_has_fee(tx.raw_proven_transaction()).map_err(Status::from)?; + let protocol_config = load_protocol_config(&self.state.view(), &reference_header).await?; + ensure_transaction_has_fee(tx.raw_proven_transaction(), protocol_config.fee_asset_id()) + .map_err(Status::from)?; self.block_producer .submit_authenticated_tx(tx) diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index b6bf0e4125..bd0dc08de0 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -1,5 +1,5 @@ -use miden_node_block_producer::store::get_tx_inputs; use miden_node_block_producer::ensure_transaction_has_fee; +use miden_node_block_producer::store::get_tx_inputs; use miden_node_proto::clients::{SequencerClient, ValidatorClient}; use miden_node_proto::domain::sequencer::AuthenticatedTransaction; use miden_node_proto::{DecodeMessageExt, generated as proto}; @@ -15,7 +15,7 @@ use miden_protocol::transaction::{ }; use tonic::{Request, Status}; -use super::{COMPONENT, RpcBackend, RpcService, submit_tx_to_validators}; +use super::{COMPONENT, RpcBackend, RpcService, load_protocol_config, submit_tx_to_validators}; use crate::LOG_TARGET; #[tonic::async_trait] @@ -73,9 +73,11 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { } // Verify the reference block is actually part of the chain. - self.verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) + let reference_header = self + .verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) .await?; - ensure_transaction_has_fee(&tx).map_err(Status::from)?; + let protocol_config = load_protocol_config(&self.state.view(), &reference_header).await?; + ensure_transaction_has_fee(&tx, protocol_config.fee_asset_id()).map_err(Status::from)?; // Rebuild a new ProvenTransaction with decorators removed from output notes let account_update = TxAccountUpdate::new( diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index f20e9ee9d2..e396304268 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -57,7 +57,7 @@ use miden_protocol::account::{ AccountUpdateDetails, AssetCallbackFlag, }; -use miden_protocol::asset::{Asset, FungibleAsset}; +use miden_protocol::asset::{Asset, AssetId, FungibleAsset}; use miden_protocol::batch::ProposedBatch; use miden_protocol::block::{BlockSignatures, ProvenBlock, SignedBlock, ValidatorConfig}; use miden_protocol::note::NoteType; @@ -137,6 +137,16 @@ impl TestStore { self.genesis_commitment } + async fn fee_asset_id(&self) -> AssetId { + let view = self.state.view(); + let header = view.get_block_header(Some(0.into()), false).await.unwrap().0.unwrap(); + view.get_protocol_config(header.protocol_config_commitment()) + .await + .unwrap() + .unwrap() + .fee_asset_id() + } + fn data_directory_path(&self) -> &std::path::Path { &self.data_directory } @@ -232,8 +242,14 @@ fn build_test_proven_tx( account: &Account, patch: &AccountPatch, genesis: Word, + fee_asset_id: AssetId, ) -> ProvenTransaction { - build_test_proven_tx_with_fee(account, patch, genesis, true) + build_test_proven_tx_with_fee( + account, + patch, + genesis, + Some(FungibleAsset::new(fee_asset_id.faucet_id(), 1).unwrap()), + ) } /// Creates a minimal proven transaction, optionally including its canonical fee output note. @@ -241,7 +257,7 @@ fn build_test_proven_tx_with_fee( account: &Account, patch: &AccountPatch, genesis: Word, - include_fee: bool, + fee: Option, ) -> ProvenTransaction { let account_id = AccountId::dummy( [0; 15], @@ -259,8 +275,7 @@ fn build_test_proven_tx_with_fee( ) .unwrap(); - let output_notes = - include_fee.then(|| fee_output_note(account_id)).into_iter().collect::>(); + let output_notes = fee.map(|asset| fee_output_note(account_id, asset)); ProvenTransaction::new( account_update, @@ -274,11 +289,11 @@ fn build_test_proven_tx_with_fee( .unwrap() } -fn fee_output_note(sender: AccountId) -> OutputNote { +fn fee_output_note(sender: AccountId, asset: FungibleAsset) -> OutputNote { let fee_note = TxFeeNote::builder() .sender(sender) .serial_number(Word::from([1u32, 2, 3, 4])) - .asset(FungibleAsset::new(FungibleAsset::mock_issuer(), 1).unwrap()) + .asset(asset) .build() .unwrap() .into(); @@ -291,6 +306,7 @@ fn build_test_proven_tx_with_id( account_id: AccountId, account: &Account, genesis: Word, + fee_asset_id: AssetId, ) -> ProvenTransaction { let patch = AccountPatch::empty(account_id); let account_update = TxAccountUpdate::new( @@ -305,7 +321,10 @@ fn build_test_proven_tx_with_id( ProvenTransaction::new( account_update, Vec::::new(), - [fee_output_note(account_id)], + [fee_output_note( + account_id, + FungibleAsset::new(fee_asset_id.faucet_id(), 1).unwrap(), + )], 0.into(), genesis, u32::MAX.into(), @@ -574,7 +593,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { // Build a valid proven transaction let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx(&account, &account_patch, genesis); + let tx = build_test_proven_tx(&account, &account_patch, genesis, store.fee_asset_id().await); // Create an incorrect patch commitment from a different account let (other_account, _) = build_test_account([1; 32]); @@ -604,12 +623,22 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { ); } +#[rstest::rstest] +#[case::missing(None, 4, "does not contain a canonical TX_FEE output note")] +#[case::foreign(Some(1), 6, "must use only the native asset")] +#[case::zero_foreign(Some(0), 6, "must use only the native asset")] #[tokio::test] -async fn rpc_server_rejects_proven_transactions_without_fees() { +async fn rpc_server_rejects_proven_transactions_without_native_fees( + #[case] foreign_fee_amount: Option, + #[case] expected_detail: u8, + #[case] expected_error: &str, +) { let store = TestStore::start().await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, false); + let fee = foreign_fee_amount + .map(|amount| FungibleAsset::new(FungibleAsset::mock_issuer(), amount).unwrap()); + let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, fee); let request = proto::submission::ProvenTransactionSubmission { transaction: Some((&tx).into()), sealed_transaction_inputs: Some(test_sealed_transaction_inputs()), @@ -625,19 +654,29 @@ async fn rpc_server_rejects_proven_transactions_without_fees() { let status = service.submit_proven_tx(Request::new(request)).await.unwrap_err(); assert_eq!(status.code(), tonic::Code::InvalidArgument); - assert_eq!(status.details(), &[4]); + assert_eq!(status.details(), &[expected_detail]); assert!( - status.message().contains("does not contain a canonical TX_FEE output note"), - "expected the missing-fee error, got: {status}" + status.message().contains(expected_error), + "expected {expected_error}, got: {status}" ); } +#[rstest::rstest] +#[case::missing(None, 4, "does not contain a canonical TX_FEE output note")] +#[case::foreign(Some(1), 6, "must use only the native asset")] +#[case::zero_foreign(Some(0), 6, "must use only the native asset")] #[tokio::test] -async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { +async fn sequencer_authenticated_rpc_rejects_transactions_without_native_fees( + #[case] foreign_fee_amount: Option, + #[case] expected_detail: u8, + #[case] expected_error: &str, +) { let store = TestStore::start().await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, false); + let fee = foreign_fee_amount + .map(|amount| FungibleAsset::new(FungibleAsset::mock_issuer(), amount).unwrap()); + let tx = build_test_proven_tx_with_fee(&account, &account_patch, genesis, fee); let inputs = TransactionInputs { account_id: tx.account_id(), account_commitment: Some(tx.account_update().initial_state_commitment()), @@ -664,8 +703,12 @@ async fn sequencer_authenticated_rpc_rejects_transactions_without_fees() { .unwrap_err(); assert_eq!(status.code(), tonic::Code::InvalidArgument); - assert_eq!(status.details(), &[4]); + assert_eq!(status.details(), &[expected_detail]); assert_eq!(block_producer.status().await.mempool_stats.uncommitted_transactions, 0); + assert!( + status.message().contains(expected_error), + "expected {expected_error}, got: {status}" + ); } #[tokio::test] @@ -673,7 +716,8 @@ async fn rpc_server_rejects_invalid_deferred_transaction_proofs() { let store = TestStore::start().await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); - let transaction = build_test_proven_tx(&account, &account_patch, genesis); + let transaction = + build_test_proven_tx(&account, &account_patch, genesis, store.fee_asset_id().await); let transaction = replace_transaction_proof( &transaction, miden_protocol::testing::dummy_deferred_execution_proof(), @@ -788,7 +832,7 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_reference_block() { // Build a valid proven transaction but with the incorrect hash (empty). let invalid = Word::empty(); let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx(&account, &account_patch, invalid); + let tx = build_test_proven_tx(&account, &account_patch, invalid, store.fee_asset_id().await); let request = proto::submission::ProvenTransactionSubmission { transaction: Some((&tx).into()), @@ -828,7 +872,12 @@ async fn rpc_rejects_post_deployment_network_account_tx() { // Build a non-deployment tx for that account. let (account, _) = build_test_account([0; 32]); - let tx = build_test_proven_tx_with_id(network_account_id, &account, genesis); + let tx = build_test_proven_tx_with_id( + network_account_id, + &account, + genesis, + store.fee_asset_id().await, + ); let request = proto::submission::ProvenTransactionSubmission { transaction: Some((&tx).into()), sealed_transaction_inputs: Some(test_sealed_transaction_inputs()), @@ -1562,7 +1611,7 @@ async fn rpc_server_rejects_tx_submissions_without_genesis() { .connect_lazy::(); let (account, account_patch) = build_test_account([0; 32]); - let tx = build_test_proven_tx(&account, &account_patch, genesis); + let tx = build_test_proven_tx(&account, &account_patch, genesis, store.fee_asset_id().await); let request = proto::submission::ProvenTransactionSubmission { transaction: Some((&tx).into()), diff --git a/docs/external/src/rpc/errors-and-limits.md b/docs/external/src/rpc/errors-and-limits.md index d048d32334..a65bfd954f 100644 --- a/docs/external/src/rpc/errors-and-limits.md +++ b/docs/external/src/rpc/errors-and-limits.md @@ -37,13 +37,14 @@ If you are missing specific error information that could be useful, please open `SubmitProvenTx` and `SubmitProvenTxBatch` may return the following detail codes when a transaction or batch is rejected during submission validation or by the sequencer's mempool. -| Error | Value | gRPC status | Meaning | -| ------------------ | ----- | ------------------ | ------------------------------------- | -| `Internal` | `0` | `INTERNAL` | Internal submission failure | -| `Expired` | `1` | `INVALID_ARGUMENT` | Transaction expired | -| `StateConflict` | `2` | `INVALID_ARGUMENT` | State conflict | -| `CapacityExceeded` | `3` | `INVALID_ARGUMENT` | Mempool capacity exceeded | -| `MissingFee` | `4` | `INVALID_ARGUMENT` | Transaction has no canonical fee note | +| Error | Value | gRPC status | Meaning | +| ------------------ | ----- | ------------------ | ----------------------------------------------- | +| `Internal` | `0` | `INTERNAL` | Internal submission failure | +| `Expired` | `1` | `INVALID_ARGUMENT` | Transaction expired | +| `StateConflict` | `2` | `INVALID_ARGUMENT` | State conflict | +| `CapacityExceeded` | `3` | `INVALID_ARGUMENT` | Mempool capacity exceeded | +| `MissingFee` | `4` | `INVALID_ARGUMENT` | Transaction has no canonical fee note | +| `InvalidFeeAsset` | `6` | `INVALID_ARGUMENT` | Fee note does not contain only the native asset | `Expired` means the transaction or batch has expired, or will expire too soon for the sequencer to consider accepting it. @@ -55,9 +56,11 @@ conflict, and use the detail byte when a client needs stable branching between b `CapacityExceeded` means the mempool capacity has been exhausted and is under load. `MissingFee` means that a standalone transaction submitted through `SubmitProvenTx` does not contain an output note with -the canonical `TX_FEE` script. The internal `SubmitAuthenticatedTx` endpoint applies the same check. Transactions within -user-submitted batches are exempt because those batches handle their own fee collection. This check does not establish -that the fee amount is sufficient. A fee note can contain a zero-valued asset when the required fee is zero. +the canonical `TX_FEE` script. Each such note must contain exactly one native asset, as specified by the reference +block's protocol configuration. `InvalidFeeAsset` means that a fee note does not meet this asset requirement. The +internal `SubmitAuthenticatedTx` endpoint applies the same checks. Transactions within user-submitted batches are exempt +because those batches handle their own fee collection. These checks do not establish that the fee amount is sufficient. +A fee note can contain a zero-valued native asset when the required fee is zero. ### Encrypted input errors From b0d9322de65802eaff4a51f10dc679c0ece02cc7 Mon Sep 17 00:00:00 2001 From: Mirko von Leipzig <48352201+Mirko-von-Leipzig@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:28:20 +0200 Subject: [PATCH 8/8] Allow transactions without fee notes when fees are zero --- .../block-producer/src/domain/transaction.rs | 36 ++++++--- crates/rpc/src/server/api/submit_auth_tx.rs | 8 +- crates/rpc/src/server/api/submit_proven_tx.rs | 7 +- crates/rpc/src/tests.rs | 81 +++++++++++++++---- docs/external/src/rpc/errors-and-limits.md | 11 +-- 5 files changed, 109 insertions(+), 34 deletions(-) diff --git a/crates/block-producer/src/domain/transaction.rs b/crates/block-producer/src/domain/transaction.rs index c4dc4a508c..44e2f2823f 100644 --- a/crates/block-producer/src/domain/transaction.rs +++ b/crates/block-producer/src/domain/transaction.rs @@ -1,16 +1,18 @@ use miden_protocol::asset::AssetId; +use miden_protocol::block::FeeParameters; use miden_protocol::transaction::{OutputNote, ProvenTransaction}; use miden_standards::note::TxFeeNote; use crate::errors::MempoolSubmissionError; -/// Ensures that a transaction creates a canonical fee note with the native asset. -/// All canonical fee notes must contain exactly one asset with the specified ID. +/// Requires a fee note when the reference block's verification base fee is nonzero. +/// Every fee note must contain exactly one asset with the specified native asset ID. /// /// This check does not validate that the fee is sufficient for the transaction execution cost. pub fn ensure_transaction_has_fee( tx: &ProvenTransaction, fee_asset_id: AssetId, + fee_parameters: &FeeParameters, ) -> Result<(), MempoolSubmissionError> { let fee_script_root = TxFeeNote::script_root(); let mut contains_fee = false; @@ -30,7 +32,7 @@ pub fn ensure_transaction_has_fee( contains_fee = true; } - if contains_fee { + if contains_fee || fee_parameters.verification_base_fee() == 0 { Ok(()) } else { Err(MempoolSubmissionError::MissingFee { transaction_id: tx.id() }) @@ -43,6 +45,7 @@ mod tests { use miden_node_proto::{BuildUnchecked, DecodeMessage}; use miden_protocol::Word; use miden_protocol::asset::{Asset, AssetId, FungibleAsset}; + use miden_protocol::block::FeeParameters; use miden_protocol::note::{Note, NoteAssets}; use miden_protocol::testing::account_id::ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET_1; use miden_protocol::transaction::{OutputNote, ProvenTransaction, PublicOutputNote}; @@ -97,7 +100,7 @@ mod tests { fn transaction_fee_requires_the_canonical_note_script() { let tx = transaction_with_fee_amount(1); - ensure_transaction_has_fee(&tx, fee_asset_id()).unwrap(); + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)).unwrap(); } #[test] @@ -105,16 +108,23 @@ mod tests { let tx = MockProvenTxBuilder::with_account_index(1).build(); assert_matches!( - ensure_transaction_has_fee(&tx, fee_asset_id()), + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)), Err(MempoolSubmissionError::MissingFee { transaction_id }) if transaction_id == tx.id() ); } + #[test] + fn transaction_without_fee_is_accepted_when_fees_are_zero() { + let tx = MockProvenTxBuilder::with_account_index(1).build(); + + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(0)).unwrap(); + } + #[test] fn transaction_with_zero_fee_asset_is_accepted() { let tx = transaction_with_fee_amount(0); - ensure_transaction_has_fee(&tx, fee_asset_id()).unwrap(); + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)).unwrap(); } #[test] @@ -127,11 +137,13 @@ mod tests { let tx = MockProvenTxBuilder::with_account_index(1) .output_notes(vec![fee_output_note(&assets, 1)]) .build(); - assert_matches!( - ensure_transaction_has_fee(&tx, fee_asset_id()), - Err(MempoolSubmissionError::InvalidFeeAsset { transaction_id, .. }) - if transaction_id == tx.id() - ); + for base_fee in [0, 1] { + assert_matches!( + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(base_fee)), + Err(MempoolSubmissionError::InvalidFeeAsset { transaction_id, .. }) + if transaction_id == tx.id() + ); + } } } @@ -150,7 +162,7 @@ mod tests { ]) .build(); assert_matches!( - ensure_transaction_has_fee(&tx, fee_asset_id()), + ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)), Err(MempoolSubmissionError::InvalidFeeAsset { .. }) ); } diff --git a/crates/rpc/src/server/api/submit_auth_tx.rs b/crates/rpc/src/server/api/submit_auth_tx.rs index 5b1628fb3c..5c6d77005f 100644 --- a/crates/rpc/src/server/api/submit_auth_tx.rs +++ b/crates/rpc/src/server/api/submit_auth_tx.rs @@ -55,8 +55,12 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService { } let protocol_config = load_protocol_config(&self.state.view(), &reference_header).await?; - ensure_transaction_has_fee(tx.raw_proven_transaction(), protocol_config.fee_asset_id()) - .map_err(Status::from)?; + ensure_transaction_has_fee( + tx.raw_proven_transaction(), + protocol_config.fee_asset_id(), + reference_header.fee_parameters(), + ) + .map_err(Status::from)?; self.block_producer .submit_authenticated_tx(tx) diff --git a/crates/rpc/src/server/api/submit_proven_tx.rs b/crates/rpc/src/server/api/submit_proven_tx.rs index bd0dc08de0..c95c850ff3 100644 --- a/crates/rpc/src/server/api/submit_proven_tx.rs +++ b/crates/rpc/src/server/api/submit_proven_tx.rs @@ -77,7 +77,12 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService { .verify_reference_commitment(tx.ref_block_num(), tx.ref_block_commitment()) .await?; let protocol_config = load_protocol_config(&self.state.view(), &reference_header).await?; - ensure_transaction_has_fee(&tx, protocol_config.fee_asset_id()).map_err(Status::from)?; + ensure_transaction_has_fee( + &tx, + protocol_config.fee_asset_id(), + reference_header.fee_parameters(), + ) + .map_err(Status::from)?; // Rebuild a new ProvenTransaction with decorators removed from output notes let account_update = TxAccountUpdate::new( diff --git a/crates/rpc/src/tests.rs b/crates/rpc/src/tests.rs index e396304268..dcb95f3ba0 100644 --- a/crates/rpc/src/tests.rs +++ b/crates/rpc/src/tests.rs @@ -59,7 +59,13 @@ use miden_protocol::account::{ }; use miden_protocol::asset::{Asset, AssetId, FungibleAsset}; use miden_protocol::batch::ProposedBatch; -use miden_protocol::block::{BlockSignatures, ProvenBlock, SignedBlock, ValidatorConfig}; +use miden_protocol::block::{ + BlockSignatures, + FeeParameters, + ProvenBlock, + SignedBlock, + ValidatorConfig, +}; use miden_protocol::note::NoteType; use miden_protocol::protocol_config::ProtocolConfig; use miden_protocol::testing::account_id::{ACCOUNT_ID_PUBLIC_FUNGIBLE_FAUCET, ACCOUNT_ID_SENDER}; @@ -152,8 +158,13 @@ impl TestStore { } async fn start() -> Self { + Self::start_with_base_fee(0).await + } + + async fn start_with_base_fee(verification_base_fee: u32) -> Self { let data_directory = new_tempdir(); - let genesis_commitment = Self::bootstrap(&data_directory); + let genesis_commitment = + Self::bootstrap_with_base_fee(&data_directory, verification_base_fee); let (state, writer, ..) = State::for_tests(&data_directory).await; Self { state, @@ -180,13 +191,18 @@ impl TestStore { } fn bootstrap(path: &std::path::Path) -> Word { + Self::bootstrap_with_base_fee(path, 0) + } + + fn bootstrap_with_base_fee(path: &std::path::Path, verification_base_fee: u32) -> Word { let config = GenesisConfig::default(); let validator_key = miden_protocol::crypto::dsa::ecdsa_k256_keccak::SigningKey::read_from_bytes(&[7; 32]) .expect("test signing key should decode") .public_key(); let validator_config = ValidatorConfig::new(vec![validator_key], 1).unwrap(); - let (genesis_state, _) = config.into_state(validator_config).unwrap(); + let (mut genesis_state, _) = config.into_state(validator_config).unwrap(); + genesis_state.fee_parameters = FeeParameters::new(verification_base_fee); let genesis_block = genesis_state.clone().into_block().expect("genesis block should be created"); let genesis_commitment = genesis_block.inner().header().commitment(); @@ -624,16 +640,19 @@ async fn rpc_server_rejects_proven_transactions_with_invalid_commitment() { } #[rstest::rstest] -#[case::missing(None, 4, "does not contain a canonical TX_FEE output note")] -#[case::foreign(Some(1), 6, "must use only the native asset")] -#[case::zero_foreign(Some(0), 6, "must use only the native asset")] +#[case::missing(1, None, &[4], "does not contain a canonical TX_FEE output note")] +#[case::foreign(1, Some(1), &[6], "must use only the native asset")] +#[case::zero_foreign(1, Some(0), &[6], "must use only the native asset")] +#[case::foreign_without_fees(0, Some(1), &[6], "must use only the native asset")] +#[case::missing_without_fees(0, None, &[], "Invalid proof for transaction")] #[tokio::test] -async fn rpc_server_rejects_proven_transactions_without_native_fees( +async fn rpc_server_checks_transaction_fee_notes( + #[case] verification_base_fee: u32, #[case] foreign_fee_amount: Option, - #[case] expected_detail: u8, + #[case] expected_details: &[u8], #[case] expected_error: &str, ) { - let store = TestStore::start().await; + let store = TestStore::start_with_base_fee(verification_base_fee).await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); let fee = foreign_fee_amount @@ -654,7 +673,7 @@ async fn rpc_server_rejects_proven_transactions_without_native_fees( let status = service.submit_proven_tx(Request::new(request)).await.unwrap_err(); assert_eq!(status.code(), tonic::Code::InvalidArgument); - assert_eq!(status.details(), &[expected_detail]); + assert_eq!(status.details(), expected_details); assert!( status.message().contains(expected_error), "expected {expected_error}, got: {status}" @@ -662,16 +681,18 @@ async fn rpc_server_rejects_proven_transactions_without_native_fees( } #[rstest::rstest] -#[case::missing(None, 4, "does not contain a canonical TX_FEE output note")] -#[case::foreign(Some(1), 6, "must use only the native asset")] -#[case::zero_foreign(Some(0), 6, "must use only the native asset")] +#[case::missing(1, None, 4, "does not contain a canonical TX_FEE output note")] +#[case::foreign(1, Some(1), 6, "must use only the native asset")] +#[case::zero_foreign(1, Some(0), 6, "must use only the native asset")] +#[case::foreign_without_fees(0, Some(1), 6, "must use only the native asset")] #[tokio::test] async fn sequencer_authenticated_rpc_rejects_transactions_without_native_fees( + #[case] verification_base_fee: u32, #[case] foreign_fee_amount: Option, #[case] expected_detail: u8, #[case] expected_error: &str, ) { - let store = TestStore::start().await; + let store = TestStore::start_with_base_fee(verification_base_fee).await; let genesis = store.genesis_commitment(); let (account, account_patch) = build_test_account([0; 32]); let fee = foreign_fee_amount @@ -711,6 +732,38 @@ async fn sequencer_authenticated_rpc_rejects_transactions_without_native_fees( ); } +#[tokio::test] +async fn sequencer_authenticated_rpc_accepts_transactions_without_notes_when_fees_are_zero() { + let store = TestStore::start_with_base_fee(0).await; + let (account, account_patch) = build_test_account([0; 32]); + let tx = + build_test_proven_tx_with_fee(&account, &account_patch, store.genesis_commitment(), None); + let inputs = TransactionInputs { + account_id: tx.account_id(), + account_commitment: Some(tx.account_update().initial_state_commitment()), + nullifiers: HashMap::default(), + found_unauthenticated_notes: HashSet::default(), + current_block_height: 0.into(), + }; + let tx = AuthenticatedTransaction::new_unchecked(tx.into(), inputs).unwrap(); + let block_producer = BlockProducerApi::new( + Arc::clone(&store.state), + store.state.committed_tip(), + BlockProducerApiConfig::default(), + CancellationToken::new(), + ); + let service = SequencerInternalService { + state: Arc::clone(&store.state), + block_producer: block_producer.clone(), + account_admission: AccountAdmission::enabled(store.bootstrap_allowlist()), + }; + + service + .submit_authenticated_tx(Request::new(proto::sequencer::AuthenticatedTransaction::from(tx))) + .await + .expect("zero-fee transactions do not require output notes"); +} + #[tokio::test] async fn rpc_server_rejects_invalid_deferred_transaction_proofs() { let store = TestStore::start().await; diff --git a/docs/external/src/rpc/errors-and-limits.md b/docs/external/src/rpc/errors-and-limits.md index a65bfd954f..2bf8f3067e 100644 --- a/docs/external/src/rpc/errors-and-limits.md +++ b/docs/external/src/rpc/errors-and-limits.md @@ -56,11 +56,12 @@ conflict, and use the detail byte when a client needs stable branching between b `CapacityExceeded` means the mempool capacity has been exhausted and is under load. `MissingFee` means that a standalone transaction submitted through `SubmitProvenTx` does not contain an output note with -the canonical `TX_FEE` script. Each such note must contain exactly one native asset, as specified by the reference -block's protocol configuration. `InvalidFeeAsset` means that a fee note does not meet this asset requirement. The -internal `SubmitAuthenticatedTx` endpoint applies the same checks. Transactions within user-submitted batches are exempt -because those batches handle their own fee collection. These checks do not establish that the fee amount is sufficient. -A fee note can contain a zero-valued native asset when the required fee is zero. +the `TX_FEE` script when the reference block's verification base fee is nonzero. A transaction can omit the fee note +when that base fee is zero. Each fee note must contain exactly one native asset, as specified by the reference block's +protocol configuration, even when fees are zero. `InvalidFeeAsset` means that a fee note does not meet this asset +requirement. The internal `SubmitAuthenticatedTx` endpoint applies the same checks. Transactions within user-submitted +batches are exempt because those batches handle their own fee collection. These checks do not establish that the fee +amount is sufficient. A fee note can contain a zero-valued native asset when the required fee is zero. ### Encrypted input errors