iuna

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

iuna.rs (91937B)


      1 use std::{
      2     collections::BTreeMap,
      3     time::{SystemTime, UNIX_EPOCH},
      4 };
      5 
      6 use iuna::{
      7     adapters::chain_store::SqliteChainStore,
      8     app::{
      9         DEFAULT_BURN_PER_BLOCK, GossipEnvelope, InMemoryNetwork, NodeConfig, NodeCore, PeerBook,
     10         PeerDirection, TRANSACTION_BATCH_LIMIT,
     11     },
     12     domain::{
     13         Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, FinalizerMode, GenesisBurn, Ledger,
     14         MAX_BLOCK_BYTES, MICRO_IUNA, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS,
     15         TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, revealed_blinded_transactions,
     16         run_vdf, verify_vdf,
     17     },
     18 };
     19 use tempfile::tempdir;
     20 
     21 fn iuna(amount: Amount) -> Amount {
     22     amount * MICRO_IUNA
     23 }
     24 
     25 fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore {
     26     NodeCore::new(NodeConfig {
     27         wallet,
     28         genesis_allocations: allocations,
     29         vdf_rounds: 25,
     30         burn_per_block: DEFAULT_BURN_PER_BLOCK,
     31         burn_fee: 1,
     32         pow_mining_workers: 1,
     33         recovery_vdf_top_rank_percent: 100,
     34     })
     35 }
     36 
     37 fn wallets(names: &[&str]) -> Vec<Wallet> {
     38     names.iter().map(|name| Wallet::from_seed(name)).collect()
     39 }
     40 
     41 fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
     42     wallets
     43         .iter()
     44         .map(|wallet| (wallet.address().to_string(), amount))
     45         .collect()
     46 }
     47 
     48 fn unix_now_ms() -> u64 {
     49     SystemTime::now()
     50         .duration_since(UNIX_EPOCH)
     51         .unwrap_or_default()
     52         .as_millis()
     53         .try_into()
     54         .unwrap_or(u64::MAX)
     55 }
     56 
     57 fn next_ticket_slot_timestamp(ledger: &Ledger, offset_ms: u64) -> u64 {
     58     ledger
     59         .chain()
     60         .last()
     61         .expect("ledger has genesis")
     62         .timestamp_ms
     63         .saturating_add(VDF_TARGET_BLOCK_MS)
     64         .saturating_add(offset_ms)
     65 }
     66 
     67 fn mine_wallet_burn_block(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) -> String {
     68     let burn = ledger.build_burn(wallet, 1, 0).unwrap();
     69     ledger.submit_transaction(burn).unwrap();
     70     let block = ledger.mine_next_block(wallet, timestamp_ms).unwrap();
     71     let hash = block.hash.clone();
     72     ledger.apply_block(block).unwrap();
     73     hash
     74 }
     75 
     76 fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) {
     77     let tx = ledger.build_burn(wallet, amount, 0).unwrap();
     78     ledger.submit_transaction(tx).unwrap();
     79 }
     80 
     81 fn queue_plaintext_burn(
     82     node: &mut NodeCore,
     83     wallet: &Wallet,
     84     amount: Amount,
     85 ) -> iuna::domain::Transaction {
     86     let tx = node.ledger().build_burn(wallet, amount, 0).unwrap();
     87     node.receive_transaction(tx.clone()).unwrap();
     88     tx
     89 }
     90 
     91 fn queue_plaintext_transfer(
     92     node: &mut NodeCore,
     93     wallet: &Wallet,
     94     to: impl Into<String>,
     95     amount: Amount,
     96 ) -> iuna::domain::Transaction {
     97     let tx = node.ledger().build_transfer(wallet, to, amount, 0).unwrap();
     98     node.receive_transaction(tx.clone()).unwrap();
     99     tx
    100 }
    101 
    102 fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> iuna::domain::Transaction {
    103     ledger.build_burn(wallet, amount, 0).unwrap()
    104 }
    105 
    106 fn transfer_tx(
    107     ledger: &Ledger,
    108     wallet: &Wallet,
    109     to: impl Into<String>,
    110     amount: Amount,
    111 ) -> iuna::domain::Transaction {
    112     ledger.build_transfer(wallet, to, amount, 0).unwrap()
    113 }
    114 
    115 fn transfer_fee_tx(
    116     ledger: &Ledger,
    117     wallet: &Wallet,
    118     to: impl Into<String>,
    119     amount: Amount,
    120     fee: Amount,
    121 ) -> iuna::domain::Transaction {
    122     ledger.build_transfer(wallet, to, amount, fee).unwrap()
    123 }
    124 
    125 fn fork_with_better_vrf_block(
    126     base: &Ledger,
    127     wallet: &Wallet,
    128     local_fork_block_hash: &str,
    129     first_timestamp_ms: u64,
    130 ) -> Option<Ledger> {
    131     for offset in 0..10_000 {
    132         let mut candidate = base.clone();
    133         let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset);
    134         let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms);
    135         if hash.as_str() < local_fork_block_hash {
    136             return Some(candidate);
    137         }
    138     }
    139     None
    140 }
    141 
    142 fn fork_with_worse_vrf_block(
    143     base: &Ledger,
    144     wallet: &Wallet,
    145     local_fork_block_hash: &str,
    146     first_timestamp_ms: u64,
    147 ) -> Option<Ledger> {
    148     for offset in 0..10_000 {
    149         let mut candidate = base.clone();
    150         let timestamp_ms = next_ticket_slot_timestamp(&candidate, first_timestamp_ms + offset);
    151         let hash = mine_wallet_burn_block(&mut candidate, wallet, timestamp_ms);
    152         if hash.as_str() > local_fork_block_hash {
    153             return Some(candidate);
    154         }
    155     }
    156     None
    157 }
    158 
    159 fn starter_node(wallet: Wallet) -> NodeCore {
    160     let mut genesis = BTreeMap::new();
    161     genesis.insert(wallet.address().to_string(), 1);
    162     let ledger =
    163         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25)
    164             .unwrap();
    165     NodeCore::from_ledger_with_burn_fee_and_enabled(
    166         wallet,
    167         ledger,
    168         true,
    169         DEFAULT_BURN_PER_BLOCK,
    170         DEFAULT_FEE_PER_BYTE,
    171     )
    172 }
    173 
    174 #[test]
    175 fn genesis_burn_starts_chain_with_reward_and_first_leader() {
    176     let alice = Wallet::from_seed("alice");
    177     let node = starter_node(alice.clone());
    178 
    179     let genesis = &node.ledger().chain()[0];
    180     assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD);
    181     assert_eq!(genesis.height, 0);
    182     assert_eq!(genesis.miner, alice.address());
    183     assert_eq!(genesis.reward, BLOCK_REWARD);
    184     assert_eq!(genesis.transactions.len(), 1);
    185     assert!(genesis.transactions[0].is_burn());
    186     assert_eq!(genesis.transactions[0].amount(), 1);
    187     assert_eq!(
    188         node.ledger().expected_leader_for_next_block().as_deref(),
    189         Some(alice.address())
    190     );
    191 }
    192 
    193 #[test]
    194 fn burn_amount_weights_leader_selection() {
    195     let mut high_weight_leaders = 0;
    196     for sample in 0..100 {
    197         let low = Wallet::from_seed(&format!("weighted-low-{sample}"));
    198         let high = Wallet::from_seed(&format!("weighted-high-{sample}"));
    199         let mut allocations = BTreeMap::new();
    200         allocations.insert(low.address().to_string(), 1_000);
    201         allocations.insert(high.address().to_string(), 1_000);
    202         let ledger = Ledger::new_with_genesis_burns(
    203             allocations,
    204             vec![
    205                 GenesisBurn::new(low.address(), 1),
    206                 GenesisBurn::new(high.address(), 99),
    207             ],
    208             25,
    209         )
    210         .unwrap();
    211 
    212         if ledger.expected_leader_for_next_block().as_deref() == Some(high.address()) {
    213             high_weight_leaders += 1;
    214         }
    215     }
    216 
    217     assert!(
    218         high_weight_leaders >= 90,
    219         "high burn ticket should win most weighted draws, won {high_weight_leaders}/100"
    220     );
    221 }
    222 
    223 #[test]
    224 fn starter_node_waits_for_a_burn_before_vdf_work() {
    225     let alice = Wallet::from_seed("alice");
    226     let mut node = starter_node(alice.clone());
    227 
    228     let outcome = node.automatic_mine_once(1);
    229     assert_eq!(outcome.burned.as_ref().map(|tx| tx.amount()), Some(1));
    230     assert!(outcome.block.is_some(), "{outcome:?}");
    231     assert_eq!(node.ledger().status().height, 1);
    232     assert!(node.ledger().balance_of(alice.address()) < BLOCK_REWARD);
    233 }
    234 
    235 #[test]
    236 fn burn_in_block_creates_ticket_after_maturity_delay() {
    237     let alice = Wallet::from_seed("alice");
    238     let bob = Wallet::from_seed("bob");
    239     let mut allocations = BTreeMap::new();
    240     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    241     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    242 
    243     let mut ledger = Ledger::new(allocations, 10);
    244     submit_burn(&mut ledger, &bob, 80);
    245 
    246     let launch_leader =
    247         if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) {
    248             &alice
    249         } else {
    250             &bob
    251         };
    252     let first = ledger.mine_next_block(launch_leader, 1).unwrap();
    253     ledger.apply_block(first).unwrap();
    254 
    255     for height in 2..=3 {
    256         let leader_wallet =
    257             if ledger.expected_leader_for_next_block().as_deref() == Some(alice.address()) {
    258                 &alice
    259             } else {
    260                 &bob
    261             };
    262         submit_burn(&mut ledger, leader_wallet, 1);
    263         let block = ledger.mine_next_block(leader_wallet, height).unwrap();
    264         ledger.apply_block(block).unwrap();
    265     }
    266 
    267     assert_eq!(
    268         ledger.expected_leader_for_next_block().as_deref(),
    269         Some(bob.address())
    270     );
    271 
    272     assert!(
    273         ledger.mine_next_block(&alice, 4).is_err(),
    274         "the block 1 burn should not be eligible before its maturity delay, and only Bob should hold it at block 4"
    275     );
    276 
    277     submit_burn(&mut ledger, &bob, 1);
    278     assert!(ledger.mine_next_block(&bob, 4).is_ok());
    279 }
    280 
    281 #[test]
    282 fn transfer_and_burn_update_balances_when_block_is_applied() {
    283     let alice = Wallet::from_seed("alice");
    284     let bob = Wallet::from_seed("bob");
    285     let mut allocations = BTreeMap::new();
    286     allocations.insert(alice.address().to_string(), iuna(1_000));
    287     allocations.insert(bob.address().to_string(), iuna(100));
    288 
    289     let mut ledger = Ledger::new(allocations, 10);
    290     let transfer = transfer_tx(&ledger, &alice, bob.address(), iuna(125));
    291     ledger.submit_transaction(transfer).unwrap();
    292     let burn = burn_tx(&ledger, &alice, iuna(25));
    293     ledger.submit_transaction(burn).unwrap();
    294     let block = ledger.mine_next_block(&alice, 1).unwrap();
    295     ledger.apply_block(block).unwrap();
    296 
    297     assert_eq!(ledger.balance_of(alice.address()), iuna(850));
    298     assert_eq!(ledger.balance_of(bob.address()), iuna(225));
    299 }
    300 
    301 #[test]
    302 fn forged_transaction_is_rejected() {
    303     let alice = Wallet::from_seed("alice");
    304     let bob = Wallet::from_seed("bob");
    305     let mut allocations = BTreeMap::new();
    306     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    307     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    308     let mut ledger = Ledger::new(allocations, 10);
    309 
    310     let mut forged = burn_tx(&ledger, &bob, 10);
    311     if let iuna::domain::Transaction::Burn { inputs, .. } = &mut forged {
    312         inputs[0].owner = alice.address().to_string();
    313     }
    314 
    315     let error = ledger.submit_transaction(forged).unwrap_err();
    316     assert!(error.to_string().contains("signature"));
    317 }
    318 
    319 #[test]
    320 fn block_with_forged_transaction_is_rejected() {
    321     let alice = Wallet::from_seed("alice");
    322     let mut allocations = BTreeMap::new();
    323     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    324     let mut ledger = Ledger::new(allocations, 10);
    325     submit_burn(&mut ledger, &alice, 1);
    326 
    327     let mut block = ledger.mine_next_block(&alice, 1).unwrap();
    328     if let iuna::domain::Transaction::Burn { signature, .. } = &mut block.transactions[0] {
    329         signature.push_str("00");
    330     }
    331     block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
    332     block.hash = block.compute_hash();
    333 
    334     let error = ledger.apply_block(block).unwrap_err();
    335     assert!(error.to_string().contains("signature"));
    336 }
    337 
    338 #[test]
    339 fn mine_action_uses_fixed_reward_and_finalizer_fee() {
    340     let alice = Wallet::from_seed("alice");
    341     let mut allocations = BTreeMap::new();
    342     allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA);
    343 
    344     let mut ledger = Ledger::new(allocations, 10);
    345     submit_burn(&mut ledger, &alice, MICRO_IUNA);
    346     let mine = ledger.build_mine(alice.address()).unwrap();
    347     assert_eq!(mine.amount(), MINE_REWARD);
    348     assert_eq!(mine.fee(), MICRO_IUNA);
    349     ledger.submit_transaction(mine.clone()).unwrap();
    350     let block = ledger.mine_next_block(&alice, 1).unwrap();
    351     assert_eq!(block.reward, MICRO_IUNA);
    352 
    353     ledger.apply_block(block).unwrap();
    354     assert_eq!(
    355         ledger.balance_of(alice.address()),
    356         2 * MICRO_IUNA + MINE_REWARD
    357     );
    358     assert!(!ledger.submit_transaction(mine).unwrap());
    359     assert_eq!(
    360         ledger.balance_of(alice.address()),
    361         2 * MICRO_IUNA + MINE_REWARD
    362     );
    363 }
    364 
    365 #[test]
    366 fn mine_action_protocol_fee_is_paid_to_block_finalizer() {
    367     let alice = Wallet::from_seed("mine-fee-alice");
    368     let bob = Wallet::from_seed("mine-fee-bob");
    369     let mut allocations = BTreeMap::new();
    370     allocations.insert(bob.address().to_string(), 2 * MICRO_IUNA);
    371 
    372     let mut ledger = Ledger::new(allocations, 10);
    373     let mine = ledger.build_mine(alice.address()).unwrap();
    374     assert_eq!(mine.amount(), MINE_REWARD);
    375     assert_eq!(mine.fee(), MICRO_IUNA);
    376     ledger.submit_transaction(mine).unwrap();
    377     submit_burn(&mut ledger, &bob, MICRO_IUNA);
    378 
    379     let block = ledger.mine_next_block(&bob, 1).unwrap();
    380     assert_eq!(block.reward, MICRO_IUNA);
    381     ledger.apply_block(block).unwrap();
    382 
    383     assert_eq!(ledger.balance_of(alice.address()), MINE_REWARD);
    384     assert_eq!(ledger.balance_of(bob.address()), 2 * MICRO_IUNA);
    385 }
    386 
    387 #[test]
    388 fn forged_mine_action_cannot_introduce_iuna() {
    389     let alice = Wallet::from_seed("forged-mine-alice");
    390     let mut allocations = BTreeMap::new();
    391     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    392     let mut ledger = Ledger::new(allocations, 10);
    393 
    394     let mut forged = ledger.build_mine(alice.address()).unwrap();
    395     if let iuna::domain::Transaction::Mine { nonce, .. } = &mut forged {
    396         *nonce += 1;
    397     }
    398 
    399     let error = ledger.submit_transaction(forged).unwrap_err();
    400     assert!(format!("{error:#}").contains("mine transaction proof hash is invalid"));
    401     assert_eq!(ledger.balance_of(alice.address()), MICRO_IUNA);
    402 }
    403 
    404 #[test]
    405 fn burn_larger_than_mine_reward_is_paid_from_existing_utxos() {
    406     let alice = Wallet::from_seed("large-burn-existing-utxos-alice");
    407     let mut allocations = BTreeMap::new();
    408     allocations.insert(alice.address().to_string(), BLOCK_REWARD + iuna(50));
    409 
    410     let mut ledger = Ledger::new(allocations, 10);
    411     let burn_amount = BLOCK_REWARD + iuna(25);
    412     let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap();
    413     ledger.submit_transaction(burn).unwrap();
    414 
    415     let block = ledger.mine_next_block(&alice, 1).unwrap();
    416     assert_eq!(block.transactions[0].amount(), burn_amount);
    417     assert_eq!(block.reward, 0);
    418 
    419     ledger.apply_block(block).unwrap();
    420     assert_eq!(ledger.balance_of(alice.address()), iuna(25));
    421 }
    422 
    423 #[test]
    424 fn burn_larger_than_existing_balance_cannot_use_next_block_reward() {
    425     let alice = Wallet::from_seed("large-burn-cannot-use-next-reward-alice");
    426     let mut allocations = BTreeMap::new();
    427     allocations.insert(alice.address().to_string(), BLOCK_REWARD);
    428 
    429     let ledger = Ledger::new(allocations, 10);
    430     let error = ledger
    431         .build_burn(&alice, BLOCK_REWARD + MICRO_IUNA, 0)
    432         .unwrap_err();
    433 
    434     assert!(format!("{error:#}").contains("insufficient funds"));
    435 }
    436 
    437 #[test]
    438 fn transaction_fees_are_paid_to_the_block_finalizer() {
    439     let alice = Wallet::from_seed("fee-miner-alice");
    440     let bob = Wallet::from_seed("fee-payer-bob");
    441     let mut allocations = BTreeMap::new();
    442     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    443     allocations.insert(bob.address().to_string(), iuna(200));
    444 
    445     let mut ledger = Ledger::new_with_genesis_burns(
    446         allocations,
    447         vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
    448         10,
    449     )
    450     .unwrap();
    451     submit_burn(&mut ledger, &alice, MICRO_IUNA);
    452     let tx = transfer_fee_tx(&ledger, &bob, alice.address(), iuna(10), iuna(7));
    453     ledger.submit_transaction(tx).unwrap();
    454 
    455     let block = ledger.mine_next_block(&alice, 1).unwrap();
    456     assert_eq!(block.reward, iuna(7));
    457     ledger.apply_block(block).unwrap();
    458 
    459     assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + iuna(16));
    460     assert_eq!(ledger.balance_of(bob.address()), iuna(183));
    461 }
    462 
    463 #[test]
    464 fn miner_orders_block_transactions_by_fee_rate_after_required_burn() {
    465     let wallets = wallets(&["fee-order-alice", "fee-order-bob", "fee-order-carol"]);
    466     let alice = &wallets[0];
    467     let bob = &wallets[1];
    468     let carol = &wallets[2];
    469     let mut allocations = allocations(&wallets, 1_000);
    470     allocations.insert(alice.address().to_string(), 1);
    471     let mut ledger =
    472         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10)
    473             .unwrap();
    474     let required_burn = burn_tx(&ledger, alice, 1);
    475     let low_fee = transfer_fee_tx(&ledger, bob, alice.address(), 1, 1);
    476     let high_fee = transfer_fee_tx(&ledger, carol, alice.address(), 1, 20);
    477     ledger.submit_transaction(low_fee.clone()).unwrap();
    478     ledger.submit_transaction(high_fee.clone()).unwrap();
    479     ledger.submit_transaction(required_burn.clone()).unwrap();
    480 
    481     let block = ledger.mine_next_block(alice, 1).unwrap();
    482     let signatures = block
    483         .transactions
    484         .iter()
    485         .map(|tx| tx.signature().to_string())
    486         .collect::<Vec<_>>();
    487 
    488     assert_eq!(signatures[0], required_burn.signature());
    489     assert!(
    490         signatures
    491             .iter()
    492             .position(|signature| signature == high_fee.signature())
    493             < signatures
    494                 .iter()
    495                 .position(|signature| signature == low_fee.signature())
    496     );
    497 }
    498 
    499 #[test]
    500 fn oversized_blocks_are_rejected() {
    501     let alice = Wallet::from_seed("oversized-block-alice");
    502     let bob = Wallet::from_seed("oversized-block-bob");
    503     let mut allocations = BTreeMap::new();
    504     allocations.insert(alice.address().to_string(), 1);
    505     allocations.insert(bob.address().to_string(), 1_000);
    506     let mut ledger =
    507         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10)
    508             .unwrap();
    509     submit_burn(&mut ledger, &alice, 1);
    510     let mut block = ledger.mine_next_block(&alice, 1).unwrap();
    511     let mut oversized_transfer = transfer_fee_tx(&ledger, &bob, alice.address(), 1, 3);
    512     if let iuna::domain::Transaction::Transfer { outputs, .. } = &mut oversized_transfer {
    513         outputs[0].address = "x".repeat(MAX_BLOCK_BYTES);
    514     }
    515     block.transactions.push(oversized_transfer);
    516     block.reward = 3;
    517     block.hash = block.compute_hash();
    518 
    519     let error = ledger.apply_block(block).unwrap_err();
    520 
    521     assert!(format!("{error:#}").contains("max block size"));
    522 }
    523 
    524 #[test]
    525 fn transfer_that_would_overflow_recipient_balance_is_rejected() {
    526     let alice = Wallet::from_seed("alice");
    527     let bob = Wallet::from_seed("bob");
    528     let mut allocations = BTreeMap::new();
    529     allocations.insert(alice.address().to_string(), 1);
    530     allocations.insert(bob.address().to_string(), Amount::MAX);
    531 
    532     let ledger = Ledger::new(allocations, 10);
    533     let error = ledger
    534         .build_transfer(&alice, bob.address(), 1, 0)
    535         .unwrap_err();
    536 
    537     assert!(format!("{error:#}").contains("balance overflow"));
    538 }
    539 
    540 #[test]
    541 fn mine_reward_that_would_overflow_recipient_balance_is_rejected() {
    542     let alice = Wallet::from_seed("alice");
    543     let mut allocations = BTreeMap::new();
    544     allocations.insert(alice.address().to_string(), Amount::MAX);
    545 
    546     let ledger = Ledger::new(allocations, 10);
    547     let error = ledger.build_mine(alice.address()).unwrap_err();
    548 
    549     assert!(format!("{error:#}").contains("balance overflow"));
    550 }
    551 
    552 #[test]
    553 fn block_without_mature_ticket_cannot_be_mined() {
    554     let alice = Wallet::from_seed("alice");
    555     let allocations = BTreeMap::new();
    556 
    557     let ledger = Ledger::new(allocations, 10);
    558     let error = ledger.mine_next_block(&alice, 1).unwrap_err();
    559     assert!(error.to_string().contains("mature burn ticket"));
    560 }
    561 
    562 #[test]
    563 fn vdf_work_requires_at_least_one_pending_burn() {
    564     let alice = Wallet::from_seed("alice");
    565     let mut allocations = BTreeMap::new();
    566     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    567 
    568     let ledger = Ledger::new(allocations, 10);
    569     let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err();
    570 
    571     assert!(format!("{error:#}").contains("at least one burn"));
    572 }
    573 
    574 #[test]
    575 fn leader_block_without_burn_is_rejected() {
    576     let alice = Wallet::from_seed("alice");
    577     let mut allocations = BTreeMap::new();
    578     allocations.insert(alice.address().to_string(), 1_000);
    579 
    580     let mut ledger = Ledger::new(allocations, 10);
    581     submit_burn(&mut ledger, &alice, 1);
    582     let mut block = ledger.mine_next_block(&alice, 1).unwrap();
    583     block.transactions.clear();
    584     block.hash = block.compute_hash();
    585 
    586     let error = ledger.apply_block(block).unwrap_err();
    587     assert!(format!("{error:#}").contains("at least one burn"));
    588 }
    589 
    590 #[test]
    591 fn block_hash_is_bound_to_block_contents() {
    592     let alice = Wallet::from_seed("alice");
    593     let mut allocations = BTreeMap::new();
    594     allocations.insert(alice.address().to_string(), 1_000);
    595 
    596     let mut ledger = Ledger::new(allocations, 10);
    597     submit_burn(&mut ledger, &alice, 1);
    598     let mut block = ledger.mine_next_block(&alice, 1).unwrap();
    599     block.timestamp_ms += 1;
    600 
    601     let error = ledger.apply_block(block).unwrap_err();
    602     assert!(error.to_string().contains("block hash is invalid"));
    603 }
    604 
    605 #[test]
    606 fn automatic_mining_burns_configured_amount_once_per_height() {
    607     let alice = Wallet::from_seed("alice");
    608     let bob = Wallet::from_seed("auto-once-leader");
    609     let mut allocations = BTreeMap::new();
    610     allocations.insert(alice.address().to_string(), iuna(1_000));
    611     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    612     let ledger = Ledger::new_with_genesis_burns(
    613         allocations,
    614         vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
    615         10,
    616     )
    617     .unwrap();
    618     assert_eq!(
    619         ledger.expected_leader_for_next_block().as_deref(),
    620         Some(bob.address())
    621     );
    622     let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    623         alice.clone(),
    624         ledger,
    625         true,
    626         iuna(25),
    627         DEFAULT_FEE_PER_BYTE,
    628     );
    629 
    630     let first = node.automatic_mine_once(1);
    631     assert!(first.burned.is_some());
    632     let burned = first.burned.as_ref().unwrap();
    633     assert_eq!(burned.amount(), iuna(25));
    634     assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE);
    635     assert!(first.block.is_none());
    636     assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
    637 
    638     let second = node.automatic_mine_once(2);
    639     assert!(second.burned.is_none());
    640     assert!(second.block.is_none());
    641     assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
    642 }
    643 
    644 #[test]
    645 fn default_automatic_mining_does_not_burn() {
    646     assert_eq!(DEFAULT_BURN_PER_BLOCK, 0);
    647 
    648     let alice = Wallet::from_seed("alice");
    649     let mut allocations = BTreeMap::new();
    650     allocations.insert(alice.address().to_string(), 1_000);
    651     let mut node = node("alice", alice.clone(), allocations);
    652 
    653     let outcome = node.automatic_mine_once(1);
    654     assert!(outcome.burned.is_none());
    655     assert!(outcome.block.is_none());
    656     assert!(
    657         outcome
    658             .skipped_reason
    659             .as_deref()
    660             .is_some_and(|reason| reason.contains("automatic mining is off"))
    661     );
    662     assert_eq!(node.ledger().balance_of(alice.address()), 1_000);
    663 }
    664 
    665 #[test]
    666 fn burn_per_block_can_be_set_to_zero() {
    667     let alice = Wallet::from_seed("alice");
    668     let mut allocations = BTreeMap::new();
    669     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    670     let mut node = node("alice", alice, allocations);
    671 
    672     let burned = node.set_burn_per_block(25).unwrap();
    673     assert!(burned.is_some());
    674     assert!(node.status().mining.automatic);
    675     assert_eq!(node.status().mining.burn_per_block, 25);
    676     let burned = node.set_burn_per_block(0).unwrap();
    677     assert!(burned.is_none());
    678     assert!(!node.status().mining.automatic);
    679     assert_eq!(node.status().mining.burn_per_block, 0);
    680 }
    681 
    682 #[test]
    683 fn automatic_burn_status_shows_configured_fee() {
    684     let alice = Wallet::from_seed("auto-fee-status-alice");
    685     let mut allocations = BTreeMap::new();
    686     allocations.insert(alice.address().to_string(), 1_000);
    687     let mut node = node("alice", alice, allocations);
    688 
    689     assert_eq!(node.status().mining.automatic_burn_fee, 1);
    690 
    691     node.set_burn_per_block(1).unwrap();
    692     assert_eq!(node.status().mining.burn_per_block, 1);
    693     assert_eq!(node.status().mining.automatic_burn_fee, 1);
    694 
    695     node.set_automatic_burn(50, 3).unwrap();
    696     assert_eq!(node.status().mining.burn_per_block, 50);
    697     assert_eq!(node.status().mining.automatic_burn_fee, 3);
    698 }
    699 
    700 #[test]
    701 fn automatic_mining_uses_configured_burn_fee() {
    702     let alice = Wallet::from_seed("auto-fee-burn-alice");
    703     let bob = Wallet::from_seed("auto-fee-burn-leader");
    704     let mut allocations = BTreeMap::new();
    705     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    706     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    707     let ledger = Ledger::new_with_genesis_burns(
    708         allocations,
    709         vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
    710         25,
    711     )
    712     .unwrap();
    713     assert_eq!(
    714         ledger.expected_leader_for_next_block().as_deref(),
    715         Some(bob.address())
    716     );
    717     let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(alice, ledger, true, 50, 3);
    718 
    719     let outcome = node.automatic_mine_once(1);
    720     let burned = outcome.burned.as_ref().unwrap();
    721     assert_eq!(burned.amount(), 50);
    722     assert!(burned.fee() > burned.economic_size_bytes() as u64 * 3);
    723     assert!(outcome.block.is_none());
    724     assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
    725 }
    726 
    727 #[test]
    728 fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
    729     let alice = Wallet::from_seed("auto-burn-cap-alice");
    730     let bob = Wallet::from_seed("auto-burn-cap-leader");
    731     let mut allocations = BTreeMap::new();
    732     allocations.insert(alice.address().to_string(), BLOCK_REWARD);
    733     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    734     let ledger = Ledger::new_with_genesis_burns(
    735         allocations,
    736         vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
    737         10,
    738     )
    739     .unwrap();
    740     assert_eq!(
    741         ledger.expected_leader_for_next_block().as_deref(),
    742         Some(bob.address())
    743     );
    744     let planning_node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    745         alice.clone(),
    746         ledger.clone(),
    747         true,
    748         BLOCK_REWARD + iuna(50),
    749         DEFAULT_FEE_PER_BYTE,
    750     );
    751     let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    752         alice.clone(),
    753         ledger,
    754         true,
    755         BLOCK_REWARD + iuna(50),
    756         DEFAULT_FEE_PER_BYTE,
    757     );
    758 
    759     let outcome = node.automatic_mine_once(1);
    760 
    761     let burned = outcome.burned.as_ref().unwrap();
    762     let next_amount = burned.amount() + 1;
    763     if let Ok(next_estimate) = planning_node.estimate_burn_fee(next_amount, DEFAULT_FEE_PER_BYTE) {
    764         assert!(next_amount + next_estimate.fee > BLOCK_REWARD);
    765     }
    766     assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE);
    767     assert!(outcome.block.is_none());
    768     assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
    769 }
    770 
    771 #[test]
    772 fn automatic_mining_preserves_configured_burn_when_only_fee_is_short() {
    773     let alice = Wallet::from_seed("auto-burn-exact-target-alice");
    774     let bob = Wallet::from_seed("auto-burn-exact-target-leader");
    775     let mut allocations = BTreeMap::new();
    776     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    777     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    778     let ledger = Ledger::new_with_genesis_burns(
    779         allocations,
    780         vec![GenesisBurn::new(bob.address(), MICRO_IUNA)],
    781         10,
    782     )
    783     .unwrap();
    784     assert_eq!(
    785         ledger.expected_leader_for_next_block().as_deref(),
    786         Some(bob.address())
    787     );
    788     let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    789         alice.clone(),
    790         ledger,
    791         true,
    792         MICRO_IUNA,
    793         DEFAULT_FEE_PER_BYTE,
    794     );
    795 
    796     let outcome = node.automatic_mine_once(1);
    797 
    798     let burned = outcome.burned.as_ref().unwrap();
    799     assert_eq!(burned.amount(), MICRO_IUNA);
    800     assert_eq!(burned.fee(), 0);
    801     assert!(outcome.block.is_none());
    802     assert_eq!(node.ledger().pending_blinded_transactions().len(), 1);
    803 }
    804 
    805 #[test]
    806 fn setting_burn_rate_after_running_at_zero_prepares_private_anchor_burn() {
    807     let alice = Wallet::from_seed("alice");
    808     let bob = Wallet::from_seed("bob");
    809     let mut allocations = BTreeMap::new();
    810     allocations.insert(alice.address().to_string(), MICRO_IUNA);
    811     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    812 
    813     let mut ledger = Ledger::new(allocations.clone(), 25);
    814     submit_burn(&mut ledger, &alice, 1);
    815     let first = ledger.mine_next_block(&alice, 1).unwrap();
    816     ledger.apply_block(first).unwrap();
    817 
    818     let mut alice_node = node("alice", alice.clone(), allocations);
    819     alice_node
    820         .receive(iuna::app::GossipEnvelope::ChainSnapshot(ledger.snapshot()))
    821         .unwrap();
    822 
    823     let skipped = alice_node.automatic_mine_once(2);
    824     assert!(skipped.burned.is_none());
    825     assert!(skipped.block.is_none());
    826 
    827     let burned = alice_node.set_burn_per_block(1).unwrap();
    828 
    829     assert!(burned.is_some());
    830     assert!(alice_node.ledger().pending().is_empty());
    831     let outcome = alice_node.automatic_mine_once(2);
    832     assert!(outcome.block.is_some());
    833 }
    834 
    835 #[test]
    836 fn automatic_mining_waits_when_wallet_is_not_selected_leader() {
    837     let alice = Wallet::from_seed("alice");
    838     let bob = Wallet::from_seed("bob");
    839     let mut allocations = BTreeMap::new();
    840     allocations.insert(alice.address().to_string(), 1_000);
    841     allocations.insert(bob.address().to_string(), MICRO_IUNA);
    842 
    843     let mut ledger = Ledger::new(allocations.clone(), 25);
    844     submit_burn(&mut ledger, &alice, 1);
    845     let first = ledger.mine_next_block(&alice, 1).unwrap();
    846     ledger.apply_block(first).unwrap();
    847 
    848     let mut bob_node = node("bob", bob.clone(), allocations);
    849     bob_node.set_burn_per_block(10).unwrap();
    850     bob_node
    851         .receive(iuna::app::GossipEnvelope::Block(ledger.chain()[1].clone()))
    852         .unwrap();
    853 
    854     let outcome = bob_node.automatic_mine_once(2);
    855     assert!(outcome.burned.is_some());
    856     assert!(outcome.block.is_none());
    857     assert!(outcome.skipped_reason.unwrap().contains(alice.address()));
    858     assert_eq!(bob_node.ledger().chain().len(), 2);
    859 }
    860 
    861 #[test]
    862 fn waiting_wallet_gossips_pending_burn_to_selected_leader() {
    863     let alice = Wallet::from_seed("deadlock-alice");
    864     let bob = Wallet::from_seed("deadlock-bob");
    865     let mut allocations = BTreeMap::new();
    866     allocations.insert(alice.address().to_string(), iuna(1_000));
    867     allocations.insert(bob.address().to_string(), iuna(1_000));
    868     let ledger =
    869         Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25)
    870             .unwrap();
    871 
    872     assert_eq!(
    873         ledger.expected_leader_for_next_block().as_deref(),
    874         Some(bob.address())
    875     );
    876     let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1);
    877     let mut bob_node =
    878         NodeCore::from_ledger_with_burn_fee_and_enabled(bob.clone(), ledger, true, 1, 1);
    879 
    880     let alice_outcome = alice_node.automatic_mine_once(1);
    881     assert!(alice_outcome.burned.is_some());
    882     assert!(alice_outcome.block.is_none());
    883     assert!(
    884         alice_outcome
    885             .skipped_reason
    886             .unwrap()
    887             .contains(bob.address())
    888     );
    889     assert!(bob_node.ledger().pending().is_empty());
    890 
    891     for envelope in alice_node.mempool_gossip() {
    892         bob_node.receive(envelope).unwrap();
    893     }
    894 
    895     assert!(bob_node.ledger().pending().is_empty());
    896     assert_eq!(bob_node.ledger().pending_blinded_transactions().len(), 1);
    897     let bob_outcome = bob_node.automatic_mine_once(1);
    898     assert!(bob_outcome.skipped_reason.is_none());
    899     assert!(bob_outcome.block.is_some());
    900 }
    901 
    902 #[test]
    903 fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() {
    904     let alice = Wallet::from_seed("pob-only-finalizer-alice");
    905     let bob = Wallet::from_seed("pow-only-miner-bob");
    906     let mut allocations = BTreeMap::new();
    907     allocations.insert(alice.address().to_string(), 2 * MICRO_IUNA);
    908     let ledger = Ledger::new_with_genesis_burns(
    909         allocations,
    910         vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
    911         25,
    912     )
    913     .unwrap();
    914 
    915     assert_eq!(
    916         ledger.expected_leader_for_next_block().as_deref(),
    917         Some(alice.address())
    918     );
    919     let mut alice_node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    920         alice,
    921         ledger.clone(),
    922         true,
    923         DEFAULT_BURN_PER_BLOCK,
    924         DEFAULT_FEE_PER_BYTE,
    925     );
    926     let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled(
    927         bob.clone(),
    928         ledger,
    929         false,
    930         DEFAULT_BURN_PER_BLOCK,
    931         DEFAULT_FEE_PER_BYTE,
    932     );
    933     bob_node.set_pow_mining_enabled(true);
    934 
    935     let bob_plan = (1..10_000)
    936         .map(|timestamp| bob_node.prepare_automatic_mining(timestamp))
    937         .find(|plan| plan.pow_mined.is_some())
    938         .expect("B should eventually queue a mine action");
    939     let mine = bob_plan.pow_mined.expect("B should queue a mine action");
    940     assert_eq!(
    941         bob_plan.skipped_reason.as_deref(),
    942         Some("automatic mining is off")
    943     );
    944     assert!(alice_node.ledger().pending().is_empty());
    945 
    946     for envelope in bob_node.drain_outbox() {
    947         alice_node.receive(envelope).unwrap();
    948     }
    949 
    950     assert!(!alice_node.ledger().pending().is_empty());
    951     assert!(
    952         alice_node
    953             .ledger()
    954             .pending_blinded_transactions()
    955             .is_empty()
    956     );
    957     assert!(
    958         alice_node
    959             .ledger()
    960             .pending()
    961             .iter()
    962             .any(|tx| tx.signature() == mine.signature())
    963     );
    964 }
    965 
    966 #[test]
    967 fn block_with_wrong_vdf_rounds_is_rejected() {
    968     let wallet = Wallet::from_seed("alice");
    969     let mut genesis = BTreeMap::new();
    970     genesis.insert(wallet.address().to_string(), 1_000);
    971     let mut ledger =
    972         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 25)
    973             .unwrap();
    974     submit_burn(&mut ledger, &wallet, 1);
    975 
    976     let mut block = ledger.mine_next_block(&wallet, 1).unwrap();
    977     block.vdf_rounds = 1;
    978     block.hash = block.compute_hash();
    979     block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
    980 
    981     assert!(ledger.apply_block(block).is_err());
    982 }
    983 
    984 #[test]
    985 fn fallback_finalizer_can_build_block_with_extra_vdf_rounds() {
    986     let alice = Wallet::from_seed("fallback-finalizer-alice");
    987     let bob = Wallet::from_seed("fallback-finalizer-bob");
    988     let wallets = [&alice, &bob];
    989     let mut genesis = BTreeMap::new();
    990     genesis.insert(alice.address().to_string(), iuna(10));
    991     genesis.insert(bob.address().to_string(), iuna(10));
    992     let mut ledger = Ledger::new_with_genesis_burns(
    993         genesis,
    994         vec![
    995             GenesisBurn::new(alice.address(), 1),
    996             GenesisBurn::new(bob.address(), 1),
    997         ],
    998         25,
    999     )
   1000     .unwrap();
   1001     let leader = ledger.expected_leader_for_next_block().unwrap();
   1002     let fallback = wallets
   1003         .into_iter()
   1004         .find(|wallet| wallet.address() != leader)
   1005         .unwrap();
   1006 
   1007     submit_burn(&mut ledger, fallback, 1);
   1008     let block = ledger.mine_next_block(fallback, 1).unwrap();
   1009 
   1010     assert_eq!(block.miner, fallback.address());
   1011     assert_eq!(block.finalizer_rank, 1);
   1012     assert_eq!(block.vdf_rounds, 50);
   1013     ledger.apply_block(block).unwrap();
   1014 }
   1015 
   1016 #[test]
   1017 fn fallback_finalizer_with_primary_vdf_rounds_is_rejected() {
   1018     let alice = Wallet::from_seed("fallback-rounds-alice");
   1019     let bob = Wallet::from_seed("fallback-rounds-bob");
   1020     let wallets = [&alice, &bob];
   1021     let mut genesis = BTreeMap::new();
   1022     genesis.insert(alice.address().to_string(), iuna(10));
   1023     genesis.insert(bob.address().to_string(), iuna(10));
   1024     let mut ledger = Ledger::new_with_genesis_burns(
   1025         genesis,
   1026         vec![
   1027             GenesisBurn::new(alice.address(), 1),
   1028             GenesisBurn::new(bob.address(), 1),
   1029         ],
   1030         25,
   1031     )
   1032     .unwrap();
   1033     let leader = ledger.expected_leader_for_next_block().unwrap();
   1034     let fallback = wallets
   1035         .into_iter()
   1036         .find(|wallet| wallet.address() != leader)
   1037         .unwrap();
   1038 
   1039     submit_burn(&mut ledger, fallback, 1);
   1040     let mut block = ledger.mine_next_block(fallback, 1).unwrap();
   1041     block.vdf_rounds = 25;
   1042     block.vdf_output = run_vdf(&block.vdf_seed(), block.vdf_rounds);
   1043     block.hash = block.compute_hash();
   1044 
   1045     let error = ledger.apply_block(block).unwrap_err();
   1046     assert!(format!("{error:#}").contains("block VDF rounds are invalid"));
   1047 }
   1048 
   1049 #[test]
   1050 fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() {
   1051     let alice = Wallet::from_seed("fallback-network-alice");
   1052     let bob = Wallet::from_seed("fallback-network-bob");
   1053     let wallets = [&alice, &bob];
   1054     let mut genesis = BTreeMap::new();
   1055     genesis.insert(alice.address().to_string(), iuna(10));
   1056     genesis.insert(bob.address().to_string(), iuna(10));
   1057     let ledger = Ledger::new_with_genesis_burns(
   1058         genesis,
   1059         vec![
   1060             GenesisBurn::new(alice.address(), 1),
   1061             GenesisBurn::new(bob.address(), 1),
   1062         ],
   1063         25,
   1064     )
   1065     .unwrap();
   1066     let primary = ledger.expected_leader_for_next_block().unwrap();
   1067     let fallback = wallets
   1068         .into_iter()
   1069         .find(|wallet| wallet.address() != primary)
   1070         .unwrap();
   1071     let primary_wallet = wallets
   1072         .into_iter()
   1073         .find(|wallet| wallet.address() == primary)
   1074         .unwrap();
   1075 
   1076     let mut network = InMemoryNetwork::default();
   1077     network.insert(
   1078         "primary",
   1079         NodeCore::from_ledger(
   1080             primary_wallet.clone(),
   1081             ledger.clone(),
   1082             DEFAULT_BURN_PER_BLOCK,
   1083         ),
   1084     );
   1085     network.insert(
   1086         "fallback",
   1087         NodeCore::from_ledger(fallback.clone(), ledger, DEFAULT_BURN_PER_BLOCK),
   1088     );
   1089 
   1090     queue_plaintext_burn(network.node_mut("fallback").unwrap(), fallback, 1);
   1091     let block = network
   1092         .node_mut("fallback")
   1093         .unwrap()
   1094         .mine_one_at(1)
   1095         .unwrap();
   1096     assert_eq!(block.finalizer_rank, 1);
   1097     assert_eq!(block.miner, fallback.address());
   1098     network.deliver_until_idle().unwrap();
   1099 
   1100     let tip = network.node("fallback").unwrap().ledger().status().tip_hash;
   1101     for id in ["primary", "fallback"] {
   1102         let status = network.node(id).unwrap().ledger().status();
   1103         assert_eq!(status.height, 1, "{id} did not accept fallback block");
   1104         assert_eq!(status.tip_hash, tip, "{id} has a different tip");
   1105     }
   1106 }
   1107 
   1108 #[test]
   1109 fn recovery_block_is_rejected_before_timeout() {
   1110     let alice = Wallet::from_seed("recovery-before-timeout-alice");
   1111     let bob = Wallet::from_seed("recovery-before-timeout-bob");
   1112     let mut genesis = BTreeMap::new();
   1113     genesis.insert(alice.address().to_string(), iuna(10));
   1114     genesis.insert(bob.address().to_string(), iuna(10));
   1115     let mut ledger =
   1116         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
   1117             .unwrap();
   1118 
   1119     submit_burn(&mut ledger, &bob, 1);
   1120     let error = ledger
   1121         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS - 1)
   1122         .unwrap_err();
   1123 
   1124     assert!(format!("{error:#}").contains("recovery block is not available"));
   1125 }
   1126 
   1127 #[test]
   1128 fn recovery_block_unblocks_chain_when_only_ticket_holder_stops() {
   1129     let alice = Wallet::from_seed("recovery-unblocks-alice");
   1130     let bob = Wallet::from_seed("recovery-unblocks-bob");
   1131     let mut genesis = BTreeMap::new();
   1132     genesis.insert(alice.address().to_string(), iuna(10));
   1133     genesis.insert(bob.address().to_string(), iuna(10));
   1134     let mut ledger =
   1135         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
   1136             .unwrap();
   1137 
   1138     assert_eq!(
   1139         ledger.expected_leader_for_next_block().as_deref(),
   1140         Some(alice.address())
   1141     );
   1142     submit_burn(&mut ledger, &bob, 1);
   1143     assert!(ledger.mine_next_block(&bob, 1).is_err());
   1144 
   1145     let block = ledger
   1146         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   1147         .unwrap();
   1148 
   1149     assert_eq!(block.miner, bob.address());
   1150     assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
   1151     assert_eq!(block.finalizer_rank, 0);
   1152     assert_eq!(block.vdf_rounds, 25);
   1153     assert!(block.leader_proof.is_none());
   1154     ledger.apply_block(block).unwrap();
   1155     assert_eq!(ledger.status().height, 1);
   1156 }
   1157 
   1158 #[test]
   1159 fn recovery_block_requires_finalizer_own_burn() {
   1160     let alice = Wallet::from_seed("recovery-own-burn-alice");
   1161     let bob = Wallet::from_seed("recovery-own-burn-bob");
   1162     let mut genesis = BTreeMap::new();
   1163     genesis.insert(alice.address().to_string(), iuna(10));
   1164     genesis.insert(bob.address().to_string(), iuna(10));
   1165     let mut ledger =
   1166         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
   1167             .unwrap();
   1168 
   1169     submit_burn(&mut ledger, &alice, 1);
   1170     let error = ledger
   1171         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   1172         .unwrap_err();
   1173 
   1174     assert!(format!("{error:#}").contains("burn from the finalizer"));
   1175 }
   1176 
   1177 #[test]
   1178 fn recovery_block_prioritizes_finalizer_own_burn() {
   1179     let alice = Wallet::from_seed("recovery-prioritizes-alice");
   1180     let bob = Wallet::from_seed("recovery-prioritizes-bob");
   1181     let mut genesis = BTreeMap::new();
   1182     genesis.insert(alice.address().to_string(), iuna(10));
   1183     genesis.insert(bob.address().to_string(), iuna(10));
   1184     let mut ledger =
   1185         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
   1186             .unwrap();
   1187 
   1188     let alice_burn = ledger.build_burn(&alice, 1, 1).unwrap();
   1189     ledger.submit_transaction(alice_burn).unwrap();
   1190     let bob_burn = ledger.build_burn(&bob, 1, 0).unwrap();
   1191     let bob_burn_signature = bob_burn.signature().to_string();
   1192     ledger.submit_transaction(bob_burn).unwrap();
   1193 
   1194     let block = ledger
   1195         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   1196         .unwrap();
   1197 
   1198     assert!(
   1199         block
   1200             .transactions
   1201             .iter()
   1202             .any(|tx| tx.signature() == bob_burn_signature)
   1203     );
   1204 }
   1205 
   1206 #[test]
   1207 fn recovery_vdf_seed_is_bound_to_timestamp() {
   1208     let alice = Wallet::from_seed("recovery-vdf-seed-alice");
   1209     let bob = Wallet::from_seed("recovery-vdf-seed-bob");
   1210     let mut genesis = BTreeMap::new();
   1211     genesis.insert(alice.address().to_string(), iuna(10));
   1212     genesis.insert(bob.address().to_string(), iuna(10));
   1213     let mut ledger =
   1214         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
   1215             .unwrap();
   1216 
   1217     submit_burn(&mut ledger, &bob, 1);
   1218     let mut block = ledger
   1219         .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
   1220         .unwrap();
   1221     block.timestamp_ms += 1;
   1222     block.hash = block.compute_hash();
   1223 
   1224     let error = ledger.apply_block(block).unwrap_err();
   1225     assert!(format!("{error:#}").contains("block VDF output is invalid"));
   1226 }
   1227 
   1228 #[test]
   1229 fn fork_choice_prefers_ticket_block_over_recovery_block_at_same_height() {
   1230     let alice = Wallet::from_seed("recovery-fork-alice");
   1231     let bob = Wallet::from_seed("recovery-fork-bob");
   1232     let wallets = [&alice, &bob];
   1233     let mut genesis = BTreeMap::new();
   1234     genesis.insert(alice.address().to_string(), iuna(10));
   1235     genesis.insert(bob.address().to_string(), iuna(10));
   1236     let common = Ledger::new_with_genesis_burns(
   1237         genesis,
   1238         vec![
   1239             GenesisBurn::new(alice.address(), 1),
   1240             GenesisBurn::new(bob.address(), 1),
   1241         ],
   1242         25,
   1243     )
   1244     .unwrap();
   1245     let leader_address = common.expected_leader_for_next_block().unwrap();
   1246     let leader = wallets
   1247         .iter()
   1248         .copied()
   1249         .find(|wallet| wallet.address() == leader_address)
   1250         .unwrap();
   1251     let recovery = wallets
   1252         .into_iter()
   1253         .find(|wallet| wallet.address() != leader_address)
   1254         .unwrap();
   1255 
   1256     let mut local = common.clone();
   1257     submit_burn(&mut local, recovery, 1);
   1258     let recovery_block = local
   1259         .mine_recovery_block(recovery, RECOVERY_BLOCK_DELAY_MS)
   1260         .unwrap();
   1261     assert_eq!(recovery_block.finalizer_mode, FinalizerMode::Recovery);
   1262     local.apply_block(recovery_block).unwrap();
   1263 
   1264     let mut remote = common;
   1265     submit_burn(&mut remote, leader, 1);
   1266     let leader_block = remote.mine_next_block(leader, 1).unwrap();
   1267     assert_eq!(leader_block.finalizer_mode, FinalizerMode::Ticket);
   1268     let leader_hash = leader_block.hash.clone();
   1269     remote.apply_block(leader_block).unwrap();
   1270 
   1271     assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
   1272     assert_eq!(local.status().tip_hash, leader_hash);
   1273 }
   1274 
   1275 #[test]
   1276 fn fork_choice_prefers_primary_finalizer_over_fallback_rank() {
   1277     let alice = Wallet::from_seed("fallback-fork-alice");
   1278     let bob = Wallet::from_seed("fallback-fork-bob");
   1279     let wallets = [&alice, &bob];
   1280     let mut genesis = BTreeMap::new();
   1281     genesis.insert(alice.address().to_string(), iuna(10));
   1282     genesis.insert(bob.address().to_string(), iuna(10));
   1283     let common = Ledger::new_with_genesis_burns(
   1284         genesis,
   1285         vec![
   1286             GenesisBurn::new(alice.address(), 1),
   1287             GenesisBurn::new(bob.address(), 1),
   1288         ],
   1289         25,
   1290     )
   1291     .unwrap();
   1292     let leader_address = common.expected_leader_for_next_block().unwrap();
   1293     let leader = wallets
   1294         .iter()
   1295         .copied()
   1296         .find(|wallet| wallet.address() == leader_address)
   1297         .unwrap();
   1298     let fallback = wallets
   1299         .into_iter()
   1300         .find(|wallet| wallet.address() != leader_address)
   1301         .unwrap();
   1302 
   1303     let mut local = common.clone();
   1304     submit_burn(&mut local, fallback, 1);
   1305     let fallback_block = local.mine_next_block(fallback, 1).unwrap();
   1306     assert_eq!(fallback_block.finalizer_rank, 1);
   1307     local.apply_block(fallback_block).unwrap();
   1308 
   1309     let mut remote = common;
   1310     submit_burn(&mut remote, leader, 1);
   1311     let leader_block = remote.mine_next_block(leader, 1).unwrap();
   1312     assert_eq!(leader_block.finalizer_rank, 0);
   1313     let leader_hash = leader_block.hash.clone();
   1314     remote.apply_block(leader_block).unwrap();
   1315 
   1316     assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
   1317     assert_eq!(local.status().tip_hash, leader_hash);
   1318 }
   1319 
   1320 #[test]
   1321 fn vdf_solution_verifies_without_rerunning_delay() {
   1322     let solution = run_vdf("test-seed", 128);
   1323 
   1324     assert!(verify_vdf("test-seed", 128, &solution));
   1325     assert!(!verify_vdf("other-seed", 128, &solution));
   1326     assert!(!verify_vdf("test-seed", 129, &solution));
   1327     assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution"));
   1328 }
   1329 
   1330 #[test]
   1331 fn vdf_rounds_retarget_toward_target_block_time() {
   1332     let wallet = Wallet::from_seed("alice");
   1333     let mut genesis = BTreeMap::new();
   1334     genesis.insert(wallet.address().to_string(), 1_000);
   1335     let mut ledger = Ledger::new(genesis, 100);
   1336 
   1337     submit_burn(&mut ledger, &wallet, 1);
   1338     let block1 = ledger
   1339         .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS)
   1340         .unwrap();
   1341     assert_eq!(block1.vdf_rounds, 100);
   1342     ledger.apply_block(block1).unwrap();
   1343     assert_eq!(ledger.vdf_rounds(), 100);
   1344 
   1345     submit_burn(&mut ledger, &wallet, 1);
   1346     let block2 = ledger
   1347         .mine_next_block(&wallet, VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2)
   1348         .unwrap();
   1349     assert_eq!(block2.vdf_rounds, 100);
   1350     ledger.apply_block(block2).unwrap();
   1351     assert_eq!(ledger.vdf_rounds(), 102);
   1352 
   1353     submit_burn(&mut ledger, &wallet, 1);
   1354     let block3 = ledger
   1355         .mine_next_block(
   1356             &wallet,
   1357             VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2,
   1358         )
   1359         .unwrap();
   1360     assert_eq!(block3.vdf_rounds, 102);
   1361     ledger.apply_block(block3).unwrap();
   1362     assert_eq!(ledger.vdf_rounds(), 100);
   1363 }
   1364 
   1365 #[test]
   1366 fn block_timestamp_too_far_in_future_is_rejected() {
   1367     let wallet = Wallet::from_seed("future-timestamp-alice");
   1368     let mut genesis = BTreeMap::new();
   1369     genesis.insert(wallet.address().to_string(), 1_000);
   1370     let mut ledger = Ledger::new(genesis, 25);
   1371     submit_burn(&mut ledger, &wallet, 1);
   1372 
   1373     let far_future = unix_now_ms()
   1374         .saturating_add(VDF_TARGET_BLOCK_MS)
   1375         .saturating_add(1);
   1376     let block = ledger.mine_next_block(&wallet, far_future).unwrap();
   1377 
   1378     let error = ledger.apply_block(block).unwrap_err();
   1379 
   1380     assert!(format!("{error:#}").contains("too far in the future"));
   1381 }
   1382 
   1383 #[test]
   1384 fn genesis_wall_clock_gap_does_not_lower_vdf_rounds() {
   1385     let wallet = Wallet::from_seed("genesis-gap-vdf-alice");
   1386     let mut genesis = BTreeMap::new();
   1387     genesis.insert(wallet.address().to_string(), 1_000);
   1388     let mut ledger = Ledger::new(genesis, 100);
   1389     let now = unix_now_ms();
   1390     let first_timestamp = now.saturating_sub(VDF_TARGET_BLOCK_MS);
   1391 
   1392     submit_burn(&mut ledger, &wallet, 1);
   1393     let block1 = ledger.mine_next_block(&wallet, first_timestamp).unwrap();
   1394     ledger.apply_block(block1).unwrap();
   1395     assert_eq!(ledger.vdf_rounds(), 100);
   1396 
   1397     submit_burn(&mut ledger, &wallet, 1);
   1398     let block2 = ledger.mine_next_block(&wallet, now).unwrap();
   1399     ledger.apply_block(block2).unwrap();
   1400 
   1401     assert_eq!(ledger.vdf_rounds(), 100);
   1402 }
   1403 
   1404 #[test]
   1405 fn conflicting_utxo_spends_are_not_accepted_together() {
   1406     let wallet = Wallet::from_seed("alice");
   1407     let mut genesis = BTreeMap::new();
   1408     genesis.insert(wallet.address().to_string(), 1_000);
   1409     let mut ledger = Ledger::new(genesis, 25);
   1410 
   1411     let first = burn_tx(&ledger, &wallet, 3);
   1412     let conflicting = burn_tx(&ledger, &wallet, 4);
   1413     ledger.submit_transaction(first.clone()).unwrap();
   1414     let outcome = ledger
   1415         .submit_transaction_with_outcome(conflicting.clone())
   1416         .unwrap();
   1417     assert_eq!(outcome, TransactionSubmitOutcome::ConflictsWithPending);
   1418     assert!(!ledger.submit_transaction(conflicting).unwrap());
   1419     assert_eq!(ledger.pending().len(), 1);
   1420 
   1421     let block = ledger
   1422         .prepare_next_block(wallet.address(), 1)
   1423         .unwrap()
   1424         .finish(&wallet, "test-vdf".to_string());
   1425     assert_eq!(block.transactions.len(), 1);
   1426     assert!(block.transactions.contains(&first));
   1427 }
   1428 
   1429 #[test]
   1430 fn in_memory_network_syncs_nodes_without_tcp() {
   1431     let alice = Wallet::from_seed("alice");
   1432     let bob = Wallet::from_seed("bob");
   1433     let mut allocations = BTreeMap::new();
   1434     allocations.insert(alice.address().to_string(), 1_000);
   1435     allocations.insert(bob.address().to_string(), 1_000);
   1436 
   1437     let mut network = InMemoryNetwork::default();
   1438     network.insert("alice", node("alice", alice.clone(), allocations.clone()));
   1439     network.insert("bob", node("bob", bob.clone(), allocations));
   1440 
   1441     queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 10);
   1442     network.deliver_until_idle().unwrap();
   1443     assert!(network.node("bob").unwrap().ledger().pending().is_empty());
   1444 
   1445     network.node_mut("alice").unwrap().mine_one().unwrap();
   1446     network.deliver_until_idle().unwrap();
   1447 
   1448     let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash;
   1449     let bob_tip = network.node("bob").unwrap().ledger().status().tip_hash;
   1450     assert_eq!(alice_tip, bob_tip);
   1451     assert_eq!(network.node("bob").unwrap().ledger().chain().len(), 2);
   1452 }
   1453 
   1454 #[test]
   1455 fn in_memory_network_delivers_transaction_to_multiple_peers() {
   1456     let wallets = wallets(&["alice", "bob", "carol", "dave"]);
   1457     let allocations = allocations(&wallets, MICRO_IUNA);
   1458     let mut network = InMemoryNetwork::default();
   1459 
   1460     for (name, wallet) in ["alice", "bob", "carol", "dave"]
   1461         .iter()
   1462         .zip(wallets.clone())
   1463     {
   1464         network.insert(*name, node(name, wallet, allocations.clone()));
   1465     }
   1466 
   1467     network.node_mut("alice").unwrap().burn(15).unwrap();
   1468     network.deliver_until_idle().unwrap();
   1469 
   1470     for name in ["bob", "carol", "dave"] {
   1471         let ledger = network.node(name).unwrap().ledger();
   1472         assert!(
   1473             ledger.pending().is_empty(),
   1474             "{name} received a plaintext transaction"
   1475         );
   1476         assert_eq!(
   1477             ledger.pending_blinded_transactions().len(),
   1478             1,
   1479             "{name} did not receive alice's blinded burn"
   1480         );
   1481     }
   1482 }
   1483 
   1484 #[test]
   1485 fn in_memory_network_syncs_mined_block_to_multiple_peers() {
   1486     let wallets = wallets(&["alice", "bob", "carol", "dave"]);
   1487     let allocations = allocations(&wallets, MICRO_IUNA);
   1488     let mut network = InMemoryNetwork::default();
   1489 
   1490     for (name, wallet) in ["alice", "bob", "carol", "dave"]
   1491         .iter()
   1492         .zip(wallets.clone())
   1493     {
   1494         network.insert(*name, node(name, wallet, allocations.clone()));
   1495     }
   1496 
   1497     queue_plaintext_burn(network.node_mut("alice").unwrap(), &wallets[0], 10);
   1498     network.deliver_until_idle().unwrap();
   1499     network.node_mut("alice").unwrap().mine_one().unwrap();
   1500     network.deliver_until_idle().unwrap();
   1501 
   1502     let tip = network.node("alice").unwrap().ledger().status().tip_hash;
   1503     for name in ["alice", "bob", "carol", "dave"] {
   1504         let ledger = network.node(name).unwrap().ledger();
   1505         assert_eq!(ledger.status().height, 1, "{name} is at the wrong height");
   1506         assert_eq!(ledger.status().tip_hash, tip, "{name} has a different tip");
   1507         assert!(
   1508             ledger.pending().is_empty(),
   1509             "{name} kept mined transactions"
   1510         );
   1511     }
   1512 }
   1513 
   1514 #[test]
   1515 fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() {
   1516     let alice = Wallet::from_seed("alice");
   1517     let bob = Wallet::from_seed("bob");
   1518     let wallets = vec![alice.clone(), bob];
   1519     let allocations = allocations(&wallets, 1_000);
   1520     let mut network = InMemoryNetwork::default();
   1521 
   1522     network.insert("alice", node("alice", alice.clone(), allocations.clone()));
   1523     network.insert("bob", node("bob", wallets[1].clone(), allocations));
   1524 
   1525     for height in 1..=5 {
   1526         let alice_node = network.node_mut("alice").unwrap();
   1527         queue_plaintext_burn(alice_node, &alice, 1);
   1528         alice_node
   1529             .mine_one_at(height * VDF_TARGET_BLOCK_MS)
   1530             .unwrap();
   1531     }
   1532     assert_eq!(network.node("alice").unwrap().ledger().status().height, 5);
   1533     assert_eq!(network.node("bob").unwrap().ledger().status().height, 0);
   1534 
   1535     assert!(network.sync_node_from_peer("alice", "bob", 2).unwrap());
   1536     assert_eq!(network.node("bob").unwrap().ledger().status().height, 2);
   1537 
   1538     assert!(network.sync_node_from_peer("alice", "bob", 10).unwrap());
   1539     let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash;
   1540     let bob_status = network.node("bob").unwrap().ledger().status();
   1541     assert_eq!(bob_status.height, 5);
   1542     assert_eq!(bob_status.tip_hash, alice_tip);
   1543 
   1544     network.deliver_until_idle().unwrap();
   1545     queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 1);
   1546     network.deliver_until_idle().unwrap();
   1547     network
   1548         .node_mut("alice")
   1549         .unwrap()
   1550         .mine_one_at(6 * VDF_TARGET_BLOCK_MS)
   1551         .unwrap();
   1552     network.deliver_until_idle().unwrap();
   1553 
   1554     let alice_tip = network.node("alice").unwrap().ledger().status().tip_hash;
   1555     let bob_status = network.node("bob").unwrap().ledger().status();
   1556     assert_eq!(bob_status.height, 6);
   1557     assert_eq!(bob_status.tip_hash, alice_tip);
   1558 }
   1559 
   1560 #[test]
   1561 fn joined_nodes_import_transfer_block_and_blinded_burn_reveal() {
   1562     let alice = Wallet::from_seed("flow-alice");
   1563     let bob = Wallet::from_seed("flow-bob");
   1564     let carol = Wallet::from_seed("flow-carol");
   1565     let mut genesis = BTreeMap::new();
   1566     genesis.insert(alice.address().to_string(), iuna(100));
   1567     let alice_ledger = Ledger::new_with_genesis_burns(
   1568         genesis,
   1569         vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
   1570         5,
   1571     )
   1572     .unwrap();
   1573 
   1574     let mut network = InMemoryNetwork::default();
   1575     network.insert(
   1576         "a",
   1577         NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK),
   1578     );
   1579     let mut mined_by = Vec::new();
   1580 
   1581     for height in 1..=2 {
   1582         queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1583         network.deliver_until_idle().unwrap();
   1584         let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap();
   1585         mined_by.push(block.miner.clone());
   1586         network.deliver_until_idle().unwrap();
   1587     }
   1588 
   1589     let bob_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap();
   1590     network.insert(
   1591         "b",
   1592         NodeCore::from_ledger(bob.clone(), bob_ledger, DEFAULT_BURN_PER_BLOCK),
   1593     );
   1594 
   1595     for height in 3..=4 {
   1596         queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1597         network.deliver_until_idle().unwrap();
   1598         let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap();
   1599         mined_by.push(block.miner.clone());
   1600         network.deliver_until_idle().unwrap();
   1601     }
   1602 
   1603     let carol_ledger = Ledger::from_snapshot(network.node("a").unwrap().chain_snapshot()).unwrap();
   1604     network.insert(
   1605         "c",
   1606         NodeCore::from_ledger(carol.clone(), carol_ledger, DEFAULT_BURN_PER_BLOCK),
   1607     );
   1608 
   1609     queue_plaintext_transfer(
   1610         network.node_mut("a").unwrap(),
   1611         &alice,
   1612         bob.address(),
   1613         iuna(30),
   1614     );
   1615     queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1616     let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap();
   1617     assert!(
   1618         block5
   1619             .transactions
   1620             .iter()
   1621             .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == iuna(30))
   1622     );
   1623     mined_by.push(block5.miner.clone());
   1624     network.deliver_until_idle().unwrap();
   1625 
   1626     for id in ["a", "b", "c"] {
   1627         assert_eq!(
   1628             network.node(id).unwrap().ledger().status().height,
   1629             5,
   1630             "{id} did not import block 5"
   1631         );
   1632         assert_eq!(
   1633             network.node(id).unwrap().ledger().balance_of(bob.address()),
   1634             iuna(30),
   1635             "{id} did not apply A -> B transfer"
   1636         );
   1637     }
   1638 
   1639     let bob_burn = network.node_mut("b").unwrap().burn(10).unwrap();
   1640     network.deliver_until_idle().unwrap();
   1641     assert_eq!(
   1642         network
   1643             .node("a")
   1644             .unwrap()
   1645             .ledger()
   1646             .pending_blinded_transactions()
   1647             .len(),
   1648         1
   1649     );
   1650 
   1651     queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1652     let commit_block = network.node_mut("a").unwrap().mine_one_at(6).unwrap();
   1653     assert_eq!(commit_block.blinded_transactions.len(), 1);
   1654     assert_eq!(commit_block.blinded_transactions[0].fee, bob_burn.fee());
   1655     mined_by.push(commit_block.miner.clone());
   1656     network.deliver_until_idle().unwrap();
   1657     assert_eq!(
   1658         network
   1659             .node("a")
   1660             .unwrap()
   1661             .ledger()
   1662             .pending_blinded_reveals()
   1663             .len(),
   1664         1
   1665     );
   1666 
   1667     queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1668     let reveal_block = network.node_mut("a").unwrap().mine_one_at(7).unwrap();
   1669     assert_eq!(reveal_block.all_blinded_reveals().len(), 1);
   1670     mined_by.push(reveal_block.miner.clone());
   1671     network.deliver_until_idle().unwrap();
   1672     let revealed = revealed_blinded_transactions(&network.node("a").unwrap().chain_snapshot())
   1673         .unwrap()
   1674         .into_iter()
   1675         .filter(|revealed| revealed.height == 7)
   1676         .collect::<Vec<_>>();
   1677     assert_eq!(revealed.len(), 1);
   1678     assert!(revealed[0].transaction.is_burn(), "{revealed:?}");
   1679     assert_eq!(revealed[0].transaction.amount(), 10);
   1680 
   1681     for height in 8..=9 {
   1682         queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1683         let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap();
   1684         mined_by.push(block.miner.clone());
   1685         network.deliver_until_idle().unwrap();
   1686     }
   1687 
   1688     let final_tip = network.node("a").unwrap().ledger().status().tip_hash;
   1689     for id in ["a", "b", "c"] {
   1690         assert_eq!(network.node(id).unwrap().ledger().status().height, 9);
   1691         assert_eq!(
   1692             network.node(id).unwrap().ledger().status().tip_hash,
   1693             final_tip
   1694         );
   1695     }
   1696     let ranks = network
   1697         .node("a")
   1698         .unwrap()
   1699         .burn_leader_ranks_for_block(10)
   1700         .unwrap();
   1701     assert!(
   1702         ranks.iter().any(|rank| rank.owner == bob.address()
   1703             && rank.amount == 10
   1704             && rank.eligible_from_height == 10),
   1705         "alice={} bob={} ranks={ranks:?}",
   1706         alice.address(),
   1707         bob.address()
   1708     );
   1709     assert!(mined_by.iter().all(|miner| miner == alice.address()));
   1710 }
   1711 
   1712 #[test]
   1713 fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() {
   1714     let temp = tempdir().unwrap();
   1715     let alice = Wallet::from_seed("persistent-flow-alice");
   1716     let bob = Wallet::from_seed("persistent-flow-bob");
   1717     let carol = Wallet::from_seed("persistent-flow-carol");
   1718     let mut genesis = BTreeMap::new();
   1719     genesis.insert(alice.address().to_string(), 50);
   1720     let alice_ledger =
   1721         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 5)
   1722             .unwrap();
   1723 
   1724     let mut network = InMemoryNetwork::default();
   1725     network.insert(
   1726         "a",
   1727         NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK),
   1728     );
   1729 
   1730     queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1731     network.node_mut("a").unwrap().mine_one_at(1).unwrap();
   1732     network.deliver_until_idle().unwrap();
   1733 
   1734     let bob_store = SqliteChainStore::open(temp.path().join("bob.sqlite3")).unwrap();
   1735     bob_store
   1736         .save(&network.node("a").unwrap().chain_snapshot())
   1737         .unwrap();
   1738     let bob_joined_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap();
   1739     network.insert(
   1740         "b",
   1741         NodeCore::from_ledger(bob.clone(), bob_joined_ledger, DEFAULT_BURN_PER_BLOCK),
   1742     );
   1743     assert_eq!(
   1744         network.node("b").unwrap().ledger().status().tip_hash,
   1745         network.node("a").unwrap().ledger().status().tip_hash
   1746     );
   1747 
   1748     queue_plaintext_transfer(network.node_mut("a").unwrap(), &alice, bob.address(), 10);
   1749     queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1750     network.deliver_until_idle().unwrap();
   1751     network.node_mut("a").unwrap().mine_one_at(2).unwrap();
   1752     network.deliver_until_idle().unwrap();
   1753     assert_eq!(
   1754         network
   1755             .node("b")
   1756             .unwrap()
   1757             .ledger()
   1758             .balance_of(bob.address()),
   1759         10
   1760     );
   1761 
   1762     bob_store
   1763         .save(&network.node("b").unwrap().chain_snapshot())
   1764         .unwrap();
   1765     let bob_restarted_ledger = Ledger::from_snapshot(bob_store.load().unwrap().unwrap()).unwrap();
   1766     network.insert(
   1767         "b",
   1768         NodeCore::from_ledger(bob.clone(), bob_restarted_ledger, DEFAULT_BURN_PER_BLOCK),
   1769     );
   1770     assert_eq!(
   1771         network.node("b").unwrap().ledger().status().tip_hash,
   1772         network.node("a").unwrap().ledger().status().tip_hash,
   1773         "restarted Bob should resume the persisted chain tip"
   1774     );
   1775 
   1776     let carol_store = SqliteChainStore::open(temp.path().join("carol.sqlite3")).unwrap();
   1777     carol_store
   1778         .save(&network.node("a").unwrap().chain_snapshot())
   1779         .unwrap();
   1780     let carol_joined_ledger = Ledger::from_snapshot(carol_store.load().unwrap().unwrap()).unwrap();
   1781     network.insert(
   1782         "c",
   1783         NodeCore::from_ledger(carol, carol_joined_ledger, DEFAULT_BURN_PER_BLOCK),
   1784     );
   1785 
   1786     for height in 3..=6 {
   1787         queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1);
   1788         network.node_mut("a").unwrap().mine_one_at(height).unwrap();
   1789         network.deliver_until_idle().unwrap();
   1790     }
   1791 
   1792     let final_status = network.node("a").unwrap().ledger().status();
   1793     for id in ["b", "c"] {
   1794         assert_eq!(
   1795             network.node(id).unwrap().ledger().status().height,
   1796             final_status.height,
   1797             "{id} did not catch up after Bob restarted"
   1798         );
   1799         assert_eq!(
   1800             network.node(id).unwrap().ledger().status().tip_hash,
   1801             final_status.tip_hash,
   1802             "{id} ended on a different tip after Bob restarted"
   1803         );
   1804     }
   1805 
   1806     bob_store
   1807         .save(&network.node("b").unwrap().chain_snapshot())
   1808         .unwrap();
   1809     assert_eq!(
   1810         bob_store
   1811             .load()
   1812             .unwrap()
   1813             .unwrap()
   1814             .blocks
   1815             .last()
   1816             .unwrap()
   1817             .height,
   1818         final_status.height
   1819     );
   1820 }
   1821 
   1822 #[test]
   1823 fn mined_block_gossip_does_not_include_full_chain_snapshot() {
   1824     let alice = Wallet::from_seed("alice");
   1825     let bob = Wallet::from_seed("bob");
   1826     let wallets = vec![alice.clone(), bob.clone()];
   1827     let allocations = allocations(&wallets, MICRO_IUNA);
   1828 
   1829     let mut alice_node = NodeCore::new(NodeConfig {
   1830         wallet: alice,
   1831         genesis_allocations: allocations.clone(),
   1832         vdf_rounds: 10,
   1833         burn_per_block: 1,
   1834         burn_fee: 1,
   1835         pow_mining_workers: 1,
   1836         recovery_vdf_top_rank_percent: 100,
   1837     });
   1838 
   1839     let plan = alice_node.prepare_automatic_mining(1);
   1840     let burn_outbox = alice_node.drain_outbox();
   1841     assert!(burn_outbox.is_empty());
   1842 
   1843     let work = plan.work.unwrap();
   1844     let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
   1845     alice_node
   1846         .complete_prepared_block(work, vdf_output)
   1847         .unwrap();
   1848     let block_outbox = alice_node.drain_outbox();
   1849 
   1850     assert_eq!(block_outbox.len(), 1);
   1851     assert!(matches!(
   1852         block_outbox[0],
   1853         iuna::app::GossipEnvelope::Block(_)
   1854     ));
   1855 }
   1856 
   1857 #[test]
   1858 fn mempool_gossip_splits_blinded_batches_at_receiver_limit() {
   1859     let alice = Wallet::from_seed("mempool-batch-alice");
   1860     let bob = Wallet::from_seed("mempool-batch-bob");
   1861     let burn_wallets = (0..=TRANSACTION_BATCH_LIMIT)
   1862         .map(|index| Wallet::from_seed(&format!("mempool-batch-burn-{index}")))
   1863         .collect::<Vec<_>>();
   1864     let mut wallets = vec![alice.clone(), bob.clone()];
   1865     wallets.extend(burn_wallets.clone());
   1866     let allocations = allocations(&wallets, 10_000);
   1867     let mut alice_node = node("alice", alice, allocations.clone());
   1868     let mut bob_node = node("bob", bob, allocations);
   1869 
   1870     for wallet in &burn_wallets {
   1871         let tx = alice_node.ledger().build_burn(wallet, 1, 0).unwrap();
   1872         let built = alice_node
   1873             .ledger()
   1874             .build_blinded_transaction(wallet, tx, 20)
   1875             .unwrap();
   1876         alice_node
   1877             .receive_blinded_transaction(built.transaction)
   1878             .unwrap();
   1879     }
   1880     alice_node.drain_outbox();
   1881 
   1882     let gossip = alice_node.mempool_gossip();
   1883     assert_eq!(gossip.len(), 2);
   1884     let total_transactions = gossip
   1885         .iter()
   1886         .map(|envelope| match envelope {
   1887             GossipEnvelope::BlindedTransactions { transactions } => {
   1888                 assert!(transactions.len() <= TRANSACTION_BATCH_LIMIT);
   1889                 transactions.len()
   1890             }
   1891             other => panic!("expected blinded transaction batch, got {other:?}"),
   1892         })
   1893         .sum::<usize>();
   1894     assert_eq!(total_transactions, TRANSACTION_BATCH_LIMIT + 1);
   1895 
   1896     for envelope in gossip {
   1897         bob_node.receive(envelope).unwrap();
   1898     }
   1899     assert!(bob_node.ledger().pending().is_empty());
   1900     assert_eq!(
   1901         bob_node.ledger().pending_blinded_transactions().len(),
   1902         TRANSACTION_BATCH_LIMIT + 1
   1903     );
   1904 }
   1905 
   1906 #[test]
   1907 fn received_block_is_rebroadcast_to_other_peers_without_networking() {
   1908     let names = ["alice", "bob", "carol"];
   1909     let wallets = wallets(&names);
   1910     let allocations = allocations(&wallets, 1_000);
   1911     let alice = wallets[0].clone();
   1912     let bob = wallets[1].clone();
   1913     let carol = wallets[2].clone();
   1914 
   1915     let mut miner = node("alice", alice.clone(), allocations.clone());
   1916     let mut hub = node("bob", bob, allocations.clone());
   1917     let mut carol_node = node("carol", carol, allocations);
   1918 
   1919     queue_plaintext_burn(&mut miner, &alice, 10);
   1920     miner.drain_outbox();
   1921     let block = miner.mine_one_at(1).unwrap();
   1922     miner.drain_outbox();
   1923 
   1924     hub.receive(iuna::app::GossipEnvelope::Block(block.clone()))
   1925         .unwrap();
   1926     let forwarded = hub.drain_outbox();
   1927     assert_eq!(forwarded.len(), 1);
   1928     assert!(matches!(forwarded[0], iuna::app::GossipEnvelope::Block(_)));
   1929 
   1930     for envelope in forwarded {
   1931         carol_node.receive(envelope).unwrap();
   1932     }
   1933     assert_eq!(carol_node.ledger().height(), 1);
   1934     assert_eq!(
   1935         carol_node.ledger().status().tip_hash,
   1936         miner.ledger().status().tip_hash
   1937     );
   1938 
   1939     hub.receive(iuna::app::GossipEnvelope::Block(block))
   1940         .unwrap();
   1941     assert!(hub.drain_outbox().is_empty());
   1942 }
   1943 
   1944 #[test]
   1945 fn imported_snapshot_blocks_are_rebroadcast_without_networking() {
   1946     let alice = Wallet::from_seed("alice");
   1947     let bob = Wallet::from_seed("bob");
   1948     let wallets = vec![alice.clone(), bob.clone()];
   1949     let allocations = allocations(&wallets, 1_000);
   1950     let mut miner = node("alice", alice.clone(), allocations.clone());
   1951     let mut hub = node("bob", bob, allocations);
   1952 
   1953     queue_plaintext_burn(&mut miner, &alice, 1);
   1954     miner.drain_outbox();
   1955     miner.mine_one_at(1).unwrap();
   1956     miner.drain_outbox();
   1957 
   1958     queue_plaintext_burn(&mut miner, &alice, 1);
   1959     miner.drain_outbox();
   1960     miner.mine_one_at(2).unwrap();
   1961     miner.drain_outbox();
   1962 
   1963     hub.import_chain_snapshot(miner.chain_snapshot()).unwrap();
   1964     let outbox = hub.drain_outbox();
   1965     assert_eq!(outbox.len(), 1);
   1966     match &outbox[0] {
   1967         iuna::app::GossipEnvelope::Blocks { blocks } => {
   1968             assert_eq!(blocks.len(), 2);
   1969             assert_eq!(blocks[0].height, 1);
   1970             assert_eq!(blocks[1].height, 2);
   1971         }
   1972         other => panic!("expected imported blocks gossip, got {other:?}"),
   1973     }
   1974 }
   1975 
   1976 #[test]
   1977 fn multiple_peers_can_contribute_blinded_burns_to_lottery_ranks() {
   1978     let finalizer = Wallet::from_seed("blinded-burn-ranks-finalizer");
   1979     let names = ["alice", "bob", "carol", "dave"];
   1980     let wallets = wallets(&names);
   1981     let mut all_wallets = vec![finalizer.clone()];
   1982     all_wallets.extend(wallets.clone());
   1983     let allocations = allocations(&all_wallets, 1_000);
   1984     let ledger = Ledger::new_with_genesis_burns(
   1985         allocations.clone(),
   1986         vec![GenesisBurn::new(finalizer.address(), 1)],
   1987         25,
   1988     )
   1989     .unwrap();
   1990     let mut network = InMemoryNetwork::default();
   1991 
   1992     network.insert(
   1993         "finalizer",
   1994         NodeCore::from_ledger(finalizer.clone(), ledger.clone(), DEFAULT_BURN_PER_BLOCK),
   1995     );
   1996     for (name, wallet) in names.iter().zip(wallets.clone()) {
   1997         network.insert(
   1998             *name,
   1999             NodeCore::from_ledger(wallet, ledger.clone(), DEFAULT_BURN_PER_BLOCK),
   2000         );
   2001     }
   2002 
   2003     for ((name, wallet), amount) in names.iter().zip(wallets.iter()).zip([10, 20, 30, 40]) {
   2004         let tx = network.node_mut(name).unwrap().burn(amount).unwrap();
   2005         assert!(tx.is_burn());
   2006         assert_eq!(tx.sender(), wallet.address());
   2007     }
   2008     network.deliver_until_idle().unwrap();
   2009     assert_eq!(
   2010         network
   2011             .node("alice")
   2012             .unwrap()
   2013             .ledger()
   2014             .pending_blinded_transactions()
   2015             .len(),
   2016         4
   2017     );
   2018 
   2019     queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1);
   2020     let commit_block = network
   2021         .node_mut("finalizer")
   2022         .unwrap()
   2023         .mine_one_at(1)
   2024         .unwrap();
   2025     assert_eq!(commit_block.blinded_transactions.len(), 4);
   2026     network.deliver_until_idle().unwrap();
   2027 
   2028     assert_eq!(
   2029         network
   2030             .node("alice")
   2031             .unwrap()
   2032             .ledger()
   2033             .pending_blinded_reveals()
   2034             .len(),
   2035         4
   2036     );
   2037     queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1);
   2038     network.gossip_mempools_once().unwrap();
   2039     network.deliver_until_idle().unwrap();
   2040     let reveal_block = network
   2041         .node_mut("finalizer")
   2042         .unwrap()
   2043         .mine_one_at(2)
   2044         .unwrap();
   2045     assert_eq!(reveal_block.all_blinded_reveals().len(), 4);
   2046     network.deliver_until_idle().unwrap();
   2047 
   2048     for height in 3..=4 {
   2049         queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1);
   2050         network
   2051             .node_mut("finalizer")
   2052             .unwrap()
   2053             .mine_one_at(height)
   2054             .unwrap();
   2055         network.deliver_until_idle().unwrap();
   2056     }
   2057 
   2058     let final_tip = network
   2059         .node("finalizer")
   2060         .unwrap()
   2061         .ledger()
   2062         .status()
   2063         .tip_hash;
   2064     for name in names {
   2065         let ledger = network.node(name).unwrap().ledger();
   2066         assert_eq!(ledger.status().height, 4);
   2067         assert_eq!(ledger.status().tip_hash, final_tip);
   2068         let ranks = network
   2069             .node(name)
   2070             .unwrap()
   2071             .burn_leader_ranks_for_block(5)
   2072             .unwrap();
   2073         for (wallet, amount) in wallets.iter().zip([10, 20, 30, 40]) {
   2074             assert!(
   2075                 ranks.iter().any(|rank| rank.owner == wallet.address()
   2076                     && rank.amount == amount
   2077                     && rank.eligible_from_height == 5),
   2078                 "{name} missing revealed burn ticket for {} in {ranks:?}",
   2079                 wallet.address()
   2080             );
   2081         }
   2082     }
   2083 }
   2084 
   2085 #[test]
   2086 fn peer_book_tracks_multiple_peers_without_networking() {
   2087     let mut peers = PeerBook::from_addresses(vec![
   2088         "127.0.0.1:9444".to_string(),
   2089         "127.0.0.1:9445".to_string(),
   2090         "127.0.0.1:9444".to_string(),
   2091     ]);
   2092 
   2093     peers.record_sent("127.0.0.1:9444", 2);
   2094     peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string());
   2095     peers.record_error("127.0.0.1:9445", "connection refused");
   2096     peers.record_received("127.0.0.1:9555", 1);
   2097     peers.record_inbound_error("127.0.0.1:56666", "invalid nonce");
   2098 
   2099     let mut list = peers.list();
   2100     list.sort_by(|left, right| left.address.cmp(&right.address));
   2101     assert_eq!(list.len(), 4);
   2102 
   2103     let outbound_addresses = peers.addresses();
   2104     assert_eq!(outbound_addresses.len(), 2);
   2105     assert!(outbound_addresses.contains(&"127.0.0.1:9444".to_string()));
   2106     assert!(outbound_addresses.contains(&"127.0.0.1:9445".to_string()));
   2107     assert!(!outbound_addresses.contains(&"127.0.0.1:56666".to_string()));
   2108 
   2109     let sent_peer = list
   2110         .iter()
   2111         .find(|peer| peer.address == "127.0.0.1:9444")
   2112         .unwrap();
   2113     assert_eq!(sent_peer.messages_sent, 2);
   2114     assert_eq!(sent_peer.last_known_height, Some(12));
   2115     assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash"));
   2116     assert_eq!(sent_peer.last_error, None);
   2117     assert!(sent_peer.last_contact_ms.is_some());
   2118     assert!(sent_peer.last_success_ms.is_some());
   2119     assert_eq!(sent_peer.last_error_ms, None);
   2120 
   2121     let failed_peer = list
   2122         .iter()
   2123         .find(|peer| peer.address == "127.0.0.1:9445")
   2124         .unwrap();
   2125     assert_eq!(
   2126         failed_peer.last_error.as_deref(),
   2127         Some("connection refused")
   2128     );
   2129     assert!(failed_peer.last_contact_ms.is_some());
   2130     assert!(failed_peer.last_error_ms.is_some());
   2131 
   2132     let inbound_peer = list
   2133         .iter()
   2134         .find(|peer| peer.address == "127.0.0.1:9555")
   2135         .unwrap();
   2136     assert_eq!(inbound_peer.direction, PeerDirection::Inbound);
   2137     assert_eq!(inbound_peer.messages_received, 1);
   2138     assert!(inbound_peer.last_contact_ms.is_some());
   2139     assert!(inbound_peer.last_success_ms.is_some());
   2140 
   2141     let inbound_error = list
   2142         .iter()
   2143         .find(|peer| peer.address == "127.0.0.1:56666")
   2144         .unwrap();
   2145     assert_eq!(inbound_error.direction, PeerDirection::Inbound);
   2146     assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce"));
   2147 
   2148     assert!(peers.remove_peer("127.0.0.1:9445"));
   2149     assert!(!peers.addresses().contains(&"127.0.0.1:9445".to_string()));
   2150     assert!(!peers.remove_peer("127.0.0.1:9555"));
   2151     assert!(
   2152         peers
   2153             .list()
   2154             .iter()
   2155             .any(|peer| peer.address == "127.0.0.1:9555")
   2156     );
   2157 }
   2158 
   2159 #[test]
   2160 fn peer_book_reports_only_connectable_peers_as_outbound() {
   2161     let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]);
   2162     peers.record_received("127.0.0.1:56666", 1);
   2163     peers.add_discovered_peer("127.0.0.1:9445");
   2164     peers.record_inbound_misbehavior("127.0.0.1:57777", "invalid transaction");
   2165 
   2166     assert!(peers.is_connectable_peer("127.0.0.1:9444"));
   2167     assert!(peers.is_connectable_peer("127.0.0.1:9445"));
   2168     assert!(!peers.is_connectable_peer("127.0.0.1:56666"));
   2169     assert!(!peers.is_connectable_peer("127.0.0.1:57777"));
   2170     assert!(peers.addresses().contains(&"127.0.0.1:9445".to_string()));
   2171 
   2172     assert!(peers.remove_peer("127.0.0.1:9444"));
   2173     assert!(!peers.is_connectable_peer("127.0.0.1:9444"));
   2174 }
   2175 
   2176 #[test]
   2177 fn peer_book_prunes_stale_inbound_observations() {
   2178     let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]);
   2179     peers.record_status("127.0.0.1:9444", 1, "tip".to_string());
   2180     peers.observe_inbound_peer("127.0.0.1:56666");
   2181     peers.record_received("127.0.0.1:57777", 1);
   2182 
   2183     assert_eq!(
   2184         peers.prune_stale_inbound_peers_at(iuna::app::now_ms(), 60_000),
   2185         1
   2186     );
   2187     let listed = peers.list();
   2188 
   2189     assert!(listed.iter().any(|peer| peer.address == "127.0.0.1:9444"));
   2190     assert!(listed.iter().any(|peer| peer.address == "127.0.0.1:57777"));
   2191     assert!(!listed.iter().any(|peer| peer.address == "127.0.0.1:56666"));
   2192 }
   2193 
   2194 #[test]
   2195 fn peer_book_bans_misbehaving_peer_temporarily_and_recovers_on_success() {
   2196     let mut peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]);
   2197 
   2198     peers.record_misbehavior_at("127.0.0.1:9444", "invalid transaction", 100);
   2199     peers.record_misbehavior_at("127.0.0.1:9444", "invalid block", 200);
   2200     assert!(!peers.is_banned_at("127.0.0.1:9444", 200));
   2201     assert_eq!(peers.connectable_addresses_at(200), vec!["127.0.0.1:9444"]);
   2202 
   2203     peers.record_misbehavior_at("127.0.0.1:9444", "wrong genesis", 300);
   2204     assert!(peers.is_banned_at("127.0.0.1:9444", 300));
   2205     assert!(peers.connectable_addresses_at(300).is_empty());
   2206     assert_eq!(peers.addresses(), vec!["127.0.0.1:9444"]);
   2207     let banned = peers.list().pop().unwrap();
   2208     assert_eq!(banned.misbehavior_score, 3);
   2209     assert_eq!(banned.ban_reason.as_deref(), Some("wrong genesis"));
   2210 
   2211     assert!(!peers.is_banned_at("127.0.0.1:9444", 11 * 60 * 1_000));
   2212     peers.record_status("127.0.0.1:9444", 1, "tip".to_string());
   2213     let recovered = peers.list().pop().unwrap();
   2214     assert_eq!(recovered.misbehavior_score, 0);
   2215     assert_eq!(recovered.banned_until_ms, None);
   2216 }
   2217 
   2218 #[test]
   2219 fn peer_book_uses_median_accepted_clock_offset() {
   2220     let mut peers = PeerBook::from_addresses(vec![
   2221         "127.0.0.1:9444".to_string(),
   2222         "127.0.0.1:9445".to_string(),
   2223         "127.0.0.1:9446".to_string(),
   2224     ]);
   2225     let now = 1_000_000;
   2226     peers.record_status("127.0.0.1:9444", 1, "tip-a".to_string());
   2227     peers.record_status("127.0.0.1:9445", 1, "tip-b".to_string());
   2228     peers.record_status("127.0.0.1:9446", 1, "tip-c".to_string());
   2229     peers.record_clock_observation("127.0.0.1:9444", PeerDirection::Outbound, now + 1_000, now);
   2230     peers.record_clock_observation("127.0.0.1:9445", PeerDirection::Outbound, now + 2_000, now);
   2231     peers.record_clock_observation(
   2232         "127.0.0.1:9446",
   2233         PeerDirection::Outbound,
   2234         now + 20 * 60 * 1_000,
   2235         now,
   2236     );
   2237 
   2238     assert_eq!(peers.network_time_offset_ms_at(now), Some(2_000));
   2239     assert_eq!(peers.adjusted_time_ms_at(now), now + 2_000);
   2240     assert_eq!(peers.bad_clock_peer_count_at(now), 1);
   2241     assert!(!peers.is_banned_at("127.0.0.1:9446", now));
   2242 }
   2243 
   2244 #[test]
   2245 fn peer_book_address_replacement_keeps_newest_clock_observation() {
   2246     let mut peers = PeerBook::from_addresses(vec![
   2247         "seed.example:9444".to_string(),
   2248         "142.132.164.59:9444".to_string(),
   2249     ]);
   2250     peers.record_clock_observation(
   2251         "seed.example:9444",
   2252         PeerDirection::Outbound,
   2253         1_001_000,
   2254         1_000_000,
   2255     );
   2256     peers.record_clock_observation(
   2257         "142.132.164.59:9444",
   2258         PeerDirection::Outbound,
   2259         2_002_000,
   2260         2_000_000,
   2261     );
   2262 
   2263     peers.replace_peer_address("seed.example:9444", "142.132.164.59:9444");
   2264 
   2265     let peer = peers.list().pop().unwrap();
   2266     assert_eq!(peer.last_clock_offset_ms, Some(2_000));
   2267     assert_eq!(peer.last_clock_observed_ms, Some(2_000_000));
   2268 }
   2269 
   2270 #[test]
   2271 fn chain_snapshot_round_trips_ledger_state() {
   2272     let alice = Wallet::from_seed("alice");
   2273     let mut allocations = BTreeMap::new();
   2274     allocations.insert(alice.address().to_string(), 1_000);
   2275 
   2276     let mut ledger = Ledger::new(allocations, 10);
   2277     submit_burn(&mut ledger, &alice, 10);
   2278     let block = ledger.mine_next_block(&alice, 1).unwrap();
   2279     ledger.apply_block(block).unwrap();
   2280 
   2281     let restored = Ledger::from_snapshot(ledger.snapshot()).unwrap();
   2282     assert_eq!(restored.status().height, ledger.status().height);
   2283     assert_eq!(restored.status().tip_hash, ledger.status().tip_hash);
   2284     assert_eq!(
   2285         restored.balance_of(alice.address()),
   2286         ledger.balance_of(alice.address())
   2287     );
   2288 }
   2289 
   2290 #[test]
   2291 fn friend_node_can_join_snapshot_from_started_chain() {
   2292     let alice = Wallet::from_seed("alice");
   2293     let bob = Wallet::from_seed("bob");
   2294 
   2295     let mut alice_genesis = BTreeMap::new();
   2296     alice_genesis.insert(alice.address().to_string(), 1_000);
   2297     let mut alice_node = node("alice", alice.clone(), alice_genesis);
   2298     alice_node
   2299         .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, DEFAULT_FEE_PER_BYTE)
   2300         .unwrap();
   2301     queue_plaintext_burn(&mut alice_node, &alice, 1);
   2302     alice_node.automatic_mine_once(1);
   2303 
   2304     let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap();
   2305     let mut bob_node = NodeCore::from_ledger(bob.clone(), joined_ledger, DEFAULT_BURN_PER_BLOCK);
   2306 
   2307     assert_eq!(
   2308         bob_node.ledger().status().tip_hash,
   2309         alice_node.ledger().status().tip_hash
   2310     );
   2311     assert_eq!(bob_node.ledger().status().height, 1);
   2312     assert_eq!(bob_node.ledger().balance_of(bob.address()), 0);
   2313 
   2314     let outcome = bob_node.automatic_mine_once(2);
   2315     assert!(outcome.burned.is_none());
   2316 }
   2317 
   2318 #[test]
   2319 fn running_node_rejects_snapshot_from_different_genesis() {
   2320     let alice = Wallet::from_seed("alice");
   2321     let bob = Wallet::from_seed("bob");
   2322 
   2323     let mut alice_genesis = BTreeMap::new();
   2324     alice_genesis.insert(alice.address().to_string(), 1_000);
   2325     let alice_node = node("alice", alice, alice_genesis);
   2326 
   2327     let mut bob_genesis = BTreeMap::new();
   2328     bob_genesis.insert(bob.address().to_string(), 1_000);
   2329     let mut bob_node = node("bob", bob, bob_genesis);
   2330 
   2331     let error = bob_node
   2332         .import_chain_snapshot(alice_node.chain_snapshot())
   2333         .unwrap_err();
   2334 
   2335     assert!(error.to_string().contains("genesis"));
   2336 }
   2337 
   2338 #[test]
   2339 fn same_height_fork_snapshot_does_not_reorg() {
   2340     let alice = Wallet::from_seed("alice");
   2341     let bob = Wallet::from_seed("bob");
   2342     let wallets = vec![alice.clone(), bob.clone()];
   2343     let shared_genesis = allocations(&wallets, 1_000);
   2344     let base = Ledger::new_with_genesis_burns(
   2345         shared_genesis,
   2346         vec![GenesisBurn::new(alice.address(), 1)],
   2347         1,
   2348     )
   2349     .unwrap();
   2350     let mut local = base.clone();
   2351 
   2352     let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 1);
   2353     let remote = fork_with_worse_vrf_block(&base, &alice, &local_first_fork_hash, 1).unwrap();
   2354 
   2355     let local_tip = local.status().tip_hash;
   2356     assert!(!local.extend_from_snapshot(remote.snapshot()).unwrap());
   2357     assert_eq!(local.status().tip_hash, local_tip);
   2358 }
   2359 
   2360 #[test]
   2361 fn fork_choice_preflight_rejects_non_matching_genesis_before_scoring() {
   2362     let alice = Wallet::from_seed("preflight-genesis-alice");
   2363     let bob = Wallet::from_seed("preflight-genesis-bob");
   2364     let mut local_genesis = BTreeMap::new();
   2365     local_genesis.insert(alice.address().to_string(), 1_000);
   2366     let mut remote_genesis = BTreeMap::new();
   2367     remote_genesis.insert(bob.address().to_string(), 1_000);
   2368     let mut local = Ledger::new(local_genesis, 1);
   2369     let mut remote = Ledger::new(remote_genesis, 1);
   2370 
   2371     mine_wallet_burn_block(&mut local, &alice, 1);
   2372     for timestamp in 1..=3 {
   2373         mine_wallet_burn_block(&mut remote, &bob, timestamp);
   2374     }
   2375 
   2376     let local_tip = local.status().tip_hash;
   2377     let error = local.extend_from_snapshot(remote.snapshot()).unwrap_err();
   2378 
   2379     assert!(error.to_string().contains("genesis"));
   2380     assert_eq!(local.status().tip_hash, local_tip);
   2381 }
   2382 
   2383 #[test]
   2384 fn fork_choice_preflight_rejects_invalid_fork_before_vrf_scoring() {
   2385     let alice = Wallet::from_seed("preflight-invalid-alice");
   2386     let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
   2387     let mut common = Ledger::new(shared_genesis, 1);
   2388     for timestamp in 1..=5 {
   2389         mine_wallet_burn_block(&mut common, &alice, timestamp);
   2390     }
   2391 
   2392     let mut local = common.clone();
   2393     let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
   2394     mine_wallet_burn_block(&mut local, &alice, 7);
   2395     mine_wallet_burn_block(&mut local, &alice, 8);
   2396 
   2397     let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
   2398     assert!(remote.chain()[6].hash < local.chain()[6].hash);
   2399     let mut invalid_snapshot = remote.snapshot();
   2400     if let Some(transaction) = invalid_snapshot.blocks[6].transactions.first_mut() {
   2401         match transaction {
   2402             iuna::domain::Transaction::Burn { signature, .. }
   2403             | iuna::domain::Transaction::Transfer { signature, .. }
   2404             | iuna::domain::Transaction::Mine { signature, .. } => signature.push_str("00"),
   2405         }
   2406     }
   2407 
   2408     let local_tip = local.status().tip_hash;
   2409     let error = local.extend_from_snapshot(invalid_snapshot).unwrap_err();
   2410 
   2411     assert!(error.to_string().contains("invalid"));
   2412     assert_eq!(local.status().height, 8);
   2413     assert_eq!(local.status().tip_hash, local_tip);
   2414 }
   2415 
   2416 #[test]
   2417 fn fork_conflict_before_last_six_blocks_is_finalized_even_if_remote_is_longer() {
   2418     let alice = Wallet::from_seed("finality-alice");
   2419     let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
   2420     let mut common = Ledger::new(shared_genesis, 1);
   2421     mine_wallet_burn_block(&mut common, &alice, 1);
   2422 
   2423     let mut local = common.clone();
   2424     for timestamp in 2..=8 {
   2425         let timestamp_ms = next_ticket_slot_timestamp(&local, timestamp);
   2426         mine_wallet_burn_block(&mut local, &alice, timestamp_ms);
   2427     }
   2428 
   2429     let mut remote = common;
   2430     for timestamp in 20..=29 {
   2431         let timestamp_ms = next_ticket_slot_timestamp(&remote, timestamp);
   2432         mine_wallet_burn_block(&mut remote, &alice, timestamp_ms);
   2433     }
   2434 
   2435     assert_eq!(local.status().height, 8);
   2436     assert_eq!(remote.status().height, 11);
   2437     let finalized_local_tip = local.status().tip_hash;
   2438 
   2439     assert!(
   2440         !local.extend_from_snapshot(remote.snapshot()).unwrap(),
   2441         "forks that rewrite blocks before the last six should not be accepted"
   2442     );
   2443     assert_eq!(local.status().height, 8);
   2444     assert_eq!(local.status().tip_hash, finalized_local_tip);
   2445 }
   2446 
   2447 #[test]
   2448 fn shorter_better_rank_fork_inside_last_six_does_not_beat_positive_quality() {
   2449     let alice = Wallet::from_seed("better-vrf-alice");
   2450     let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
   2451     let mut common = Ledger::new(shared_genesis, 1);
   2452     for timestamp in 1..=5 {
   2453         mine_wallet_burn_block(&mut common, &alice, timestamp);
   2454     }
   2455 
   2456     let mut local = common.clone();
   2457     let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
   2458     mine_wallet_burn_block(&mut local, &alice, 7);
   2459     mine_wallet_burn_block(&mut local, &alice, 8);
   2460 
   2461     let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
   2462 
   2463     assert_eq!(local.status().height, 8);
   2464     assert_eq!(remote.status().height, 6);
   2465     assert!(remote.status().height + 2 >= local.status().height);
   2466     assert!(
   2467         remote.chain()[6].hash < local.chain()[6].hash,
   2468         "test setup should give the remote fork the better VRF leader score"
   2469     );
   2470     let remote_tip = remote.status().tip_hash;
   2471 
   2472     assert!(
   2473         !local.extend_from_snapshot(remote.snapshot()).unwrap(),
   2474         "a shorter fork should not beat greater positive chain quality"
   2475     );
   2476     assert_eq!(local.status().height, 8);
   2477     assert_ne!(local.status().tip_hash, remote_tip);
   2478 }
   2479 
   2480 #[test]
   2481 fn better_vrf_fork_inside_last_six_loses_when_more_than_two_blocks_shorter() {
   2482     let alice = Wallet::from_seed("too-short-vrf-alice");
   2483     let shared_genesis = allocations(std::slice::from_ref(&alice), 10_000);
   2484     let mut common = Ledger::new(shared_genesis, 1);
   2485     for timestamp in 1..=5 {
   2486         mine_wallet_burn_block(&mut common, &alice, timestamp);
   2487     }
   2488 
   2489     let mut local = common.clone();
   2490     let local_first_fork_hash = mine_wallet_burn_block(&mut local, &alice, 6);
   2491     mine_wallet_burn_block(&mut local, &alice, 7);
   2492     mine_wallet_burn_block(&mut local, &alice, 8);
   2493     mine_wallet_burn_block(&mut local, &alice, 9);
   2494 
   2495     let remote = fork_with_better_vrf_block(&common, &alice, &local_first_fork_hash, 100).unwrap();
   2496 
   2497     assert_eq!(local.status().height, 9);
   2498     assert_eq!(remote.status().height, 6);
   2499     assert!(remote.chain()[6].hash < local.chain()[6].hash);
   2500     let local_tip = local.status().tip_hash;
   2501 
   2502     assert!(
   2503         !local.extend_from_snapshot(remote.snapshot()).unwrap(),
   2504         "even a better VRF fork should not win when more than two blocks shorter"
   2505     );
   2506     assert_eq!(local.status().height, 9);
   2507     assert_eq!(local.status().tip_hash, local_tip);
   2508 }
   2509 
   2510 #[test]
   2511 fn transactions_from_abandoned_fork_blocks_return_to_mempool_after_switch() {
   2512     let alice = Wallet::from_seed("reorg-alice");
   2513     let bob = Wallet::from_seed("reorg-bob");
   2514     let carol = Wallet::from_seed("reorg-carol");
   2515     let wallets = vec![alice.clone(), bob.clone(), carol.clone()];
   2516     let shared_genesis = allocations(&wallets, 10_000);
   2517     let mut common = Ledger::new_with_genesis_burns(
   2518         shared_genesis,
   2519         vec![GenesisBurn::new(alice.address(), 1)],
   2520         1,
   2521     )
   2522     .unwrap();
   2523     mine_wallet_burn_block(&mut common, &alice, 1);
   2524 
   2525     let mut local = common.clone();
   2526     let abandoned_transfer = transfer_tx(&local, &bob, carol.address(), 7);
   2527     local
   2528         .submit_transaction(abandoned_transfer.clone())
   2529         .unwrap();
   2530     mine_wallet_burn_block(&mut local, &alice, 2);
   2531 
   2532     let mut remote = common;
   2533     for timestamp in 20..=23 {
   2534         mine_wallet_burn_block(&mut remote, &alice, timestamp);
   2535     }
   2536 
   2537     assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
   2538     assert!(
   2539         local
   2540             .pending()
   2541             .iter()
   2542             .any(|tx| tx.signature() == abandoned_transfer.signature()),
   2543         "transactions mined only on the abandoned fork should return to the mempool"
   2544     );
   2545 }
   2546 
   2547 #[test]
   2548 fn longer_valid_fork_snapshot_reorgs_and_preserves_local_transactions() {
   2549     let alice = Wallet::from_seed("alice");
   2550     let bob = Wallet::from_seed("bob");
   2551     let wallets = vec![alice.clone(), bob.clone()];
   2552     let shared_genesis = allocations(&wallets, 1_000);
   2553     let mut local = Ledger::new_with_genesis_burns(
   2554         shared_genesis.clone(),
   2555         vec![GenesisBurn::new(alice.address(), 1)],
   2556         1,
   2557     )
   2558     .unwrap();
   2559     let mut remote = Ledger::new_with_genesis_burns(
   2560         shared_genesis,
   2561         vec![GenesisBurn::new(alice.address(), 1)],
   2562         1,
   2563     )
   2564     .unwrap();
   2565 
   2566     let local_burn = burn_tx(&local, &alice, 1);
   2567     local.submit_transaction(local_burn.clone()).unwrap();
   2568     let local_block = local.mine_next_block(&alice, 1).unwrap();
   2569     local.apply_block(local_block).unwrap();
   2570     let local_transfer = transfer_tx(&local, &bob, alice.address(), 5);
   2571     local.submit_transaction(local_transfer.clone()).unwrap();
   2572 
   2573     submit_burn(&mut remote, &alice, 1);
   2574     let remote_block_1 = remote.mine_next_block(&alice, 1).unwrap();
   2575     remote.apply_block(remote_block_1).unwrap();
   2576     submit_burn(&mut remote, &alice, 1);
   2577     let remote_block_2 = remote.mine_next_block(&alice, 2).unwrap();
   2578     remote.apply_block(remote_block_2).unwrap();
   2579 
   2580     let remote_tip = remote.status().tip_hash;
   2581     assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
   2582     assert_eq!(local.status().height, 2);
   2583     assert_eq!(local.status().tip_hash, remote_tip);
   2584     assert!(
   2585         local
   2586             .pending()
   2587             .iter()
   2588             .any(|tx| tx.signature() == local_transfer.signature())
   2589     );
   2590 }
   2591 
   2592 #[test]
   2593 fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() {
   2594     let alice = Wallet::from_seed("alice");
   2595     let bob = Wallet::from_seed("bob");
   2596 
   2597     let wallets = vec![alice.clone(), bob.clone()];
   2598     let shared_genesis = allocations(&wallets, 1_000);
   2599     let mut alice_node = node("alice", alice.clone(), shared_genesis.clone());
   2600     queue_plaintext_burn(&mut alice_node, &alice, 1);
   2601     alice_node.mine_one().unwrap();
   2602 
   2603     let mut bob_node = node("bob", bob, shared_genesis);
   2604 
   2605     bob_node
   2606         .receive(iuna::app::GossipEnvelope::ChainSnapshot(
   2607             alice_node.chain_snapshot(),
   2608         ))
   2609         .unwrap();
   2610 
   2611     assert_eq!(
   2612         bob_node.ledger().status().tip_hash,
   2613         alice_node.ledger().status().tip_hash
   2614     );
   2615 }