commit f4e6a5c313c801d5de8253fdb671a530270e7b33
parent 3ec94ea3445d4f9e543f9f3a4a4ed3a921eebf9e
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 15 Aug 2026 20:10:26 +0200
Block zero-fee spam after activation
Diffstat:
11 files changed, 368 insertions(+), 28 deletions(-)
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -174,6 +174,10 @@ Starting at height `750`, reveal fee attribution is per reveal mask. A signed re
Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. If an envelope expires unrevealed, its declared fee is burned and any remaining locked value returns as deterministic change to the owner of the first visible input. Expired local envelopes and reveals are dropped from local selection.
+Starting at height `750`, blinded envelopes must lock at least one visible input. This rejects free, unauthenticated zero-input envelopes while preserving compatibility with historical devnet blocks before the activation height.
+
+Starting at height `750`, every item that consumes block space must pay a fee, with one exception: a block may include one zero-fee plaintext burn from the block finalizer as its local anchor burn. Other plaintext transactions, additional finalizer burns, blinded envelopes, and revealed blinded payloads must carry a non-zero fee. This keeps historical devnet blocks valid while removing free blockspace spam after activation.
+
This does not make censorship impossible. A finalizer can still ignore all blinded traffic, or censor based on network metadata. But it removes the cheap strategy of inspecting plaintext mempool transactions and excluding third-party burns while including other fee-paying transactions.
## P2P Mempool Gossip
@@ -188,6 +192,8 @@ The P2P mempool gossips only:
It does not gossip plaintext transfers or burns. Wallet-created transfers and burns enter the network as blinded envelopes first, and are only decoded after a reveal. Mine actions are gossiped as public transactions. The one plaintext anchor burn required for every normal block is prepared locally by the finalizer and appears in the block itself.
+Nodes only keep blinded reveal keys in their local mempool when the reveal references an active blinded envelope and decrypts successfully. Unknown, stale, or wrong-key reveals are rejected before they consume pending reveal capacity. Pending transaction, blinded-envelope, reveal, and orphan pools are bounded by both item count and serialized byte size.
+
## Block Selection
When a node builds a block, it selects transactions in this order:
@@ -195,7 +201,7 @@ When a node builds a block, it selects transactions in this order:
1. Collect valid signed reveal bundles for the next height.
2. Reserve the local plaintext anchor burn as the first plaintext block item.
3. For recovery blocks, ensure at least one plaintext anchor burn is from the recovery finalizer.
-4. Fill remaining envelope space with valid public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to `2` actions per anchor.
+4. Fill remaining envelope space with valid fee-paying public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to `2` actions per anchor.
5. Bind the VDF seed to the three reveal-bundle slot hashes, using default hashes for missing slots.
Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes.
diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs
@@ -557,7 +557,10 @@ mod tests {
assert!(
message.contains("mine transaction anchor is not on this chain")
|| message.contains("conflicts with an existing pending transaction")
- || message.contains("blinded transaction expired"),
+ || message.contains("blinded transaction expired")
+ || message.contains(
+ "blinded reveal does not reference an active blinded transaction",
+ ),
"unexpected sparse chaos delivery error: {message}"
);
}
diff --git a/src/domain.rs b/src/domain.rs
@@ -57,6 +57,8 @@ use ledger_ops::estimated_block_selection_size_bytes;
use ledger_ops::fee_reward;
#[cfg(test)]
use ledger_ops::reward_outpoint;
+#[cfg(test)]
+use ledger_ops::validate_block_fee_policy;
use ledger_ops::{
apply_transaction, credit_reward_output, ensure_block_has_burn, ensure_block_has_burn_from,
ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs,
@@ -74,7 +76,8 @@ use mining::{mine_payload, mine_signature};
pub use profile::{GenesisBurn, LaunchProfile};
pub use protocol::{
Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR,
- BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS, BLOCK_REWARD,
+ BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BLINDED_REVEAL_FINALIZER_FEE_BPS,
+ BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BLOCK_REWARD,
DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_FEE, DEFAULT_TRANSACTION_FEE,
MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_BLOCK_BYTES, MAX_PENDING_TRANSACTIONS,
MAX_REVEAL_BUNDLE_BYTES, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT,
@@ -86,7 +89,7 @@ use protocol::{
BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLOCK_MEDIAN_TIME_PAST_WINDOW,
DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, FORK_FINALITY_DEPTH, HASH_BYTES,
MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MAX_BLOCK_TRANSACTIONS, MAX_ORPHAN_TRANSACTIONS,
- PUBLIC_KEY_BYTES, SIGNATURE_BYTES,
+ MAX_PENDING_POOL_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES,
};
use reveal::RevealBundlePayload;
#[cfg(test)]
diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs
@@ -8,7 +8,7 @@ use super::blinded::{
use super::ledger_ops::{
apply_transaction, block_reward, credit_reward_output, ensure_block_has_burn,
ensure_valid_recovery_block, spend_blinded_inputs, validate_block_blinded_items,
- verify_leader_proof,
+ validate_block_fee_policy, verify_leader_proof,
};
use super::mine_policy::ensure_mine_anchor_limit;
use super::ticket::{
@@ -17,10 +17,10 @@ use super::ticket::{
};
use super::transaction::{blinded_transaction_inputs_available, transaction_inputs_available};
use super::{
- Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode, Ledger,
- MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MaskedBlindedReveal, REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT,
- RevealBundleSection, RevealBundleSignature, Transaction, blinded_reveal_finalizer_fee,
- unix_now_ms, verify_vdf,
+ Amount, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode,
+ Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MaskedBlindedReveal,
+ REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT, RevealBundleSection, RevealBundleSignature, Transaction,
+ blinded_reveal_finalizer_fee, unix_now_ms, verify_vdf,
};
impl Ledger {
@@ -98,6 +98,12 @@ impl Ledger {
.context("blinded reveal does not reference an active blinded transaction")?
.clone();
let tx = self.decrypt_active_blinded(&active, reveal)?;
+ if block.height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && tx.fee() == 0 {
+ bail!(
+ "revealed blinded transaction must pay a fee from height {}",
+ BLOCK_ITEM_FEES_REQUIRED_HEIGHT
+ );
+ }
self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?;
let reveal_bundle_signatures = reveal_fee_signatures_for_height(
block.height,
@@ -296,6 +302,7 @@ impl Ledger {
}
ensure_mine_anchor_limit(block.height, &block.transactions)?;
ensure_block_has_burn(&block.transactions)?;
+ validate_block_fee_policy(block)?;
self.validate_reveal_bundle_section_for_block(
block.height,
&block.prev_hash,
diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs
@@ -1,4 +1,5 @@
-use anyhow::{Result, bail};
+use anyhow::{Context, Result, bail};
+use serde::Serialize;
use super::ledger_ops::{
apply_transaction, ensure_blinded_transaction_fits_empty_block,
@@ -9,7 +10,10 @@ use super::transaction::{
BlindedReveal, BlindedTransaction, Transaction, blinded_transaction_inputs_spent_by,
transaction_inputs_spent_by, transaction_inputs_spent_by_inputs,
};
-use super::{Ledger, MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, TransactionSubmitOutcome};
+use super::{
+ Ledger, MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_POOL_BYTES, MAX_PENDING_TRANSACTIONS,
+ TransactionSubmitOutcome,
+};
impl Ledger {
pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> {
@@ -44,6 +48,12 @@ impl Ledger {
if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS {
bail!("blinded mempool is full");
}
+ ensure_pending_pool_bytes(
+ "blinded mempool",
+ &self.pending_blinded,
+ &transaction,
+ MAX_PENDING_POOL_BYTES,
+ )?;
self.pending_blinded.push(transaction);
Ok(true)
}
@@ -53,12 +63,18 @@ impl Ledger {
return Ok(false);
}
self.validate_blinded_reveal_terms(&reveal)?;
+ self.pending_reveal_transaction(&reveal)?;
if self.pending_reveals.len() >= MAX_PENDING_TRANSACTIONS
- && (!self.has_active_blinded_transaction(&reveal.commitment)
- || !self.drop_one_invalid_pending_blinded_reveal())
+ && !self.drop_one_invalid_pending_blinded_reveal()
{
bail!("blinded reveal pool is full");
}
+ ensure_pending_pool_bytes(
+ "blinded reveal pool",
+ &self.pending_reveals,
+ &reveal,
+ MAX_PENDING_POOL_BYTES,
+ )?;
self.pending_reveals.push(reveal);
Ok(true)
}
@@ -104,12 +120,50 @@ impl Ledger {
if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS {
bail!("orphan transaction pool is full");
}
+ ensure_pending_pool_bytes(
+ "orphan transaction pool",
+ &self.orphans,
+ &transaction,
+ MAX_PENDING_POOL_BYTES,
+ )?;
self.orphans.push(transaction);
return Ok(TransactionSubmitOutcome::Added);
}
apply_transaction(&transaction, &mut utxos)?;
+ ensure_pending_pool_bytes(
+ "mempool",
+ &self.pending,
+ &transaction,
+ MAX_PENDING_POOL_BYTES,
+ )?;
self.pending.push(transaction);
self.promote_orphan_transactions()?;
Ok(TransactionSubmitOutcome::Added)
}
}
+
+fn ensure_pending_pool_bytes<T: Serialize>(
+ label: &str,
+ existing: &[T],
+ candidate: &T,
+ max_bytes: usize,
+) -> Result<()> {
+ let existing_bytes = existing.iter().try_fold(0usize, |total, item| {
+ let bytes = serde_json::to_vec(item)
+ .context("failed to serialize pending item for size check")?
+ .len();
+ total
+ .checked_add(bytes)
+ .context("pending pool byte size overflow")
+ })?;
+ let candidate_bytes = serde_json::to_vec(candidate)
+ .context("failed to serialize pending item for size check")?
+ .len();
+ let total_bytes = existing_bytes
+ .checked_add(candidate_bytes)
+ .context("pending pool byte size overflow")?;
+ if total_bytes > max_bytes {
+ bail!("{label} byte limit exceeded");
+ }
+ Ok(())
+}
diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs
@@ -267,6 +267,43 @@ pub(super) fn apply_transaction(
Ok(())
}
+pub(super) fn validate_block_fee_policy(block: &Block) -> Result<()> {
+ if block.height < super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT {
+ return Ok(());
+ }
+
+ let mut free_finalizer_anchor_burns = 0usize;
+ for transaction in &block.transactions {
+ if transaction.fee() > 0 {
+ continue;
+ }
+ if transaction.is_burn() && transaction.sender() == block.miner {
+ free_finalizer_anchor_burns += 1;
+ if free_finalizer_anchor_burns > 1 {
+ bail!(
+ "block may include only one zero-fee finalizer anchor burn from height {}",
+ super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT
+ );
+ }
+ continue;
+ }
+ bail!(
+ "block transaction must pay a fee from height {} unless it is the finalizer anchor burn",
+ super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT
+ );
+ }
+
+ for transaction in &block.blinded_transactions {
+ if transaction.fee == 0 {
+ bail!(
+ "blinded transaction must pay a fee from height {}",
+ super::BLOCK_ITEM_FEES_REQUIRED_HEIGHT
+ );
+ }
+ }
+ Ok(())
+}
+
pub(super) fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> {
let mut commitments = BTreeSet::new();
for transaction in &block.blinded_transactions {
diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs
@@ -29,7 +29,8 @@ use super::validation::{
validate_address, validate_hash, validate_signature, validate_stratum_header,
};
use super::{
- Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BlindedReveal, BlindedTransaction, Ledger,
+ Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT,
+ BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BlindedReveal, BlindedTransaction, Ledger,
MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_PENDING_TRANSACTIONS,
MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput, decode_hex, decode_hex_array,
};
@@ -87,9 +88,13 @@ impl Ledger {
pub(super) fn select_block_transactions(
&self,
+ miner: &str,
required_burn_signature: Option<&str>,
) -> Result<BlockSelection> {
- self.select_block_transactions_with_required_burn_owner(None, required_burn_signature)
+ self.select_block_transactions_with_required_burn_owner(
+ Some(miner),
+ required_burn_signature,
+ )
}
pub(super) fn select_recovery_block_transactions(
@@ -113,6 +118,7 @@ impl Ledger {
let mut remaining_blinded = self.valid_pending_blinded_transactions();
let mut selected = Vec::new();
let mut selected_blinded = Vec::new();
+ let next_height = self.height().saturating_add(1);
if let Some(signature) = required_burn_signature {
let index = remaining
@@ -140,6 +146,11 @@ impl Ledger {
apply_transaction(&tx, &mut utxos)
.context("required block anchor burn is not spendable")?;
selected.push(tx);
+ remove_extra_zero_fee_transactions_after_anchor(
+ &mut remaining,
+ required_burn_owner,
+ next_height,
+ );
}
let needs_first_burn = !selected.iter().any(Transaction::is_burn);
@@ -166,6 +177,11 @@ impl Ledger {
{
apply_transaction(&tx, &mut utxos)?;
selected.push(tx);
+ remove_extra_zero_fee_transactions_after_anchor(
+ &mut remaining,
+ required_burn_owner,
+ next_height,
+ );
}
}
}
@@ -189,6 +205,14 @@ impl Ledger {
match item {
SelectableItem::Plain(index, _) => {
let tx = remaining.remove(index);
+ if !zero_fee_transaction_is_selectable(
+ &tx,
+ required_burn_owner,
+ selected.iter().filter(|tx| tx.fee() == 0).count(),
+ next_height,
+ ) {
+ continue;
+ }
let mut candidate = BlockSelection {
transactions: selected.clone(),
blinded_transactions: selected_blinded.clone(),
@@ -205,6 +229,9 @@ impl Ledger {
}
SelectableItem::Blinded(index, _) => {
let transaction = remaining_blinded.remove(index);
+ if next_height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && transaction.fee == 0 {
+ continue;
+ }
let mut candidate = BlockSelection {
transactions: selected.clone(),
blinded_transactions: selected_blinded.clone(),
@@ -437,6 +464,19 @@ impl Ledger {
bail!("blinded transaction expiry is too far in the future");
}
validate_transaction_inputs(&transaction.inputs)?;
+ let next_height = self.height().saturating_add(1);
+ if transaction.inputs.is_empty() && next_height >= BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT {
+ bail!(
+ "blinded transaction must lock visible inputs from height {}",
+ BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT
+ );
+ }
+ if next_height >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT && transaction.fee == 0 {
+ bail!(
+ "blinded transaction must pay a fee from height {}",
+ BLOCK_ITEM_FEES_REQUIRED_HEIGHT
+ );
+ }
if transaction.inputs.is_empty() && transaction.fee > 0 {
bail!("blinded transaction with a fee must lock visible inputs");
}
@@ -602,3 +642,32 @@ impl Ledger {
Ok(utxos)
}
}
+
+fn zero_fee_transaction_is_selectable(
+ transaction: &Transaction,
+ finalizer: Option<&str>,
+ selected_zero_fee_transactions: usize,
+ height: u64,
+) -> bool {
+ if height < BLOCK_ITEM_FEES_REQUIRED_HEIGHT || transaction.fee() > 0 {
+ return true;
+ }
+ selected_zero_fee_transactions == 0
+ && transaction.is_burn()
+ && finalizer.is_some_and(|owner| transaction.sender() == owner)
+}
+
+fn remove_extra_zero_fee_transactions_after_anchor(
+ remaining: &mut Vec<Transaction>,
+ finalizer: Option<&str>,
+ height: u64,
+) {
+ if height < BLOCK_ITEM_FEES_REQUIRED_HEIGHT {
+ return;
+ }
+ remaining.retain(|transaction| {
+ transaction.fee() > 0
+ || !(transaction.is_burn()
+ && finalizer.is_some_and(|owner| transaction.sender() == owner))
+ });
+}
diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs
@@ -55,7 +55,7 @@ impl Ledger {
let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?;
let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles);
- let selection = self.select_block_transactions(required_burn_signature)?;
+ let selection = self.select_block_transactions(miner, required_burn_signature)?;
ensure_block_has_burn(&selection.transactions)?;
let tip = self.tip();
diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs
@@ -22,8 +22,11 @@ pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500;
pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500;
pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000;
pub const REVEAL_FEE_MASK_ATTRIBUTION_HEIGHT: u64 = 750;
+pub const BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT: u64 = 750;
+pub const BLOCK_ITEM_FEES_REQUIRED_HEIGHT: u64 = 750;
pub const MAX_PENDING_TRANSACTIONS: usize = 10_000;
+pub(super) const MAX_PENDING_POOL_BYTES: usize = 8 * 1024 * 1024;
pub(super) const MAX_ORPHAN_TRANSACTIONS: usize = 1_024;
pub(super) const MAX_BLOCK_TRANSACTIONS: usize = 1_000;
pub(super) const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3;
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
@@ -1979,6 +1979,144 @@ fn fee_bearing_blinded_commit_without_inputs_is_rejected() {
assert!(format!("{error:#}").contains("must lock visible inputs"));
}
+fn set_tip_height_for_validation(ledger: &mut Ledger, height: u64) {
+ ledger.chain.last_mut().unwrap().height = height;
+}
+
+fn inputless_zero_fee_blinded_burn(ledger: &Ledger, wallet: &Wallet) -> BlindedTransaction {
+ let mut builder = ledger.clone();
+ if builder.height().saturating_add(1) >= BLOCK_ITEM_FEES_REQUIRED_HEIGHT {
+ set_tip_height_for_validation(&mut builder, BLOCK_ITEM_FEES_REQUIRED_HEIGHT - 2);
+ }
+ let mut blinded = builder
+ .build_blinded_burn(wallet, 1, 0, ledger.height() + 4)
+ .unwrap()
+ .transaction;
+ blinded.inputs.clear();
+ blinded.commitment = blinded_transaction_commitment(&blinded).unwrap();
+ blinded
+}
+
+#[test]
+fn inputless_zero_fee_blinded_commit_is_allowed_before_height_750() {
+ let alice = Wallet::from_seed("blinded-no-input-before-activation-alice");
+ let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
+ set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 2);
+ let blinded = inputless_zero_fee_blinded_burn(&ledger, &alice);
+
+ assert!(ledger.submit_blinded_transaction(blinded).unwrap());
+}
+
+#[test]
+fn inputless_zero_fee_blinded_commit_is_rejected_from_height_750() {
+ let alice = Wallet::from_seed("blinded-no-input-after-activation-alice");
+ let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
+ set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 1);
+ let blinded = inputless_zero_fee_blinded_burn(&ledger, &alice);
+
+ let error = ledger.submit_blinded_transaction(blinded).unwrap_err();
+
+ assert!(format!("{error:#}").contains("must lock visible inputs from height 750"));
+}
+
+fn block_for_fee_policy(miner: &Wallet, height: u64, transactions: Vec<Transaction>) -> Block {
+ Block {
+ height,
+ prev_hash: "0".repeat(64),
+ timestamp_ms: height,
+ miner: miner.address().to_string(),
+ finalizer_mode: FinalizerMode::Ticket,
+ finalizer_rank: 0,
+ reward: fee_reward(&transactions).unwrap(),
+ vdf_rounds: 1,
+ vdf_output: "vdf".to_string(),
+ leader_proof: None,
+ blinded_transactions: Vec::new(),
+ reveal_bundle_section: RevealBundleSection::default(),
+ transactions,
+ hash: String::new(),
+ }
+}
+
+#[test]
+fn block_fee_policy_allows_one_zero_fee_finalizer_anchor_from_height_750() {
+ let alice = Wallet::from_seed("fee-policy-finalizer-anchor-alice");
+ let ledger = ledger_with_allocation(&alice, 3 * MICRO_IUNA);
+ let anchor = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap();
+ let paid = ledger.build_burn(&alice, MICRO_IUNA, 1).unwrap();
+ let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![anchor, paid]);
+
+ validate_block_fee_policy(&block).unwrap();
+}
+
+#[test]
+fn block_fee_policy_rejects_zero_fee_non_finalizer_burn_from_height_750() {
+ let alice = Wallet::from_seed("fee-policy-finalizer-alice");
+ let bob = Wallet::from_seed("fee-policy-non-finalizer-bob");
+ let ledger = ledger_with_allocation(&bob, MICRO_IUNA);
+ let burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap();
+ let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![burn]);
+
+ let error = validate_block_fee_policy(&block).unwrap_err();
+
+ assert!(format!("{error:#}").contains("must pay a fee from height 750"));
+}
+
+#[test]
+fn block_fee_policy_rejects_second_zero_fee_finalizer_burn_from_height_750() {
+ let alice = Wallet::from_seed("fee-policy-second-anchor-alice");
+ let ledger = ledger_with_wallet_utxos(&alice, &[MICRO_IUNA, MICRO_IUNA]);
+ let first = ledger
+ .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[test_utxo_outpoint(0)])
+ .unwrap();
+ let second = ledger
+ .build_burn_with_inputs(&alice, MICRO_IUNA, 0, &[test_utxo_outpoint(1)])
+ .unwrap();
+ let block = block_for_fee_policy(&alice, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, vec![first, second]);
+
+ let error = validate_block_fee_policy(&block).unwrap_err();
+
+ assert!(format!("{error:#}").contains("only one zero-fee finalizer anchor burn"));
+}
+
+fn large_inputless_zero_fee_blinded_spam(index: usize) -> BlindedTransaction {
+ let ciphertext = format!("{index:08x}{}", "ab".repeat(40_000));
+ let mut transaction = BlindedTransaction {
+ commitment: String::new(),
+ inputs: Vec::new(),
+ fee: 0,
+ encrypted_size: 40_004,
+ expires_at_height: BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 1,
+ nonce: format!("{index:024x}"),
+ ciphertext,
+ payload_hash: hex_hash(format!("large-blinded-spam:{index}")),
+ };
+ transaction.commitment = blinded_transaction_commitment(&transaction).unwrap();
+ transaction
+}
+
+#[test]
+fn blinded_mempool_rejects_byte_limit_even_before_height_750() {
+ let alice = Wallet::from_seed("blinded-byte-limit-alice");
+ let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
+ set_tip_height_for_validation(&mut ledger, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT - 3);
+
+ let sample = large_inputless_zero_fee_blinded_spam(0);
+ let sample_bytes = serde_json::to_vec(&sample).unwrap().len();
+ let existing_count = MAX_PENDING_POOL_BYTES / sample_bytes;
+ ledger.pending_blinded = (0..existing_count)
+ .map(large_inputless_zero_fee_blinded_spam)
+ .collect();
+
+ let error = ledger
+ .submit_blinded_transaction(large_inputless_zero_fee_blinded_spam(existing_count))
+ .unwrap_err();
+
+ assert!(existing_count > 1);
+ assert!(existing_count < MAX_PENDING_TRANSACTIONS);
+ assert!(format!("{error:#}").contains("blinded mempool byte limit exceeded"));
+}
+
#[test]
fn mine_actions_cannot_be_blinded() {
let alice = Wallet::from_seed("blinded-mine-collateral-alice");
@@ -2319,12 +2457,32 @@ fn expired_blinded_reveal_is_not_selected() {
ledger.submit_transaction(filler_burn).unwrap();
mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
- ledger.submit_blinded_reveal(blinded.reveal).unwrap();
- assert!(ledger.valid_pending_blinded_reveals().is_empty());
+ let error = ledger.submit_blinded_reveal(blinded.reveal).unwrap_err();
+
+ assert!(
+ format!("{error:#}").contains("does not reference an active blinded transaction"),
+ "{error:#}"
+ );
}
#[test]
-fn active_blinded_reveal_displaces_invalid_reveal_spam_when_pool_is_full() {
+fn unknown_blinded_reveal_spam_is_rejected() {
+ let alice = Wallet::from_seed("blinded-unknown-reveal-spam-alice");
+ let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA);
+
+ let error = ledger
+ .submit_blinded_reveal(BlindedReveal {
+ commitment: hex_hash("unknown-blinded-reveal-spam"),
+ key: "00".repeat(BLINDED_KEY_BYTES),
+ })
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("does not reference an active blinded transaction"));
+ assert!(ledger.pending_blinded_reveals().is_empty());
+}
+
+#[test]
+fn active_blinded_reveal_displaces_invalid_legacy_reveal_when_pool_is_full() {
let alice = Wallet::from_seed("blinded-spam-finalizer-alice");
let bob = Wallet::from_seed("blinded-spam-finalizer-bob");
let carol = Wallet::from_seed("blinded-spam-carol");
@@ -2339,14 +2497,12 @@ fn active_blinded_reveal_displaces_invalid_reveal_spam_when_pool_is_full() {
queue_next_leader_burn(&mut ledger, &finalizers);
mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
- for index in 0..MAX_PENDING_TRANSACTIONS {
- ledger
- .submit_blinded_reveal(BlindedReveal {
- commitment: hex_hash(format!("unknown-blinded-reveal-spam:{index}")),
- key: "00".repeat(BLINDED_KEY_BYTES),
- })
- .unwrap();
- }
+ ledger.pending_reveals = (0..MAX_PENDING_TRANSACTIONS)
+ .map(|index| BlindedReveal {
+ commitment: hex_hash(format!("unknown-blinded-reveal-spam:{index}")),
+ key: "00".repeat(BLINDED_KEY_BYTES),
+ })
+ .collect();
assert_eq!(
ledger.pending_blinded_reveals().len(),
MAX_PENDING_TRANSACTIONS
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -1883,7 +1883,9 @@ fn receive_chaotic_envelope(
|| message.contains("reveal bundle parent hash is invalid")
|| message.contains("mine transaction anchor is not on this chain")
|| message.contains("blinded transaction spends missing output")
- || message.contains("blinded transaction expiry is too far in the future"),
+ || message.contains("blinded transaction expiry is too far in the future")
+ || message
+ .contains("blinded reveal does not reference an active blinded transaction",),
"unexpected chaotic delivery error: {message}"
);
}