iuna

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

commit 923ec4427b1672e33fc3eefa340fa7e69288d8fb
parent bead6125d79e0c4d7a4d8f5b18bf4c39d1c801af
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri,  7 Aug 2026 22:56:55 +0200

Add sparse network chaos stability test

Diffstat:
Msrc/app.rs | 534++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 533 insertions(+), 1 deletion(-)

diff --git a/src/app.rs b/src/app.rs @@ -2436,7 +2436,7 @@ fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use crate::domain::{ FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, @@ -3818,6 +3818,538 @@ mod tests { ); } } + + #[derive(Clone, Debug)] + struct ChaosRng { + state: u64, + } + + impl ChaosRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0x517c_c1b7_2722_0a95, + } + } + + 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 + } + } + + #[test] + fn sparse_network_stays_bounded_under_generated_actions() { + const NODES: usize = 10; + const ROUNDS: usize = 36; + const SETTLE_BLOCKS: u64 = 14; + const QUIET_BLOCKS: usize = SETTLE_BLOCKS as usize + 4; + + let mut rng = ChaosRng::new(0x1aba_0100); + let wallets = (0..NODES) + .map(|index| Wallet::from_seed(&format!("sparse-network-chaos-{index}"))) + .collect::<Vec<_>>(); + let allocations = wallets + .iter() + .map(|wallet| (wallet.address().to_string(), 75 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let genesis_burns = wallets + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect::<Vec<_>>(); + let ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1) + .expect("chaos genesis is valid"); + let node_ids = (0..NODES) + .map(|index| format!("n{index}")) + .collect::<Vec<_>>(); + let peers = sparse_chaos_peers(NODES, &mut rng); + let mut offline_until = vec![0_usize; NODES]; + let mut pending_since = BTreeMap::new(); + 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 / 10, + 0, + ); + node.set_recovery_vdf_top_rank_percent(100); + network.insert(node_ids[index].clone(), node); + } + + for round in 0..ROUNDS { + let online = online_chaos_nodes(&offline_until, round); + for index in online.iter().copied().collect::<Vec<_>>() { + match rng.index(32) { + 0 => { + let recipient = wallets[rng.index(wallets.len())].address().to_string(); + let _ = network + .node_mut(&node_ids[index]) + .expect("actor node exists") + .blinded_transfer_with_fee(recipient, 1, 0, chaos_expiry_height(round)); + } + 1 => { + attempt_bounded_mine_action( + &mut network, + &node_ids[index], + wallets[index].address(), + &mut rng, + ); + } + 2 if online.len() > 1 => { + offline_until[index] = round + 1 + rng.index(4); + } + _ => {} + } + } + + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + + for round in ROUNDS..ROUNDS + QUIET_BLOCKS { + let online = online_chaos_nodes(&offline_until, round); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + + let all_online = (0..NODES).collect::<BTreeSet<_>>(); + for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize { + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &all_online, + chaos_timestamp(round), + &mut rng, + ); + if !sparse_chaos_has_pending(&network, &node_ids) { + break; + } + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &all_online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &all_online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + assert_sparse_chaos_converged(&network, &node_ids); + assert_sparse_chaos_mempools_empty(&network, &node_ids); + } + + fn sparse_chaos_peers(nodes: usize, rng: &mut ChaosRng) -> Vec<Vec<usize>> { + let mut peers = vec![BTreeSet::new(); nodes]; + for index in 0..nodes { + let next = (index + 1) % nodes; + peers[index].insert(next); + peers[next].insert(index); + } + for index in 0..nodes { + let target_degree = 1 + rng.index(4); + while peers[index].len() < target_degree { + let peer = rng.index(nodes); + if peer != index { + peers[index].insert(peer); + peers[peer].insert(index); + } + } + } + peers + .into_iter() + .map(|set| set.into_iter().take(4).collect()) + .collect() + } + + fn online_chaos_nodes(offline_until: &[usize], round: usize) -> BTreeSet<usize> { + offline_until + .iter() + .enumerate() + .filter_map(|(index, until)| (*until <= round).then_some(index)) + .collect() + } + + fn chaos_timestamp(round: usize) -> u64 { + (round as u64 + 1) * (RECOVERY_BLOCK_DELAY_MS + VDF_TARGET_BLOCK_MS) + } + + fn chaos_expiry_height(round: usize) -> u64 { + round as u64 + 16 + } + + fn attempt_bounded_mine_action( + network: &mut InMemoryNetwork, + node_id: &str, + recipient: &str, + rng: &mut ChaosRng, + ) { + let outcome = network + .node(node_id) + .expect("mine actor exists") + .ledger() + .search_mine(recipient, rng.next_u64(), 0, 4) + .expect("bounded mine search is valid"); + let Some(transaction) = outcome.transaction else { + return; + }; + let _ = network + .node_mut(node_id) + .expect("mine actor exists") + .receive_mine_action(transaction); + } + + fn mine_one_sparse_chaos_block( + network: &mut InMemoryNetwork, + node_ids: &[String], + wallets: &[Wallet], + online: &BTreeSet<usize>, + timestamp_ms: u64, + ) { + let Some(reference_index) = highest_online_node(network, node_ids, online) else { + return; + }; + let reference = network + .node(&node_ids[reference_index]) + .expect("reference node exists"); + let leader = reference.ledger().expected_leader_for_next_block(); + let producer_index = leader + .as_deref() + .and_then(|leader| { + wallets + .iter() + .position(|wallet| wallet.address() == leader) + .filter(|index| online.contains(index)) + }) + .filter(|index| { + network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() + }) + .unwrap_or(reference_index); + + let mut producer = network + .node(&node_ids[producer_index]) + .expect("producer node exists") + .clone(); + let plan = producer.prepare_automatic_finalization(timestamp_ms); + let Some(work) = plan.work else { + return; + }; + let block = work.finish_at( + &wallets[producer_index], + "preverified-chaos-vdf".to_string(), + timestamp_ms, + ); + network + .node_mut(&node_ids[producer_index]) + .expect("producer node exists") + .receive_preverified_block_at(block, timestamp_ms) + .expect("mock-VDF block applies locally"); + } + + fn highest_online_node( + network: &InMemoryNetwork, + node_ids: &[String], + online: &BTreeSet<usize>, + ) -> Option<usize> { + online.iter().copied().max_by_key(|index| { + network + .node(&node_ids[*index]) + .expect("online node exists") + .chain_height() + }) + } + + fn deliver_sparse_chaos_until_idle( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + rng: &mut ChaosRng, + ) { + for _ in 0..512 { + let mut progressed = + sync_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms); + progressed |= + deliver_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms, rng); + if !progressed { + return; + } + } + panic!("sparse chaos network did not become idle"); + } + + fn sync_sparse_chaos_once( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + ) -> bool { + let mut syncs = Vec::new(); + for from in online { + let from_height = network.node(&node_ids[*from]).unwrap().chain_height(); + for to in &peers[*from] { + if !online.contains(to) { + continue; + } + let to_height = network.node(&node_ids[*to]).unwrap().chain_height(); + if from_height > to_height { + let blocks = network + .node(&node_ids[*from]) + .unwrap() + .blocks_from(to_height + 1, 16); + syncs.push((*to, blocks)); + } + } + } + + let mut progressed = false; + for (to, blocks) in syncs { + for block in blocks { + let before = network.node(&node_ids[to]).unwrap().chain_height(); + receive_sparse_chaos_block(network, &node_ids[to], block, timestamp_ms); + progressed |= network.node(&node_ids[to]).unwrap().chain_height() > before; + } + } + progressed + } + + fn deliver_sparse_chaos_once( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + rng: &mut ChaosRng, + ) -> bool { + let mut outbound = Vec::new(); + for from in online { + let node = network + .node_mut(&node_ids[*from]) + .expect("online node exists"); + outbound.extend( + node.drain_outbox() + .into_iter() + .map(|envelope| (*from, 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 to in &peers[from] { + if online.contains(to) { + receive_sparse_chaos_envelope( + network, + &node_ids[*to], + envelope.clone(), + timestamp_ms, + ); + } + } + } + true + } + + fn receive_sparse_chaos_envelope( + network: &mut InMemoryNetwork, + node_id: &str, + envelope: GossipEnvelope, + timestamp_ms: u64, + ) { + match envelope { + GossipEnvelope::Block(block) => { + receive_sparse_chaos_block(network, node_id, block, timestamp_ms); + } + other => { + if let Err(error) = network + .node_mut(node_id) + .expect("node exists") + .receive(other) + { + let message = error.to_string(); + assert!( + message.contains("mine transaction anchor is not on this chain") + || message.contains("conflicts with an existing pending transaction") + || message.contains("blinded transaction expired"), + "unexpected sparse chaos delivery error: {message}" + ); + } + } + } + } + + fn receive_sparse_chaos_block( + network: &mut InMemoryNetwork, + node_id: &str, + block: crate::domain::Block, + timestamp_ms: u64, + ) { + if let Err(error) = network + .node_mut(node_id) + .expect("node exists") + .receive_preverified_block_at(block, timestamp_ms) + { + let message = error.to_string(); + assert!( + message.contains("expected block height") + || message.contains("same-height fork") + || message.contains("block is already known"), + "unexpected sparse chaos block error: {message}" + ); + } + } + + fn observe_bounded_pending( + network: &InMemoryNetwork, + node_ids: &[String], + pending_since: &mut BTreeMap<String, u64>, + settle_blocks: u64, + ) { + let mut current = BTreeSet::new(); + for node_id in node_ids { + let node = network.node(node_id).expect("node exists"); + for id in pending_item_ids(node) { + current.insert(id); + } + } + let max_height = node_ids + .iter() + .map(|id| network.node(id).unwrap().chain_height()) + .max() + .unwrap_or_default(); + pending_since.retain(|id, _| current.contains(id)); + for id in current { + pending_since.entry(id).or_insert(max_height); + } + for (id, first_height) in pending_since { + assert!( + max_height.saturating_sub(*first_height) <= settle_blocks, + "pending item {id} stayed in mempool for more than {settle_blocks} blocks" + ); + } + } + + fn pending_item_ids(node: &NodeCore) -> Vec<String> { + let mut ids = Vec::new(); + ids.extend( + node.pending_transactions() + .into_iter() + .map(|tx| format!("tx:{}", tx.signature())), + ); + ids.extend( + node.pending_blinded_transactions() + .into_iter() + .map(|tx| format!("commit:{}", tx.commitment)), + ); + ids.extend( + node.pending_blinded_reveals() + .into_iter() + .map(|reveal| format!("reveal:{}", reveal.commitment)), + ); + ids + } + + fn assert_sparse_chaos_converged(network: &InMemoryNetwork, node_ids: &[String]) { + let first = network.node(&node_ids[0]).expect("first node exists"); + let height = first.chain_height(); + let tip = first.ledger().status().tip_hash.clone(); + for node_id in node_ids.iter().skip(1) { + let node = network.node(node_id).expect("node exists"); + assert_eq!(node.chain_height(), height, "{node_id} height diverged"); + assert_eq!( + node.ledger().status().tip_hash, + tip, + "{node_id} tip diverged" + ); + } + } + + fn sparse_chaos_has_pending(network: &InMemoryNetwork, node_ids: &[String]) -> bool { + node_ids.iter().any(|node_id| { + let node = network.node(node_id).expect("node exists"); + !node.pending_transactions().is_empty() + || !node.pending_blinded_transactions().is_empty() + || !node.pending_blinded_reveals().is_empty() + }) + } + + fn assert_sparse_chaos_mempools_empty(network: &InMemoryNetwork, node_ids: &[String]) { + for node_id in node_ids { + let node = network.node(node_id).expect("node exists"); + let pending = pending_item_ids(node); + assert!( + pending.is_empty(), + "{node_id} still has pending mempool items: {pending:?}" + ); + } + } } #[derive(Debug, Default)]