iuna

iuna

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

ledger_chain.rs (32115B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use anyhow::{Result, bail};
      4 
      5 use crate::compact::CompactBlockContext;
      6 
      7 use super::fork::{FinalityCheckpoint, ForkChoice, ForkPoint, ForkQuality};
      8 use super::genesis::{build_genesis_block, utxos_after_genesis, validate_genesis_block};
      9 use super::ledger_ops::validate_genesis_allocations;
     10 use super::ticket::genesis_tickets;
     11 use super::{
     12     Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MINE_REWARD,
     13     OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, unix_now_ms,
     14 };
     15 
     16 impl Ledger {
     17     pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u64) -> Self {
     18         Self::new_with_genesis_transactions(
     19             genesis_allocations,
     20             Vec::new(),
     21             vdf_rounds,
     22             LaunchProfile::default(),
     23         )
     24         .expect("empty genesis transactions are valid")
     25     }
     26 
     27     pub fn new_with_genesis_burns(
     28         genesis_allocations: BTreeMap<String, Amount>,
     29         genesis_burns: Vec<GenesisBurn>,
     30         vdf_rounds: u64,
     31     ) -> Result<Self> {
     32         Self::new_with_genesis_burns_and_profile(
     33             genesis_allocations,
     34             genesis_burns,
     35             vdf_rounds,
     36             LaunchProfile::default(),
     37         )
     38     }
     39 
     40     pub fn new_with_genesis_burns_and_profile(
     41         genesis_allocations: BTreeMap<String, Amount>,
     42         genesis_burns: Vec<GenesisBurn>,
     43         vdf_rounds: u64,
     44         launch_profile: LaunchProfile,
     45     ) -> Result<Self> {
     46         let transactions = genesis_burns
     47             .into_iter()
     48             .map(|burn| {
     49                 let allocation = genesis_allocations
     50                     .get(&burn.from)
     51                     .copied()
     52                     .unwrap_or_default();
     53                 Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation)
     54             })
     55             .collect::<Result<Vec<_>>>()?;
     56         Self::new_with_genesis_transactions(
     57             genesis_allocations,
     58             transactions,
     59             vdf_rounds,
     60             launch_profile,
     61         )
     62     }
     63 
     64     fn new_with_genesis_transactions(
     65         genesis_allocations: BTreeMap<String, Amount>,
     66         genesis_transactions: Vec<Transaction>,
     67         vdf_rounds: u64,
     68         launch_profile: LaunchProfile,
     69     ) -> Result<Self> {
     70         validate_genesis_allocations(&genesis_allocations)?;
     71         let genesis = build_genesis_block(&genesis_allocations, genesis_transactions);
     72         let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?;
     73         let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?;
     74         let compact_block_context = if genesis_allocations.is_empty() {
     75             CompactBlockContext::default()
     76         } else {
     77             CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?
     78         };
     79         let mined_transaction_ids = genesis
     80             .transactions
     81             .iter()
     82             .map(|transaction| transaction.signature().to_string())
     83             .collect();
     84         Ok(Self {
     85             chain: vec![genesis],
     86             genesis_allocations: genesis_allocations.clone(),
     87             utxos,
     88             utxo_lineage: BTreeMap::new(),
     89             lineage_values: BTreeMap::new(),
     90             lineage_owners: BTreeMap::new(),
     91             tickets,
     92             mined_transaction_ids,
     93             pending: Vec::new(),
     94             orphans: Vec::new(),
     95             pending_bytes: 0,
     96             orphan_bytes: 0,
     97             pending_v2: Vec::new(),
     98             pending_v2_bytes: 0,
     99             mine_reward: MINE_REWARD,
    100             mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits],
    101             initial_vdf_rounds: vdf_rounds,
    102             vdf_rounds,
    103             launch_profile,
    104             compact_block_context,
    105             objective_finality_checkpoint: None,
    106         })
    107     }
    108 
    109     pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
    110         Self::from_snapshot_at(snapshot, unix_now_ms())
    111     }
    112 
    113     pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
    114         Self::from_persisted_snapshot_revalidating_from(snapshot, Some(1))
    115     }
    116 
    117     /// Restore a local snapshot and only revalidate blocks at or above the supplied height.
    118     /// `None` trusts every persisted block while rebuilding its derived in-memory state.
    119     pub fn from_persisted_snapshot_revalidating_from(
    120         snapshot: ChainSnapshot,
    121         revalidate_from_height: Option<u64>,
    122     ) -> Result<Self> {
    123         let verify_vdf_from_height = if cfg!(feature = "e2e")
    124             && snapshot.launch_profile.profile_id == LaunchProfile::local_testnet().profile_id
    125         {
    126             None
    127         } else {
    128             revalidate_from_height
    129         };
    130         let trusted_before_height = revalidate_from_height.unwrap_or(u64::MAX);
    131         Self::from_snapshot_with_revalidation_policy(
    132             snapshot,
    133             Some(trusted_before_height),
    134             revalidate_from_height,
    135             verify_vdf_from_height,
    136             u64::MAX,
    137         )
    138     }
    139 
    140     /// Restore state from a local snapshot already trusted under the current consensus ruleset.
    141     pub fn from_locally_verified_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
    142         Self::from_persisted_snapshot_revalidating_from(snapshot, None)
    143     }
    144 
    145     pub(crate) fn from_preverified_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
    146         Self::from_preverified_snapshot_at(snapshot, u64::MAX)
    147     }
    148 
    149     pub(crate) fn from_preverified_snapshot_at(
    150         snapshot: ChainSnapshot,
    151         now_ms: u64,
    152     ) -> Result<Self> {
    153         Self::from_snapshot_with_vdf_policy(snapshot, false, now_ms)
    154     }
    155 
    156     pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> {
    157         Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms)
    158     }
    159 
    160     pub(crate) fn from_snapshot_with_vdf_policy(
    161         snapshot: ChainSnapshot,
    162         verify_vdf: bool,
    163         now_ms: u64,
    164     ) -> Result<Self> {
    165         Self::from_snapshot_with_revalidation_policy(
    166             snapshot,
    167             None,
    168             Some(0),
    169             verify_vdf.then_some(0),
    170             now_ms,
    171         )
    172     }
    173 
    174     fn from_snapshot_with_revalidation_policy(
    175         snapshot: ChainSnapshot,
    176         trusted_before_height: Option<u64>,
    177         revalidate_from_height: Option<u64>,
    178         verify_vdf_from_height: Option<u64>,
    179         now_ms: u64,
    180     ) -> Result<Self> {
    181         let ChainSnapshot {
    182             genesis_allocations,
    183             vdf_rounds,
    184             launch_profile,
    185             blocks,
    186         } = snapshot;
    187 
    188         if blocks.is_empty() {
    189             bail!("chain snapshot is empty");
    190         }
    191 
    192         validate_genesis_allocations(&genesis_allocations)?;
    193         let genesis = blocks[0].clone();
    194         validate_genesis_block(&genesis)?;
    195         let expected_genesis =
    196             build_genesis_block(&genesis_allocations, genesis.transactions.clone());
    197         if genesis != expected_genesis {
    198             bail!("chain snapshot genesis does not match its allocations and transactions");
    199         }
    200         let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?;
    201         let compact_block_context =
    202             CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?;
    203         let mined_transaction_ids = genesis
    204             .transactions
    205             .iter()
    206             .map(|transaction| transaction.signature().to_string())
    207             .collect();
    208 
    209         let mut ledger = Self {
    210             chain: vec![genesis],
    211             genesis_allocations,
    212             utxos,
    213             utxo_lineage: BTreeMap::new(),
    214             lineage_values: BTreeMap::new(),
    215             lineage_owners: BTreeMap::new(),
    216             tickets: Vec::new(),
    217             mined_transaction_ids,
    218             pending: Vec::new(),
    219             orphans: Vec::new(),
    220             pending_bytes: 0,
    221             orphan_bytes: 0,
    222             pending_v2: Vec::new(),
    223             pending_v2_bytes: 0,
    224             mine_reward: MINE_REWARD,
    225             mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits],
    226             initial_vdf_rounds: vdf_rounds,
    227             vdf_rounds,
    228             launch_profile,
    229             compact_block_context,
    230             objective_finality_checkpoint: None,
    231         };
    232         ledger.tickets = genesis_tickets(
    233             &ledger.genesis_allocations,
    234             ledger.tip(),
    235             &ledger.launch_profile,
    236         )?;
    237 
    238         for block in blocks.into_iter().skip(1) {
    239             if trusted_before_height.is_some_and(|height| block.height < height) {
    240                 ledger.apply_trusted_block_at(block)?;
    241             } else if revalidate_from_height.is_some_and(|height| block.height >= height) {
    242                 if verify_vdf_from_height.is_some_and(|height| block.height >= height) {
    243                     ledger.apply_block_at(block, now_ms)?;
    244                 } else {
    245                     ledger.apply_preverified_block_at(block, now_ms)?;
    246                 }
    247             } else {
    248                 ledger.apply_preverified_block_at(block, now_ms)?;
    249             }
    250         }
    251         Ok(ledger)
    252     }
    253 
    254     pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> {
    255         self.extend_from_snapshot_with_vdf_policy(snapshot, true, unix_now_ms())
    256     }
    257 
    258     /// Applies normal snapshot identity, consensus, finality, and fork-choice checks while
    259     /// skipping only VDF verification. Accelerated e2e tests verify newly produced proofs
    260     /// separately because their committed fixture uses the production VDF round count.
    261     #[cfg(feature = "e2e")]
    262     pub fn extend_from_preverified_snapshot_for_e2e(
    263         &mut self,
    264         snapshot: ChainSnapshot,
    265     ) -> Result<bool> {
    266         self.extend_from_preverified_snapshot_at(snapshot, u64::MAX)
    267     }
    268 
    269     /// Applies all normal snapshot and fork-choice validation while skipping only VDF proof
    270     /// verification. Callers must verify every untrusted block proof separately before adopting
    271     /// the returned ledger.
    272     pub(crate) fn extend_from_preverified_snapshot_at(
    273         &mut self,
    274         snapshot: ChainSnapshot,
    275         now_ms: u64,
    276     ) -> Result<bool> {
    277         self.extend_from_snapshot_with_vdf_policy(snapshot, false, now_ms)
    278     }
    279 
    280     #[allow(dead_code)]
    281     pub(crate) fn extend_from_snapshot_at(
    282         &mut self,
    283         snapshot: ChainSnapshot,
    284         now_ms: u64,
    285     ) -> Result<bool> {
    286         self.extend_from_snapshot_with_vdf_policy(snapshot, true, now_ms)
    287     }
    288 
    289     pub(crate) fn extend_from_snapshot_with_vdf_policy(
    290         &mut self,
    291         snapshot: ChainSnapshot,
    292         verify_vdf: bool,
    293         now_ms: u64,
    294     ) -> Result<bool> {
    295         self.validate_snapshot_identity(&snapshot)?;
    296         let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, now_ms)?;
    297         let fork_point = self.fork_point_with_candidate(&candidate)?;
    298 
    299         if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal {
    300             return Ok(false);
    301         }
    302 
    303         self.replace_with_better_chain(candidate, fork_point);
    304 
    305         Ok(true)
    306     }
    307 
    308     fn validate_snapshot_identity(&self, snapshot: &ChainSnapshot) -> Result<u64> {
    309         if snapshot.blocks.is_empty() {
    310             bail!("chain snapshot is empty");
    311         }
    312         if snapshot.vdf_rounds != self.initial_vdf_rounds {
    313             bail!("chain snapshot initial VDF rounds do not match local chain");
    314         }
    315         if snapshot.launch_profile != self.launch_profile {
    316             bail!("chain snapshot launch profile does not match local chain");
    317         }
    318         if snapshot.genesis_allocations != self.genesis_allocations {
    319             bail!("chain snapshot genesis allocations do not match local chain");
    320         }
    321         if snapshot.blocks[0].hash != self.genesis_hash() {
    322             bail!("chain snapshot genesis does not match local chain");
    323         }
    324 
    325         let remote_height = snapshot
    326             .blocks
    327             .last()
    328             .map(|block| block.height)
    329             .unwrap_or(0);
    330 
    331         Ok(remote_height)
    332     }
    333 
    334     fn fork_point_with_candidate(&self, candidate: &Ledger) -> Result<ForkPoint> {
    335         if candidate.genesis_hash() != self.genesis_hash() {
    336             bail!("candidate chain has no common genesis block");
    337         }
    338         let max_common_index = self.chain.len().min(candidate.chain.len()) - 1;
    339         for index in 0..=max_common_index {
    340             if self.chain[index] != candidate.chain[index] {
    341                 if index == 0 {
    342                     bail!("candidate chain has no common genesis block");
    343                 }
    344                 return Ok(ForkPoint {
    345                     common_ancestor_height: index as u64 - 1,
    346                 });
    347             }
    348         }
    349         Ok(ForkPoint {
    350             common_ancestor_height: max_common_index as u64,
    351         })
    352     }
    353 
    354     fn choose_fork(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkChoice {
    355         let local_height = self.height();
    356         let remote_height = candidate.height();
    357         if remote_height == local_height && candidate.tip().hash == self.tip().hash {
    358             return ForkChoice::KeepLocal;
    359         }
    360 
    361         if fork_point.first_diverging_height() < OBJECTIVE_FINALITY_ACTIVATION_HEIGHT {
    362             if local_height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
    363                 || fork_rewrites_finalized_history(local_height, fork_point.common_ancestor_height)
    364             {
    365                 return ForkChoice::KeepLocal;
    366             }
    367         } else {
    368             match objective_finality_quality(
    369                 self.objective_finality_checkpoint.as_ref(),
    370                 candidate.objective_finality_checkpoint.as_ref(),
    371             ) {
    372                 ForkQuality::RemoteBetter => return ForkChoice::SwitchToCandidate,
    373                 ForkQuality::LocalBetter => return ForkChoice::KeepLocal,
    374                 ForkQuality::Equal => {}
    375             }
    376         }
    377 
    378         if remote_height > local_height {
    379             return ForkChoice::SwitchToCandidate;
    380         }
    381         if remote_height < local_height {
    382             return ForkChoice::KeepLocal;
    383         }
    384 
    385         match self.fork_quality(candidate, fork_point) {
    386             ForkQuality::RemoteBetter => ForkChoice::SwitchToCandidate,
    387             ForkQuality::LocalBetter | ForkQuality::Equal => ForkChoice::KeepLocal,
    388         }
    389     }
    390 
    391     fn fork_quality(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkQuality {
    392         let local_fork = self
    393             .chain
    394             .iter()
    395             .skip(fork_point.first_diverging_height() as usize);
    396         let remote_fork = candidate
    397             .chain
    398             .iter()
    399             .skip(fork_point.first_diverging_height() as usize);
    400         for (local, remote) in local_fork.zip(remote_fork) {
    401             match local.leader_score().cmp(&remote.leader_score()) {
    402                 std::cmp::Ordering::Equal => continue,
    403                 ordering => return ForkQuality::from(ordering),
    404             }
    405         }
    406         ForkQuality::Equal
    407     }
    408 
    409     fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) {
    410         let mut carry_forward = self.pending.clone();
    411         carry_forward.extend(self.orphans.clone());
    412         for block in self
    413             .chain
    414             .iter()
    415             .skip(fork_point.first_diverging_height() as usize)
    416         {
    417             carry_forward.extend(block.transactions.clone());
    418         }
    419 
    420         let mined_signatures = candidate
    421             .chain
    422             .iter()
    423             .flat_map(|block| block.transactions.iter())
    424             .map(|tx| tx.signature().to_string())
    425             .collect::<BTreeSet<_>>();
    426         for transaction in carry_forward {
    427             if !mined_signatures.contains(transaction.signature()) {
    428                 let _ = candidate.submit_transaction(transaction);
    429             }
    430         }
    431 
    432         let mut carry_forward_v2 = Vec::new();
    433         for block in self
    434             .chain
    435             .iter()
    436             .skip(fork_point.first_diverging_height() as usize)
    437         {
    438             for envelope in &block.transactions_v2 {
    439                 if let Ok(bytes) = super::decode_hex(envelope)
    440                     && let Ok(transaction) = self.decode_transaction_v2(&bytes)
    441                 {
    442                     carry_forward_v2.push(transaction);
    443                 }
    444             }
    445         }
    446         carry_forward_v2.extend(self.pending_v2.clone());
    447         let candidate_domain = candidate.transaction_v2_domain().ok();
    448         for transaction in carry_forward_v2 {
    449             let already_mined = candidate_domain.as_ref().is_some_and(|domain| {
    450                 transaction
    451                     .transaction_id(domain)
    452                     .map(super::hex_encode)
    453                     .is_ok_and(|id| candidate.mined_transaction_ids.contains(&id))
    454             });
    455             if !already_mined {
    456                 let _ = candidate.submit_transaction_v2(transaction);
    457             }
    458         }
    459 
    460         *self = candidate;
    461     }
    462 }
    463 
    464 fn objective_finality_quality(
    465     local: Option<&FinalityCheckpoint>,
    466     remote: Option<&FinalityCheckpoint>,
    467 ) -> ForkQuality {
    468     match (local, remote) {
    469         (None, None) => ForkQuality::Equal,
    470         (None, Some(_)) => ForkQuality::RemoteBetter,
    471         (Some(_), None) => ForkQuality::LocalBetter,
    472         (Some(local), Some(remote)) => match local.height.cmp(&remote.height) {
    473             std::cmp::Ordering::Less => ForkQuality::RemoteBetter,
    474             std::cmp::Ordering::Greater => ForkQuality::LocalBetter,
    475             std::cmp::Ordering::Equal if local.hash == remote.hash => ForkQuality::Equal,
    476             // A conflicting certificate is a safety failure. The canonical hash ordering
    477             // nevertheless gives every honest node the same recovery decision.
    478             std::cmp::Ordering::Equal if remote.hash < local.hash => ForkQuality::RemoteBetter,
    479             std::cmp::Ordering::Equal => ForkQuality::LocalBetter,
    480         },
    481     }
    482 }
    483 
    484 fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u64) -> bool {
    485     let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH);
    486     common_ancestor_height < finalized_floor
    487 }
    488 
    489 #[cfg(test)]
    490 mod tests {
    491     use std::collections::BTreeMap;
    492 
    493     use super::{
    494         ForkChoice, ForkPoint, ForkQuality, fork_rewrites_finalized_history,
    495         objective_finality_quality,
    496     };
    497     use crate::domain::{
    498         FORK_FINALITY_DEPTH, FinalityCheckpoint, LaunchProfile, Ledger, StratumMineShare,
    499         TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, Wallet,
    500     };
    501 
    502     fn profile(profile_id: &str) -> LaunchProfile {
    503         LaunchProfile {
    504             profile_id: profile_id.to_string(),
    505             mine_difficulty_bits: 0,
    506             ..LaunchProfile::default()
    507         }
    508     }
    509 
    510     fn ledger_with_profile(allocations: BTreeMap<String, u64>, profile_id: &str) -> Ledger {
    511         Ledger::new_with_genesis_burns_and_profile(allocations, Vec::new(), 1, profile(profile_id))
    512             .unwrap()
    513     }
    514 
    515     fn set_next_height(ledger: &mut Ledger, next_height: u64) {
    516         ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1);
    517     }
    518 
    519     #[test]
    520     fn forks_may_rewrite_six_blocks_but_not_seven() {
    521         assert_eq!(FORK_FINALITY_DEPTH, 6);
    522         assert!(!fork_rewrites_finalized_history(100, 94));
    523         assert!(fork_rewrites_finalized_history(100, 93));
    524         assert!(!fork_rewrites_finalized_history(5, 0));
    525     }
    526 
    527     #[test]
    528     fn higher_objective_checkpoint_wins_even_when_candidate_is_shorter() {
    529         let mut local = Ledger::new(BTreeMap::new(), 1);
    530         let mut candidate = local.clone();
    531         local.chain.last_mut().unwrap().height = 1_012;
    532         local.chain.last_mut().unwrap().hash = "local-tip".to_string();
    533         local.objective_finality_checkpoint = Some(FinalityCheckpoint {
    534             height: 1_004,
    535             hash: "local-finalized".to_string(),
    536         });
    537         candidate.chain.last_mut().unwrap().height = 1_008;
    538         candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string();
    539         candidate.objective_finality_checkpoint = Some(FinalityCheckpoint {
    540             height: 1_007,
    541             hash: "remote-finalized".to_string(),
    542         });
    543 
    544         assert_eq!(
    545             local.choose_fork(
    546                 &candidate,
    547                 ForkPoint {
    548                     common_ancestor_height: 1_000,
    549                 },
    550             ),
    551             ForkChoice::SwitchToCandidate
    552         );
    553     }
    554 
    555     #[test]
    556     fn conflicting_same_height_certificates_have_deterministic_hash_tie_break() {
    557         let local = FinalityCheckpoint {
    558             height: 1_010,
    559             hash: "bbbb".to_string(),
    560         };
    561         let remote = FinalityCheckpoint {
    562             height: 1_010,
    563             hash: "aaaa".to_string(),
    564         };
    565 
    566         assert_eq!(
    567             objective_finality_quality(Some(&local), Some(&remote)),
    568             ForkQuality::RemoteBetter
    569         );
    570         assert_eq!(
    571             objective_finality_quality(Some(&remote), Some(&local)),
    572             ForkQuality::LocalBetter
    573         );
    574     }
    575 
    576     #[test]
    577     fn objective_finality_partition_model_is_total_antisymmetric_and_transitive() {
    578         let checkpoints = [
    579             None,
    580             Some(FinalityCheckpoint {
    581                 height: 1_000,
    582                 hash: "aaaa".to_string(),
    583             }),
    584             Some(FinalityCheckpoint {
    585                 height: 1_000,
    586                 hash: "bbbb".to_string(),
    587             }),
    588             Some(FinalityCheckpoint {
    589                 height: 1_001,
    590                 hash: "aaaa".to_string(),
    591             }),
    592             Some(FinalityCheckpoint {
    593                 height: 1_020,
    594                 hash: "cccc".to_string(),
    595             }),
    596         ];
    597 
    598         for local in &checkpoints {
    599             for remote in &checkpoints {
    600                 let forward = objective_finality_quality(local.as_ref(), remote.as_ref());
    601                 let reverse = objective_finality_quality(remote.as_ref(), local.as_ref());
    602                 assert!(matches!(
    603                     (forward, reverse),
    604                     (ForkQuality::Equal, ForkQuality::Equal)
    605                         | (ForkQuality::LocalBetter, ForkQuality::RemoteBetter)
    606                         | (ForkQuality::RemoteBetter, ForkQuality::LocalBetter)
    607                 ));
    608             }
    609         }
    610 
    611         for first in &checkpoints {
    612             for second in &checkpoints {
    613                 for third in &checkpoints {
    614                     let first_beats_second =
    615                         objective_finality_quality(first.as_ref(), second.as_ref())
    616                             == ForkQuality::LocalBetter;
    617                     let second_beats_third =
    618                         objective_finality_quality(second.as_ref(), third.as_ref())
    619                             == ForkQuality::LocalBetter;
    620                     if first_beats_second && second_beats_third {
    621                         assert_eq!(
    622                             objective_finality_quality(first.as_ref(), third.as_ref()),
    623                             ForkQuality::LocalBetter
    624                         );
    625                     }
    626                 }
    627             }
    628         }
    629     }
    630 
    631     #[test]
    632     fn activated_node_freezes_history_before_height_1000() {
    633         let mut local = Ledger::new(BTreeMap::new(), 1);
    634         let mut candidate = local.clone();
    635         local.chain.last_mut().unwrap().height = 1_010;
    636         local.chain.last_mut().unwrap().hash = "local-tip".to_string();
    637         candidate.chain.last_mut().unwrap().height = 1_020;
    638         candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string();
    639         candidate.objective_finality_checkpoint = Some(FinalityCheckpoint {
    640             height: 1_019,
    641             hash: "remote-finalized".to_string(),
    642         });
    643 
    644         assert_eq!(
    645             local.choose_fork(
    646                 &candidate,
    647                 ForkPoint {
    648                     common_ancestor_height: 998,
    649                 },
    650             ),
    651             ForkChoice::KeepLocal
    652         );
    653     }
    654 
    655     #[test]
    656     fn transfer_and_burn_signatures_cannot_replay_between_chain_ids() {
    657         let alice = Wallet::from_seed("chain-replay-alice");
    658         let bob = Wallet::from_seed("chain-replay-bob");
    659         let allocations = BTreeMap::from([(alice.address().to_string(), 100)]);
    660         let chain_ids = [
    661             "iuna-mainnet-candidate",
    662             "iuna-mainnet-v1",
    663             "iuna-testnet-v1",
    664         ];
    665 
    666         for foreign_chain_id in &chain_ids[1..] {
    667             let mut source = ledger_with_profile(allocations.clone(), chain_ids[0]);
    668             let mut foreign = ledger_with_profile(allocations.clone(), foreign_chain_id);
    669             set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    670             set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    671             assert_eq!(source.genesis_hash(), foreign.genesis_hash());
    672             assert_eq!(source.utxos, foreign.utxos);
    673 
    674             let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
    675             let transfer_error = foreign.submit_transaction(transfer).unwrap_err();
    676             assert!(
    677                 transfer_error
    678                     .to_string()
    679                     .contains("transaction signature is invalid")
    680             );
    681 
    682             let burn = source.build_burn(&alice, 10, 1).unwrap();
    683             let burn_error = foreign.submit_transaction(burn).unwrap_err();
    684             assert!(
    685                 burn_error
    686                     .to_string()
    687                     .contains("transaction signature is invalid")
    688             );
    689         }
    690     }
    691 
    692     #[test]
    693     fn legacy_signatures_remain_compatible_before_height_1000() {
    694         let alice = Wallet::from_seed("pre-activation-replay-alice");
    695         let bob = Wallet::from_seed("pre-activation-replay-bob");
    696         let allocations = BTreeMap::from([(alice.address().to_string(), 100)]);
    697         let mut source = ledger_with_profile(allocations.clone(), "legacy-chain-a");
    698         let mut foreign = ledger_with_profile(allocations, "legacy-chain-b");
    699         set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1);
    700         set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1);
    701 
    702         let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
    703 
    704         assert!(foreign.submit_transaction(transfer).unwrap());
    705     }
    706 
    707     #[test]
    708     fn signatures_cannot_replay_between_distinct_genesis_hashes() {
    709         let alice = Wallet::from_seed("genesis-replay-alice");
    710         let bob = Wallet::from_seed("genesis-replay-bob");
    711         let base_allocations = BTreeMap::from([(alice.address().to_string(), 100)]);
    712         let other_allocations = BTreeMap::from([
    713             (alice.address().to_string(), 100),
    714             (bob.address().to_string(), 1),
    715         ]);
    716         let mut source = ledger_with_profile(base_allocations, "same-chain-id");
    717         let mut foreign = ledger_with_profile(other_allocations, "same-chain-id");
    718         set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    719         set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    720         assert_ne!(source.genesis_hash(), foreign.genesis_hash());
    721 
    722         let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap();
    723         let error = foreign.submit_transaction(transfer).unwrap_err();
    724 
    725         assert!(
    726             error
    727                 .to_string()
    728                 .contains("transaction signature is invalid")
    729         );
    730     }
    731 
    732     #[test]
    733     fn mine_proofs_cannot_replay_between_chain_ids() {
    734         let miner = Wallet::from_seed("mine-replay-miner");
    735         let allocations = BTreeMap::from([(miner.address().to_string(), 100)]);
    736         let mut source = ledger_with_profile(allocations.clone(), "mine-chain-a");
    737         let mut foreign = ledger_with_profile(allocations, "mine-chain-b");
    738         set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    739         set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    740         assert_eq!(source.genesis_hash(), foreign.genesis_hash());
    741 
    742         let mine = source.build_mine(miner.address()).unwrap();
    743         let error = foreign.submit_transaction(mine).unwrap_err();
    744 
    745         assert!(
    746             error
    747                 .to_string()
    748                 .contains("mine transaction proof hash is invalid")
    749         );
    750     }
    751 
    752     #[test]
    753     fn stratum_mine_proofs_cannot_replay_between_chain_ids() {
    754         let miner = Wallet::from_seed("stratum-replay-miner");
    755         let allocations = BTreeMap::from([(miner.address().to_string(), 100)]);
    756         let mut source = ledger_with_profile(allocations.clone(), "stratum-chain-a");
    757         let mut foreign = ledger_with_profile(allocations, "stratum-chain-b");
    758         set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    759         set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    760         let template = source
    761             .stratum_mine_template(
    762                 miner.address(),
    763                 source.genesis_hash(),
    764                 7,
    765                 source.current_mine_difficulty_bits(),
    766             )
    767             .unwrap();
    768         let mine = (0..u32::MAX)
    769             .find_map(|nonce| {
    770                 source
    771                     .build_stratum_mine(
    772                         template.clone(),
    773                         StratumMineShare {
    774                             extranonce2: [0; 4],
    775                             header_nonce: nonce.to_le_bytes(),
    776                         },
    777                     )
    778                     .ok()
    779             })
    780             .expect("test difficulty must yield a Stratum share");
    781 
    782         let error = foreign.submit_transaction(mine).unwrap_err();
    783 
    784         assert!(error.to_string().contains("proof header is invalid"));
    785     }
    786 
    787     #[test]
    788     fn transaction_hex_casing_cannot_be_malleated_after_signing() {
    789         let alice = Wallet::from_seed("hex-malleability-alice");
    790         let bob = Wallet::from_seed("hex-malleability-bob");
    791         let mut ledger = ledger_with_profile(
    792             BTreeMap::from([(alice.address().to_string(), 100)]),
    793             "hex-malleability-chain",
    794         );
    795         set_next_height(&mut ledger, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT);
    796         let transaction = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap();
    797 
    798         let reject = |mutated| {
    799             let error = ledger
    800                 .clone()
    801                 .submit_transaction(mutated)
    802                 .expect_err("noncanonical transaction hex must be rejected");
    803             assert!(
    804                 format!("{error:#}").contains("canonical lowercase"),
    805                 "unexpected rejection: {error:#}"
    806             );
    807         };
    808 
    809         let mut recipient = transaction.clone();
    810         let Transaction::Transfer { outputs, .. } = &mut recipient else {
    811             unreachable!()
    812         };
    813         outputs[0].address.make_ascii_uppercase();
    814         reject(recipient);
    815 
    816         let mut change = transaction.clone();
    817         let Transaction::Transfer { outputs, .. } = &mut change else {
    818             unreachable!()
    819         };
    820         outputs[1].address.make_ascii_uppercase();
    821         reject(change);
    822 
    823         let mut owner = transaction.clone();
    824         let Transaction::Transfer { inputs, .. } = &mut owner else {
    825             unreachable!()
    826         };
    827         inputs[0].owner.make_ascii_uppercase();
    828         reject(owner);
    829 
    830         let mut outpoint = transaction.clone();
    831         let Transaction::Transfer { inputs, .. } = &mut outpoint else {
    832             unreachable!()
    833         };
    834         inputs[0].outpoint.txid.make_ascii_uppercase();
    835         reject(outpoint);
    836 
    837         let mut signature = transaction;
    838         let Transaction::Transfer {
    839             inputs,
    840             signature: transaction_signature,
    841             ..
    842         } = &mut signature
    843         else {
    844             unreachable!()
    845         };
    846         transaction_signature.make_ascii_uppercase();
    847         for input in inputs {
    848             input.signature.make_ascii_uppercase();
    849         }
    850         reject(signature);
    851     }
    852 }