iuna

iuna

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

adversarial_tests.rs (159005B)


      1 use std::collections::{BTreeMap, BTreeSet};
      2 
      3 use proptest::prelude::*;
      4 use proptest::test_runner::{Config, TestCaseResult};
      5 use sha2::{Digest, Sha256};
      6 
      7 use super::ledger_ops::{block_reward, verify_address_signature};
      8 use super::reveal::{BurnBundlePayload, burn_bundle_slot_mask};
      9 use super::ticket::{
     10     BurnTicket, MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT, ticket_block_min_timestamp,
     11 };
     12 use super::{
     13     Amount, BURN_COMMITTEE_SIZE, BURN_LINEAGE_MATURITY_HEIGHTS, Block, BurnBundle,
     14     BurnBundleSignature, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, FinalizerMode,
     15     GRINDING_RESISTANCE_ACTIVATION_HEIGHT, GenesisBurn, LeaderProofPayload, Ledger,
     16     MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MICRO_IUNA, MaskedBurn,
     17     OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, OutPoint, TRANSACTION_V2_ACTIVATION_HEIGHT, Transaction,
     18     TransactionSubmitOutcome, TxOutput, UtxoLineageRoot, VDF_TARGET_BLOCK_MS, Wallet,
     19     genesis_allocation_outpoint, hex_hash, reward_outputs_for_block, run_vdf,
     20 };
     21 
     22 const NOW_MS: u64 = 10_000_000_000;
     23 const LEVELS: [u8; 6] = [1, 5, 10, 25, 33, 50];
     24 const COMBINED_LEVELS: [(u8, u8); 5] = [(10, 1), (10, 10), (25, 5), (25, 25), (50, 10)];
     25 const LINEAGE_RESOURCE_ROOTS: usize = 10;
     26 
     27 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     28 enum AdversaryStrategy {
     29     Honest,
     30     CensorBurns,
     31     MaximizeBurnWeight,
     32     MaximizeCommitteeWeight,
     33     WithholdBurnFromCommittee,
     34     MissRank0,
     35     ForceFallback,
     36     AttemptRecovery,
     37     AddressRotation,
     38     CombinedStrategy,
     39 }
     40 
     41 impl AdversaryStrategy {
     42     fn from_index(index: usize) -> Self {
     43         match index % 10 {
     44             0 => Self::Honest,
     45             1 => Self::CensorBurns,
     46             2 => Self::MaximizeBurnWeight,
     47             3 => Self::MaximizeCommitteeWeight,
     48             4 => Self::WithholdBurnFromCommittee,
     49             5 => Self::MissRank0,
     50             6 => Self::ForceFallback,
     51             7 => Self::AttemptRecovery,
     52             8 => Self::AddressRotation,
     53             _ => Self::CombinedStrategy,
     54         }
     55     }
     56 }
     57 
     58 #[derive(Clone, Debug)]
     59 struct Actor {
     60     wallet: Wallet,
     61     burn_wallets: Vec<Wallet>,
     62     lineage_wallets: Vec<Wallet>,
     63     strategy: AdversaryStrategy,
     64 }
     65 
     66 #[derive(Clone, Copy, Debug)]
     67 struct ResourceLevel {
     68     burn_percent: u8,
     69     lineage_percent: u8,
     70 }
     71 
     72 #[derive(Clone, Copy, Debug)]
     73 struct EconomicSweepCase {
     74     seed: u64,
     75     burn_percent: u8,
     76     lineage_percent: u8,
     77     peer_isolation_percent: u8,
     78     gossip_latency_blocks: u8,
     79     offline_finalizer_percent: u8,
     80     fee_pressure_burns_per_block: u8,
     81 }
     82 
     83 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     84 enum EconomicAttackClass {
     85     NoCensorship,
     86     RequiresNetworkIsolation,
     87     RequiresFinalizerDisruption,
     88     RequiresFeePressure,
     89 }
     90 
     91 #[derive(Clone, Debug)]
     92 struct EconomicSweepResult {
     93     case: EconomicSweepCase,
     94     strategy: AdversaryStrategy,
     95     metrics: AdversarialMetrics,
     96     class: EconomicAttackClass,
     97 }
     98 
     99 #[derive(Clone, Debug, Default)]
    100 struct AdversarialMetrics {
    101     attacker_finalizations: usize,
    102     attacker_finalization_share: f64,
    103     attacker_committee_slots: usize,
    104     committee_slots: usize,
    105     attacker_committee_share: f64,
    106     third_party_burns: usize,
    107     unseen_third_party_burns: usize,
    108     delayed_third_party_burns: usize,
    109     censored_third_party_burns: usize,
    110     third_party_burn_censorship_rate: f64,
    111     fee_pressure_burns: usize,
    112     fee_pressure_displaced_burns: usize,
    113     fallback_opportunities: usize,
    114     fallback_blocks: usize,
    115     fallback_rate: f64,
    116     recovery_rate: f64,
    117     average_blocks_until_recovery: f64,
    118     attacker_burn_cost: Amount,
    119     attacker_net_reward: i128,
    120 }
    121 
    122 impl AdversarialMetrics {
    123     fn successful_censorship_cost_per_burn(&self) -> Option<f64> {
    124         (self.censored_third_party_burns > 0)
    125             .then(|| self.attacker_burn_cost as f64 / self.censored_third_party_burns as f64)
    126     }
    127 }
    128 
    129 impl EconomicSweepCase {
    130     fn strategy(self) -> AdversaryStrategy {
    131         if self.peer_isolation_percent > 0 && self.offline_finalizer_percent > 0 {
    132             AdversaryStrategy::CombinedStrategy
    133         } else if self.peer_isolation_percent > 0 || self.gossip_latency_blocks > 0 {
    134             AdversaryStrategy::WithholdBurnFromCommittee
    135         } else if self.offline_finalizer_percent > 0 {
    136             AdversaryStrategy::ForceFallback
    137         } else if self.fee_pressure_burns_per_block > 0 {
    138             AdversaryStrategy::MaximizeBurnWeight
    139         } else {
    140             AdversaryStrategy::CensorBurns
    141         }
    142     }
    143 }
    144 
    145 struct Harness {
    146     ledger: Ledger,
    147     attacker: Actor,
    148     honest: Vec<Wallet>,
    149     wallets: BTreeMap<String, Wallet>,
    150     seed: u64,
    151     resource: ResourceLevel,
    152 }
    153 
    154 impl Harness {
    155     fn new(seed: u64, burn_percent: u8, lineage_percent: u8, strategy: AdversaryStrategy) -> Self {
    156         let attacker_wallet = Wallet::from_seed(&format!("adv-{seed}-attacker-main"));
    157         let attacker_burn_wallets = (0..4)
    158             .map(|index| Wallet::from_seed(&format!("adv-{seed}-attacker-burn-{index}")))
    159             .collect::<Vec<_>>();
    160         let attacker_lineage_wallets = (0..4)
    161             .map(|index| Wallet::from_seed(&format!("adv-{seed}-attacker-lineage-{index}")))
    162             .collect::<Vec<_>>();
    163         let honest = (0..8)
    164             .map(|index| Wallet::from_seed(&format!("adv-{seed}-honest-{index}")))
    165             .collect::<Vec<_>>();
    166         let mut all_wallets = vec![attacker_wallet.clone()];
    167         all_wallets.extend(attacker_burn_wallets.clone());
    168         all_wallets.extend(attacker_lineage_wallets.clone());
    169         all_wallets.extend(honest.clone());
    170 
    171         let mut allocations = BTreeMap::new();
    172         for wallet in &all_wallets {
    173             allocations.insert(wallet.address().to_string(), 10_000 * MICRO_IUNA);
    174         }
    175         let attacker_burn = u64::from(burn_percent).max(1) * MICRO_IUNA;
    176         let honest_burn_total = u64::from(100_u8.saturating_sub(burn_percent)).max(1) * MICRO_IUNA;
    177         let honest_burn_each = (honest_burn_total / honest.len() as u64).max(1);
    178         let mut genesis_burns = vec![GenesisBurn::new(attacker_wallet.address(), attacker_burn)];
    179         genesis_burns.extend(
    180             honest
    181                 .iter()
    182                 .map(|wallet| GenesisBurn::new(wallet.address(), honest_burn_each)),
    183         );
    184         let mut ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap();
    185         ledger.launch_profile.mine_difficulty_bits = 0;
    186         ledger.mine_difficulty_windows[0] = 0;
    187 
    188         let wallets = all_wallets
    189             .into_iter()
    190             .map(|wallet| (wallet.address().to_string(), wallet))
    191             .collect::<BTreeMap<_, _>>();
    192 
    193         Self {
    194             ledger,
    195             attacker: Actor {
    196                 wallet: attacker_wallet,
    197                 burn_wallets: attacker_burn_wallets,
    198                 lineage_wallets: attacker_lineage_wallets,
    199                 strategy,
    200             },
    201             honest,
    202             wallets,
    203             seed,
    204             resource: ResourceLevel {
    205                 burn_percent,
    206                 lineage_percent,
    207             },
    208         }
    209     }
    210 
    211     fn wallet(&self, address: &str) -> &Wallet {
    212         self.wallets
    213             .get(address)
    214             .unwrap_or_else(|| panic!("seed {} missing wallet {address}", self.seed))
    215     }
    216 
    217     fn set_next_height(&mut self, next_height: u64) {
    218         self.ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1);
    219         for ticket in &mut self.ledger.tickets {
    220             ticket.eligible_from_height = next_height;
    221             ticket.eligible_until_height = next_height;
    222         }
    223     }
    224 
    225     fn next_rank(&self, rank: usize) -> BurnLeaderRank {
    226         let height = self.ledger.height() + 1;
    227         let ticket = self
    228             .ledger
    229             .ticket_for_finalizer_rank(height, rank as u32)
    230             .unwrap_or_else(|| panic!("seed {} has no rank {rank} at height {height}", self.seed));
    231         BurnLeaderRank {
    232             rank: rank as u32,
    233             ticket_id: ticket.id,
    234             owner: ticket.owner,
    235             amount: ticket.amount,
    236             eligible_from_height: ticket.eligible_from_height,
    237             eligible_until_height: ticket.eligible_until_height,
    238         }
    239     }
    240 
    241     fn submit_anchor_burn(&mut self, wallet: &Wallet) -> Transaction {
    242         let burn = self
    243             .ledger
    244             .build_burn(wallet, 1, 1)
    245             .unwrap_or_else(|error| {
    246                 panic!(
    247                     "seed {} failed to build anchor burn for {}: {error:#}",
    248                     self.seed,
    249                     wallet.address()
    250                 )
    251             });
    252         self.ledger.submit_transaction(burn.clone()).unwrap();
    253         burn
    254     }
    255 
    256     fn submit_fee_burn(&mut self, wallet: &Wallet, amount: Amount, fee: Amount) -> Transaction {
    257         let burn = self
    258             .ledger
    259             .build_burn(wallet, amount, fee)
    260             .unwrap_or_else(|error| {
    261                 panic!(
    262                     "seed {} failed to build fee burn for {}: {error:#}",
    263                     self.seed,
    264                     wallet.address()
    265                 )
    266             });
    267         self.ledger.submit_transaction(burn.clone()).unwrap();
    268         burn
    269     }
    270 
    271     fn committee_bundles(&self) -> Vec<BurnBundle> {
    272         self.committee_bundles_for_rank(0)
    273     }
    274 
    275     fn committee_bundles_for_rank(&self, rank: usize) -> Vec<BurnBundle> {
    276         let burns = self
    277             .ledger
    278             .pending()
    279             .iter()
    280             .filter(|transaction| transaction.is_burn())
    281             .cloned()
    282             .collect::<Vec<_>>();
    283         self.committee_bundles_for_rank_and_burns(rank, burns)
    284     }
    285 
    286     fn committee_bundles_for_rank_and_burns(
    287         &self,
    288         rank: usize,
    289         mut burns: Vec<Transaction>,
    290     ) -> Vec<BurnBundle> {
    291         let height = self.ledger.height() + 1;
    292         let prev_hash = self.ledger.tip_hash().to_string();
    293         burns.sort_by(|left, right| {
    294             right
    295                 .fee()
    296                 .cmp(&left.fee())
    297                 .then_with(|| left.signature().cmp(right.signature()))
    298         });
    299         self.ledger
    300             .burn_committee_for_next_ticket_block(rank as u32)
    301             .into_iter()
    302             .filter_map(|member| {
    303                 let wallet = self.wallets.get(&member.owner)?;
    304                 Some(wallet.burn_bundle(BurnBundlePayload {
    305                     height,
    306                     prev_hash: prev_hash.clone(),
    307                     slot: member.slot,
    308                     member: member.owner,
    309                     reward_address: None,
    310                     burns: burns.clone(),
    311                     burns_v2: Vec::new(),
    312                 }))
    313             })
    314             .collect()
    315     }
    316 
    317     fn prepared_ticket_block(&mut self, rank: usize, bundles: Vec<BurnBundle>) -> Block {
    318         let leader = self.next_rank(rank);
    319         let wallet = self.wallet(&leader.owner).clone();
    320         self.submit_anchor_burn(&wallet);
    321         self.finish_ticket_block_from_pending(rank, bundles)
    322     }
    323 
    324     fn finish_ticket_block_from_pending(&self, rank: usize, bundles: Vec<BurnBundle>) -> Block {
    325         let leader = self.next_rank(rank);
    326         let wallet = self.wallet(&leader.owner).clone();
    327         let timestamp = self
    328             .ledger
    329             .tip()
    330             .timestamp_ms
    331             .saturating_add(VDF_TARGET_BLOCK_MS * 2 * rank as u64)
    332             .saturating_add(1);
    333         let prepared = self
    334             .ledger
    335             .prepare_next_block_with_burn_bundles(wallet.address(), timestamp, bundles)
    336             .unwrap_or_else(|error| {
    337                 panic!(
    338                     "seed {} failed to prepare ticket block rank {rank}: {error:#}",
    339                     self.seed
    340                 )
    341             });
    342         let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
    343         prepared.finish(&wallet, vdf_output)
    344     }
    345 
    346     fn mine_ticket_block(&mut self, rank: usize) -> Block {
    347         let leader = self.next_rank(rank);
    348         let wallet = self.wallet(&leader.owner).clone();
    349         self.submit_anchor_burn(&wallet);
    350         let bundles = self.committee_bundles_for_rank(rank);
    351         let block = self.finish_ticket_block_from_pending(rank, bundles);
    352         self.ledger
    353             .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms))
    354             .unwrap_or_else(|error| {
    355                 panic!(
    356                     "seed {} validator rejected generated ticket block: {error:#}",
    357                     self.seed
    358                 )
    359             });
    360         block
    361     }
    362 
    363     fn mine_recovery_block(&mut self, wallet: &Wallet) -> Block {
    364         self.submit_anchor_burn(wallet);
    365         let timestamp = self.ledger.recovery_block_min_timestamp();
    366         let prepared = self
    367             .ledger
    368             .prepare_recovery_block(wallet.address(), timestamp)
    369             .unwrap();
    370         let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
    371         let block = prepared.finish(wallet, vdf_output);
    372         self.ledger
    373             .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms))
    374             .unwrap();
    375         block
    376     }
    377 
    378     fn mature_lineages(&mut self, attacker_roots: usize, honest_roots: usize) {
    379         let mut roots_remaining = attacker_roots + honest_roots;
    380         let mut latest_root_height = self.ledger.height();
    381         while roots_remaining > 0 {
    382             let anchor = self.ledger.tip_hash().to_string();
    383             for index in 0..2 {
    384                 if roots_remaining == 0 {
    385                     break;
    386                 }
    387                 let wallet = if roots_remaining > honest_roots {
    388                     self.attacker.lineage_wallets[index % self.attacker.lineage_wallets.len()]
    389                         .clone()
    390                 } else {
    391                     self.honest[index % self.honest.len()].clone()
    392                 };
    393                 let mine = self
    394                     .ledger
    395                     .build_mine(wallet.address())
    396                     .unwrap_or_else(|error| {
    397                         panic!(
    398                             "seed {} failed to build mine for anchor {anchor}: {error:#}",
    399                             self.seed
    400                         )
    401                     });
    402                 self.ledger.submit_transaction(mine).unwrap();
    403                 roots_remaining -= 1;
    404             }
    405             self.mine_ticket_block(0);
    406             latest_root_height = self.ledger.height();
    407         }
    408         while self.ledger.height()
    409             < latest_root_height
    410                 .saturating_add(BURN_LINEAGE_MATURITY_HEIGHTS)
    411                 .saturating_add(1)
    412         {
    413             self.mine_ticket_block(0);
    414         }
    415     }
    416 
    417     fn mature_resource_lineages(&mut self) {
    418         let (attacker_roots, honest_roots) = lineage_resource_roots(self.resource.lineage_percent);
    419         self.mature_lineages(attacker_roots, honest_roots);
    420     }
    421 
    422     fn ensure_lineage_owners_have_next_height_tickets(&mut self) {
    423         let target_height = self.ledger.height() + 1;
    424         let owners = self
    425             .ledger
    426             .lineage_owners
    427             .values()
    428             .flat_map(|owners| owners.keys().cloned())
    429             .collect::<BTreeSet<_>>();
    430         for (index, owner) in owners.into_iter().enumerate() {
    431             if self.ledger.tickets.iter().any(|ticket| {
    432                 ticket.owner == owner
    433                     && ticket.eligible_from_height <= target_height
    434                     && target_height <= ticket.eligible_until_height
    435             }) {
    436                 continue;
    437             }
    438             self.ledger.tickets.push(BurnTicket {
    439                 id: hex_hash(format!("fixture-lineage-ticket-{target_height}-{index}")),
    440                 owner,
    441                 amount: 1,
    442                 eligible_from_height: target_height,
    443                 eligible_until_height: target_height,
    444             });
    445         }
    446     }
    447 
    448     fn attacker_addresses(&self) -> BTreeSet<String> {
    449         std::iter::once(self.attacker.wallet.address().to_string())
    450             .chain(
    451                 self.attacker
    452                     .burn_wallets
    453                     .iter()
    454                     .map(|wallet| wallet.address().to_string()),
    455             )
    456             .chain(
    457                 self.attacker
    458                     .lineage_wallets
    459                     .iter()
    460                     .map(|wallet| wallet.address().to_string()),
    461             )
    462             .collect()
    463     }
    464 
    465     fn run_strategy(&mut self, blocks: usize) -> AdversarialMetrics {
    466         self.mature_resource_lineages();
    467         let attacker_addresses = self.attacker_addresses();
    468         let mut metrics = AdversarialMetrics::default();
    469         let mut third_party_burns = 0usize;
    470         let mut censored_third_party_burns = 0usize;
    471         let mut finalizations = 0usize;
    472         let mut attacker_finalizations = 0usize;
    473         let mut committee_slots = 0usize;
    474         let mut attacker_committee_slots = 0usize;
    475         let mut fallback_blocks = 0usize;
    476         let mut fallback_opportunities = 0usize;
    477         let mut recovery_blocks = 0usize;
    478         let mut blocks_until_recovery = Vec::new();
    479 
    480         for step in 0..blocks {
    481             let rank_count = self.ledger.finalizer_rank_count_for_next_block();
    482             let rank = match self.attacker.strategy {
    483                 AdversaryStrategy::MissRank0
    484                 | AdversaryStrategy::ForceFallback
    485                 | AdversaryStrategy::CombinedStrategy => {
    486                     let has_fallback = rank_count > 1;
    487                     fallback_opportunities += usize::from(has_fallback);
    488                     usize::from(has_fallback)
    489                 }
    490                 _ => 0,
    491             };
    492             let planned_finalizer = self.next_rank(rank).owner;
    493             let victim = self
    494                 .honest
    495                 .iter()
    496                 .cycle()
    497                 .skip(step)
    498                 .find(|wallet| wallet.address() != planned_finalizer)
    499                 .expect("test fixture should have a non-finalizer victim")
    500                 .clone();
    501             let third_party_burn = if matches!(
    502                 self.attacker.strategy,
    503                 AdversaryStrategy::CensorBurns
    504                     | AdversaryStrategy::WithholdBurnFromCommittee
    505                     | AdversaryStrategy::CombinedStrategy
    506             ) {
    507                 third_party_burns += 1;
    508                 if matches!(
    509                     self.attacker.strategy,
    510                     AdversaryStrategy::WithholdBurnFromCommittee
    511                         | AdversaryStrategy::CombinedStrategy
    512                 ) {
    513                     Some(self.ledger.build_burn(&victim, 1, 1).unwrap())
    514                 } else {
    515                     Some(self.submit_fee_burn(&victim, 1, 1))
    516                 }
    517             } else {
    518                 None
    519             };
    520 
    521             let committee = self.ledger.burn_committee_for_next_block();
    522             committee_slots += committee.len();
    523             attacker_committee_slots += committee
    524                 .iter()
    525                 .filter(|member| attacker_addresses.contains(&member.owner))
    526                 .count();
    527 
    528             let block = if matches!(
    529                 self.attacker.strategy,
    530                 AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy
    531             ) && self
    532                 .ledger
    533                 .recovery_block_available_at(self.ledger.recovery_block_min_timestamp())
    534                 && step % 7 == 6
    535             {
    536                 recovery_blocks += 1;
    537                 blocks_until_recovery.push((step + 1) as f64);
    538                 let wallet = self.attacker.wallet.clone();
    539                 self.mine_recovery_block(&wallet)
    540             } else {
    541                 if rank > 0 {
    542                     fallback_blocks += 1;
    543                 }
    544                 self.mine_ticket_block(rank)
    545             };
    546 
    547             finalizations += 1;
    548             metrics.attacker_net_reward += i128::from(attacker_reward_from_block(
    549                 &block,
    550                 &committee,
    551                 &attacker_addresses,
    552             ));
    553             if attacker_addresses.contains(&block.miner) {
    554                 attacker_finalizations += 1;
    555                 metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1);
    556             }
    557             if let Some(burn) = third_party_burn {
    558                 censored_third_party_burns += usize::from(
    559                     !block
    560                         .transactions
    561                         .iter()
    562                         .any(|tx| tx.signature() == burn.signature()),
    563                 );
    564             }
    565         }
    566 
    567         metrics.attacker_finalizations = attacker_finalizations;
    568         metrics.attacker_finalization_share = share(attacker_finalizations, finalizations);
    569         metrics.attacker_committee_slots = attacker_committee_slots;
    570         metrics.committee_slots = committee_slots;
    571         metrics.attacker_committee_share = share(attacker_committee_slots, committee_slots);
    572         metrics.third_party_burns = third_party_burns;
    573         metrics.censored_third_party_burns = censored_third_party_burns;
    574         metrics.third_party_burn_censorship_rate =
    575             share(censored_third_party_burns, third_party_burns);
    576         metrics.fallback_opportunities = fallback_opportunities;
    577         metrics.fallback_blocks = fallback_blocks;
    578         metrics.fallback_rate = share(fallback_blocks, blocks);
    579         metrics.recovery_rate = share(recovery_blocks, blocks);
    580         metrics.average_blocks_until_recovery = if blocks_until_recovery.is_empty() {
    581             0.0
    582         } else {
    583             blocks_until_recovery.iter().sum::<f64>() / blocks_until_recovery.len() as f64
    584         };
    585         metrics.attacker_net_reward -= i128::from(metrics.attacker_burn_cost);
    586         metrics
    587     }
    588 
    589     fn run_economic_case(&mut self, case: EconomicSweepCase, blocks: usize) -> AdversarialMetrics {
    590         self.mature_resource_lineages();
    591         if case.fee_pressure_burns_per_block > 0 {
    592             self.ledger.launch_profile.max_block_transactions = 2;
    593         }
    594 
    595         let attacker_addresses = self.attacker_addresses();
    596         let mut metrics = AdversarialMetrics::default();
    597         let mut delayed_burns: Vec<(usize, Transaction)> = Vec::new();
    598         let mut finalizations = 0usize;
    599         let mut attacker_finalizations = 0usize;
    600         let mut committee_slots = 0usize;
    601         let mut attacker_committee_slots = 0usize;
    602         let mut fallback_blocks = 0usize;
    603         let mut fallback_opportunities = 0usize;
    604         let mut recovery_blocks = 0usize;
    605         let mut blocks_until_recovery = Vec::new();
    606 
    607         for step in 0..blocks {
    608             let mut visible_burns = Vec::new();
    609             let ready = delayed_burns
    610                 .iter()
    611                 .filter(|(release_step, _)| *release_step <= step)
    612                 .map(|(_, burn)| burn.clone())
    613                 .collect::<Vec<_>>();
    614             delayed_burns.retain(|(release_step, _)| *release_step > step);
    615             for burn in ready {
    616                 if self.ledger.submit_transaction(burn.clone()).is_ok() {
    617                     metrics.delayed_third_party_burns += 1;
    618                     visible_burns.push(burn);
    619                 }
    620             }
    621 
    622             let rank_count = self.ledger.finalizer_rank_count_for_next_block();
    623             let finalizer_offline =
    624                 economic_percent_active(case.offline_finalizer_percent, case.seed, step);
    625             let has_fallback = finalizer_offline && rank_count > 1;
    626             fallback_opportunities += usize::from(has_fallback);
    627             let rank = usize::from(has_fallback);
    628             let planned_finalizer = self.next_rank(rank).owner;
    629 
    630             let victim = self
    631                 .honest
    632                 .iter()
    633                 .cycle()
    634                 .skip(step)
    635                 .find(|wallet| wallet.address() != planned_finalizer)
    636                 .expect("test fixture should have a non-finalizer victim")
    637                 .clone();
    638             let isolated = economic_percent_active(case.peer_isolation_percent, case.seed, step);
    639             let victim_burn = self.ledger.build_burn(&victim, 1, 1).unwrap();
    640             metrics.third_party_burns += 1;
    641             if isolated {
    642                 metrics.unseen_third_party_burns += 1;
    643             } else if case.gossip_latency_blocks > 0 {
    644                 metrics.unseen_third_party_burns += 1;
    645                 delayed_burns.push((
    646                     step.saturating_add(usize::from(case.gossip_latency_blocks)),
    647                     victim_burn,
    648                 ));
    649             } else {
    650                 self.ledger.submit_transaction(victim_burn.clone()).unwrap();
    651                 visible_burns.push(victim_burn);
    652             }
    653 
    654             let mut pressure_burns = Vec::new();
    655             for pressure_index in 0..case.fee_pressure_burns_per_block {
    656                 let pressure_wallet = self
    657                     .honest
    658                     .iter()
    659                     .cycle()
    660                     .skip(step + usize::from(pressure_index) + 1)
    661                     .find(|wallet| {
    662                         wallet.address() != planned_finalizer
    663                             && wallet.address() != victim.address()
    664                     })
    665                     .expect("test fixture should have fee pressure wallets")
    666                     .clone();
    667                 if let Ok(burn) =
    668                     self.ledger
    669                         .build_burn(&pressure_wallet, 1, 10 + u64::from(pressure_index))
    670                 {
    671                     if self.ledger.submit_transaction(burn.clone()).is_ok() {
    672                         metrics.fee_pressure_burns += 1;
    673                         pressure_burns.push(burn);
    674                     }
    675                 }
    676             }
    677 
    678             let committee = self.ledger.burn_committee_for_next_block();
    679             committee_slots += committee.len();
    680             attacker_committee_slots += committee
    681                 .iter()
    682                 .filter(|member| attacker_addresses.contains(&member.owner))
    683                 .count();
    684 
    685             let block = if case.fee_pressure_burns_per_block > 0 {
    686                 if rank > 0 {
    687                     fallback_blocks += 1;
    688                 }
    689                 let bundles = self.committee_bundles_for_rank_and_burns(
    690                     rank,
    691                     pressure_burns.into_iter().take(1).collect::<Vec<_>>(),
    692                 );
    693                 let leader = self.next_rank(rank);
    694                 let wallet = self.wallet(&leader.owner).clone();
    695                 self.submit_anchor_burn(&wallet);
    696                 let block = self.finish_ticket_block_from_pending(rank, bundles);
    697                 self.ledger
    698                     .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms))
    699                     .unwrap_or_else(|error| {
    700                         panic!(
    701                             "seed {} validator rejected generated fee-pressure block: {error:#}",
    702                             self.seed
    703                         )
    704                     });
    705                 block
    706             } else if finalizer_offline
    707                 && self
    708                     .ledger
    709                     .recovery_block_available_at(self.ledger.recovery_block_min_timestamp())
    710                 && step % 7 == 6
    711             {
    712                 recovery_blocks += 1;
    713                 blocks_until_recovery.push((step + 1) as f64);
    714                 let wallet = self.attacker.wallet.clone();
    715                 self.mine_recovery_block(&wallet)
    716             } else {
    717                 if rank > 0 {
    718                     fallback_blocks += 1;
    719                 }
    720                 self.mine_ticket_block(rank)
    721             };
    722 
    723             finalizations += 1;
    724             metrics.attacker_net_reward += i128::from(attacker_reward_from_block(
    725                 &block,
    726                 &committee,
    727                 &attacker_addresses,
    728             ));
    729             if attacker_addresses.contains(&block.miner) {
    730                 attacker_finalizations += 1;
    731                 metrics.attacker_burn_cost = metrics.attacker_burn_cost.saturating_add(1);
    732             }
    733             for burn in visible_burns {
    734                 let included = block
    735                     .transactions
    736                     .iter()
    737                     .any(|tx| tx.signature() == burn.signature());
    738                 if !included {
    739                     metrics.censored_third_party_burns += 1;
    740                     if case.fee_pressure_burns_per_block > 0 {
    741                         metrics.fee_pressure_displaced_burns += 1;
    742                     }
    743                 }
    744             }
    745             if case.fee_pressure_burns_per_block > 0 {
    746                 self.ledger.clear_pending_transactions();
    747             }
    748         }
    749 
    750         metrics.attacker_finalizations = attacker_finalizations;
    751         metrics.attacker_finalization_share = share(attacker_finalizations, finalizations);
    752         metrics.attacker_committee_slots = attacker_committee_slots;
    753         metrics.committee_slots = committee_slots;
    754         metrics.attacker_committee_share = share(attacker_committee_slots, committee_slots);
    755         metrics.third_party_burn_censorship_rate = share(
    756             metrics.censored_third_party_burns,
    757             metrics.third_party_burns,
    758         );
    759         metrics.fallback_opportunities = fallback_opportunities;
    760         metrics.fallback_blocks = fallback_blocks;
    761         metrics.fallback_rate = share(fallback_blocks, blocks);
    762         metrics.recovery_rate = share(recovery_blocks, blocks);
    763         metrics.average_blocks_until_recovery = if blocks_until_recovery.is_empty() {
    764             0.0
    765         } else {
    766             blocks_until_recovery.iter().sum::<f64>() / blocks_until_recovery.len() as f64
    767         };
    768         metrics.attacker_net_reward -= i128::from(metrics.attacker_burn_cost);
    769         metrics
    770     }
    771 }
    772 
    773 fn share(numerator: usize, denominator: usize) -> f64 {
    774     if denominator == 0 {
    775         0.0
    776     } else {
    777         numerator as f64 / denominator as f64
    778     }
    779 }
    780 
    781 fn run_economic_sweep(cases: &[EconomicSweepCase], blocks: usize) -> Vec<EconomicSweepResult> {
    782     cases
    783         .iter()
    784         .map(|case| {
    785             let strategy = case.strategy();
    786             let mut harness =
    787                 Harness::new(case.seed, case.burn_percent, case.lineage_percent, strategy);
    788             let run_blocks = if matches!(
    789                 strategy,
    790                 AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy
    791             ) {
    792                 blocks.max(7)
    793             } else {
    794                 blocks
    795             };
    796             let metrics = harness.run_economic_case(*case, run_blocks);
    797             let class = classify_economic_attack(*case, &metrics);
    798             EconomicSweepResult {
    799                 case: *case,
    800                 strategy,
    801                 metrics,
    802                 class,
    803             }
    804         })
    805         .collect()
    806 }
    807 
    808 fn classify_economic_attack(
    809     case: EconomicSweepCase,
    810     metrics: &AdversarialMetrics,
    811 ) -> EconomicAttackClass {
    812     if metrics.fee_pressure_displaced_burns > 0 {
    813         return EconomicAttackClass::RequiresFeePressure;
    814     }
    815     if metrics.unseen_third_party_burns > 0 {
    816         return EconomicAttackClass::RequiresNetworkIsolation;
    817     }
    818     if metrics.censored_third_party_burns == 0 {
    819         return EconomicAttackClass::NoCensorship;
    820     }
    821     if case.peer_isolation_percent > 0 || case.gossip_latency_blocks > 0 {
    822         return EconomicAttackClass::RequiresNetworkIsolation;
    823     }
    824     EconomicAttackClass::RequiresFinalizerDisruption
    825 }
    826 
    827 fn economic_percent_active(percent: u8, seed: u64, step: usize) -> bool {
    828     percent >= 100 || ((seed as usize + step.saturating_mul(37)) % 100) < usize::from(percent)
    829 }
    830 
    831 fn lineage_resource_roots(lineage_percent: u8) -> (usize, usize) {
    832     let attacker_roots = (usize::from(lineage_percent) * LINEAGE_RESOURCE_ROOTS).div_ceil(100);
    833     let attacker_roots = attacker_roots.clamp(1, LINEAGE_RESOURCE_ROOTS);
    834     (attacker_roots, LINEAGE_RESOURCE_ROOTS - attacker_roots)
    835 }
    836 
    837 fn rehash(block: &mut Block) {
    838     block.reward = block_reward(&block.transactions, 0).unwrap();
    839     block.hash = block.compute_hash();
    840 }
    841 
    842 fn assert_rejects(mut ledger: Ledger, block: Block, label: &str) {
    843     assert!(
    844         ledger.apply_block_at(block, NOW_MS).is_err(),
    845         "{label} was accepted by the consensus validator"
    846     );
    847 }
    848 
    849 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    850 enum MiniBlockVerdict {
    851     Accept,
    852     Height,
    853     Parent,
    854     Hash,
    855     Reward,
    856     VdfRounds,
    857     Timestamp,
    858     FutureTimestamp,
    859     TooManyTransactions,
    860     TooLarge,
    861     MissingBurn,
    862     FeePolicy,
    863     BurnBundleSection,
    864     FinalizerTicket,
    865     RecoveryRules,
    866 }
    867 
    868 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    869 enum MiniSupplyVerdict {
    870     Balanced,
    871     Mismatch,
    872 }
    873 
    874 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
    875 struct MiniTicket {
    876     id: String,
    877     owner: String,
    878     amount: Amount,
    879     eligible_from_height: u64,
    880     eligible_until_height: u64,
    881 }
    882 
    883 #[derive(Clone, Debug, Default)]
    884 struct MiniLineageState {
    885     utxos: BTreeMap<OutPoint, TxOutput>,
    886     utxo_lineage: BTreeMap<OutPoint, UtxoLineageRoot>,
    887     lineage_values: BTreeMap<UtxoLineageRoot, Amount>,
    888     lineage_owners: BTreeMap<UtxoLineageRoot, BTreeMap<String, BTreeMap<OutPoint, Amount>>>,
    889 }
    890 
    891 #[derive(Clone, Debug, Eq, PartialEq)]
    892 struct MiniLineageCandidate {
    893     root: UtxoLineageRoot,
    894     value: Amount,
    895     weight: u64,
    896     owner: String,
    897 }
    898 
    899 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
    900 struct MiniLeaderScore {
    901     finalizer_mode_rank: u8,
    902     finalizer_rank: u32,
    903     proof_rank: String,
    904 }
    905 
    906 impl MiniTicket {
    907     fn from_ticket(ticket: &BurnTicket) -> Self {
    908         Self {
    909             id: ticket.id.clone(),
    910             owner: ticket.owner.clone(),
    911             amount: ticket.amount,
    912             eligible_from_height: ticket.eligible_from_height,
    913             eligible_until_height: ticket.eligible_until_height,
    914         }
    915     }
    916 
    917     fn is_eligible_for_height(&self, height: u64) -> bool {
    918         self.eligible_from_height <= height && height <= self.eligible_until_height
    919     }
    920 }
    921 
    922 fn mini_block_verdict(ledger: &Ledger, block: &Block, now_ms: u64) -> MiniBlockVerdict {
    923     let parent = ledger.tip();
    924     if block.height != parent.height.saturating_add(1) {
    925         return MiniBlockVerdict::Height;
    926     }
    927     if block.prev_hash != parent.hash {
    928         return MiniBlockVerdict::Parent;
    929     }
    930     if block.compute_hash() != block.hash {
    931         return MiniBlockVerdict::Hash;
    932     }
    933 
    934     let expected_reward = block
    935         .transactions
    936         .iter()
    937         .try_fold(0_u64, |total, transaction| {
    938             total.checked_add(transaction.fee())
    939         });
    940     if expected_reward != Some(block.reward) {
    941         return MiniBlockVerdict::Reward;
    942     }
    943 
    944     let expected_vdf_rounds = match block.finalizer_mode {
    945         FinalizerMode::Ticket => {
    946             super::ticket::vdf_rounds_for_finalizer_rank(ledger.vdf_rounds, block.finalizer_rank)
    947         }
    948         FinalizerMode::Recovery => {
    949             super::ticket::vdf_rounds_for_finalizer_rank(ledger.vdf_rounds, 0)
    950         }
    951     };
    952     if expected_vdf_rounds.ok() != Some(block.vdf_rounds) {
    953         return MiniBlockVerdict::VdfRounds;
    954     }
    955 
    956     if block.timestamp_ms <= parent.timestamp_ms {
    957         return MiniBlockVerdict::Timestamp;
    958     }
    959     if block.finalizer_mode == FinalizerMode::Ticket {
    960         let Ok(min_timestamp) = ticket_block_min_timestamp(parent, block.finalizer_rank) else {
    961             return MiniBlockVerdict::Timestamp;
    962         };
    963         if block.timestamp_ms < min_timestamp {
    964             return MiniBlockVerdict::Timestamp;
    965         }
    966     }
    967     let mut timestamps = ledger
    968         .chain
    969         .iter()
    970         .rev()
    971         .take(super::BLOCK_MEDIAN_TIME_PAST_WINDOW)
    972         .map(|block| block.timestamp_ms)
    973         .collect::<Vec<_>>();
    974     timestamps.sort_unstable();
    975     if block.timestamp_ms <= timestamps[timestamps.len() / 2] {
    976         return MiniBlockVerdict::Timestamp;
    977     }
    978     if block.timestamp_ms > now_ms.saturating_add(super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS) {
    979         return MiniBlockVerdict::FutureTimestamp;
    980     }
    981 
    982     if block.transactions.len() > ledger.launch_profile.max_block_transactions {
    983         return MiniBlockVerdict::TooManyTransactions;
    984     }
    985     if ledger
    986         .consensus_block_size_bytes(block)
    987         .ok()
    988         .is_none_or(|bytes| bytes > ledger.launch_profile.max_block_bytes)
    989     {
    990         return MiniBlockVerdict::TooLarge;
    991     }
    992     if !block.transactions.iter().any(Transaction::is_burn) {
    993         return MiniBlockVerdict::MissingBurn;
    994     }
    995     if block
    996         .transactions
    997         .iter()
    998         .any(|transaction| transaction.fee() == 0)
    999     {
   1000         return MiniBlockVerdict::FeePolicy;
   1001     }
   1002     if mini_validate_burn_bundle_section(ledger, block).is_none() {
   1003         return MiniBlockVerdict::BurnBundleSection;
   1004     }
   1005 
   1006     if block.finalizer_mode == FinalizerMode::Ticket {
   1007         let Ok(ranks) = ledger.burn_leader_ranks_for_block(block.height) else {
   1008             return MiniBlockVerdict::FinalizerTicket;
   1009         };
   1010         let Some(selected) = ranks.get(block.finalizer_rank as usize) else {
   1011             return MiniBlockVerdict::FinalizerTicket;
   1012         };
   1013         let Some(proof) = block.leader_proof.as_ref() else {
   1014             return MiniBlockVerdict::FinalizerTicket;
   1015         };
   1016         if selected.rank != block.finalizer_rank
   1017             || selected.owner != block.miner
   1018             || proof.ticket_id != selected.ticket_id
   1019             || proof.public_key != block.miner
   1020             || selected.eligible_from_height > block.height
   1021             || selected.eligible_until_height < block.height
   1022         {
   1023             return MiniBlockVerdict::FinalizerTicket;
   1024         }
   1025         let payload = LeaderProofPayload {
   1026             height: block.height,
   1027             prev_hash: block.prev_hash.clone(),
   1028             finalizer_rank: block.finalizer_rank,
   1029             vdf_output: block.vdf_output.clone(),
   1030             ticket_id: selected.ticket_id.clone(),
   1031             ticket_amount: selected.amount,
   1032             ticket_owner: selected.owner.clone(),
   1033         };
   1034         if verify_address_signature(
   1035             &proof.public_key,
   1036             &payload.canonical(),
   1037             &proof.signature,
   1038             "mini-validator leader",
   1039         )
   1040         .is_err()
   1041         {
   1042             return MiniBlockVerdict::FinalizerTicket;
   1043         }
   1044     } else {
   1045         if block.finalizer_rank != 0 || block.leader_proof.is_some() {
   1046             return MiniBlockVerdict::RecoveryRules;
   1047         }
   1048         if block.timestamp_ms
   1049             < parent
   1050                 .timestamp_ms
   1051                 .saturating_add(super::RECOVERY_BLOCK_DELAY_MS)
   1052         {
   1053             return MiniBlockVerdict::RecoveryRules;
   1054         }
   1055         if !block
   1056             .transactions
   1057             .iter()
   1058             .any(|transaction| transaction.is_burn() && transaction.sender() == block.miner)
   1059         {
   1060             return MiniBlockVerdict::RecoveryRules;
   1061         }
   1062     }
   1063 
   1064     MiniBlockVerdict::Accept
   1065 }
   1066 
   1067 fn consensus_block_verdict(mut ledger: Ledger, block: Block, now_ms: u64) -> MiniBlockVerdict {
   1068     let error = match ledger.apply_block_at(block, now_ms) {
   1069         Ok(()) => return MiniBlockVerdict::Accept,
   1070         Err(error) => error.to_string(),
   1071     };
   1072 
   1073     if error.contains("expected block height") || error.contains("conflicts with local chain") {
   1074         MiniBlockVerdict::Height
   1075     } else if error.contains("does not extend local tip") {
   1076         MiniBlockVerdict::Parent
   1077     } else if error.contains("block hash is invalid") {
   1078         MiniBlockVerdict::Hash
   1079     } else if error.contains("block reward is invalid") {
   1080         MiniBlockVerdict::Reward
   1081     } else if error.contains("block VDF rounds are invalid") {
   1082         MiniBlockVerdict::VdfRounds
   1083     } else if error.contains("timestamp must") || error.contains("before finalizer rank") {
   1084         MiniBlockVerdict::Timestamp
   1085     } else if error.contains("too far in the future") {
   1086         MiniBlockVerdict::FutureTimestamp
   1087     } else if error.contains("too many transaction") {
   1088         MiniBlockVerdict::TooManyTransactions
   1089     } else if error.contains("max block size") {
   1090         MiniBlockVerdict::TooLarge
   1091     } else if error.contains("at least one burn transaction") {
   1092         MiniBlockVerdict::MissingBurn
   1093     } else if error.contains("fee must be greater than zero") {
   1094         MiniBlockVerdict::FeePolicy
   1095     } else if error.contains("burn bundle") || error.contains("attested burn") {
   1096         MiniBlockVerdict::BurnBundleSection
   1097     } else if error.contains("recovery block") {
   1098         MiniBlockVerdict::RecoveryRules
   1099     } else if error.contains("selected for rank")
   1100         || error.contains("selected ticket")
   1101         || error.contains("leader proof")
   1102         || error.contains("leader ticket")
   1103         || error.contains("leader signature")
   1104     {
   1105         MiniBlockVerdict::FinalizerTicket
   1106     } else {
   1107         panic!("unclassified consensus error: {error}");
   1108     }
   1109 }
   1110 
   1111 fn assert_mini_validator_agrees(
   1112     ledger: &Ledger,
   1113     block: Block,
   1114     now_ms: u64,
   1115     expected: MiniBlockVerdict,
   1116 ) {
   1117     let mini = mini_block_verdict(ledger, &block, now_ms);
   1118     assert_eq!(mini, expected, "mini-validator disagreed with test setup");
   1119 
   1120     let consensus = consensus_block_verdict(ledger.clone(), block, now_ms);
   1121     assert_eq!(
   1122         consensus, mini,
   1123         "production validator and mini-validator diverged"
   1124     );
   1125 }
   1126 
   1127 fn mini_expected_supply(snapshot: &ChainSnapshot) -> Option<Amount> {
   1128     let mut supply = snapshot
   1129         .genesis_allocations
   1130         .values()
   1131         .try_fold(0_u64, |total, amount| total.checked_add(*amount))?;
   1132 
   1133     for block in &snapshot.blocks {
   1134         supply = supply.checked_add(block.reward)?;
   1135         for transaction in &block.transactions {
   1136             match transaction {
   1137                 Transaction::Transfer { fee, .. } => {
   1138                     supply = supply.checked_sub(*fee)?;
   1139                 }
   1140                 Transaction::Burn { amount, fee, .. } => {
   1141                     supply = supply.checked_sub(amount.checked_add(*fee)?)?;
   1142                 }
   1143                 Transaction::Mine { .. } => {
   1144                     supply = supply.checked_add(transaction.amount())?;
   1145                 }
   1146             }
   1147         }
   1148     }
   1149 
   1150     Some(supply)
   1151 }
   1152 
   1153 fn mini_supply_verdict(ledger: &Ledger, snapshot: &ChainSnapshot) -> MiniSupplyVerdict {
   1154     if mini_expected_supply(snapshot) == Some(live_supply(ledger)) {
   1155         MiniSupplyVerdict::Balanced
   1156     } else {
   1157         MiniSupplyVerdict::Mismatch
   1158     }
   1159 }
   1160 
   1161 fn mini_ticket_inventory(snapshot: &ChainSnapshot) -> Option<Vec<MiniTicket>> {
   1162     let genesis = snapshot.blocks.first()?;
   1163     let mut tickets = mini_genesis_tickets(&snapshot.genesis_allocations, genesis, snapshot)?;
   1164 
   1165     for pair in snapshot.blocks.windows(2) {
   1166         let [parent, block] = pair else {
   1167             unreachable!("windows(2) yields two blocks");
   1168         };
   1169         mini_apply_ticket_block(parent, block, snapshot, &mut tickets)?;
   1170     }
   1171 
   1172     Some(tickets)
   1173 }
   1174 
   1175 fn mini_genesis_tickets(
   1176     genesis_allocations: &BTreeMap<String, Amount>,
   1177     genesis: &Block,
   1178     snapshot: &ChainSnapshot,
   1179 ) -> Option<Vec<MiniTicket>> {
   1180     if snapshot.launch_profile.ticket_maturity_delay_heights == 0 {
   1181         return mini_tickets_created_by_transactions(
   1182             genesis.height,
   1183             &genesis.transactions,
   1184             snapshot,
   1185         );
   1186     }
   1187 
   1188     let burn_sources = genesis
   1189         .transactions
   1190         .iter()
   1191         .filter_map(|transaction| {
   1192             let Transaction::Burn {
   1193                 inputs,
   1194                 amount,
   1195                 signature,
   1196                 ..
   1197             } = transaction
   1198             else {
   1199                 return None;
   1200             };
   1201             let owner = inputs.first()?.owner.clone();
   1202             (*amount > 0).then(|| (owner, *amount, signature.clone()))
   1203         })
   1204         .collect::<Vec<_>>();
   1205 
   1206     if !burn_sources.is_empty() {
   1207         return mini_genesis_bootstrap_tickets(burn_sources, snapshot, genesis);
   1208     }
   1209 
   1210     let (owner, amount) = genesis_allocations
   1211         .iter()
   1212         .rev()
   1213         .find(|(_, amount)| **amount > 0)?;
   1214     let source_id = hex_hash(format!(
   1215         "iuna-genesis-ticket:{owner}:{amount}:{}",
   1216         genesis.hash
   1217     ));
   1218     mini_genesis_bootstrap_tickets(vec![(owner.clone(), 1, source_id)], snapshot, genesis)
   1219 }
   1220 
   1221 fn mini_genesis_bootstrap_tickets(
   1222     sources: Vec<(String, Amount, String)>,
   1223     snapshot: &ChainSnapshot,
   1224     genesis: &Block,
   1225 ) -> Option<Vec<MiniTicket>> {
   1226     let mut tickets = Vec::new();
   1227     for height in 1..=snapshot.launch_profile.ticket_maturity_delay_heights {
   1228         for (owner, amount, source_id) in &sources {
   1229             tickets.push(MiniTicket {
   1230                 id: hex_hash(format!(
   1231                     "iuna-genesis-bootstrap-ticket:{}:{source_id}:{height}",
   1232                     genesis.hash
   1233                 )),
   1234                 owner: owner.clone(),
   1235                 amount: *amount,
   1236                 eligible_from_height: height,
   1237                 eligible_until_height: height,
   1238             });
   1239         }
   1240     }
   1241     Some(tickets)
   1242 }
   1243 
   1244 fn mini_tickets_created_by_transactions(
   1245     block_height: u64,
   1246     transactions: &[Transaction],
   1247     snapshot: &ChainSnapshot,
   1248 ) -> Option<Vec<MiniTicket>> {
   1249     if snapshot.launch_profile.ticket_expiry_window_heights == 0 {
   1250         return None;
   1251     }
   1252     let mut tickets = Vec::new();
   1253     for transaction in transactions {
   1254         let Transaction::Burn {
   1255             inputs,
   1256             amount,
   1257             signature,
   1258             ..
   1259         } = transaction
   1260         else {
   1261             continue;
   1262         };
   1263         let Some(owner) = inputs.first().map(|input| input.owner.clone()) else {
   1264             continue;
   1265         };
   1266         if *amount == 0 {
   1267             continue;
   1268         }
   1269         let eligible_from_height =
   1270             block_height.checked_add(snapshot.launch_profile.ticket_maturity_delay_heights)?;
   1271         let eligible_until_height = eligible_from_height
   1272             .checked_add(snapshot.launch_profile.ticket_expiry_window_heights - 1)?;
   1273         tickets.push(MiniTicket {
   1274             id: signature.clone(),
   1275             owner,
   1276             amount: *amount,
   1277             eligible_from_height,
   1278             eligible_until_height,
   1279         });
   1280     }
   1281     Some(tickets)
   1282 }
   1283 
   1284 fn mini_apply_ticket_block(
   1285     parent: &Block,
   1286     block: &Block,
   1287     snapshot: &ChainSnapshot,
   1288     tickets: &mut Vec<MiniTicket>,
   1289 ) -> Option<()> {
   1290     match block.finalizer_mode {
   1291         FinalizerMode::Ticket => {
   1292             let proof = block.leader_proof.as_ref()?;
   1293             if !tickets.iter().any(|ticket| {
   1294                 ticket.id == proof.ticket_id && ticket.is_eligible_for_height(block.height)
   1295             }) {
   1296                 return None;
   1297             }
   1298             let invalidated = mini_invalidated_ticket_ids(parent, block, tickets, &proof.ticket_id);
   1299             tickets.retain(|ticket| {
   1300                 !invalidated.contains(&ticket.id) && ticket.eligible_until_height > block.height
   1301             });
   1302         }
   1303         FinalizerMode::Recovery => {
   1304             tickets.retain(|ticket| {
   1305                 !ticket.is_eligible_for_height(block.height)
   1306                     && ticket.eligible_until_height > block.height
   1307             });
   1308         }
   1309     }
   1310     tickets.extend(mini_tickets_created_by_transactions(
   1311         block.height,
   1312         &block.transactions,
   1313         snapshot,
   1314     )?);
   1315     Some(())
   1316 }
   1317 
   1318 fn mini_invalidated_ticket_ids(
   1319     parent: &Block,
   1320     block: &Block,
   1321     tickets: &[MiniTicket],
   1322     leader_ticket_id: &str,
   1323 ) -> BTreeSet<String> {
   1324     let ranked_tickets = mini_ranked_tickets_for_height(parent, block.height, tickets);
   1325     let Some(finalizer_index) = ranked_tickets
   1326         .iter()
   1327         .position(|ticket| ticket.id == leader_ticket_id)
   1328     else {
   1329         return [leader_ticket_id.to_string()].into();
   1330     };
   1331     if block.height < MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT || finalizer_index == 0 {
   1332         return [leader_ticket_id.to_string()].into();
   1333     }
   1334 
   1335     let missed_and_finalizer_owners = ranked_tickets
   1336         .iter()
   1337         .take(finalizer_index + 1)
   1338         .map(|ticket| ticket.owner.clone())
   1339         .collect::<BTreeSet<_>>();
   1340 
   1341     tickets
   1342         .iter()
   1343         .filter(|ticket| {
   1344             ticket.is_eligible_for_height(block.height)
   1345                 && missed_and_finalizer_owners.contains(&ticket.owner)
   1346         })
   1347         .map(|ticket| ticket.id.clone())
   1348         .collect()
   1349 }
   1350 
   1351 fn mini_ranked_tickets_for_height(
   1352     parent: &Block,
   1353     target_height: u64,
   1354     tickets: &[MiniTicket],
   1355 ) -> Vec<MiniTicket> {
   1356     let mut remaining = tickets
   1357         .iter()
   1358         .filter(|ticket| ticket.is_eligible_for_height(target_height))
   1359         .cloned()
   1360         .collect::<Vec<_>>();
   1361     let mut ranked = Vec::with_capacity(remaining.len());
   1362 
   1363     for rank in 0.. {
   1364         let Some(selected_index) =
   1365             mini_select_weighted_ticket_index(parent, target_height, rank, &remaining)
   1366         else {
   1367             break;
   1368         };
   1369         ranked.push(remaining.remove(selected_index));
   1370     }
   1371 
   1372     ranked
   1373 }
   1374 
   1375 fn mini_select_weighted_ticket_index(
   1376     parent: &Block,
   1377     target_height: u64,
   1378     rank: u32,
   1379     tickets: &[MiniTicket],
   1380 ) -> Option<usize> {
   1381     let total_weight = tickets.iter().try_fold(0_u128, |total, ticket| {
   1382         total.checked_add(u128::from(ticket.amount))
   1383     })?;
   1384     if total_weight == 0 {
   1385         return None;
   1386     }
   1387     let draw = mini_weighted_ticket_draw(parent, target_height, rank, total_weight);
   1388     let mut cumulative = 0_u128;
   1389     for (index, ticket) in tickets.iter().enumerate() {
   1390         cumulative = cumulative.checked_add(u128::from(ticket.amount))?;
   1391         if draw < cumulative {
   1392             return Some(index);
   1393         }
   1394     }
   1395     None
   1396 }
   1397 
   1398 fn mini_weighted_ticket_draw(
   1399     parent: &Block,
   1400     target_height: u64,
   1401     rank: u32,
   1402     total_weight: u128,
   1403 ) -> u128 {
   1404     let parent_randomness = if target_height >= GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
   1405         format!("{}:{}", parent.vdf_seed(), parent.vdf_output)
   1406     } else {
   1407         format!("{}:{}", parent.hash, parent.vdf_output)
   1408     };
   1409     let seed = if rank == 0 {
   1410         format!("iuna-ticket-draw:{target_height}:{parent_randomness}")
   1411     } else {
   1412         format!("iuna-ticket-draw-rank:{target_height}:{rank}:{parent_randomness}")
   1413     };
   1414     let digest = Sha256::digest(seed.as_bytes());
   1415     let mut bytes = [0_u8; 16];
   1416     bytes.copy_from_slice(&digest[..16]);
   1417     u128::from_be_bytes(bytes) % total_weight
   1418 }
   1419 
   1420 fn assert_mini_ticket_inventory_matches(ledger: &Ledger) {
   1421     let mut ledger_tickets = ledger
   1422         .tickets
   1423         .iter()
   1424         .map(MiniTicket::from_ticket)
   1425         .collect::<Vec<_>>();
   1426     ledger_tickets.sort();
   1427     let mut mini_tickets = mini_ticket_inventory(&ledger.snapshot())
   1428         .expect("ledger snapshot has valid ticket history");
   1429     mini_tickets.sort();
   1430     assert_eq!(mini_tickets, ledger_tickets);
   1431 }
   1432 
   1433 fn mini_lineage_state(snapshot: &ChainSnapshot) -> Option<MiniLineageState> {
   1434     let mut state = MiniLineageState::default();
   1435     for (address, amount) in snapshot
   1436         .genesis_allocations
   1437         .iter()
   1438         .filter(|(_, amount)| **amount > 0)
   1439     {
   1440         mini_insert_output(
   1441             &mut state,
   1442             genesis_allocation_outpoint(address),
   1443             TxOutput {
   1444                 address: address.clone(),
   1445                 amount: *amount,
   1446             },
   1447             None,
   1448         )?;
   1449     }
   1450 
   1451     let genesis = snapshot.blocks.first()?;
   1452     for transaction in &genesis.transactions {
   1453         mini_apply_transaction(&mut state, transaction, genesis.height, false)?;
   1454     }
   1455     mini_credit_reward(&mut state, genesis, &[])?;
   1456 
   1457     let mut tickets = mini_genesis_tickets(&snapshot.genesis_allocations, genesis, snapshot)?;
   1458     let mut parent = genesis;
   1459     for block in snapshot.blocks.iter().skip(1) {
   1460         let committee = mini_burn_committee_for_block(parent, block, &tickets, &state);
   1461         for transaction in &block.transactions {
   1462             mini_apply_transaction(&mut state, transaction, block.height, true)?;
   1463         }
   1464         mini_credit_reward(&mut state, block, &committee)?;
   1465         mini_apply_ticket_block(parent, block, snapshot, &mut tickets)?;
   1466         parent = block;
   1467     }
   1468 
   1469     Some(state)
   1470 }
   1471 
   1472 fn mini_apply_transaction(
   1473     state: &mut MiniLineageState,
   1474     transaction: &Transaction,
   1475     block_height: u64,
   1476     track_mine_lineage: bool,
   1477 ) -> Option<()> {
   1478     if let Transaction::Mine { recipient, .. } = transaction {
   1479         let outpoint = OutPoint {
   1480             txid: transaction.signature().to_string(),
   1481             index: 0,
   1482         };
   1483         let root = track_mine_lineage.then(|| UtxoLineageRoot {
   1484             outpoint: outpoint.clone(),
   1485             height: block_height,
   1486         });
   1487         return mini_insert_output(
   1488             state,
   1489             outpoint,
   1490             TxOutput {
   1491                 address: recipient.clone(),
   1492                 amount: transaction.amount(),
   1493             },
   1494             root,
   1495         );
   1496     }
   1497 
   1498     let mut seen = BTreeSet::new();
   1499     let mut input_total = 0_u64;
   1500     let mut inherited_root = None;
   1501     for input in transaction.inputs() {
   1502         if !seen.insert(input.outpoint.clone()) {
   1503             return None;
   1504         }
   1505         let output = state.utxos.remove(&input.outpoint)?;
   1506         if output.address != input.owner {
   1507             return None;
   1508         }
   1509         input_total = input_total.checked_add(output.amount)?;
   1510         if let Some(root) = state.utxo_lineage.remove(&input.outpoint) {
   1511             mini_subtract_lineage(
   1512                 state,
   1513                 &root,
   1514                 &output.address,
   1515                 &input.outpoint,
   1516                 output.amount,
   1517             )?;
   1518             inherited_root = mini_newest_lineage_root(inherited_root, Some(root));
   1519         }
   1520     }
   1521 
   1522     let outputs = transaction.outputs();
   1523     let output_total = outputs
   1524         .iter()
   1525         .try_fold(0_u64, |total, output| total.checked_add(output.amount))?;
   1526     let burn_amount = match transaction {
   1527         Transaction::Burn { amount, .. } => *amount,
   1528         Transaction::Transfer { .. } => 0,
   1529         Transaction::Mine { .. } => unreachable!("mine transactions returned above"),
   1530     };
   1531     let required = output_total
   1532         .checked_add(transaction.fee())?
   1533         .checked_add(burn_amount)?;
   1534     if input_total != required {
   1535         return None;
   1536     }
   1537 
   1538     for (index, output) in outputs.into_iter().enumerate() {
   1539         mini_insert_output(
   1540             state,
   1541             OutPoint {
   1542                 txid: transaction.signature().to_string(),
   1543                 index: index as u32,
   1544             },
   1545             output,
   1546             inherited_root.clone(),
   1547         )?;
   1548     }
   1549     Some(())
   1550 }
   1551 
   1552 fn mini_insert_output(
   1553     state: &mut MiniLineageState,
   1554     outpoint: OutPoint,
   1555     output: TxOutput,
   1556     root: Option<UtxoLineageRoot>,
   1557 ) -> Option<()> {
   1558     if state
   1559         .utxos
   1560         .insert(outpoint.clone(), output.clone())
   1561         .is_some()
   1562     {
   1563         return None;
   1564     }
   1565     if let Some(root) = root {
   1566         state.utxo_lineage.insert(outpoint.clone(), root.clone());
   1567         let value = state.lineage_values.entry(root.clone()).or_insert(0);
   1568         *value = value.checked_add(output.amount)?;
   1569         state
   1570             .lineage_owners
   1571             .entry(root)
   1572             .or_default()
   1573             .entry(output.address)
   1574             .or_default()
   1575             .insert(outpoint, output.amount);
   1576     }
   1577     Some(())
   1578 }
   1579 
   1580 fn mini_credit_reward(
   1581     state: &mut MiniLineageState,
   1582     block: &Block,
   1583     committee: &[BurnCommitteeMember],
   1584 ) -> Option<()> {
   1585     for (outpoint, output) in reward_outputs_for_block(block, committee) {
   1586         mini_insert_output(state, outpoint, output, None)?;
   1587     }
   1588     Some(())
   1589 }
   1590 
   1591 fn mini_subtract_lineage(
   1592     state: &mut MiniLineageState,
   1593     root: &UtxoLineageRoot,
   1594     owner: &str,
   1595     outpoint: &OutPoint,
   1596     amount: Amount,
   1597 ) -> Option<()> {
   1598     let value = state.lineage_values.get_mut(root)?;
   1599     *value = value.checked_sub(amount)?;
   1600     if *value == 0 {
   1601         state.lineage_values.remove(root);
   1602     }
   1603 
   1604     let owners = state.lineage_owners.get_mut(root)?;
   1605     let outputs = owners.get_mut(owner)?;
   1606     outputs.remove(outpoint)?;
   1607     if outputs.is_empty() {
   1608         owners.remove(owner);
   1609     }
   1610     if owners.is_empty() {
   1611         state.lineage_owners.remove(root);
   1612     }
   1613     Some(())
   1614 }
   1615 
   1616 fn mini_newest_lineage_root(
   1617     left: Option<UtxoLineageRoot>,
   1618     right: Option<UtxoLineageRoot>,
   1619 ) -> Option<UtxoLineageRoot> {
   1620     match (left, right) {
   1621         (None, None) => None,
   1622         (Some(root), None) | (None, Some(root)) => Some(root),
   1623         (Some(left), Some(right)) => {
   1624             if (right.height, &right.outpoint) > (left.height, &left.outpoint) {
   1625                 Some(right)
   1626             } else {
   1627                 Some(left)
   1628             }
   1629         }
   1630     }
   1631 }
   1632 
   1633 fn mini_lineage_committee_weight(value: Amount) -> u64 {
   1634     let one_plus_value = u128::from(value) + 1;
   1635     u128::BITS as u64 - one_plus_value.leading_zeros() as u64 - 1
   1636 }
   1637 
   1638 fn mini_burn_committee_for_next_block(ledger: &Ledger) -> Option<Vec<BurnCommitteeMember>> {
   1639     let snapshot = ledger.snapshot();
   1640     let parent = snapshot.blocks.last()?;
   1641     let tickets = mini_ticket_inventory(&snapshot)?;
   1642     let state = mini_lineage_state(&snapshot)?;
   1643     Some(mini_burn_committee_for_height(
   1644         parent,
   1645         parent.height.checked_add(1)?,
   1646         &tickets,
   1647         &state,
   1648         0,
   1649     ))
   1650 }
   1651 
   1652 fn mini_burn_committee_for_block(
   1653     parent: &Block,
   1654     block: &Block,
   1655     tickets: &[MiniTicket],
   1656     state: &MiniLineageState,
   1657 ) -> Vec<BurnCommitteeMember> {
   1658     match block.finalizer_mode {
   1659         FinalizerMode::Ticket => mini_burn_committee_for_height(
   1660             parent,
   1661             block.height,
   1662             tickets,
   1663             state,
   1664             block.finalizer_rank,
   1665         ),
   1666         FinalizerMode::Recovery => vec![BurnCommitteeMember {
   1667             slot: 0,
   1668             root: block.hash.clone(),
   1669             owner: block.miner.clone(),
   1670             weight: 0,
   1671         }],
   1672     }
   1673 }
   1674 
   1675 fn mini_burn_committee_for_height(
   1676     parent: &Block,
   1677     height: u64,
   1678     tickets: &[MiniTicket],
   1679     state: &MiniLineageState,
   1680     finalizer_rank: u32,
   1681 ) -> Vec<BurnCommitteeMember> {
   1682     let ranked = mini_ranked_tickets_for_height(parent, height, tickets);
   1683     let Some(finalizer) = ranked.get(finalizer_rank as usize) else {
   1684         return Vec::new();
   1685     };
   1686     let mut committee = vec![BurnCommitteeMember {
   1687         slot: 0,
   1688         root: finalizer.id.clone(),
   1689         owner: finalizer.owner.clone(),
   1690         weight: finalizer.amount,
   1691     }];
   1692     let max_committee_size = BURN_COMMITTEE_SIZE;
   1693     let eligible_ticket_owners = ranked
   1694         .iter()
   1695         .map(|ticket| ticket.owner.clone())
   1696         .collect::<BTreeSet<_>>();
   1697     let mut skipped_owners = ranked
   1698         .iter()
   1699         .take(finalizer_rank as usize)
   1700         .map(|ticket| ticket.owner.clone())
   1701         .collect::<BTreeSet<_>>();
   1702     skipped_owners.insert(finalizer.owner.clone());
   1703     let mut remaining = mini_eligible_lineage_candidates(parent, state, &skipped_owners)
   1704         .into_iter()
   1705         .filter_map(|candidate| {
   1706             let owner = mini_representative_owner_for_lineage_root(
   1707                 state,
   1708                 &candidate.root,
   1709                 &skipped_owners,
   1710                 &eligible_ticket_owners,
   1711             )?;
   1712             Some(MiniLineageCandidate { owner, ..candidate })
   1713         })
   1714         .collect::<Vec<_>>();
   1715 
   1716     for slot in 1..max_committee_size {
   1717         let Some(index) =
   1718             mini_select_weighted_lineage_index(parent, height, slot as u8, &remaining)
   1719         else {
   1720             break;
   1721         };
   1722         let selected = remaining.remove(index);
   1723         skipped_owners.insert(selected.owner.clone());
   1724         committee.push(BurnCommitteeMember {
   1725             slot: slot as u8,
   1726             root: outpoint_id(&selected.root.outpoint),
   1727             owner: selected.owner,
   1728             weight: selected.value,
   1729         });
   1730         remaining.retain(|candidate| {
   1731             candidate.root != selected.root
   1732                 && mini_representative_owner_for_lineage_root(
   1733                     state,
   1734                     &candidate.root,
   1735                     &skipped_owners,
   1736                     &eligible_ticket_owners,
   1737                 )
   1738                 .is_some()
   1739         });
   1740         for candidate in &mut remaining {
   1741             candidate.owner = mini_representative_owner_for_lineage_root(
   1742                 state,
   1743                 &candidate.root,
   1744                 &skipped_owners,
   1745                 &eligible_ticket_owners,
   1746             )
   1747             .expect("retained mini lineage candidate has representative owner");
   1748         }
   1749     }
   1750 
   1751     committee
   1752 }
   1753 
   1754 fn mini_eligible_lineage_candidates(
   1755     parent: &Block,
   1756     state: &MiniLineageState,
   1757     skipped_owners: &BTreeSet<String>,
   1758 ) -> Vec<MiniLineageCandidate> {
   1759     state
   1760         .lineage_values
   1761         .iter()
   1762         .filter(|(root, value)| {
   1763             **value > 0
   1764                 && root.height.saturating_add(BURN_LINEAGE_MATURITY_HEIGHTS) <= parent.height
   1765                 && !skipped_owners
   1766                     .iter()
   1767                     .any(|owner| mini_lineage_root_has_owner(state, root, owner))
   1768         })
   1769         .filter_map(|(root, value)| {
   1770             let weight = mini_lineage_committee_weight(*value);
   1771             (weight > 0).then(|| MiniLineageCandidate {
   1772                 root: root.clone(),
   1773                 value: *value,
   1774                 weight,
   1775                 owner: String::new(),
   1776             })
   1777         })
   1778         .collect()
   1779 }
   1780 
   1781 fn mini_lineage_root_has_owner(
   1782     state: &MiniLineageState,
   1783     root: &UtxoLineageRoot,
   1784     owner: &str,
   1785 ) -> bool {
   1786     state
   1787         .lineage_owners
   1788         .get(root)
   1789         .and_then(|owners| owners.get(owner))
   1790         .is_some_and(|outputs| !outputs.is_empty())
   1791 }
   1792 
   1793 fn mini_representative_owner_for_lineage_root(
   1794     state: &MiniLineageState,
   1795     root: &UtxoLineageRoot,
   1796     skipped_owners: &BTreeSet<String>,
   1797     eligible_ticket_owners: &BTreeSet<String>,
   1798 ) -> Option<String> {
   1799     state.lineage_owners.get(root).and_then(|owners| {
   1800         owners
   1801             .iter()
   1802             .filter(|(owner, outputs)| {
   1803                 eligible_ticket_owners.contains(*owner)
   1804                     && !skipped_owners.contains(*owner)
   1805                     && !outputs.is_empty()
   1806             })
   1807             .filter_map(|(owner, outputs)| {
   1808                 let (outpoint, amount) = outputs
   1809                     .iter()
   1810                     .max_by(|left, right| left.1.cmp(right.1).then_with(|| right.0.cmp(left.0)))?;
   1811                 Some((owner.clone(), *amount, outpoint.clone()))
   1812             })
   1813             .max_by(|left, right| {
   1814                 left.1
   1815                     .cmp(&right.1)
   1816                     .then_with(|| right.2.cmp(&left.2))
   1817                     .then_with(|| right.0.cmp(&left.0))
   1818             })
   1819             .map(|(owner, _, _)| owner)
   1820     })
   1821 }
   1822 
   1823 fn mini_select_weighted_lineage_index(
   1824     parent: &Block,
   1825     target_height: u64,
   1826     slot: u8,
   1827     candidates: &[MiniLineageCandidate],
   1828 ) -> Option<usize> {
   1829     let total_weight = candidates.iter().try_fold(0_u128, |total, candidate| {
   1830         total.checked_add(u128::from(candidate.weight))
   1831     })?;
   1832     if total_weight == 0 {
   1833         return None;
   1834     }
   1835     let parent_randomness = if target_height >= GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
   1836         format!("{}:{}", parent.vdf_seed(), parent.vdf_output)
   1837     } else {
   1838         format!("{}:{}", parent.hash, parent.vdf_output)
   1839     };
   1840     let seed = format!("iuna-burn-lineage-draw-v1:{target_height}:{parent_randomness}:{slot}");
   1841     let digest = Sha256::digest(seed.as_bytes());
   1842     let mut bytes = [0_u8; 16];
   1843     bytes.copy_from_slice(&digest[..16]);
   1844     let draw = u128::from_be_bytes(bytes) % total_weight;
   1845     let mut cumulative = 0_u128;
   1846     for (index, candidate) in candidates.iter().enumerate() {
   1847         cumulative = cumulative.checked_add(u128::from(candidate.weight))?;
   1848         if draw < cumulative {
   1849             return Some(index);
   1850         }
   1851     }
   1852     None
   1853 }
   1854 
   1855 fn outpoint_id(outpoint: &OutPoint) -> String {
   1856     format!("{}:{}", outpoint.txid, outpoint.index)
   1857 }
   1858 
   1859 fn mini_validate_burn_bundle_section(ledger: &Ledger, block: &Block) -> Option<()> {
   1860     let section = &block.burn_bundle_section;
   1861     if section.signatures.len() > BURN_COMMITTEE_SIZE.saturating_sub(1) {
   1862         return None;
   1863     }
   1864     if section
   1865         .signatures
   1866         .windows(2)
   1867         .any(|pair| pair[0].slot >= pair[1].slot)
   1868     {
   1869         return None;
   1870     }
   1871 
   1872     let committee = mini_burn_committee_for_next_block(ledger)?
   1873         .into_iter()
   1874         .map(|member| (member.slot, member))
   1875         .collect::<BTreeMap<_, _>>();
   1876     let mut seen_slots = BTreeSet::new();
   1877     let mut seen_members = BTreeSet::new();
   1878     let mut included_mask = 0_u8;
   1879     for signature in &section.signatures {
   1880         if usize::from(signature.slot) >= BURN_COMMITTEE_SIZE || signature.slot == 0 {
   1881             return None;
   1882         }
   1883         if !seen_slots.insert(signature.slot) || !seen_members.insert(signature.member.clone()) {
   1884             return None;
   1885         }
   1886         let member = committee.get(&signature.slot)?;
   1887         if signature.member != member.owner {
   1888             return None;
   1889         }
   1890         included_mask |= mini_burn_bundle_slot_mask(signature.slot)?;
   1891     }
   1892 
   1893     let required_signatures = mini_required_explicit_burn_signatures(
   1894         block.height,
   1895         block.finalizer_mode,
   1896         block.finalizer_rank,
   1897         committee.len(),
   1898     );
   1899     if section.signatures.len() < required_signatures {
   1900         return None;
   1901     }
   1902 
   1903     let mut seen_burns = BTreeSet::new();
   1904     let mut previous_key: Option<(Amount, String)> = None;
   1905     for masked in &section.burns {
   1906         if masked.bundle_mask & !mini_burn_committee_mask() != 0 {
   1907             return None;
   1908         }
   1909         if masked.bundle_mask & !included_mask != 0 {
   1910             return None;
   1911         }
   1912         if !seen_burns.insert(masked.burn.signature().to_string()) {
   1913             return None;
   1914         }
   1915         if !masked.burn.is_burn() || !mini_matching_burn_by_signature(&masked.burn, block) {
   1916             return None;
   1917         }
   1918         let key = (masked.burn.fee(), masked.burn.signature().to_string());
   1919         if let Some((previous_fee, previous_signature)) = &previous_key {
   1920             if key.0 > *previous_fee || key.0 == *previous_fee && key.1 < *previous_signature {
   1921                 return None;
   1922             }
   1923         }
   1924         previous_key = Some(key);
   1925     }
   1926 
   1927     for signature in &section.signatures {
   1928         let slot_mask = mini_burn_bundle_slot_mask(signature.slot)?;
   1929         let burns = section
   1930             .burns
   1931             .iter()
   1932             .filter(|masked| masked.bundle_mask & slot_mask != 0)
   1933             .map(|masked| masked.burn.clone())
   1934             .collect::<Vec<_>>();
   1935         let burns_v2 = section
   1936             .burns_v2
   1937             .iter()
   1938             .filter(|masked| masked.bundle_mask & slot_mask != 0)
   1939             .map(|masked| masked.envelope.clone())
   1940             .collect::<Vec<_>>();
   1941         let bundle = BurnBundle {
   1942             height: block.height,
   1943             prev_hash: block.prev_hash.clone(),
   1944             slot: signature.slot,
   1945             member: signature.member.clone(),
   1946             reward_address: signature.reward_address.clone(),
   1947             burns: burns.clone(),
   1948             burns_v2: burns_v2.clone(),
   1949             signature: signature.signature.clone(),
   1950         };
   1951         if bundle.serialized_size_bytes().ok()? > MAX_BURN_BUNDLE_BYTES {
   1952             return None;
   1953         }
   1954         let payload = BurnBundlePayload {
   1955             height: block.height,
   1956             prev_hash: block.prev_hash.clone(),
   1957             slot: signature.slot,
   1958             member: signature.member.clone(),
   1959             reward_address: signature.reward_address.clone(),
   1960             burns,
   1961             burns_v2,
   1962         };
   1963         if verify_address_signature(
   1964             &signature.member,
   1965             &payload.canonical(),
   1966             &signature.signature,
   1967             "mini-validator burn bundle",
   1968         )
   1969         .is_err()
   1970         {
   1971             return None;
   1972         }
   1973     }
   1974 
   1975     Some(())
   1976 }
   1977 
   1978 fn mini_required_explicit_burn_signatures(
   1979     height: u64,
   1980     finalizer_mode: FinalizerMode,
   1981     finalizer_rank: u32,
   1982     committee_size: usize,
   1983 ) -> usize {
   1984     if committee_size == 0 {
   1985         return 0;
   1986     }
   1987     match finalizer_mode {
   1988         FinalizerMode::Ticket
   1989             if finalizer_rank == 0 && height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT =>
   1990         {
   1991             committee_size.saturating_mul(2) / 3
   1992         }
   1993         FinalizerMode::Ticket if finalizer_rank == 0 => committee_size.min(3).saturating_sub(1),
   1994         FinalizerMode::Ticket if finalizer_rank == 1 => committee_size.min(2).saturating_sub(1),
   1995         FinalizerMode::Ticket | FinalizerMode::Recovery => 0,
   1996     }
   1997 }
   1998 
   1999 fn mini_matching_burn_by_signature(attested: &Transaction, block: &Block) -> bool {
   2000     block.transactions.iter().any(|transaction| {
   2001         transaction.is_burn()
   2002             && transaction.signature() == attested.signature()
   2003             && transaction.canonical() == attested.canonical()
   2004     })
   2005 }
   2006 
   2007 fn mini_burn_bundle_slot_mask(slot: u8) -> Option<u8> {
   2008     if usize::from(slot) >= BURN_COMMITTEE_SIZE || slot >= 8 {
   2009         return None;
   2010     }
   2011     Some(1_u8 << slot)
   2012 }
   2013 
   2014 fn mini_burn_committee_mask() -> u8 {
   2015     (0..BURN_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot))
   2016 }
   2017 
   2018 fn assert_mini_burn_committee_matches(ledger: &Ledger) {
   2019     let mini_committee =
   2020         mini_burn_committee_for_next_block(ledger).expect("ledger snapshot has valid lineage");
   2021     assert_eq!(mini_committee, ledger.burn_committee_for_next_block());
   2022 }
   2023 
   2024 fn mini_choose_fork(local: &Ledger, candidate: &Ledger) -> Option<bool> {
   2025     if candidate.genesis_hash() != local.genesis_hash() {
   2026         return None;
   2027     }
   2028     let common_ancestor_height = mini_common_ancestor_height(local.chain(), candidate.chain())?;
   2029     if candidate.height() == local.height() && candidate.tip_hash() == local.tip_hash() {
   2030         return Some(false);
   2031     }
   2032 
   2033     let first_diverging_height = common_ancestor_height.saturating_add(1);
   2034     if first_diverging_height < OBJECTIVE_FINALITY_ACTIVATION_HEIGHT {
   2035         let finalized_floor = local.height().saturating_sub(super::FORK_FINALITY_DEPTH);
   2036         if local.height() >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT
   2037             || common_ancestor_height < finalized_floor
   2038         {
   2039             return Some(false);
   2040         }
   2041     } else {
   2042         match (
   2043             local.objective_finality_checkpoint(),
   2044             candidate.objective_finality_checkpoint(),
   2045         ) {
   2046             (None, Some(_)) => return Some(true),
   2047             (Some(_), None) => return Some(false),
   2048             (Some((local_height, local_hash)), Some((remote_height, remote_hash))) => {
   2049                 if remote_height != local_height {
   2050                     return Some(remote_height > local_height);
   2051                 }
   2052                 if remote_hash != local_hash {
   2053                     return Some(remote_hash < local_hash);
   2054                 }
   2055             }
   2056             (None, None) => {}
   2057         }
   2058     }
   2059     if candidate.height() > local.height() {
   2060         return Some(true);
   2061     }
   2062     if candidate.height() < local.height() {
   2063         return Some(false);
   2064     }
   2065 
   2066     Some(mini_remote_fork_is_better(
   2067         local.chain(),
   2068         candidate.chain(),
   2069         common_ancestor_height.saturating_add(1),
   2070     ))
   2071 }
   2072 
   2073 fn mini_common_ancestor_height(local: &[Block], candidate: &[Block]) -> Option<u64> {
   2074     let max_common_index = local.len().min(candidate.len()).checked_sub(1)?;
   2075     for index in 0..=max_common_index {
   2076         if local[index] != candidate[index] {
   2077             return (index > 0).then_some(index as u64 - 1);
   2078         }
   2079     }
   2080     Some(max_common_index as u64)
   2081 }
   2082 
   2083 fn mini_remote_fork_is_better(local: &[Block], candidate: &[Block], first_diverging: u64) -> bool {
   2084     let local_fork = local.iter().skip(first_diverging as usize);
   2085     let remote_fork = candidate.iter().skip(first_diverging as usize);
   2086     for (local_block, remote_block) in local_fork.zip(remote_fork) {
   2087         match mini_leader_score(local_block).cmp(&mini_leader_score(remote_block)) {
   2088             std::cmp::Ordering::Less => return false,
   2089             std::cmp::Ordering::Greater => return true,
   2090             std::cmp::Ordering::Equal => {}
   2091         }
   2092     }
   2093     false
   2094 }
   2095 
   2096 fn mini_leader_score(block: &Block) -> MiniLeaderScore {
   2097     MiniLeaderScore {
   2098         finalizer_mode_rank: match block.finalizer_mode {
   2099             FinalizerMode::Ticket => 0,
   2100             FinalizerMode::Recovery => 1,
   2101         },
   2102         finalizer_rank: block.finalizer_rank,
   2103         proof_rank: block
   2104             .leader_proof
   2105             .as_ref()
   2106             .map(|proof| {
   2107                 hex_hash(format!(
   2108                     "iuna-leader-rank:{}:{}",
   2109                     proof.ticket_id, proof.signature
   2110                 ))
   2111             })
   2112             .unwrap_or_else(|| block.hash.clone()),
   2113     }
   2114 }
   2115 
   2116 fn harness_for_percent(seed: u64, burn_percent: u8) -> Harness {
   2117     Harness::new(seed, burn_percent, 10, AdversaryStrategy::Honest)
   2118 }
   2119 
   2120 fn fork_harness_from(source: &Harness, snapshot: ChainSnapshot) -> Harness {
   2121     Harness {
   2122         ledger: Ledger::from_snapshot_at(snapshot, NOW_MS).unwrap(),
   2123         attacker: source.attacker.clone(),
   2124         honest: source.honest.clone(),
   2125         wallets: source.wallets.clone(),
   2126         seed: source.seed,
   2127         resource: source.resource,
   2128     }
   2129 }
   2130 
   2131 fn live_supply(ledger: &Ledger) -> Amount {
   2132     ledger
   2133         .all_utxos()
   2134         .into_iter()
   2135         .try_fold(0_u64, |total, (_, output)| total.checked_add(output.amount))
   2136         .expect("test supply should not overflow")
   2137 }
   2138 
   2139 fn attacker_reward_from_block(
   2140     block: &Block,
   2141     committee: &[BurnCommitteeMember],
   2142     attacker_addresses: &BTreeSet<String>,
   2143 ) -> Amount {
   2144     reward_outputs_for_block(block, committee)
   2145         .into_iter()
   2146         .filter(|(_, output)| attacker_addresses.contains(&output.address))
   2147         .try_fold(0_u64, |total, (_, output)| total.checked_add(output.amount))
   2148         .expect("test reward should not overflow")
   2149 }
   2150 
   2151 fn expected_supply(snapshot: &ChainSnapshot) -> Amount {
   2152     mini_expected_supply(snapshot).expect("test snapshot supply accounting should not overflow")
   2153 }
   2154 
   2155 fn finish_prepared_block(wallet: &Wallet, block: super::PreparedBlock) -> Block {
   2156     let vdf_output = run_vdf(block.vdf_seed(), block.vdf_rounds());
   2157     block.finish(wallet, vdf_output)
   2158 }
   2159 
   2160 fn assert_supply_invariant(ledger: &Ledger) {
   2161     let snapshot = ledger.snapshot();
   2162     for block in snapshot.blocks.iter().skip(1) {
   2163         assert_eq!(
   2164             block.reward,
   2165             block_reward(&block.transactions, 0).unwrap(),
   2166             "block {} reward does not match fee total",
   2167             block.height
   2168         );
   2169     }
   2170     assert_eq!(live_supply(ledger), expected_supply(&snapshot));
   2171 }
   2172 
   2173 fn mutate_signature(signature: &mut String) {
   2174     let replacement = if signature.starts_with('0') { "1" } else { "0" };
   2175     signature.replace_range(0..1, replacement);
   2176 }
   2177 
   2178 fn committee_roots_are_unique(committee: &[BurnCommitteeMember]) -> bool {
   2179     let mut roots = BTreeSet::new();
   2180     committee
   2181         .iter()
   2182         .filter(|member| member.slot > 0)
   2183         .all(|member| roots.insert(member.root.clone()))
   2184 }
   2185 
   2186 #[test]
   2187 #[ignore = "long-running adversarial reward split coverage; run via deployment.sh"]
   2188 fn rank_zero_reward_is_credited_to_finalizer_and_extra_committee_members() {
   2189     let mut harness = Harness::new(91, 50, 50, AdversaryStrategy::Honest);
   2190     harness.mature_lineages(2, 2);
   2191     harness.ensure_lineage_owners_have_next_height_tickets();
   2192     let committee = harness.ledger.burn_committee_for_next_block();
   2193     assert!(
   2194         committee.len() >= 3,
   2195         "test setup needs multiple eligible ticket-owning committee members"
   2196     );
   2197 
   2198     let finalizer = harness.next_rank(0).owner;
   2199     let committee_owners = committee
   2200         .iter()
   2201         .filter(|member| member.slot > 0)
   2202         .map(|member| member.owner.clone())
   2203         .collect::<Vec<_>>();
   2204     let third_party = harness
   2205         .wallets
   2206         .values()
   2207         .find(|wallet| {
   2208             wallet.address() != finalizer
   2209                 && !committee_owners
   2210                     .iter()
   2211                     .any(|owner| owner == wallet.address())
   2212         })
   2213         .expect("test setup has a third-party burner")
   2214         .clone();
   2215 
   2216     let finalizer_before = harness.ledger.balance_of(&finalizer);
   2217     let committee_before = committee_owners
   2218         .iter()
   2219         .map(|owner| (owner.clone(), harness.ledger.balance_of(owner)))
   2220         .collect::<BTreeMap<_, _>>();
   2221 
   2222     let finalizer_wallet = harness.wallet(&finalizer).clone();
   2223     harness.submit_anchor_burn(&finalizer_wallet);
   2224     harness.submit_fee_burn(&third_party, 1, 99);
   2225     let bundles = harness.committee_bundles();
   2226     let block = harness.finish_ticket_block_from_pending(0, bundles);
   2227     assert_eq!(block.reward, 100);
   2228 
   2229     harness
   2230         .ledger
   2231         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   2232         .unwrap();
   2233 
   2234     assert_eq!(
   2235         harness.ledger.balance_of(&finalizer),
   2236         finalizer_before + 50 - 2
   2237     );
   2238     let committee_reward = committee_owners
   2239         .iter()
   2240         .map(|owner| {
   2241             let credited = harness.ledger.balance_of(owner) - committee_before[owner];
   2242             assert!(credited > 0, "included committee member must be rewarded");
   2243             credited
   2244         })
   2245         .sum::<Amount>();
   2246     assert_eq!(committee_reward, 50);
   2247 }
   2248 
   2249 proptest! {
   2250     #![proptest_config(Config { cases: 32, .. Config::default() })]
   2251 
   2252     #[test]
   2253     #[ignore = "long-running adversarial proptest; run via deployment.sh"]
   2254     fn required_attested_burn_must_be_included(seed in any::<u64>(), burn_idx in 0usize..LEVELS.len()) {
   2255         let mut harness = harness_for_percent(seed, LEVELS[burn_idx]);
   2256         let leader = harness.next_rank(0);
   2257         let finalizer = harness.wallet(&leader.owner).clone();
   2258         harness.submit_anchor_burn(&finalizer);
   2259         let victim = harness
   2260             .honest
   2261             .iter()
   2262             .find(|wallet| wallet.address() != finalizer.address())
   2263             .unwrap()
   2264             .clone();
   2265         let victim_burn = harness.submit_fee_burn(&victim, 1, 1);
   2266         let bundle = finalizer.burn_bundle(BurnBundlePayload {
   2267             height: harness.ledger.height() + 1,
   2268             prev_hash: harness.ledger.tip_hash().to_string(),
   2269             slot: 0,
   2270             member: finalizer.address().to_string(),
   2271             reward_address: None,
   2272             burns: vec![victim_burn.clone()],
   2273             burns_v2: Vec::new(),
   2274         });
   2275         let mut block = harness.finish_ticket_block_from_pending(0, vec![bundle]);
   2276         block.transactions.retain(|tx| tx.signature() != victim_burn.signature());
   2277         rehash(&mut block);
   2278 
   2279         prop_assert!(
   2280             harness.ledger.apply_block_at(block, NOW_MS).is_err(),
   2281             "seed={} burn_percent={} strategy={:?} accepted a block missing attested burn {}",
   2282             seed,
   2283             LEVELS[burn_idx],
   2284             harness.attacker.strategy,
   2285             victim_burn.signature()
   2286         );
   2287     }
   2288 
   2289     #[test]
   2290     #[ignore = "long-running adversarial proptest; run via deployment.sh"]
   2291     fn timing_and_vdf_mutations_are_rejected(seed in any::<u64>(), rank in 1usize..3) {
   2292         let mut harness = harness_for_percent(seed, 25);
   2293         let leader = harness.next_rank(rank);
   2294         let wallet = harness.wallet(&leader.owner).clone();
   2295         let parent = harness.ledger.tip().clone();
   2296         let block = harness.prepared_ticket_block(rank, Vec::new());
   2297 
   2298         let mut early = block.clone();
   2299         early.timestamp_ms = ticket_block_min_timestamp(&parent, rank as u32).unwrap() - 1;
   2300         rehash(&mut early);
   2301         prop_assert!(
   2302             harness.ledger.clone().apply_block_at(early, NOW_MS).is_err(),
   2303             "seed={} rank={} accepted early fallback timestamp for {}",
   2304             seed,
   2305             rank,
   2306             wallet.address()
   2307         );
   2308 
   2309         let mut future = block.clone();
   2310         future.timestamp_ms = NOW_MS + super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS + 1;
   2311         rehash(&mut future);
   2312         prop_assert!(
   2313             harness.ledger.clone().apply_block_at(future, NOW_MS).is_err(),
   2314             "seed={} rank={} accepted future drift timestamp",
   2315             seed,
   2316             rank
   2317         );
   2318     }
   2319 
   2320 }
   2321 
   2322 fn check_committee_selection(seed: u64, lineage_idx: usize) -> TestCaseResult {
   2323     let mut harness = Harness::new(
   2324         seed,
   2325         10,
   2326         LEVELS[lineage_idx],
   2327         AdversaryStrategy::AddressRotation,
   2328     );
   2329     harness.mature_lineages(2, 4);
   2330     let committee = harness.ledger.burn_committee_for_next_block();
   2331     let mini_committee = mini_burn_committee_for_next_block(&harness.ledger)
   2332         .expect("mini committee oracle should replay generated lineage");
   2333     let snapshot_ledger = Ledger::from_snapshot_at(harness.ledger.snapshot(), NOW_MS);
   2334     prop_assert!(
   2335         snapshot_ledger.is_ok(),
   2336         "seed={} snapshot replay failed: {}",
   2337         seed,
   2338         snapshot_ledger.as_ref().err().unwrap()
   2339     );
   2340     let snapshot_committee = snapshot_ledger.unwrap().burn_committee_for_next_block();
   2341 
   2342     prop_assert_eq!(
   2343         committee.clone(),
   2344         snapshot_committee,
   2345         "seed={} committee selection is not deterministic",
   2346         seed
   2347     );
   2348     prop_assert_eq!(
   2349         committee.clone(),
   2350         mini_committee,
   2351         "seed={} mini committee oracle diverged",
   2352         seed
   2353     );
   2354     prop_assert!(
   2355         committee_roots_are_unique(&committee),
   2356         "seed={} selected one lineage more than once: {:?}",
   2357         seed,
   2358         committee
   2359     );
   2360     let non_finalizer_roots = committee.iter().filter(|member| member.slot > 0).count();
   2361     prop_assert!(non_finalizer_roots < super::BURN_COMMITTEE_SIZE);
   2362     Ok(())
   2363 }
   2364 
   2365 fn check_adversarial_state_machine(
   2366     seed: u64,
   2367     strategy_idx: usize,
   2368     level_idx: usize,
   2369     blocks: usize,
   2370 ) -> TestCaseResult {
   2371     let strategy = AdversaryStrategy::from_index(strategy_idx);
   2372     let level = LEVELS[level_idx];
   2373     let mut harness = Harness::new(seed, level, level, strategy);
   2374     let metrics = harness.run_strategy(blocks);
   2375     prop_assert!(metrics.attacker_finalization_share.is_finite());
   2376     prop_assert!(metrics.attacker_committee_share.is_finite());
   2377     match strategy {
   2378         AdversaryStrategy::Honest
   2379         | AdversaryStrategy::MaximizeBurnWeight
   2380         | AdversaryStrategy::MaximizeCommitteeWeight
   2381         | AdversaryStrategy::AddressRotation => {
   2382             prop_assert_eq!(metrics.third_party_burn_censorship_rate, 0.0);
   2383             prop_assert_eq!(metrics.fallback_rate, 0.0);
   2384             prop_assert_eq!(metrics.recovery_rate, 0.0);
   2385         }
   2386         AdversaryStrategy::CensorBurns => {
   2387             prop_assert_eq!(metrics.third_party_burn_censorship_rate, 0.0);
   2388         }
   2389         AdversaryStrategy::WithholdBurnFromCommittee => {
   2390             prop_assert!(metrics.third_party_burn_censorship_rate > 0.0);
   2391         }
   2392         AdversaryStrategy::MissRank0 | AdversaryStrategy::ForceFallback => {
   2393             prop_assert_eq!(metrics.fallback_blocks, metrics.fallback_opportunities);
   2394             prop_assert_eq!(metrics.recovery_rate, 0.0);
   2395         }
   2396         AdversaryStrategy::AttemptRecovery => {
   2397             if blocks >= 7 {
   2398                 prop_assert!(metrics.recovery_rate > 0.0);
   2399             } else {
   2400                 prop_assert_eq!(metrics.recovery_rate, 0.0);
   2401             }
   2402         }
   2403         AdversaryStrategy::CombinedStrategy => {
   2404             prop_assert!(metrics.third_party_burn_censorship_rate > 0.0);
   2405             prop_assert_eq!(metrics.fallback_blocks, metrics.fallback_opportunities);
   2406         }
   2407     }
   2408     Ok(())
   2409 }
   2410 
   2411 macro_rules! adversarial_proptest_shard {
   2412     ($committee_name:ident, $state_machine_name:ident) => {
   2413         proptest! {
   2414             #![proptest_config(Config { cases: 8, .. Config::default() })]
   2415 
   2416             #[test]
   2417             #[ignore = "long-running adversarial proptest; run via deployment.sh"]
   2418             fn $committee_name(seed in any::<u64>(), lineage_idx in 0usize..LEVELS.len()) {
   2419                 check_committee_selection(seed, lineage_idx)?;
   2420             }
   2421 
   2422             #[test]
   2423             #[ignore = "long-running adversarial state-machine proptest; run via deployment.sh"]
   2424             fn $state_machine_name(
   2425                 seed in any::<u64>(),
   2426                 strategy_idx in 0usize..10,
   2427                 level_idx in 0usize..LEVELS.len(),
   2428                 blocks in 3usize..8,
   2429             ) {
   2430                 check_adversarial_state_machine(seed, strategy_idx, level_idx, blocks)?;
   2431             }
   2432         }
   2433     };
   2434 }
   2435 
   2436 adversarial_proptest_shard!(
   2437     committee_selection_is_deterministic_and_sybil_resistant_shard_1,
   2438     adversarial_state_machine_keeps_invalid_paths_out_shard_1
   2439 );
   2440 adversarial_proptest_shard!(
   2441     committee_selection_is_deterministic_and_sybil_resistant_shard_2,
   2442     adversarial_state_machine_keeps_invalid_paths_out_shard_2
   2443 );
   2444 adversarial_proptest_shard!(
   2445     committee_selection_is_deterministic_and_sybil_resistant_shard_3,
   2446     adversarial_state_machine_keeps_invalid_paths_out_shard_3
   2447 );
   2448 adversarial_proptest_shard!(
   2449     committee_selection_is_deterministic_and_sybil_resistant_shard_4,
   2450     adversarial_state_machine_keeps_invalid_paths_out_shard_4
   2451 );
   2452 
   2453 #[test]
   2454 fn attested_burn_is_not_selected_again_as_normal_transaction() {
   2455     let mut harness = harness_for_percent(10, 25);
   2456     let leader = harness.next_rank(0);
   2457     let finalizer = harness.wallet(&leader.owner).clone();
   2458     harness.submit_anchor_burn(&finalizer);
   2459     let victim = harness
   2460         .honest
   2461         .iter()
   2462         .find(|wallet| wallet.address() != finalizer.address())
   2463         .unwrap()
   2464         .clone();
   2465     let attested_burn = harness.submit_fee_burn(&victim, 1, 1);
   2466     let extra_burn = harness.submit_fee_burn(&victim, 1, 1);
   2467     let bundle = finalizer.burn_bundle(BurnBundlePayload {
   2468         height: harness.ledger.height() + 1,
   2469         prev_hash: harness.ledger.tip_hash().to_string(),
   2470         slot: 0,
   2471         member: finalizer.address().to_string(),
   2472         reward_address: None,
   2473         burns: vec![attested_burn.clone()],
   2474         burns_v2: Vec::new(),
   2475     });
   2476 
   2477     let block = harness.finish_ticket_block_from_pending(0, vec![bundle]);
   2478 
   2479     assert_eq!(
   2480         block
   2481             .burn_bundle_section
   2482             .burns
   2483             .iter()
   2484             .filter(|masked| masked.burn.signature() == attested_burn.signature())
   2485             .count(),
   2486         1
   2487     );
   2488     assert_eq!(
   2489         block
   2490             .transactions
   2491             .iter()
   2492             .filter(|tx| tx.signature() == attested_burn.signature())
   2493             .count(),
   2494         1
   2495     );
   2496     assert!(
   2497         block
   2498             .transactions
   2499             .iter()
   2500             .any(|tx| tx.signature() == extra_burn.signature())
   2501     );
   2502 }
   2503 
   2504 #[test]
   2505 fn independent_mini_validator_matches_consensus_for_block_prechecks() {
   2506     let mut harness = harness_for_percent(30, 25);
   2507     let block = harness.prepared_ticket_block(0, Vec::new());
   2508     let ledger = harness.ledger.clone();
   2509     let now_ms = NOW_MS.saturating_add(block.timestamp_ms);
   2510 
   2511     assert_mini_validator_agrees(&ledger, block.clone(), now_ms, MiniBlockVerdict::Accept);
   2512 
   2513     let mut wrong_height = block.clone();
   2514     wrong_height.height += 1;
   2515     rehash(&mut wrong_height);
   2516     assert_mini_validator_agrees(&ledger, wrong_height, now_ms, MiniBlockVerdict::Height);
   2517 
   2518     let mut wrong_parent = block.clone();
   2519     wrong_parent.prev_hash = if ledger.tip_hash() == "0".repeat(64) {
   2520         "1".repeat(64)
   2521     } else {
   2522         "0".repeat(64)
   2523     };
   2524     rehash(&mut wrong_parent);
   2525     assert_mini_validator_agrees(&ledger, wrong_parent, now_ms, MiniBlockVerdict::Parent);
   2526 
   2527     let mut wrong_hash = block.clone();
   2528     mutate_signature(&mut wrong_hash.hash);
   2529     assert_mini_validator_agrees(&ledger, wrong_hash, now_ms, MiniBlockVerdict::Hash);
   2530 
   2531     let mut wrong_reward = block.clone();
   2532     wrong_reward.reward += 1;
   2533     wrong_reward.hash = wrong_reward.compute_hash();
   2534     assert_mini_validator_agrees(&ledger, wrong_reward, now_ms, MiniBlockVerdict::Reward);
   2535 
   2536     let mut wrong_vdf_rounds = block.clone();
   2537     wrong_vdf_rounds.vdf_rounds += 1;
   2538     wrong_vdf_rounds.hash = wrong_vdf_rounds.compute_hash();
   2539     assert_mini_validator_agrees(
   2540         &ledger,
   2541         wrong_vdf_rounds,
   2542         now_ms,
   2543         MiniBlockVerdict::VdfRounds,
   2544     );
   2545 
   2546     let mut early_timestamp = block.clone();
   2547     early_timestamp.timestamp_ms = ledger.tip().timestamp_ms;
   2548     rehash(&mut early_timestamp);
   2549     assert_mini_validator_agrees(
   2550         &ledger,
   2551         early_timestamp,
   2552         now_ms,
   2553         MiniBlockVerdict::Timestamp,
   2554     );
   2555 
   2556     let mut future_timestamp = block.clone();
   2557     future_timestamp.timestamp_ms = NOW_MS + super::MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS + 1;
   2558     rehash(&mut future_timestamp);
   2559     assert_mini_validator_agrees(
   2560         &ledger,
   2561         future_timestamp,
   2562         NOW_MS,
   2563         MiniBlockVerdict::FutureTimestamp,
   2564     );
   2565 
   2566     let mut count_limited_ledger = ledger.clone();
   2567     count_limited_ledger.launch_profile.max_block_transactions =
   2568         block.transactions.len().saturating_sub(1);
   2569     assert_mini_validator_agrees(
   2570         &count_limited_ledger,
   2571         block.clone(),
   2572         now_ms,
   2573         MiniBlockVerdict::TooManyTransactions,
   2574     );
   2575 
   2576     let mut size_limited_ledger = ledger.clone();
   2577     size_limited_ledger.launch_profile.max_block_bytes = ledger
   2578         .consensus_block_size_bytes(&block)
   2579         .unwrap()
   2580         .saturating_sub(1);
   2581     assert_mini_validator_agrees(
   2582         &size_limited_ledger,
   2583         block.clone(),
   2584         now_ms,
   2585         MiniBlockVerdict::TooLarge,
   2586     );
   2587 
   2588     let mut missing_burn = block.clone();
   2589     missing_burn
   2590         .transactions
   2591         .retain(|transaction| !transaction.is_burn());
   2592     rehash(&mut missing_burn);
   2593     assert_mini_validator_agrees(&ledger, missing_burn, now_ms, MiniBlockVerdict::MissingBurn);
   2594 
   2595     let mut zero_fee = block.clone();
   2596     match zero_fee
   2597         .transactions
   2598         .iter_mut()
   2599         .find(|transaction| !matches!(transaction, Transaction::Mine { .. }))
   2600         .expect("prepared block includes a burn anchor")
   2601     {
   2602         Transaction::Transfer { fee, .. } | Transaction::Burn { fee, .. } => *fee = 0,
   2603         Transaction::Mine { .. } => unreachable!("mine transactions are filtered out"),
   2604     }
   2605     rehash(&mut zero_fee);
   2606     assert_mini_validator_agrees(&ledger, zero_fee, now_ms, MiniBlockVerdict::FeePolicy);
   2607 
   2608     let mut wrong_finalizer = block.clone();
   2609     wrong_finalizer.miner = harness.honest[0].address().to_string();
   2610     rehash(&mut wrong_finalizer);
   2611     assert_mini_validator_agrees(
   2612         &ledger,
   2613         wrong_finalizer,
   2614         now_ms,
   2615         MiniBlockVerdict::FinalizerTicket,
   2616     );
   2617 
   2618     let mut missing_proof = block.clone();
   2619     missing_proof.leader_proof = None;
   2620     rehash(&mut missing_proof);
   2621     assert_mini_validator_agrees(
   2622         &ledger,
   2623         missing_proof,
   2624         now_ms,
   2625         MiniBlockVerdict::FinalizerTicket,
   2626     );
   2627 
   2628     let mut wrong_proof_signature = block.clone();
   2629     mutate_signature(
   2630         &mut wrong_proof_signature
   2631             .leader_proof
   2632             .as_mut()
   2633             .expect("ticket block carries a leader proof")
   2634             .signature,
   2635     );
   2636     rehash(&mut wrong_proof_signature);
   2637     assert_mini_validator_agrees(
   2638         &ledger,
   2639         wrong_proof_signature,
   2640         now_ms,
   2641         MiniBlockVerdict::FinalizerTicket,
   2642     );
   2643 
   2644     let mut wrong_ticket_id = block;
   2645     wrong_ticket_id
   2646         .leader_proof
   2647         .as_mut()
   2648         .expect("ticket block carries a leader proof")
   2649         .ticket_id = "0".repeat(64);
   2650     rehash(&mut wrong_ticket_id);
   2651     assert_mini_validator_agrees(
   2652         &ledger,
   2653         wrong_ticket_id,
   2654         now_ms,
   2655         MiniBlockVerdict::FinalizerTicket,
   2656     );
   2657 }
   2658 
   2659 #[test]
   2660 fn attested_burn_block_validates_independent_of_local_mempool() {
   2661     let mut harness = harness_for_percent(22, 25);
   2662     let leader = harness.next_rank(0);
   2663     let finalizer = harness.wallet(&leader.owner).clone();
   2664     harness.submit_anchor_burn(&finalizer);
   2665     let victim = harness
   2666         .honest
   2667         .iter()
   2668         .find(|wallet| wallet.address() != finalizer.address())
   2669         .unwrap()
   2670         .clone();
   2671     let attested_burn = harness.submit_fee_burn(&victim, 1, 1);
   2672     let bundle = finalizer.burn_bundle(BurnBundlePayload {
   2673         height: harness.ledger.height() + 1,
   2674         prev_hash: harness.ledger.tip_hash().to_string(),
   2675         slot: 0,
   2676         member: finalizer.address().to_string(),
   2677         reward_address: None,
   2678         burns: vec![attested_burn.clone()],
   2679         burns_v2: Vec::new(),
   2680     });
   2681     let block = harness.finish_ticket_block_from_pending(0, vec![bundle]);
   2682     let parent_snapshot = harness.ledger.snapshot();
   2683 
   2684     let mut empty_mempool = Ledger::from_snapshot_at(parent_snapshot.clone(), NOW_MS).unwrap();
   2685     empty_mempool
   2686         .apply_block_at(block.clone(), NOW_MS.saturating_add(block.timestamp_ms))
   2687         .unwrap();
   2688 
   2689     let mut conflicting_mempool = Ledger::from_snapshot_at(parent_snapshot, NOW_MS).unwrap();
   2690     let conflict = conflicting_mempool
   2691         .build_transfer(&victim, finalizer.address(), 1, 1)
   2692         .unwrap();
   2693     conflicting_mempool
   2694         .submit_transaction(conflict.clone())
   2695         .unwrap();
   2696     conflicting_mempool
   2697         .apply_block_at(block, NOW_MS.saturating_add(1))
   2698         .unwrap();
   2699     assert!(
   2700         conflicting_mempool
   2701             .pending()
   2702             .iter()
   2703             .all(|tx| tx.signature() != conflict.signature())
   2704     );
   2705 }
   2706 
   2707 #[test]
   2708 fn required_burn_cannot_be_executed_twice_in_one_block() {
   2709     let mut harness = harness_for_percent(11, 25);
   2710     let leader = harness.next_rank(0);
   2711     let finalizer = harness.wallet(&leader.owner).clone();
   2712     harness.submit_anchor_burn(&finalizer);
   2713     let victim = harness
   2714         .honest
   2715         .iter()
   2716         .find(|wallet| wallet.address() != finalizer.address())
   2717         .unwrap()
   2718         .clone();
   2719     let burn = harness.submit_fee_burn(&victim, 1, 1);
   2720     let mut block = harness.finish_ticket_block_from_pending(0, Vec::new());
   2721     block.transactions.push(burn.clone());
   2722     rehash(&mut block);
   2723 
   2724     assert_rejects(harness.ledger, block, "duplicate burn transaction");
   2725 }
   2726 
   2727 #[test]
   2728 fn misused_or_extra_committee_bundle_is_rejected() {
   2729     let mut harness = harness_for_percent(12, 25);
   2730     let mut block = harness.prepared_ticket_block(0, Vec::new());
   2731     block
   2732         .burn_bundle_section
   2733         .signatures
   2734         .push(BurnBundleSignature {
   2735             slot: 1,
   2736             member: harness.attacker.wallet.address().to_string(),
   2737             reward_address: None,
   2738             signature: "00".repeat(64),
   2739         });
   2740     rehash(&mut block);
   2741 
   2742     assert_rejects(harness.ledger, block, "extra committee signature");
   2743 }
   2744 
   2745 #[test]
   2746 #[ignore = "long-running committee quorum mutation coverage; run via deployment.sh"]
   2747 fn mini_burn_bundle_quorum_oracle_matches_consensus_mutations() {
   2748     let mut harness = harness_for_percent(38, 25);
   2749     harness.mature_lineages(2, 4);
   2750     let lineage_owners = harness
   2751         .ledger
   2752         .lineage_owners
   2753         .values()
   2754         .flat_map(|owners| owners.keys().cloned())
   2755         .collect::<BTreeSet<_>>();
   2756     for owner in lineage_owners {
   2757         let wallet = harness.wallet(&owner).clone();
   2758         harness.submit_fee_burn(&wallet, 1, 1);
   2759     }
   2760     for _ in 0..harness.ledger.launch_profile.ticket_maturity_delay_heights {
   2761         harness.mine_ticket_block(0);
   2762     }
   2763     assert_mini_burn_committee_matches(&harness.ledger);
   2764 
   2765     let leader = harness.next_rank(0);
   2766     let finalizer = harness.wallet(&leader.owner).clone();
   2767     harness.submit_anchor_burn(&finalizer);
   2768     let victim = harness
   2769         .honest
   2770         .iter()
   2771         .find(|wallet| wallet.address() != finalizer.address())
   2772         .unwrap()
   2773         .clone();
   2774     harness.submit_fee_burn(&victim, 1, 1);
   2775     let bundles = harness.committee_bundles();
   2776     let block = harness.finish_ticket_block_from_pending(0, bundles);
   2777     assert!(
   2778         !block.burn_bundle_section.signatures.is_empty(),
   2779         "test setup should require explicit committee signatures"
   2780     );
   2781     assert!(
   2782         !block.burn_bundle_section.burns.is_empty(),
   2783         "test setup should include an attested third-party burn"
   2784     );
   2785     let now_ms = NOW_MS.saturating_add(block.timestamp_ms);
   2786     assert_mini_validator_agrees(
   2787         &harness.ledger,
   2788         block.clone(),
   2789         now_ms,
   2790         MiniBlockVerdict::Accept,
   2791     );
   2792 
   2793     let mut missing_signature = block.clone();
   2794     missing_signature.burn_bundle_section.signatures.pop();
   2795     rehash(&mut missing_signature);
   2796     assert_mini_validator_agrees(
   2797         &harness.ledger,
   2798         missing_signature,
   2799         now_ms,
   2800         MiniBlockVerdict::BurnBundleSection,
   2801     );
   2802 
   2803     let mut invalid_mask = block.clone();
   2804     invalid_mask.burn_bundle_section.burns[0].bundle_mask |= 0b1000_0000;
   2805     rehash(&mut invalid_mask);
   2806     assert_mini_validator_agrees(
   2807         &harness.ledger,
   2808         invalid_mask,
   2809         now_ms,
   2810         MiniBlockVerdict::BurnBundleSection,
   2811     );
   2812 
   2813     let mut duplicate_burn = block;
   2814     duplicate_burn
   2815         .burn_bundle_section
   2816         .burns
   2817         .push(duplicate_burn.burn_bundle_section.burns[0].clone());
   2818     rehash(&mut duplicate_burn);
   2819     assert_mini_validator_agrees(
   2820         &harness.ledger,
   2821         duplicate_burn,
   2822         now_ms,
   2823         MiniBlockVerdict::BurnBundleSection,
   2824     );
   2825 }
   2826 
   2827 #[test]
   2828 #[ignore = "long-running committee availability coverage; run via deployment.sh"]
   2829 fn finalizer_anchor_alone_requires_available_committee_signatures() {
   2830     let mut harness = harness_for_percent(20, 25);
   2831     harness.mature_lineages(1, 4);
   2832     harness.ensure_lineage_owners_have_next_height_tickets();
   2833     let leader = harness.next_rank(0);
   2834     let finalizer = harness.wallet(&leader.owner).clone();
   2835     harness.submit_anchor_burn(&finalizer);
   2836 
   2837     let error = harness
   2838         .ledger
   2839         .prepare_next_block_with_burn_bundles(
   2840             finalizer.address(),
   2841             harness.ledger.tip().timestamp_ms.saturating_add(1),
   2842             Vec::new(),
   2843         )
   2844         .unwrap_err();
   2845     assert!(
   2846         error
   2847             .to_string()
   2848             .contains("not enough burn bundle signatures collected"),
   2849         "unexpected anchor-only preparation error: {error:#}"
   2850     );
   2851 
   2852     let bundles = harness.committee_bundles();
   2853     let with_committee = harness.finish_ticket_block_from_pending(0, bundles);
   2854     harness
   2855         .ledger
   2856         .apply_block_at(with_committee, NOW_MS.saturating_add(1))
   2857         .unwrap();
   2858 }
   2859 
   2860 #[test]
   2861 #[ignore = "long-running rank fallback committee coverage; run via deployment.sh"]
   2862 fn rank_one_committee_excludes_missed_rank_zero_owner_and_requires_remaining_slot() {
   2863     let mut harness = harness_for_percent(22, 25);
   2864     harness.mature_lineages(2, 4);
   2865     harness.ensure_lineage_owners_have_next_height_tickets();
   2866     let target_height = harness.ledger.height() + 1;
   2867     let rank_zero = harness
   2868         .ledger
   2869         .ticket_for_finalizer_rank(target_height, 0)
   2870         .expect("synthetic rank 0 ticket should be eligible")
   2871         .owner;
   2872     let rank_one = harness
   2873         .ledger
   2874         .ticket_for_finalizer_rank(target_height, 1)
   2875         .expect("synthetic rank 1 ticket should be eligible")
   2876         .owner;
   2877 
   2878     let committee = harness.ledger.burn_committee_for_next_ticket_block(1);
   2879     assert!(
   2880         committee.len() > 1,
   2881         "test setup should retain at least one rank-1 committee member"
   2882     );
   2883     assert_eq!(
   2884         committee.first().map(|member| member.owner.as_str()),
   2885         Some(rank_one.as_str())
   2886     );
   2887     assert!(
   2888         committee.iter().all(|member| member.owner != rank_zero),
   2889         "missed rank-0 owner must not remain in rank-1 committee: {committee:?}"
   2890     );
   2891 
   2892     let finalizer = harness.wallet(&rank_one).clone();
   2893     harness.submit_anchor_burn(&finalizer);
   2894     let error = harness
   2895         .ledger
   2896         .prepare_next_block_with_burn_bundles(
   2897             finalizer.address(),
   2898             harness
   2899                 .ledger
   2900                 .tip()
   2901                 .timestamp_ms
   2902                 .saturating_add(VDF_TARGET_BLOCK_MS * 2)
   2903                 .saturating_add(1),
   2904             Vec::new(),
   2905         )
   2906         .unwrap_err();
   2907     assert!(
   2908         error
   2909             .to_string()
   2910             .contains("not enough burn bundle signatures collected"),
   2911         "unexpected rank-1 preparation error: {error:#}"
   2912     );
   2913 
   2914     let member = committee
   2915         .iter()
   2916         .find(|member| member.slot == 1)
   2917         .expect("rank-1 committee should have slot 1")
   2918         .clone();
   2919     let wallet = harness.wallet(&member.owner);
   2920     let bundle = wallet.burn_bundle(BurnBundlePayload {
   2921         height: harness.ledger.height() + 1,
   2922         prev_hash: harness.ledger.tip_hash().to_string(),
   2923         slot: member.slot,
   2924         member: member.owner,
   2925         reward_address: None,
   2926         burns: Vec::new(),
   2927         burns_v2: Vec::new(),
   2928     });
   2929     let with_committee = finish_prepared_block(
   2930         &finalizer,
   2931         harness
   2932             .ledger
   2933             .prepare_next_block_with_burn_bundles(
   2934                 finalizer.address(),
   2935                 harness
   2936                     .ledger
   2937                     .tip()
   2938                     .timestamp_ms
   2939                     .saturating_add(VDF_TARGET_BLOCK_MS * 2)
   2940                     .saturating_add(1),
   2941                 vec![bundle],
   2942             )
   2943             .unwrap(),
   2944     );
   2945 
   2946     harness
   2947         .ledger
   2948         .apply_block_at(
   2949             with_committee,
   2950             NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS * 2 + 1),
   2951         )
   2952         .unwrap();
   2953 }
   2954 
   2955 #[test]
   2956 fn pending_third_party_burn_does_not_affect_block_validity() {
   2957     let mut harness = harness_for_percent(21, 25);
   2958     harness.mature_lineages(1, 4);
   2959     let leader = harness.next_rank(0);
   2960     let finalizer = harness.wallet(&leader.owner).clone();
   2961     let anchor = harness.submit_anchor_burn(&finalizer);
   2962     let victim = harness
   2963         .honest
   2964         .iter()
   2965         .find(|wallet| wallet.address() != finalizer.address())
   2966         .unwrap()
   2967         .clone();
   2968     let third_party = harness.submit_fee_burn(&victim, 1, 1);
   2969     let bundles = harness.committee_bundles_for_rank_and_burns(0, Vec::new());
   2970     let mut block = harness.finish_ticket_block_from_pending(0, bundles);
   2971     block
   2972         .transactions
   2973         .retain(|tx| tx.signature() == anchor.signature());
   2974     rehash(&mut block);
   2975 
   2976     assert!(
   2977         harness
   2978             .ledger
   2979             .pending()
   2980             .iter()
   2981             .any(|tx| tx.signature() == third_party.signature())
   2982     );
   2983     harness
   2984         .ledger
   2985         .apply_block_at(block, NOW_MS.saturating_add(1))
   2986         .unwrap();
   2987 }
   2988 
   2989 #[test]
   2990 #[ignore = "long-running required third-party burn committee coverage; run via deployment.sh"]
   2991 fn required_third_party_burn_requires_committee_signatures() {
   2992     let mut harness = harness_for_percent(21, 25);
   2993     harness.mature_lineages(1, 4);
   2994     harness.ensure_lineage_owners_have_next_height_tickets();
   2995     let leader = harness.next_rank(0);
   2996     let finalizer = harness.wallet(&leader.owner).clone();
   2997     harness.submit_anchor_burn(&finalizer);
   2998     let victim = harness
   2999         .honest
   3000         .iter()
   3001         .find(|wallet| wallet.address() != finalizer.address())
   3002         .unwrap()
   3003         .clone();
   3004     let third_party = harness.submit_fee_burn(&victim, 1, 1);
   3005     let bundles = harness.committee_bundles_for_rank_and_burns(0, vec![third_party]);
   3006     assert!(
   3007         !bundles.is_empty(),
   3008         "test setup should provide an explicit committee signature"
   3009     );
   3010     let mut block = harness.finish_ticket_block_from_pending(0, bundles);
   3011     block.burn_bundle_section.signatures.clear();
   3012     rehash(&mut block);
   3013 
   3014     assert_rejects(
   3015         harness.ledger,
   3016         block,
   3017         "required third-party burn without committee signatures",
   3018     );
   3019 }
   3020 
   3021 #[test]
   3022 fn zero_fee_public_burn_is_rejected() {
   3023     let wallet = Wallet::from_seed("zero-fee-public-burn");
   3024     let mut allocations = BTreeMap::new();
   3025     allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   3026     let ledger = Ledger::new_with_genesis_burns(
   3027         allocations,
   3028         vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
   3029         1,
   3030     )
   3031     .unwrap();
   3032 
   3033     let error = ledger.build_burn(&wallet, 1, 0).unwrap_err();
   3034 
   3035     assert!(
   3036         error
   3037             .to_string()
   3038             .contains("burn transaction fee must be greater than zero")
   3039     );
   3040 }
   3041 
   3042 #[test]
   3043 fn post_genesis_transactions_cannot_spend_with_genesis_input_signatures() {
   3044     let alice = Wallet::from_seed("post-genesis-signature-alice");
   3045     let bob = Wallet::from_seed("post-genesis-signature-bob");
   3046     let mut allocations = BTreeMap::new();
   3047     allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
   3048     allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA);
   3049     let mut ledger = Ledger::new_with_genesis_burns(
   3050         allocations,
   3051         vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
   3052         1,
   3053     )
   3054     .unwrap();
   3055     let mut transaction = ledger.build_transfer(&alice, bob.address(), 1, 1).unwrap();
   3056     let Transaction::Transfer {
   3057         inputs, signature, ..
   3058     } = &mut transaction
   3059     else {
   3060         panic!("test builds a transfer");
   3061     };
   3062     for input in inputs {
   3063         input.signature = "genesis".to_string();
   3064     }
   3065     *signature = "0".repeat(128);
   3066 
   3067     let error = ledger.submit_transaction(transaction).unwrap_err();
   3068 
   3069     assert!(
   3070         error.to_string().contains("invalid input signature")
   3071             || error
   3072                 .to_string()
   3073                 .contains("transaction signature is invalid")
   3074     );
   3075 }
   3076 
   3077 #[test]
   3078 #[ignore = "long-running committee signature rejection coverage; run via deployment.sh"]
   3079 fn invalid_committee_signature_is_rejected() {
   3080     let mut harness = harness_for_percent(13, 25);
   3081     harness.mature_lineages(1, 4);
   3082     harness.ensure_lineage_owners_have_next_height_tickets();
   3083     let member = harness
   3084         .ledger
   3085         .burn_committee_for_next_block()
   3086         .into_iter()
   3087         .find(|member| member.slot > 0)
   3088         .expect("mature lineage should provide an additional committee member");
   3089     let wallet = harness.wallet(&member.owner).clone();
   3090     let burn = harness.submit_fee_burn(&harness.honest[0].clone(), 1, 1);
   3091     let mut bundle = harness.ledger.build_burn_bundle(&wallet).unwrap().unwrap();
   3092     assert!(
   3093         bundle
   3094             .burns
   3095             .iter()
   3096             .any(|tx| tx.signature() == burn.signature())
   3097     );
   3098     mutate_signature(&mut bundle.signature);
   3099 
   3100     assert!(
   3101         harness
   3102             .ledger
   3103             .validate_next_block_burn_bundles(vec![bundle])
   3104             .is_err()
   3105     );
   3106 }
   3107 
   3108 #[test]
   3109 fn non_mature_lineage_cannot_join_committee() {
   3110     let mut harness = harness_for_percent(14, 25);
   3111     let attacker = harness.attacker.lineage_wallets[0].clone();
   3112     let mine = harness.ledger.build_mine(attacker.address()).unwrap();
   3113     harness.ledger.submit_transaction(mine).unwrap();
   3114     harness.mine_ticket_block(0);
   3115 
   3116     let committee = harness.ledger.burn_committee_for_next_block();
   3117     assert!(
   3118         committee
   3119             .iter()
   3120             .all(|member| member.owner != attacker.address()),
   3121         "non-mature lineage appeared in committee: {committee:?}"
   3122     );
   3123 }
   3124 
   3125 #[test]
   3126 fn local_testnet_lineage_with_an_eligible_ticket_is_immediately_required_by_rank_zero_quorum() {
   3127     let mut harness = harness_for_percent(141, 25);
   3128     harness.ledger.launch_profile.burn_lineage_maturity_heights = 0;
   3129     let current_leader = harness.next_rank(0).owner;
   3130     let committee_wallet = harness
   3131         .honest
   3132         .iter()
   3133         .find(|wallet| wallet.address() != current_leader)
   3134         .expect("test setup needs a non-finalizer ticket wallet")
   3135         .clone();
   3136     let mine = harness
   3137         .ledger
   3138         .build_mine(committee_wallet.address())
   3139         .unwrap();
   3140     harness.ledger.submit_transaction(mine).unwrap();
   3141     harness.mine_ticket_block(0);
   3142 
   3143     let committee = harness.ledger.burn_committee_for_next_block();
   3144     assert!(
   3145         committee
   3146             .iter()
   3147             .any(|member| member.slot > 0 && member.owner == committee_wallet.address()),
   3148         "local-testnet lineage did not enter the next committee: {committee:?}"
   3149     );
   3150     assert!(
   3151         harness
   3152             .ledger
   3153             .explicit_burn_bundle_signatures_required_for_next_block(
   3154                 FinalizerMode::Ticket,
   3155                 0,
   3156                 harness.next_rank(0).owner.as_str(),
   3157             )
   3158             > 0,
   3159         "rank-0 committee should require an explicit signature: {committee:?}"
   3160     );
   3161 
   3162     let leader = harness.next_rank(0);
   3163     let finalizer = harness.wallet(&leader.owner).clone();
   3164     harness.submit_anchor_burn(&finalizer);
   3165     let timestamp = harness
   3166         .ledger
   3167         .tip()
   3168         .timestamp_ms
   3169         .saturating_add(VDF_TARGET_BLOCK_MS);
   3170     let error = harness
   3171         .ledger
   3172         .prepare_next_block_with_burn_bundles(finalizer.address(), timestamp, Vec::new())
   3173         .unwrap_err();
   3174 
   3175     assert!(
   3176         error
   3177             .to_string()
   3178             .contains("not enough burn bundle signatures collected")
   3179     );
   3180 }
   3181 
   3182 #[test]
   3183 fn changing_attestation_set_after_vdf_changes_seed_and_invalidates_block() {
   3184     let mut harness = harness_for_percent(15, 25);
   3185     let leader = harness.next_rank(0);
   3186     let finalizer = harness.wallet(&leader.owner).clone();
   3187     harness.submit_anchor_burn(&finalizer);
   3188     let victim = harness
   3189         .honest
   3190         .iter()
   3191         .find(|wallet| wallet.address() != finalizer.address())
   3192         .unwrap()
   3193         .clone();
   3194     let victim_burn = harness.submit_fee_burn(&victim, 1, 1);
   3195     let mut block = harness.finish_ticket_block_from_pending(0, Vec::new());
   3196     let original_seed = block.vdf_seed();
   3197     block.burn_bundle_section.burns.push(MaskedBurn {
   3198         burn: victim_burn,
   3199         bundle_mask: burn_bundle_slot_mask(0).unwrap_or(0),
   3200     });
   3201     let changed_seed = block.vdf_seed();
   3202     assert_ne!(original_seed, changed_seed);
   3203     rehash(&mut block);
   3204 
   3205     assert_rejects(harness.ledger, block, "post-VDF attestation mutation");
   3206 }
   3207 
   3208 #[test]
   3209 fn fallback_block_must_match_its_rank_and_ticket() {
   3210     let mut harness = harness_for_percent(16, 25);
   3211     let mut block = harness.prepared_ticket_block(1, Vec::new());
   3212     block.finalizer_rank = 0;
   3213     rehash(&mut block);
   3214 
   3215     assert_rejects(harness.ledger, block, "fallback block with wrong rank");
   3216 }
   3217 
   3218 #[test]
   3219 fn consumed_ticket_cannot_be_reused() {
   3220     let mut harness = harness_for_percent(17, 25);
   3221     let block = harness.mine_ticket_block(0);
   3222     let used_ticket = block.leader_proof.as_ref().unwrap().ticket_id.clone();
   3223     let next_ranks = harness
   3224         .ledger
   3225         .burn_leader_ranks_for_block(harness.ledger.height() + 1)
   3226         .unwrap();
   3227 
   3228     assert!(
   3229         next_ranks.iter().all(|rank| rank.ticket_id != used_ticket),
   3230         "consumed ticket {used_ticket} was eligible again"
   3231     );
   3232 }
   3233 
   3234 #[test]
   3235 #[ignore = "long-running mini ticket oracle sweep; run via deployment.sh"]
   3236 fn mini_ticket_oracle_matches_ledger_across_maturity_expiry_and_consumption() {
   3237     let mut harness = harness_for_percent(32, 25);
   3238     assert_mini_ticket_inventory_matches(&harness.ledger);
   3239 
   3240     for _ in 0..8 {
   3241         let before_inventory =
   3242             mini_ticket_inventory(&harness.ledger.snapshot()).expect("valid ticket snapshot");
   3243         let next_height = harness.ledger.height() + 1;
   3244         let mini_ranked_ids =
   3245             mini_ranked_tickets_for_height(harness.ledger.tip(), next_height, &before_inventory)
   3246                 .into_iter()
   3247                 .map(|ticket| ticket.id)
   3248                 .collect::<Vec<_>>();
   3249         let ledger_ranked_ids = harness
   3250             .ledger
   3251             .burn_leader_ranks_for_block(next_height)
   3252             .unwrap()
   3253             .into_iter()
   3254             .map(|rank| rank.ticket_id)
   3255             .collect::<Vec<_>>();
   3256         assert_eq!(
   3257             mini_ranked_ids, ledger_ranked_ids,
   3258             "mini ticket ranking diverged at height {next_height}"
   3259         );
   3260 
   3261         let block = harness.mine_ticket_block(0);
   3262         let used_ticket = block.leader_proof.as_ref().unwrap().ticket_id.clone();
   3263         assert_mini_ticket_inventory_matches(&harness.ledger);
   3264         let after_inventory =
   3265             mini_ticket_inventory(&harness.ledger.snapshot()).expect("valid ticket snapshot");
   3266         let after_ids = after_inventory
   3267             .iter()
   3268             .map(|ticket| ticket.id.clone())
   3269             .collect::<BTreeSet<_>>();
   3270 
   3271         assert!(
   3272             !after_ids.contains(&used_ticket),
   3273             "consumed ticket {used_ticket} stayed live after block {}",
   3274             block.height
   3275         );
   3276         for ticket in before_inventory {
   3277             if ticket.eligible_until_height <= block.height {
   3278                 assert!(
   3279                     !after_ids.contains(&ticket.id),
   3280                     "expired ticket {} stayed live after block {}",
   3281                     ticket.id,
   3282                     block.height
   3283                 );
   3284             }
   3285         }
   3286 
   3287         for transaction in &block.transactions {
   3288             let Transaction::Burn {
   3289                 inputs,
   3290                 amount,
   3291                 signature,
   3292                 ..
   3293             } = transaction
   3294             else {
   3295                 continue;
   3296             };
   3297             if *amount == 0 {
   3298                 continue;
   3299             }
   3300             let expected_from = block
   3301                 .height
   3302                 .checked_add(harness.ledger.launch_profile.ticket_maturity_delay_heights)
   3303                 .unwrap();
   3304             let expected_until = expected_from
   3305                 .checked_add(harness.ledger.launch_profile.ticket_expiry_window_heights - 1)
   3306                 .unwrap();
   3307             let owner = inputs.first().expect("burn has owner input").owner.as_str();
   3308             assert!(
   3309                 after_inventory.iter().any(|ticket| {
   3310                     ticket.id == *signature
   3311                         && ticket.owner == owner
   3312                         && ticket.amount == *amount
   3313                         && ticket.eligible_from_height == expected_from
   3314                         && ticket.eligible_until_height == expected_until
   3315                 }),
   3316                 "burn ticket {signature} was not scheduled with expected maturity/expiry"
   3317             );
   3318         }
   3319     }
   3320 }
   3321 
   3322 #[test]
   3323 fn recovery_cannot_bypass_ticket_rules_before_threshold_and_requires_own_burn() {
   3324     let mut harness = harness_for_percent(18, 25);
   3325     let attacker = harness.attacker.wallet.clone();
   3326     harness.submit_anchor_burn(&attacker);
   3327     assert!(
   3328         harness
   3329             .ledger
   3330             .prepare_recovery_block(attacker.address(), VDF_TARGET_BLOCK_MS)
   3331             .is_err()
   3332     );
   3333     harness.ledger.clear_pending_transactions();
   3334 
   3335     let mut recovery = harness.mine_recovery_block(&attacker);
   3336     recovery
   3337         .transactions
   3338         .retain(|tx| !(tx.is_burn() && tx.sender() == attacker.address()));
   3339     rehash(&mut recovery);
   3340     let parent_snapshot = ChainSnapshot {
   3341         genesis_allocations: harness.ledger.genesis_allocations.clone(),
   3342         vdf_rounds: harness.ledger.initial_vdf_rounds,
   3343         launch_profile: harness.ledger.launch_profile.clone(),
   3344         blocks: harness.ledger.chain[..harness.ledger.chain.len() - 1].to_vec(),
   3345     };
   3346     let parent_ledger = Ledger::from_snapshot_at(parent_snapshot, NOW_MS).unwrap();
   3347 
   3348     assert_rejects(parent_ledger, recovery, "recovery without finalizer burn");
   3349 }
   3350 
   3351 #[test]
   3352 fn mini_validator_matches_consensus_for_recovery_rules() {
   3353     let mut harness = harness_for_percent(39, 25);
   3354     let miner = harness.attacker.wallet.clone();
   3355     let victim = harness
   3356         .honest
   3357         .iter()
   3358         .find(|wallet| wallet.address() != miner.address())
   3359         .unwrap()
   3360         .clone();
   3361     let anchor = harness.submit_anchor_burn(&miner);
   3362     let victim_burn = harness.submit_fee_burn(&victim, 1, 1);
   3363     let timestamp = harness.ledger.recovery_block_min_timestamp();
   3364     let prepared = harness
   3365         .ledger
   3366         .prepare_recovery_block(miner.address(), timestamp)
   3367         .unwrap();
   3368     let recovery = finish_prepared_block(&miner, prepared);
   3369     let now_ms = NOW_MS.saturating_add(recovery.timestamp_ms);
   3370     assert!(
   3371         recovery
   3372             .transactions
   3373             .iter()
   3374             .any(|transaction| transaction.signature() == victim_burn.signature()),
   3375         "test setup needs a non-finalizer burn to isolate the recovery finalizer-burn rule"
   3376     );
   3377 
   3378     assert_mini_validator_agrees(
   3379         &harness.ledger,
   3380         recovery.clone(),
   3381         now_ms,
   3382         MiniBlockVerdict::Accept,
   3383     );
   3384 
   3385     let mut wrong_rank = recovery.clone();
   3386     wrong_rank.finalizer_rank = 1;
   3387     rehash(&mut wrong_rank);
   3388     assert_mini_validator_agrees(
   3389         &harness.ledger,
   3390         wrong_rank,
   3391         now_ms,
   3392         MiniBlockVerdict::RecoveryRules,
   3393     );
   3394 
   3395     let mut with_leader_proof = recovery.clone();
   3396     with_leader_proof.leader_proof = Some(super::LeaderProof {
   3397         ticket_id: "0".repeat(64),
   3398         public_key: miner.address().to_string(),
   3399         signature: "0".repeat(128),
   3400     });
   3401     rehash(&mut with_leader_proof);
   3402     assert_mini_validator_agrees(
   3403         &harness.ledger,
   3404         with_leader_proof,
   3405         now_ms,
   3406         MiniBlockVerdict::RecoveryRules,
   3407     );
   3408 
   3409     let mut too_early = recovery.clone();
   3410     too_early.timestamp_ms = timestamp.saturating_sub(1);
   3411     rehash(&mut too_early);
   3412     assert_mini_validator_agrees(
   3413         &harness.ledger,
   3414         too_early,
   3415         now_ms,
   3416         MiniBlockVerdict::RecoveryRules,
   3417     );
   3418 
   3419     let mut missing_finalizer_burn = recovery;
   3420     missing_finalizer_burn
   3421         .transactions
   3422         .retain(|transaction| transaction.signature() != anchor.signature());
   3423     rehash(&mut missing_finalizer_burn);
   3424     assert_mini_validator_agrees(
   3425         &harness.ledger,
   3426         missing_finalizer_burn,
   3427         now_ms,
   3428         MiniBlockVerdict::RecoveryRules,
   3429     );
   3430 }
   3431 
   3432 #[test]
   3433 #[ignore = "long-running recovery/fork-choice coverage; run via deployment.sh"]
   3434 fn recovery_and_fork_choice_cannot_cross_finality_depth() {
   3435     let mut local = harness_for_percent(19, 25);
   3436     for _ in 0..9 {
   3437         local.mine_ticket_block(0);
   3438     }
   3439 
   3440     let fork_base = ChainSnapshot {
   3441         genesis_allocations: local.ledger.genesis_allocations.clone(),
   3442         vdf_rounds: local.ledger.initial_vdf_rounds,
   3443         launch_profile: local.ledger.launch_profile.clone(),
   3444         blocks: local.ledger.chain[..2].to_vec(),
   3445     };
   3446     let mut remote = Harness {
   3447         ledger: Ledger::from_snapshot_at(fork_base, NOW_MS).unwrap(),
   3448         attacker: local.attacker.clone(),
   3449         honest: local.honest.clone(),
   3450         wallets: local.wallets.clone(),
   3451         seed: local.seed,
   3452         resource: local.resource,
   3453     };
   3454     remote.mine_ticket_block(1);
   3455     for _ in 0..12 {
   3456         remote.mine_ticket_block(0);
   3457     }
   3458 
   3459     assert_eq!(mini_choose_fork(&local.ledger, &remote.ledger), Some(false));
   3460     let switched = local
   3461         .ledger
   3462         .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS)
   3463         .unwrap();
   3464     assert!(!switched, "fork deeper than finality depth was accepted");
   3465 }
   3466 
   3467 #[test]
   3468 fn same_height_fork_switches_to_better_leader_quality() {
   3469     let base = harness_for_percent(23, 25);
   3470     let snapshot = base.ledger.snapshot();
   3471     let mut local = fork_harness_from(&base, snapshot.clone());
   3472     let mut remote = fork_harness_from(&base, snapshot);
   3473 
   3474     let local_block = local.mine_ticket_block(1);
   3475     let remote_block = remote.mine_ticket_block(0);
   3476     assert_eq!(local.ledger.height(), remote.ledger.height());
   3477     assert!(remote_block.leader_score() < local_block.leader_score());
   3478     assert!(mini_leader_score(&remote_block) < mini_leader_score(&local_block));
   3479 
   3480     assert_eq!(mini_choose_fork(&local.ledger, &remote.ledger), Some(true));
   3481     let switched = local
   3482         .ledger
   3483         .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS)
   3484         .unwrap();
   3485 
   3486     assert!(switched, "same-height better fork was not selected");
   3487     assert_eq!(local.ledger.tip_hash(), remote.ledger.tip_hash());
   3488 }
   3489 
   3490 #[test]
   3491 fn taller_valid_fork_inside_finality_window_is_adopted() {
   3492     let mut local = harness_for_percent(24, 25);
   3493     for _ in 0..3 {
   3494         local.mine_ticket_block(0);
   3495     }
   3496     let fork_base = local.ledger.snapshot();
   3497     local.mine_ticket_block(0);
   3498 
   3499     let mut remote = fork_harness_from(&local, fork_base);
   3500     remote.mine_ticket_block(0);
   3501     remote.mine_ticket_block(0);
   3502 
   3503     assert_eq!(mini_choose_fork(&local.ledger, &remote.ledger), Some(true));
   3504     let switched = local
   3505         .ledger
   3506         .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS)
   3507         .unwrap();
   3508 
   3509     assert!(
   3510         switched,
   3511         "taller valid fork inside finality was not adopted"
   3512     );
   3513     assert_eq!(local.ledger.tip_hash(), remote.ledger.tip_hash());
   3514 }
   3515 
   3516 #[test]
   3517 fn snapshot_with_invalid_late_block_is_rejected_without_replacing_local_chain() {
   3518     let mut local = harness_for_percent(25, 25);
   3519     let fork_base = local.ledger.snapshot();
   3520     local.mine_ticket_block(0);
   3521     let local_tip = local.ledger.tip_hash().to_string();
   3522 
   3523     let mut remote = fork_harness_from(&local, fork_base);
   3524     remote.mine_ticket_block(0);
   3525     remote.mine_ticket_block(0);
   3526     let mut snapshot = remote.ledger.snapshot();
   3527     let last = snapshot.blocks.last_mut().expect("snapshot has a tip");
   3528     last.reward = last.reward.saturating_add(1);
   3529     last.hash = last.compute_hash();
   3530 
   3531     let error = local
   3532         .ledger
   3533         .extend_from_snapshot_at(snapshot, NOW_MS)
   3534         .unwrap_err();
   3535 
   3536     assert!(error.to_string().contains("block reward is invalid"));
   3537     assert_eq!(local.ledger.tip_hash(), local_tip);
   3538 }
   3539 
   3540 #[test]
   3541 fn pending_transactions_from_stale_fork_are_carried_forward_after_reorg() {
   3542     let base = harness_for_percent(26, 25);
   3543     let snapshot = base.ledger.snapshot();
   3544     let mut remote = fork_harness_from(&base, snapshot.clone());
   3545     remote.mine_ticket_block(0);
   3546     remote.mine_ticket_block(0);
   3547     let remote_miners = remote
   3548         .ledger
   3549         .chain
   3550         .iter()
   3551         .skip(snapshot.blocks.len())
   3552         .map(|block| block.miner.clone())
   3553         .collect::<BTreeSet<_>>();
   3554     let sender = base
   3555         .honest
   3556         .iter()
   3557         .find(|wallet| !remote_miners.contains(wallet.address()))
   3558         .expect("test fixture should have a non-finalizer sender")
   3559         .clone();
   3560     let recipient = base
   3561         .honest
   3562         .iter()
   3563         .find(|wallet| {
   3564             wallet.address() != sender.address() && !remote_miners.contains(wallet.address())
   3565         })
   3566         .expect("test fixture should have a recipient")
   3567         .clone();
   3568 
   3569     let mut local = fork_harness_from(&base, snapshot);
   3570     let stale_fork_tx = local
   3571         .ledger
   3572         .build_transfer(&sender, recipient.address(), 1, 1)
   3573         .unwrap();
   3574     local
   3575         .ledger
   3576         .submit_transaction(stale_fork_tx.clone())
   3577         .unwrap();
   3578     local.mine_ticket_block(0);
   3579 
   3580     let switched = local
   3581         .ledger
   3582         .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS)
   3583         .unwrap();
   3584 
   3585     assert!(switched, "taller fork should trigger a reorg");
   3586     assert!(
   3587         local
   3588             .ledger
   3589             .pending()
   3590             .iter()
   3591             .any(|tx| tx.signature() == stale_fork_tx.signature()),
   3592         "stale fork transaction was not carried forward"
   3593     );
   3594 }
   3595 
   3596 #[test]
   3597 fn partition_delayed_burn_bundle_block_import_deduplicates_late_burn_gossip() {
   3598     let base = harness_for_percent(1_600, 25);
   3599     let snapshot = base.ledger.snapshot();
   3600     let mut producer = fork_harness_from(&base, snapshot.clone());
   3601     let mut isolated = fork_harness_from(&base, snapshot);
   3602 
   3603     let leader = producer.next_rank(0);
   3604     let finalizer = producer.wallet(&leader.owner).clone();
   3605     let victim = producer
   3606         .honest
   3607         .iter()
   3608         .find(|wallet| wallet.address() != finalizer.address())
   3609         .expect("fixture should contain a non-finalizer burn victim")
   3610         .clone();
   3611     let delayed_burn = producer.submit_fee_burn(&victim, 7, 2);
   3612     producer.submit_anchor_burn(&finalizer);
   3613     let bundles = producer.committee_bundles();
   3614     assert!(
   3615         bundles.iter().any(|bundle| {
   3616             bundle
   3617                 .burns
   3618                 .iter()
   3619                 .any(|burn| burn.signature() == delayed_burn.signature())
   3620         }),
   3621         "producer partition did not attest delayed burn"
   3622     );
   3623 
   3624     let block = producer.finish_ticket_block_from_pending(0, bundles);
   3625     assert!(
   3626         block
   3627             .transactions
   3628             .iter()
   3629             .any(|transaction| transaction.signature() == delayed_burn.signature()),
   3630         "attested delayed burn was not included in the produced block"
   3631     );
   3632     assert!(
   3633         block
   3634             .burn_bundle_section
   3635             .burns
   3636             .iter()
   3637             .any(|masked| masked.burn.signature() == delayed_burn.signature()),
   3638         "produced block did not carry delayed burn attestation"
   3639     );
   3640     assert!(
   3641         isolated.ledger.pending().is_empty(),
   3642         "isolated partition should not know the delayed burn before reconnect"
   3643     );
   3644 
   3645     let now_ms = NOW_MS.saturating_add(block.timestamp_ms);
   3646     producer
   3647         .ledger
   3648         .apply_block_at(block.clone(), now_ms)
   3649         .unwrap();
   3650     isolated
   3651         .ledger
   3652         .apply_block_at(block.clone(), now_ms)
   3653         .unwrap();
   3654     assert_eq!(isolated.ledger.tip_hash(), block.hash);
   3655     assert_eq!(
   3656         isolated
   3657             .ledger
   3658             .submit_transaction_with_outcome(delayed_burn.clone())
   3659             .unwrap(),
   3660         TransactionSubmitOutcome::AlreadyKnown,
   3661         "late burn gossip after reconnect should be deduplicated against the chain"
   3662     );
   3663     assert!(
   3664         isolated
   3665             .ledger
   3666             .pending()
   3667             .iter()
   3668             .all(|transaction| transaction.signature() != delayed_burn.signature()),
   3669         "delayed burn was re-added to pending after it was already mined"
   3670     );
   3671 }
   3672 
   3673 #[test]
   3674 fn eclipsed_node_rejects_minority_fork_then_recovers_to_majority_tip() {
   3675     let base = harness_for_percent(1_601, 25);
   3676     let snapshot = base.ledger.snapshot();
   3677     let mut local = fork_harness_from(&base, snapshot.clone());
   3678     let mut minority = fork_harness_from(&base, snapshot.clone());
   3679     let mut majority = fork_harness_from(&base, snapshot);
   3680 
   3681     local.mine_ticket_block(0);
   3682     local.mine_ticket_block(0);
   3683     let local_tip_before_minority = local.ledger.tip_hash().to_string();
   3684 
   3685     minority.mine_ticket_block(1);
   3686     let switched_to_minority = local
   3687         .ledger
   3688         .extend_from_snapshot_at(minority.ledger.snapshot(), NOW_MS)
   3689         .unwrap();
   3690     assert!(
   3691         !switched_to_minority,
   3692         "eclipsed node accepted a shorter attacker-only fork"
   3693     );
   3694     assert_eq!(local.ledger.tip_hash(), local_tip_before_minority);
   3695 
   3696     for _ in 0..3 {
   3697         majority.mine_ticket_block(0);
   3698     }
   3699     assert_eq!(
   3700         mini_choose_fork(&local.ledger, &majority.ledger),
   3701         Some(true)
   3702     );
   3703     let switched_to_majority = local
   3704         .ledger
   3705         .extend_from_snapshot_at(majority.ledger.snapshot(), NOW_MS)
   3706         .unwrap();
   3707 
   3708     assert!(
   3709         switched_to_majority,
   3710         "node did not recover to the better chain after partition healed"
   3711     );
   3712     assert_eq!(local.ledger.tip_hash(), majority.ledger.tip_hash());
   3713     assert_supply_invariant(&local.ledger);
   3714 }
   3715 
   3716 #[test]
   3717 fn supply_invariant_holds_for_mixed_burns_fees_and_pow_mine_actions() {
   3718     let mut harness = harness_for_percent(27, 25);
   3719     assert_supply_invariant(&harness.ledger);
   3720     let starting_supply = live_supply(&harness.ledger);
   3721     let leader = harness.next_rank(0);
   3722     let finalizer = harness.wallet(&leader.owner).clone();
   3723     let actors = harness
   3724         .honest
   3725         .iter()
   3726         .filter(|wallet| wallet.address() != finalizer.address())
   3727         .cloned()
   3728         .collect::<Vec<_>>();
   3729     assert!(actors.len() >= 4, "test fixture needs non-finalizer actors");
   3730 
   3731     let transfer = harness
   3732         .ledger
   3733         .build_transfer(&actors[0], actors[1].address(), 11, 2)
   3734         .unwrap();
   3735     harness.ledger.submit_transaction(transfer.clone()).unwrap();
   3736     let burn = harness.ledger.build_burn(&actors[2], 7, 3).unwrap();
   3737     harness.ledger.submit_transaction(burn.clone()).unwrap();
   3738     let mine = harness.ledger.build_mine(actors[3].address()).unwrap();
   3739     harness.ledger.submit_transaction(mine.clone()).unwrap();
   3740     let anchor = harness.submit_anchor_burn(&finalizer);
   3741 
   3742     let block = harness.finish_ticket_block_from_pending(0, Vec::new());
   3743     let block_signatures = block
   3744         .transactions
   3745         .iter()
   3746         .map(|transaction| transaction.signature().to_string())
   3747         .collect::<BTreeSet<_>>();
   3748     for transaction in [&transfer, &burn, &mine, &anchor] {
   3749         assert!(
   3750             block_signatures.contains(transaction.signature()),
   3751             "mixed block did not include transaction {}",
   3752             transaction.signature()
   3753         );
   3754     }
   3755 
   3756     harness
   3757         .ledger
   3758         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   3759         .unwrap();
   3760 
   3761     assert_supply_invariant(&harness.ledger);
   3762     assert_eq!(
   3763         live_supply(&harness.ledger),
   3764         starting_supply
   3765             .checked_add(mine.amount())
   3766             .and_then(|supply| supply.checked_add(mine.fee()))
   3767             .and_then(|supply| supply.checked_sub(burn.amount()))
   3768             .and_then(|supply| supply.checked_sub(anchor.amount()))
   3769             .unwrap()
   3770     );
   3771 }
   3772 
   3773 #[test]
   3774 fn mini_supply_oracle_detects_accounting_mutations() {
   3775     let mut harness = harness_for_percent(31, 25);
   3776     let leader = harness.next_rank(0);
   3777     let finalizer = harness.wallet(&leader.owner).clone();
   3778     let actors = harness
   3779         .honest
   3780         .iter()
   3781         .filter(|wallet| wallet.address() != finalizer.address())
   3782         .cloned()
   3783         .collect::<Vec<_>>();
   3784     assert!(actors.len() >= 4, "test fixture needs non-finalizer actors");
   3785 
   3786     let transfer = harness
   3787         .ledger
   3788         .build_transfer(&actors[0], actors[1].address(), 11, 2)
   3789         .unwrap();
   3790     harness.ledger.submit_transaction(transfer).unwrap();
   3791     let burn = harness.ledger.build_burn(&actors[2], 7, 3).unwrap();
   3792     harness.ledger.submit_transaction(burn).unwrap();
   3793     let mine = harness.ledger.build_mine(actors[3].address()).unwrap();
   3794     harness.ledger.submit_transaction(mine).unwrap();
   3795     harness.submit_anchor_burn(&finalizer);
   3796 
   3797     let block = harness.finish_ticket_block_from_pending(0, Vec::new());
   3798     harness
   3799         .ledger
   3800         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   3801         .unwrap();
   3802     let snapshot = harness.ledger.snapshot();
   3803     assert_eq!(
   3804         mini_supply_verdict(&harness.ledger, &snapshot),
   3805         MiniSupplyVerdict::Balanced
   3806     );
   3807 
   3808     let mut reward_inflation = snapshot.clone();
   3809     reward_inflation
   3810         .blocks
   3811         .last_mut()
   3812         .expect("snapshot has a mined block")
   3813         .reward += 1;
   3814     assert_eq!(
   3815         mini_supply_verdict(&harness.ledger, &reward_inflation),
   3816         MiniSupplyVerdict::Mismatch
   3817     );
   3818 
   3819     let mut fee_erasure = snapshot.clone();
   3820     let fee = fee_erasure
   3821         .blocks
   3822         .last_mut()
   3823         .expect("snapshot has a mined block")
   3824         .transactions
   3825         .iter_mut()
   3826         .find_map(|transaction| match transaction {
   3827             Transaction::Transfer { fee, .. } if *fee > 0 => Some(fee),
   3828             _ => None,
   3829         })
   3830         .expect("mixed block has a fee-paying transfer");
   3831     *fee = 0;
   3832     assert_eq!(
   3833         mini_supply_verdict(&harness.ledger, &fee_erasure),
   3834         MiniSupplyVerdict::Mismatch
   3835     );
   3836 
   3837     let mut burn_erasure = snapshot;
   3838     let amount = burn_erasure
   3839         .blocks
   3840         .last_mut()
   3841         .expect("snapshot has a mined block")
   3842         .transactions
   3843         .iter_mut()
   3844         .find_map(|transaction| match transaction {
   3845             Transaction::Burn { amount, .. } if *amount > 0 => Some(amount),
   3846             _ => None,
   3847         })
   3848         .expect("mixed block has a positive burn");
   3849     *amount = 0;
   3850     assert_eq!(
   3851         mini_supply_verdict(&harness.ledger, &burn_erasure),
   3852         MiniSupplyVerdict::Mismatch
   3853     );
   3854 }
   3855 
   3856 #[test]
   3857 fn supply_invariant_tracks_reorg_to_better_fork() {
   3858     let base = harness_for_percent(28, 25);
   3859     let snapshot = base.ledger.snapshot();
   3860     let mut local = fork_harness_from(&base, snapshot.clone());
   3861     let mut remote = fork_harness_from(&base, snapshot);
   3862 
   3863     local.mine_ticket_block(0);
   3864     assert_supply_invariant(&local.ledger);
   3865 
   3866     let remote_leader = remote.next_rank(0);
   3867     let remote_finalizer = remote.wallet(&remote_leader.owner).clone();
   3868     let mine_recipient = remote
   3869         .honest
   3870         .iter()
   3871         .find(|wallet| wallet.address() != remote_finalizer.address())
   3872         .unwrap()
   3873         .clone();
   3874     let remote_mine = remote.ledger.build_mine(mine_recipient.address()).unwrap();
   3875     remote
   3876         .ledger
   3877         .submit_transaction(remote_mine.clone())
   3878         .unwrap();
   3879     let remote_first_block = remote.mine_ticket_block(0);
   3880     assert!(
   3881         remote_first_block
   3882             .transactions
   3883             .iter()
   3884             .any(|transaction| transaction.signature() == remote_mine.signature()),
   3885         "remote fork did not include PoW mine action"
   3886     );
   3887     remote.mine_ticket_block(0);
   3888     assert_supply_invariant(&remote.ledger);
   3889 
   3890     let switched = local
   3891         .ledger
   3892         .extend_from_snapshot_at(remote.ledger.snapshot(), NOW_MS)
   3893         .unwrap();
   3894 
   3895     assert!(switched, "better remote fork should be adopted");
   3896     assert_eq!(local.ledger.tip_hash(), remote.ledger.tip_hash());
   3897     assert_supply_invariant(&local.ledger);
   3898     assert_eq!(live_supply(&local.ledger), live_supply(&remote.ledger));
   3899 }
   3900 
   3901 #[test]
   3902 fn replay_and_double_spend_do_not_change_supply() {
   3903     let mut harness = harness_for_percent(29, 25);
   3904     let leader = harness.next_rank(0);
   3905     let finalizer = harness.wallet(&leader.owner).clone();
   3906     let actors = harness
   3907         .honest
   3908         .iter()
   3909         .filter(|wallet| wallet.address() != finalizer.address())
   3910         .cloned()
   3911         .collect::<Vec<_>>();
   3912     assert!(actors.len() >= 3, "test fixture needs non-finalizer actors");
   3913     let spend_outpoint = harness
   3914         .ledger
   3915         .available_utxos_for_address(actors[0].address())
   3916         .unwrap()
   3917         .first()
   3918         .map(|(outpoint, _)| outpoint.clone())
   3919         .expect("sender should have a spendable output");
   3920     let first_spend = harness
   3921         .ledger
   3922         .build_transfer_with_inputs(
   3923             &actors[0],
   3924             actors[1].address(),
   3925             1,
   3926             1,
   3927             &[spend_outpoint.clone()],
   3928         )
   3929         .unwrap();
   3930     let double_spend = harness
   3931         .ledger
   3932         .build_transfer_with_inputs(&actors[0], actors[2].address(), 1, 2, &[spend_outpoint])
   3933         .unwrap();
   3934     let starting_supply = live_supply(&harness.ledger);
   3935 
   3936     assert_eq!(
   3937         harness
   3938             .ledger
   3939             .submit_transaction_with_outcome(first_spend.clone())
   3940             .unwrap(),
   3941         TransactionSubmitOutcome::Added
   3942     );
   3943     assert_eq!(
   3944         harness
   3945             .ledger
   3946             .submit_transaction_with_outcome(first_spend.clone())
   3947             .unwrap(),
   3948         TransactionSubmitOutcome::AlreadyKnown
   3949     );
   3950     assert_eq!(
   3951         harness
   3952             .ledger
   3953             .submit_transaction_with_outcome(double_spend.clone())
   3954             .unwrap(),
   3955         TransactionSubmitOutcome::ConflictsWithPending
   3956     );
   3957 
   3958     let anchor = harness.submit_anchor_burn(&finalizer);
   3959     let block = harness.finish_ticket_block_from_pending(0, Vec::new());
   3960     let block_signatures = block
   3961         .transactions
   3962         .iter()
   3963         .map(|transaction| transaction.signature().to_string())
   3964         .collect::<BTreeSet<_>>();
   3965     assert!(
   3966         block_signatures.contains(first_spend.signature()),
   3967         "test block did not mine the first spend before replay check"
   3968     );
   3969     assert!(
   3970         block_signatures.contains(anchor.signature()),
   3971         "test block did not mine the anchor burn"
   3972     );
   3973     let mut malicious_block = block.clone();
   3974     malicious_block.transactions.push(double_spend);
   3975     malicious_block.reward = block_reward(&malicious_block.transactions, 0).unwrap();
   3976     malicious_block.hash = malicious_block.compute_hash();
   3977     assert!(
   3978         harness
   3979             .ledger
   3980             .clone()
   3981             .apply_block_at(malicious_block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   3982             .is_err(),
   3983         "double-spend block was accepted"
   3984     );
   3985 
   3986     harness
   3987         .ledger
   3988         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   3989         .unwrap();
   3990     assert_eq!(
   3991         harness
   3992             .ledger
   3993             .submit_transaction_with_outcome(first_spend)
   3994             .unwrap(),
   3995         TransactionSubmitOutcome::AlreadyKnown
   3996     );
   3997     assert_supply_invariant(&harness.ledger);
   3998     assert_eq!(
   3999         live_supply(&harness.ledger),
   4000         starting_supply.checked_sub(anchor.amount()).unwrap()
   4001     );
   4002 }
   4003 
   4004 #[test]
   4005 #[ignore = "long-running adversarial resource matrix; run via deployment.sh"]
   4006 fn adversarial_scenarios_cover_resource_matrix() {
   4007     let strategies = [
   4008         AdversaryStrategy::MaximizeBurnWeight,
   4009         AdversaryStrategy::MaximizeCommitteeWeight,
   4010         AdversaryStrategy::CensorBurns,
   4011         AdversaryStrategy::WithholdBurnFromCommittee,
   4012         AdversaryStrategy::ForceFallback,
   4013         AdversaryStrategy::AttemptRecovery,
   4014         AdversaryStrategy::AddressRotation,
   4015         AdversaryStrategy::CombinedStrategy,
   4016     ];
   4017 
   4018     for (case, strategy) in strategies.into_iter().enumerate() {
   4019         let (burn, lineage) = COMBINED_LEVELS[case % COMBINED_LEVELS.len()];
   4020         let mut harness = Harness::new(100 + case as u64, burn, lineage, strategy);
   4021         let blocks = if matches!(
   4022             strategy,
   4023             AdversaryStrategy::AttemptRecovery | AdversaryStrategy::CombinedStrategy
   4024         ) {
   4025             7
   4026         } else {
   4027             4
   4028         };
   4029         let metrics = harness.run_strategy(blocks);
   4030         assert!(
   4031             metrics.attacker_finalization_share.is_finite()
   4032                 && metrics.attacker_committee_share.is_finite(),
   4033             "strategy={strategy:?} burn={burn}% lineage={lineage}% metrics={metrics:?}",
   4034         );
   4035         match strategy {
   4036             AdversaryStrategy::MaximizeBurnWeight
   4037             | AdversaryStrategy::MaximizeCommitteeWeight
   4038             | AdversaryStrategy::AddressRotation => {
   4039                 assert_eq!(
   4040                     metrics.third_party_burn_censorship_rate, 0.0,
   4041                     "strategy={strategy:?} should not censor burns: {metrics:?}"
   4042                 );
   4043                 assert_eq!(
   4044                     metrics.fallback_rate, 0.0,
   4045                     "strategy={strategy:?} should not force fallbacks: {metrics:?}"
   4046                 );
   4047                 assert_eq!(
   4048                     metrics.recovery_rate, 0.0,
   4049                     "strategy={strategy:?} should not mine recovery blocks: {metrics:?}"
   4050                 );
   4051             }
   4052             AdversaryStrategy::CensorBurns => {
   4053                 assert_eq!(
   4054                     metrics.third_party_burn_censorship_rate, 0.0,
   4055                     "visible burns should be included by consensus-valid ticket blocks: {metrics:?}"
   4056                 );
   4057             }
   4058             AdversaryStrategy::WithholdBurnFromCommittee => {
   4059                 assert!(
   4060                     metrics.third_party_burn_censorship_rate > 0.0,
   4061                     "withheld burns should be absent from produced blocks: {metrics:?}"
   4062                 );
   4063             }
   4064             AdversaryStrategy::ForceFallback => {
   4065                 assert_eq!(
   4066                     metrics.fallback_blocks, metrics.fallback_opportunities,
   4067                     "force-fallback strategy did not use every fallback opportunity: {metrics:?}"
   4068                 );
   4069                 assert_eq!(
   4070                     metrics.recovery_rate, 0.0,
   4071                     "force-fallback strategy should not mine recovery blocks: {metrics:?}"
   4072                 );
   4073             }
   4074             AdversaryStrategy::AttemptRecovery => {
   4075                 assert!(
   4076                     metrics.recovery_rate > 0.0,
   4077                     "recovery strategy did not produce recovery blocks: {metrics:?}"
   4078                 );
   4079             }
   4080             AdversaryStrategy::CombinedStrategy => {
   4081                 assert!(
   4082                     metrics.third_party_burn_censorship_rate > 0.0,
   4083                     "combined strategy did not withhold burns: {metrics:?}"
   4084                 );
   4085                 assert_eq!(
   4086                     metrics.fallback_blocks, metrics.fallback_opportunities,
   4087                     "combined strategy did not use every fallback opportunity: {metrics:?}"
   4088                 );
   4089                 assert!(
   4090                     metrics.recovery_rate > 0.0,
   4091                     "combined strategy did not produce recovery blocks: {metrics:?}"
   4092                 );
   4093             }
   4094             AdversaryStrategy::Honest | AdversaryStrategy::MissRank0 => unreachable!(),
   4095         }
   4096     }
   4097 }
   4098 
   4099 fn assert_honest_resource_matrix_for_burn(burn: u8) {
   4100     for lineage in LEVELS {
   4101         let mut harness = Harness::new(
   4102             200 + u64::from(burn) * 10 + u64::from(lineage),
   4103             burn,
   4104             lineage,
   4105             AdversaryStrategy::Honest,
   4106         );
   4107         let metrics = harness.run_strategy(3);
   4108         assert!(
   4109             metrics.attacker_finalization_share.is_finite()
   4110                 && metrics.attacker_committee_share.is_finite(),
   4111             "burn={burn}% lineage={lineage}% configured={:?} metrics={metrics:?}",
   4112             (
   4113                 harness.resource.burn_percent,
   4114                 harness.resource.lineage_percent
   4115             ),
   4116         );
   4117         assert_eq!(
   4118             metrics.third_party_burn_censorship_rate, 0.0,
   4119             "honest resource matrix run censored third-party burns: {metrics:?}"
   4120         );
   4121         assert_eq!(
   4122             metrics.fallback_rate, 0.0,
   4123             "honest resource matrix run produced fallback blocks: {metrics:?}"
   4124         );
   4125         assert_eq!(
   4126             metrics.recovery_rate, 0.0,
   4127             "honest resource matrix run produced recovery blocks: {metrics:?}"
   4128         );
   4129     }
   4130 }
   4131 
   4132 macro_rules! honest_resource_matrix_tests {
   4133     ($($name:ident: $burn:literal),+ $(,)?) => {
   4134         $(
   4135             #[test]
   4136             #[ignore = "long-running adversarial resource matrix; run via deployment.sh"]
   4137             fn $name() {
   4138                 assert_honest_resource_matrix_for_burn($burn);
   4139             }
   4140         )+
   4141     };
   4142 }
   4143 
   4144 honest_resource_matrix_tests! {
   4145     adversarial_honest_resource_matrix_burn_1: 1,
   4146     adversarial_honest_resource_matrix_burn_5: 5,
   4147     adversarial_honest_resource_matrix_burn_10: 10,
   4148     adversarial_honest_resource_matrix_burn_25: 25,
   4149     adversarial_honest_resource_matrix_burn_33: 33,
   4150     adversarial_honest_resource_matrix_burn_50: 50,
   4151 }
   4152 
   4153 #[test]
   4154 #[ignore = "long-running economic attack sweep; run via deployment.sh"]
   4155 fn attack_economics_visible_burns_have_no_finite_censorship_price() {
   4156     let cases = [(1, 1), (10, 1), (10, 25), (25, 10), (50, 50)];
   4157 
   4158     for (case, (burn, lineage)) in cases.into_iter().enumerate() {
   4159         let mut harness = Harness::new(
   4160             1_000 + case as u64,
   4161             burn,
   4162             lineage,
   4163             AdversaryStrategy::CensorBurns,
   4164         );
   4165         let metrics = harness.run_strategy(4);
   4166 
   4167         assert_eq!(
   4168             metrics.third_party_burns, 4,
   4169             "case={case} burn={burn}% lineage={lineage}% did not create visible victim burns: {metrics:?}"
   4170         );
   4171         assert_eq!(
   4172             metrics.censored_third_party_burns, 0,
   4173             "case={case} burn={burn}% lineage={lineage}% censored visible burns: {metrics:?}"
   4174         );
   4175         assert_eq!(
   4176             metrics.successful_censorship_cost_per_burn(),
   4177             None,
   4178             "case={case} burn={burn}% lineage={lineage}% found a finite price for visible burn censorship: {metrics:?}"
   4179         );
   4180         assert_eq!(
   4181             metrics.fallback_rate, 0.0,
   4182             "case={case} burn={burn}% lineage={lineage}% needed fallback to keep censoring: {metrics:?}"
   4183         );
   4184         assert_eq!(
   4185             metrics.recovery_rate, 0.0,
   4186             "case={case} burn={burn}% lineage={lineage}% needed recovery to keep censoring: {metrics:?}"
   4187         );
   4188     }
   4189 }
   4190 
   4191 #[test]
   4192 #[ignore = "long-running economic attack sweep; run via deployment.sh"]
   4193 fn economic_sweep_runner_classifies_attack_costs_across_dimensions() {
   4194     let cases = [
   4195         EconomicSweepCase {
   4196             seed: 1_300,
   4197             burn_percent: 25,
   4198             lineage_percent: 25,
   4199             peer_isolation_percent: 0,
   4200             gossip_latency_blocks: 0,
   4201             offline_finalizer_percent: 0,
   4202             fee_pressure_burns_per_block: 0,
   4203         },
   4204         EconomicSweepCase {
   4205             seed: 1_301,
   4206             burn_percent: 25,
   4207             lineage_percent: 25,
   4208             peer_isolation_percent: 100,
   4209             gossip_latency_blocks: 0,
   4210             offline_finalizer_percent: 0,
   4211             fee_pressure_burns_per_block: 0,
   4212         },
   4213         EconomicSweepCase {
   4214             seed: 1_302,
   4215             burn_percent: 25,
   4216             lineage_percent: 25,
   4217             peer_isolation_percent: 0,
   4218             gossip_latency_blocks: 2,
   4219             offline_finalizer_percent: 0,
   4220             fee_pressure_burns_per_block: 0,
   4221         },
   4222         EconomicSweepCase {
   4223             seed: 1_303,
   4224             burn_percent: 50,
   4225             lineage_percent: 25,
   4226             peer_isolation_percent: 0,
   4227             gossip_latency_blocks: 0,
   4228             offline_finalizer_percent: 100,
   4229             fee_pressure_burns_per_block: 0,
   4230         },
   4231         EconomicSweepCase {
   4232             seed: 1_304,
   4233             burn_percent: 50,
   4234             lineage_percent: 50,
   4235             peer_isolation_percent: 0,
   4236             gossip_latency_blocks: 0,
   4237             offline_finalizer_percent: 0,
   4238             fee_pressure_burns_per_block: 4,
   4239         },
   4240         EconomicSweepCase {
   4241             seed: 1_305,
   4242             burn_percent: 50,
   4243             lineage_percent: 50,
   4244             peer_isolation_percent: 50,
   4245             gossip_latency_blocks: 0,
   4246             offline_finalizer_percent: 100,
   4247             fee_pressure_burns_per_block: 0,
   4248         },
   4249     ];
   4250 
   4251     let results = run_economic_sweep(&cases, 4);
   4252     assert_eq!(results.len(), cases.len());
   4253     for (index, result) in results.iter().enumerate() {
   4254         assert_eq!(result.case.seed, cases[index].seed);
   4255         assert!(
   4256             result.metrics.attacker_finalization_share.is_finite()
   4257                 && result.metrics.attacker_committee_share.is_finite()
   4258                 && result.metrics.third_party_burn_censorship_rate.is_finite()
   4259                 && result.metrics.fallback_rate.is_finite()
   4260                 && result.metrics.recovery_rate.is_finite(),
   4261             "economic sweep produced non-finite metrics: {result:?}"
   4262         );
   4263     }
   4264 
   4265     assert_eq!(results[0].strategy, AdversaryStrategy::CensorBurns);
   4266     assert_eq!(results[0].class, EconomicAttackClass::NoCensorship);
   4267     assert_eq!(results[0].metrics.censored_third_party_burns, 0);
   4268     assert_eq!(
   4269         results[0].metrics.successful_censorship_cost_per_burn(),
   4270         None
   4271     );
   4272 
   4273     assert_eq!(
   4274         results[1].class,
   4275         EconomicAttackClass::RequiresNetworkIsolation
   4276     );
   4277     assert!(results[1].metrics.unseen_third_party_burns > 0);
   4278     assert_eq!(results[1].metrics.censored_third_party_burns, 0);
   4279     assert_eq!(
   4280         results[2].class,
   4281         EconomicAttackClass::RequiresNetworkIsolation
   4282     );
   4283     assert!(results[2].metrics.unseen_third_party_burns > 0);
   4284     assert!(results[2].metrics.delayed_third_party_burns > 0);
   4285 
   4286     assert_eq!(results[3].class, EconomicAttackClass::NoCensorship);
   4287     assert_eq!(
   4288         results[3].metrics.fallback_blocks, results[3].metrics.fallback_opportunities,
   4289         "offline-finalizer sweep did not use available fallback pressure: {:?}",
   4290         results[3]
   4291     );
   4292 
   4293     assert_eq!(results[4].strategy, AdversaryStrategy::MaximizeBurnWeight);
   4294     assert_eq!(results[4].class, EconomicAttackClass::RequiresFeePressure);
   4295     assert!(results[4].metrics.fee_pressure_burns > 0);
   4296     assert!(results[4].metrics.fee_pressure_displaced_burns > 0);
   4297 
   4298     assert_eq!(results[5].strategy, AdversaryStrategy::CombinedStrategy);
   4299     assert_eq!(
   4300         results[5].class,
   4301         EconomicAttackClass::RequiresNetworkIsolation
   4302     );
   4303     assert!(results[5].metrics.unseen_third_party_burns > 0);
   4304     assert_eq!(results[5].metrics.censored_third_party_burns, 0);
   4305     assert!(results[5].metrics.fallback_opportunities > 0);
   4306     assert!(results[5].metrics.fallback_blocks > 0);
   4307     assert!(results[5].metrics.recovery_rate > 0.0);
   4308     assert_eq!(
   4309         results[5].metrics.successful_censorship_cost_per_burn(),
   4310         None
   4311     );
   4312 }
   4313 
   4314 #[test]
   4315 #[ignore = "long-running economic attack sweep; run via deployment.sh"]
   4316 fn attack_economics_withheld_burns_require_gossip_isolation() {
   4317     let mut visible = Harness::new(1_100, 25, 25, AdversaryStrategy::CensorBurns);
   4318     let visible_metrics = visible.run_strategy(4);
   4319     assert_eq!(
   4320         visible_metrics.censored_third_party_burns, 0,
   4321         "visible victim burns should not be censored: {visible_metrics:?}"
   4322     );
   4323 
   4324     let mut withheld = Harness::new(1_100, 25, 25, AdversaryStrategy::WithholdBurnFromCommittee);
   4325     let withheld_metrics = withheld.run_strategy(4);
   4326     assert_eq!(
   4327         withheld_metrics.third_party_burns, 4,
   4328         "withheld scenario did not create victim burns: {withheld_metrics:?}"
   4329     );
   4330     assert_eq!(
   4331         withheld_metrics.censored_third_party_burns, 4,
   4332         "burns kept out of local relay should be absent from produced blocks: {withheld_metrics:?}"
   4333     );
   4334     assert_eq!(
   4335         withheld_metrics.fallback_rate, 0.0,
   4336         "gossip isolation should not require fallback blocks in this model: {withheld_metrics:?}"
   4337     );
   4338     assert_eq!(
   4339         withheld_metrics.recovery_rate, 0.0,
   4340         "gossip isolation should not require recovery blocks in this model: {withheld_metrics:?}"
   4341     );
   4342 }
   4343 
   4344 #[test]
   4345 #[ignore = "long-running economic attack sweep; run via deployment.sh"]
   4346 fn attack_economics_committee_capture_requires_matured_lineage_weight() {
   4347     let mut low_lineage = Harness::new(1_200, 10, 1, AdversaryStrategy::MaximizeCommitteeWeight);
   4348     let low_metrics = low_lineage.run_strategy(8);
   4349     let mut high_lineage = Harness::new(1_200, 10, 50, AdversaryStrategy::MaximizeCommitteeWeight);
   4350     let high_metrics = high_lineage.run_strategy(8);
   4351 
   4352     assert!(
   4353         high_metrics.attacker_committee_slots >= low_metrics.attacker_committee_slots,
   4354         "more matured attacker lineage roots should not reduce committee capture in this deterministic sweep; low={low_metrics:?} high={high_metrics:?}"
   4355     );
   4356     assert!(
   4357         high_metrics.committee_slots >= low_metrics.committee_slots,
   4358         "matured lineage sweep should keep committee slots available; low={low_metrics:?} high={high_metrics:?}"
   4359     );
   4360     assert_eq!(
   4361         low_metrics.third_party_burn_censorship_rate, 0.0,
   4362         "low-lineage committee strategy should not censor by itself: {low_metrics:?}"
   4363     );
   4364     assert_eq!(
   4365         high_metrics.third_party_burn_censorship_rate, 0.0,
   4366         "high-lineage committee strategy should not censor by itself: {high_metrics:?}"
   4367     );
   4368 }
   4369 
   4370 #[test]
   4371 fn performance_budget_block_validation_rejects_count_and_byte_overflow() {
   4372     let mut harness = harness_for_percent(1_500, 25);
   4373     harness.set_next_height(TRANSACTION_V2_ACTIVATION_HEIGHT.unwrap().saturating_add(1));
   4374     let leader = harness.next_rank(0);
   4375     let finalizer = harness.wallet(&leader.owner).clone();
   4376     let anchor = harness
   4377         .ledger
   4378         .build_burn_for_next_block(&finalizer, 1, 1)
   4379         .unwrap();
   4380     harness.ledger.submit_transaction(anchor).unwrap();
   4381     let block = harness.finish_ticket_block_from_pending(0, Vec::new());
   4382     let now_ms = NOW_MS.saturating_add(block.timestamp_ms);
   4383 
   4384     let mut count_limited = harness.ledger.clone();
   4385     count_limited.launch_profile.max_block_transactions = block.transactions.len();
   4386     count_limited
   4387         .apply_block_at(block.clone(), now_ms)
   4388         .expect("block at transaction-count budget should validate");
   4389 
   4390     let mut count_overflow = harness.ledger.clone();
   4391     count_overflow.launch_profile.max_block_transactions =
   4392         block.transactions.len().saturating_sub(1);
   4393     assert!(
   4394         count_overflow
   4395             .apply_block_at(block.clone(), now_ms)
   4396             .is_err(),
   4397         "block over transaction-count budget validated"
   4398     );
   4399 
   4400     let block_bytes = harness.ledger.consensus_block_size_bytes(&block).unwrap();
   4401     let mut byte_limited = harness.ledger.clone();
   4402     byte_limited.launch_profile.max_block_bytes = block_bytes;
   4403     byte_limited
   4404         .apply_block_at(block.clone(), now_ms)
   4405         .expect("block at byte budget should validate");
   4406 
   4407     let mut byte_overflow = harness.ledger;
   4408     byte_overflow.launch_profile.max_block_bytes = block_bytes.saturating_sub(1);
   4409     assert!(
   4410         byte_overflow.apply_block_at(block, now_ms).is_err(),
   4411         "block over byte budget validated"
   4412     );
   4413 }
   4414 
   4415 #[test]
   4416 fn performance_budget_burn_bundle_processing_respects_10kb_cap() {
   4417     let finalizer = Wallet::from_seed("burn-bundle-budget-finalizer");
   4418     let victims = (0..128)
   4419         .map(|index| Wallet::from_seed(&format!("burn-bundle-budget-victim-{index}")))
   4420         .collect::<Vec<_>>();
   4421     let mut allocations = BTreeMap::new();
   4422     allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
   4423     for victim in &victims {
   4424         allocations.insert(victim.address().to_string(), 10 * MICRO_IUNA);
   4425     }
   4426     let mut ledger = Ledger::new_with_genesis_burns(
   4427         allocations,
   4428         vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
   4429         1,
   4430     )
   4431     .unwrap();
   4432 
   4433     for victim in &victims {
   4434         let burn = ledger.build_burn(victim, 1, 1).unwrap();
   4435         ledger.submit_transaction(burn).unwrap();
   4436     }
   4437 
   4438     let bundle = ledger
   4439         .build_burn_bundle(&finalizer)
   4440         .unwrap()
   4441         .expect("pending burns should produce a bundle");
   4442     let bundle_size = bundle.serialized_size_bytes().unwrap();
   4443     assert!(
   4444         bundle_size <= MAX_BURN_BUNDLE_BYTES,
   4445         "selected burn bundle exceeds budget: {bundle_size} > {MAX_BURN_BUNDLE_BYTES}"
   4446     );
   4447 
   4448     let selected = bundle
   4449         .burns
   4450         .iter()
   4451         .map(|burn| burn.signature().to_string())
   4452         .collect::<BTreeSet<_>>();
   4453     let excluded = ledger
   4454         .pending()
   4455         .iter()
   4456         .find(|transaction| transaction.is_burn() && !selected.contains(transaction.signature()))
   4457         .expect("fixture should contain at least one burn that does not fit");
   4458     let mut oversized = bundle.clone();
   4459     oversized.burns.push(excluded.clone());
   4460     assert!(
   4461         oversized.serialized_size_bytes().unwrap() > MAX_BURN_BUNDLE_BYTES,
   4462         "adding one more burn should exceed the bundle budget"
   4463     );
   4464 
   4465     let prepared = ledger
   4466         .prepare_next_block_with_burn_bundles(finalizer.address(), 1, vec![bundle])
   4467         .unwrap();
   4468     let block = finish_prepared_block(&finalizer, prepared);
   4469     ledger
   4470         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   4471         .unwrap();
   4472 }
   4473 
   4474 #[test]
   4475 #[ignore = "long-running snapshot replay budget coverage; run via deployment.sh"]
   4476 fn performance_budget_snapshot_replay_handles_multi_block_chain() {
   4477     let mut harness = harness_for_percent(1_501, 25);
   4478     for _ in 0..32 {
   4479         harness.mine_ticket_block(0);
   4480     }
   4481     let snapshot = harness.ledger.snapshot();
   4482     let expected_height = harness.ledger.height();
   4483     let expected_tip = harness.ledger.tip_hash().to_string();
   4484     let expected_supply = live_supply(&harness.ledger);
   4485 
   4486     let restored = Ledger::from_persisted_snapshot(snapshot).unwrap();
   4487 
   4488     assert_eq!(restored.height(), expected_height);
   4489     assert_eq!(restored.tip_hash(), expected_tip);
   4490     assert_eq!(live_supply(&restored), expected_supply);
   4491     assert_mini_ticket_inventory_matches(&restored);
   4492     assert_mini_burn_committee_matches(&restored);
   4493 }
   4494 
   4495 #[test]
   4496 fn blockspace_flood_stays_bounded_by_transaction_count_and_bytes() {
   4497     let finalizer = Wallet::from_seed("blockspace-flood-finalizer");
   4498     let recipient = Wallet::from_seed("blockspace-flood-recipient");
   4499     let max_test_transactions = 64;
   4500     let senders = (0..(max_test_transactions + 32))
   4501         .map(|index| Wallet::from_seed(&format!("blockspace-flood-sender-{index}")))
   4502         .collect::<Vec<_>>();
   4503     let v2_migrators = (0..32)
   4504         .map(|index| Wallet::from_seed(&format!("blockspace-flood-v2-migrator-{index}")))
   4505         .collect::<Vec<_>>();
   4506     let mut allocations = BTreeMap::new();
   4507     allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA);
   4508     allocations.insert(recipient.address().to_string(), 10 * MICRO_IUNA);
   4509     for wallet in senders.iter().chain(&v2_migrators) {
   4510         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   4511     }
   4512     let mut ledger = Ledger::new_with_genesis_burns(
   4513         allocations,
   4514         vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)],
   4515         1,
   4516     )
   4517     .unwrap();
   4518     let next_height = TRANSACTION_V2_ACTIVATION_HEIGHT.unwrap().saturating_add(1);
   4519     ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1);
   4520     for ticket in &mut ledger.tickets {
   4521         ticket.eligible_from_height = next_height;
   4522         ticket.eligible_until_height = next_height;
   4523     }
   4524     ledger.launch_profile.max_block_transactions = max_test_transactions;
   4525 
   4526     for sender in &senders {
   4527         let tx = ledger
   4528             .build_transfer(sender, recipient.address(), 1, 1)
   4529             .unwrap();
   4530         ledger.submit_transaction(tx).unwrap();
   4531     }
   4532     for migrator in &v2_migrators {
   4533         let transaction = ledger.build_v2_migration(migrator, 10).unwrap();
   4534         ledger.submit_transaction_v2(transaction).unwrap();
   4535     }
   4536     let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap();
   4537     ledger.submit_transaction(anchor.clone()).unwrap();
   4538 
   4539     let prepared = ledger
   4540         .prepare_next_block_with_burn_bundles(finalizer.address(), 1, Vec::new())
   4541         .unwrap();
   4542     let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds());
   4543     let block = prepared.finish(&finalizer, vdf_output);
   4544 
   4545     assert!(
   4546         block
   4547             .transactions
   4548             .iter()
   4549             .any(|tx| tx.signature() == anchor.signature()),
   4550         "flooded block did not preserve the required finalizer anchor burn"
   4551     );
   4552     let selected_transactions = block.transactions.len() + block.transactions_v2.len();
   4553     assert!(!block.transactions_v2.is_empty());
   4554     assert!(
   4555         selected_transactions <= max_test_transactions,
   4556         "block selected too many transactions: {selected_transactions} > {max_test_transactions}"
   4557     );
   4558     let block_bytes = ledger.consensus_block_size_bytes(&block).unwrap();
   4559     assert!(
   4560         block_bytes <= MAX_BLOCK_BYTES,
   4561         "block exceeded byte limit under flood: {block_bytes} > {MAX_BLOCK_BYTES}"
   4562     );
   4563 
   4564     ledger
   4565         .apply_block_at(block, NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS))
   4566         .unwrap();
   4567     assert!(
   4568         !ledger.pending().is_empty() || !ledger.pending_v2().is_empty(),
   4569         "blockspace flood should leave excess paid transactions pending instead of exceeding limits"
   4570     );
   4571 }