commit faed2d5f0579bf1016c0f9c9c949ca87f321efc7
parent 6b8bdc1bc5accc636238c7609326eb53631780f7
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 2 Aug 2026 01:38:27 +0200
Add burn inclusion claims
Diffstat:
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());