commit 39b55713237e118b8c5e85e1cbe7f4336819164c
parent 287ab9de7531aa3eb9268c9fe863ba9237aabb04
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 26 Jul 2026 20:30:16 +0200
Add property tests for chain invariants
Diffstat:
2 files changed, 799 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -130,6 +130,9 @@ cargo clippy --all-targets --all-features -- -D warnings
cargo test
```
+The property-style chain tests live in a separate integration target and can be
+run directly with `cargo test --test properties`.
+
To install the included pre-commit hook:
```sh
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -0,0 +1,796 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use luun::{
+ app::{InMemoryNetwork, NodeCore},
+ domain::{
+ Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_LUUN, MINE_REWARD, OutPoint, Transaction,
+ TxInput, TxOutput, Wallet, hex_hash, verify_vdf,
+ },
+};
+
+const LEDGER_PROPERTY_SEEDS: std::ops::Range<u64> = 0..16;
+const LEDGER_PROPERTY_ROUNDS: usize = 18;
+const NETWORK_PROPERTY_SEEDS: std::ops::Range<u64> = 100..108;
+const NETWORK_PROPERTY_ROUNDS: usize = 12;
+const TAMPER_PROPERTY_SEEDS: std::ops::Range<u64> = 200..208;
+const FORK_PROPERTY_SEEDS: std::ops::Range<u64> = 300..306;
+const NETWORK_CHAOS_SEEDS: std::ops::Range<u64> = 400..405;
+const NETWORK_CHAOS_ROUNDS: usize = 10;
+
+#[derive(Clone, Debug)]
+struct TestRng {
+ state: u64,
+}
+
+impl TestRng {
+ fn new(seed: u64) -> Self {
+ Self {
+ state: seed ^ 0x9e37_79b9_7f4a_7c15,
+ }
+ }
+
+ fn next_u64(&mut self) -> u64 {
+ self.state = self
+ .state
+ .wrapping_mul(6_364_136_223_846_793_005)
+ .wrapping_add(1_442_695_040_888_963_407);
+ self.state
+ }
+
+ fn index(&mut self, len: usize) -> usize {
+ assert!(len > 0);
+ (self.next_u64() as usize) % len
+ }
+
+ fn amount(&mut self, max_inclusive: Amount) -> Amount {
+ 1 + self.next_u64() % max_inclusive
+ }
+}
+
+fn test_wallets(seed: u64, count: usize) -> Vec<Wallet> {
+ (0..count)
+ .map(|index| Wallet::from_seed(&format!("property-wallet-{seed}-{index}")))
+ .collect()
+}
+
+fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> {
+ wallets
+ .iter()
+ .map(|wallet| (wallet.address().to_string(), amount))
+ .collect()
+}
+
+fn genesis_burns(wallets: &[Wallet], amount: Amount) -> Vec<GenesisBurn> {
+ wallets
+ .iter()
+ .map(|wallet| GenesisBurn::new(wallet.address(), amount))
+ .collect()
+}
+
+fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) {
+ let wallets = test_wallets(seed, wallet_count);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 250 * MICRO_LUUN),
+ genesis_burns(&wallets, MICRO_LUUN),
+ 1,
+ )
+ .expect("property genesis is valid");
+ (wallets, ledger)
+}
+
+fn single_finalizer_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) {
+ let wallets = test_wallets(seed, wallet_count);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 250 * MICRO_LUUN),
+ vec![GenesisBurn::new(wallets[0].address(), MICRO_LUUN)],
+ 1,
+ )
+ .expect("single-finalizer property genesis is valid");
+ (wallets, ledger)
+}
+
+fn assert_chain_properties(snapshot: ChainSnapshot) {
+ let replayed =
+ Ledger::from_snapshot(snapshot.clone()).expect("snapshot replays as valid chain");
+ assert_eq!(replayed.snapshot(), snapshot);
+
+ let chain = replayed.chain();
+ assert!(!chain.is_empty());
+ assert_eq!(chain[0].height, 0);
+ assert_eq!(chain[0].prev_hash, "0".repeat(64));
+ assert_eq!(chain[0].hash, chain[0].compute_hash());
+
+ for (index, block) in chain.iter().enumerate() {
+ assert_eq!(block.height as usize, index);
+ assert_eq!(block.hash, block.compute_hash());
+ if index > 0 {
+ assert_eq!(block.prev_hash, chain[index - 1].hash);
+ assert!(
+ block.timestamp_ms > chain[index - 1].timestamp_ms,
+ "timestamps must increase at height {}",
+ block.height
+ );
+ assert!(
+ verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output),
+ "VDF must verify at height {}",
+ block.height
+ );
+ assert!(
+ block.transactions.iter().any(Transaction::is_burn),
+ "non-genesis block must include a burn at height {}",
+ block.height
+ );
+ }
+ }
+
+ let confirmed_supply = replayed
+ .status()
+ .balances
+ .values()
+ .try_fold(0_u64, |total, amount| total.checked_add(*amount))
+ .expect("confirmed supply does not overflow");
+ assert_eq!(confirmed_supply, expected_confirmed_supply(&snapshot));
+ assert_reference_model_matches(&snapshot, &replayed);
+}
+
+fn expected_confirmed_supply(snapshot: &ChainSnapshot) -> Amount {
+ let mut supply = snapshot
+ .genesis_allocations
+ .values()
+ .try_fold(0_u64, |total, amount| total.checked_add(*amount))
+ .expect("genesis supply does not overflow");
+
+ for block in &snapshot.blocks {
+ for tx in &block.transactions {
+ match tx {
+ Transaction::Transfer { fee, .. } => {
+ supply = supply.checked_sub(*fee).expect("transfer fee is funded");
+ }
+ Transaction::Burn { amount, fee, .. } => {
+ supply = supply.checked_sub(*amount).expect("burn is funded");
+ supply = supply.checked_sub(*fee).expect("burn fee is funded");
+ }
+ Transaction::Mine { output, .. } => {
+ supply = supply
+ .checked_add(output.amount)
+ .expect("mine output does not overflow supply");
+ }
+ }
+ }
+ supply = supply
+ .checked_add(block.reward)
+ .expect("block reward does not overflow supply");
+ }
+
+ supply
+}
+
+fn assert_reference_model_matches(snapshot: &ChainSnapshot, replayed: &Ledger) {
+ let reference_balances = reference_balances(snapshot);
+ assert_eq!(reference_balances, replayed.status().balances);
+
+ let reference_supply = reference_balances
+ .values()
+ .try_fold(0_u64, |total, amount| total.checked_add(*amount))
+ .expect("reference supply does not overflow");
+ assert_eq!(reference_supply, expected_confirmed_supply(snapshot));
+}
+
+fn reference_balances(snapshot: &ChainSnapshot) -> BTreeMap<String, Amount> {
+ let mut utxos = BTreeMap::new();
+
+ for block in &snapshot.blocks {
+ if block.height == 0 {
+ seed_reference_genesis_allocations(snapshot, &mut utxos);
+ }
+
+ let mut block_signatures = BTreeSet::new();
+ let mut block_fees = 0_u64;
+ for tx in &block.transactions {
+ assert!(
+ block_signatures.insert(tx.signature().to_string()),
+ "duplicate transaction in block {}",
+ block.height
+ );
+ apply_reference_transaction(tx, &mut utxos);
+ block_fees = block_fees
+ .checked_add(tx.fee())
+ .expect("reference block fees do not overflow");
+ }
+
+ if block.height > 0 {
+ assert_eq!(block.reward, block_fees);
+ }
+ if block.reward > 0 {
+ let replaced = utxos.insert(
+ OutPoint {
+ txid: block.hash.clone(),
+ index: u32::MAX,
+ },
+ TxOutput {
+ address: block.miner.clone(),
+ amount: block.reward,
+ },
+ );
+ assert!(replaced.is_none(), "duplicate reference reward output");
+ }
+ }
+
+ balances_from_reference_utxos(&utxos)
+}
+
+fn seed_reference_genesis_allocations(
+ snapshot: &ChainSnapshot,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) {
+ for (address, amount) in &snapshot.genesis_allocations {
+ if *amount == 0 {
+ continue;
+ }
+ utxos.insert(
+ OutPoint {
+ txid: hex_hash(format!("luun-genesis-allocation:{address}")),
+ index: 0,
+ },
+ TxOutput {
+ address: address.clone(),
+ amount: *amount,
+ },
+ );
+ }
+}
+
+fn apply_reference_transaction(
+ transaction: &Transaction,
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) {
+ if let Transaction::Mine { output, fee, .. } = transaction {
+ assert_eq!(
+ output
+ .amount
+ .checked_add(*fee)
+ .expect("mine output plus fee does not overflow"),
+ MINE_REWARD
+ );
+ insert_reference_outputs(transaction, std::slice::from_ref(output), utxos);
+ return;
+ }
+
+ let mut seen_inputs = BTreeSet::new();
+ let mut input_total = 0_u64;
+ for input in reference_inputs(transaction) {
+ assert!(
+ seen_inputs.insert(input.outpoint.clone()),
+ "duplicate reference input"
+ );
+ let spent = utxos
+ .remove(&input.outpoint)
+ .expect("reference transaction spends an existing output");
+ assert_eq!(spent.address, input.owner);
+ input_total = input_total
+ .checked_add(spent.amount)
+ .expect("reference input total does not overflow");
+ }
+
+ let output_total = reference_outputs(transaction)
+ .iter()
+ .fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .expect("reference output total does not overflow")
+ });
+ let burn_amount = match transaction {
+ Transaction::Burn { amount, .. } => *amount,
+ Transaction::Transfer { .. } | Transaction::Mine { .. } => 0,
+ };
+ let required = output_total
+ .checked_add(transaction.fee())
+ .expect("reference outputs plus fee do not overflow")
+ .checked_add(burn_amount)
+ .expect("reference outputs plus burn do not overflow");
+ assert_eq!(input_total, required);
+
+ insert_reference_outputs(transaction, reference_outputs(transaction), utxos);
+}
+
+fn insert_reference_outputs(
+ transaction: &Transaction,
+ outputs: &[TxOutput],
+ utxos: &mut BTreeMap<OutPoint, TxOutput>,
+) {
+ for (index, output) in outputs.iter().enumerate() {
+ let replaced = utxos.insert(
+ OutPoint {
+ txid: transaction.signature().to_string(),
+ index: index as u32,
+ },
+ output.clone(),
+ );
+ assert!(replaced.is_none(), "duplicate reference transaction output");
+ }
+}
+
+fn reference_inputs(transaction: &Transaction) -> &[TxInput] {
+ match transaction {
+ Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
+ Transaction::Mine { .. } => &[],
+ }
+}
+
+fn reference_outputs(transaction: &Transaction) -> &[TxOutput] {
+ match transaction {
+ Transaction::Transfer { outputs, .. } => outputs,
+ Transaction::Burn { change, .. } => change,
+ Transaction::Mine { output, .. } => std::slice::from_ref(output),
+ }
+}
+
+fn balances_from_reference_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, Amount> {
+ let mut balances = BTreeMap::new();
+ for output in utxos.values() {
+ let balance = balances.entry(output.address.clone()).or_insert(0_u64);
+ *balance = balance
+ .checked_add(output.amount)
+ .expect("reference balance does not overflow");
+ }
+ balances
+}
+
+fn random_wallet_pair<'a>(rng: &mut TestRng, wallets: &'a [Wallet]) -> (&'a Wallet, &'a Wallet) {
+ let from = rng.index(wallets.len());
+ let mut to = rng.index(wallets.len() - 1);
+ if to >= from {
+ to += 1;
+ }
+ (&wallets[from], &wallets[to])
+}
+
+fn try_random_transaction(
+ seed: u64,
+ round: usize,
+ rng: &mut TestRng,
+ wallets: &[Wallet],
+ ledger: &mut Ledger,
+) {
+ match rng.index(5) {
+ 0 => {
+ let (from, to) = random_wallet_pair(rng, wallets);
+ let amount = rng.amount(5 * MICRO_LUUN);
+ let fee = rng.next_u64() % 4;
+ if let Ok(tx) = ledger.build_transfer(from, to.address(), amount, fee) {
+ let _ = ledger.submit_transaction(tx);
+ }
+ }
+ 1 => {
+ let wallet = &wallets[rng.index(wallets.len())];
+ let amount = rng.amount(3 * MICRO_LUUN);
+ let fee = rng.next_u64() % 4;
+ if let Ok(tx) = ledger.build_burn(wallet, amount, fee) {
+ let _ = ledger.submit_transaction(tx);
+ }
+ }
+ 2 if round % 4 == 0 => {
+ let wallet = &wallets[rng.index(wallets.len())];
+ if let Ok(tx) = ledger.build_mine_with_fee(wallet.address(), rng.next_u64() % 3) {
+ let _ = ledger.submit_transaction(tx);
+ }
+ }
+ 3 => {
+ let wallet = &wallets[rng.index(wallets.len())];
+ let impossible = (seed + round as u64 + 1) * MICRO_LUUN * 10_000;
+ assert!(ledger.build_burn(wallet, impossible, 0).is_err());
+ }
+ _ => {}
+ }
+}
+
+fn try_finalize_next_block(round: usize, wallets: &[Wallet], ledger: &mut Ledger) {
+ let Some(leader) = ledger.expected_leader_for_next_block() else {
+ return;
+ };
+ let Some(wallet) = wallets.iter().find(|wallet| wallet.address() == leader) else {
+ return;
+ };
+
+ if let Ok(tx) = ledger.build_burn(wallet, MICRO_LUUN, 0) {
+ let _ = ledger.submit_transaction(tx);
+ }
+
+ if let Ok(block) = ledger.mine_next_block(wallet, (round + 1) as u64) {
+ ledger
+ .apply_block(block)
+ .expect("locally mined block applies");
+ }
+}
+
+fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) {
+ let burn = ledger
+ .build_burn(wallet, MICRO_LUUN, 0)
+ .expect("finalizer can build burn");
+ let _ = ledger
+ .submit_transaction(burn)
+ .expect("finalizer burn enters mempool");
+ let block = ledger
+ .mine_next_block(wallet, timestamp_ms)
+ .expect("finalizer can mine next block");
+ ledger.apply_block(block).expect("finalizer block applies");
+}
+
+fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_timestamp_ms: u64) {
+ for offset in 0..count {
+ finalize_with_wallet(ledger, wallet, start_timestamp_ms + offset as u64);
+ }
+}
+
+#[test]
+fn generated_chain_snapshots_preserve_core_invariants() {
+ for seed in LEDGER_PROPERTY_SEEDS {
+ let (wallets, mut ledger) = property_ledger(seed, 4);
+ let mut rng = TestRng::new(seed);
+
+ assert_chain_properties(ledger.snapshot());
+ for round in 0..LEDGER_PROPERTY_ROUNDS {
+ try_random_transaction(seed, round, &mut rng, &wallets, &mut ledger);
+ if round % 2 == 0 {
+ try_finalize_next_block(round, &wallets, &mut ledger);
+ }
+ assert_chain_properties(ledger.snapshot());
+ }
+ }
+}
+
+#[test]
+fn generated_forks_reorg_only_inside_finality_and_preserve_local_transactions() {
+ for seed in FORK_PROPERTY_SEEDS {
+ let (wallets, mut common) = single_finalizer_ledger(seed, 3);
+ let finalizer = &wallets[0];
+ let sender = &wallets[1];
+ let recipient = &wallets[2];
+ let mut rng = TestRng::new(seed);
+
+ finalize_many(&mut common, finalizer, 2 + rng.index(2), 1);
+ assert_chain_properties(common.snapshot());
+
+ let mut local = common.clone();
+ let abandoned = local
+ .build_transfer(sender, recipient.address(), MICRO_LUUN + rng.amount(5), 0)
+ .expect("abandoned fork transfer builds");
+ local
+ .submit_transaction(abandoned.clone())
+ .expect("abandoned fork transfer enters mempool");
+ finalize_many(&mut local, finalizer, 1 + rng.index(2), 20);
+
+ let mut remote = common.clone();
+ finalize_many(&mut remote, finalizer, 4 + rng.index(3), 100);
+
+ let remote_tip = remote.status().tip_hash;
+ assert!(
+ local
+ .extend_from_snapshot(remote.snapshot())
+ .expect("valid fresh fork import succeeds"),
+ "longer fresh fork should be accepted"
+ );
+ assert_eq!(local.status().tip_hash, remote_tip);
+ assert!(
+ local
+ .pending()
+ .iter()
+ .any(|tx| tx.signature() == abandoned.signature()),
+ "transactions mined only on the abandoned fork should return to the mempool"
+ );
+ assert_chain_properties(local.snapshot());
+
+ let (wallets, mut common) = single_finalizer_ledger(seed + 10_000, 2);
+ let finalizer = &wallets[0];
+ finalize_with_wallet(&mut common, finalizer, 1);
+
+ let mut finalized_local = common.clone();
+ finalize_many(&mut finalized_local, finalizer, 8 + rng.index(2), 10);
+ let finalized_tip = finalized_local.status().tip_hash;
+
+ let mut too_old_remote = common;
+ finalize_many(&mut too_old_remote, finalizer, 12 + rng.index(2), 200);
+
+ assert!(
+ !finalized_local
+ .extend_from_snapshot(too_old_remote.snapshot())
+ .expect("valid old fork import is evaluated"),
+ "forks that rewrite finalized history must be rejected"
+ );
+ assert_eq!(finalized_local.status().tip_hash, finalized_tip);
+ assert_chain_properties(finalized_local.snapshot());
+ }
+}
+
+#[test]
+fn in_memory_network_converges_under_generated_node_actions() {
+ for seed in NETWORK_PROPERTY_SEEDS {
+ let (wallets, ledger) = property_ledger(seed, 3);
+ let mut network = InMemoryNetwork::default();
+
+ for (index, wallet) in wallets.iter().enumerate() {
+ let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
+ network.insert(
+ format!("n{index}"),
+ NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_LUUN,
+ 0,
+ ),
+ );
+ }
+
+ let mut rng = TestRng::new(seed);
+ network
+ .deliver_until_idle()
+ .expect("initial network delivery succeeds");
+
+ for round in 0..NETWORK_PROPERTY_ROUNDS {
+ let node_index = rng.index(wallets.len());
+ let node_id = format!("n{node_index}");
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+
+ match rng.index(4) {
+ 0 => {
+ let _ = network
+ .node_mut(&node_id)
+ .expect("node exists")
+ .transfer_with_fee(recipient, rng.amount(2 * MICRO_LUUN), 0);
+ }
+ 1 => {
+ let _ = network
+ .node_mut(&node_id)
+ .expect("node exists")
+ .burn_with_fee(MICRO_LUUN, 0);
+ }
+ 2 => {
+ let _ = network
+ .node_mut(&node_id)
+ .expect("node exists")
+ .mine_pow_reward();
+ }
+ _ => {}
+ }
+
+ network
+ .deliver_until_idle()
+ .expect("transaction gossip converges");
+
+ let leader = network
+ .node("n0")
+ .expect("anchor node exists")
+ .ledger()
+ .expected_leader_for_next_block();
+ if let Some(leader) = leader {
+ if let Some((leader_index, _)) = wallets
+ .iter()
+ .enumerate()
+ .find(|(_, wallet)| wallet.address() == leader)
+ {
+ let outcome = network
+ .node_mut(&format!("n{leader_index}"))
+ .expect("leader node exists")
+ .automatic_mine_once((round + 1) as u64);
+ if let Some(reason) = outcome.skipped_reason {
+ assert!(
+ reason.contains("at least one burn")
+ || reason.contains("selected finalizer")
+ || reason.contains("could not")
+ || reason.contains("automatic"),
+ "unexpected mining skip reason: {reason}"
+ );
+ }
+ }
+ }
+
+ network
+ .deliver_until_idle()
+ .expect("block gossip converges");
+ assert_network_converged(&network, wallets.len());
+ }
+ }
+}
+
+fn assert_network_converged(network: &InMemoryNetwork, nodes: usize) {
+ let first = network.node("n0").expect("first node exists");
+ let height = first.chain_height();
+ let tip = first.ledger().status().tip_hash;
+ assert_chain_properties(first.chain_snapshot());
+
+ for index in 1..nodes {
+ let node = network.node(&format!("n{index}")).expect("node exists");
+ assert_eq!(node.chain_height(), height, "node {index} height diverged");
+ assert_eq!(
+ node.ledger().status().tip_hash,
+ tip,
+ "node {index} tip diverged"
+ );
+ assert_chain_properties(node.chain_snapshot());
+ }
+}
+
+fn deliver_with_chaos(
+ network: &mut InMemoryNetwork,
+ node_ids: &[String],
+ offline: &BTreeSet<String>,
+ rng: &mut TestRng,
+) -> bool {
+ let mut outbound = Vec::new();
+ for id in node_ids {
+ if offline.contains(id) {
+ continue;
+ }
+ let node = network.node_mut(id).expect("node exists");
+ for envelope in node.drain_outbox() {
+ outbound.push((id.clone(), envelope));
+ }
+ }
+
+ if outbound.is_empty() {
+ return false;
+ }
+
+ while !outbound.is_empty() {
+ let index = rng.index(outbound.len());
+ let (from, envelope) = outbound.swap_remove(index);
+ for id in node_ids {
+ if *id == from || offline.contains(id) {
+ continue;
+ }
+ let duplicate = rng.index(5) == 0;
+ receive_chaotic_envelope(network, id, envelope.clone());
+ if duplicate {
+ receive_chaotic_envelope(network, id, envelope.clone());
+ }
+ }
+ }
+
+ true
+}
+
+fn receive_chaotic_envelope(
+ network: &mut InMemoryNetwork,
+ id: &str,
+ envelope: luun::app::GossipEnvelope,
+) {
+ if let Err(error) = network.node_mut(id).expect("node exists").receive(envelope) {
+ let message = error.to_string();
+ assert!(
+ message.contains("expected block height")
+ || message.contains("mine transaction anchor is not on this chain"),
+ "unexpected chaotic delivery error: {message}"
+ );
+ }
+}
+
+fn deliver_chaos_until_idle(
+ network: &mut InMemoryNetwork,
+ node_ids: &[String],
+ offline: &BTreeSet<String>,
+ rng: &mut TestRng,
+) {
+ for _ in 0..32 {
+ if !deliver_with_chaos(network, node_ids, offline, rng) {
+ return;
+ }
+ }
+ panic!("chaotic network delivery did not become idle");
+}
+
+#[test]
+fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() {
+ for seed in NETWORK_CHAOS_SEEDS {
+ let (wallets, ledger) = single_finalizer_ledger(seed, 3);
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+ let mut network = InMemoryNetwork::default();
+
+ for (index, wallet) in wallets.iter().enumerate() {
+ let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
+ network.insert(
+ &node_ids[index],
+ NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_LUUN,
+ 0,
+ ),
+ );
+ }
+
+ let mut rng = TestRng::new(seed);
+ deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
+
+ for round in 0..NETWORK_CHAOS_ROUNDS {
+ let mut offline = BTreeSet::new();
+ if round % 3 == 0 {
+ offline.insert("n2".to_string());
+ } else if round % 4 == 0 {
+ offline.insert("n1".to_string());
+ }
+
+ let actor_index = 1 + rng.index(wallets.len() - 1);
+ let actor_id = format!("n{actor_index}");
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ match rng.index(3) {
+ 0 => {
+ let _ = network
+ .node_mut(&actor_id)
+ .expect("actor node exists")
+ .transfer_with_fee(recipient, MICRO_LUUN + rng.amount(17), 0);
+ }
+ 1 => {
+ let _ = network
+ .node_mut(&actor_id)
+ .expect("actor node exists")
+ .mine_pow_reward();
+ }
+ _ => {
+ let _ = network
+ .node_mut("n0")
+ .expect("finalizer node exists")
+ .burn_with_fee(MICRO_LUUN, 0);
+ }
+ }
+
+ let outcome = network
+ .node_mut("n0")
+ .expect("finalizer node exists")
+ .automatic_mine_once((round + 1) as u64);
+ if let Some(reason) = outcome.skipped_reason {
+ assert!(
+ reason.contains("at least one burn") || reason.contains("could not"),
+ "unexpected chaotic mining skip reason: {reason}"
+ );
+ }
+
+ deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng);
+ }
+
+ deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
+ for id in node_ids.iter().skip(1) {
+ while network
+ .sync_node_from_peer("n0", id, 128)
+ .expect("lagging node range sync succeeds")
+ {}
+ }
+ deliver_chaos_until_idle(&mut network, &node_ids, &BTreeSet::new(), &mut rng);
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+#[test]
+fn generated_snapshot_tampering_is_rejected() {
+ for seed in TAMPER_PROPERTY_SEEDS {
+ let (wallets, mut ledger) = property_ledger(seed, 3);
+ for round in 0..6 {
+ try_finalize_next_block(round, &wallets, &mut ledger);
+ }
+ assert_chain_properties(ledger.snapshot());
+
+ let mut mutated_hash = ledger.snapshot();
+ if let Some(block) = mutated_hash.blocks.last_mut() {
+ block.timestamp_ms = block.timestamp_ms.saturating_add(1);
+ }
+ assert!(Ledger::from_snapshot(mutated_hash).is_err());
+
+ let mut mutated_transaction = ledger.snapshot();
+ if let Some(transaction) = mutated_transaction
+ .blocks
+ .iter_mut()
+ .flat_map(|block| block.transactions.iter_mut())
+ .next()
+ {
+ match transaction {
+ Transaction::Transfer { signature, .. }
+ | Transaction::Burn { signature, .. }
+ | Transaction::Mine { signature, .. } => signature.push_str("00"),
+ }
+ }
+ assert!(Ledger::from_snapshot(mutated_transaction).is_err());
+ }
+}