iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit faed2d5f0579bf1016c0f9c9c949ca87f321efc7
parent 6b8bdc1bc5accc636238c7609326eb53631780f7
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun,  2 Aug 2026 01:38:27 +0200

Add burn inclusion claims

Diffstat:
Msrc/adapters/chain_store.rs | 1+
Msrc/adapters/http.rs | 18++++++++++++++++++
Msrc/adapters/p2p.rs | 2++
Msrc/app.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/domain.rs | 736++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtests/iuna.rs | 3++-
Mtests/properties.rs | 11++++++++---
7 files changed, 903 insertions(+), 51 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -341,6 +341,7 @@ fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow> .and_then(|amount| amount.checked_add(transaction.fee())) .context("block metric mine issuance overflow")?; } + Transaction::BurnClaim { .. } => {} } } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -243,6 +243,7 @@ impl WalletTransactionFilters { Transaction::Transfer { .. } => self.transfer, Transaction::Mine { .. } => self.mine, Transaction::Burn { .. } => self.burn, + Transaction::BurnClaim { .. } => self.burn, } } } @@ -1768,6 +1769,22 @@ fn ui_transaction( proof_bits: Some(proof_bits(signature)), proof_hash: Some(signature.clone()), }, + Transaction::BurnClaim { + burn, signature, .. + } => UiTransaction { + kind: "burn_claim", + from: transaction.sender().to_string(), + to: None, + amount: burn.amount(), + fee: 0, + inputs: Vec::new(), + outputs: Vec::new(), + change: Vec::new(), + signature: signature.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }, } } @@ -1868,6 +1885,7 @@ fn index_transaction_outputs( address: recipient.clone(), amount: *required_burn_amount, }], + Transaction::BurnClaim { .. } => Vec::new(), }; for (index, output) in created_outputs.iter().enumerate() { outputs.insert( diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1821,6 +1821,7 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip } GossipEnvelope::Transaction(_) | GossipEnvelope::Transactions { .. } + | GossipEnvelope::BurnSeen(_) | GossipEnvelope::Block(_) | GossipEnvelope::Blocks { .. } | GossipEnvelope::ChainSnapshot(_) => { @@ -1898,6 +1899,7 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { GossipEnvelope::Hello(_) | GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::Transaction(_) + | GossipEnvelope::BurnSeen(_) | GossipEnvelope::Block(_) | GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerVerificationChallenge { .. } diff --git a/src/app.rs b/src/app.rs @@ -13,11 +13,11 @@ use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use crate::domain::{ - Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, - DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, - StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, - hex_hash, run_vdf, + Amount, BURN_CLAIM_SEEN_WINDOW_BLOCKS, Block, BurnSeen, ChainSnapshot, ChainStatus, + DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, DEFAULT_TRANSACTION_FEE, + Ledger, MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, + StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, + VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -133,6 +133,7 @@ pub enum GossipEnvelope { Transactions { transactions: Vec<Transaction>, }, + BurnSeen(BurnSeen), Block(Block), Blocks { blocks: Vec<Block>, @@ -266,6 +267,7 @@ pub struct NodeCore { last_auto_pow_mine_anchor: Option<String>, last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, + burn_seen_pool: BTreeMap<String, BTreeMap<String, BurnSeen>>, outbox: Vec<GossipEnvelope>, } @@ -352,6 +354,7 @@ impl NodeCore { last_auto_pow_mine_anchor: None, last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, + burn_seen_pool: BTreeMap::new(), outbox: Vec::new(), } } @@ -793,11 +796,116 @@ impl NodeCore { pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; if outcome.added() { - self.outbox.push(GossipEnvelope::Transaction(tx)); + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + self.maybe_attest_burn(&tx)?; + self.try_submit_burn_claim(tx.signature())?; } Ok(outcome) } + pub fn receive_burn_seen(&mut self, seen: BurnSeen) -> Result<()> { + seen.verify_signature()?; + let burn_signature = seen.burn_signature.clone(); + if self.remember_burn_seen(seen.clone()) { + self.outbox.push(GossipEnvelope::BurnSeen(seen)); + } + self.try_submit_burn_claim(&burn_signature) + } + + fn maybe_attest_burn(&mut self, tx: &Transaction) -> Result<()> { + if !tx.is_burn() { + return Ok(()); + } + let Ok(wallet) = self.wallet.unlocked() else { + return Ok(()); + }; + let Some((seen_height, seen_block_hash)) = self.recent_finalizer_block_for_wallet() else { + return Ok(()); + }; + let seen = wallet.burn_seen(tx.signature(), seen_height, seen_block_hash); + if !self.remember_burn_seen(seen.clone()) { + return Ok(()); + } + self.outbox.push(GossipEnvelope::BurnSeen(seen)); + Ok(()) + } + + fn remember_burn_seen(&mut self, seen: BurnSeen) -> bool { + let entry = self + .burn_seen_pool + .entry(seen.burn_signature.clone()) + .or_default(); + let should_store = entry + .get(&seen.signer) + .is_none_or(|existing| seen.seen_height > existing.seen_height); + if should_store { + entry.insert(seen.signer.clone(), seen); + } + should_store + } + + fn attest_pending_burns(&mut self) -> Result<()> { + let burns = self + .ledger + .pending() + .iter() + .filter(|transaction| transaction.is_burn()) + .cloned() + .collect::<Vec<_>>(); + for burn in burns { + self.maybe_attest_burn(&burn)?; + self.try_submit_burn_claim(burn.signature())?; + } + Ok(()) + } + + fn recent_finalizer_block_for_wallet(&self) -> Option<(u64, String)> { + let address = self.wallet.address(); + let tip_height = self.ledger.height(); + self.ledger + .chain() + .iter() + .rev() + .find(|block| { + block.height > 0 + && block.miner == address + && block.height.saturating_add(BURN_CLAIM_SEEN_WINDOW_BLOCKS) > tip_height + }) + .map(|block| (block.height, block.hash.clone())) + } + + fn try_submit_burn_claim(&mut self, burn_signature: &str) -> Result<()> { + let Some(burn) = self.ledger.transaction_by_signature(burn_signature) else { + return Ok(()); + }; + if !burn.is_burn() { + return Ok(()); + } + let tip_height = self.ledger.height(); + let Some(seen_by_signer) = self.burn_seen_pool.get_mut(burn_signature) else { + return Ok(()); + }; + seen_by_signer.retain(|_, seen| { + seen.seen_height > 0 + && seen.seen_height <= tip_height + && seen + .seen_height + .saturating_add(BURN_CLAIM_SEEN_WINDOW_BLOCKS) + > tip_height + }); + if seen_by_signer.is_empty() { + return Ok(()); + } + let seen = seen_by_signer.values().cloned().collect::<Vec<_>>(); + let Ok(claim) = self.ledger.build_burn_claim(burn, seen) else { + return Ok(()); + }; + if self.ledger.submit_transaction(claim.clone())? { + self.outbox.push(GossipEnvelope::Transaction(claim)); + } + Ok(()) + } + fn build_burn_with_fee_rate( &self, amount: Amount, @@ -1210,6 +1318,7 @@ impl NodeCore { .mine_next_block(self.wallet.unlocked()?, timestamp_ms)?; self.ledger.apply_locally_mined_block(block.clone())?; self.outbox.push(GossipEnvelope::Block(block.clone())); + self.attest_pending_burns()?; Ok(block) } @@ -1221,6 +1330,7 @@ impl NodeCore { let block = work.finish(self.wallet.unlocked()?, vdf_output); self.ledger.apply_locally_mined_block(block.clone())?; self.outbox.push(GossipEnvelope::Block(block.clone())); + self.attest_pending_burns()?; Ok(block) } @@ -1244,11 +1354,13 @@ impl NodeCore { } Ok(()) } + GossipEnvelope::BurnSeen(seen) => self.receive_burn_seen(seen), GossipEnvelope::Block(block) => { let previous_height = self.ledger.height(); self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { self.outbox.push(GossipEnvelope::Block(block)); + self.attest_pending_burns()?; } Ok(()) } @@ -1264,6 +1376,7 @@ impl NodeCore { for block in imported { self.outbox.push(GossipEnvelope::Block(block)); } + self.attest_pending_burns()?; Ok(()) } GossipEnvelope::ChainSnapshot(snapshot) => self.import_chain_snapshot(snapshot), @@ -1280,6 +1393,7 @@ impl NodeCore { .apply_preverified_block_at(block.clone(), now_ms)?; if self.ledger.height() > previous_height { self.outbox.push(GossipEnvelope::Block(block)); + self.attest_pending_burns()?; } Ok(()) } @@ -1301,7 +1415,9 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.burn_seen_pool.clear(); self.enqueue_imported_blocks(previous_height); + self.attest_pending_burns()?; } Ok(()) } @@ -1322,7 +1438,9 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.burn_seen_pool.clear(); self.enqueue_imported_blocks(previous_height); + self.attest_pending_burns()?; Ok(true) } @@ -1923,7 +2041,7 @@ mod tests { MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, Wallet, }; - use super::{NodeConfig, NodeCore}; + use super::{GossipEnvelope, NodeConfig, NodeCore}; #[test] fn same_height_verified_import_does_not_reset_auto_burn_guard() { @@ -2200,6 +2318,41 @@ mod tests { let minimum_burn_fee = burn.economic_size_bytes() as u64 * 3; assert!(burn.fee() >= minimum_burn_fee); } + + #[test] + fn recent_finalizer_gossips_burn_seen_and_claim_for_received_burn() { + let finalizer = Wallet::from_seed("burn-seen-node-finalizer"); + let burner = Wallet::from_seed("burn-seen-node-burner"); + let mut allocations = BTreeMap::new(); + allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(burner.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 finalizer_burn = ledger.build_burn(&finalizer, 1, 0).unwrap(); + ledger.submit_transaction(finalizer_burn).unwrap(); + let block = ledger.mine_next_block(&finalizer, 1).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + let burn = ledger.build_burn(&burner, 1, 0).unwrap(); + let burn_signature = burn.signature().to_string(); + let mut node = NodeCore::from_ledger(finalizer, ledger, 0); + + node.receive_transaction(burn).unwrap(); + let outbox = node.drain_outbox(); + + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BurnSeen(seen) if seen.burn_signature == burn_signature + ))); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::Transaction(Transaction::BurnClaim { burn, .. }) + if burn.signature() == burn_signature + ))); + } } #[derive(Debug, Default)] @@ -2236,7 +2389,7 @@ impl InMemoryNetwork { for (from, envelope) in outbound { for (id, node) in &mut self.nodes { if *id != from { - node.receive(envelope.clone())?; + receive_in_memory_envelope(node, envelope.clone())?; } } } @@ -2265,3 +2418,17 @@ impl InMemoryNetwork { Ok(true) } } + +fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> { + let transaction_like = matches!( + envelope, + GossipEnvelope::Transaction(_) + | GossipEnvelope::Transactions { .. } + | GossipEnvelope::BurnSeen(_) + ); + match node.receive(envelope) { + Ok(()) => Ok(()), + Err(_) if transaction_like => Ok(()), + Err(error) => Err(error), + } +} diff --git a/src/domain.rs b/src/domain.rs @@ -23,6 +23,9 @@ pub const VDF_TARGET_BLOCK_MS: u64 = 10 * 60 * 1_000; pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; +pub const BURN_CLAIM_SEEN_QUORUM: usize = 3; +pub const BURN_CLAIM_SEEN_WINDOW_BLOCKS: u64 = 10; +pub const BURN_CLAIM_INCLUDE_WITHIN_BLOCKS: u64 = 3; const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; @@ -90,6 +93,29 @@ impl Wallet { signature, } } + + pub fn burn_seen( + &self, + burn_signature: impl Into<String>, + seen_height: u64, + seen_block_hash: impl Into<String>, + ) -> BurnSeen { + let burn_signature = burn_signature.into(); + let seen_block_hash = seen_block_hash.into(); + let payload = burn_seen_payload( + &burn_signature, + seen_height, + &seen_block_hash, + self.address(), + ); + BurnSeen { + burn_signature, + seen_height, + seen_block_hash, + signer: self.address.clone(), + signature: self.sign_payload(&payload), + } + } } #[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -141,6 +167,35 @@ pub enum Transaction { proof_header: Option<String>, signature: String, }, + BurnClaim { + burn: Box<Transaction>, + seen: Vec<BurnSeen>, + signature: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BurnSeen { + pub burn_signature: String, + pub seen_height: u64, + pub seen_block_hash: String, + pub signer: String, + pub signature: String, +} + +impl BurnSeen { + pub fn verify_signature(&self) -> Result<()> { + verify_burn_seen_signature(self) + } + + fn signing_payload(&self) -> String { + burn_seen_payload( + &self.burn_signature, + self.seen_height, + &self.seen_block_hash, + &self.signer, + ) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -156,6 +211,18 @@ impl Transaction { Self::genesis_burn_with_change(from, amount, Vec::new()) } + pub fn burn_claim(burn: Transaction, seen: Vec<BurnSeen>) -> Result<Self> { + if !burn.is_burn() { + bail!("burn claim must reference a burn transaction"); + } + let signature = burn_claim_signature(&burn, &seen); + Ok(Self::BurnClaim { + burn: Box::new(burn), + seen, + signature, + }) + } + fn genesis_burn_with_allocation( from: impl Into<String>, amount: Amount, @@ -206,6 +273,9 @@ impl Transaction { .map(|input| input.owner.as_str()) .unwrap_or(""), Self::Mine { recipient, .. } => recipient.as_str(), + Self::BurnClaim { seen, .. } => { + seen.first().map(|seen| seen.signer.as_str()).unwrap_or("") + } } } @@ -214,6 +284,7 @@ impl Transaction { Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), Self::Burn { .. } => None, Self::Mine { recipient, .. } => Some(recipient.as_str()), + Self::BurnClaim { .. } => None, } } @@ -227,6 +298,7 @@ impl Transaction { required_burn_amount, .. } => *required_burn_amount, + Self::BurnClaim { .. } => 0, } } @@ -234,11 +306,12 @@ impl Transaction { match self { Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, Self::Mine { .. } => MINE_FINALIZER_FEE, + Self::BurnClaim { .. } => 0, } } pub fn total_debit(&self) -> Result<Amount> { - if matches!(self, Self::Mine { .. }) { + if matches!(self, Self::Mine { .. } | Self::BurnClaim { .. }) { return Ok(0); } self.amount() @@ -250,6 +323,7 @@ impl Transaction { match self { Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature, Self::Mine { signature, .. } => signature, + Self::BurnClaim { signature, .. } => signature, } } @@ -260,7 +334,7 @@ impl Transaction { fn burn_amount(&self) -> Amount { match self { Self::Burn { amount, .. } => *amount, - Self::Transfer { .. } | Self::Mine { .. } => 0, + Self::Transfer { .. } | Self::Mine { .. } | Self::BurnClaim { .. } => 0, } } @@ -320,6 +394,7 @@ impl Transaction { *nonce, *difficulty_bits, ), + Self::BurnClaim { burn, seen, .. } => burn_claim_payload(burn, seen), } } @@ -367,6 +442,22 @@ impl Transaction { } return Ok(()); } + if let Self::BurnClaim { + burn, + seen, + signature, + } = self + { + burn.verify_signature()?; + for seen in seen { + verify_burn_seen_signature(seen)?; + } + let expected = burn_claim_signature(burn, seen); + if *signature != expected { + bail!("burn claim signature is invalid"); + } + return Ok(()); + } if self.signature().starts_with("iuna-genesis-burn:") || self.inputs_are_genesis_signed() { return Ok(()); } @@ -393,7 +484,7 @@ impl Transaction { fn inputs(&self) -> &[TxInput] { match self { Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, - Self::Mine { .. } => &[], + Self::Mine { .. } | Self::BurnClaim { .. } => &[], } } @@ -409,6 +500,7 @@ impl Transaction { address: recipient.clone(), amount: *required_burn_amount, }], + Self::BurnClaim { .. } => Vec::new(), } } @@ -576,6 +668,49 @@ fn mine_signature( )) } +fn burn_seen_payload( + burn_signature: &str, + seen_height: u64, + seen_block_hash: &str, + signer: &str, +) -> String { + format!("iuna-burn-seen:{burn_signature}:{seen_height}:{seen_block_hash}:{signer}") +} + +fn canonical_burn_seen(seen: &BurnSeen) -> String { + format!( + "{}:{}:{}:{}:{}", + seen.burn_signature, seen.seen_height, seen.seen_block_hash, seen.signer, seen.signature + ) +} + +fn burn_claim_payload(burn: &Transaction, seen: &[BurnSeen]) -> String { + let mut seen = seen.iter().map(canonical_burn_seen).collect::<Vec<_>>(); + seen.sort_unstable(); + format!("iuna-burn-claim:{}:{}", burn.canonical(), seen.join("|")) +} + +fn burn_claim_signature(burn: &Transaction, seen: &[BurnSeen]) -> String { + hex_hash(burn_claim_payload(burn, seen)) +} + +fn verify_burn_seen_signature(seen: &BurnSeen) -> Result<()> { + validate_protocol_id(&seen.burn_signature, "burn seen transaction signature")?; + validate_hash(&seen.seen_block_hash, "burn seen block hash")?; + validate_address(&seen.signer, "burn seen signer")?; + validate_signature(&seen.signature, "burn seen signature")?; + let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&seen.signer) + .with_context(|| format!("invalid burn seen signer {}", seen.signer))?; + let signature = decode_hex_array::<SIGNATURE_BYTES>(&seen.signature) + .context("invalid burn seen signature")?; + let verifying_key = + VerifyingKey::from_bytes(&public_key).context("invalid burn seen public key")?; + let signature = Signature::from_bytes(&signature); + verifying_key + .verify(seen.signing_payload().as_bytes(), &signature) + .context("burn seen signature is invalid") +} + pub const STRATUM_EXTRANONCE1_HEX: &str = "00000000"; pub const STRATUM_EXTRANONCE2_SIZE: usize = 4; const STRATUM_MINE_VERSION: [u8; 4] = [1, 0, 0, 0]; @@ -994,6 +1129,15 @@ struct BurnTicket { eligible_until_height: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct BurnClaimState { + burn: Transaction, + claim_signature: String, + claim_height: u64, + due_height: u64, + expires_at_height: u64, +} + #[derive(Clone, Debug)] pub struct PreparedBlock { height: u64, @@ -1236,6 +1380,7 @@ pub struct Ledger { genesis_allocations: BTreeMap<String, Amount>, utxos: BTreeMap<OutPoint, TxOutput>, tickets: Vec<BurnTicket>, + burn_claims: Vec<BurnClaimState>, pending: Vec<Transaction>, orphans: Vec<Transaction>, mine_reward: Amount, @@ -1283,6 +1428,7 @@ impl Ledger { genesis_allocations: genesis_allocations.clone(), utxos, tickets, + burn_claims: Vec::new(), pending: Vec::new(), orphans: Vec::new(), mine_reward: MINE_REWARD, @@ -1335,6 +1481,7 @@ impl Ledger { genesis_allocations, utxos, tickets: Vec::new(), + burn_claims: Vec::new(), pending: Vec::new(), orphans: Vec::new(), mine_reward: MINE_REWARD, @@ -1816,6 +1963,12 @@ impl Ledger { Ok(transaction) } + pub fn build_burn_claim(&self, burn: Transaction, seen: Vec<BurnSeen>) -> Result<Transaction> { + let transaction = Transaction::burn_claim(burn, seen)?; + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } + pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { self.build_mine_with_required_burn(recipient, self.recommended_mine_required_burn_amount()) } @@ -2155,6 +2308,7 @@ impl Ledger { .collect::<BTreeSet<_>>(); self.utxos = utxos; self.tickets = tickets; + self.update_burn_claims_after_block(&block)?; self.chain.push(block); let available = self.utxos.clone(); let pending = std::mem::take(&mut self.pending); @@ -2233,6 +2387,7 @@ impl Ledger { } ensure_block_has_burn(&block.transactions)?; ensure_mine_actions_have_required_burns(&block.transactions)?; + self.ensure_due_burn_claims_are_included(block.height, &block.transactions)?; match block.finalizer_mode { FinalizerMode::Ticket => { let selected_ticket = self @@ -2362,21 +2517,43 @@ impl Ledger { let mut selected = Vec::new(); let mut selected_burn_amount = 0_u64; - let first_burn_index = if let Some(owner) = required_burn_owner { - best_selectable_burn_from_index(&remaining, &utxos, owner) - } else { - best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) - }; - if let Some(index) = first_burn_index { - let tx = remaining.remove(index); + for tx in self.due_required_claimed_burns(self.tip().height + 1)? { + remaining.retain(|pending| pending.signature() != tx.signature()); let mut candidate = selected.clone(); candidate.push(tx.clone()); - if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { - apply_transaction(&tx, &mut utxos)?; - selected_burn_amount = selected_burn_amount - .checked_add(tx.burn_amount()) - .context("selected block burns overflow")?; - selected.push(tx); + if estimated_block_size_bytes(&candidate)? > self.launch_profile.max_block_bytes { + bail!("due claimed burns exceed max block size"); + } + apply_transaction(&tx, &mut utxos)?; + selected_burn_amount = selected_burn_amount + .checked_add(tx.burn_amount()) + .context("selected block burns overflow")?; + 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 + .iter() + .any(|transaction| transaction.is_burn() && transaction.sender() == owner) + }); + if needs_first_burn || needs_owner_burn { + let first_burn_index = if let Some(owner) = required_burn_owner { + best_selectable_burn_from_index(&remaining, &utxos, owner) + } else { + best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) + }; + if let Some(index) = first_burn_index { + let tx = remaining.remove(index); + let mut candidate = selected.clone(); + candidate.push(tx.clone()); + if estimated_block_size_bytes(&candidate)? <= self.launch_profile.max_block_bytes { + apply_transaction(&tx, &mut utxos)?; + selected_burn_amount = selected_burn_amount + .checked_add(tx.burn_amount()) + .context("selected block burns overflow")?; + selected.push(tx); + } } } @@ -2403,6 +2580,56 @@ impl Ledger { Ok(selected) } + fn ensure_due_burn_claims_are_included( + &self, + block_height: u64, + transactions: &[Transaction], + ) -> Result<()> { + let included_burns = transactions + .iter() + .filter(|transaction| transaction.is_burn()) + .map(|transaction| transaction.signature().to_string()) + .collect::<BTreeSet<_>>(); + for burn in self.due_required_claimed_burns(block_height)? { + if !included_burns.contains(burn.signature()) { + bail!( + "block omits due claimed burn transaction {}", + burn.signature() + ); + } + } + Ok(()) + } + + fn due_required_claimed_burns(&self, block_height: u64) -> Result<Vec<Transaction>> { + let mut claims = self + .burn_claims + .iter() + .filter(|claim| claim.due_height <= block_height) + .collect::<Vec<_>>(); + claims.sort_by(|left, right| { + left.due_height + .cmp(&right.due_height) + .then_with(|| left.claim_height.cmp(&right.claim_height)) + .then_with(|| left.claim_signature.cmp(&right.claim_signature)) + }); + + let mut utxos = self.utxos.clone(); + let mut required = Vec::new(); + let mut seen_burns = BTreeSet::new(); + for claim in claims { + if !seen_burns.insert(claim.burn.signature().to_string()) { + continue; + } + let mut candidate = utxos.clone(); + if apply_transaction(&claim.burn, &mut candidate).is_ok() { + utxos = candidate; + required.push(claim.burn.clone()); + } + } + Ok(required) + } + fn select_inputs( &self, address: &str, @@ -2554,6 +2781,125 @@ impl Ledger { bail!("mine transaction difficulty is invalid"); } } + Transaction::BurnClaim { + burn, + seen, + signature, + } => { + validate_hash(signature, "burn claim signature")?; + self.validate_burn_claim(burn, seen)?; + } + } + Ok(()) + } + + fn validate_burn_claim(&self, burn: &Transaction, seen: &[BurnSeen]) -> Result<()> { + if !burn.is_burn() { + bail!("burn claim must reference a burn transaction"); + } + burn.verify_signature()?; + self.validate_transaction_terms(burn)?; + let mut burn_utxos = self.utxos.clone(); + apply_transaction(burn, &mut burn_utxos)?; + if self + .chain + .iter() + .flat_map(|block| block.transactions.iter()) + .any(|transaction| transaction.signature() == burn.signature()) + { + bail!("burn claim references an already confirmed burn"); + } + if burn.serialized_size_bytes()? > self.launch_profile.max_block_bytes { + bail!("burn claim references a burn larger than max block size"); + } + + let recent_finalizers = self.recent_finalizers_for_burn_claim(); + let required_quorum = BURN_CLAIM_SEEN_QUORUM.min(recent_finalizers.len()).max(1); + let mut unique_seen_signers = BTreeSet::new(); + for attestation in seen { + if attestation.burn_signature != burn.signature() { + bail!("burn seen attestation references a different burn"); + } + let Some(block) = recent_finalizers.get(&attestation.signer) else { + bail!("burn seen signer is not a recent finalizer"); + }; + if attestation.seen_height != block.height || attestation.seen_block_hash != block.hash + { + bail!("burn seen attestation does not match recent finalizer block"); + } + if !unique_seen_signers.insert(attestation.signer.as_str()) { + bail!("burn claim contains duplicate finalizer attestation"); + } + } + if unique_seen_signers.len() < required_quorum { + bail!( + "burn claim has {} finalizer attestations but requires {}", + unique_seen_signers.len(), + required_quorum + ); + } + Ok(()) + } + + fn recent_finalizers_for_burn_claim(&self) -> BTreeMap<String, Block> { + let min_height = self + .tip() + .height + .saturating_sub(BURN_CLAIM_SEEN_WINDOW_BLOCKS) + .saturating_add(1); + let mut finalizers = BTreeMap::new(); + for block in self.chain.iter().rev() { + if block.height == 0 || block.height < min_height { + break; + } + finalizers + .entry(block.miner.clone()) + .or_insert_with(|| block.clone()); + } + finalizers + } + + fn update_burn_claims_after_block(&mut self, block: &Block) -> Result<()> { + let included_burns = block + .transactions + .iter() + .filter(|transaction| transaction.is_burn()) + .map(|transaction| transaction.signature().to_string()) + .collect::<BTreeSet<_>>(); + self.burn_claims.retain(|claim| { + !included_burns.contains(claim.burn.signature()) + && claim.expires_at_height > block.height + && claim.due_height > block.height + }); + + let mut active_burns = self + .burn_claims + .iter() + .map(|claim| claim.burn.signature().to_string()) + .collect::<BTreeSet<_>>(); + for transaction in &block.transactions { + let Transaction::BurnClaim { + burn, signature, .. + } = transaction + else { + continue; + }; + if included_burns.contains(burn.signature()) || active_burns.contains(burn.signature()) + { + continue; + } + active_burns.insert(burn.signature().to_string()); + let due_height = block + .height + .checked_add(BURN_CLAIM_INCLUDE_WITHIN_BLOCKS) + .context("burn claim due height overflows")?; + self.burn_claims.push(BurnClaimState { + burn: burn.as_ref().clone(), + claim_signature: signature.clone(), + claim_height: block.height, + due_height, + expires_at_height: due_height, + }); } Ok(()) } @@ -3201,9 +3547,27 @@ fn canonical_transaction_size_bytes(transaction: &Transaction) -> usize { .unwrap_or(0) + hash_size_bytes(signature) } + Transaction::BurnClaim { + burn, + seen, + signature, + } => { + 1 + canonical_transaction_size_bytes(burn) + + compact_len(seen.len() as u128) + + seen.iter().map(burn_seen_size_bytes).sum::<usize>() + + hash_size_bytes(signature) + } } } +fn burn_seen_size_bytes(seen: &BurnSeen) -> usize { + hash_size_bytes(&seen.burn_signature) + + compact_len(u128::from(seen.seen_height)) + + hash_size_bytes(&seen.seen_block_hash) + + address_size_bytes(&seen.signer) + + signature_size_bytes(&seen.signature) +} + fn compact_inputs_size_bytes(inputs: &[TxInput]) -> usize { inputs .iter() @@ -3343,25 +3707,28 @@ fn apply_transaction( utxos: &mut BTreeMap<OutPoint, TxOutput>, ) -> Result<()> { transaction.verify_signature()?; - if let Transaction::Mine { - recipient, - required_burn_amount, - .. - } = transaction - { - let output = TxOutput { - address: recipient.clone(), - amount: *required_burn_amount, - }; - ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; - utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: 0, - }, - output, - ); - return Ok(()); + match transaction { + Transaction::Mine { + recipient, + required_burn_amount, + .. + } => { + let output = TxOutput { + address: recipient.clone(), + amount: *required_burn_amount, + }; + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: 0, + }, + output, + ); + return Ok(()); + } + Transaction::BurnClaim { .. } => return Ok(()), + Transaction::Transfer { .. } | Transaction::Burn { .. } => {} } ensure_single_input_owner(transaction)?; let input_total = spend_inputs(transaction, utxos)?; @@ -3376,7 +3743,9 @@ fn apply_transaction( .context("transaction outputs plus fee overflow")? .checked_add(match transaction { Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + Transaction::Transfer { .. } + | Transaction::Mine { .. } + | Transaction::BurnClaim { .. } => 0, }) .context("transaction outputs plus burn overflow")?; if input_total != required { @@ -3435,7 +3804,10 @@ fn transaction_has_missing_inputs( } fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { - if matches!(transaction, Transaction::Mine { .. }) { + if matches!( + transaction, + Transaction::Mine { .. } | Transaction::BurnClaim { .. } + ) { return Ok(()); } let Some(first) = transaction.inputs().first() else { @@ -3525,7 +3897,9 @@ fn utxos_after_genesis( validate_genesis_burn_transaction(transaction)?; apply_transaction(transaction, &mut utxos)?; } - Transaction::Transfer { .. } | Transaction::Mine { .. } => { + Transaction::Transfer { .. } + | Transaction::Mine { .. } + | Transaction::BurnClaim { .. } => { bail!("genesis only supports burn transactions") } } @@ -3611,7 +3985,9 @@ fn genesis_miner( .iter() .filter_map(|transaction| match transaction { Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()), - Transaction::Transfer { .. } | Transaction::Mine { .. } => None, + Transaction::Transfer { .. } + | Transaction::Mine { .. } + | Transaction::BurnClaim { .. } => None, }) .find(|from| genesis_allocations.contains_key(*from)) .or_else(|| genesis_allocations.keys().next().map(String::as_str)) @@ -3943,6 +4319,113 @@ mod tests { panic!("expected to find mine proof"); } + fn ledger_with_finalizers_and_burner(finalizers: &[Wallet], burner: &Wallet) -> Ledger { + let mut allocations = BTreeMap::new(); + for wallet in finalizers { + allocations.insert(wallet.address().to_string(), 100 * MICRO_IUNA); + } + allocations.insert(burner.address().to_string(), 100 * MICRO_IUNA); + let genesis_burns = finalizers + .iter() + .map(|wallet| GenesisBurn { + from: wallet.address().to_string(), + amount: MICRO_IUNA, + }) + .collect::<Vec<_>>(); + Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() + } + + fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { + wallets + .iter() + .find(|wallet| wallet.address() == address) + .unwrap_or_else(|| panic!("missing wallet for address {address}")) + } + + fn recent_unique_finalizer_count(ledger: &Ledger) -> usize { + ledger.recent_finalizers_for_burn_claim().len() + } + + fn mine_next_preverified_burn_block(ledger: &mut Ledger, wallets: &[Wallet]) -> Block { + let recent_finalizers = ledger.recent_finalizers_for_burn_claim(); + let wallet = wallets + .iter() + .find(|wallet| { + !recent_finalizers.contains_key(wallet.address()) + && ledger + .finalizer_rank_for_next_block(wallet.address()) + .is_some() + }) + .or_else(|| { + wallets.iter().find(|wallet| { + ledger + .finalizer_rank_for_next_block(wallet.address()) + .is_some() + }) + }) + .expect("expected an eligible finalizer wallet"); + let burn = ledger + .build_burn(wallet, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(burn).unwrap(); + let prepared = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + ledger + .apply_preverified_block_at(block.clone(), u64::MAX) + .unwrap(); + block + } + + fn mine_until_recent_finalizers( + ledger: &mut Ledger, + wallets: &[Wallet], + required_unique: usize, + ) { + for _ in 0..30 { + if recent_unique_finalizer_count(ledger) >= required_unique { + return; + } + mine_next_preverified_burn_block(ledger, wallets); + } + panic!("could not mine {required_unique} recent unique finalizers"); + } + + fn recent_burn_seen_attestations( + ledger: &Ledger, + wallets: &[Wallet], + burn: &Transaction, + count: usize, + ) -> Vec<BurnSeen> { + let mut seen = Vec::new(); + let mut signers = BTreeSet::new(); + for block in ledger.chain().iter().rev().filter(|block| block.height > 0) { + if !signers.insert(block.miner.clone()) { + continue; + } + let wallet = wallet_for_address(wallets, &block.miner); + seen.push(wallet.burn_seen(burn.signature(), block.height, block.hash.clone())); + if seen.len() == count { + break; + } + } + assert_eq!(seen.len(), count); + seen + } + + fn confirm_burn_claim( + ledger: &mut Ledger, + wallets: &[Wallet], + burn: Transaction, + ) -> Transaction { + let seen = recent_burn_seen_attestations(ledger, wallets, &burn, BURN_CLAIM_SEEN_QUORUM); + let claim = ledger.build_burn_claim(burn, seen).unwrap(); + ledger.submit_transaction(claim.clone()).unwrap(); + mine_next_preverified_burn_block(ledger, wallets); + claim + } + fn transfer_with_extra_zero_outputs( ledger: &Ledger, wallet: &Wallet, @@ -4616,6 +5099,181 @@ mod tests { } #[test] + fn burn_claim_requires_recent_finalizer_quorum() { + let finalizers = (0..5) + .map(|index| Wallet::from_seed(&format!("burn-claim-quorum-finalizer-{index}"))) + .collect::<Vec<_>>(); + let burner = Wallet::from_seed("burn-claim-quorum-burner"); + let mut ledger = ledger_with_finalizers_and_burner(&finalizers, &burner); + mine_until_recent_finalizers(&mut ledger, &finalizers, BURN_CLAIM_SEEN_QUORUM); + assert!(recent_unique_finalizer_count(&ledger) >= BURN_CLAIM_SEEN_QUORUM); + + let burn = ledger + .build_burn(&burner, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + let seen = + recent_burn_seen_attestations(&ledger, &finalizers, &burn, BURN_CLAIM_SEEN_QUORUM - 1); + + let error = ledger.build_burn_claim(burn, seen).unwrap_err(); + + assert!(format!("{error:#}").contains("finalizer attestations")); + } + + #[test] + fn burn_claim_rejects_invalid_seen_signature() { + let finalizers = (0..5) + .map(|index| Wallet::from_seed(&format!("burn-claim-signature-finalizer-{index}"))) + .collect::<Vec<_>>(); + let burner = Wallet::from_seed("burn-claim-signature-burner"); + let mut ledger = ledger_with_finalizers_and_burner(&finalizers, &burner); + mine_until_recent_finalizers(&mut ledger, &finalizers, BURN_CLAIM_SEEN_QUORUM); + + let burn = ledger + .build_burn(&burner, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + let mut seen = + recent_burn_seen_attestations(&ledger, &finalizers, &burn, BURN_CLAIM_SEEN_QUORUM); + seen[0].signature = "00".repeat(SIGNATURE_BYTES); + let claim = Transaction::burn_claim(burn, seen).unwrap(); + + let error = ledger.submit_transaction(claim).unwrap_err(); + + assert!(format!("{error:#}").contains("burn seen signature")); + } + + #[test] + fn due_burn_claim_makes_block_omitting_burn_invalid() { + let finalizers = (0..5) + .map(|index| Wallet::from_seed(&format!("burn-claim-due-finalizer-{index}"))) + .collect::<Vec<_>>(); + let burner = Wallet::from_seed("burn-claim-due-burner"); + let mut ledger = ledger_with_finalizers_and_burner(&finalizers, &burner); + mine_until_recent_finalizers(&mut ledger, &finalizers, BURN_CLAIM_SEEN_QUORUM); + let burn = ledger + .build_burn(&burner, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + let burn_signature = burn.signature().to_string(); + + confirm_burn_claim(&mut ledger, &finalizers, burn); + let claim_height = ledger.height(); + while ledger.height() + 1 < claim_height + BURN_CLAIM_INCLUDE_WITHIN_BLOCKS { + mine_next_preverified_burn_block(&mut ledger, &finalizers); + } + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let filler_burn = ledger + .build_burn(wallet, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(filler_burn.clone()).unwrap(); + let mut prepared = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + assert!( + prepared + .transactions + .iter() + .any(|transaction| transaction.signature() == burn_signature) + ); + prepared + .transactions + .retain(|transaction| transaction.signature() != burn_signature); + assert!( + prepared + .transactions + .iter() + .any(|transaction| transaction.signature() == filler_burn.signature()) + ); + prepared.reward = fee_reward(&prepared.transactions).unwrap(); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("omits due claimed burn")); + } + + #[test] + fn block_selection_includes_due_claimed_burn() { + let finalizers = (0..5) + .map(|index| Wallet::from_seed(&format!("burn-claim-select-finalizer-{index}"))) + .collect::<Vec<_>>(); + let burner = Wallet::from_seed("burn-claim-select-burner"); + let mut ledger = ledger_with_finalizers_and_burner(&finalizers, &burner); + mine_until_recent_finalizers(&mut ledger, &finalizers, BURN_CLAIM_SEEN_QUORUM); + let burn = ledger + .build_burn(&burner, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + let burn_signature = burn.signature().to_string(); + + confirm_burn_claim(&mut ledger, &finalizers, burn); + let claim_height = ledger.height(); + while ledger.height() + 1 < claim_height + BURN_CLAIM_INCLUDE_WITHIN_BLOCKS { + mine_next_preverified_burn_block(&mut ledger, &finalizers); + } + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let prepared = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + + assert!( + prepared + .transactions + .iter() + .any(|transaction| transaction.signature() == burn_signature) + ); + } + + #[test] + fn claimed_burn_is_not_required_after_it_becomes_invalid() { + let finalizers = (0..5) + .map(|index| Wallet::from_seed(&format!("burn-claim-invalid-finalizer-{index}"))) + .collect::<Vec<_>>(); + let burner = Wallet::from_seed("burn-claim-invalid-burner"); + let recipient = Wallet::from_seed("burn-claim-invalid-recipient"); + let mut ledger = ledger_with_finalizers_and_burner(&finalizers, &burner); + mine_until_recent_finalizers(&mut ledger, &finalizers, BURN_CLAIM_SEEN_QUORUM); + let burn = ledger + .build_burn(&burner, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + let burn_signature = burn.signature().to_string(); + let burn_input = burn.inputs().first().unwrap().outpoint.clone(); + + confirm_burn_claim(&mut ledger, &finalizers, burn); + let claim_height = ledger.height(); + let spend = ledger + .build_transfer_with_inputs(&burner, recipient.address(), 1, 0, &[burn_input]) + .unwrap(); + ledger.submit_transaction(spend).unwrap(); + mine_next_preverified_burn_block(&mut ledger, &finalizers); + while ledger.height() + 1 < claim_height + BURN_CLAIM_INCLUDE_WITHIN_BLOCKS { + mine_next_preverified_burn_block(&mut ledger, &finalizers); + } + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let filler_burn = ledger + .build_burn(wallet, MIN_MINE_REQUIRED_BURN, 0) + .unwrap(); + ledger.submit_transaction(filler_burn).unwrap(); + let block = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap() + .finish(wallet, "preverified-vdf".to_string()); + + assert!( + !block + .transactions + .iter() + .any(|transaction| transaction.signature() == burn_signature) + ); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + } + + #[test] fn block_with_mine_action_requires_enough_burn_amount() { let alice = Wallet::from_seed("mine-required-burn-block-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -2377,7 +2377,8 @@ fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() { match transaction { iuna::domain::Transaction::Burn { signature, .. } | iuna::domain::Transaction::Transfer { signature, .. } - | iuna::domain::Transaction::Mine { signature, .. } => signature.push_str("00"), + | iuna::domain::Transaction::Mine { signature, .. } + | iuna::domain::Transaction::BurnClaim { signature, .. } => signature.push_str("00"), } } diff --git a/tests/properties.rs b/tests/properties.rs @@ -174,6 +174,7 @@ fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount { .checked_add(*required_burn_amount) .expect("mine output does not overflow supply"); } + Transaction::BurnClaim { .. } => {} } } supply = supply @@ -302,7 +303,9 @@ fn apply_reference_transaction( }); let burn_amount = match transaction { Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + Transaction::Transfer { .. } | Transaction::Mine { .. } | Transaction::BurnClaim { .. } => { + 0 + } }; let required = output_total .checked_add(transaction.fee()) @@ -334,7 +337,7 @@ fn insert_reference_outputs( fn reference_inputs(transaction: &Transaction) -> &[TxInput] { match transaction { Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, - Transaction::Mine { .. } => &[], + Transaction::Mine { .. } | Transaction::BurnClaim { .. } => &[], } } @@ -350,6 +353,7 @@ fn reference_outputs(transaction: &Transaction) -> Vec<TxOutput> { address: recipient.clone(), amount: *required_burn_amount, }], + Transaction::BurnClaim { .. } => Vec::new(), } } @@ -878,7 +882,8 @@ fn generated_snapshot_tampering_is_rejected() { match transaction { Transaction::Transfer { signature, .. } | Transaction::Burn { signature, .. } - | Transaction::Mine { signature, .. } => signature.push_str("00"), + | Transaction::Mine { signature, .. } + | Transaction::BurnClaim { signature, .. } => signature.push_str("00"), } } assert!(Ledger::from_snapshot(mutated_transaction).is_err());