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 §ion.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 §ion.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 §ion.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 }