commit def191e375ea07846d950899013644ec00f93767
parent 15f7dbef16fa789f47a46da883f748efea10cfac
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 15:09:40 +0200
Add multi-node adversarial network test
Diffstat:
3 files changed, 194 insertions(+), 4 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
@@ -48,7 +48,7 @@ These items are not protocol rules. They are the attack and reliability checks t
### Should Before Mainnet
- [x] HTTP/auth abuse tests cover CSRF same-origin behavior, lockout/backoff behavior, forwarded-header spoofing from untrusted peers, and session expiry.
-- [ ] Multi-node in-memory simulation covers delayed gossip, withheld burn bundles, bundle equivocation, partitions, restarts, persistence reload, and convergence.
+- [x] Multi-node in-memory simulation covers delayed gossip, withheld burn bundles, bundle equivocation, partitions, restarts, persistence reload, and convergence.
- [ ] Long-running release-mode soak test runs with automatic burn/finalization, P2P sync, Stratum-disabled and Stratum-enabled nodes, and periodic node restarts.
- [ ] Operator failure playbooks exist for stalled height, divergent tips, old snapshots, no burn committee signatures, recovery blocks, and corrupted local persistence.
- [ ] Mainnet-candidate release rehearsal includes fresh genesis, published bootnodes, checksums, backup/restore instructions, and a no-reset stability window.
diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs
@@ -543,9 +543,11 @@ mod tests {
use std::collections::BTreeMap;
use crate::{
- app::InMemoryNetwork,
+ adapters::chain_store::SqliteChainStore,
+ app::{GossipEnvelope, InMemoryNetwork},
domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf},
};
+ use tempfile::tempdir;
use super::NodeCore;
@@ -580,6 +582,180 @@ mod tests {
}
#[test]
+ fn in_memory_network_survives_adversarial_gossip_restart_and_converges() {
+ let alice = Wallet::from_seed("network-adversarial-alice");
+ let bob = Wallet::from_seed("network-adversarial-bob");
+ let carol = Wallet::from_seed("network-adversarial-carol");
+ let wallets = [alice.clone(), bob.clone(), carol.clone()];
+ let ledger = funded_ledger(&wallets);
+ let finalizer = selected_finalizer(&ledger, &wallets);
+ let mut burners = wallets
+ .iter()
+ .filter(|wallet| wallet.address() != finalizer.address());
+ let burn_a = ledger.build_burn(burners.next().unwrap(), 1, 1).unwrap();
+ let burn_b = ledger.build_burn(burners.next().unwrap(), 2, 1).unwrap();
+
+ let mut alpha = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ finalizer.clone(),
+ ledger.clone(),
+ true,
+ 0,
+ 1,
+ );
+ let beta = NodeCore::from_ledger_with_burn_fee_and_enabled(
+ finalizer.clone(),
+ ledger.clone(),
+ true,
+ 0,
+ 1,
+ );
+ let gamma = NodeCore::from_ledger(finalizer.clone(), ledger.clone(), 0);
+ alpha.receive_transaction(burn_a.clone()).unwrap();
+ alpha.receive_transaction(burn_b.clone()).unwrap();
+
+ let mut network = InMemoryNetwork::default();
+ network.insert("alpha", alpha);
+ network.insert("beta", beta);
+ network.insert("gamma", gamma);
+
+ network
+ .gossip_mempools_once_filtered(|_, to, envelope| {
+ !(to == "gamma" && matches!(envelope, GossipEnvelope::Transactions { .. }))
+ })
+ .unwrap();
+ assert!(
+ network
+ .node("beta")
+ .unwrap()
+ .pending_transactions()
+ .iter()
+ .any(|tx| tx.signature() == burn_a.signature())
+ );
+ assert!(
+ !network
+ .node("gamma")
+ .unwrap()
+ .pending_transactions()
+ .iter()
+ .any(|tx| tx.signature() == burn_a.signature())
+ );
+
+ network.gossip_mempools_once().unwrap();
+ assert!(
+ network
+ .node("gamma")
+ .unwrap()
+ .pending_transactions()
+ .iter()
+ .any(|tx| tx.signature() == burn_b.signature())
+ );
+
+ network
+ .node_mut("alpha")
+ .unwrap()
+ .publish_burn_bundle_for_next_block()
+ .unwrap();
+ network
+ .deliver_until_idle_filtered(|_, to, envelope| {
+ !(to == "gamma" && matches!(envelope, GossipEnvelope::BurnBundle(_)))
+ })
+ .unwrap();
+ let bundle = network
+ .node("beta")
+ .unwrap()
+ .usable_burn_bundles()
+ .into_iter()
+ .find(|bundle| bundle.member == finalizer.address())
+ .expect("beta should receive alpha's burn bundle");
+ assert!(
+ network
+ .node("gamma")
+ .unwrap()
+ .usable_burn_bundles()
+ .is_empty()
+ );
+
+ let conflicting_bundle = ledger.test_burn_bundle(&finalizer, vec![burn_b.clone()]);
+ assert_ne!(bundle.canonical(), conflicting_bundle.canonical());
+ network
+ .node_mut("beta")
+ .unwrap()
+ .receive(GossipEnvelope::BurnBundle(conflicting_bundle))
+ .unwrap();
+ assert!(
+ network
+ .node("beta")
+ .unwrap()
+ .usable_burn_bundles()
+ .into_iter()
+ .all(|candidate| candidate.slot != bundle.slot)
+ );
+
+ let block = {
+ let alpha = network.node_mut("alpha").unwrap();
+ alpha.prepare_automatic_burn(2).unwrap();
+ let work = alpha.prepare_next_block_with_local_anchor(2).unwrap();
+ alpha
+ .complete_prepared_block_at(
+ work.clone(),
+ run_vdf(work.vdf_seed(), work.vdf_rounds()),
+ 2,
+ )
+ .unwrap()
+ };
+ assert!(
+ block
+ .burn_bundle_section
+ .burns
+ .iter()
+ .any(|masked| masked.burn.signature() == burn_a.signature())
+ );
+ let stale_gamma_height = network.node("gamma").unwrap().chain_height();
+
+ network
+ .deliver_until_idle_filtered(|_, to, envelope| {
+ !(to == "gamma"
+ && matches!(
+ envelope,
+ GossipEnvelope::Block(_)
+ | GossipEnvelope::Blocks { .. }
+ | GossipEnvelope::ChainSnapshot(_)
+ ))
+ })
+ .unwrap();
+ assert_eq!(
+ network.node("alpha").unwrap().chain_tip_hash(),
+ network.node("beta").unwrap().chain_tip_hash()
+ );
+ assert_eq!(
+ network.node("gamma").unwrap().chain_height(),
+ stale_gamma_height
+ );
+
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("gamma.sqlite3")).unwrap();
+ store
+ .save(&network.node("gamma").unwrap().chain_snapshot())
+ .unwrap();
+ let restored_ledger =
+ Ledger::from_persisted_snapshot(store.load().unwrap().unwrap()).unwrap();
+ network.insert(
+ "gamma",
+ NodeCore::from_ledger(finalizer.clone(), restored_ledger, 0),
+ );
+ assert_eq!(
+ network.node("gamma").unwrap().chain_height(),
+ stale_gamma_height
+ );
+
+ assert!(network.sync_node_from_peer("alpha", "gamma", 128).unwrap());
+ network.deliver_until_idle().unwrap();
+ let tip = network.node("alpha").unwrap().chain_tip_hash();
+ assert_eq!(network.node("beta").unwrap().chain_tip_hash(), tip);
+ assert_eq!(network.node("gamma").unwrap().chain_tip_hash(), tip);
+ }
+
+ #[test]
fn local_anchor_burn_is_included_when_publishing_bundle() {
let wallet = Wallet::from_seed("local-anchor-bundle-wallet");
let mut allocations = BTreeMap::new();
diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs
@@ -23,6 +23,13 @@ impl InMemoryNetwork {
}
pub fn deliver_until_idle(&mut self) -> Result<()> {
+ self.deliver_until_idle_filtered(|_, _, _| true)
+ }
+
+ pub fn deliver_until_idle_filtered(
+ &mut self,
+ mut allow: impl FnMut(&str, &str, &GossipEnvelope) -> bool,
+ ) -> Result<()> {
loop {
let mut outbound = Vec::new();
for (id, node) in &mut self.nodes {
@@ -37,7 +44,7 @@ impl InMemoryNetwork {
for (from, envelope) in outbound {
for (id, node) in &mut self.nodes {
- if *id != from {
+ if *id != from && allow(&from, id, &envelope) {
receive_in_memory_envelope(node, envelope.clone())?;
}
}
@@ -46,6 +53,13 @@ impl InMemoryNetwork {
}
pub fn gossip_mempools_once(&mut self) -> Result<()> {
+ self.gossip_mempools_once_filtered(|_, _, _| true)
+ }
+
+ pub fn gossip_mempools_once_filtered(
+ &mut self,
+ mut allow: impl FnMut(&str, &str, &GossipEnvelope) -> bool,
+ ) -> Result<()> {
let mut outbound = Vec::new();
for (id, node) in &mut self.nodes {
for envelope in node.mempool_gossip() {
@@ -55,7 +69,7 @@ impl InMemoryNetwork {
for (from, envelope) in outbound {
for (id, node) in &mut self.nodes {
- if *id != from {
+ if *id != from && allow(&from, id, &envelope) {
receive_in_memory_envelope(node, envelope.clone())?;
}
}