commit 55c740310bb240d8d9d437441d4b078c57d06bc8
parent 7ee830187b0ea85ff82df4967a3f159d15218c64
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 3 Aug 2026 22:52:38 +0200
Require plaintext burn in blinded blocks
Diffstat:
6 files changed, 67 insertions(+), 24 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.11"
+version = "0.2.12"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.11"
+version = "0.2.12"
edition = "2024"
license = "Apache-2.0"
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -44,7 +44,7 @@ For each block height, eligible tickets are ranked:
The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules.
-Every normal block must include at least one plaintext burn, blinded transaction envelope, or blinded reveal. Plaintext transaction fees go to the block finalizer immediately; blinded transaction fees are paid to the finalizer that committed the envelope when the payload is revealed and executed.
+Every normal block must include at least one plaintext burn. A blinded transaction envelope does not satisfy that rule, because the finalizer and validators cannot know whether the encrypted payload is a burn until reveal. Plaintext transaction fees go to the block finalizer immediately; blinded transaction fees are paid to the finalizer that committed the envelope when the payload is revealed and executed.
## VDF Timing
@@ -136,7 +136,7 @@ This does not make censorship impossible. A finalizer can still ignore all blind
When a node builds a block, it selects transactions in this order:
1. Include valid blinded reveals first, so already committed encrypted payloads can execute.
-2. Ensure the block has at least one plaintext burn, blinded transaction, or blinded reveal.
+2. Ensure the block has at least one plaintext burn.
3. For recovery blocks, ensure at least one plaintext burn is from the recovery finalizer.
4. Fill remaining space with valid plaintext transactions and blinded transactions ordered by fee rate.
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -537,6 +537,8 @@ mod tests {
.iter()
.find(|wallet| wallet.address() == leader)
.unwrap();
+ let burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
let block = ledger.mine_next_block(wallet, 1).unwrap();
ledger.apply_locally_mined_block(block).unwrap();
ledger.submit_blinded_reveal(blinded.reveal).unwrap();
@@ -545,6 +547,8 @@ mod tests {
.iter()
.find(|wallet| wallet.address() == leader)
.unwrap();
+ let burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
let block = ledger.mine_next_block(wallet, 2).unwrap();
ledger.apply_locally_mined_block(block).unwrap();
@@ -553,8 +557,8 @@ mod tests {
let last = metrics.last().unwrap();
let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>();
- assert_eq!(last.burn_count, 1);
- assert_eq!(last.burned_amount, 3);
+ assert_eq!(last.burn_count, 2);
+ assert_eq!(last.burned_amount, 4);
assert_eq!(last.fees_amount, 7);
assert_eq!(last.circulating_supply, supply_from_balances);
}
diff --git a/src/app.rs b/src/app.rs
@@ -2290,6 +2290,8 @@ mod tests {
.iter()
.find(|wallet| wallet.address() == leader)
.unwrap();
+ let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap();
+ finalizer_ledger.submit_transaction(burn).unwrap();
let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap();
wallet_node
diff --git a/src/domain.rs b/src/domain.rs
@@ -2258,7 +2258,7 @@ impl Ledger {
}
let selection = self.select_block_transactions()?;
- ensure_block_has_burn_or_blinded(&selection)?;
+ ensure_block_has_burn(&selection.transactions)?;
let tip = self.tip();
let prev_hash = tip.hash.clone();
@@ -2545,11 +2545,7 @@ impl Ledger {
if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes {
bail!("block exceeds max block size");
}
- ensure_block_has_burn_or_blinded(&BlockSelection {
- transactions: block.transactions.clone(),
- blinded_transactions: block.blinded_transactions.clone(),
- blinded_reveals: block.blinded_reveals.clone(),
- })?;
+ ensure_block_has_burn(&block.transactions)?;
validate_block_blinded_items(block, self)?;
match block.finalizer_mode {
FinalizerMode::Ticket => {
@@ -3405,16 +3401,6 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
Ok(())
}
-fn ensure_block_has_burn_or_blinded(selection: &BlockSelection) -> Result<()> {
- if !selection.transactions.iter().any(Transaction::is_burn)
- && selection.blinded_transactions.is_empty()
- && selection.blinded_reveals.is_empty()
- {
- bail!("block must include at least one burn transaction or blinded transaction");
- }
- Ok(())
-}
-
fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> {
if !transactions
.iter()
@@ -4687,6 +4673,13 @@ mod tests {
block
}
+ fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) {
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(wallets, &leader);
+ let burn = ledger.build_burn(wallet, 1, 0).unwrap();
+ ledger.submit_transaction(burn).unwrap();
+ }
+
fn transfer_with_extra_zero_outputs(
ledger: &Ledger,
wallet: &Wallet,
@@ -5579,15 +5572,24 @@ mod tests {
ledger
.submit_blinded_transaction(blinded.transaction.clone())
.unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
let inclusion_finalizer = commit_block.miner.clone();
- assert!(commit_block.transactions.is_empty());
+ assert_eq!(
+ commit_block
+ .transactions
+ .iter()
+ .filter(|transaction| transaction.is_burn())
+ .count(),
+ 1
+ );
assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]);
assert_eq!(commit_block.reward, 0);
let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer);
ledger.submit_blinded_reveal(blinded.reveal).unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
let reveal_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
assert_eq!(reveal_block.blinded_reveals.len(), 1);
@@ -5595,9 +5597,18 @@ mod tests {
ledger.balance_of(carol.address()),
before_carol - burn_amount - fee
);
+ let reveal_plaintext_burn_spent_by_inclusion_finalizer = reveal_block
+ .transactions
+ .iter()
+ .filter(|transaction| {
+ transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str()
+ })
+ .fold(0_u64, |total, transaction| {
+ total + transaction.amount() + transaction.fee()
+ });
assert_eq!(
ledger.balance_of(&inclusion_finalizer),
- before_inclusion_finalizer + fee
+ before_inclusion_finalizer + fee - reveal_plaintext_burn_spent_by_inclusion_finalizer
);
}
@@ -5614,6 +5625,7 @@ mod tests {
ledger
.submit_blinded_transaction(blinded.transaction.clone())
.unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
let leader = ledger.expected_leader_for_next_block().unwrap();
@@ -5649,6 +5661,7 @@ mod tests {
ledger
.submit_blinded_transaction(blinded.transaction.clone())
.unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
let leader = ledger.expected_leader_for_next_block().unwrap();
@@ -5683,6 +5696,27 @@ mod tests {
}
#[test]
+ fn blinded_transaction_does_not_satisfy_plaintext_burn_requirement() {
+ let alice = Wallet::from_seed("blinded-no-burn-finalizer-alice");
+ let bob = Wallet::from_seed("blinded-no-burn-finalizer-bob");
+ let carol = Wallet::from_seed("blinded-no-burn-carol");
+ let finalizers = [alice.clone(), bob.clone()];
+ let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]);
+ let blinded = ledger
+ .build_blinded_burn(&carol, 3, 7, ledger.height() + 4)
+ .unwrap();
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .unwrap();
+
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let wallet = wallet_for_address(&finalizers, &leader);
+ let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err();
+
+ assert!(format!("{error:#}").contains("block must include at least one burn transaction"));
+ }
+
+ #[test]
fn revealed_blinded_transaction_cannot_be_included_again() {
let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice");
let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob");
@@ -5695,10 +5729,12 @@ mod tests {
ledger
.submit_blinded_transaction(blinded.transaction.clone())
.unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
mine_preverified_as_next_leader(&mut ledger, &finalizers, 1);
ledger
.submit_blinded_reveal(blinded.reveal.clone())
.unwrap();
+ queue_next_leader_burn(&mut ledger, &finalizers);
mine_preverified_as_next_leader(&mut ledger, &finalizers, 2);
let leader = ledger.expected_leader_for_next_block().unwrap();
@@ -5734,6 +5770,7 @@ mod tests {
local
.submit_blinded_transaction(blinded.transaction.clone())
.unwrap();
+ queue_next_leader_burn(&mut local, &finalizers);
mine_preverified_as_next_leader(&mut local, &finalizers, 1);
for timestamp_ms in [1, 2] {