Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 96 additions & 43 deletions crates/block-producer/src/domain/transaction.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
use miden_protocol::Word;
use miden_protocol::asset::AssetId;
use miden_protocol::block::FeeParameters;
use miden_protocol::transaction::ProvenTransaction;
use miden_protocol::transaction::{OutputNote, ProvenTransaction};
use miden_standards::note::TxFeeNote;

use crate::errors::MempoolSubmissionError;

/// Ensures that a transaction pays a non-zero fee when fees are enabled.
/// 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.
///
/// 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.
/// 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> {
if fee_parameters.verification_base_fee() == 0 {
return Ok(());
let fee_script_root = TxFeeNote::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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's technically possible to create a valid fee note that has the assets, but with a quantity of 0. If so, wonder if we should check that the amount is at least > 0 (but maybe it'd be even better to have a minimum amount that a minimal transaction would cost to compare against). cc @PhilippGackstatter @mmagician to confirm

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually want to allow zero so that this same flow works on networks with a zero fee.

This technically doesn't work at the moment because some protocol procedures explicitly emit no note when fees set to zero.

It's an.. open question at the moment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think none of the standard auth components emit fee notes when fees are zero. I think the main motivation was avoiding touching most protocol tests that would now output fee notes and change the assertions. Certainly something we could revisit, but for now the node may be better off skipping the fee requirement when fees are zero.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in bb18069. Both standalone transaction endpoints now allow a missing fee note when the transaction reference block has a zero verification base fee. Any fee notes that are emitted must still contain only the native asset; user batches remain exempt. The batch-building PR (#2647) also skips the collector transaction when there are no fee notes, since a deployed collector cannot execute without input notes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But should transactions that create a fee note with 0 assets (or a small enough amount of tokens which you know won't cover the tx) for networks that require fees even be allowed? Technically yes because at the batch level fees may still be enough to include the transaction? I understand standards pay fees correctly but a user could decide to pay whatever amount of fees they wanted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch builder can decide, in theory, to sponsor or accept whatever transactions it wants.

I wanted this to be a flat:

  • never allow txs without a fee note
  • only accept fee notes with a native asset
  • only accept fee notes which pay more than their tx requires

but we can' really do that

return Err(MempoolSubmissionError::InvalidFeeAsset {
transaction_id: tx.id(),
fee_asset_id,
});
}
contains_fee = true;
}

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
});

if contains_fee {
if contains_fee || fee_parameters.verification_base_fee() == 0 {
Ok(())
} else {
Err(MempoolSubmissionError::MissingFee { transaction_id: tx.id() })
Expand All @@ -42,8 +44,10 @@ 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::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};
use miden_standards::note::TxFeeNote;

Expand All @@ -63,55 +67,104 @@ 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 {
MockProvenTxBuilder::with_account_index(1)
.output_notes(vec![fee_output_note(
&[FungibleAsset::new(FungibleAsset::mock_issuer(), amount).unwrap().into()],
1,
)])
.build()
}

fn transaction_with_fee_amount(amount: u64) -> ProvenTransaction {
let fee_note = TxFeeNote::builder()
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, &fee_parameters(1)).unwrap();
ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)).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, fee_asset_id(), &FeeParameters::new(1)),
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_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);

assert_matches!(
ensure_transaction_has_fee(&tx, &fee_parameters(1)),
Err(MempoolSubmissionError::MissingFee { transaction_id }) if transaction_id == tx.id()
);
ensure_transaction_has_fee(&tx, fee_asset_id(), &FeeParameters::new(1)).unwrap();
}

#[test]
fn transaction_without_fee_is_accepted_when_fees_are_disabled() {
let tx = MockProvenTxBuilder::with_account_index(1).build();
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();
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()
);
}
}
}

ensure_transaction_has_fee(&tx, &fee_parameters(0)).unwrap();
#[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(), &FeeParameters::new(1)),
Err(MempoolSubmissionError::InvalidFeeAsset { .. })
);
}
}
}
11 changes: 10 additions & 1 deletion crates/block-producer/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -76,7 +77,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:?}")]
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions crates/rpc/src/server/api/submit_auth_tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,8 +54,13 @@ impl sequencer_api::SubmitAuthenticatedTx for SequencerInternalService {
)));
}

ensure_transaction_has_fee(tx.raw_proven_transaction(), reference_header.fee_parameters())
.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(),
reference_header.fee_parameters(),
)
.map_err(Status::from)?;

self.block_producer
.submit_authenticated_tx(tx)
Expand Down
10 changes: 8 additions & 2 deletions crates/rpc/src/server/api/submit_proven_tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -76,7 +76,13 @@ impl proto::server::rpc_api::SubmitProvenTx for RpcService {
let reference_header = 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)?;
let protocol_config = load_protocol_config(&self.state.view(), &reference_header).await?;
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(
Expand Down
2 changes: 1 addition & 1 deletion crates/rpc/src/server/api/submit_proven_tx_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ impl proto::server::rpc_api::SubmitProvenTxBatch for RpcService {
}
}

// Verify the reference block is actually part of the chain.
// Verify that the reference block is part of the chain.
self.verify_reference_commitment(
proven_batch.reference_block_num(),
proven_batch.reference_block_commitment(),
Expand Down
Loading
Loading