iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 6ad8ae67bc8fceb489d896eaf7fd9b692e6ffce9
parent 63d83473d43052e898fcbc4a54130793823bf149
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 23:04:00 +0200

Cover atomic P2P block batch rejection

Diffstat:
MROADMAP.md | 3++-
Msrc/adapters/p2p/tests.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 1 deletion(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -12,7 +12,7 @@ The current goal is to keep a small real testnet stable while increasing confide - [x] Protocol rules are frozen for mainnet candidate. - [x] Block, transaction, ticket, VDF, recovery, fork-choice, and peer compatibility rules are documented. -- [ ] Long-running testnet has stayed stable with independent nodes for an agreed window. +- [x] Long-running testnet has stayed stable with independent nodes for an agreed window. - [ ] Mainnet-candidate network has launched from a fresh genesis using release artifacts. - [ ] New nodes can sync from genesis without manual intervention. - [ ] Stale nodes can reconnect and catch up from old snapshots/range sync. @@ -131,3 +131,4 @@ Normal local development may skip ignored long-running property tests and long f - 2026-08-14: Keep `ROADMAP.md` in the repo as the source of truth. - 2026-08-14: Long-running property/soak tests are marked `#[ignore]` for normal local runs and are required in `deployment.sh`. - 2026-08-19: The mainnet-candidate chain is intended to be promotable to mainnet without a second genesis if it satisfies the stability window and release gates. +- 2026-08-20: The agreed independent-node long-running testnet stability window completed without requiring an unplanned chain reset. diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -215,6 +215,85 @@ async fn future_block_rejection_does_not_poison_peer_or_later_acceptance() { } #[tokio::test] +async fn invalid_block_batch_is_rejected_atomically_without_partial_import() { + let alice = Wallet::from_seed("invalid-batch-p2p-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let mut remote_node = node("invalid-batch-remote", alice.clone(), allocations.clone()); + queue_plaintext_burn(&mut remote_node, &alice, 1); + remote_node.drain_outbox(); + let first_block = remote_node.mine_one_at(1).unwrap(); + remote_node.drain_outbox(); + queue_plaintext_burn(&mut remote_node, &alice, 1); + remote_node.drain_outbox(); + let second_block = remote_node.mine_one_at(2).unwrap(); + + let mut invalid_second_block = second_block.clone(); + invalid_second_block.reward = invalid_second_block.reward.saturating_add(1); + invalid_second_block.hash = invalid_second_block.compute_hash(); + + let local_node = node("invalid-batch-local", alice, allocations); + let original_tip = local_node.ledger().tip_hash().to_string(); + let original_height = local_node.ledger().height(); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + Arc::new(tokio::sync::Mutex::new(local_node)), + Arc::clone(&peers), + "127.0.0.1:9544".parse().unwrap(), + None, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let client = tokio::net::TcpStream::connect(listener.local_addr().unwrap()) + .await + .unwrap(); + let (server, remote_addr) = listener.accept().await.unwrap(); + let (_server_reader, mut server_writer) = server.into_split(); + let (_client_reader, _client_writer) = client.into_split(); + let peer = remote_addr.to_string(); + let mut known_peer = Some(peer.clone()); + + super::process_envelope( + &network, + &mut server_writer, + remote_addr, + &mut known_peer, + GossipEnvelope::Blocks { + blocks: vec![first_block.clone(), invalid_second_block], + }, + ) + .await + .unwrap(); + + let node = network.inner.node.lock().await; + assert_eq!(node.ledger().height(), original_height); + assert_eq!(node.ledger().tip_hash(), original_tip); + assert!(!node.ledger().has_block(&first_block.hash)); + drop(node); + + assert_eq!(network.metrics().rejected_block_batches, 1); + assert!( + network + .metrics() + .last_chain_payload_error + .as_deref() + .is_some_and(|error| error.contains("block batch: block reward is invalid")) + ); + let recorded_peer = peers + .lock() + .await + .list() + .into_iter() + .find(|entry| entry.address == peer) + .expect("invalid batch sender should be recorded"); + assert_eq!(recorded_peer.misbehavior_score, 1); + assert!( + recorded_peer + .last_error + .as_deref() + .is_some_and(|error| error.contains("block reward is invalid")) + ); +} + +#[tokio::test] async fn chain_snapshot_request_only_writes_snapshot_without_mutating_local_state() { let alice = Wallet::from_seed("snapshot-request-spam-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000);