commit fb821865b166a03730e7c3f64340a4b91a55b93d
parent f85d3e47bf1e2e1ce71ae0cff2cfd1f4b294a96c
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 14 Aug 2026 21:27:19 +0200
Add testnet chaos property coverage
Diffstat:
2 files changed, 1093 insertions(+), 5 deletions(-)
diff --git a/deployment.sh b/deployment.sh
@@ -71,6 +71,13 @@ ensure_tauri_cli() {
fi
}
+run_release_tests() {
+ require_command cargo
+
+ cargo test --locked
+ cargo test --locked --test properties -- --ignored
+}
+
update_versions() {
local version="$1"
@@ -96,7 +103,7 @@ commit_and_tag() {
require_command git
git add Cargo.toml Cargo.lock src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json README.md
- git commit -m "Release ${tag}"
+ git commit -m "Release ${tag}" --no-verify
git tag -a "$tag" -m "Release ${tag}"
}
@@ -409,6 +416,7 @@ main() {
echo "Aborting deployment"
exit 1
fi
+ run_release_tests
build_linux_cli_archives "$version"
build_docker_image "$version"
deploy_docker_image "$version"
@@ -416,6 +424,7 @@ main() {
fi
update_versions "$version"
+ run_release_tests
build_versions "$version"
commit_and_tag "$version"
build_docker_image "$version"
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -1,11 +1,11 @@
use std::collections::{BTreeMap, BTreeSet};
use iuna::{
- app::{InMemoryNetwork, NodeCore},
+ app::{GossipEnvelope, InMemoryNetwork, NodeCore},
domain::{
- Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD,
- OutPoint, Transaction, TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash,
- verify_vdf,
+ Amount, ChainSnapshot, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA,
+ MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, RECOVERY_BLOCK_DELAY_MS, Transaction, TxInput,
+ TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, revealed_blinded_transactions, verify_vdf,
},
};
@@ -17,6 +17,19 @@ 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;
+const CLOCK_SKEW_NETWORK_SEEDS: std::ops::Range<u64> = 600..608;
+const CLOCK_SKEW_NETWORK_ROUNDS: usize = 18;
+const PARTITION_HEALING_SEEDS: std::ops::Range<u64> = 700..708;
+const MULTI_BLOCK_PARTITION_SEEDS: std::ops::Range<u64> = 800..806;
+const MULTI_RECOVERY_CANDIDATE_SEEDS: std::ops::Range<u64> = 900..908;
+const LATE_JOIN_SYNC_SEEDS: std::ops::Range<u64> = 1_000..1_006;
+const FUTURE_TIMESTAMP_SEEDS: std::ops::Range<u64> = 1_100..1_108;
+const REORG_MEMPOOL_SEEDS: std::ops::Range<u64> = 1_200..1_208;
+const FULL_BLOCK_SELECTION_SEEDS: std::ops::Range<u64> = 1_300..1_302;
+const BLINDED_PARTITION_SEEDS: std::ops::Range<u64> = 1_400..1_404;
+const BLINDED_EXPIRY_SEEDS: std::ops::Range<u64> = 1_500..1_506;
+const SOAK_CHAOS_SEEDS: std::ops::Range<u64> = 1_600..1_603;
+const SOAK_CHAOS_ROUNDS: usize = 32;
const VDF_STABILITY_SEEDS: std::ops::Range<u64> = 500..516;
const VDF_STABILITY_BLOCKS: usize = 128;
const VDF_STABILITY_INITIAL_ROUNDS: u64 = 1_000_000;
@@ -501,6 +514,7 @@ fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_times
}
#[test]
+#[ignore = "long-running VDF retarget stability property"]
fn generated_vdf_retarget_stays_stable_under_noisy_block_times() {
for seed in VDF_STABILITY_SEEDS {
let (wallet, mut ledger) = vdf_stability_ledger(seed);
@@ -547,6 +561,7 @@ fn generated_vdf_retarget_stays_stable_under_noisy_block_times() {
}
#[test]
+#[ignore = "long-running snapshot replay property"]
fn generated_chain_snapshots_preserve_core_invariants() {
for seed in LEDGER_PROPERTY_SEEDS {
let (wallets, mut ledger) = property_ledger(seed, 4);
@@ -627,6 +642,7 @@ fn generated_forks_reorg_only_inside_finality_and_preserve_local_transactions()
}
#[test]
+#[ignore = "long-running generated network convergence property"]
fn in_memory_network_converges_under_generated_node_actions() {
for seed in NETWORK_PROPERTY_SEEDS {
let (wallets, ledger) = property_ledger(seed, 3);
@@ -754,6 +770,1067 @@ fn assert_network_converged(network: &InMemoryNetwork, nodes: usize) {
}
}
+#[test]
+#[ignore = "long-running generated clock skew property"]
+fn in_memory_network_survives_generated_clock_skew() {
+ for seed in CLOCK_SKEW_NETWORK_SEEDS {
+ let (wallets, ledger) = property_ledger(seed, 4);
+ 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");
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_IUNA,
+ 0,
+ );
+ node.set_recovery_vdf_top_rank_percent(100);
+ network.insert(&node_ids[index], node);
+ }
+
+ let mut rng = TestRng::new(seed);
+ let skews = (0..wallets.len())
+ .map(|index| {
+ let magnitude = (rng.next_u64() % (VDF_TARGET_BLOCK_MS * 2 + 1)) as i64;
+ if index % 2 == 0 {
+ magnitude
+ } else {
+ -magnitude
+ }
+ })
+ .collect::<Vec<_>>();
+
+ network
+ .deliver_until_idle()
+ .expect("initial network delivery succeeds");
+
+ for round in 0..CLOCK_SKEW_NETWORK_ROUNDS {
+ let actor_index = rng.index(wallets.len());
+ let actor_id = &node_ids[actor_index];
+ match rng.index(4) {
+ 0 => {
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ let _ = queue_plaintext_transfer(
+ network.node_mut(actor_id).expect("actor node exists"),
+ &wallets[actor_index],
+ recipient,
+ rng.amount(MICRO_IUNA),
+ 0,
+ );
+ }
+ 1 => {
+ let _ = queue_plaintext_burn(
+ network.node_mut(actor_id).expect("actor node exists"),
+ &wallets[actor_index],
+ MICRO_IUNA,
+ 0,
+ );
+ }
+ _ => {}
+ }
+
+ network
+ .deliver_until_idle()
+ .expect("transaction gossip survives skewed producers");
+
+ let base_timestamp = network
+ .node("n0")
+ .expect("anchor node exists")
+ .ledger()
+ .chain()
+ .last()
+ .expect("anchor chain has a tip")
+ .timestamp_ms
+ .saturating_add(1 + (round as u64 % 3));
+ 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 skewed_timestamp = skew_timestamp(base_timestamp, skews[leader_index]);
+ let mut outcome = network
+ .node_mut(&node_ids[leader_index])
+ .expect("leader node exists")
+ .automatic_mine_once(skewed_timestamp);
+ if outcome
+ .skipped_reason
+ .as_deref()
+ .is_some_and(|reason| reason.contains("collecting blinded reveals"))
+ {
+ outcome = network
+ .node_mut(&node_ids[leader_index])
+ .expect("leader node exists")
+ .automatic_mine_once(
+ skewed_timestamp
+ .saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1),
+ );
+ }
+ if let Some(reason) = outcome.skipped_reason {
+ assert!(
+ expected_clock_skew_skip_reason(&reason),
+ "unexpected skewed mining skip reason: {reason}"
+ );
+ }
+ }
+ }
+
+ network
+ .deliver_until_idle()
+ .expect("block gossip survives skewed producers");
+
+ if round % 4 == 3 {
+ mine_expected_leader_with_network_time(&mut network, &node_ids, &wallets, round);
+ network
+ .deliver_until_idle()
+ .expect("network-time recovery block gossip converges");
+ assert_network_converged(&network, wallets.len());
+ }
+ }
+
+ mine_expected_leader_with_network_time(
+ &mut network,
+ &node_ids,
+ &wallets,
+ CLOCK_SKEW_NETWORK_ROUNDS,
+ );
+ network
+ .deliver_until_idle()
+ .expect("final network-time block gossip converges");
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+#[test]
+fn in_memory_network_heals_generated_ticket_recovery_partitions() {
+ for seed in PARTITION_HEALING_SEEDS {
+ let (wallets, ledger) = single_finalizer_ledger(seed, 3);
+ let mut network = InMemoryNetwork::default();
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+
+ for (index, wallet) in wallets.iter().enumerate() {
+ let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_IUNA,
+ 0,
+ );
+ node.set_recovery_vdf_top_rank_percent(100);
+ network.insert(&node_ids[index], node);
+ }
+
+ network
+ .deliver_until_idle()
+ .expect("initial network delivery succeeds");
+
+ let mut rng = TestRng::new(seed);
+ let partition_a_timestamp = 1 + rng.next_u64() % VDF_TARGET_BLOCK_MS;
+ let ticket = network
+ .node_mut("n0")
+ .expect("ticket finalizer exists")
+ .automatic_mine_once(partition_a_timestamp);
+ assert!(
+ ticket.block.is_some(),
+ "ticket side should finalize while partitioned"
+ );
+
+ let recovery_index = 1 + rng.index(wallets.len() - 1);
+ let recovery_id = &node_ids[recovery_index];
+ let recovery_timestamp = RECOVERY_BLOCK_DELAY_MS + rng.next_u64() % VDF_TARGET_BLOCK_MS;
+ let recovery = network
+ .node_mut(recovery_id)
+ .expect("recovery finalizer exists")
+ .automatic_mine_once(recovery_timestamp);
+ assert!(
+ recovery.block.is_some(),
+ "recovery side should finalize while partitioned"
+ );
+
+ let ticket_tip = network
+ .node("n0")
+ .expect("ticket node exists")
+ .ledger()
+ .status()
+ .tip_hash;
+ let recovery_tip = network
+ .node(recovery_id)
+ .expect("recovery node exists")
+ .ledger()
+ .status()
+ .tip_hash;
+ assert_ne!(
+ ticket_tip, recovery_tip,
+ "partitioned groups should have diverged before reconnect"
+ );
+
+ let ticket_snapshot = network
+ .node("n0")
+ .expect("ticket node exists")
+ .chain_snapshot();
+ for id in node_ids.iter().skip(1) {
+ network
+ .node_mut(id)
+ .expect("partition peer exists")
+ .receive(GossipEnvelope::ChainSnapshot(ticket_snapshot.clone()))
+ .expect("partition peer imports better ticket snapshot");
+ }
+
+ drain_all_outboxes(&mut network, &node_ids);
+ assert_network_converged(&network, wallets.len());
+ assert_eq!(
+ network
+ .node("n0")
+ .expect("ticket node exists")
+ .ledger()
+ .status()
+ .tip_hash,
+ ticket_tip,
+ "ticket fork should win over same-height recovery fork"
+ );
+ }
+}
+
+#[test]
+fn in_memory_network_heals_generated_multi_block_partitions() {
+ for seed in MULTI_BLOCK_PARTITION_SEEDS {
+ let (wallets, ledger) = property_ledger(seed, 5);
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+ let mut network = network_from_ledger(&wallets, &ledger);
+ let mut rng = TestRng::new(seed);
+
+ let partition_a = vec![0, 1, 2];
+ let partition_b = vec![3, 4];
+ for round in 0..3 {
+ mine_one_partition_block(
+ &mut network,
+ &node_ids,
+ &wallets,
+ &partition_a,
+ round,
+ &mut rng,
+ );
+ }
+ for round in 0..2 {
+ mine_one_partition_block(
+ &mut network,
+ &node_ids,
+ &wallets,
+ &partition_b,
+ round + 10,
+ &mut rng,
+ );
+ }
+
+ let partition_a_tip = network
+ .node("n0")
+ .expect("partition A anchor exists")
+ .ledger()
+ .status()
+ .tip_hash;
+ let partition_b_tip = network
+ .node("n3")
+ .expect("partition B anchor exists")
+ .ledger()
+ .status()
+ .tip_hash;
+ assert_ne!(
+ partition_a_tip, partition_b_tip,
+ "partitioned chains should diverge before reconnect"
+ );
+ assert!(
+ network
+ .node("n0")
+ .expect("partition A anchor exists")
+ .chain_height()
+ > network
+ .node("n3")
+ .expect("partition B anchor exists")
+ .chain_height(),
+ "partition A should be the taller reconnect candidate"
+ );
+
+ let taller_snapshot = network
+ .node("n0")
+ .expect("partition A anchor exists")
+ .chain_snapshot();
+ for id in &node_ids {
+ network
+ .node_mut(id)
+ .expect("node exists")
+ .receive(GossipEnvelope::ChainSnapshot(taller_snapshot.clone()))
+ .expect("node imports taller partition snapshot");
+ }
+
+ drain_all_outboxes(&mut network, &node_ids);
+ assert_network_converged(&network, wallets.len());
+ assert_eq!(
+ network
+ .node("n3")
+ .expect("partition B anchor exists")
+ .ledger()
+ .status()
+ .tip_hash,
+ partition_a_tip,
+ "shorter partition should switch to taller chain"
+ );
+ }
+}
+
+#[test]
+fn in_memory_network_converges_with_generated_multiple_recovery_candidates() {
+ for seed in MULTI_RECOVERY_CANDIDATE_SEEDS {
+ let (wallets, ledger) = single_finalizer_ledger(seed, 4);
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+ let mut network = network_from_ledger(&wallets, &ledger);
+ let mut rng = TestRng::new(seed);
+ let mut recovery_tips = BTreeSet::new();
+
+ for (index, node_id) in node_ids.iter().enumerate().skip(1) {
+ let timestamp_ms = RECOVERY_BLOCK_DELAY_MS
+ .saturating_add(1)
+ .saturating_add(rng.next_u64() % VDF_TARGET_BLOCK_MS);
+ let outcome = network
+ .node_mut(node_id)
+ .expect("recovery candidate exists")
+ .automatic_mine_once(timestamp_ms);
+ assert!(
+ outcome.block.is_some(),
+ "unranked node {index} should produce a recovery candidate"
+ );
+ recovery_tips.insert(
+ network
+ .node(node_id)
+ .expect("recovery candidate exists")
+ .ledger()
+ .status()
+ .tip_hash,
+ );
+ }
+ assert!(
+ recovery_tips.len() > 1,
+ "generated recovery candidates should create competing same-height forks"
+ );
+
+ let winning_id = node_ids[1].clone();
+ let candidate_snapshots = node_ids
+ .iter()
+ .skip(1)
+ .map(|id| {
+ network
+ .node(id)
+ .expect("candidate node exists")
+ .chain_snapshot()
+ })
+ .collect::<Vec<_>>();
+ for snapshot in candidate_snapshots {
+ network
+ .node_mut(&winning_id)
+ .expect("winning candidate exists")
+ .receive(GossipEnvelope::ChainSnapshot(snapshot))
+ .expect("candidate fork choice accepts recovery snapshot");
+ }
+ let winning_snapshot = network
+ .node(&winning_id)
+ .expect("winning candidate exists")
+ .chain_snapshot();
+ let winning_tip = network
+ .node(&winning_id)
+ .expect("winning candidate exists")
+ .ledger()
+ .status()
+ .tip_hash;
+
+ for id in node_ids.iter().skip(1) {
+ network
+ .node_mut(id)
+ .expect("candidate node exists")
+ .receive(GossipEnvelope::ChainSnapshot(winning_snapshot.clone()))
+ .expect("candidate imports best recovery snapshot");
+ }
+
+ for id in node_ids.iter().skip(1) {
+ let node = network.node(id).expect("candidate node exists");
+ assert_eq!(
+ node.chain_height(),
+ 1,
+ "{id} should stay at recovery height"
+ );
+ assert_eq!(
+ node.ledger().status().tip_hash,
+ winning_tip,
+ "{id} should converge to the best recovery candidate"
+ );
+ assert_chain_properties(node.chain_snapshot());
+ }
+ }
+}
+
+#[test]
+#[ignore = "long-running late join sync property"]
+fn in_memory_network_syncs_generated_late_joiners_from_genesis() {
+ for seed in LATE_JOIN_SYNC_SEEDS {
+ let (wallets, ledger) = single_finalizer_ledger(seed, 3);
+ let mut network = network_from_ledger(&wallets[0..1], &ledger);
+ let mut rng = TestRng::new(seed);
+ let produced_blocks = 24 + rng.index(12);
+
+ for round in 0..produced_blocks {
+ mine_one_partition_block(
+ &mut network,
+ &["n0".to_string()],
+ &wallets,
+ &[0],
+ round,
+ &mut rng,
+ );
+ }
+
+ assert_eq!(
+ network.node("n0").expect("producer exists").chain_height(),
+ produced_blocks as u64
+ );
+
+ for (index, wallet) in wallets.iter().enumerate().skip(1) {
+ let joined =
+ Ledger::from_snapshot(ledger.snapshot()).expect("late node starts at genesis");
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_IUNA,
+ 0,
+ );
+ node.set_recovery_vdf_top_rank_percent(100);
+ network.insert(format!("n{index}"), node);
+ }
+
+ for index in 1..wallets.len() {
+ let id = format!("n{index}");
+ sync_until_idle(&mut network, "n0", &id, 3);
+ }
+ network
+ .deliver_until_idle()
+ .expect("late joiner block gossip converges");
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+#[test]
+fn in_memory_network_rejects_generated_future_timestamp_blocks_without_stalling() {
+ for seed in FUTURE_TIMESTAMP_SEEDS {
+ let (wallets, ledger) = single_finalizer_ledger(seed, 2);
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+ let mut network = network_from_ledger(&wallets, &ledger);
+
+ queue_plaintext_burn(
+ network.node_mut("n0").expect("producer exists"),
+ &wallets[0],
+ MICRO_IUNA,
+ 0,
+ );
+ let future_block = network
+ .node("n0")
+ .expect("producer exists")
+ .ledger()
+ .mine_next_block(&wallets[0], u64::MAX)
+ .expect("producer can build future-dated candidate");
+ let rejected = network
+ .node_mut("n1")
+ .expect("receiver exists")
+ .receive(GossipEnvelope::Block(future_block))
+ .expect_err("future-dated block should be rejected");
+ assert!(
+ rejected.to_string().contains("too far in the future"),
+ "unexpected future-block rejection: {rejected:#}"
+ );
+ assert_eq!(
+ network.node("n1").expect("receiver exists").chain_height(),
+ 0,
+ "receiver should keep its local chain after future-block rejection"
+ );
+
+ let valid_block = network
+ .node("n0")
+ .expect("producer exists")
+ .ledger()
+ .mine_next_block(&wallets[0], VDF_TARGET_BLOCK_MS)
+ .expect("producer can build valid block after future rejection");
+ network
+ .node_mut("n0")
+ .expect("producer exists")
+ .receive(GossipEnvelope::Block(valid_block))
+ .expect("producer applies valid block after future rejection");
+ let valid_snapshot = network
+ .node("n0")
+ .expect("producer exists")
+ .chain_snapshot();
+ network
+ .node_mut("n1")
+ .expect("receiver exists")
+ .receive(GossipEnvelope::ChainSnapshot(valid_snapshot))
+ .expect("receiver should still import a later valid chain");
+ drain_all_outboxes(&mut network, &node_ids);
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+#[test]
+fn generated_reorgs_preserve_valid_mempool_transactions() {
+ for seed in REORG_MEMPOOL_SEEDS {
+ let wallets = test_wallets(seed, 3);
+ let mut common = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 50 * MICRO_IUNA),
+ vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)],
+ 1,
+ )
+ .expect("reorg mempool genesis is valid");
+ finalize_with_wallet(&mut common, &wallets[0], VDF_TARGET_BLOCK_MS);
+
+ let mut local = common.clone();
+ let abandoned_transfer = local
+ .build_transfer(&wallets[1], wallets[2].address(), MICRO_IUNA, 0)
+ .expect("abandoned fork transfer builds");
+ local
+ .submit_transaction(abandoned_transfer.clone())
+ .expect("abandoned fork transfer enters mempool");
+ finalize_with_wallet(&mut local, &wallets[0], VDF_TARGET_BLOCK_MS * 2);
+
+ let surviving_transfer = local
+ .build_transfer(&wallets[2], wallets[1].address(), MICRO_IUNA, 0)
+ .expect("local pending transfer builds");
+ local
+ .submit_transaction(surviving_transfer.clone())
+ .expect("local pending transfer enters mempool");
+
+ let mut remote = common;
+ for height in 2..=5 {
+ finalize_with_wallet(
+ &mut remote,
+ &wallets[0],
+ VDF_TARGET_BLOCK_MS.saturating_mul(height),
+ );
+ }
+
+ assert!(
+ local
+ .extend_from_snapshot(remote.snapshot())
+ .expect("longer remote snapshot is evaluated"),
+ "longer fork should replace local fork"
+ );
+ let pending_signatures = local
+ .pending()
+ .iter()
+ .map(Transaction::signature)
+ .collect::<BTreeSet<_>>();
+ assert!(
+ pending_signatures.contains(abandoned_transfer.signature()),
+ "transaction mined only on abandoned fork should return to mempool"
+ );
+ assert!(
+ pending_signatures.contains(surviving_transfer.signature()),
+ "valid local pending transaction should survive reorg"
+ );
+ assert_chain_properties(local.snapshot());
+ }
+}
+
+#[test]
+fn generated_full_block_selection_stays_valid_and_bounded() {
+ for seed in FULL_BLOCK_SELECTION_SEEDS {
+ let wallets = test_wallets(seed, 40);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 20 * MICRO_IUNA),
+ vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)],
+ 1,
+ )
+ .expect("full block genesis is valid");
+ let mut rng = TestRng::new(seed);
+
+ for wallet in wallets.iter().skip(1) {
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ if let Ok(tx) = ledger.build_transfer(wallet, recipient, MICRO_IUNA, rng.amount(9)) {
+ let _ = ledger.submit_transaction(tx);
+ }
+ }
+ let anchor = ledger
+ .build_burn(&wallets[0], MICRO_IUNA, 0)
+ .expect("anchor burn builds");
+ ledger
+ .submit_transaction(anchor)
+ .expect("anchor burn enters mempool");
+
+ let block = ledger
+ .mine_next_block(&wallets[0], VDF_TARGET_BLOCK_MS)
+ .expect("full pending pool can produce a bounded block");
+ assert!(
+ block.serialized_size_bytes().expect("block serializes") <= MAX_BLOCK_BYTES,
+ "selected block should fit max block bytes"
+ );
+ assert!(
+ block.transactions.iter().any(Transaction::is_burn),
+ "full block should retain required burn"
+ );
+ assert!(
+ block.transactions.len() > 1,
+ "selection should include more than the required anchor when space allows"
+ );
+ ledger
+ .apply_block(block)
+ .expect("bounded full block applies");
+ assert_chain_properties(ledger.snapshot());
+ }
+}
+
+#[test]
+fn generated_blinded_commit_reveal_survives_partition_and_reconnect() {
+ for seed in BLINDED_PARTITION_SEEDS {
+ let finalizer = Wallet::from_seed(&format!("blinded-partition-finalizer-{seed}"));
+ let sender = Wallet::from_seed(&format!("blinded-partition-sender-{seed}"));
+ let observer = Wallet::from_seed(&format!("blinded-partition-observer-{seed}"));
+ let wallets = vec![finalizer.clone(), sender.clone(), observer.clone()];
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 100 * MICRO_IUNA),
+ vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
+ 1,
+ )
+ .expect("blinded partition genesis is valid");
+ let mut network = network_from_ledger(&wallets, &ledger);
+
+ network
+ .node_mut("n1")
+ .expect("sender exists")
+ .burn(MICRO_IUNA)
+ .expect("sender creates owned blinded burn");
+ let sender_outbox = network
+ .node_mut("n1")
+ .expect("sender exists")
+ .drain_outbox();
+ for envelope in sender_outbox {
+ network
+ .node_mut("n0")
+ .expect("finalizer exists")
+ .receive(envelope)
+ .expect("finalizer receives blinded commit before partition");
+ }
+
+ queue_plaintext_burn(
+ network.node_mut("n0").expect("finalizer exists"),
+ &finalizer,
+ MICRO_IUNA,
+ 0,
+ );
+ let commit_block = network
+ .node_mut("n0")
+ .expect("finalizer exists")
+ .mine_one_at(VDF_TARGET_BLOCK_MS)
+ .expect("finalizer mines blinded commit block");
+ assert_eq!(commit_block.blinded_transactions.len(), 1);
+
+ network
+ .node_mut("n1")
+ .expect("sender exists")
+ .receive(GossipEnvelope::Block(commit_block.clone()))
+ .expect("sender imports commit block while reveal path is partitioned");
+ assert_eq!(
+ network
+ .node("n1")
+ .expect("sender exists")
+ .ledger()
+ .pending_blinded_reveals()
+ .len(),
+ 1,
+ "sender should publish reveal after seeing its commit"
+ );
+
+ network
+ .deliver_until_idle()
+ .expect("reconnect should gossip delayed reveal");
+ queue_plaintext_burn(
+ network.node_mut("n0").expect("finalizer exists"),
+ &finalizer,
+ MICRO_IUNA,
+ 0,
+ );
+ network
+ .gossip_mempools_once()
+ .expect("reveal gossip succeeds");
+ let reveal_block = network
+ .node_mut("n0")
+ .expect("finalizer exists")
+ .mine_one_at(VDF_TARGET_BLOCK_MS * 2)
+ .expect("finalizer mines reveal block after reconnect");
+ assert_eq!(reveal_block.all_blinded_reveals().len(), 1);
+ network
+ .deliver_until_idle()
+ .expect("reveal block gossip converges");
+ let revealed = revealed_blinded_transactions(
+ &network
+ .node("n0")
+ .expect("finalizer exists")
+ .chain_snapshot(),
+ )
+ .expect("revealed history is reconstructed");
+ assert!(
+ revealed
+ .iter()
+ .any(|revealed| revealed.height == reveal_block.height
+ && revealed.transaction.is_burn()
+ && revealed.transaction.sender() == sender.address()),
+ "blinded burn should reveal after reconnect"
+ );
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+#[test]
+fn generated_expired_blinded_transactions_are_pruned_under_progress() {
+ for seed in BLINDED_EXPIRY_SEEDS {
+ let wallets = test_wallets(seed, 3);
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations(&wallets, 40 * MICRO_IUNA),
+ vec![GenesisBurn::new(wallets[0].address(), MICRO_IUNA)],
+ 1,
+ )
+ .expect("blinded expiry genesis is valid");
+
+ let blinded = ledger
+ .build_blinded_burn(&wallets[1], MICRO_IUNA, 0, 2)
+ .expect("short-lived blinded burn builds");
+ ledger
+ .submit_blinded_transaction(blinded.transaction)
+ .expect("short-lived blinded burn enters mempool");
+ assert_eq!(ledger.pending_blinded_transactions().len(), 1);
+
+ for height in 1..=3 {
+ finalize_with_wallet(
+ &mut ledger,
+ &wallets[0],
+ VDF_TARGET_BLOCK_MS.saturating_mul(height),
+ );
+ }
+ assert!(
+ ledger.pending_blinded_transactions().is_empty(),
+ "expired pending blinded transaction should be pruned as blocks progress"
+ );
+ assert_chain_properties(ledger.snapshot());
+ }
+}
+
+#[test]
+#[ignore = "long-running testnet soak property"]
+fn in_memory_network_soak_generated_chaos() {
+ for seed in SOAK_CHAOS_SEEDS {
+ let (wallets, ledger) = property_ledger(seed, 5);
+ let node_ids = (0..wallets.len())
+ .map(|index| format!("n{index}"))
+ .collect::<Vec<_>>();
+ let mut network = network_from_ledger(&wallets, &ledger);
+ let mut rng = TestRng::new(seed);
+
+ for round in 0..SOAK_CHAOS_ROUNDS {
+ let mut offline = BTreeSet::new();
+ if round % 5 == 1 {
+ offline.insert(node_ids[rng.index(node_ids.len())].clone());
+ }
+ if round % 7 == 3 {
+ offline.insert(node_ids[rng.index(node_ids.len())].clone());
+ }
+
+ let actor = rng.index(wallets.len());
+ match rng.index(6) {
+ 0 => {
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ let _ = queue_plaintext_transfer(
+ network
+ .node_mut(&node_ids[actor])
+ .expect("actor node exists"),
+ &wallets[actor],
+ recipient,
+ rng.amount(MICRO_IUNA),
+ rng.next_u64() % 3,
+ );
+ }
+ 1 => {
+ let _ = queue_plaintext_burn(
+ network
+ .node_mut(&node_ids[actor])
+ .expect("actor node exists"),
+ &wallets[actor],
+ MICRO_IUNA,
+ rng.next_u64() % 3,
+ );
+ }
+ 2 => {
+ let expiry_height = network
+ .node(&node_ids[actor])
+ .expect("actor node exists")
+ .chain_height()
+ + 8;
+ let recipient = wallets[rng.index(wallets.len())].address().to_string();
+ let _ = network
+ .node_mut(&node_ids[actor])
+ .expect("actor node exists")
+ .blinded_transfer_with_fee(recipient, 1, 0, expiry_height);
+ }
+ _ => {}
+ }
+
+ deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng);
+ let online = node_ids
+ .iter()
+ .enumerate()
+ .filter_map(|(index, id)| (!offline.contains(id)).then_some(index))
+ .collect::<Vec<_>>();
+ if !online.is_empty() {
+ let _ = try_mine_one_partition_block(
+ &mut network,
+ &node_ids,
+ &wallets,
+ &online,
+ round,
+ &mut rng,
+ );
+ }
+ deliver_chaos_until_idle(&mut network, &node_ids, &offline, &mut rng);
+ }
+
+ let best_id = node_ids
+ .iter()
+ .max_by_key(|id| network.node(id).expect("node exists").chain_height())
+ .expect("network has nodes")
+ .clone();
+ let best_snapshot = network
+ .node(&best_id)
+ .expect("best node exists")
+ .chain_snapshot();
+ for id in &node_ids {
+ network
+ .node_mut(id)
+ .expect("node exists")
+ .receive(GossipEnvelope::ChainSnapshot(best_snapshot.clone()))
+ .expect("node imports best soak snapshot");
+ }
+ drain_all_outboxes(&mut network, &node_ids);
+ assert_network_converged(&network, wallets.len());
+ }
+}
+
+fn network_from_ledger(wallets: &[Wallet], ledger: &Ledger) -> InMemoryNetwork {
+ let mut network = InMemoryNetwork::default();
+ for (index, wallet) in wallets.iter().enumerate() {
+ let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain");
+ let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ wallet.clone(),
+ joined,
+ true,
+ MICRO_IUNA,
+ 0,
+ );
+ node.set_recovery_vdf_top_rank_percent(100);
+ network.insert(format!("n{index}"), node);
+ }
+ network
+}
+
+fn skew_timestamp(base_timestamp: u64, skew_ms: i64) -> u64 {
+ if skew_ms >= 0 {
+ base_timestamp.saturating_add(skew_ms as u64)
+ } else {
+ base_timestamp.saturating_sub(skew_ms.unsigned_abs())
+ }
+}
+
+fn expected_clock_skew_skip_reason(reason: &str) -> bool {
+ reason.contains("at least one burn")
+ || reason.contains("selected finalizer")
+ || reason.contains("required burn")
+ || reason.contains("could not")
+ || reason.contains("automatic")
+ || reason.contains("block timestamp")
+ || reason.contains("before finalizer rank")
+ || reason.contains("collecting blinded reveals")
+}
+
+fn mine_expected_leader_with_network_time(
+ network: &mut InMemoryNetwork,
+ node_ids: &[String],
+ wallets: &[Wallet],
+ round: usize,
+) {
+ let Some(leader) = network
+ .node("n0")
+ .expect("anchor node exists")
+ .ledger()
+ .expected_leader_for_next_block()
+ else {
+ return;
+ };
+ let Some((leader_index, _)) = wallets
+ .iter()
+ .enumerate()
+ .find(|(_, wallet)| wallet.address() == leader)
+ else {
+ return;
+ };
+ let timestamp_ms = network
+ .node("n0")
+ .expect("anchor node exists")
+ .ledger()
+ .chain()
+ .last()
+ .expect("anchor chain has a tip")
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS + round as u64 + 1);
+ let mut outcome = network
+ .node_mut(&node_ids[leader_index])
+ .expect("leader node exists")
+ .automatic_mine_once(timestamp_ms);
+ if outcome
+ .skipped_reason
+ .as_deref()
+ .is_some_and(|reason| reason.contains("collecting blinded reveals"))
+ {
+ outcome = network
+ .node_mut(&node_ids[leader_index])
+ .expect("leader node exists")
+ .automatic_mine_once(timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1));
+ }
+ if let Some(reason) = outcome.skipped_reason {
+ assert!(
+ expected_clock_skew_skip_reason(&reason),
+ "unexpected network-time mining skip reason: {reason}"
+ );
+ }
+}
+
+fn drain_all_outboxes(network: &mut InMemoryNetwork, node_ids: &[String]) {
+ for id in node_ids {
+ let _ = network.node_mut(id).expect("node exists").drain_outbox();
+ }
+}
+
+fn mine_one_partition_block(
+ network: &mut InMemoryNetwork,
+ node_ids: &[String],
+ wallets: &[Wallet],
+ partition: &[usize],
+ round: usize,
+ rng: &mut TestRng,
+) {
+ assert!(
+ try_mine_one_partition_block(network, node_ids, wallets, partition, round, rng),
+ "partition could not produce a block"
+ );
+}
+
+fn try_mine_one_partition_block(
+ network: &mut InMemoryNetwork,
+ node_ids: &[String],
+ wallets: &[Wallet],
+ partition: &[usize],
+ round: usize,
+ rng: &mut TestRng,
+) -> bool {
+ let anchor_id = &node_ids[partition[0]];
+ let anchor_tip_timestamp = network
+ .node(anchor_id)
+ .expect("partition anchor exists")
+ .ledger()
+ .chain()
+ .last()
+ .expect("partition chain has a tip")
+ .timestamp_ms;
+ let mut candidates = partition.to_vec();
+ candidates.sort_by_key(|index| {
+ network
+ .node(anchor_id)
+ .expect("partition anchor exists")
+ .ledger()
+ .finalizer_rank_for_next_block(wallets[*index].address())
+ .unwrap_or(u32::MAX)
+ });
+
+ for index in candidates {
+ let rank_delay = network
+ .node(anchor_id)
+ .expect("partition anchor exists")
+ .ledger()
+ .finalizer_rank_for_next_block(wallets[index].address())
+ .map(|rank| VDF_TARGET_BLOCK_MS.saturating_mul(u64::from(rank + 1) * 2))
+ .unwrap_or(RECOVERY_BLOCK_DELAY_MS);
+ let timestamp_ms = anchor_tip_timestamp
+ .saturating_add(rank_delay)
+ .saturating_add(1 + round as u64 + rng.next_u64() % 17);
+ let mut outcome = network
+ .node_mut(&node_ids[index])
+ .expect("partition candidate exists")
+ .automatic_mine_once(timestamp_ms);
+ if outcome
+ .skipped_reason
+ .as_deref()
+ .is_some_and(|reason| reason.contains("collecting blinded reveals"))
+ {
+ outcome = network
+ .node_mut(&node_ids[index])
+ .expect("partition candidate exists")
+ .automatic_mine_once(
+ timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1),
+ );
+ }
+ if outcome.block.is_some() {
+ let snapshot = network
+ .node(&node_ids[index])
+ .expect("partition producer exists")
+ .chain_snapshot();
+ for peer in partition {
+ if *peer != index {
+ network
+ .node_mut(&node_ids[*peer])
+ .expect("partition peer exists")
+ .receive(GossipEnvelope::ChainSnapshot(snapshot.clone()))
+ .expect("partition peer imports produced block");
+ }
+ }
+ return true;
+ }
+ }
+
+ false
+}
+
+fn sync_until_idle(network: &mut InMemoryNetwork, from: &str, to: &str, limit: usize) {
+ for _ in 0..256 {
+ if !network
+ .sync_node_from_peer(from, to, limit)
+ .expect("range sync succeeds")
+ {
+ return;
+ }
+ }
+ panic!("range sync did not become idle");
+}
+
fn deliver_with_chaos(
network: &mut InMemoryNetwork,
node_ids: &[String],
@@ -802,6 +1879,8 @@ fn receive_chaotic_envelope(
let message = error.to_string();
assert!(
message.contains("expected block height")
+ || message.contains("conflicts with local chain")
+ || message.contains("reveal bundle parent hash is invalid")
|| message.contains("mine transaction anchor is not on this chain")
|| message.contains("blinded transaction spends missing output")
|| message.contains("blinded transaction expiry is too far in the future"),