iuna

iuna

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

commit cd8956473ad106d4296499b8db0dbc8d49ff5bb3
parent fe6d18766c646acda5c4bfc613c40066c9d2512a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 20:20:53 +0200

Add economic sweep runner

Diffstat:
MPLAN.md | 4++++
Msrc/domain/adversarial_tests.rs | 178++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 181 insertions(+), 1 deletion(-)

diff --git a/PLAN.md b/PLAN.md @@ -53,6 +53,10 @@ Acceptance: ## 3. Economic Sweep Runner +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. + 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 @@ -22,7 +22,7 @@ const LEVELS: [u8; 6] = [1, 5, 10, 25, 33, 50]; const COMBINED_LEVELS: [(u8, u8); 5] = [(10, 1), (10, 10), (25, 5), (25, 25), (50, 10)]; const LINEAGE_RESOURCE_ROOTS: usize = 10; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AdversaryStrategy { Honest, CensorBurns, @@ -67,6 +67,32 @@ struct ResourceLevel { lineage_percent: u8, } +#[derive(Clone, Copy, Debug)] +struct EconomicSweepCase { + seed: u64, + burn_percent: u8, + lineage_percent: u8, + peer_isolation_percent: u8, + gossip_latency_blocks: u8, + offline_finalizer_percent: u8, + fee_pressure_burns_per_block: u8, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EconomicAttackClass { + NoCensorship, + RequiresNetworkIsolation, + RequiresFinalizerDisruption, +} + +#[derive(Clone, Debug)] +struct EconomicSweepResult { + case: EconomicSweepCase, + strategy: AdversaryStrategy, + metrics: AdversarialMetrics, + class: EconomicAttackClass, +} + #[derive(Clone, Debug, Default)] struct AdversarialMetrics { attacker_finalizations: usize, @@ -93,6 +119,22 @@ impl AdversarialMetrics { } } +impl EconomicSweepCase { + fn strategy(self) -> AdversaryStrategy { + if self.peer_isolation_percent > 0 && self.offline_finalizer_percent > 0 { + AdversaryStrategy::CombinedStrategy + } else if self.peer_isolation_percent > 0 || self.gossip_latency_blocks > 0 { + AdversaryStrategy::WithholdBurnFromCommittee + } else if self.offline_finalizer_percent > 0 { + AdversaryStrategy::ForceFallback + } else if self.fee_pressure_burns_per_block > 0 { + AdversaryStrategy::MaximizeBurnWeight + } else { + AdversaryStrategy::CensorBurns + } + } +} + struct Harness { ledger: Ledger, attacker: Actor, @@ -472,6 +514,46 @@ fn share(numerator: usize, denominator: usize) -> f64 { } } +fn run_economic_sweep(cases: &[EconomicSweepCase], blocks: usize) -> Vec<EconomicSweepResult> { + cases + .iter() + .map(|case| { + let strategy = case.strategy(); + let mut harness = + Harness::new(case.seed, case.burn_percent, case.lineage_percent, strategy); + let run_blocks = if matches!( + strategy, + AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy + ) { + blocks.max(7) + } else { + blocks + }; + let metrics = harness.run_strategy(run_blocks); + let class = classify_economic_attack(*case, &metrics); + EconomicSweepResult { + case: *case, + strategy, + metrics, + class, + } + }) + .collect() +} + +fn classify_economic_attack( + case: EconomicSweepCase, + metrics: &AdversarialMetrics, +) -> EconomicAttackClass { + if metrics.censored_third_party_burns == 0 { + return EconomicAttackClass::NoCensorship; + } + if case.peer_isolation_percent > 0 || case.gossip_latency_blocks > 0 { + return EconomicAttackClass::RequiresNetworkIsolation; + } + EconomicAttackClass::RequiresFinalizerDisruption +} + 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); @@ -3162,6 +3244,100 @@ fn attack_economics_visible_burns_have_no_finite_censorship_price() { } #[test] +fn economic_sweep_runner_classifies_attack_costs_across_dimensions() { + let cases = [ + EconomicSweepCase { + seed: 1_300, + burn_percent: 25, + lineage_percent: 25, + peer_isolation_percent: 0, + gossip_latency_blocks: 0, + offline_finalizer_percent: 0, + fee_pressure_burns_per_block: 0, + }, + EconomicSweepCase { + seed: 1_301, + burn_percent: 25, + lineage_percent: 25, + peer_isolation_percent: 100, + gossip_latency_blocks: 0, + offline_finalizer_percent: 0, + fee_pressure_burns_per_block: 0, + }, + EconomicSweepCase { + seed: 1_302, + burn_percent: 25, + lineage_percent: 25, + peer_isolation_percent: 0, + gossip_latency_blocks: 2, + offline_finalizer_percent: 0, + fee_pressure_burns_per_block: 0, + }, + EconomicSweepCase { + seed: 1_303, + burn_percent: 50, + lineage_percent: 25, + peer_isolation_percent: 0, + gossip_latency_blocks: 0, + offline_finalizer_percent: 100, + fee_pressure_burns_per_block: 0, + }, + EconomicSweepCase { + seed: 1_304, + burn_percent: 50, + lineage_percent: 50, + peer_isolation_percent: 0, + gossip_latency_blocks: 0, + offline_finalizer_percent: 0, + fee_pressure_burns_per_block: 4, + }, + ]; + + let results = run_economic_sweep(&cases, 4); + assert_eq!(results.len(), cases.len()); + for (index, result) in results.iter().enumerate() { + assert_eq!(result.case.seed, cases[index].seed); + assert!( + result.metrics.attacker_finalization_share.is_finite() + && result.metrics.attacker_committee_share.is_finite() + && result.metrics.third_party_burn_censorship_rate.is_finite() + && result.metrics.fallback_rate.is_finite() + && result.metrics.recovery_rate.is_finite(), + "economic sweep produced non-finite metrics: {result:?}" + ); + } + + assert_eq!(results[0].strategy, AdversaryStrategy::CensorBurns); + assert_eq!(results[0].class, EconomicAttackClass::NoCensorship); + assert_eq!(results[0].metrics.censored_third_party_burns, 0); + assert_eq!( + results[0].metrics.successful_censorship_cost_per_burn(), + None + ); + + assert_eq!( + results[1].class, + EconomicAttackClass::RequiresNetworkIsolation + ); + assert!(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_eq!(results[3].class, EconomicAttackClass::NoCensorship); + assert_eq!( + results[3].metrics.fallback_blocks, results[3].metrics.fallback_opportunities, + "offline-finalizer sweep did not use available fallback pressure: {:?}", + results[3] + ); + + assert_eq!(results[4].strategy, AdversaryStrategy::MaximizeBurnWeight); + assert_eq!(results[4].class, EconomicAttackClass::NoCensorship); +} + +#[test] fn attack_economics_withheld_burns_require_gossip_isolation() { let mut visible = Harness::new(1_100, 25, 25, AdversaryStrategy::CensorBurns); let visible_metrics = visible.run_strategy(4);