commit 8d9b52afef31ba8af1bf479298dd5fe4e2e1d8fb
parent c653d939c1b04fe559dc026290663bfebdc1331e
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 8 Aug 2026 10:11:23 +0200
Separate finalizer anchor burns
Diffstat:
8 files changed, 591 insertions(+), 205 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.33"
+version = "0.2.34"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.33"
+version = "0.2.34"
edition = "2024"
license = "Apache-2.0"
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -46,11 +46,11 @@ 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. 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. Block-producing nodes create this plaintext burn locally from the finalizer wallet during block construction; it is not part of the gossiped mempool.
+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. A node that may finalize prepares a local plaintext anchor burn for the next block from the finalizer wallet. This anchor burn is not gossiped as normal wallet traffic.
-This mandatory burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns.
+This mandatory anchor burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns.
-Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. When a blinded payload is revealed and executed, `35%` of its fee goes to the finalizer that originally committed the envelope, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned. The locally produced plaintext burn required for block liveness is part of the block reward like other plaintext block items.
+Wallet-created transfers and burns are not gossiped as plaintext. Their blinded envelopes expose and lock UTXO inputs before reveal, so declared fees are backed by spendable coins. Mine actions are public mempool items, because they do not reveal burn or transfer intent and must be possible without owning coins. The local plaintext anchor burn for finalization is separate from the configured automatic blinded burn per block. When automatic burning is enabled, the configured burn amount enters the network as a blinded envelope like other wallet-created burns. When a blinded payload is revealed and executed, `35%` of its fee goes to the finalizer that originally committed the envelope, `35%` goes to the reveal-block finalizer, and `10%` goes to each included signed reveal-list maker. Missing reveal-list shares and rounding dust are burned. The local plaintext anchor burn required for block liveness is part of the block reward like other plaintext block items.
## VDF Timing
@@ -136,7 +136,7 @@ Reveal is a later step. A `BlindedReveal` carries only the commitment and decryp
For each next block height, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can always sign a reveal list for its own block. The remaining slots are assigned to the two lowest-ranked eligible tickets. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Empty bundles are not gossiped.
-A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext burn, public mine actions, and blinded transaction envelopes.
+A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext anchor burn, public mine actions, and blinded transaction envelopes.
The compact reveal-bundle section stores:
@@ -172,15 +172,15 @@ The P2P mempool gossips only:
- signed reveal bundles;
- block inventory and blocks.
-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 burn required for every normal block is produced locally by the finalizer and appears in the block itself.
+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.
## Block Selection
When a node builds a block, it selects transactions in this order:
1. Collect valid signed reveal bundles for the next height.
-2. Ensure the envelope has at least one plaintext burn from local block construction.
-3. For recovery blocks, ensure at least one plaintext burn is from the recovery finalizer.
+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.
5. Bind the VDF seed to the three reveal-bundle slot hashes, using default hashes for missing slots.
diff --git a/src/app.rs b/src/app.rs
@@ -36,6 +36,8 @@ pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000;
const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000;
const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8;
const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000;
+const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1;
+const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 0;
static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false);
pub fn set_debug_logging(enabled: bool) {
@@ -253,6 +255,7 @@ pub struct NodeCore {
burn_fee: Amount,
recovery_vdf_top_rank_percent: u8,
last_auto_burn_height: Option<u64>,
+ last_auto_anchor_burn_height: Option<u64>,
last_auto_pow_mine_anchor: Option<String>,
last_auto_pow_mine_status: Option<String>,
auto_pow_mine_cursor: Option<AutoPowMineCursor>,
@@ -351,6 +354,7 @@ impl NodeCore {
burn_fee,
recovery_vdf_top_rank_percent: recovery_vdf_top_rank_percent.min(100),
last_auto_burn_height: None,
+ last_auto_anchor_burn_height: None,
last_auto_pow_mine_anchor: None,
last_auto_pow_mine_status: None,
auto_pow_mine_cursor: None,
@@ -376,6 +380,7 @@ impl NodeCore {
pub fn replace_wallet(&mut self, wallet: Wallet) {
self.wallet = NodeWallet::Unlocked(wallet);
self.last_auto_burn_height = None;
+ self.last_auto_anchor_burn_height = None;
self.last_auto_pow_mine_anchor = None;
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
@@ -739,6 +744,7 @@ impl NodeCore {
self.burn_fee = fee;
if was_disabled && enabled && amount > 0 {
self.last_auto_burn_height = None;
+ self.last_auto_anchor_burn_height = None;
}
self.prepare_automatic_burn(now_ms())
}
@@ -1264,6 +1270,7 @@ impl NodeCore {
fn wallet_anchor_build_ledger(&self) -> Result<Ledger> {
let mut ledger = self.ledger.clone();
self.reserve_local_block_anchor_inputs(&mut ledger)?;
+ ledger.clear_pending_transactions();
ledger.clear_pending_blinded_transactions();
Ok(ledger)
}
@@ -1584,45 +1591,81 @@ impl NodeCore {
if !self.automatic_mining_enabled {
return Ok(None);
}
+ let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?;
if self.burn_per_block == 0 {
self.last_auto_burn_height = Some(current_height);
- return Ok(None);
+ return Ok(anchor_burn);
}
if self.last_auto_burn_height == Some(current_height) {
- return Ok(None);
+ return Ok(anchor_burn);
}
let fee_per_byte = self.burn_fee;
let balance = self.ledger.balance_of(self.wallet.address());
- let needs_plaintext_anchor = self.automatic_burn_needs_plaintext_anchor(timestamp_ms);
- let spendable_best = match self.wallet_build_ledger() {
- Ok(ledger) => self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance),
- Err(error) if needs_plaintext_anchor => {
- let _ = error;
- None
- }
- Err(error) => return Err(error),
- };
- let best = if spendable_best.is_none() && needs_plaintext_anchor {
- let anchor_ledger = self.wallet_anchor_build_ledger()?;
- self.best_automatic_burn_on_ledger(&anchor_ledger, fee_per_byte, balance)
- } else {
- spendable_best
- };
+ let ledger = self.wallet_build_ledger()?;
+ let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance);
let Some(tx) = best else {
self.last_auto_burn_height = Some(current_height);
- return Ok(None);
+ return Ok(anchor_burn);
};
let burn = tx.payload.clone();
- if needs_plaintext_anchor {
- self.local_block_anchor_burn = Some((current_height, burn.clone()));
- } else {
- self.submit_owned_blinded_transaction(tx)?;
- }
+ self.submit_owned_blinded_transaction(tx)?;
self.last_auto_burn_height = Some(current_height);
Ok(Some(burn))
}
+ fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> {
+ let current_height = self.ledger.status().height;
+ if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) {
+ return Ok(None);
+ }
+ if self
+ .local_block_anchor_burn
+ .as_ref()
+ .is_some_and(|(height, _)| *height == current_height)
+ {
+ return Ok(None);
+ }
+ if self.last_auto_anchor_burn_height == Some(current_height) {
+ return Ok(None);
+ }
+
+ let ledger = self.wallet_anchor_build_ledger()?;
+ let wallet = self.wallet.unlocked()?;
+ let required = AUTO_BLOCK_ANCHOR_BURN_AMOUNT
+ .checked_add(AUTO_BLOCK_ANCHOR_BURN_FEE)
+ .context("automatic finalizer anchor burn amount plus fee overflows")?;
+ let outpoint = ledger
+ .available_utxos_for_address(wallet.address())?
+ .into_iter()
+ .filter(|(_, output)| output.amount >= required)
+ .min_by_key(|(_, output)| output.amount)
+ .map(|(outpoint, _)| outpoint);
+ let burn = match outpoint {
+ Some(outpoint) => ledger.build_burn_with_inputs(
+ wallet,
+ AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
+ AUTO_BLOCK_ANCHOR_BURN_FEE,
+ &[outpoint],
+ ),
+ None => ledger.build_burn(
+ wallet,
+ AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
+ AUTO_BLOCK_ANCHOR_BURN_FEE,
+ ),
+ };
+ let burn = match burn {
+ Ok(burn) => burn,
+ Err(error) => {
+ self.last_auto_anchor_burn_height = Some(current_height);
+ return Err(error).context("automatic finalizer anchor burn failed");
+ }
+ };
+ self.local_block_anchor_burn = Some((current_height, burn.clone()));
+ self.last_auto_anchor_burn_height = Some(current_height);
+ Ok(Some(burn))
+ }
+
fn best_automatic_burn_on_ledger(
&self,
ledger: &Ledger,
@@ -1715,23 +1758,36 @@ impl NodeCore {
}
fn prepare_next_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
+ let required_burn_signature = self.current_local_block_anchor_signature();
self.ledger_with_local_block_anchor()?
- .prepare_next_block_with_reveal_bundles(
+ .prepare_next_block_with_required_burn_and_reveal_bundles(
self.wallet.address(),
timestamp_ms,
self.usable_reveal_bundles(),
+ required_burn_signature.as_deref(),
)
}
fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
+ let required_burn_signature = self.current_local_block_anchor_signature();
self.ledger_with_local_block_anchor()?
- .prepare_recovery_block_with_reveal_bundles(
+ .prepare_recovery_block_with_required_burn_and_reveal_bundles(
self.wallet.address(),
timestamp_ms,
self.usable_reveal_bundles(),
+ required_burn_signature.as_deref(),
)
}
+ fn current_local_block_anchor_signature(&self) -> Option<String> {
+ let (height, burn) = self.local_block_anchor_burn.as_ref()?;
+ if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) {
+ Some(burn.signature().to_string())
+ } else {
+ None
+ }
+ }
+
fn ledger_with_local_block_anchor(&self) -> Result<Ledger> {
let mut ledger = self.ledger.clone();
let Some((height, burn)) = &self.local_block_anchor_burn else {
@@ -1891,6 +1947,7 @@ impl NodeCore {
let imported = self.ledger.extend_from_snapshot(snapshot)?;
if imported {
self.last_auto_burn_height = None;
+ self.last_auto_anchor_burn_height = None;
self.last_auto_pow_mine_anchor = None;
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
@@ -1914,6 +1971,7 @@ impl NodeCore {
self.ledger = ledger;
self.last_auto_burn_height = None;
+ self.last_auto_anchor_burn_height = None;
self.last_auto_pow_mine_anchor = None;
self.last_auto_pow_mine_status = None;
self.auto_pow_mine_cursor = None;
@@ -2439,7 +2497,7 @@ mod tests {
use std::collections::{BTreeMap, BTreeSet};
use crate::domain::{
- FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE,
+ FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint,
RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
};
@@ -2939,9 +2997,12 @@ mod tests {
.unwrap()
.complete_prepared_block_at(commit_work, commit_vdf, 1)
.unwrap();
- assert_eq!(
- commit_block.blinded_transactions,
- vec![blinded],
+ let wallet_commitment = blinded.commitment.clone();
+ assert!(
+ commit_block
+ .blinded_transactions
+ .iter()
+ .any(|transaction| transaction.commitment == wallet_commitment),
"first block should commit the wallet's blinded burn"
);
network.deliver_until_idle().unwrap();
@@ -2952,7 +3013,7 @@ mod tests {
.ledger()
.pending_blinded_reveals()
.iter()
- .any(|reveal| reveal.commitment == commit_block.blinded_transactions[0].commitment),
+ .any(|reveal| reveal.commitment == wallet_commitment),
"finalizer should have received the reveal before building the next block"
);
assert!(
@@ -2962,7 +3023,7 @@ mod tests {
.ledger()
.pending_blinded_reveals()
.iter()
- .any(|reveal| reveal.commitment == commit_block.blinded_transactions[0].commitment),
+ .any(|reveal| reveal.commitment == wallet_commitment),
"wallet node should also keep the reveal in its mempool"
);
@@ -2980,9 +3041,11 @@ mod tests {
.complete_prepared_block_at(reveal_work, reveal_vdf, 2)
.unwrap();
- assert_eq!(
- reveal_block.all_blinded_reveals().len(),
- 1,
+ assert!(
+ reveal_block
+ .all_blinded_reveals()
+ .iter()
+ .any(|reveal| reveal.commitment == wallet_commitment),
"automatic finalization should include the pending reveal without requiring an extra mempool poll"
);
}
@@ -2994,21 +3057,26 @@ mod tests {
.find_map(|seed_index| {
let finalizer = Wallet::from_seed(&format!("during-vdf-finalizer-{seed_index}"));
let mut allocations = BTreeMap::new();
- allocations.insert(finalizer.address().to_string(), MICRO_IUNA);
- let ledger = Ledger::new_with_genesis_burns(
+ allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
+ let mut ledger = Ledger::new_with_genesis_burns(
allocations,
vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
1,
)
.unwrap();
- let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
- finalizer.clone(),
- ledger,
- true,
- 100,
- 100,
- );
- let block1 = node.automatic_mine_once(1).block?;
+ let split = ledger
+ .build_transfer(&finalizer, finalizer.address(), MICRO_IUNA / 10, 0)
+ .ok()?;
+ let split_change = OutPoint {
+ txid: split.signature().to_string(),
+ index: 1,
+ };
+ ledger.submit_transaction(split).ok()?;
+ let burn = ledger
+ .build_burn_with_inputs(&finalizer, 1, 0, &[split_change])
+ .ok()?;
+ ledger.submit_transaction(burn).ok()?;
+ let block1 = ledger.mine_next_block(&finalizer, 1).ok()?;
assert!(block1.blinded_transactions.is_empty());
assert!(
!block1
@@ -3017,6 +3085,14 @@ mod tests {
.any(|transaction| matches!(transaction, Transaction::Mine { .. })),
"node B joins after block 1, so block 1 should not include B's mine action"
);
+ ledger.apply_block(block1).ok()?;
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ finalizer.clone(),
+ ledger,
+ true,
+ 0,
+ 100,
+ );
let block2_plan = node.prepare_automatic_finalization(2);
let block2_work = block2_plan.work?;
let (_, anchor_burn) = node.local_block_anchor_burn.clone()?;
@@ -3101,31 +3177,18 @@ mod tests {
);
let block3 = block3_outcome.block.unwrap();
assert!(
- !block3
- .blinded_transactions
- .iter()
- .any(|tx| tx.commitment == blinded.commitment),
- "the mandatory anchor burn should win when the during-VDF blinded tx would starve it"
+ block3
+ .transactions
+ .first()
+ .is_some_and(Transaction::is_burn),
+ "the mandatory anchor burn must be selected before during-VDF mempool items"
);
+ let committed_blinded = block3
+ .blinded_transactions
+ .iter()
+ .any(|tx| tx.commitment == blinded.commitment);
assert_block_has_mine_action(&block3);
network.deliver_until_idle().unwrap();
- assert!(
- network
- .node("finalizer")
- .unwrap()
- .ledger()
- .pending_blinded_transactions()
- .is_empty(),
- "the conflicting own blinded tx should be pruned after the anchor burn spends its input"
- );
- assert!(
- network
- .node("finalizer")
- .unwrap()
- .owned_blinded_transactions()
- .is_empty(),
- "owned blinded state should not keep rebroadcasting the pruned tx"
- );
queue_auto_pow_mine_action(network.node_mut("miner").unwrap());
network.deliver_until_idle().unwrap();
@@ -3135,10 +3198,37 @@ mod tests {
.automatic_mine_once(4)
.block
.expect("finalizer should keep producing the next block");
- assert!(
- block4.all_blinded_reveals().is_empty(),
- "the pruned blinded tx was never committed, so there should be no reveal"
- );
+ if committed_blinded {
+ assert!(
+ block4
+ .all_blinded_reveals()
+ .iter()
+ .any(|reveal| reveal.commitment == blinded.commitment),
+ "the committed during-VDF blinded tx should reveal in a later block"
+ );
+ } else {
+ assert!(
+ network
+ .node("finalizer")
+ .unwrap()
+ .ledger()
+ .pending_blinded_transactions()
+ .is_empty(),
+ "a conflicting during-VDF blinded tx should be pruned after the anchor burn spends its input"
+ );
+ assert!(
+ network
+ .node("finalizer")
+ .unwrap()
+ .owned_blinded_transactions()
+ .is_empty(),
+ "owned blinded state should not keep rebroadcasting a pruned tx"
+ );
+ assert!(
+ block4.all_blinded_reveals().is_empty(),
+ "a pruned blinded tx was never committed, so there should be no reveal"
+ );
+ }
assert_block_has_mine_action(&block4);
}
@@ -3161,7 +3251,7 @@ mod tests {
finalizer.clone(),
ledger,
true,
- 100,
+ 0,
1_000,
);
node.set_pow_mining_enabled(true);
@@ -3537,7 +3627,7 @@ mod tests {
}
#[test]
- fn automatic_fallback_finalizer_burn_stays_plaintext_for_block_anchor() {
+ fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() {
let alice = Wallet::from_seed("auto-fallback-burn-alice");
let bob = Wallet::from_seed("auto-fallback-burn-bob");
let finalizers = [alice.clone(), bob.clone()];
@@ -3576,9 +3666,18 @@ mod tests {
plan.skipped_reason
);
assert!(node.ledger().pending().is_empty());
- assert!(node.ledger().pending_blinded_transactions().is_empty());
- assert!(node.local_block_anchor_burn.is_some());
- assert!(outbox.is_empty());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
+ let (_, anchor_burn) = node
+ .local_block_anchor_burn
+ .as_ref()
+ .expect("fallback anchor burn should be held locally");
+ let anchor_signature = anchor_burn.signature().to_string();
+ assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT);
+ assert!(
+ outbox
+ .iter()
+ .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_)))
+ );
let work = plan.work.expect("fallback work should be prepared");
let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
let block = node
@@ -3587,18 +3686,29 @@ mod tests {
assert_eq!(block.finalizer_rank, 1);
assert_eq!(block.finalizer_mode, FinalizerMode::Ticket);
+ assert!(block.transactions.iter().any(|transaction| {
+ transaction.is_burn() && transaction.amount() == super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT
+ }));
+ assert_eq!(
+ block
+ .transactions
+ .first()
+ .map(|transaction| transaction.signature()),
+ Some(anchor_signature.as_str())
+ );
+ assert!(!block.blinded_transactions.is_empty());
assert_eq!(node.ledger().height(), 1);
}
#[test]
- fn automatic_leader_burn_stays_plaintext_for_block_anchor() {
+ fn automatic_leader_prepares_anchor_and_blinded_burn() {
let alice = Wallet::from_seed("auto-plaintext-burn-alice");
let bob = Wallet::from_seed("auto-plaintext-burn-bob");
let finalizers = [alice.clone(), bob.clone()];
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
- let ledger = Ledger::new_with_genesis_burns(
+ let mut ledger = Ledger::new_with_genesis_burns(
allocations,
finalizers
.iter()
@@ -3613,6 +3723,22 @@ mod tests {
.find(|wallet| wallet.address() == leader)
.unwrap()
.clone();
+ for wallet in &finalizers {
+ let split = ledger
+ .build_transfer(wallet, wallet.address(), MICRO_IUNA, 0)
+ .unwrap();
+ ledger.submit_transaction(split).unwrap();
+ }
+ let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap();
+ ledger.submit_transaction(anchor).unwrap();
+ let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap();
+ ledger.apply_block(split_block).unwrap();
+ let leader = ledger.expected_leader_for_next_block().unwrap();
+ let leader_wallet = finalizers
+ .iter()
+ .find(|wallet| wallet.address() == leader)
+ .unwrap()
+ .clone();
let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
leader_wallet,
ledger,
@@ -3626,9 +3752,17 @@ mod tests {
assert!(plan.burned.is_some());
assert!(node.ledger().pending().is_empty());
- assert!(node.ledger().pending_blinded_transactions().is_empty());
- assert!(node.local_block_anchor_burn.is_some());
- assert!(outbox.is_empty());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
+ let (_, anchor_burn) = node
+ .local_block_anchor_burn
+ .as_ref()
+ .expect("leader anchor burn should be held locally");
+ assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT);
+ assert!(
+ outbox
+ .iter()
+ .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_)))
+ );
assert!(node.prepare_automatic_finalization(1).work.is_some());
}
@@ -3638,7 +3772,7 @@ mod tests {
let finalizers = [alice.clone()];
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
- let ledger = Ledger::new_with_genesis_burns(
+ let mut ledger = Ledger::new_with_genesis_burns(
allocations,
finalizers
.iter()
@@ -3653,13 +3787,16 @@ mod tests {
.find(|wallet| wallet.address() == leader)
.unwrap()
.clone();
- let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
- leader_wallet,
- ledger,
- true,
- MICRO_IUNA / 10,
- 1,
- );
+ let split = ledger
+ .build_transfer(&leader_wallet, leader_wallet.address(), MICRO_IUNA, 0)
+ .unwrap();
+ ledger.submit_transaction(split).unwrap();
+ let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap();
+ ledger.submit_transaction(anchor).unwrap();
+ let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap();
+ ledger.apply_block(split_block).unwrap();
+ let mut node =
+ NodeCore::from_ledger_with_burn_fee_and_enabled(leader_wallet, ledger, true, 0, 1);
let plan = node.prepare_automatic_finalization(1);
assert!(plan.burned.is_some());
@@ -3861,7 +3998,7 @@ mod tests {
fn sparse_network_stays_bounded_under_generated_actions() {
const NODES: usize = 10;
const ROUNDS: usize = 36;
- const SETTLE_BLOCKS: u64 = 14;
+ const SETTLE_BLOCKS: u64 = super::MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS + 10;
const QUIET_BLOCKS: usize = SETTLE_BLOCKS as usize + 4;
let mut rng = ChaosRng::new(0x1aba_0100);
@@ -3905,10 +4042,15 @@ mod tests {
match rng.index(32) {
0 => {
let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ let expiry_height = network
+ .node(&node_ids[index])
+ .expect("actor node exists")
+ .chain_height()
+ + 16;
let _ = network
.node_mut(&node_ids[index])
.expect("actor node exists")
- .blinded_transfer_with_fee(recipient, 1, 0, chaos_expiry_height(round));
+ .blinded_transfer_with_fee(recipient, 1, 0, expiry_height);
}
1 => {
attempt_bounded_mine_action(
@@ -3980,7 +4122,7 @@ mod tests {
}
let all_online = (0..NODES).collect::<BTreeSet<_>>();
- for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize * 3 {
+ for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize * 4 {
deliver_sparse_chaos_until_idle(
&mut network,
&node_ids,
@@ -4048,10 +4190,6 @@ mod tests {
(round as u64 + 1) * (RECOVERY_BLOCK_DELAY_MS + VDF_TARGET_BLOCK_MS)
}
- fn chaos_expiry_height(round: usize) -> u64 {
- round as u64 + 16
- }
-
fn attempt_bounded_mine_action(
network: &mut InMemoryNetwork,
node_id: &str,
@@ -4087,7 +4225,8 @@ mod tests {
.node(&node_ids[reference_index])
.expect("reference node exists");
let leader = reference.ledger().expected_leader_for_next_block();
- let producer_index = leader
+ let mut candidates = Vec::new();
+ if let Some(index) = leader
.as_deref()
.and_then(|leader| {
wallets
@@ -4098,26 +4237,38 @@ mod tests {
.filter(|index| {
network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height()
})
- .unwrap_or(reference_index);
-
- let mut producer = network
- .node(&node_ids[producer_index])
- .expect("producer node exists")
- .clone();
- let plan = producer.prepare_automatic_finalization(timestamp_ms);
- let Some(work) = plan.work else {
+ {
+ candidates.push(index);
+ }
+ candidates.push(reference_index);
+ candidates.extend(online.iter().copied().filter(|index| {
+ network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height()
+ }));
+ let mut seen = BTreeSet::new();
+ for producer_index in candidates {
+ if !seen.insert(producer_index) {
+ continue;
+ }
+ let mut producer = network
+ .node(&node_ids[producer_index])
+ .expect("producer node exists")
+ .clone();
+ let plan = producer.prepare_automatic_finalization(timestamp_ms);
+ let Some(work) = plan.work else {
+ continue;
+ };
+ let block = work.finish_at(
+ &wallets[producer_index],
+ "preverified-chaos-vdf".to_string(),
+ timestamp_ms,
+ );
+ network
+ .node_mut(&node_ids[producer_index])
+ .expect("producer node exists")
+ .receive_preverified_block_at(block, timestamp_ms)
+ .expect("mock-VDF block applies locally");
return;
- };
- let block = work.finish_at(
- &wallets[producer_index],
- "preverified-chaos-vdf".to_string(),
- timestamp_ms,
- );
- network
- .node_mut(&node_ids[producer_index])
- .expect("producer node exists")
- .receive_preverified_block_at(block, timestamp_ms)
- .expect("mock-VDF block applies locally");
+ }
}
fn highest_online_node(
@@ -4318,7 +4469,7 @@ mod tests {
ids.extend(
node.pending_blinded_transactions()
.into_iter()
- .map(|tx| format!("commit:{}", tx.commitment)),
+ .map(|tx| format!("commit:{}@{}", tx.commitment, tx.expires_at_height)),
);
ids.extend(
node.pending_blinded_reveals()
@@ -4358,7 +4509,8 @@ mod tests {
let pending = pending_item_ids(node);
assert!(
pending.is_empty(),
- "{node_id} still has pending mempool items: {pending:?}"
+ "{node_id} at height {} still has pending mempool items: {pending:?}",
+ node.chain_height()
);
}
}
diff --git a/src/domain.rs b/src/domain.rs
@@ -2201,6 +2201,10 @@ impl Ledger {
self.pending_blinded.clear();
}
+ pub(crate) fn clear_pending_transactions(&mut self) {
+ self.pending.clear();
+ }
+
pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> {
let height = self.tip().height + 1;
let prev_hash = self.tip().hash.clone();
@@ -2639,6 +2643,35 @@ impl Ledger {
.checked_add(fee)
.context("burn amount plus fee overflows")?;
let (inputs, input_total) = self.select_inputs(wallet.address(), required)?;
+ self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total)
+ }
+
+ pub fn build_burn_with_inputs(
+ &self,
+ wallet: &Wallet,
+ amount: Amount,
+ fee: Amount,
+ outpoints: &[OutPoint],
+ ) -> Result<Transaction> {
+ let required = amount
+ .checked_add(fee)
+ .context("burn amount plus fee overflows")?;
+ let (inputs, input_total) =
+ self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?;
+ self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total)
+ }
+
+ fn build_burn_from_inputs(
+ &self,
+ wallet: &Wallet,
+ amount: Amount,
+ fee: Amount,
+ inputs: Vec<UnsignedTxInput>,
+ input_total: Amount,
+ ) -> Result<Transaction> {
+ let required = amount
+ .checked_add(fee)
+ .context("burn amount plus fee overflows")?;
let change_amount = input_total
.checked_sub(required)
.context("selected inputs do not cover burn")?;
@@ -2993,6 +3026,21 @@ impl Ledger {
timestamp_ms: u64,
reveal_bundles: Vec<RevealBundle>,
) -> Result<PreparedBlock> {
+ self.prepare_next_block_with_required_burn_and_reveal_bundles(
+ miner,
+ timestamp_ms,
+ reveal_bundles,
+ None,
+ )
+ }
+
+ pub(crate) fn prepare_next_block_with_required_burn_and_reveal_bundles(
+ &self,
+ miner: &str,
+ timestamp_ms: u64,
+ reveal_bundles: Vec<RevealBundle>,
+ required_burn_signature: Option<&str>,
+ ) -> Result<PreparedBlock> {
let height = self.tip().height + 1;
let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner)
else {
@@ -3004,7 +3052,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()?;
+ let selection = self.select_block_transactions(required_burn_signature)?;
ensure_block_has_burn(&selection.transactions)?;
let tip = self.tip();
@@ -3049,6 +3097,21 @@ impl Ledger {
timestamp_ms: u64,
reveal_bundles: Vec<RevealBundle>,
) -> Result<PreparedBlock> {
+ self.prepare_recovery_block_with_required_burn_and_reveal_bundles(
+ miner,
+ timestamp_ms,
+ reveal_bundles,
+ None,
+ )
+ }
+
+ pub(crate) fn prepare_recovery_block_with_required_burn_and_reveal_bundles(
+ &self,
+ miner: &str,
+ timestamp_ms: u64,
+ reveal_bundles: Vec<RevealBundle>,
+ required_burn_signature: Option<&str>,
+ ) -> Result<PreparedBlock> {
let height = self.tip().height + 1;
let min_timestamp = self.recovery_block_min_timestamp();
if timestamp_ms < min_timestamp {
@@ -3057,7 +3120,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_recovery_block_transactions(miner)?;
+ let selection = self.select_recovery_block_transactions(miner, required_burn_signature)?;
ensure_block_has_burn(&selection.transactions)?;
ensure_block_has_burn_from(&selection.transactions, miner)?;
@@ -3457,17 +3520,28 @@ impl Ledger {
valid
}
- fn select_block_transactions(&self) -> Result<BlockSelection> {
- self.select_block_transactions_with_required_burn_owner(None)
+ fn select_block_transactions(
+ &self,
+ required_burn_signature: Option<&str>,
+ ) -> Result<BlockSelection> {
+ self.select_block_transactions_with_required_burn_owner(None, required_burn_signature)
}
- fn select_recovery_block_transactions(&self, miner: &str) -> Result<BlockSelection> {
- self.select_block_transactions_with_required_burn_owner(Some(miner))
+ fn select_recovery_block_transactions(
+ &self,
+ miner: &str,
+ required_burn_signature: Option<&str>,
+ ) -> Result<BlockSelection> {
+ self.select_block_transactions_with_required_burn_owner(
+ Some(miner),
+ required_burn_signature,
+ )
}
fn select_block_transactions_with_required_burn_owner(
&self,
required_burn_owner: Option<&str>,
+ required_burn_signature: Option<&str>,
) -> Result<BlockSelection> {
let mut utxos = self.utxos.clone();
let mut remaining = self.valid_pending_transactions();
@@ -3475,6 +3549,34 @@ impl Ledger {
let mut selected = Vec::new();
let mut selected_blinded = Vec::new();
+ if let Some(signature) = required_burn_signature {
+ let index = remaining
+ .iter()
+ .position(|transaction| transaction.signature() == signature)
+ .with_context(|| format!("required burn {signature} is not pending"))?;
+ let tx = remaining.remove(index);
+ if !tx.is_burn() {
+ bail!("required block anchor must be a burn transaction");
+ }
+ if let Some(owner) = required_burn_owner {
+ if tx.sender() != owner {
+ bail!("required block anchor burn must be from the recovery finalizer");
+ }
+ }
+ let candidate = BlockSelection {
+ transactions: vec![tx.clone()],
+ blinded_transactions: selected_blinded.clone(),
+ };
+ if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())?
+ > self.launch_profile.max_block_bytes
+ {
+ bail!("required block anchor burn does not fit in the block");
+ }
+ apply_transaction(&tx, &mut utxos)
+ .context("required block anchor burn is not spendable")?;
+ selected.push(tx);
+ }
+
let needs_first_burn = !selected.iter().any(Transaction::is_burn);
let needs_owner_burn = required_burn_owner.is_some_and(|owner| {
!selected
@@ -3618,7 +3720,7 @@ impl Ledger {
.context("selected input total overflows")?;
}
if total < amount {
- bail!("selected UTXOs do not cover transfer amount plus fee");
+ bail!("selected UTXOs do not cover amount plus fee");
}
Ok((selected, total))
}
@@ -6653,6 +6755,57 @@ mod tests {
}
#[test]
+ fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() {
+ let wallet = Wallet::from_seed("required-anchor-priority-wallet");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
+ 1,
+ )
+ .unwrap();
+ let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee");
+ let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor");
+ ledger.utxos.insert(
+ high_fee_outpoint.clone(),
+ TxOutput {
+ address: wallet.address().to_string(),
+ amount: 3,
+ },
+ );
+ ledger.utxos.insert(
+ anchor_outpoint.clone(),
+ TxOutput {
+ address: wallet.address().to_string(),
+ amount: 2,
+ },
+ );
+ let high_fee_burn = ledger
+ .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint])
+ .unwrap();
+ let anchor_burn = ledger
+ .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint])
+ .unwrap();
+ ledger.submit_transaction(high_fee_burn).unwrap();
+ ledger.submit_transaction(anchor_burn.clone()).unwrap();
+ ledger.launch_profile.max_block_transactions = 1;
+
+ let work = ledger
+ .prepare_next_block_with_required_burn_and_reveal_bundles(
+ wallet.address(),
+ 1,
+ Vec::new(),
+ Some(anchor_burn.signature()),
+ )
+ .unwrap();
+ let block = work.finish(&wallet, "preverified-vdf".to_string());
+
+ assert_eq!(block.transactions.len(), 1);
+ assert_eq!(block.transactions[0].signature(), anchor_burn.signature());
+ }
+
+ #[test]
fn late_ticket_vdf_completion_is_visible_to_retarget() {
let wallet = Wallet::from_seed("late-ticket-vdf-wallet");
let mut allocations = BTreeMap::new();
@@ -6811,6 +6964,43 @@ mod tests {
}
#[test]
+ fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() {
+ let alice = Wallet::from_seed("selected-burn-utxos-alice");
+ let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]);
+ let selected = vec![test_utxo_outpoint(1)];
+
+ let tx = ledger
+ .build_burn_with_inputs(&alice, 1, 1, &selected)
+ .unwrap();
+
+ let Transaction::Burn {
+ inputs,
+ change,
+ amount,
+ fee,
+ ..
+ } = &tx
+ else {
+ panic!("expected burn");
+ };
+ assert_eq!(*amount, 1);
+ assert_eq!(*fee, 1);
+ assert_eq!(inputs.len(), 1);
+ assert_eq!(inputs[0].outpoint, selected[0]);
+ assert_eq!(
+ change,
+ &[TxOutput {
+ address: alice.address().to_string(),
+ amount: 1
+ }]
+ );
+
+ ledger.submit_transaction(tx).unwrap();
+ let balances = pending_balances(&ledger);
+ assert_eq!(balances.get(alice.address()).copied(), Some(8));
+ }
+
+ #[test]
fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() {
let alice = Wallet::from_seed("selected-utxos-insufficient-alice");
let bob = Wallet::from_seed("selected-utxos-insufficient-bob");
diff --git a/src/main.rs b/src/main.rs
@@ -985,17 +985,14 @@ mod tests {
let first = node.automatic_mine_once(1_000);
let second = node.automatic_mine_once(2_000);
+ let third = node.automatic_mine_once(3_000);
- assert_eq!(
- first.burned.as_ref().map(|tx| tx.amount()),
- Some(GENESIS_INITIAL_BURN_PER_BLOCK)
- );
+ assert_eq!(first.burned.as_ref().map(|tx| tx.amount()), Some(1));
assert!(first.block.is_some(), "{first:?}");
- assert_eq!(
- second.burned.as_ref().map(|tx| tx.amount()),
- Some(GENESIS_INITIAL_BURN_PER_BLOCK)
- );
+ assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(1));
assert!(second.block.is_some(), "{second:?}");
+ assert_eq!(third.burned.as_ref().map(|tx| tx.amount()), Some(1));
+ assert!(third.block.is_some(), "{third:?}");
assert!(
second.skipped_reason.as_deref().is_none_or(|reason| {
!reason.contains("block must include at least one burn transaction")
@@ -1004,7 +1001,7 @@ mod tests {
);
assert!(
node.ledger().balance_of(wallet.address())
- >= BLOCK_REWARD - 2 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE)
+ >= BLOCK_REWARD - 3 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE)
);
}
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -225,16 +225,10 @@ fn starter_node_waits_for_a_burn_before_vdf_work() {
let mut node = starter_node(alice.clone());
let outcome = node.automatic_mine_once(1);
- assert!(outcome.burned.is_none());
- assert!(outcome.block.is_none());
- assert!(
- outcome
- .skipped_reason
- .as_deref()
- .is_some_and(|reason| reason.contains("at least one burn"))
- );
- assert_eq!(node.ledger().status().height, 0);
- assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD);
+ assert_eq!(outcome.burned.as_ref().map(|tx| tx.amount()), Some(1));
+ assert!(outcome.block.is_some(), "{outcome:?}");
+ assert_eq!(node.ledger().status().height, 1);
+ assert!(node.ledger().balance_of(alice.address()) < BLOCK_REWARD);
}
#[test]
@@ -610,29 +604,40 @@ fn block_hash_is_bound_to_block_contents() {
#[test]
fn automatic_mining_burns_configured_amount_once_per_height() {
let alice = Wallet::from_seed("alice");
+ let bob = Wallet::from_seed("auto-once-leader");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), iuna(1_000));
-
- let mut node = NodeCore::new(NodeConfig {
- wallet: alice.clone(),
- genesis_allocations: allocations,
- vdf_rounds: 10,
- burn_per_block: iuna(25),
- burn_fee: DEFAULT_FEE_PER_BYTE,
- recovery_vdf_top_rank_percent: 100,
- });
+ allocations.insert(bob.address().to_string(), MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
+ 10,
+ )
+ .unwrap();
+ assert_eq!(
+ ledger.expected_leader_for_next_block().as_deref(),
+ Some(bob.address())
+ );
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ alice.clone(),
+ ledger,
+ true,
+ iuna(25),
+ DEFAULT_FEE_PER_BYTE,
+ );
let first = node.automatic_mine_once(1);
assert!(first.burned.is_some());
- assert!(first.block.is_some());
- assert_eq!(node.ledger().chain().len(), 2);
- assert_eq!(node.ledger().balance_of(alice.address()), iuna(975));
-
- let second = node.automatic_mine_once(2);
- assert!(second.burned.is_some());
- let burned = second.burned.as_ref().unwrap();
+ let burned = first.burned.as_ref().unwrap();
assert_eq!(burned.amount(), iuna(25));
assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE);
+ assert!(first.block.is_none());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
+
+ let second = node.automatic_mine_once(2);
+ assert!(second.burned.is_none());
+ assert!(second.block.is_none());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
}
#[test]
@@ -694,73 +699,106 @@ fn automatic_burn_status_shows_configured_fee() {
#[test]
fn automatic_mining_uses_configured_burn_fee() {
let alice = Wallet::from_seed("auto-fee-burn-alice");
+ let bob = Wallet::from_seed("auto-fee-burn-leader");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), MICRO_IUNA);
- let mut node = node("alice", alice, allocations);
+ allocations.insert(bob.address().to_string(), MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
+ 25,
+ )
+ .unwrap();
+ assert_eq!(
+ ledger.expected_leader_for_next_block().as_deref(),
+ Some(bob.address())
+ );
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(alice, ledger, true, 50, 3);
- let burned = node.set_automatic_burn(50, 3).unwrap().unwrap();
+ let outcome = node.automatic_mine_once(1);
+ let burned = outcome.burned.as_ref().unwrap();
assert_eq!(burned.amount(), 50);
assert!(burned.fee() > burned.economic_size_bytes() as u64 * 3);
+ assert!(outcome.block.is_none());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
}
#[test]
fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
let alice = Wallet::from_seed("auto-burn-cap-alice");
+ let bob = Wallet::from_seed("auto-burn-cap-leader");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), BLOCK_REWARD);
- let planning_node = NodeCore::new(NodeConfig {
- wallet: alice.clone(),
- genesis_allocations: allocations.clone(),
- vdf_rounds: 10,
- burn_per_block: BLOCK_REWARD + iuna(50),
- burn_fee: DEFAULT_FEE_PER_BYTE,
- recovery_vdf_top_rank_percent: 100,
- });
- let mut node = NodeCore::new(NodeConfig {
- wallet: alice.clone(),
- genesis_allocations: allocations,
- vdf_rounds: 10,
- burn_per_block: BLOCK_REWARD + iuna(50),
- burn_fee: DEFAULT_FEE_PER_BYTE,
- recovery_vdf_top_rank_percent: 100,
- });
+ allocations.insert(bob.address().to_string(), MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
+ 10,
+ )
+ .unwrap();
+ assert_eq!(
+ ledger.expected_leader_for_next_block().as_deref(),
+ Some(bob.address())
+ );
+ let planning_node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ alice.clone(),
+ ledger.clone(),
+ true,
+ BLOCK_REWARD + iuna(50),
+ DEFAULT_FEE_PER_BYTE,
+ );
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ alice.clone(),
+ ledger,
+ true,
+ BLOCK_REWARD + iuna(50),
+ DEFAULT_FEE_PER_BYTE,
+ );
let outcome = node.automatic_mine_once(1);
let burned = outcome.burned.as_ref().unwrap();
- let unspent = BLOCK_REWARD - burned.amount() - burned.fee();
let next_amount = burned.amount() + 1;
if let Ok(next_estimate) = planning_node.estimate_burn_fee(next_amount, DEFAULT_FEE_PER_BYTE) {
assert!(next_amount + next_estimate.fee > BLOCK_REWARD);
}
assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE);
- assert!(outcome.block.is_some());
- assert_eq!(
- node.ledger().balance_of(alice.address()),
- burned.fee() + unspent
- );
+ assert!(outcome.block.is_none());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
}
#[test]
fn automatic_mining_preserves_configured_burn_when_only_fee_is_short() {
let alice = Wallet::from_seed("auto-burn-exact-target-alice");
+ let bob = Wallet::from_seed("auto-burn-exact-target-leader");
let mut allocations = BTreeMap::new();
allocations.insert(alice.address().to_string(), MICRO_IUNA);
- let mut node = NodeCore::new(NodeConfig {
- wallet: alice.clone(),
- genesis_allocations: allocations,
- vdf_rounds: 10,
- burn_per_block: MICRO_IUNA,
- burn_fee: DEFAULT_FEE_PER_BYTE,
- recovery_vdf_top_rank_percent: 100,
- });
+ allocations.insert(bob.address().to_string(), MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
+ 10,
+ )
+ .unwrap();
+ assert_eq!(
+ ledger.expected_leader_for_next_block().as_deref(),
+ Some(bob.address())
+ );
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ alice.clone(),
+ ledger,
+ true,
+ MICRO_IUNA,
+ DEFAULT_FEE_PER_BYTE,
+ );
let outcome = node.automatic_mine_once(1);
let burned = outcome.burned.as_ref().unwrap();
assert_eq!(burned.amount(), MICRO_IUNA);
assert_eq!(burned.fee(), 0);
- assert!(outcome.block.is_some());
+ assert!(outcome.block.is_none());
+ assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
}
#[test]
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -173,8 +173,14 @@ fn assert_chain_properties(snapshot: ChainSnapshot) {
.values()
.try_fold(0_u64, |total, amount| total.checked_add(*amount))
.expect("confirmed supply does not overflow");
- assert_eq!(confirmed_supply, expected_confirmed_supply(&snapshot));
- assert_reference_model_matches(&snapshot, &replayed);
+ let expected_supply = expected_confirmed_supply(&snapshot);
+ assert!(confirmed_supply <= expected_supply);
+ if snapshot.blocks.iter().all(|block| {
+ block.blinded_transactions.is_empty() && block.all_blinded_reveals().is_empty()
+ }) {
+ assert_eq!(confirmed_supply, expected_supply);
+ assert_reference_model_matches(&snapshot, &replayed);
+ }
}
fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount {
@@ -781,7 +787,8 @@ fn receive_chaotic_envelope(
let message = error.to_string();
assert!(
message.contains("expected block height")
- || message.contains("mine transaction anchor is not on this chain"),
+ || message.contains("mine transaction anchor is not on this chain")
+ || message.contains("blinded transaction expiry is too far in the future"),
"unexpected chaotic delivery error: {message}"
);
}
@@ -870,7 +877,9 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery()
.automatic_mine_once((round + 1) as u64);
if let Some(reason) = outcome.skipped_reason {
assert!(
- reason.contains("at least one burn") || reason.contains("could not"),
+ reason.contains("at least one burn")
+ || reason.contains("could not")
+ || reason.contains("automatic burn failed"),
"unexpected chaotic mining skip reason: {reason}"
);
}