iuna

iuna

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

commit c3b679dff97eb75e9c4c0d7595a3963b00805156
parent 16cd783aa1851763fc549ecb39c1967ac033de14
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 14:51:26 +0200

Add supply invariant adversarial tests

Diffstat:
MROADMAP.md | 2+-
Msrc/domain/adversarial_tests.rs | 263++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 263 insertions(+), 2 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -43,7 +43,7 @@ These items are not protocol rules. They are the attack and reliability checks t - [x] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps. - [x] 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. - [x] 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. +- [x] Supply invariant tests cover mixed burns, fees, PoW mine actions, reorgs, no replay, and no double spend. ### Should Before Mainnet diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -9,7 +9,7 @@ use super::ticket::ticket_block_min_timestamp; use super::{ Amount, BURN_LINEAGE_MATURITY_HEIGHTS, Block, BurnBundle, BurnBundleSignature, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, - MaskedBurn, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + MaskedBurn, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; const NOW_MS: u64 = 10_000_000_000; @@ -467,6 +467,62 @@ fn fork_harness_from(source: &Harness, snapshot: ChainSnapshot) -> Harness { } } +fn live_supply(ledger: &Ledger) -> Amount { + ledger + .all_utxos() + .into_iter() + .try_fold(0_u64, |total, (_, output)| total.checked_add(output.amount)) + .expect("test supply should not overflow") +} + +fn expected_supply(snapshot: &ChainSnapshot) -> Amount { + let mut supply = snapshot + .genesis_allocations + .values() + .try_fold(0_u64, |total, amount| total.checked_add(*amount)) + .expect("test genesis supply should not overflow"); + + for block in &snapshot.blocks { + supply = supply + .checked_add(block.reward) + .expect("test reward supply should not overflow"); + for transaction in &block.transactions { + match transaction { + Transaction::Transfer { fee, .. } => { + supply = supply + .checked_sub(*fee) + .expect("transfer fee should be backed by supply"); + } + Transaction::Burn { amount, fee, .. } => { + supply = supply + .checked_sub(amount.checked_add(*fee).expect("burn debit overflow")) + .expect("burn should be backed by supply"); + } + Transaction::Mine { .. } => { + supply = supply + .checked_add(transaction.amount()) + .expect("mine reward supply should not overflow"); + } + } + } + } + + supply +} + +fn assert_supply_invariant(ledger: &Ledger) { + let snapshot = ledger.snapshot(); + for block in snapshot.blocks.iter().skip(1) { + assert_eq!( + block.reward, + block_reward(&block.transactions, 0).unwrap(), + "block {} reward does not match fee total", + block.height + ); + } + assert_eq!(live_supply(ledger), expected_supply(&snapshot)); +} + fn mutate_signature(signature: &mut String) { let replacement = if signature.starts_with('0') { "1" } else { "0" }; signature.replace_range(0..1, replacement); @@ -1152,6 +1208,211 @@ fn pending_transactions_from_stale_fork_are_carried_forward_after_reorg() { } #[test] +fn supply_invariant_holds_for_mixed_burns_fees_and_pow_mine_actions() { + let mut harness = harness_for_percent(27, 25); + assert_supply_invariant(&harness.ledger); + let starting_supply = live_supply(&harness.ledger); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + let actors = harness + .honest + .iter() + .filter(|wallet| wallet.address() != finalizer.address()) + .cloned() + .collect::<Vec<_>>(); + assert!(actors.len() >= 4, "test fixture needs non-finalizer actors"); + + let transfer = harness + .ledger + .build_transfer(&actors[0], actors[1].address(), 11, 2) + .unwrap(); + harness.ledger.submit_transaction(transfer.clone()).unwrap(); + let burn = harness.ledger.build_burn(&actors[2], 7, 3).unwrap(); + harness.ledger.submit_transaction(burn.clone()).unwrap(); + let mine = harness.ledger.build_mine(actors[3].address()).unwrap(); + harness.ledger.submit_transaction(mine.clone()).unwrap(); + let anchor = harness.submit_anchor_burn(&finalizer); + + let block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let block_signatures = block + .transactions + .iter() + .map(|transaction| transaction.signature().to_string()) + .collect::<BTreeSet<_>>(); + for transaction in [&transfer, &burn, &mine, &anchor] { + assert!( + block_signatures.contains(transaction.signature()), + "mixed block did not include transaction {}", + transaction.signature() + ); + } + + harness + .ledger + .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS)) + .unwrap(); + + assert_supply_invariant(&harness.ledger); + assert_eq!( + live_supply(&harness.ledger), + starting_supply + .checked_add(mine.amount()) + .and_then(|supply| supply.checked_add(mine.fee())) + .and_then(|supply| supply.checked_sub(burn.amount())) + .and_then(|supply| supply.checked_sub(anchor.amount())) + .unwrap() + ); +} + +#[test] +fn supply_invariant_tracks_reorg_to_better_fork() { + let base = harness_for_percent(28, 25); + let snapshot = base.ledger.snapshot(); + let mut local = fork_harness_from(&base, snapshot.clone()); + let mut remote = fork_harness_from(&base, snapshot); + + local.mine_ticket_block(0); + assert_supply_invariant(&local.ledger); + + let remote_leader = remote.next_rank(0); + let remote_finalizer = remote.wallet(&remote_leader.owner).clone(); + let mine_recipient = remote + .honest + .iter() + .find(|wallet| wallet.address() != remote_finalizer.address()) + .unwrap() + .clone(); + let remote_mine = remote.ledger.build_mine(mine_recipient.address()).unwrap(); + remote + .ledger + .submit_transaction(remote_mine.clone()) + .unwrap(); + let remote_first_block = remote.mine_ticket_block(0); + assert!( + remote_first_block + .transactions + .iter() + .any(|transaction| transaction.signature() == remote_mine.signature()), + "remote fork did not include PoW mine action" + ); + remote.mine_ticket_block(0); + assert_supply_invariant(&remote.ledger); + + let switched = local + .ledger + .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS) + .unwrap(); + + assert!(switched, "better remote fork should be adopted"); + assert_eq!(local.ledger.tip_hash(), remote.ledger.tip_hash()); + assert_supply_invariant(&local.ledger); + assert_eq!(live_supply(&local.ledger), live_supply(&remote.ledger)); +} + +#[test] +fn replay_and_double_spend_do_not_change_supply() { + let mut harness = harness_for_percent(29, 25); + let leader = harness.next_rank(0); + let finalizer = harness.wallet(&leader.owner).clone(); + let actors = harness + .honest + .iter() + .filter(|wallet| wallet.address() != finalizer.address()) + .cloned() + .collect::<Vec<_>>(); + assert!(actors.len() >= 3, "test fixture needs non-finalizer actors"); + let spend_outpoint = harness + .ledger + .available_utxos_for_address(actors[0].address()) + .unwrap() + .first() + .map(|(outpoint, _)| outpoint.clone()) + .expect("sender should have a spendable output"); + let first_spend = harness + .ledger + .build_transfer_with_inputs( + &actors[0], + actors[1].address(), + 1, + 1, + &[spend_outpoint.clone()], + ) + .unwrap(); + let double_spend = harness + .ledger + .build_transfer_with_inputs(&actors[0], actors[2].address(), 1, 2, &[spend_outpoint]) + .unwrap(); + let starting_supply = live_supply(&harness.ledger); + + assert_eq!( + harness + .ledger + .submit_transaction_with_outcome(first_spend.clone()) + .unwrap(), + TransactionSubmitOutcome::Added + ); + assert_eq!( + harness + .ledger + .submit_transaction_with_outcome(first_spend.clone()) + .unwrap(), + TransactionSubmitOutcome::AlreadyKnown + ); + assert_eq!( + harness + .ledger + .submit_transaction_with_outcome(double_spend.clone()) + .unwrap(), + TransactionSubmitOutcome::ConflictsWithPending + ); + + let anchor = harness.submit_anchor_burn(&finalizer); + let block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let block_signatures = block + .transactions + .iter() + .map(|transaction| transaction.signature().to_string()) + .collect::<BTreeSet<_>>(); + assert!( + block_signatures.contains(first_spend.signature()), + "test block did not mine the first spend before replay check" + ); + assert!( + block_signatures.contains(anchor.signature()), + "test block did not mine the anchor burn" + ); + let mut malicious_block = block.clone(); + malicious_block.transactions.push(double_spend); + malicious_block.reward = block_reward(&malicious_block.transactions, 0).unwrap(); + malicious_block.hash = malicious_block.compute_hash(); + assert!( + harness + .ledger + .clone() + .apply_block_at(malicious_block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS)) + .is_err(), + "double-spend block was accepted" + ); + + harness + .ledger + .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS)) + .unwrap(); + assert_eq!( + harness + .ledger + .submit_transaction_with_outcome(first_spend) + .unwrap(), + TransactionSubmitOutcome::AlreadyKnown + ); + assert_supply_invariant(&harness.ledger); + assert_eq!( + live_supply(&harness.ledger), + starting_supply.checked_sub(anchor.amount()).unwrap() + ); +} + +#[test] fn adversarial_scenarios_cover_resource_matrix() { let strategies = [ AdversaryStrategy::MaximizeBurnWeight,