iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

ledger_ops.rs (34446B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Context, Result, bail};
      4 
      5 use crate::compact::{
      6     CompactBlockContext, CompactBlockSizeBreakdown, compact_snapshot_fixed_prefix_size,
      7     compact_varint_size,
      8 };
      9 
     10 use super::hex::hex_hash;
     11 use super::reveal::{BurnBundleSection, canonical_burn_bundle_hashes};
     12 use super::selection::{TransactionKind, fee_rate_key};
     13 use super::ticket::ticket_is_eligible_for_height;
     14 use super::transaction::Transaction;
     15 use super::vdf::vdf_solution_placeholder;
     16 use super::{
     17     Amount, BURN_COMMITTEE_SIZE, Block, BlockSelection, BurnCommitteeMember, BurnTicket,
     18     FinalizerMode, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, LeaderProof, LeaderProofPayload, Ledger,
     19     MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, SIGNATURE_BYTES, TxInput,
     20     TxOutput, decode_hex_array, validate_address, validate_hash, validate_protocol_id,
     21     validate_signature,
     22 };
     23 
     24 pub(super) fn compact_block_context(ledger: &Ledger) -> &CompactBlockContext {
     25     &ledger.compact_block_context
     26 }
     27 
     28 impl Ledger {
     29     pub(crate) fn consensus_block_size_bytes(&self, block: &Block) -> Result<usize> {
     30         self.compact_block_context.block_size_bytes(block)
     31     }
     32 
     33     pub(crate) fn storage_size_breakdowns(
     34         &self,
     35         requested_blocks: &[Block],
     36     ) -> BTreeMap<String, CompactBlockSizeBreakdown> {
     37         requested_blocks
     38             .iter()
     39             .filter_map(|block| {
     40                 self.compact_block_context
     41                     .stored_block_size_breakdown(&block.hash)
     42                     .cloned()
     43                     .or_else(|| self.compact_block_context.block_size_breakdown(block).ok())
     44                     .map(|breakdown| (block.hash.clone(), breakdown))
     45             })
     46             .collect()
     47     }
     48 
     49     pub(crate) fn chain_storage_bytes_by_hash(&self) -> Result<BTreeMap<String, u64>> {
     50         let mut cumulative_block_bytes = 0_u64;
     51         let mut sizes = BTreeMap::new();
     52         let fixed_prefix_bytes = compact_snapshot_fixed_prefix_size(
     53             &self.genesis_allocations,
     54             self.initial_vdf_rounds,
     55             &self.launch_profile,
     56         )?;
     57         for (index, block) in self.chain.iter().enumerate() {
     58             let block_bytes = self
     59                 .compact_block_context
     60                 .stored_block_size_breakdown(&block.hash)
     61                 .with_context(|| {
     62                     format!(
     63                         "missing compact storage size for block {} at height {}",
     64                         block.hash, block.height
     65                     )
     66                 })?
     67                 .total_bytes;
     68             cumulative_block_bytes = cumulative_block_bytes
     69                 .checked_add(u64::try_from(block_bytes).context("block storage size overflows")?)
     70                 .context("cumulative block storage size overflows")?;
     71             let prefix_bytes = fixed_prefix_bytes
     72                 .checked_add(compact_varint_size(index + 1))
     73                 .context("snapshot prefix size overflows")?;
     74             let chain_bytes = cumulative_block_bytes
     75                 .checked_add(u64::try_from(prefix_bytes).context("snapshot prefix size overflows")?)
     76                 .context("chain storage size overflows")?;
     77             sizes.insert(block.hash.clone(), chain_bytes);
     78         }
     79         Ok(sizes)
     80     }
     81 }
     82 
     83 pub(super) fn validate_genesis_allocations(
     84     genesis_allocations: &BTreeMap<String, Amount>,
     85 ) -> Result<()> {
     86     for address in genesis_allocations.keys() {
     87         validate_address(address, "genesis allocation")?;
     88     }
     89     Ok(())
     90 }
     91 
     92 pub(super) fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> {
     93     for input in inputs {
     94         validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?;
     95         validate_address(&input.owner, "input owner")?;
     96         validate_signature(&input.signature, "input signature")?;
     97     }
     98     Ok(())
     99 }
    100 
    101 pub(super) fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> {
    102     for output in outputs {
    103         validate_address(&output.address, "output recipient")?;
    104     }
    105     Ok(())
    106 }
    107 
    108 pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> {
    109     let Transaction::Burn {
    110         inputs,
    111         change,
    112         fee,
    113         signature,
    114         ..
    115     } = transaction
    116     else {
    117         bail!("genesis only supports burn transactions");
    118     };
    119     if *fee != 0 {
    120         bail!("genesis burn fee must be zero");
    121     }
    122     validate_hash(signature, "genesis burn signature")?;
    123     validate_transaction_outputs(change)?;
    124     for input in inputs {
    125         validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?;
    126         validate_address(&input.owner, "genesis burn input owner")?;
    127         if input.signature != "genesis" {
    128             bail!("genesis burn input signature is invalid");
    129         }
    130     }
    131     Ok(())
    132 }
    133 
    134 pub(super) fn estimated_block_selection_size_bytes(
    135     context: &CompactBlockContext,
    136     selection: &BlockSelection,
    137     finalizer_mode: FinalizerMode,
    138     burn_bundle_section: &BurnBundleSection,
    139 ) -> Result<usize> {
    140     let has_leader_proof = finalizer_mode == FinalizerMode::Ticket;
    141     let block = Block {
    142         height: u64::MAX,
    143         prev_hash: "f".repeat(64),
    144         timestamp_ms: u64::MAX,
    145         miner: "f".repeat(64),
    146         reward_address: None,
    147         reward_address_signature: None,
    148         finalizer_mode,
    149         finalizer_rank: 0,
    150         reward: u64::MAX,
    151         vdf_rounds: u64::MAX,
    152         vdf_output: vdf_solution_placeholder(),
    153         leader_proof: has_leader_proof.then(|| LeaderProof {
    154             ticket_id: "f".repeat(64),
    155             public_key: "f".repeat(64),
    156             signature: "f".repeat(128),
    157         }),
    158         burn_bundle_section: burn_bundle_section.clone(),
    159         transactions: selection.transactions.clone(),
    160         transactions_v2: selection.transactions_v2.clone(),
    161         hash: "f".repeat(64),
    162     };
    163     context.block_size_bytes(&block)
    164 }
    165 
    166 pub(super) fn ensure_transaction_fits_empty_block(
    167     context: &CompactBlockContext,
    168     transaction: &Transaction,
    169     max_block_bytes: usize,
    170 ) -> Result<()> {
    171     let selection = BlockSelection {
    172         transactions: vec![transaction.clone()],
    173         transactions_v2: Vec::new(),
    174     };
    175     if estimated_block_selection_size_bytes(
    176         context,
    177         &selection,
    178         FinalizerMode::Ticket,
    179         &BurnBundleSection::default(),
    180     )? > max_block_bytes
    181     {
    182         bail!("transaction exceeds max block size");
    183     }
    184     Ok(())
    185 }
    186 
    187 pub(super) fn ensure_transaction_v2_fits_empty_block(
    188     context: &CompactBlockContext,
    189     envelope: &str,
    190     max_block_bytes: usize,
    191 ) -> Result<()> {
    192     let selection = BlockSelection {
    193         transactions: Vec::new(),
    194         transactions_v2: vec![envelope.to_string()],
    195     };
    196     if estimated_block_selection_size_bytes(
    197         context,
    198         &selection,
    199         FinalizerMode::Ticket,
    200         &BurnBundleSection::default(),
    201     )? > max_block_bytes
    202     {
    203         bail!("transaction v2 exceeds max block size");
    204     }
    205     Ok(())
    206 }
    207 
    208 pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
    209     let Some(proof) = &block.leader_proof else {
    210         bail!("block is missing leader proof");
    211     };
    212     if proof.public_key != block.miner {
    213         bail!("leader proof public key does not match block finalizer");
    214     }
    215     let ticket = tickets
    216         .iter()
    217         .find(|ticket| {
    218             ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height)
    219         })
    220         .context("leader ticket is not pending for this height")?;
    221     if ticket.owner != block.miner {
    222         bail!("leader ticket owner does not match block finalizer");
    223     }
    224     if ticket.eligible_from_height > block.height {
    225         bail!("leader ticket is not mature");
    226     }
    227 
    228     let payload = LeaderProofPayload {
    229         height: block.height,
    230         prev_hash: block.prev_hash.clone(),
    231         finalizer_rank: block.finalizer_rank,
    232         vdf_output: block.vdf_output.clone(),
    233         ticket_id: ticket.id.clone(),
    234         ticket_amount: ticket.amount,
    235         ticket_owner: ticket.owner.clone(),
    236     };
    237     verify_leader_signature(proof, &payload)?;
    238     Ok(())
    239 }
    240 
    241 pub(super) fn verify_leader_signature(
    242     proof: &LeaderProof,
    243     payload: &LeaderProofPayload,
    244 ) -> Result<()> {
    245     verify_address_signature(
    246         &proof.public_key,
    247         &payload.canonical(),
    248         &proof.signature,
    249         "leader",
    250     )
    251 }
    252 
    253 pub(super) fn verify_address_signature(
    254     address: &str,
    255     payload: &str,
    256     signature: &str,
    257     label: &str,
    258 ) -> Result<()> {
    259     let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address)
    260         .with_context(|| format!("invalid {label} public key {address}"))?;
    261     let signature = decode_hex_array::<SIGNATURE_BYTES>(signature)
    262         .with_context(|| format!("invalid {label} signature hex"))?;
    263     super::verify_ed25519(&public_key, payload.as_bytes(), &signature, label)
    264 }
    265 
    266 pub(super) fn vdf_seed_for_child(
    267     prev_hash: &str,
    268     height: u64,
    269     bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE],
    270     content_commitment: &str,
    271 ) -> String {
    272     if height < GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
    273         return hex_hash(format!(
    274             "iuna-vdf-child:{prev_hash}:{height}:{}",
    275             canonical_burn_bundle_hashes(bundle_hashes)
    276         ));
    277     }
    278     hex_hash(format!(
    279         "iuna-vdf-child-v2:{prev_hash}:{height}:{content_commitment}:{}",
    280         canonical_burn_bundle_hashes(bundle_hashes)
    281     ))
    282 }
    283 
    284 pub(super) fn recovery_vdf_seed_for_child(
    285     prev_hash: &str,
    286     height: u64,
    287     timestamp_ms: u64,
    288     bundle_hashes: &[String; super::BURN_COMMITTEE_SIZE],
    289     content_commitment: &str,
    290 ) -> String {
    291     if height < GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
    292         return hex_hash(format!(
    293             "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}",
    294             canonical_burn_bundle_hashes(bundle_hashes)
    295         ));
    296     }
    297     hex_hash(format!(
    298         "iuna-recovery-vdf-child-v2:{prev_hash}:{height}:{timestamp_ms}:{content_commitment}:{}",
    299         canonical_burn_bundle_hashes(bundle_hashes)
    300     ))
    301 }
    302 
    303 #[allow(clippy::too_many_arguments)]
    304 pub(super) fn vdf_content_commitment(
    305     height: u64,
    306     prev_hash: &str,
    307     miner: &str,
    308     reward_address: Option<&str>,
    309     finalizer_mode: FinalizerMode,
    310     finalizer_rank: u32,
    311     reward: Amount,
    312     vdf_rounds: u64,
    313     leader_ticket_id: Option<&str>,
    314     burn_bundle_section: &BurnBundleSection,
    315     transactions: &[Transaction],
    316     transactions_v2: &[String],
    317 ) -> String {
    318     let mode = match finalizer_mode {
    319         FinalizerMode::Ticket => "ticket",
    320         FinalizerMode::Recovery => "recovery",
    321     };
    322     let ticket_id = leader_ticket_id.unwrap_or("none");
    323     let transaction_hash = hex_hash(format!(
    324         "iuna-vdf-transactions-v1:{}",
    325         transactions
    326             .iter()
    327             .map(Transaction::canonical)
    328             .collect::<Vec<_>>()
    329             .join("|")
    330     ));
    331     let burn_section_hash = hex_hash(format!(
    332         "iuna-vdf-burn-section-v1:{}",
    333         burn_bundle_section.canonical()
    334     ));
    335     if let Some(reward_address) = reward_address {
    336         let transaction_v2_hash = hex_hash(format!(
    337             "iuna-vdf-transactions-v2:{}",
    338             transactions_v2.join("|")
    339         ));
    340         return hex_hash(format!(
    341             "iuna-vdf-content-v3:{height}:{prev_hash}:{miner}:{reward_address}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}"
    342         ));
    343     }
    344     if transactions_v2.is_empty() {
    345         return hex_hash(format!(
    346             "iuna-vdf-content-v1:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{burn_section_hash}"
    347         ));
    348     }
    349     let transaction_v2_hash = hex_hash(format!(
    350         "iuna-vdf-transactions-v2:{}",
    351         transactions_v2.join("|")
    352     ));
    353     hex_hash(format!(
    354         "iuna-vdf-content-v2:{height}:{prev_hash}:{miner}:{mode}:{finalizer_rank}:{reward}:{vdf_rounds}:{ticket_id}:{transaction_hash}:{transaction_v2_hash}:{burn_section_hash}"
    355     ))
    356 }
    357 
    358 pub(super) fn apply_transaction(
    359     transaction: &Transaction,
    360     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    361     signing_domain: &super::TransactionSigningDomain,
    362 ) -> Result<()> {
    363     transaction.verify_signature(signing_domain)?;
    364     match transaction {
    365         Transaction::Mine { recipient, .. } => {
    366             let output = TxOutput {
    367                 address: recipient.clone(),
    368                 amount: MINE_REWARD,
    369             };
    370             ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
    371             utxos.insert(
    372                 OutPoint {
    373                     txid: transaction.signature().to_string(),
    374                     index: 0,
    375                 },
    376                 output,
    377             );
    378             return Ok(());
    379         }
    380         Transaction::Transfer { .. } | Transaction::Burn { .. } => {}
    381     }
    382     ensure_single_input_owner(transaction)?;
    383     let input_total = spend_inputs(transaction, utxos)?;
    384     let outputs = transaction.outputs();
    385     let output_total = outputs.iter().try_fold(0_u64, |total, output| {
    386         total
    387             .checked_add(output.amount)
    388             .context("transaction outputs overflow")
    389     })?;
    390     let required = output_total
    391         .checked_add(transaction.fee())
    392         .context("transaction outputs plus fee overflow")?
    393         .checked_add(match transaction {
    394             Transaction::Burn { amount, .. } => *amount,
    395             Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    396         })
    397         .context("transaction outputs plus burn overflow")?;
    398     if input_total != required {
    399         bail!("transaction inputs do not balance outputs, burn, and fee");
    400     }
    401     ensure_outputs_do_not_overflow(utxos, &outputs)?;
    402     for (index, output) in outputs.iter().enumerate() {
    403         utxos.insert(
    404             OutPoint {
    405                 txid: transaction.signature().to_string(),
    406                 index: index as u32,
    407             },
    408             output.clone(),
    409         );
    410     }
    411     Ok(())
    412 }
    413 
    414 pub(super) fn validate_block_fee_policy(block: &Block) -> Result<()> {
    415     for transaction in &block.transactions {
    416         if transaction.fee() == 0 {
    417             bail!("block transaction fee must be greater than zero");
    418         }
    419     }
    420     Ok(())
    421 }
    422 
    423 pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
    424     transactions.iter().try_fold(0_u64, |total, tx| {
    425         total.checked_add(tx.fee()).context("block fees overflow")
    426     })
    427 }
    428 
    429 pub(super) fn block_reward(
    430     transactions: &[Transaction],
    431     additional_finalizer_fees: Amount,
    432 ) -> Result<Amount> {
    433     fee_reward(transactions)?
    434         .checked_add(additional_finalizer_fees)
    435         .context("block reward overflow")
    436 }
    437 
    438 pub(super) fn spend_inputs(
    439     transaction: &Transaction,
    440     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    441 ) -> Result<Amount> {
    442     let mut seen = BTreeSet::new();
    443     let mut total = 0_u64;
    444     for input in transaction.inputs() {
    445         if !seen.insert(input.outpoint.clone()) {
    446             bail!("duplicate input in transaction");
    447         }
    448         let output = utxos.remove(&input.outpoint).with_context(|| {
    449             format!("transaction spends missing output {}", input.outpoint.id())
    450         })?;
    451         if output.address != input.owner {
    452             bail!("transaction input owner does not match spent output");
    453         }
    454         total = total
    455             .checked_add(output.amount)
    456             .context("transaction input total overflows")?;
    457     }
    458     Ok(total)
    459 }
    460 
    461 pub(super) fn apply_spendable_pending_transaction(
    462     transaction: &Transaction,
    463     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    464     signing_domain: &super::TransactionSigningDomain,
    465 ) -> Result<()> {
    466     if matches!(transaction, Transaction::Mine { .. }) {
    467         bail!("pending mine outputs are not spendable");
    468     }
    469     transaction.verify_signature(signing_domain)?;
    470     ensure_single_input_owner(transaction)?;
    471     let input_total = transaction_input_total(transaction, utxos)?;
    472     let outputs = transaction.outputs();
    473     let output_total = outputs.iter().try_fold(0_u64, |total, output| {
    474         total
    475             .checked_add(output.amount)
    476             .context("transaction outputs overflow")
    477     })?;
    478     let required = output_total
    479         .checked_add(transaction.fee())
    480         .context("transaction outputs plus fee overflow")?
    481         .checked_add(match transaction {
    482             Transaction::Burn { amount, .. } => *amount,
    483             Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    484         })
    485         .context("transaction outputs plus burn overflow")?;
    486     if input_total != required {
    487         bail!("transaction inputs do not balance outputs, burn, and fee");
    488     }
    489     ensure_outputs_do_not_overflow(utxos, &outputs)?;
    490     for input in transaction.inputs() {
    491         utxos.remove(&input.outpoint);
    492     }
    493     for (index, output) in outputs.iter().enumerate() {
    494         utxos.insert(
    495             OutPoint {
    496                 txid: transaction.signature().to_string(),
    497                 index: index as u32,
    498             },
    499             output.clone(),
    500         );
    501     }
    502     Ok(())
    503 }
    504 
    505 pub(super) fn transaction_input_total(
    506     transaction: &Transaction,
    507     utxos: &BTreeMap<OutPoint, TxOutput>,
    508 ) -> Result<Amount> {
    509     let mut seen = BTreeSet::new();
    510     let mut total = 0_u64;
    511     for input in transaction.inputs() {
    512         if !seen.insert(input.outpoint.clone()) {
    513             bail!("duplicate input in transaction");
    514         }
    515         let output = utxos.get(&input.outpoint).with_context(|| {
    516             format!("transaction spends missing output {}", input.outpoint.id())
    517         })?;
    518         if output.address != input.owner {
    519             bail!("transaction input owner does not match spent output");
    520         }
    521         total = total
    522             .checked_add(output.amount)
    523             .context("transaction input total overflows")?;
    524     }
    525     Ok(total)
    526 }
    527 
    528 pub(super) fn transaction_has_missing_inputs(
    529     transaction: &Transaction,
    530     utxos: &BTreeMap<OutPoint, TxOutput>,
    531 ) -> bool {
    532     transaction
    533         .inputs()
    534         .iter()
    535         .any(|input| !utxos.contains_key(&input.outpoint))
    536 }
    537 
    538 pub(super) fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> {
    539     if matches!(transaction, Transaction::Mine { .. }) {
    540         return Ok(());
    541     }
    542     ensure_single_input_owner_for_inputs(transaction.inputs())
    543 }
    544 
    545 pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> {
    546     let Some(first) = inputs.first() else {
    547         bail!("transaction has no inputs");
    548     };
    549     if inputs.iter().any(|input| input.owner != first.owner) {
    550         bail!("transaction inputs must have one owner");
    551     }
    552     Ok(())
    553 }
    554 
    555 pub(super) fn credit_reward_outputs(
    556     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    557     block: &Block,
    558     committee: &[BurnCommitteeMember],
    559 ) -> Result<()> {
    560     let outputs = reward_outputs_for_block(block, committee);
    561     let tx_outputs = outputs
    562         .iter()
    563         .map(|(_, output)| output.clone())
    564         .collect::<Vec<_>>();
    565     ensure_outputs_do_not_overflow(utxos, &tx_outputs)?;
    566     for (outpoint, output) in outputs {
    567         utxos.insert(outpoint, output);
    568     }
    569     Ok(())
    570 }
    571 
    572 pub(super) fn credit_reward_output(
    573     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    574     block: &Block,
    575 ) -> Result<()> {
    576     credit_reward_outputs(utxos, block, &[])
    577 }
    578 
    579 pub fn reward_outputs_for_block(
    580     block: &Block,
    581     committee: &[BurnCommitteeMember],
    582 ) -> Vec<(OutPoint, TxOutput)> {
    583     if block.reward == 0 {
    584         return Vec::new();
    585     }
    586 
    587     let mut outputs = Vec::new();
    588     let committee_slots = reward_committee_slots(block);
    589     let committee_members = committee_slots
    590         .into_iter()
    591         .filter_map(|slot| {
    592             committee
    593                 .iter()
    594                 .find(|member| member.slot == slot && member.owner != block.miner)
    595         })
    596         .collect::<Vec<_>>();
    597     let committee_pool = if committee_members.is_empty() {
    598         0
    599     } else {
    600         block.reward / 2
    601     };
    602     let finalizer_amount = block.reward.saturating_sub(committee_pool);
    603 
    604     if finalizer_amount > 0 {
    605         outputs.push((
    606             reward_outpoint(&block.hash),
    607             TxOutput {
    608                 address: block
    609                     .reward_address
    610                     .clone()
    611                     .unwrap_or_else(|| block.miner.clone()),
    612                 amount: finalizer_amount,
    613             },
    614         ));
    615     }
    616 
    617     let mut remaining = committee_pool;
    618     for (index, member) in committee_members.iter().enumerate() {
    619         let members_left = committee_members.len() - index;
    620         let amount = if members_left == 1 {
    621             remaining
    622         } else {
    623             remaining / members_left as u64
    624         };
    625         remaining = remaining.saturating_sub(amount);
    626         if amount == 0 {
    627             continue;
    628         }
    629         outputs.push((
    630             committee_reward_outpoint(&block.hash, member.slot),
    631             TxOutput {
    632                 address: block
    633                     .burn_bundle_section
    634                     .signatures
    635                     .iter()
    636                     .find(|signature| signature.slot == member.slot)
    637                     .and_then(|signature| signature.reward_address.clone())
    638                     .unwrap_or_else(|| member.owner.clone()),
    639                 amount,
    640             },
    641         ));
    642     }
    643 
    644     outputs
    645 }
    646 
    647 fn reward_committee_slots(block: &Block) -> Vec<u8> {
    648     match block.finalizer_mode {
    649         FinalizerMode::Ticket if block.finalizer_rank <= 1 => block
    650             .burn_bundle_section
    651             .signatures
    652             .iter()
    653             .map(|signature| signature.slot)
    654             .collect(),
    655         FinalizerMode::Ticket | FinalizerMode::Recovery => Vec::new(),
    656     }
    657 }
    658 
    659 pub(super) fn ensure_outputs_do_not_overflow(
    660     utxos: &BTreeMap<OutPoint, TxOutput>,
    661     outputs: &[TxOutput],
    662 ) -> Result<()> {
    663     let mut balances = BTreeMap::new();
    664     for output in utxos.values() {
    665         let balance = balances.entry(output.address.clone()).or_insert(0_u64);
    666         *balance = balance
    667             .checked_add(output.amount)
    668             .with_context(|| format!("balance overflow for {}", output.address))?;
    669     }
    670     for output in outputs {
    671         let balance = balances.entry(output.address.clone()).or_insert(0_u64);
    672         *balance = balance
    673             .checked_add(output.amount)
    674             .with_context(|| format!("balance overflow for {}", output.address))?;
    675     }
    676     Ok(())
    677 }
    678 
    679 pub(super) fn ensure_block_has_burn(
    680     transactions: &[Transaction],
    681     transactions_v2: &[String],
    682 ) -> Result<()> {
    683     let has_v2_burn = transactions_v2.iter().any(|envelope| {
    684         super::decode_hex(envelope)
    685             .ok()
    686             .and_then(|encoded| super::TransactionV2::decode(&encoded).ok())
    687             .is_some_and(|(_, transaction)| transaction.is_burn())
    688     });
    689     if !transactions.iter().any(Transaction::is_burn) && !has_v2_burn {
    690         bail!("block must include at least one burn transaction");
    691     }
    692     Ok(())
    693 }
    694 
    695 pub(super) fn ensure_block_has_burn_from(
    696     transactions: &[Transaction],
    697     transactions_v2: &[String],
    698     miner: &str,
    699 ) -> Result<()> {
    700     let has_legacy_burn = transactions
    701         .iter()
    702         .any(|transaction| transaction.is_burn() && transaction.sender() == miner);
    703     let has_v2_burn = transactions_v2.iter().any(|envelope| {
    704         super::decode_hex(envelope)
    705             .ok()
    706             .and_then(|encoded| super::TransactionV2::decode(&encoded).ok())
    707             .and_then(|(_, transaction)| transaction.burn_legacy_owner().ok().flatten())
    708             .is_some_and(|owner| owner == miner)
    709     });
    710     if !has_legacy_burn && !has_v2_burn {
    711         bail!("recovery block must include a burn from the finalizer");
    712     }
    713     Ok(())
    714 }
    715 
    716 pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> {
    717     if block.finalizer_rank != 0 {
    718         bail!("recovery block finalizer rank must be 0");
    719     }
    720     if block.leader_proof.is_some() {
    721         bail!("recovery block must not carry a leader proof");
    722     }
    723     let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS);
    724     if block.timestamp_ms < min_timestamp {
    725         bail!("recovery block is not available before timestamp {min_timestamp}");
    726     }
    727     ensure_block_has_burn_from(&block.transactions, &block.transactions_v2, &block.miner)
    728 }
    729 
    730 pub(super) fn best_selectable_transaction_index(
    731     transactions: &[Transaction],
    732     utxos: &BTreeMap<OutPoint, TxOutput>,
    733     required_kind: Option<TransactionKind>,
    734     signing_domain: &super::TransactionSigningDomain,
    735 ) -> Option<usize> {
    736     transactions
    737         .iter()
    738         .enumerate()
    739         .filter(|(_, tx)| match required_kind {
    740             Some(TransactionKind::Burn) => tx.is_burn(),
    741             None => true,
    742         })
    743         .filter(|(_, tx)| {
    744             let mut utxos = utxos.clone();
    745             apply_transaction(tx, &mut utxos, signing_domain).is_ok()
    746         })
    747         .max_by(|(_, left), (_, right)| {
    748             fee_rate_key(left)
    749                 .cmp(&fee_rate_key(right))
    750                 .then_with(|| left.fee().cmp(&right.fee()))
    751                 .then_with(|| left.is_burn().cmp(&right.is_burn()))
    752                 .then_with(|| right.signature().cmp(left.signature()))
    753         })
    754         .map(|(index, _)| index)
    755 }
    756 
    757 pub(super) fn best_selectable_burn_from_index(
    758     transactions: &[Transaction],
    759     utxos: &BTreeMap<OutPoint, TxOutput>,
    760     owner: &str,
    761     signing_domain: &super::TransactionSigningDomain,
    762 ) -> Option<usize> {
    763     transactions
    764         .iter()
    765         .enumerate()
    766         .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner)
    767         .filter(|(_, tx)| {
    768             let mut utxos = utxos.clone();
    769             apply_transaction(tx, &mut utxos, signing_domain).is_ok()
    770         })
    771         .max_by(|(_, left), (_, right)| {
    772             fee_rate_key(left)
    773                 .cmp(&fee_rate_key(right))
    774                 .then_with(|| left.fee().cmp(&right.fee()))
    775                 .then_with(|| right.signature().cmp(left.signature()))
    776         })
    777         .map(|(index, _)| index)
    778 }
    779 
    780 pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint {
    781     OutPoint {
    782         txid: block_hash.to_string(),
    783         index: u32::MAX,
    784     }
    785 }
    786 
    787 fn committee_reward_outpoint(block_hash: &str, slot: u8) -> OutPoint {
    788     let slot = usize::from(slot).min(BURN_COMMITTEE_SIZE.saturating_sub(1));
    789     OutPoint {
    790         txid: block_hash.to_string(),
    791         index: u32::MAX.saturating_sub(slot as u32),
    792     }
    793 }
    794 
    795 #[cfg(test)]
    796 mod tests {
    797     use super::*;
    798     use crate::domain::{BurnBundleSection, BurnBundleSignature};
    799 
    800     fn reward_block(finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount) -> Block {
    801         let mut block = Block {
    802             height: 1,
    803             prev_hash: "p".repeat(64),
    804             timestamp_ms: 1,
    805             miner: "finalizer".to_string(),
    806             reward_address: None,
    807             reward_address_signature: None,
    808             finalizer_mode,
    809             finalizer_rank,
    810             reward,
    811             vdf_rounds: 1,
    812             vdf_output: "out".to_string(),
    813             leader_proof: None,
    814             burn_bundle_section: BurnBundleSection::default(),
    815             transactions: Vec::new(),
    816             transactions_v2: Vec::new(),
    817             hash: "h".repeat(64),
    818         };
    819         block.hash = block.compute_hash();
    820         block
    821     }
    822 
    823     fn committee_member(slot: u8, owner: &str) -> BurnCommitteeMember {
    824         BurnCommitteeMember {
    825             slot,
    826             root: format!("root-{slot}"),
    827             owner: owner.to_string(),
    828             weight: 1,
    829         }
    830     }
    831 
    832     fn attest(block: &mut Block, slots: &[u8]) {
    833         block.burn_bundle_section.signatures = slots
    834             .iter()
    835             .map(|slot| BurnBundleSignature {
    836                 slot: *slot,
    837                 member: format!("committee-{slot}"),
    838                 reward_address: None,
    839                 signature: format!("signature-{slot}"),
    840             })
    841             .collect();
    842     }
    843 
    844     fn output_amount(outputs: &[(OutPoint, TxOutput)], owner: &str) -> Amount {
    845         outputs
    846             .iter()
    847             .filter(|(_, output)| output.address == owner)
    848             .map(|(_, output)| output.amount)
    849             .sum()
    850     }
    851 
    852     #[test]
    853     fn activated_rewards_use_explicit_hybrid_payout_addresses() {
    854         let mut block = reward_block(FinalizerMode::Ticket, 0, 100);
    855         block.height = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT;
    856         block.reward_address = Some("finalizer-hybrid".to_string());
    857         block.burn_bundle_section.signatures = vec![BurnBundleSignature {
    858             slot: 1,
    859             member: "committee".to_string(),
    860             reward_address: Some("committee-hybrid".to_string()),
    861             signature: "signature".to_string(),
    862         }];
    863         let committee = vec![
    864             committee_member(0, "finalizer"),
    865             committee_member(1, "committee"),
    866         ];
    867 
    868         let outputs = reward_outputs_for_block(&block, &committee);
    869 
    870         assert_eq!(output_amount(&outputs, "finalizer-hybrid"), 50);
    871         assert_eq!(output_amount(&outputs, "committee-hybrid"), 50);
    872         assert_eq!(output_amount(&outputs, "finalizer"), 0);
    873         assert_eq!(output_amount(&outputs, "committee"), 0);
    874     }
    875 
    876     #[test]
    877     fn rank_zero_splits_half_to_two_extra_committee_members() {
    878         let mut block = reward_block(FinalizerMode::Ticket, 0, 100);
    879         attest(&mut block, &[1, 2]);
    880         let committee = vec![
    881             committee_member(0, "finalizer"),
    882             committee_member(1, "committee-2"),
    883             committee_member(2, "committee-3"),
    884         ];
    885 
    886         let outputs = reward_outputs_for_block(&block, &committee);
    887 
    888         assert_eq!(output_amount(&outputs, "finalizer"), 50);
    889         assert_eq!(output_amount(&outputs, "committee-2"), 25);
    890         assert_eq!(output_amount(&outputs, "committee-3"), 25);
    891         assert!(
    892             outputs
    893                 .iter()
    894                 .any(|(outpoint, _)| outpoint.index == u32::MAX)
    895         );
    896         assert!(
    897             outputs
    898                 .iter()
    899                 .any(|(outpoint, _)| outpoint.index == u32::MAX - 1)
    900         );
    901         assert!(
    902             outputs
    903                 .iter()
    904                 .any(|(outpoint, _)| outpoint.index == u32::MAX - 2)
    905         );
    906     }
    907 
    908     #[test]
    909     fn rank_zero_gives_committee_half_to_the_only_available_extra_member() {
    910         let mut block = reward_block(FinalizerMode::Ticket, 0, 101);
    911         attest(&mut block, &[1]);
    912         let committee = vec![
    913             committee_member(0, "finalizer"),
    914             committee_member(1, "committee-2"),
    915         ];
    916 
    917         let outputs = reward_outputs_for_block(&block, &committee);
    918 
    919         assert_eq!(output_amount(&outputs, "finalizer"), 51);
    920         assert_eq!(output_amount(&outputs, "committee-2"), 50);
    921     }
    922 
    923     #[test]
    924     fn rank_one_splits_with_the_member_that_attested() {
    925         let mut block = reward_block(FinalizerMode::Ticket, 1, 100);
    926         attest(&mut block, &[2]);
    927         let committee = vec![
    928             committee_member(0, "finalizer"),
    929             committee_member(1, "committee-1"),
    930             committee_member(2, "committee-2"),
    931         ];
    932 
    933         let outputs = reward_outputs_for_block(&block, &committee);
    934 
    935         assert_eq!(output_amount(&outputs, "finalizer"), 50);
    936         assert_eq!(output_amount(&outputs, "committee-2"), 50);
    937         assert_eq!(output_amount(&outputs, "committee-1"), 0);
    938     }
    939 
    940     #[test]
    941     fn rank_two_and_recovery_pay_the_finalizer_only() {
    942         let committee = vec![
    943             committee_member(1, "committee-2"),
    944             committee_member(2, "committee-3"),
    945         ];
    946 
    947         let rank_two = reward_block(FinalizerMode::Ticket, 2, 100);
    948         let recovery = reward_block(FinalizerMode::Recovery, 0, 100);
    949 
    950         assert_eq!(
    951             output_amount(
    952                 &reward_outputs_for_block(&rank_two, &committee),
    953                 "finalizer"
    954             ),
    955             100
    956         );
    957         assert_eq!(reward_outputs_for_block(&rank_two, &committee).len(), 1);
    958         assert_eq!(
    959             output_amount(
    960                 &reward_outputs_for_block(&recovery, &committee),
    961                 "finalizer"
    962             ),
    963             100
    964         );
    965         assert_eq!(reward_outputs_for_block(&recovery, &committee).len(), 1);
    966     }
    967 
    968     #[test]
    969     fn finalizer_and_committee_reward_outputs_do_not_create_lineage() {
    970         let mut block = reward_block(FinalizerMode::Ticket, 0, 100);
    971         attest(&mut block, &[1]);
    972         let committee = vec![
    973             committee_member(0, "finalizer"),
    974             committee_member(1, "committee-1"),
    975         ];
    976         let mut utxos = BTreeMap::new();
    977         let utxo_lineage = BTreeMap::<OutPoint, super::super::UtxoLineageRoot>::new();
    978         let lineage_values = BTreeMap::<super::super::UtxoLineageRoot, Amount>::new();
    979 
    980         credit_reward_outputs(&mut utxos, &block, &committee).unwrap();
    981 
    982         assert_eq!(utxos.len(), 2);
    983         assert!(
    984             utxos
    985                 .keys()
    986                 .all(|outpoint| !utxo_lineage.contains_key(outpoint))
    987         );
    988         assert!(lineage_values.is_empty());
    989     }
    990 
    991     #[test]
    992     fn recovery_vdf_seed_binds_timestamp_while_ticket_seed_does_not() {
    993         let hashes = std::array::from_fn(super::super::default_burn_bundle_hash);
    994         let ticket_seed = vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "content");
    995         let recovery_at_one =
    996             recovery_vdf_seed_for_child(&"a".repeat(64), 42, 1, &hashes, "content");
    997         let recovery_at_two =
    998             recovery_vdf_seed_for_child(&"a".repeat(64), 42, 2, &hashes, "content");
    999 
   1000         assert_ne!(ticket_seed, recovery_at_one);
   1001         assert_ne!(recovery_at_one, recovery_at_two);
   1002         assert_eq!(
   1003             ticket_seed,
   1004             vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "content")
   1005         );
   1006         assert_eq!(
   1007             ticket_seed,
   1008             vdf_seed_for_child(&"a".repeat(64), 42, &hashes, "different-content")
   1009         );
   1010 
   1011         let activated = GRINDING_RESISTANCE_ACTIVATION_HEIGHT;
   1012         assert_ne!(
   1013             vdf_seed_for_child(&"a".repeat(64), activated, &hashes, "content"),
   1014             vdf_seed_for_child(&"a".repeat(64), activated, &hashes, "different-content")
   1015         );
   1016     }
   1017 }