iuna

iuna

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

commit 3872f2d08cd11689e101af2b5919eaa47095a204
parent cd8956473ad106d4296499b8db0dbc8d49ff5bb3
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 20:29:09 +0200

Model economic sweep dimensions

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

diff --git a/PLAN.md b/PLAN.md @@ -55,7 +55,10 @@ Acceptance: Status: started with a deterministic in-process sweep runner that maps attack dimensions to adversarial strategies and classifies observed censorship as -unavailable, network-isolation dependent, or finalizer-disruption dependent. +unavailable, network-isolation dependent, fee-pressure dependent, or +finalizer-disruption dependent. Fee pressure, gossip latency, peer isolation, and +offline finalizer schedules are now modeled as runner inputs instead of only as +strategy labels. Goal: quantify whether attacks are impossible, only possible under network isolation, or possible but expensive. diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -83,6 +83,7 @@ enum EconomicAttackClass { NoCensorship, RequiresNetworkIsolation, RequiresFinalizerDisruption, + RequiresFeePressure, } #[derive(Clone, Debug)] @@ -101,8 +102,12 @@ struct AdversarialMetrics { committee_slots: usize, attacker_committee_share: f64, third_party_burns: usize, + unseen_third_party_burns: usize, + delayed_third_party_burns: usize, censored_third_party_burns: usize, third_party_burn_censorship_rate: f64, + fee_pressure_burns: usize, + fee_pressure_displaced_burns: usize, fallback_opportunities: usize, fallback_blocks: usize, fallback_rate: f64, @@ -263,6 +268,25 @@ impl Harness { .collect() } + fn committee_bundles_for_burns(&self, burns: Vec<Transaction>) -> Vec<BurnBundle> { + let height = self.ledger.height() + 1; + let prev_hash = self.ledger.tip_hash().to_string(); + self.ledger + .burn_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = self.wallets.get(&member.owner)?; + Some(wallet.burn_bundle(BurnBundlePayload { + height, + prev_hash: prev_hash.clone(), + slot: member.slot, + member: member.owner, + burns: burns.clone(), + })) + }) + .collect() + } + fn prepared_ticket_block(&mut self, rank: usize, bundles: Vec<BurnBundle>) -> Block { let leader = self.next_rank(rank); let wallet = self.wallet(&leader.owner).clone(); @@ -504,6 +528,184 @@ impl Harness { metrics.attacker_net_reward -= i128::from(metrics.attacker_burn_cost); metrics } + + fn run_economic_case(&mut self, case: EconomicSweepCase, blocks: usize) -> AdversarialMetrics { + self.mature_resource_lineages(); + if case.fee_pressure_burns_per_block > 0 { + self.ledger.launch_profile.max_block_transactions = 2; + } + + let attacker_addresses = self.attacker_addresses(); + let mut metrics = AdversarialMetrics::default(); + let mut delayed_burns: Vec<(usize, Transaction)> = Vec::new(); + let mut finalizations = 0usize; + let mut attacker_finalizations = 0usize; + let mut committee_slots = 0usize; + let mut attacker_committee_slots = 0usize; + let mut fallback_blocks = 0usize; + let mut fallback_opportunities = 0usize; + let mut recovery_blocks = 0usize; + let mut blocks_until_recovery = Vec::new(); + + for step in 0..blocks { + let mut visible_burns = Vec::new(); + let ready = delayed_burns + .iter() + .filter(|(release_step, _)| *release_step <= step) + .map(|(_, burn)| burn.clone()) + .collect::<Vec<_>>(); + delayed_burns.retain(|(release_step, _)| *release_step > step); + for burn in ready { + if self.ledger.submit_transaction(burn.clone()).is_ok() { + metrics.delayed_third_party_burns += 1; + visible_burns.push(burn); + } + } + + let rank_count = self.ledger.finalizer_rank_count_for_next_block(); + let finalizer_offline = + economic_percent_active(case.offline_finalizer_percent, case.seed, step); + let has_fallback = finalizer_offline && rank_count > 1; + fallback_opportunities += usize::from(has_fallback); + let rank = usize::from(has_fallback); + let planned_finalizer = self.next_rank(rank).owner; + + let victim = self + .honest + .iter() + .cycle() + .skip(step) + .find(|wallet| wallet.address() != planned_finalizer) + .expect("test fixture should have a non-finalizer victim") + .clone(); + let isolated = economic_percent_active(case.peer_isolation_percent, case.seed, step); + let victim_burn = self.ledger.build_burn(&victim, 1, 1).unwrap(); + metrics.third_party_burns += 1; + if isolated { + metrics.unseen_third_party_burns += 1; + } else if case.gossip_latency_blocks > 0 { + metrics.unseen_third_party_burns += 1; + delayed_burns.push(( + step.saturating_add(usize::from(case.gossip_latency_blocks)), + victim_burn, + )); + } else { + self.ledger.submit_transaction(victim_burn.clone()).unwrap(); + visible_burns.push(victim_burn); + } + + let mut pressure_burns = Vec::new(); + for pressure_index in 0..case.fee_pressure_burns_per_block { + let pressure_wallet = self + .honest + .iter() + .cycle() + .skip(step + usize::from(pressure_index) + 1) + .find(|wallet| { + wallet.address() != planned_finalizer + && wallet.address() != victim.address() + }) + .expect("test fixture should have fee pressure wallets") + .clone(); + if let Ok(burn) = + self.ledger + .build_burn(&pressure_wallet, 1, 10 + u64::from(pressure_index)) + { + if self.ledger.submit_transaction(burn.clone()).is_ok() { + metrics.fee_pressure_burns += 1; + pressure_burns.push(burn); + } + } + } + + let committee = self.ledger.burn_committee_for_next_block(); + committee_slots += committee.len(); + attacker_committee_slots += committee + .iter() + .filter(|member| attacker_addresses.contains(&member.owner)) + .count(); + + let block = if case.fee_pressure_burns_per_block > 0 { + if rank > 0 { + fallback_blocks += 1; + } + let bundles = self.committee_bundles_for_burns( + pressure_burns.into_iter().take(1).collect::<Vec<_>>(), + ); + let leader = self.next_rank(rank); + let wallet = self.wallet(&leader.owner).clone(); + self.submit_anchor_burn(&wallet); + let block = self.finish_ticket_block_from_pending(rank, bundles); + self.ledger + .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms)) + .unwrap_or_else(|error| { + panic!( + "seed {} validator rejected generated fee-pressure block: {error:#}", + self.seed + ) + }); + block + } else if finalizer_offline + && self + .ledger + .recovery_block_available_at(self.ledger.recovery_block_min_timestamp()) + && step % 7 == 6 + { + recovery_blocks += 1; + blocks_until_recovery.push((step + 1) as f64); + let wallet = self.attacker.wallet.clone(); + self.mine_recovery_block(&wallet) + } else { + if rank > 0 { + fallback_blocks += 1; + } + self.mine_ticket_block(rank) + }; + + finalizations += 1; + if attacker_addresses.contains(&block.miner) { + attacker_finalizations += 1; + metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1); + metrics.attacker_net_reward += i128::from(block.reward); + } + for burn in visible_burns { + let included = block + .transactions + .iter() + .any(|tx| tx.signature() == burn.signature()); + if !included { + metrics.censored_third_party_burns += 1; + if case.fee_pressure_burns_per_block > 0 { + metrics.fee_pressure_displaced_burns += 1; + } + } + } + if case.fee_pressure_burns_per_block > 0 { + self.ledger.clear_pending_transactions(); + } + } + + metrics.attacker_finalizations = attacker_finalizations; + metrics.attacker_finalization_share = share(attacker_finalizations, finalizations); + metrics.attacker_committee_slots = attacker_committee_slots; + metrics.committee_slots = committee_slots; + metrics.attacker_committee_share = share(attacker_committee_slots, committee_slots); + metrics.third_party_burn_censorship_rate = share( + metrics.censored_third_party_burns, + metrics.third_party_burns, + ); + metrics.fallback_opportunities = fallback_opportunities; + metrics.fallback_blocks = fallback_blocks; + metrics.fallback_rate = share(fallback_blocks, blocks); + metrics.recovery_rate = share(recovery_blocks, blocks); + metrics.average_blocks_until_recovery = if blocks_until_recovery.is_empty() { + 0.0 + } else { + blocks_until_recovery.iter().sum::<f64>() / blocks_until_recovery.len() as f64 + }; + metrics.attacker_net_reward -= i128::from(metrics.attacker_burn_cost); + metrics + } } fn share(numerator: usize, denominator: usize) -> f64 { @@ -529,7 +731,7 @@ fn run_economic_sweep(cases: &[EconomicSweepCase], blocks: usize) -> Vec<Economi } else { blocks }; - let metrics = harness.run_strategy(run_blocks); + let metrics = harness.run_economic_case(*case, run_blocks); let class = classify_economic_attack(*case, &metrics); EconomicSweepResult { case: *case, @@ -545,6 +747,12 @@ fn classify_economic_attack( case: EconomicSweepCase, metrics: &AdversarialMetrics, ) -> EconomicAttackClass { + if metrics.fee_pressure_displaced_burns > 0 { + return EconomicAttackClass::RequiresFeePressure; + } + if metrics.unseen_third_party_burns > 0 { + return EconomicAttackClass::RequiresNetworkIsolation; + } if metrics.censored_third_party_burns == 0 { return EconomicAttackClass::NoCensorship; } @@ -554,6 +762,10 @@ fn classify_economic_attack( EconomicAttackClass::RequiresFinalizerDisruption } +fn economic_percent_active(percent: u8, seed: u64, step: usize) -> bool { + percent >= 100 || ((seed as usize + step.saturating_mul(37)) % 100) < usize::from(percent) +} + fn lineage_resource_roots(lineage_percent: u8) -> (usize, usize) { let attacker_roots = (usize::from(lineage_percent) * LINEAGE_RESOURCE_ROOTS).div_ceil(100); let attacker_roots = attacker_roots.clamp(1, LINEAGE_RESOURCE_ROOTS); @@ -3319,12 +3531,14 @@ fn economic_sweep_runner_classifies_attack_costs_across_dimensions() { results[1].class, EconomicAttackClass::RequiresNetworkIsolation ); - assert!(results[1].metrics.censored_third_party_burns > 0); + assert!(results[1].metrics.unseen_third_party_burns > 0); + assert_eq!(results[1].metrics.censored_third_party_burns, 0); assert_eq!( results[2].class, EconomicAttackClass::RequiresNetworkIsolation ); - assert!(results[2].metrics.censored_third_party_burns > 0); + assert!(results[2].metrics.unseen_third_party_burns > 0); + assert!(results[2].metrics.delayed_third_party_burns > 0); assert_eq!(results[3].class, EconomicAttackClass::NoCensorship); assert_eq!( @@ -3334,7 +3548,9 @@ fn economic_sweep_runner_classifies_attack_costs_across_dimensions() { ); assert_eq!(results[4].strategy, AdversaryStrategy::MaximizeBurnWeight); - assert_eq!(results[4].class, EconomicAttackClass::NoCensorship); + assert_eq!(results[4].class, EconomicAttackClass::RequiresFeePressure); + assert!(results[4].metrics.fee_pressure_burns > 0); + assert!(results[4].metrics.fee_pressure_displaced_burns > 0); } #[test]