iuna

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

properties.rs (33852B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use iuna::{
      4     app::{InMemoryNetwork, NodeCore},
      5     domain::{
      6         Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD,
      7         OutPoint, Transaction, TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash,
      8         verify_vdf,
      9     },
     10 };
     11 
     12 const LEDGER_PROPERTY_SEEDS: std::ops::Range<u64> = 0..16;
     13 const LEDGER_PROPERTY_ROUNDS: usize = 18;
     14 const NETWORK_PROPERTY_SEEDS: std::ops::Range<u64> = 100..108;
     15 const NETWORK_PROPERTY_ROUNDS: usize = 12;
     16 const TAMPER_PROPERTY_SEEDS: std::ops::Range<u64> = 200..208;
     17 const FORK_PROPERTY_SEEDS: std::ops::Range<u64> = 300..306;
     18 const NETWORK_CHAOS_SEEDS: std::ops::Range<u64> = 400..405;
     19 const NETWORK_CHAOS_ROUNDS: usize = 10;
     20 const VDF_STABILITY_SEEDS: std::ops::Range<u64> = 500..516;
     21 const VDF_STABILITY_BLOCKS: usize = 128;
     22 const VDF_STABILITY_INITIAL_ROUNDS: u64 = 1_000_000;
     23 const TEST_REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000;
     24 
     25 #[derive(Clone, Debug)]
     26 struct TestRng {
     27     state: u64,
     28 }
     29 
     30 impl TestRng {
     31     fn new(seed: u64) -> Self {
     32         Self {
     33             state: seed ^ 0x9e37_79b9_7f4a_7c15,
     34         }
     35     }
     36 
     37     fn next_u64(&mut self) -> u64 {
     38         self.state = self
     39             .state
     40             .wrapping_mul(6_364_136_223_846_793_005)
     41             .wrapping_add(1_442_695_040_888_963_407);
     42         self.state
     43     }
     44 
     45     fn index(&mut self, len: usize) -> usize {
     46         assert!(len > 0);
     47         (self.next_u64() as usize) % len
     48     }
     49 
     50     fn amount(&mut self, max_inclusive: Amount) -> Amount {
     51         1 + self.next_u64() % max_inclusive
     52     }
     53 }
     54 
     55 fn test_wallets(seed: u64, count: usize) -> Vec<Wallet> {
     56     (0..count)
     57         .map(|index| Wallet::from_seed(&format!("property-wallet-{seed}-{index}")))
     58         .collect()
     59 }
     60 
     61 fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
     62     wallets
     63         .iter()
     64         .map(|wallet| (wallet.address().to_string(), amount))
     65         .collect()
     66 }
     67 
     68 fn genesis_burns(wallets: &[Wallet], amount: Amount) -> Vec<GenesisBurn> {
     69     wallets
     70         .iter()
     71         .map(|wallet| GenesisBurn::new(wallet.address(), amount))
     72         .collect()
     73 }
     74 
     75 fn queue_plaintext_burn(node: &mut NodeCore, wallet: &Wallet, amount: Amount, fee: Amount) -> bool {
     76     node.ledger()
     77         .build_burn(wallet, amount, fee)
     78         .and_then(|tx| node.receive_transaction(tx).map(|_| ()))
     79         .is_ok()
     80 }
     81 
     82 fn queue_plaintext_transfer(
     83     node: &mut NodeCore,
     84     wallet: &Wallet,
     85     recipient: impl Into<String>,
     86     amount: Amount,
     87     fee: Amount,
     88 ) -> bool {
     89     node.ledger()
     90         .build_transfer(wallet, recipient, amount, fee)
     91         .and_then(|tx| node.receive_transaction(tx).map(|_| ()))
     92         .is_ok()
     93 }
     94 
     95 fn queue_plaintext_mine(node: &mut NodeCore, wallet: &Wallet) -> bool {
     96     node.ledger()
     97         .build_mine(wallet.address())
     98         .and_then(|tx| node.receive_transaction(tx).map(|_| ()))
     99         .is_ok()
    100 }
    101 
    102 fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) {
    103     let wallets = test_wallets(seed, wallet_count);
    104     let ledger = Ledger::new_with_genesis_burns(
    105         allocations(&wallets, 250 * MICRO_IUNA),
    106         genesis_burns(&wallets, MICRO_IUNA),
    107         1,
    108     )
    109     .expect("property genesis is valid");
    110     (wallets, ledger)
    111 }
    112 
    113 fn single_finalizer_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) {
    114     let wallets = test_wallets(seed, wallet_count);
    115     let ledger = Ledger::new_with_genesis_burns(
    116         allocations(&wallets, 250 * MICRO_IUNA),
    117         vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)],
    118         1,
    119     )
    120     .expect("single-finalizer property genesis is valid");
    121     (wallets, ledger)
    122 }
    123 
    124 fn vdf_stability_ledger(seed: u64) -> (Wallet, Ledger) {
    125     let wallet = Wallet::from_seed(&format!("vdf-stability-{seed}"));
    126     let mut allocations = BTreeMap::new();
    127     allocations.insert(wallet.address().to_string(), 250 * MICRO_IUNA);
    128     let ledger = Ledger::new_with_genesis_burns(
    129         allocations,
    130         vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    131         VDF_STABILITY_INITIAL_ROUNDS,
    132     )
    133     .expect("vdf stability genesis is valid");
    134     (wallet, ledger)
    135 }
    136 
    137 fn assert_chain_properties(snapshot: ChainSnapshot) {
    138     let replayed =
    139         Ledger::from_snapshot(snapshot.clone()).expect("snapshot replays as valid chain");
    140     assert_eq!(replayed.snapshot(), snapshot);
    141 
    142     let chain = replayed.chain();
    143     assert!(!chain.is_empty());
    144     assert_eq!(chain[0].height, 0);
    145     assert_eq!(chain[0].prev_hash, "0".repeat(64));
    146     assert_eq!(chain[0].hash, chain[0].compute_hash());
    147 
    148     for (index, block) in chain.iter().enumerate() {
    149         assert_eq!(block.height as usize, index);
    150         assert_eq!(block.hash, block.compute_hash());
    151         if index > 0 {
    152             assert_eq!(block.prev_hash, chain[index - 1].hash);
    153             assert!(
    154                 block.timestamp_ms > chain[index - 1].timestamp_ms,
    155                 "timestamps must increase at height {}",
    156                 block.height
    157             );
    158             assert!(
    159                 verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output),
    160                 "VDF must verify at height {}",
    161                 block.height
    162             );
    163             assert!(
    164                 block.transactions.iter().any(Transaction::is_burn),
    165                 "non-genesis block must include a burn at height {}",
    166                 block.height
    167             );
    168         }
    169     }
    170 
    171     let confirmed_supply = replayed
    172         .status()
    173         .balances
    174         .values()
    175         .try_fold(0_u64, |total, amount| total.checked_add(*amount))
    176         .expect("confirmed supply does not overflow");
    177     let expected_supply = expected_confirmed_supply(&snapshot);
    178     assert!(confirmed_supply <= expected_supply);
    179     if snapshot.blocks.iter().all(|block| {
    180         block.blinded_transactions.is_empty() && block.all_blinded_reveals().is_empty()
    181     }) {
    182         assert_eq!(confirmed_supply, expected_supply);
    183         assert_reference_model_matches(&snapshot, &replayed);
    184     }
    185 }
    186 
    187 fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount {
    188     let mut supply = snapshot
    189         .genesis_allocations
    190         .values()
    191         .try_fold(0_u64, |total, amount| total.checked_add(*amount))
    192         .expect("genesis supply does not overflow");
    193 
    194     for block in &snapshot.blocks {
    195         for tx in &block.transactions {
    196             match tx {
    197                 Transaction::Transfer { fee, .. } => {
    198                     supply = supply.checked_sub(*fee).expect("transfer fee is funded");
    199                 }
    200                 Transaction::Burn { amount, fee, .. } => {
    201                     supply = supply.checked_sub(*amount).expect("burn is funded");
    202                     supply = supply.checked_sub(*fee).expect("burn fee is funded");
    203                 }
    204                 Transaction::Mine { .. } => {
    205                     supply = supply
    206                         .checked_add(MINE_REWARD)
    207                         .expect("mine output does not overflow supply");
    208                 }
    209             }
    210         }
    211         supply = supply
    212             .checked_add(block.reward)
    213             .expect("block reward does not overflow supply");
    214     }
    215 
    216     supply
    217 }
    218 
    219 fn assert_reference_model_matches(snapshot: &ChainSnapshot, replayed: &Ledger) {
    220     let reference_balances = reference_balances(snapshot);
    221     assert_eq!(reference_balances, replayed.status().balances);
    222 
    223     let reference_supply = reference_balances
    224         .values()
    225         .try_fold(0_u64, |total, amount| total.checked_add(*amount))
    226         .expect("reference supply does not overflow");
    227     assert_eq!(reference_supply, expected_confirmed_supply(snapshot));
    228 }
    229 
    230 fn reference_balances(snapshot: &ChainSnapshot) -> BTreeMap<String, Amount> {
    231     let mut utxos = BTreeMap::new();
    232 
    233     for block in &snapshot.blocks {
    234         if block.height == 0 {
    235             seed_reference_genesis_allocations(snapshot, &mut utxos);
    236         }
    237 
    238         let mut block_signatures = BTreeSet::new();
    239         let mut block_fees = 0_u64;
    240         for tx in &block.transactions {
    241             assert!(
    242                 block_signatures.insert(tx.signature().to_string()),
    243                 "duplicate transaction in block {}",
    244                 block.height
    245             );
    246             apply_reference_transaction(tx, &mut utxos);
    247             block_fees = block_fees
    248                 .checked_add(tx.fee())
    249                 .expect("reference block fees do not overflow");
    250         }
    251 
    252         if block.height > 0 {
    253             assert_eq!(block.reward, block_fees);
    254         }
    255         if block.reward > 0 {
    256             let replaced = utxos.insert(
    257                 OutPoint {
    258                     txid: block.hash.clone(),
    259                     index: u32::MAX,
    260                 },
    261                 TxOutput {
    262                     address: block.miner.clone(),
    263                     amount: block.reward,
    264                 },
    265             );
    266             assert!(replaced.is_none(), "duplicate reference reward output");
    267         }
    268     }
    269 
    270     balances_from_reference_utxos(&utxos)
    271 }
    272 
    273 fn seed_reference_genesis_allocations(
    274     snapshot: &ChainSnapshot,
    275     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    276 ) {
    277     for (address, amount) in &snapshot.genesis_allocations {
    278         if *amount == 0 {
    279             continue;
    280         }
    281         utxos.insert(
    282             OutPoint {
    283                 txid: hex_hash(format!("iuna-genesis-allocation:{address}")),
    284                 index: 0,
    285             },
    286             TxOutput {
    287                 address: address.clone(),
    288                 amount: *amount,
    289             },
    290         );
    291     }
    292 }
    293 
    294 fn apply_reference_transaction(
    295     transaction: &Transaction,
    296     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    297 ) {
    298     if let Transaction::Mine { recipient, .. } = transaction {
    299         let output = TxOutput {
    300             address: recipient.clone(),
    301             amount: MINE_REWARD,
    302         };
    303         assert_eq!(transaction.fee(), MINE_FINALIZER_FEE);
    304         insert_reference_outputs(transaction, &[output], utxos);
    305         return;
    306     }
    307 
    308     let mut seen_inputs = BTreeSet::new();
    309     let mut input_total = 0_u64;
    310     for input in reference_inputs(transaction) {
    311         assert!(
    312             seen_inputs.insert(input.outpoint.clone()),
    313             "duplicate reference input"
    314         );
    315         let spent = utxos
    316             .remove(&input.outpoint)
    317             .expect("reference transaction spends an existing output");
    318         assert_eq!(spent.address, input.owner);
    319         input_total = input_total
    320             .checked_add(spent.amount)
    321             .expect("reference input total does not overflow");
    322     }
    323 
    324     let outputs = reference_outputs(transaction);
    325     let output_total = outputs.iter().fold(0_u64, |total, output| {
    326         total
    327             .checked_add(output.amount)
    328             .expect("reference output total does not overflow")
    329     });
    330     let burn_amount = match transaction {
    331         Transaction::Burn { amount, .. } => *amount,
    332         Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
    333     };
    334     let required = output_total
    335         .checked_add(transaction.fee())
    336         .expect("reference outputs plus fee do not overflow")
    337         .checked_add(burn_amount)
    338         .expect("reference outputs plus burn do not overflow");
    339     assert_eq!(input_total, required);
    340 
    341     insert_reference_outputs(transaction, &outputs, utxos);
    342 }
    343 
    344 fn insert_reference_outputs(
    345     transaction: &Transaction,
    346     outputs: &[TxOutput],
    347     utxos: &mut BTreeMap<OutPoint, TxOutput>,
    348 ) {
    349     for (index, output) in outputs.iter().enumerate() {
    350         let replaced = utxos.insert(
    351             OutPoint {
    352                 txid: transaction.signature().to_string(),
    353                 index: index as u32,
    354             },
    355             output.clone(),
    356         );
    357         assert!(replaced.is_none(), "duplicate reference transaction output");
    358     }
    359 }
    360 
    361 fn reference_inputs(transaction: &Transaction) -> &[TxInput] {
    362     match transaction {
    363         Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
    364         Transaction::Mine { .. } => &[],
    365     }
    366 }
    367 
    368 fn reference_outputs(transaction: &Transaction) -> Vec<TxOutput> {
    369     match transaction {
    370         Transaction::Transfer { outputs, .. } => outputs.clone(),
    371         Transaction::Burn { change, .. } => change.clone(),
    372         Transaction::Mine { recipient, .. } => vec![TxOutput {
    373             address: recipient.clone(),
    374             amount: MINE_REWARD,
    375         }],
    376     }
    377 }
    378 
    379 fn balances_from_reference_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, Amount> {
    380     let mut balances = BTreeMap::new();
    381     for output in utxos.values() {
    382         let balance = balances.entry(output.address.clone()).or_insert(0_u64);
    383         *balance = balance
    384             .checked_add(output.amount)
    385             .expect("reference balance does not overflow");
    386     }
    387     balances
    388 }
    389 
    390 fn random_wallet_pair<'a>(rng: &mut TestRng, wallets: &'a [Wallet]) -> (&'a Wallet, &'a Wallet) {
    391     let from = rng.index(wallets.len());
    392     let mut to = rng.index(wallets.len() - 1);
    393     if to >= from {
    394         to += 1;
    395     }
    396     (&wallets[from], &wallets[to])
    397 }
    398 
    399 fn try_random_transaction(
    400     seed: u64,
    401     round: usize,
    402     rng: &mut TestRng,
    403     wallets: &[Wallet],
    404     ledger: &mut Ledger,
    405 ) {
    406     match rng.index(5) {
    407         0 => {
    408             let (from, to) = random_wallet_pair(rng, wallets);
    409             let amount = rng.amount(5 * MICRO_IUNA);
    410             let fee = rng.next_u64() % 4;
    411             if let Ok(tx) = ledger.build_transfer(from, to.address(), amount, fee) {
    412                 let _ = ledger.submit_transaction(tx);
    413             }
    414         }
    415         1 => {
    416             let wallet = &wallets[rng.index(wallets.len())];
    417             let amount = rng.amount(3 * MICRO_IUNA);
    418             let fee = rng.next_u64() % 4;
    419             if let Ok(tx) = ledger.build_burn(wallet, amount, fee) {
    420                 let _ = ledger.submit_transaction(tx);
    421             }
    422         }
    423         2 if round % 4 == 0 => {
    424             let wallet = &wallets[rng.index(wallets.len())];
    425             if let Ok(tx) = ledger.build_mine(wallet.address()) {
    426                 let _ = ledger.submit_transaction(tx);
    427             }
    428         }
    429         3 => {
    430             let wallet = &wallets[rng.index(wallets.len())];
    431             let impossible = (seed + round as u64 + 1) * MICRO_IUNA * 10_000;
    432             assert!(ledger.build_burn(wallet, impossible, 0).is_err());
    433         }
    434         _ => {}
    435     }
    436 }
    437 
    438 fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger) {
    439     let Some(leader) = ledger.expected_leader_for_next_block() else {
    440         return;
    441     };
    442     let Some(wallet) = wallets.iter().find(|wallet| wallet.address() == leader) else {
    443         return;
    444     };
    445 
    446     if let Ok(tx) = ledger.build_burn(wallet, MICRO_IUNA, 0) {
    447         let _ = ledger.submit_transaction(tx);
    448     }
    449 
    450     if let Ok(block) = ledger.mine_next_block(wallet, (round + 1) as u64) {
    451         ledger
    452             .apply_block(block)
    453             .expect("locally mined block applies");
    454     }
    455 }
    456 
    457 fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) {
    458     let burn = ledger
    459         .build_burn(wallet, MICRO_IUNA, 0)
    460         .expect("finalizer can build burn");
    461     let _ = ledger
    462         .submit_transaction(burn)
    463         .expect("finalizer burn enters mempool");
    464     let block = ledger
    465         .mine_next_block(wallet, timestamp_ms)
    466         .expect("finalizer can mine next block");
    467     ledger.apply_block(block).expect("finalizer block applies");
    468 }
    469 
    470 fn finalize_preverified_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) {
    471     let burn = ledger
    472         .build_burn(wallet, MICRO_IUNA, 0)
    473         .expect("finalizer can build burn");
    474     ledger
    475         .submit_transaction(burn)
    476         .expect("finalizer burn enters mempool");
    477     let work = ledger
    478         .prepare_next_block(wallet.address(), timestamp_ms)
    479         .expect("finalizer can prepare next block");
    480     let block = work.finish(wallet, "property-vdf".to_string());
    481     ledger
    482         .apply_locally_mined_block(block)
    483         .expect("locally mined block applies");
    484 }
    485 
    486 fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 {
    487     ledger
    488         .chain()
    489         .last()
    490         .expect("ledger has genesis")
    491         .timestamp_ms
    492         .saturating_add(VDF_TARGET_BLOCK_MS)
    493         .saturating_add(offset_ms)
    494 }
    495 
    496 fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_timestamp_ms: u64) {
    497     for offset in 0..count {
    498         let timestamp_ms = next_ticket_slot_timestamp(ledger, start_timestamp_ms + offset as u64);
    499         finalize_with_wallet(ledger, wallet, timestamp_ms);
    500     }
    501 }
    502 
    503 #[test]
    504 fn generated_vdf_retarget_stays_stable_under_noisy_block_times() {
    505     for seed in VDF_STABILITY_SEEDS {
    506         let (wallet, mut ledger) = vdf_stability_ledger(seed);
    507         let mut rng = TestRng::new(seed);
    508         let mut timestamp_ms = 0_u64;
    509         let mut min_rounds = ledger.vdf_rounds();
    510         let mut max_rounds = ledger.vdf_rounds();
    511         let mut previous_rounds = ledger.vdf_rounds();
    512         let mut pair_jitter_ms = 0_u64;
    513 
    514         for block_index in 0..VDF_STABILITY_BLOCKS {
    515             let interval_ms = if block_index == 0 {
    516                 VDF_TARGET_BLOCK_MS
    517             } else if block_index % 2 == 1 {
    518                 pair_jitter_ms = rng.next_u64() % (VDF_TARGET_BLOCK_MS / 4 + 1);
    519                 VDF_TARGET_BLOCK_MS.saturating_sub(pair_jitter_ms)
    520             } else {
    521                 VDF_TARGET_BLOCK_MS + pair_jitter_ms
    522             };
    523             timestamp_ms = timestamp_ms
    524                 .checked_add(interval_ms)
    525                 .expect("property timestamp does not overflow");
    526 
    527             finalize_preverified_with_wallet(&mut ledger, &wallet, timestamp_ms);
    528 
    529             let rounds = ledger.vdf_rounds();
    530             let max_step = (previous_rounds * 2 / 100).max(1);
    531             assert!(
    532                 rounds.abs_diff(previous_rounds) <= max_step,
    533                 "seed {seed} block {block_index}: VDF rounds changed from {previous_rounds} to {rounds}, above max step {max_step}"
    534             );
    535             min_rounds = min_rounds.min(rounds);
    536             max_rounds = max_rounds.max(rounds);
    537             previous_rounds = rounds;
    538         }
    539 
    540         let lower_bound = VDF_STABILITY_INITIAL_ROUNDS * 95 / 100;
    541         let upper_bound = VDF_STABILITY_INITIAL_ROUNDS * 105 / 100;
    542         assert!(
    543             min_rounds >= lower_bound && max_rounds <= upper_bound,
    544             "seed {seed}: VDF rounds drifted outside stability band: min {min_rounds}, max {max_rounds}"
    545         );
    546     }
    547 }
    548 
    549 #[test]
    550 fn generated_chain_snapshots_preserve_core_invariants() {
    551     for seed in LEDGER_PROPERTY_SEEDS {
    552         let (wallets, mut ledger) = property_ledger(seed, 4);
    553         let mut rng = TestRng::new(seed);
    554 
    555         assert_chain_properties(ledger.snapshot());
    556         for round in 0..LEDGER_PROPERTY_ROUNDS {
    557             try_random_transaction(seed, round, &mut rng, &wallets, &mut ledger);
    558             if round % 2 == 0 {
    559                 try_finalize_next_block(round, &wallets, &mut ledger);
    560             }
    561             assert_chain_properties(ledger.snapshot());
    562         }
    563     }
    564 }
    565 
    566 #[test]
    567 fn generated_forks_reorg_only_inside_finality_and_preserve_local_transactions() {
    568     for seed in FORK_PROPERTY_SEEDS {
    569         let (wallets, mut common) = single_finalizer_ledger(seed, 3);
    570         let finalizer = &wallets[0];
    571         let sender = &wallets[1];
    572         let recipient = &wallets[2];
    573         let mut rng = TestRng::new(seed);
    574 
    575         finalize_many(&mut common, finalizer, 2 + rng.index(2), 1);
    576         assert_chain_properties(common.snapshot());
    577 
    578         let mut local = common.clone();
    579         let abandoned = local
    580             .build_transfer(sender, recipient.address(), MICRO_IUNA + rng.amount(5), 0)
    581             .expect("abandoned fork transfer builds");
    582         local
    583             .submit_transaction(abandoned.clone())
    584             .expect("abandoned fork transfer enters mempool");
    585         finalize_many(&mut local, finalizer, 1 + rng.index(2), 20);
    586 
    587         let mut remote = common.clone();
    588         finalize_many(&mut remote, finalizer, 4 + rng.index(3), 100);
    589 
    590         let remote_tip = remote.status().tip_hash;
    591         assert!(
    592             local
    593                 .extend_from_snapshot(remote.snapshot())
    594                 .expect("valid fresh fork import succeeds"),
    595             "longer fresh fork should be accepted"
    596         );
    597         assert_eq!(local.status().tip_hash, remote_tip);
    598         assert!(
    599             local
    600                 .pending()
    601                 .iter()
    602                 .any(|tx| tx.signature() == abandoned.signature()),
    603             "transactions mined only on the abandoned fork should return to the mempool"
    604         );
    605         assert_chain_properties(local.snapshot());
    606 
    607         let (wallets, mut common) = single_finalizer_ledger(seed + 10_000, 2);
    608         let finalizer = &wallets[0];
    609         finalize_with_wallet(&mut common, finalizer, 1);
    610 
    611         let mut finalized_local = common.clone();
    612         finalize_many(&mut finalized_local, finalizer, 8 + rng.index(2), 10);
    613         let finalized_tip = finalized_local.status().tip_hash;
    614 
    615         let mut too_old_remote = common;
    616         finalize_many(&mut too_old_remote, finalizer, 12 + rng.index(2), 200);
    617 
    618         assert!(
    619             !finalized_local
    620                 .extend_from_snapshot(too_old_remote.snapshot())
    621                 .expect("valid old fork import is evaluated"),
    622             "forks that rewrite finalized history must be rejected"
    623         );
    624         assert_eq!(finalized_local.status().tip_hash, finalized_tip);
    625         assert_chain_properties(finalized_local.snapshot());
    626     }
    627 }
    628 
    629 #[test]
    630 fn in_memory_network_converges_under_generated_node_actions() {
    631     for seed in NETWORK_PROPERTY_SEEDS {
    632         let (wallets, ledger) = property_ledger(seed, 3);
    633         let mut network = InMemoryNetwork::default();
    634 
    635         for (index, wallet) in wallets.iter().enumerate() {
    636             let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
    637             network.insert(
    638                 format!("n{index}"),
    639                 NodeCore::from_ledger_with_burn_fee_and_enabled(
    640                     wallet.clone(),
    641                     joined,
    642                     true,
    643                     MICRO_IUNA,
    644                     0,
    645                 ),
    646             );
    647         }
    648 
    649         let mut rng = TestRng::new(seed);
    650         network
    651             .deliver_until_idle()
    652             .expect("initial network delivery succeeds");
    653 
    654         for round in 0..NETWORK_PROPERTY_ROUNDS {
    655             let node_index = rng.index(wallets.len());
    656             let node_id = format!("n{node_index}");
    657             let recipient = wallets[rng.index(wallets.len())].address().to_string();
    658 
    659             match rng.index(4) {
    660                 0 => {
    661                     let _ = queue_plaintext_transfer(
    662                         network.node_mut(&node_id).expect("node exists"),
    663                         &wallets[node_index],
    664                         recipient,
    665                         rng.amount(2 * MICRO_IUNA),
    666                         0,
    667                     );
    668                 }
    669                 1 => {
    670                     let _ = queue_plaintext_burn(
    671                         network.node_mut(&node_id).expect("node exists"),
    672                         &wallets[node_index],
    673                         MICRO_IUNA,
    674                         0,
    675                     );
    676                 }
    677                 2 => {
    678                     let _ = queue_plaintext_mine(
    679                         network.node_mut(&node_id).expect("node exists"),
    680                         &wallets[node_index],
    681                     );
    682                 }
    683                 _ => {}
    684             }
    685 
    686             network
    687                 .deliver_until_idle()
    688                 .expect("transaction gossip converges");
    689 
    690             let leader = network
    691                 .node("n0")
    692                 .expect("anchor node exists")
    693                 .ledger()
    694                 .expected_leader_for_next_block();
    695             if let Some(leader) = leader {
    696                 if let Some((leader_index, _)) = wallets
    697                     .iter()
    698                     .enumerate()
    699                     .find(|(_, wallet)| wallet.address() == leader)
    700                 {
    701                     let timestamp_ms = (round + 1) as u64;
    702                     let mut outcome = network
    703                         .node_mut(&format!("n{leader_index}"))
    704                         .expect("leader node exists")
    705                         .automatic_mine_once(timestamp_ms);
    706                     if outcome
    707                         .skipped_reason
    708                         .as_deref()
    709                         .is_some_and(|reason| reason.contains("collecting blinded reveals"))
    710                     {
    711                         outcome = network
    712                             .node_mut(&format!("n{leader_index}"))
    713                             .expect("leader node exists")
    714                             .automatic_mine_once(
    715                                 timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1),
    716                             );
    717                     }
    718                     if let Some(reason) = outcome.skipped_reason {
    719                         assert!(
    720                             reason.contains("at least one burn")
    721                                 || reason.contains("selected finalizer")
    722                                 || reason.contains("required burn")
    723                                 || reason.contains("could not")
    724                                 || reason.contains("automatic"),
    725                             "unexpected mining skip reason: {reason}"
    726                         );
    727                     }
    728                 }
    729             }
    730 
    731             network
    732                 .deliver_until_idle()
    733                 .expect("block gossip converges");
    734             assert_network_converged(&network, wallets.len());
    735         }
    736     }
    737 }
    738 
    739 fn assert_network_converged(network: &InMemoryNetwork, nodes: usize) {
    740     let first = network.node("n0").expect("first node exists");
    741     let height = first.chain_height();
    742     let tip = first.ledger().status().tip_hash;
    743     assert_chain_properties(first.chain_snapshot());
    744 
    745     for index in 1..nodes {
    746         let node = network.node(&format!("n{index}")).expect("node exists");
    747         assert_eq!(node.chain_height(), height, "node {index} height diverged");
    748         assert_eq!(
    749             node.ledger().status().tip_hash,
    750             tip,
    751             "node {index} tip diverged"
    752         );
    753         assert_chain_properties(node.chain_snapshot());
    754     }
    755 }
    756 
    757 fn deliver_with_chaos(
    758     network: &mut InMemoryNetwork,
    759     node_ids: &[String],
    760     offline: &BTreeSet<String>,
    761     rng: &mut TestRng,
    762 ) -> bool {
    763     let mut outbound = Vec::new();
    764     for id in node_ids {
    765         if offline.contains(id) {
    766             continue;
    767         }
    768         let node = network.node_mut(id).expect("node exists");
    769         for envelope in node.drain_outbox() {
    770             outbound.push((id.clone(), envelope));
    771         }
    772     }
    773 
    774     if outbound.is_empty() {
    775         return false;
    776     }
    777 
    778     while !outbound.is_empty() {
    779         let index = rng.index(outbound.len());
    780         let (from, envelope) = outbound.swap_remove(index);
    781         for id in node_ids {
    782             if *id == from || offline.contains(id) {
    783                 continue;
    784             }
    785             let duplicate = rng.index(5) == 0;
    786             receive_chaotic_envelope(network, id, envelope.clone());
    787             if duplicate {
    788                 receive_chaotic_envelope(network, id, envelope.clone());
    789             }
    790         }
    791     }
    792 
    793     true
    794 }
    795 
    796 fn receive_chaotic_envelope(
    797     network: &mut InMemoryNetwork,
    798     id: &str,
    799     envelope: iuna::app::GossipEnvelope,
    800 ) {
    801     if let Err(error) = network.node_mut(id).expect("node exists").receive(envelope) {
    802         let message = error.to_string();
    803         assert!(
    804             message.contains("expected block height")
    805                 || message.contains("mine transaction anchor is not on this chain")
    806                 || message.contains("blinded transaction spends missing output")
    807                 || message.contains("blinded transaction expiry is too far in the future"),
    808             "unexpected chaotic delivery error: {message}"
    809         );
    810     }
    811 }
    812 
    813 fn deliver_chaos_until_idle(
    814     network: &mut InMemoryNetwork,
    815     node_ids: &[String],
    816     offline: &BTreeSet<String>,
    817     rng: &mut TestRng,
    818 ) {
    819     for _ in 0..32 {
    820         if !deliver_with_chaos(network, node_ids, offline, rng) {
    821             return;
    822         }
    823     }
    824     panic!("chaotic network delivery did not become idle");
    825 }
    826 
    827 #[test]
    828 fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() {
    829     for seed in NETWORK_CHAOS_SEEDS {
    830         let (wallets, ledger) = single_finalizer_ledger(seed, 3);
    831         let node_ids = (0..wallets.len())
    832             .map(|index| format!("n{index}"))
    833             .collect::<Vec<_>>();
    834         let mut network = InMemoryNetwork::default();
    835 
    836         for (index, wallet) in wallets.iter().enumerate() {
    837             let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
    838             network.insert(
    839                 &node_ids[index],
    840                 NodeCore::from_ledger_with_burn_fee_and_enabled(
    841                     wallet.clone(),
    842                     joined,
    843                     true,
    844                     MICRO_IUNA,
    845                     0,
    846                 ),
    847             );
    848         }
    849 
    850         let mut rng = TestRng::new(seed);
    851         deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
    852 
    853         for round in 0..NETWORK_CHAOS_ROUNDS {
    854             let mut offline = BTreeSet::new();
    855             if round % 3 == 0 {
    856                 offline.insert("n2".to_string());
    857             } else if round % 4 == 0 {
    858                 offline.insert("n1".to_string());
    859             }
    860 
    861             let actor_index = 1 + rng.index(wallets.len() - 1);
    862             let actor_id = format!("n{actor_index}");
    863             let recipient = wallets[rng.index(wallets.len())].address().to_string();
    864             match rng.index(3) {
    865                 0 => {
    866                     let _ = queue_plaintext_transfer(
    867                         network.node_mut(&actor_id).expect("actor node exists"),
    868                         &wallets[actor_index],
    869                         recipient,
    870                         MICRO_IUNA + rng.amount(17),
    871                         0,
    872                     );
    873                 }
    874                 1 => {
    875                     let _ = queue_plaintext_mine(
    876                         network.node_mut(&actor_id).expect("actor node exists"),
    877                         &wallets[actor_index],
    878                     );
    879                 }
    880                 _ => {
    881                     let _ = queue_plaintext_burn(
    882                         network.node_mut("n0").expect("finalizer node exists"),
    883                         &wallets[0],
    884                         MICRO_IUNA,
    885                         0,
    886                     );
    887                 }
    888             }
    889 
    890             let timestamp_ms = (round + 1) as u64;
    891             let mut outcome = network
    892                 .node_mut("n0")
    893                 .expect("finalizer node exists")
    894                 .automatic_mine_once(timestamp_ms);
    895             if outcome
    896                 .skipped_reason
    897                 .as_deref()
    898                 .is_some_and(|reason| reason.contains("collecting blinded reveals"))
    899             {
    900                 outcome = network
    901                     .node_mut("n0")
    902                     .expect("finalizer node exists")
    903                     .automatic_mine_once(
    904                         timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1),
    905                     );
    906             }
    907             if let Some(reason) = outcome.skipped_reason {
    908                 assert!(
    909                     reason.contains("at least one burn")
    910                         || reason.contains("required burn")
    911                         || reason.contains("could not")
    912                         || reason.contains("automatic burn failed"),
    913                     "unexpected chaotic mining skip reason: {reason}"
    914                 );
    915             }
    916 
    917             deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng);
    918         }
    919 
    920         deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
    921         for id in node_ids.iter().skip(1) {
    922             while network
    923                 .sync_node_from_peer("n0", id, 128)
    924                 .expect("lagging node range sync succeeds")
    925             {}
    926         }
    927         deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
    928         assert_network_converged(&network, wallets.len());
    929     }
    930 }
    931 
    932 #[test]
    933 fn generated_snapshot_tampering_is_rejected() {
    934     for seed in TAMPER_PROPERTY_SEEDS {
    935         let (wallets, mut ledger) = property_ledger(seed, 3);
    936         for round in 0..6 {
    937             try_finalize_next_block(round, &wallets, &mut ledger);
    938         }
    939         assert_chain_properties(ledger.snapshot());
    940 
    941         let mut mutated_hash = ledger.snapshot();
    942         if let Some(block) = mutated_hash.blocks.last_mut() {
    943             block.timestamp_ms = block.timestamp_ms.saturating_add(1);
    944         }
    945         assert!(Ledger::from_snapshot(mutated_hash).is_err());
    946 
    947         let mut mutated_transaction = ledger.snapshot();
    948         if let Some(transaction) = mutated_transaction
    949             .blocks
    950             .iter_mut()
    951             .flat_map(|block| block.transactions.iter_mut())
    952             .next()
    953         {
    954             match transaction {
    955                 Transaction::Transfer { signature, .. }
    956                 | Transaction::Burn { signature, .. }
    957                 | Transaction::Mine { signature, .. } => signature.push_str("00"),
    958             }
    959         }
    960         assert!(Ledger::from_snapshot(mutated_transaction).is_err());
    961     }
    962 }