iuna

iuna

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

commit 36cfbfc9a31ae66a025b32a6d6a83de09ec491c5
parent 9e17dfe4cff42c7b105e6b8cac8e60bcbd6ddd57
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 14:09:37 +0200

Harden burn bundle relay checks

Diffstat:
MROADMAP.md | 23+++++++++++++++++++++++
Msrc/adapters/p2p/line_codec.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app/receive.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/adversarial_tests.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_reveal.rs | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
5 files changed, 395 insertions(+), 46 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -30,6 +30,29 @@ The current goal is to keep a small real testnet stable while increasing confide - [ ] Upgrade and rollback instructions exist. - [ ] Basic operational monitoring is available for height, tip hash, peers, last block age, finalizer mode, VDF rounds, mempool, and rejected blocks. +## Pre-Reset Mainnet-Candidate Test Backlog + +These items are not protocol rules. They are the attack and reliability checks to finish or consciously defer before the planned devnet reset that should become the mainnet-candidate network. + +### Must Before Reset + +- [x] Burn bundle relay cannot import embedded burns before bundle metadata, membership, signature, fee ordering, and size are prechecked. +- [x] Block validation with burn attestations remains independent of local mempool contents, including empty and conflicting mempools. +- [x] Post-genesis transactions cannot spend with `genesis` input signatures. +- [x] P2P envelope item limits reject batches only above their configured boundaries. +- [ ] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps. +- [ ] Fork and snapshot adversarial tests cover same-height leader-quality choice, taller valid forks inside finality, invalid late snapshot blocks, and pending transaction carry-forward after reorg. +- [ ] Compact snapshot decoder has malformed-input tests for huge lengths, oversized varints, trailing bytes, truncated payloads, invalid tags, and random byte inputs without panics or excessive allocation. +- [ ] Supply invariant tests cover mixed burns, fees, PoW mine actions, reorgs, no replay, and no double spend. + +### Should Before Mainnet + +- [ ] 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. +- [ ] 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. + ## Milestones ### M1: Testnet Hardening diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs @@ -208,11 +208,18 @@ fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> { mod tests { use crate::{ adapters::p2p::metrics::P2pMetricsCounters, - app::GossipEnvelope, - domain::{BurnBundle, OutPoint, Transaction, TxInput, TxOutput}, + app::{BlockInventory, GossipEnvelope, TRANSACTION_BATCH_LIMIT}, + domain::{ + Block, BurnBundle, BurnBundleSection, ChainSnapshot, FinalizerMode, LaunchProfile, + OutPoint, Transaction, TxInput, TxOutput, + }, }; - use super::{parse_envelope, record_received_envelope_kind}; + use super::{ + MAX_BLOCK_BATCH, MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, MAX_PEER_LIST, + MAX_SNAPSHOT_BLOCKS, parse_envelope, record_received_envelope_kind, + validate_envelope_limits, + }; fn burn(signature: &str) -> Transaction { Transaction::Burn { @@ -245,6 +252,35 @@ mod tests { } } + fn dummy_block(height: u64) -> Block { + Block { + height, + prev_hash: "0".repeat(64), + timestamp_ms: height, + miner: "0".repeat(64), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 0, + vdf_rounds: 0, + vdf_output: "0:0".to_string(), + leader_proof: None, + burn_bundle_section: BurnBundleSection::default(), + transactions: Vec::new(), + hash: format!("{height:064x}"), + } + } + + fn dummy_snapshot(blocks: usize) -> ChainSnapshot { + ChainSnapshot { + genesis_allocations: Default::default(), + vdf_rounds: 1, + launch_profile: LaunchProfile::default(), + blocks: (0..blocks) + .map(|height| dummy_block(height as u64)) + .collect(), + } + } + #[test] fn metrics_count_transaction_and_burn_bundle_batches() { let metrics = P2pMetricsCounters::default(); @@ -281,4 +317,118 @@ mod tests { assert_eq!(parse_envelope(&line).unwrap(), envelope); } + + #[test] + fn envelope_item_limits_reject_only_above_the_boundary() { + assert!( + validate_envelope_limits(&GossipEnvelope::BlockRangeRequest { + from_height: 1, + limit: MAX_BLOCK_BATCH + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BlockRangeRequest { + from_height: 1, + limit: MAX_BLOCK_BATCH + 1 + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BlockRequest { + hashes: vec!["0".repeat(64); MAX_OBJECT_REQUESTS] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BlockRequest { + hashes: vec!["0".repeat(64); MAX_OBJECT_REQUESTS + 1] + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Inventory { + blocks: vec![ + BlockInventory { + height: 1, + hash: "0".repeat(64) + }; + MAX_INVENTORY_ITEMS + ] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Inventory { + blocks: vec![ + BlockInventory { + height: 1, + hash: "0".repeat(64) + }; + MAX_INVENTORY_ITEMS + 1 + ] + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Transactions { + transactions: vec![burn("a"); TRANSACTION_BATCH_LIMIT] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Transactions { + transactions: vec![burn("a"); TRANSACTION_BATCH_LIMIT + 1] + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BurnBundles { + bundles: vec![burn_bundle(1, "a"); TRANSACTION_BATCH_LIMIT] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BurnBundles { + bundles: vec![burn_bundle(1, "a"); TRANSACTION_BATCH_LIMIT + 1] + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Blocks { + blocks: vec![dummy_block(1); MAX_BLOCK_BATCH] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::Blocks { + blocks: vec![dummy_block(1); MAX_BLOCK_BATCH + 1] + }) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::ChainSnapshot(dummy_snapshot( + MAX_SNAPSHOT_BLOCKS + ))) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::ChainSnapshot(dummy_snapshot( + MAX_SNAPSHOT_BLOCKS + 1 + ))) + .is_err() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::PeerList { + peers: vec!["127.0.0.1:9444".to_string(); MAX_PEER_LIST] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::PeerList { + peers: vec!["127.0.0.1:9444".to_string(); MAX_PEER_LIST + 1] + }) + .is_err() + ); + } } diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -32,6 +32,7 @@ impl NodeCore { return Ok(()); } let key = (bundle.height, bundle.slot); + self.ledger.precheck_next_block_burn_bundle(&bundle)?; for burn in &bundle.burns { self.receive_gossiped_transaction(burn.clone())?; } @@ -241,4 +242,48 @@ mod tests { .any(|transaction| transaction.signature() == burn.signature()) ); } + + #[test] + fn oversized_burn_bundle_does_not_import_embedded_burns() { + let alice = Wallet::from_seed("oversized-bundle-alice"); + let bob = Wallet::from_seed("oversized-bundle-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let finalizer = ledger.expected_leader_for_next_block().unwrap(); + let signer = wallets + .iter() + .find(|wallet| wallet.address() == finalizer) + .expect("test ledger should include selected finalizer") + .clone(); + let burner = wallets + .iter() + .find(|wallet| wallet.address() != signer.address()) + .expect("test ledger should include a non-finalizer") + .clone(); + let mut signer_ledger = ledger.clone(); + let mut burns = Vec::new(); + let oversized_bundle = loop { + let burn = signer_ledger.build_burn(&burner, 1, 1).unwrap(); + signer_ledger.submit_transaction(burn.clone()).unwrap(); + burns.push(burn); + let bundle = signer_ledger.test_burn_bundle(&signer, burns.clone()); + if bundle.serialized_size_bytes().unwrap() > 10_000 { + break bundle; + } + }; + let first_burn_signature = oversized_bundle.burns[0].signature().to_string(); + let mut receiver = NodeCore::from_ledger(signer, ledger, 0); + + let error = receiver.receive_burn_bundle(oversized_bundle).unwrap_err(); + + assert!(error.to_string().contains("burn bundle exceeds max size")); + assert!( + receiver + .ledger() + .pending() + .iter() + .all(|transaction| transaction.signature() != first_burn_signature) + ); + assert!(receiver.drain_outbox().is_empty()); + } } diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -648,6 +648,52 @@ fn attested_burn_is_not_selected_again_as_normal_transaction() { } #[test] +fn attested_burn_block_validates_independent_of_local_mempool() { + let mut harness = harness_for_percent(22, 25); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + harness.submit_anchor_burn(&finalizer); + let victim = harness + .honest + .iter() + .find(|wallet| wallet.address() != finalizer.address()) + .unwrap() + .clone(); + let attested_burn = harness.submit_fee_burn(&victim, 1, 1); + let bundle = finalizer.burn_bundle(BurnBundlePayload { + height: harness.ledger.height() + 1, + prev_hash: harness.ledger.tip_hash().to_string(), + slot: 0, + member: finalizer.address().to_string(), + burns: vec![attested_burn.clone()], + }); + let block = harness.finish_ticket_block_from_pending(0, vec![bundle]); + let parent_snapshot = harness.ledger.snapshot(); + + let mut empty_mempool = Ledger::from_snapshot_at(parent_snapshot.clone(), NOW_MS).unwrap(); + empty_mempool + .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms)) + .unwrap(); + + let mut conflicting_mempool = Ledger::from_snapshot_at(parent_snapshot, NOW_MS).unwrap(); + let conflict = conflicting_mempool + .build_transfer(&victim, finalizer.address(), 1, 1) + .unwrap(); + conflicting_mempool + .submit_transaction(conflict.clone()) + .unwrap(); + conflicting_mempool + .apply_block_at(block, NOW_MS.saturating_add(1)) + .unwrap(); + assert!( + conflicting_mempool + .pending() + .iter() + .all(|tx| tx.signature() != conflict.signature()) + ); +} + +#[test] fn required_burn_cannot_be_executed_twice_in_one_block() { let mut harness = harness_for_percent(11, 25); let leader = harness.next_rank(0); @@ -774,6 +820,41 @@ fn zero_fee_public_burn_is_rejected() { } #[test] +fn post_genesis_transactions_cannot_spend_with_genesis_input_signatures() { + let alice = Wallet::from_seed("post-genesis-signature-alice"); + let bob = Wallet::from_seed("post-genesis-signature-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut transaction = ledger.build_transfer(&alice, bob.address(), 1, 1).unwrap(); + let Transaction::Transfer { + inputs, signature, .. + } = &mut transaction + else { + panic!("test builds a transfer"); + }; + for input in inputs { + input.signature = "genesis".to_string(); + } + *signature = "0".repeat(128); + + let error = ledger.submit_transaction(transaction).unwrap_err(); + + assert!( + error.to_string().contains("invalid input signature") + || error + .to_string() + .contains("transaction signature is invalid") + ); +} + +#[test] fn invalid_committee_signature_is_rejected() { let mut harness = harness_for_percent(13, 25); harness.mature_lineages(1, 4); diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -6,8 +6,8 @@ use super::ledger_ops::verify_address_signature; use super::reveal::{burn_bundle_slot_mask, burn_committee_mask}; use super::{ Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection, - BurnBundleSignature, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, MaskedBurn, Transaction, - Wallet, + BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, + MaskedBurn, Transaction, Wallet, }; impl Ledger { @@ -87,6 +87,40 @@ impl Ledger { self.validate_burn_bundles_for_block(expected_height, &expected_prev_hash, bundles) } + pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> { + let expected_height = self.tip().height + 1; + let expected_prev_hash = self.tip().hash.clone(); + let committee = self + .burn_committee_for_height(expected_height) + .into_iter() + .map(|member| (member.slot, member)) + .collect::<BTreeMap<_, _>>(); + self.precheck_burn_bundle_for_block( + expected_height, + &expected_prev_hash, + &committee, + bundle, + ) + } + + #[cfg(test)] + pub fn test_burn_bundle(&self, wallet: &Wallet, burns: Vec<Transaction>) -> BurnBundle { + let height = self.tip().height + 1; + let prev_hash = self.tip().hash.clone(); + let member = self + .burn_committee_for_next_block() + .into_iter() + .find(|member| member.owner == wallet.address()) + .expect("test wallet must be a burn committee member"); + wallet.burn_bundle(BurnBundlePayload { + height, + prev_hash, + slot: member.slot, + member: wallet.address().to_string(), + burns, + }) + } + pub(super) fn burn_bundle_section_from_bundles( &self, bundles: Vec<BurnBundle>, @@ -262,59 +296,75 @@ impl Ledger { .collect::<BTreeMap<_, _>>(); let mut seen_members = BTreeSet::new(); for bundle in &bundles { - if bundle.height != expected_height { - bail!("burn bundle height is invalid"); - } - if bundle.prev_hash != expected_prev_hash { - bail!("burn bundle parent hash is invalid"); - } - if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE { - bail!("burn bundle slot is invalid"); - } if !seen_members.insert(bundle.member.clone()) { bail!("duplicate burn bundle member"); } - let member = committee - .get(&bundle.slot) - .context("burn bundle slot is not assigned")?; - if bundle.member != member.owner { - bail!("burn bundle member is not assigned to slot"); - } - if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { - bail!("burn bundle exceeds max size"); - } - verify_address_signature( - &bundle.member, - &bundle.canonical_payload(), - &bundle.signature, - "burn bundle", + self.precheck_burn_bundle_for_block( + expected_height, + expected_prev_hash, + &committee, + bundle, )?; - let mut seen_bundle_burns = BTreeSet::new(); - let mut previous_key: Option<(Amount, String)> = None; for burn in &bundle.burns { - if !seen_bundle_burns.insert(burn.signature().to_string()) { - bail!("duplicate burn in burn bundle"); - } - if !burn.is_burn() { - bail!("burn bundle contains a non-burn transaction"); - } if matching_burn_by_signature(burn, &self.pending).is_none() { bail!("burn bundle references a burn that is not in the mempool"); } - self.validate_transaction_terms(burn)?; - let key = (burn.fee(), burn.signature().to_string()); - if let Some((previous_fee, previous_signature)) = &previous_key { - if key.0 > *previous_fee - || key.0 == *previous_fee && key.1 < *previous_signature - { - bail!("burn bundle is not fee ordered"); - } - } - previous_key = Some(key); } } Ok(bundles) } + + fn precheck_burn_bundle_for_block( + &self, + expected_height: u64, + expected_prev_hash: &str, + committee: &BTreeMap<u8, BurnCommitteeMember>, + bundle: &BurnBundle, + ) -> Result<()> { + if bundle.height != expected_height { + bail!("burn bundle height is invalid"); + } + if bundle.prev_hash != expected_prev_hash { + bail!("burn bundle parent hash is invalid"); + } + if usize::from(bundle.slot) >= BURN_COMMITTEE_SIZE { + bail!("burn bundle slot is invalid"); + } + let member = committee + .get(&bundle.slot) + .context("burn bundle slot is not assigned")?; + if bundle.member != member.owner { + bail!("burn bundle member is not assigned to slot"); + } + if bundle.serialized_size_bytes()? > MAX_BURN_BUNDLE_BYTES { + bail!("burn bundle exceeds max size"); + } + verify_address_signature( + &bundle.member, + &bundle.canonical_payload(), + &bundle.signature, + "burn bundle", + )?; + let mut seen_bundle_burns = BTreeSet::new(); + let mut previous_key: Option<(Amount, String)> = None; + for burn in &bundle.burns { + if !seen_bundle_burns.insert(burn.signature().to_string()) { + bail!("duplicate burn in burn bundle"); + } + if !burn.is_burn() { + bail!("burn bundle contains a non-burn transaction"); + } + self.validate_transaction_terms(burn)?; + let key = (burn.fee(), burn.signature().to_string()); + if let Some((previous_fee, previous_signature)) = &previous_key { + if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature { + bail!("burn bundle is not fee ordered"); + } + } + previous_key = Some(key); + } + Ok(()) + } } fn matching_burn_by_signature<'a>(