iuna

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

ledger_ops.rs (21346B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Context, Result, bail};
      4 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
      5 
      6 use super::blinded::verify_blinded_input_signatures;
      7 use super::hex::hex_hash;
      8 use super::reveal::{RevealBundleSection, canonical_reveal_bundle_hashes};
      9 use super::selection::{TransactionKind, blinded_fee_rate_key, fee_rate_key};
     10 use super::ticket::ticket_is_eligible_for_height;
     11 use super::transaction::{BlindedTransaction, Transaction};
     12 use super::{
     13     Amount, Block, BlockSelection, BurnTicket, FinalizerMode, LeaderProof, LeaderProofPayload,
     14     Ledger, MINE_REWARD, OutPoint, PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS,
     15     REVEAL_COMMITTEE_SIZE, SIGNATURE_BYTES, TxInput, TxOutput, decode_hex_array, validate_address,
     16     validate_hash, validate_protocol_id, validate_signature,
     17 };
     18 
     19 pub(super) fn validate_genesis_allocations(
     20     genesis_allocations: &BTreeMap<String, Amount>,
     21 ) -> Result<()> {
     22     for address in genesis_allocations.keys() {
     23         validate_address(address, "genesis allocation")?;
     24     }
     25     Ok(())
     26 }
     27 
     28 pub(super) fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> {
     29     for input in inputs {
     30         validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?;
     31         validate_address(&input.owner, "input owner")?;
     32         validate_signature(&input.signature, "input signature")?;
     33     }
     34     Ok(())
     35 }
     36 
     37 pub(super) fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> {
     38     for output in outputs {
     39         validate_address(&output.address, "output recipient")?;
     40     }
     41     Ok(())
     42 }
     43 
     44 pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> {
     45     let Transaction::Burn {
     46         inputs,
     47         change,
     48         fee,
     49         signature,
     50         ..
     51     } = transaction
     52     else {
     53         bail!("genesis only supports burn transactions");
     54     };
     55     if *fee != 0 {
     56         bail!("genesis burn fee must be zero");
     57     }
     58     validate_hash(signature, "genesis burn signature")?;
     59     validate_transaction_outputs(change)?;
     60     for input in inputs {
     61         validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?;
     62         validate_address(&input.owner, "genesis burn input owner")?;
     63         if input.signature != "genesis" {
     64             bail!("genesis burn input signature is invalid");
     65         }
     66     }
     67     Ok(())
     68 }
     69 
     70 pub(super) fn estimated_block_selection_size_bytes(
     71     selection: &BlockSelection,
     72     recovery: bool,
     73 ) -> Result<usize> {
     74     let block = Block {
     75         height: u64::MAX,
     76         prev_hash: "f".repeat(64),
     77         timestamp_ms: u64::MAX,
     78         miner: "f".repeat(64),
     79         finalizer_mode: if recovery {
     80             FinalizerMode::Recovery
     81         } else {
     82             FinalizerMode::Ticket
     83         },
     84         finalizer_rank: 0,
     85         reward: u64::MAX,
     86         vdf_rounds: u64::MAX,
     87         vdf_output: "f".repeat(64),
     88         leader_proof: (!recovery).then(|| LeaderProof {
     89             ticket_id: "f".repeat(64),
     90             public_key: "f".repeat(64),
     91             signature: "f".repeat(128),
     92         }),
     93         blinded_transactions: selection.blinded_transactions.clone(),
     94         reveal_bundle_section: RevealBundleSection::default(),
     95         transactions: selection.transactions.clone(),
     96         hash: "f".repeat(64),
     97     };
     98     block.serialized_size_bytes()
     99 }
    100 
    101 pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> {
    102     let Some(proof) = &block.leader_proof else {
    103         bail!("block is missing leader proof");
    104     };
    105     if proof.public_key != block.miner {
    106         bail!("leader proof public key does not match block finalizer");
    107     }
    108     let ticket = tickets
    109         .iter()
    110         .find(|ticket| {
    111             ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height)
    112         })
    113         .context("leader ticket is not pending for this height")?;
    114     if ticket.owner != block.miner {
    115         bail!("leader ticket owner does not match block finalizer");
    116     }
    117     if ticket.eligible_from_height > block.height {
    118         bail!("leader ticket is not mature");
    119     }
    120 
    121     let payload = LeaderProofPayload {
    122         height: block.height,
    123         prev_hash: block.prev_hash.clone(),
    124         finalizer_rank: block.finalizer_rank,
    125         vdf_output: block.vdf_output.clone(),
    126         ticket_id: ticket.id.clone(),
    127         ticket_amount: ticket.amount,
    128         ticket_owner: ticket.owner.clone(),
    129     };
    130     verify_leader_signature(proof, &payload)?;
    131     Ok(())
    132 }
    133 
    134 pub(super) fn verify_leader_signature(
    135     proof: &LeaderProof,
    136     payload: &LeaderProofPayload,
    137 ) -> Result<()> {
    138     verify_address_signature(
    139         &proof.public_key,
    140         &payload.canonical(),
    141         &proof.signature,
    142         "leader",
    143     )
    144 }
    145 
    146 pub(super) fn verify_address_signature(
    147     address: &str,
    148     payload: &str,
    149     signature: &str,
    150     label: &str,
    151 ) -> Result<()> {
    152     let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address)
    153         .with_context(|| format!("invalid {label} public key {address}"))?;
    154     let signature = decode_hex_array::<SIGNATURE_BYTES>(signature)
    155         .with_context(|| format!("invalid {label} signature hex"))?;
    156     let verifying_key = VerifyingKey::from_bytes(&public_key)
    157         .with_context(|| format!("invalid {label} public key"))?;
    158     let signature = Signature::from_bytes(&signature);
    159     verifying_key
    160         .verify(payload.as_bytes(), &signature)
    161         .with_context(|| format!("{label} signature is invalid"))
    162 }
    163 
    164 pub(super) fn vdf_seed_for_child(
    165     prev_hash: &str,
    166     height: u64,
    167     bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE],
    168 ) -> String {
    169     hex_hash(format!(
    170         "iuna-vdf-child:{prev_hash}:{height}:{}",
    171         canonical_reveal_bundle_hashes(bundle_hashes)
    172     ))
    173 }
    174 
    175 pub(super) fn recovery_vdf_seed_for_child(
    176     prev_hash: &str,
    177     height: u64,
    178     timestamp_ms: u64,
    179     bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE],
    180 ) -> String {
    181     hex_hash(format!(
    182         "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}",
    183         canonical_reveal_bundle_hashes(bundle_hashes)
    184     ))
    185 }
    186 
    187 pub(super) fn apply_transaction(
    188     transaction: &Transaction,
    189     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    190 ) -> Result<()> {
    191     transaction.verify_signature()?;
    192     match transaction {
    193         Transaction::Mine { recipient, .. } => {
    194             let output = TxOutput {
    195                 address: recipient.clone(),
    196                 amount: MINE_REWARD,
    197             };
    198             ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
    199             utxos.insert(
    200                 OutPoint {
    201                     txid: transaction.signature().to_string(),
    202                     index: 0,
    203                 },
    204                 output,
    205             );
    206             return Ok(());
    207         }
    208         Transaction::Transfer { .. } | Transaction::Burn { .. } => {}
    209     }
    210     ensure_single_input_owner(transaction)?;
    211     let input_total = spend_inputs(transaction, utxos)?;
    212     let outputs = transaction.outputs();
    213     let output_total = outputs.iter().try_fold(0_u64, |total, output| {
    214         total
    215             .checked_add(output.amount)
    216             .context("transaction outputs overflow")
    217     })?;
    218     let required = output_total
    219         .checked_add(transaction.fee())
    220         .context("transaction outputs plus fee overflow")?
    221         .checked_add(match transaction {
    222             Transaction::Burn { amount, .. } => *amount,
    223             Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    224         })
    225         .context("transaction outputs plus burn overflow")?;
    226     if input_total != required {
    227         bail!("transaction inputs do not balance outputs, burn, and fee");
    228     }
    229     ensure_outputs_do_not_overflow(utxos, &outputs)?;
    230     for (index, output) in outputs.iter().enumerate() {
    231         utxos.insert(
    232             OutPoint {
    233                 txid: transaction.signature().to_string(),
    234                 index: index as u32,
    235             },
    236             output.clone(),
    237         );
    238     }
    239     Ok(())
    240 }
    241 
    242 pub(super) fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> {
    243     let mut commitments = BTreeSet::new();
    244     for transaction in &block.blinded_transactions {
    245         if !commitments.insert(transaction.commitment.clone()) {
    246             bail!("duplicate blinded transaction in block");
    247         }
    248         ledger.validate_blinded_transaction(transaction)?;
    249         if transaction.expires_at_height <= block.height {
    250             bail!("blinded transaction is expired for block height");
    251         }
    252         if ledger.active_blinded.contains_key(&transaction.commitment) {
    253             bail!("blinded transaction is already active");
    254         }
    255         if ledger.chain.iter().any(|block| {
    256             block
    257                 .blinded_transactions
    258                 .iter()
    259                 .any(|existing| existing.commitment == transaction.commitment)
    260         }) {
    261             bail!("blinded transaction is already on chain");
    262         }
    263     }
    264 
    265     let mut reveals = BTreeSet::new();
    266     for reveal in block.all_blinded_reveals() {
    267         if !reveals.insert(reveal.commitment.clone()) {
    268             bail!("duplicate blinded reveal in block");
    269         }
    270         if ledger.chain.iter().any(|block| {
    271             block
    272                 .all_blinded_reveals()
    273                 .iter()
    274                 .any(|existing| existing.commitment == reveal.commitment)
    275         }) {
    276             bail!("blinded reveal is already on chain");
    277         }
    278         ledger.pending_reveal_transaction(reveal)?;
    279     }
    280     Ok(())
    281 }
    282 
    283 pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> {
    284     transactions.iter().try_fold(0_u64, |total, tx| {
    285         total.checked_add(tx.fee()).context("block fees overflow")
    286     })
    287 }
    288 
    289 pub(super) fn block_reward(
    290     transactions: &[Transaction],
    291     aggregated_reveal_finalizer_fees: Amount,
    292 ) -> Result<Amount> {
    293     fee_reward(transactions)?
    294         .checked_add(aggregated_reveal_finalizer_fees)
    295         .context("block reward overflow")
    296 }
    297 
    298 pub(super) fn spend_inputs(
    299     transaction: &Transaction,
    300     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    301 ) -> Result<Amount> {
    302     let mut seen = BTreeSet::new();
    303     let mut total = 0_u64;
    304     for input in transaction.inputs() {
    305         if !seen.insert(input.outpoint.clone()) {
    306             bail!("duplicate input in transaction");
    307         }
    308         let output = utxos.remove(&input.outpoint).with_context(|| {
    309             format!("transaction spends missing output {}", input.outpoint.id())
    310         })?;
    311         if output.address != input.owner {
    312             bail!("transaction input owner does not match spent output");
    313         }
    314         total = total
    315             .checked_add(output.amount)
    316             .context("transaction input total overflows")?;
    317     }
    318     Ok(total)
    319 }
    320 
    321 pub(super) fn apply_spendable_pending_transaction(
    322     transaction: &Transaction,
    323     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    324 ) -> Result<()> {
    325     if matches!(transaction, Transaction::Mine { .. }) {
    326         bail!("pending mine outputs are not spendable");
    327     }
    328     transaction.verify_signature()?;
    329     ensure_single_input_owner(transaction)?;
    330     let input_total = transaction_input_total(transaction, utxos)?;
    331     let outputs = transaction.outputs();
    332     let output_total = outputs.iter().try_fold(0_u64, |total, output| {
    333         total
    334             .checked_add(output.amount)
    335             .context("transaction outputs overflow")
    336     })?;
    337     let required = output_total
    338         .checked_add(transaction.fee())
    339         .context("transaction outputs plus fee overflow")?
    340         .checked_add(match transaction {
    341             Transaction::Burn { amount, .. } => *amount,
    342             Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    343         })
    344         .context("transaction outputs plus burn overflow")?;
    345     if input_total != required {
    346         bail!("transaction inputs do not balance outputs, burn, and fee");
    347     }
    348     ensure_outputs_do_not_overflow(utxos, &outputs)?;
    349     for input in transaction.inputs() {
    350         utxos.remove(&input.outpoint);
    351     }
    352     for (index, output) in outputs.iter().enumerate() {
    353         utxos.insert(
    354             OutPoint {
    355                 txid: transaction.signature().to_string(),
    356                 index: index as u32,
    357             },
    358             output.clone(),
    359         );
    360     }
    361     Ok(())
    362 }
    363 
    364 pub(super) fn transaction_input_total(
    365     transaction: &Transaction,
    366     utxos: &BTreeMap<OutPoint, TxOutput>,
    367 ) -> Result<Amount> {
    368     let mut seen = BTreeSet::new();
    369     let mut total = 0_u64;
    370     for input in transaction.inputs() {
    371         if !seen.insert(input.outpoint.clone()) {
    372             bail!("duplicate input in transaction");
    373         }
    374         let output = utxos.get(&input.outpoint).with_context(|| {
    375             format!("transaction spends missing output {}", input.outpoint.id())
    376         })?;
    377         if output.address != input.owner {
    378             bail!("transaction input owner does not match spent output");
    379         }
    380         total = total
    381             .checked_add(output.amount)
    382             .context("transaction input total overflows")?;
    383     }
    384     Ok(total)
    385 }
    386 
    387 pub(super) fn spend_blinded_inputs(
    388     transaction: &BlindedTransaction,
    389     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    390 ) -> Result<Vec<TxOutput>> {
    391     verify_blinded_input_signatures(transaction)?;
    392     if transaction.inputs.is_empty() {
    393         return Ok(Vec::new());
    394     }
    395     let mut seen = BTreeSet::new();
    396     let mut locked = Vec::new();
    397     for input in &transaction.inputs {
    398         if !seen.insert(input.outpoint.clone()) {
    399             bail!("duplicate input in blinded transaction");
    400         }
    401         let output = utxos.remove(&input.outpoint).with_context(|| {
    402             format!(
    403                 "blinded transaction spends missing output {}",
    404                 input.outpoint.id()
    405             )
    406         })?;
    407         if output.address != input.owner {
    408             bail!("blinded transaction input owner does not match spent output");
    409         }
    410         locked.push(output);
    411     }
    412     let locked_total = locked.iter().try_fold(0_u64, |total, output| {
    413         total
    414             .checked_add(output.amount)
    415             .context("blinded transaction locked input total overflows")
    416     })?;
    417     if transaction.fee > locked_total {
    418         bail!("blinded transaction fee exceeds locked inputs");
    419     }
    420     Ok(locked)
    421 }
    422 
    423 pub(super) fn spend_spendable_blinded_inputs(
    424     transaction: &BlindedTransaction,
    425     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    426 ) -> Result<Vec<TxOutput>> {
    427     let locked = blinded_input_outputs(transaction, utxos)?;
    428     for input in &transaction.inputs {
    429         utxos.remove(&input.outpoint);
    430     }
    431     Ok(locked)
    432 }
    433 
    434 pub(super) fn blinded_input_outputs(
    435     transaction: &BlindedTransaction,
    436     utxos: &BTreeMap<OutPoint, TxOutput>,
    437 ) -> Result<Vec<TxOutput>> {
    438     verify_blinded_input_signatures(transaction)?;
    439     if transaction.inputs.is_empty() {
    440         return Ok(Vec::new());
    441     }
    442     let mut seen = BTreeSet::new();
    443     let mut locked = Vec::new();
    444     for input in &transaction.inputs {
    445         if !seen.insert(input.outpoint.clone()) {
    446             bail!("duplicate input in blinded transaction");
    447         }
    448         let output = utxos.get(&input.outpoint).with_context(|| {
    449             format!(
    450                 "blinded transaction spends missing output {}",
    451                 input.outpoint.id()
    452             )
    453         })?;
    454         if output.address != input.owner {
    455             bail!("blinded transaction input owner does not match spent output");
    456         }
    457         locked.push(output.clone());
    458     }
    459     let locked_total = locked.iter().try_fold(0_u64, |total, output| {
    460         total
    461             .checked_add(output.amount)
    462             .context("blinded transaction locked input total overflows")
    463     })?;
    464     if transaction.fee > locked_total {
    465         bail!("blinded transaction fee exceeds locked inputs");
    466     }
    467     Ok(locked)
    468 }
    469 
    470 pub(super) fn transaction_has_missing_inputs(
    471     transaction: &Transaction,
    472     utxos: &BTreeMap<OutPoint, TxOutput>,
    473 ) -> bool {
    474     transaction
    475         .inputs()
    476         .iter()
    477         .any(|input| !utxos.contains_key(&input.outpoint))
    478 }
    479 
    480 pub(super) fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> {
    481     if matches!(transaction, Transaction::Mine { .. }) {
    482         return Ok(());
    483     }
    484     ensure_single_input_owner_for_inputs(transaction.inputs())
    485 }
    486 
    487 pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> {
    488     let Some(first) = inputs.first() else {
    489         bail!("transaction has no inputs");
    490     };
    491     if inputs.iter().any(|input| input.owner != first.owner) {
    492         bail!("transaction inputs must have one owner");
    493     }
    494     Ok(())
    495 }
    496 
    497 pub(super) fn credit_reward_output(
    498     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    499     block: &Block,
    500 ) -> Result<()> {
    501     if block.reward == 0 {
    502         return Ok(());
    503     }
    504     let output = TxOutput {
    505         address: block.miner.clone(),
    506         amount: block.reward,
    507     };
    508     ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
    509     utxos.insert(reward_outpoint(&block.hash), output);
    510     Ok(())
    511 }
    512 
    513 pub(super) fn ensure_outputs_do_not_overflow(
    514     utxos: &BTreeMap<OutPoint, TxOutput>,
    515     outputs: &[TxOutput],
    516 ) -> Result<()> {
    517     let mut balances = BTreeMap::new();
    518     for output in utxos.values() {
    519         let balance = balances.entry(output.address.clone()).or_insert(0_u64);
    520         *balance = balance
    521             .checked_add(output.amount)
    522             .with_context(|| format!("balance overflow for {}", output.address))?;
    523     }
    524     for output in outputs {
    525         let balance = balances.entry(output.address.clone()).or_insert(0_u64);
    526         *balance = balance
    527             .checked_add(output.amount)
    528             .with_context(|| format!("balance overflow for {}", output.address))?;
    529     }
    530     Ok(())
    531 }
    532 
    533 pub(super) fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
    534     if !transactions.iter().any(Transaction::is_burn) {
    535         bail!("block must include at least one burn transaction");
    536     }
    537     Ok(())
    538 }
    539 
    540 pub(super) fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> {
    541     if !transactions
    542         .iter()
    543         .any(|transaction| transaction.is_burn() && transaction.sender() == miner)
    544     {
    545         bail!("recovery block must include a burn from the finalizer");
    546     }
    547     Ok(())
    548 }
    549 
    550 pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> {
    551     if block.finalizer_rank != 0 {
    552         bail!("recovery block finalizer rank must be 0");
    553     }
    554     if block.leader_proof.is_some() {
    555         bail!("recovery block must not carry a leader proof");
    556     }
    557     let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS);
    558     if block.timestamp_ms < min_timestamp {
    559         bail!("recovery block is not available before timestamp {min_timestamp}");
    560     }
    561     ensure_block_has_burn_from(&block.transactions, &block.miner)
    562 }
    563 
    564 pub(super) fn best_selectable_blinded_index(
    565     transactions: &[BlindedTransaction],
    566     utxos: &BTreeMap<OutPoint, TxOutput>,
    567 ) -> Option<usize> {
    568     transactions
    569         .iter()
    570         .enumerate()
    571         .filter(|(_, transaction)| {
    572             let mut utxos = utxos.clone();
    573             spend_blinded_inputs(transaction, &mut utxos).is_ok()
    574         })
    575         .max_by(|(_, left), (_, right)| {
    576             blinded_fee_rate_key(left)
    577                 .cmp(&blinded_fee_rate_key(right))
    578                 .then_with(|| left.fee.cmp(&right.fee))
    579                 .then_with(|| right.commitment.cmp(&left.commitment))
    580         })
    581         .map(|(index, _)| index)
    582 }
    583 
    584 pub(super) fn best_selectable_transaction_index(
    585     transactions: &[Transaction],
    586     utxos: &BTreeMap<OutPoint, TxOutput>,
    587     required_kind: Option<TransactionKind>,
    588 ) -> Option<usize> {
    589     transactions
    590         .iter()
    591         .enumerate()
    592         .filter(|(_, tx)| match required_kind {
    593             Some(TransactionKind::Burn) => tx.is_burn(),
    594             None => true,
    595         })
    596         .filter(|(_, tx)| {
    597             let mut utxos = utxos.clone();
    598             apply_transaction(tx, &mut utxos).is_ok()
    599         })
    600         .max_by(|(_, left), (_, right)| {
    601             fee_rate_key(left)
    602                 .cmp(&fee_rate_key(right))
    603                 .then_with(|| left.fee().cmp(&right.fee()))
    604                 .then_with(|| left.is_burn().cmp(&right.is_burn()))
    605                 .then_with(|| right.signature().cmp(left.signature()))
    606         })
    607         .map(|(index, _)| index)
    608 }
    609 
    610 pub(super) fn best_selectable_burn_from_index(
    611     transactions: &[Transaction],
    612     utxos: &BTreeMap<OutPoint, TxOutput>,
    613     owner: &str,
    614 ) -> Option<usize> {
    615     transactions
    616         .iter()
    617         .enumerate()
    618         .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner)
    619         .filter(|(_, tx)| {
    620             let mut utxos = utxos.clone();
    621             apply_transaction(tx, &mut utxos).is_ok()
    622         })
    623         .max_by(|(_, left), (_, right)| {
    624             fee_rate_key(left)
    625                 .cmp(&fee_rate_key(right))
    626                 .then_with(|| left.fee().cmp(&right.fee()))
    627                 .then_with(|| right.signature().cmp(left.signature()))
    628         })
    629         .map(|(index, _)| index)
    630 }
    631 
    632 pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint {
    633     OutPoint {
    634         txid: block_hash.to_string(),
    635         index: u32::MAX,
    636     }
    637 }