iuna

iuna

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

ledger_apply.rs (31454B)


      1 use std::collections::BTreeSet;
      2 
      3 use anyhow::{Context, Result, bail};
      4 
      5 use super::ledger_ops::{
      6     block_reward, credit_reward_outputs, ensure_block_has_burn, ensure_outputs_do_not_overflow,
      7     ensure_single_input_owner, ensure_valid_recovery_block, validate_block_fee_policy,
      8     verify_address_signature, verify_leader_proof,
      9 };
     10 use super::ledger_v2::{
     11     apply_transaction_v2_with_lineage, decode_canonical_transaction_v2_envelope,
     12 };
     13 use super::mine_policy::ensure_mine_anchor_limit;
     14 use super::ticket::{
     15     apply_finalizer_ticket_effects, ticket_block_min_timestamp, tickets_created_by_block,
     16 };
     17 use super::transaction::transaction_inputs_available;
     18 use super::{
     19     AddressNetwork, AddressVersion, Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block,
     20     BurnBundleSection, FinalityCheckpoint, FinalizerMode, Ledger,
     21     MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT,
     22     Transaction, ensure_transaction_v2_active, hex_encode, insert_output_with_lineage,
     23     output_lineage_root_for_transaction, spend_inputs_with_lineage, unix_now_ms, verify_vdf,
     24 };
     25 
     26 impl Ledger {
     27     pub fn apply_block(&mut self, block: Block) -> Result<()> {
     28         self.apply_block_at(block, unix_now_ms())
     29     }
     30 
     31     pub(crate) fn apply_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
     32         self.apply_block_with_vdf_policy(block, true, now_ms)
     33     }
     34 
     35     pub(crate) fn block_requires_vdf_verification_at(
     36         &self,
     37         block: &Block,
     38         now_ms: u64,
     39     ) -> Result<bool> {
     40         self.precheck_block_without_vdf_at(block, now_ms)
     41     }
     42 
     43     pub fn apply_locally_mined_block(&mut self, block: Block) -> Result<()> {
     44         self.apply_self_produced_block_at(block, unix_now_ms())
     45     }
     46 
     47     pub(crate) fn apply_self_produced_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
     48         self.verify_self_produced_block_at(&block, now_ms)?;
     49         self.apply_preverified_block_at(block, now_ms)
     50     }
     51 
     52     pub(crate) fn verify_self_produced_block_at(&self, block: &Block, now_ms: u64) -> Result<()> {
     53         let mut verifier = self.clone();
     54         verifier.apply_preverified_block_at(block.clone(), now_ms)?;
     55         Ok(())
     56     }
     57 
     58     pub(crate) fn apply_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> {
     59         self.apply_block_with_vdf_policy(block, false, now_ms)
     60     }
     61 
     62     fn apply_block_with_vdf_policy(
     63         &mut self,
     64         block: Block,
     65         should_verify_vdf: bool,
     66         now_ms: u64,
     67     ) -> Result<()> {
     68         if !self.precheck_block_without_vdf_at(&block, now_ms)? {
     69             return Ok(());
     70         }
     71 
     72         if should_verify_vdf && !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output)
     73         {
     74             bail!("block VDF output is invalid");
     75         }
     76 
     77         let reward_committee = self.burn_committee_for_block(&block);
     78         let certified_parent = self
     79             .block_certifies_parent(&block, reward_committee.len())
     80             .then(|| FinalityCheckpoint {
     81                 height: self.tip().height,
     82                 hash: self.tip().hash.clone(),
     83             });
     84         let mut utxos = self.utxos.clone();
     85         let mut utxo_lineage = self.utxo_lineage.clone();
     86         let mut lineage_values = self.lineage_values.clone();
     87         let mut lineage_owners = self.lineage_owners.clone();
     88         let signing_domain = self.transaction_signing_domain_at(block.height);
     89         let mut signatures = BTreeSet::new();
     90         for tx in &block.transactions {
     91             if !signatures.insert(tx.signature()) {
     92                 bail!("duplicate transaction in block");
     93             }
     94             self.validate_transaction_terms(tx)?;
     95             self.validate_transaction_anchor_for_block(tx, block.height)?;
     96             apply_transaction_with_lineage(
     97                 tx,
     98                 block.height,
     99                 &mut utxos,
    100                 &mut utxo_lineage,
    101                 &mut lineage_values,
    102                 &mut lineage_owners,
    103                 Some(&signing_domain),
    104             )?;
    105         }
    106         let transaction_v2_domain = self.transaction_v2_domain()?;
    107         let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
    108         let mut transaction_v2_ids = BTreeSet::new();
    109         for envelope in &block.transactions_v2 {
    110             let transaction =
    111                 decode_canonical_transaction_v2_envelope(envelope, &transaction_v2_domain)?;
    112             self.validate_transaction_v2_anchor_for_block(&transaction, block.height)?;
    113             let transaction_id = hex_encode(transaction.transaction_id(&transaction_v2_domain)?);
    114             if !transaction_v2_ids.insert(transaction_id) {
    115                 bail!("duplicate transaction v2 in block");
    116             }
    117             apply_transaction_v2_with_lineage(
    118                 &transaction,
    119                 &transaction_v2_domain,
    120                 network,
    121                 &mut utxos,
    122                 &mut utxo_lineage,
    123                 &mut lineage_values,
    124                 &mut lineage_owners,
    125                 true,
    126             )?;
    127         }
    128         let expected_reward = self.expected_reward_for_block(&block)?;
    129         if block.reward != expected_reward {
    130             bail!("block reward is invalid");
    131         }
    132         let mined_signatures = block
    133             .transactions
    134             .iter()
    135             .map(|tx| tx.signature().to_string())
    136             .collect::<BTreeSet<_>>();
    137         let mut tickets = self.tickets.clone();
    138         apply_finalizer_ticket_effects(self.tip(), &block, &mut tickets)?;
    139         tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?);
    140         credit_reward_outputs(&mut utxos, &block, &reward_committee)?;
    141         self.compact_block_context.append_block(&block)?;
    142         self.utxos = utxos;
    143         self.utxo_lineage = utxo_lineage;
    144         self.lineage_values = lineage_values;
    145         self.lineage_owners = lineage_owners;
    146         self.tickets = tickets;
    147         self.mined_transaction_ids
    148             .extend(mined_signatures.iter().cloned());
    149         self.mined_transaction_ids
    150             .extend(transaction_v2_ids.iter().cloned());
    151         self.chain.push(block);
    152         self.update_mine_difficulty_cache_after_tip();
    153         if let Some(checkpoint) = certified_parent {
    154             self.objective_finality_checkpoint = Some(checkpoint);
    155         }
    156         let available = self.utxos.clone();
    157         let pending = std::mem::take(&mut self.pending);
    158         self.pending = pending
    159             .into_iter()
    160             .filter(|tx| {
    161                 !mined_signatures.contains(tx.signature())
    162                     && transaction_inputs_available(tx, &available)
    163                     && self.validate_transaction_terms(tx).is_ok()
    164                     && self.validate_transaction_anchor_for_pending(tx).is_ok()
    165                     && tx
    166                         .verify_signature(&self.transaction_signing_domain_for_pending(tx))
    167                         .is_ok()
    168             })
    169             .collect();
    170         let orphans = std::mem::take(&mut self.orphans);
    171         self.orphans = orphans
    172             .into_iter()
    173             .filter(|tx| {
    174                 !mined_signatures.contains(tx.signature())
    175                     && self.validate_transaction_terms(tx).is_ok()
    176                     && self.validate_transaction_anchor_for_pending(tx).is_ok()
    177                     && tx
    178                         .verify_signature(&self.transaction_signing_domain_for_pending(tx))
    179                         .is_ok()
    180             })
    181             .collect();
    182         self.refresh_pending_pool_byte_counters()?;
    183         self.promote_orphan_transactions()?;
    184         self.revalidate_pending_v2()?;
    185         self.vdf_rounds = self.next_vdf_rounds_after_tip();
    186         Ok(())
    187     }
    188 
    189     /// Rebuild derived state from a block that this node previously validated and persisted.
    190     /// This is deliberately private to snapshot restoration: network and newly produced blocks
    191     /// must always use one of the validating apply paths above.
    192     pub(super) fn apply_trusted_block_at(&mut self, block: Block) -> Result<()> {
    193         debug_assert!(self.pending.is_empty() && self.orphans.is_empty());
    194 
    195         let reward_committee = self.burn_committee_for_block(&block);
    196         let certified_parent = self
    197             .block_certifies_parent(&block, reward_committee.len())
    198             .then(|| FinalityCheckpoint {
    199                 height: self.tip().height,
    200                 hash: self.tip().hash.clone(),
    201             });
    202         for transaction in &block.transactions {
    203             apply_transaction_with_lineage(
    204                 transaction,
    205                 block.height,
    206                 &mut self.utxos,
    207                 &mut self.utxo_lineage,
    208                 &mut self.lineage_values,
    209                 &mut self.lineage_owners,
    210                 None,
    211             )?;
    212         }
    213         let transaction_v2_domain = self.transaction_v2_domain()?;
    214         let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id);
    215         let mut transaction_v2_ids = BTreeSet::new();
    216         for envelope in &block.transactions_v2 {
    217             let transaction =
    218                 decode_canonical_transaction_v2_envelope(envelope, &transaction_v2_domain)?;
    219             let transaction_id = hex_encode(transaction.transaction_id(&transaction_v2_domain)?);
    220             apply_transaction_v2_with_lineage(
    221                 &transaction,
    222                 &transaction_v2_domain,
    223                 network,
    224                 &mut self.utxos,
    225                 &mut self.utxo_lineage,
    226                 &mut self.lineage_values,
    227                 &mut self.lineage_owners,
    228                 false,
    229             )?;
    230             transaction_v2_ids.insert(transaction_id);
    231         }
    232 
    233         let mined_signatures = block
    234             .transactions
    235             .iter()
    236             .map(|transaction| transaction.signature().to_string())
    237             .collect::<BTreeSet<_>>();
    238         let parent = self.tip().clone();
    239         apply_finalizer_ticket_effects(&parent, &block, &mut self.tickets)?;
    240         self.tickets
    241             .extend(tickets_created_by_block(&block, &self.launch_profile)?);
    242         credit_reward_outputs(&mut self.utxos, &block, &reward_committee)?;
    243         self.compact_block_context.append_trusted_block(&block)?;
    244         self.mined_transaction_ids.extend(mined_signatures);
    245         self.mined_transaction_ids.extend(transaction_v2_ids);
    246         self.chain.push(block);
    247         self.update_mine_difficulty_cache_after_tip();
    248         if let Some(checkpoint) = certified_parent {
    249             self.objective_finality_checkpoint = Some(checkpoint);
    250         }
    251         self.vdf_rounds = self.next_vdf_rounds_after_tip();
    252         Ok(())
    253     }
    254 
    255     fn ensure_block_transactions_are_not_replays(&self, block: &Block) -> Result<()> {
    256         if block.height < TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT {
    257             return Ok(());
    258         }
    259         if block
    260             .transactions
    261             .iter()
    262             .any(|transaction| self.mined_transaction_ids.contains(transaction.signature()))
    263         {
    264             bail!("block replays a previously mined transaction");
    265         }
    266         let domain = self.transaction_v2_domain()?;
    267         let mut transaction_v2_ids = BTreeSet::new();
    268         for envelope in &block.transactions_v2 {
    269             let transaction = decode_canonical_transaction_v2_envelope(envelope, &domain)?;
    270             let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
    271             if !transaction_v2_ids.insert(transaction_id.clone()) {
    272                 bail!("duplicate transaction v2 in block");
    273             }
    274             if self.mined_transaction_ids.contains(&transaction_id) {
    275                 bail!("block replays a previously mined transaction v2");
    276             }
    277         }
    278         Ok(())
    279     }
    280 
    281     fn precheck_block_without_vdf_at(&self, block: &Block, now_ms: u64) -> Result<bool> {
    282         if block.height <= self.tip().height {
    283             let existing = self
    284                 .chain
    285                 .get(block.height as usize)
    286                 .with_context(|| format!("local chain has no block at height {}", block.height))?;
    287             if existing.hash == block.hash {
    288                 return Ok(false);
    289             }
    290             return Err(super::ValidationError::BlockConflictsWithLocalChain {
    291                 height: block.height,
    292             }
    293             .into());
    294         }
    295 
    296         let expected_height = self.tip().height + 1;
    297         if block.height != expected_height {
    298             return Err(super::ValidationError::UnexpectedBlockHeight {
    299                 expected: expected_height,
    300                 actual: block.height,
    301             }
    302             .into());
    303         }
    304         if block.prev_hash != self.tip().hash {
    305             return Err(super::ValidationError::BlockDoesNotExtendLocalTip.into());
    306         }
    307         if block.compute_hash() != block.hash {
    308             bail!("block hash is invalid");
    309         }
    310         self.validate_reward_address(block.height, block.reward_address.as_deref(), "finalizer")?;
    311         if block.height < super::HYBRID_REWARD_ACTIVATION_HEIGHT {
    312             if block.reward_address_signature.is_some() {
    313                 bail!("finalizer reward address signature is not active yet");
    314             }
    315         } else {
    316             let reward_address = block
    317                 .reward_address
    318                 .as_deref()
    319                 .context("finalizer hybrid reward address is required")?;
    320             let signature = block
    321                 .reward_address_signature
    322                 .as_deref()
    323                 .context("finalizer reward address signature is required")?;
    324             verify_address_signature(
    325                 &block.miner,
    326                 &super::block::reward_address_payload(
    327                     block.height,
    328                     &block.prev_hash,
    329                     &block.miner,
    330                     reward_address,
    331                 ),
    332                 signature,
    333                 "finalizer reward address",
    334             )?;
    335         }
    336         self.ensure_block_transactions_are_not_replays(block)?;
    337         if block.reward != self.expected_reward_for_block(block)? {
    338             bail!("block reward is invalid");
    339         }
    340         let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?;
    341         if block.vdf_rounds != expected_vdf_rounds {
    342             bail!("block VDF rounds are invalid");
    343         }
    344         if block.timestamp_ms <= self.tip().timestamp_ms {
    345             bail!("block timestamp must increase");
    346         }
    347         if block.finalizer_mode == FinalizerMode::Ticket {
    348             let min_timestamp = ticket_block_min_timestamp(self.tip(), block.finalizer_rank)?;
    349             if block.timestamp_ms < min_timestamp {
    350                 return Err(super::ValidationError::BlockBeforeFinalizerRankSlot {
    351                     rank: block.finalizer_rank,
    352                     min_timestamp,
    353                 }
    354                 .into());
    355             }
    356         }
    357         let median_time_past = self.median_time_past();
    358         if block.timestamp_ms <= median_time_past {
    359             bail!("block timestamp must exceed median time past");
    360         }
    361         let max_future_timestamp = now_ms.saturating_add(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS);
    362         if block.timestamp_ms > max_future_timestamp {
    363             return Err(super::ValidationError::BlockTimestampTooFarInFuture.into());
    364         }
    365         if block
    366             .transactions
    367             .len()
    368             .saturating_add(block.transactions_v2.len())
    369             > self.launch_profile.max_block_transactions
    370         {
    371             bail!("block has too many transactions");
    372         }
    373         if !block.transactions_v2.is_empty() {
    374             ensure_transaction_v2_active(block.height)?;
    375         }
    376         if self.consensus_block_size_bytes(block)? > self.launch_profile.max_block_bytes {
    377             bail!("block exceeds max block size");
    378         }
    379         ensure_mine_anchor_limit(block.height, &block.transactions)?;
    380         ensure_block_has_burn(&block.transactions, &block.transactions_v2)?;
    381         validate_block_fee_policy(block)?;
    382         self.validate_burn_bundle_section_for_block(block)?;
    383         match block.finalizer_mode {
    384             FinalizerMode::Ticket => {
    385                 let selected_ticket = self
    386                     .ticket_for_finalizer_rank(block.height, block.finalizer_rank)
    387                     .context("no selected ticket for block finalizer rank")?;
    388                 if selected_ticket.owner != block.miner {
    389                     bail!(
    390                         "block finalizer {} is not selected for rank {}",
    391                         block.miner,
    392                         block.finalizer_rank
    393                     );
    394                 }
    395                 if block
    396                     .leader_proof
    397                     .as_ref()
    398                     .is_none_or(|proof| proof.ticket_id != selected_ticket.id)
    399                 {
    400                     bail!("block does not prove the selected leader ticket");
    401                 }
    402                 verify_leader_proof(block, &self.tickets)?;
    403             }
    404             FinalizerMode::Recovery => {
    405                 ensure_valid_recovery_block(block, self.tip())?;
    406             }
    407         }
    408 
    409         Ok(true)
    410     }
    411 
    412     fn median_time_past(&self) -> u64 {
    413         let mut timestamps = self
    414             .chain
    415             .iter()
    416             .rev()
    417             .take(BLOCK_MEDIAN_TIME_PAST_WINDOW)
    418             .map(|block| block.timestamp_ms)
    419             .collect::<Vec<_>>();
    420         timestamps.sort_unstable();
    421         timestamps[timestamps.len() / 2]
    422     }
    423 
    424     pub(super) fn expected_reward_for_next_block(
    425         &self,
    426         transactions: &[Transaction],
    427         transactions_v2: &[String],
    428         _burn_bundle_section: &BurnBundleSection,
    429     ) -> Result<Amount> {
    430         let v2_fees = self.transaction_v2_fees(transactions_v2)?;
    431         block_reward(transactions, v2_fees)
    432     }
    433 
    434     fn expected_reward_for_block(&self, block: &Block) -> Result<Amount> {
    435         let v2_fees = self.transaction_v2_fees(&block.transactions_v2)?;
    436         block_reward(&block.transactions, v2_fees)
    437     }
    438 
    439     pub(super) fn validate_reward_address(
    440         &self,
    441         height: u64,
    442         address: Option<&str>,
    443         label: &str,
    444     ) -> Result<()> {
    445         if height < super::HYBRID_REWARD_ACTIVATION_HEIGHT {
    446             if address.is_some() {
    447                 bail!(
    448                     "{label} hybrid reward address is not active before height {}",
    449                     super::HYBRID_REWARD_ACTIVATION_HEIGHT
    450                 );
    451             }
    452             return Ok(());
    453         }
    454         let address =
    455             address.with_context(|| format!("{label} hybrid reward address is required"))?;
    456         let decoded = super::decode_versioned_address(
    457             address,
    458             AddressNetwork::from_profile_id(&self.launch_profile.profile_id),
    459         )?;
    460         if decoded.version != AddressVersion::HybridKeyCommitment {
    461             bail!("{label} reward address must use address v1");
    462         }
    463         Ok(())
    464     }
    465 
    466     fn transaction_v2_fees(&self, envelopes: &[String]) -> Result<Amount> {
    467         let domain = self.transaction_v2_domain()?;
    468         envelopes.iter().try_fold(0_u64, |total, envelope| {
    469             let transaction = decode_canonical_transaction_v2_envelope(envelope, &domain)?;
    470             if transaction.fee() == 0 {
    471                 bail!("block transaction v2 fee must be greater than zero");
    472             }
    473             total
    474                 .checked_add(transaction.fee())
    475                 .context("block transaction v2 fees overflow")
    476         })
    477     }
    478 }
    479 
    480 fn apply_transaction_with_lineage(
    481     transaction: &Transaction,
    482     block_height: u64,
    483     utxos: &mut std::collections::BTreeMap<super::OutPoint, super::TxOutput>,
    484     utxo_lineage: &mut std::collections::BTreeMap<super::OutPoint, super::UtxoLineageRoot>,
    485     lineage_values: &mut std::collections::BTreeMap<super::UtxoLineageRoot, Amount>,
    486     lineage_owners: &mut super::LineageOwnerValues,
    487     signing_domain: Option<&super::TransactionSigningDomain>,
    488 ) -> Result<()> {
    489     if let Some(signing_domain) = signing_domain {
    490         transaction.verify_signature(signing_domain)?;
    491     }
    492     if matches!(transaction, Transaction::Mine { .. }) {
    493         let output = transaction.outputs().remove(0);
    494         ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?;
    495         insert_output_with_lineage(
    496             super::OutPoint {
    497                 txid: transaction.signature().to_string(),
    498                 index: 0,
    499             },
    500             output,
    501             output_lineage_root_for_transaction(transaction, block_height, None),
    502             utxos,
    503             utxo_lineage,
    504             lineage_values,
    505             lineage_owners,
    506         )?;
    507         return Ok(());
    508     }
    509 
    510     ensure_single_input_owner(transaction)?;
    511     let (input_total, inherited_root) = spend_inputs_with_lineage(
    512         transaction,
    513         utxos,
    514         utxo_lineage,
    515         lineage_values,
    516         lineage_owners,
    517     )?;
    518     let outputs = transaction.outputs();
    519     let output_total = outputs.iter().try_fold(0_u64, |total, output| {
    520         total
    521             .checked_add(output.amount)
    522             .context("transaction outputs overflow")
    523     })?;
    524     let required = output_total
    525         .checked_add(transaction.fee())
    526         .context("transaction outputs plus fee overflow")?
    527         .checked_add(match transaction {
    528             Transaction::Burn { amount, .. } => *amount,
    529             Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    530         })
    531         .context("transaction outputs plus burn overflow")?;
    532     if input_total != required {
    533         bail!("transaction inputs do not balance outputs, burn, and fee");
    534     }
    535     ensure_outputs_do_not_overflow(utxos, &outputs)?;
    536     for (index, output) in outputs.into_iter().enumerate() {
    537         insert_output_with_lineage(
    538             super::OutPoint {
    539                 txid: transaction.signature().to_string(),
    540                 index: index as u32,
    541             },
    542             output,
    543             inherited_root.clone(),
    544             utxos,
    545             utxo_lineage,
    546             lineage_values,
    547             lineage_owners,
    548         )?;
    549     }
    550     Ok(())
    551 }
    552 
    553 #[cfg(test)]
    554 mod tests {
    555     use std::collections::BTreeMap;
    556 
    557     use super::*;
    558     use crate::domain::{BurnTicket, GenesisBurn, MICRO_IUNA, Wallet, run_vdf};
    559 
    560     fn mine_transaction(signature: &str) -> Transaction {
    561         Transaction::Mine {
    562             recipient: "1".repeat(64),
    563             anchor: "2".repeat(64),
    564             salt: 1,
    565             nonce: 1,
    566             difficulty_bits: 10,
    567             proof_header: None,
    568             signature: signature.to_string(),
    569         }
    570     }
    571 
    572     #[test]
    573     fn historical_mine_replay_is_rejected_from_height_1000() {
    574         let signature = "3".repeat(64);
    575         let mut ledger = Ledger::new(BTreeMap::new(), 1);
    576         ledger.mined_transaction_ids.insert(signature.clone());
    577         let mut block = ledger.tip().clone();
    578         block.transactions = vec![mine_transaction(&signature)];
    579 
    580         block.height = TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT - 1;
    581         ledger
    582             .ensure_block_transactions_are_not_replays(&block)
    583             .unwrap();
    584 
    585         block.height = TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT;
    586         assert!(
    587             ledger
    588                 .ensure_block_transactions_are_not_replays(&block)
    589                 .unwrap_err()
    590                 .to_string()
    591                 .contains("replays a previously mined transaction")
    592         );
    593     }
    594 
    595     #[test]
    596     fn activated_block_validation_rejects_replayed_mine_proof() {
    597         let wallet = Wallet::from_seed("activated-mine-replay-wallet");
    598         let mut ledger = Ledger::new_with_genesis_burns(
    599             BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]),
    600             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    601             1,
    602         )
    603         .unwrap();
    604         ledger.chain.last_mut().unwrap().height =
    605             TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT - 1;
    606         ledger.tickets = vec![BurnTicket {
    607             id: "5".repeat(64),
    608             owner: wallet.address().to_string(),
    609             amount: MICRO_IUNA,
    610             eligible_from_height: TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT,
    611             eligible_until_height: TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT,
    612         }];
    613 
    614         let burn = ledger.build_burn_for_next_block(&wallet, 1, 1).unwrap();
    615         ledger.submit_transaction(burn).unwrap();
    616         let mine = ledger.build_mine(wallet.address()).unwrap();
    617         let replayed_id = mine.signature().to_string();
    618         ledger.submit_transaction(mine).unwrap();
    619         let prepared = ledger.prepare_next_block(wallet.address(), 1).unwrap();
    620         let block = prepared.finish(&wallet, "test-vdf-output".to_string());
    621         assert_eq!(
    622             block.height,
    623             TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT
    624         );
    625 
    626         ledger.mined_transaction_ids.insert(replayed_id);
    627         let error = ledger
    628             .apply_preverified_block_at(block, 1)
    629             .unwrap_err()
    630             .to_string();
    631 
    632         assert!(error.contains("replays a previously mined transaction"));
    633     }
    634 
    635     #[test]
    636     fn queued_burn_survives_one_applied_block_and_is_included_in_the_following_block() {
    637         let finalizer = Wallet::from_seed("queued-burn-finalizer");
    638         let burner = Wallet::from_seed("queued-burn-wallet");
    639         let mut ledger = Ledger::new_with_genesis_burns(
    640             BTreeMap::from([
    641                 (finalizer.address().to_string(), 10 * MICRO_IUNA),
    642                 (burner.address().to_string(), 10 * MICRO_IUNA),
    643             ]),
    644             vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
    645             1,
    646         )
    647         .unwrap();
    648         ledger.chain.last_mut().unwrap().height =
    649             super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 2;
    650         ledger.tickets = vec![BurnTicket {
    651             id: "6".repeat(64),
    652             owner: finalizer.address().to_string(),
    653             amount: MICRO_IUNA,
    654             eligible_from_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1,
    655             eligible_until_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1,
    656         }];
    657 
    658         let queued_burn = ledger.build_burn(&burner, 1, 1).unwrap();
    659         assert_eq!(queued_burn.burn_anchor(), Some(ledger.tip_hash()));
    660         ledger.submit_transaction(queued_burn.clone()).unwrap();
    661         let legacy_anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
    662         assert_eq!(legacy_anchor.burn_anchor(), None);
    663         ledger.submit_transaction(legacy_anchor).unwrap();
    664 
    665         let prepared = ledger.prepare_next_block(finalizer.address(), 2).unwrap();
    666         let first_block = prepared.finish(&finalizer, "first-vdf-output".to_string());
    667         assert!(
    668             first_block
    669                 .transactions
    670                 .iter()
    671                 .all(|transaction| transaction.signature() != queued_burn.signature())
    672         );
    673         ledger
    674             .apply_preverified_block_at(first_block, u64::MAX)
    675             .unwrap();
    676 
    677         assert!(
    678             ledger
    679                 .pending()
    680                 .iter()
    681                 .any(|transaction| transaction.signature() == queued_burn.signature())
    682         );
    683         assert!(ledger.transaction_is_eligible_for_next_block(&queued_burn));
    684 
    685         ledger.tickets.push(BurnTicket {
    686             id: "7".repeat(64),
    687             owner: finalizer.address().to_string(),
    688             amount: MICRO_IUNA,
    689             eligible_from_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT,
    690             eligible_until_height: super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT,
    691         });
    692         let activated_anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
    693         assert_eq!(
    694             activated_anchor.burn_anchor(),
    695             Some(ledger.tip().prev_hash.as_str())
    696         );
    697         ledger.submit_transaction(activated_anchor).unwrap();
    698 
    699         let prepared = ledger.prepare_next_block(finalizer.address(), 3).unwrap();
    700         let activated_block = prepared.finish(&finalizer, "second-vdf-output".to_string());
    701         assert_eq!(
    702             activated_block.height,
    703             super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT
    704         );
    705         assert!(
    706             activated_block
    707                 .transactions
    708                 .iter()
    709                 .any(|transaction| transaction.signature() == queued_burn.signature())
    710         );
    711         ledger
    712             .apply_preverified_block_at(activated_block, u64::MAX)
    713             .unwrap();
    714         assert!(
    715             ledger
    716                 .pending()
    717                 .iter()
    718                 .all(|transaction| transaction.signature() != queued_burn.signature())
    719         );
    720     }
    721 
    722     #[test]
    723     fn applied_blocks_and_snapshot_restore_index_mined_transaction_ids() {
    724         let wallet = Wallet::from_seed("replay-index-genesis-wallet");
    725         let mut ledger = Ledger::new_with_genesis_burns(
    726             BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]),
    727             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    728             1,
    729         )
    730         .unwrap();
    731         let genesis_transaction_id = ledger.chain[0].transactions[0].signature().to_string();
    732         let burn = ledger.build_burn(&wallet, 1, 1).unwrap();
    733         ledger.submit_transaction(burn).unwrap();
    734         let mine = ledger.build_mine(wallet.address()).unwrap();
    735         let mine_transaction_id = mine.signature().to_string();
    736         ledger.submit_transaction(mine).unwrap();
    737         let prepared = ledger.prepare_next_block(wallet.address(), 1).unwrap();
    738         let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
    739         let block = prepared.finish(&wallet, vdf_output);
    740         ledger.apply_preverified_block_at(block, 1).unwrap();
    741 
    742         assert!(
    743             ledger
    744                 .mined_transaction_ids
    745                 .contains(&genesis_transaction_id)
    746         );
    747         assert!(ledger.mined_transaction_ids.contains(&mine_transaction_id));
    748         let restored = Ledger::from_persisted_snapshot(ledger.snapshot()).unwrap();
    749         assert!(
    750             restored
    751                 .mined_transaction_ids
    752                 .contains(&genesis_transaction_id)
    753         );
    754         assert!(
    755             restored
    756                 .mined_transaction_ids
    757                 .contains(&mine_transaction_id)
    758         );
    759         assert!(restored.has_transaction(&genesis_transaction_id));
    760         assert!(restored.has_transaction(&mine_transaction_id));
    761     }
    762 
    763     #[test]
    764     fn median_time_past_uses_the_median_of_the_latest_eleven_blocks() {
    765         let mut ledger = Ledger::new(BTreeMap::new(), 1);
    766         let template = ledger.tip().clone();
    767         let timestamps = [5, 500, 20, 400, 30, 300, 40, 200, 50, 100, 60, 1_000];
    768         ledger.chain = timestamps
    769             .into_iter()
    770             .enumerate()
    771             .map(|(index, timestamp_ms)| {
    772                 let mut block = template.clone();
    773                 block.height = index as u64;
    774                 block.timestamp_ms = timestamp_ms;
    775                 block.hash = format!("{:064x}", index + 1);
    776                 block
    777             })
    778             .collect();
    779 
    780         // The oldest value (5) falls outside the 11-block window. The sorted
    781         // window is 20,30,40,50,60,100,200,300,400,500,1000.
    782         assert_eq!(ledger.median_time_past(), 100);
    783     }
    784 }