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