ledger_chain.rs (32115B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Result, bail}; 4 5 use crate::compact::CompactBlockContext; 6 7 use super::fork::{FinalityCheckpoint, ForkChoice, ForkPoint, ForkQuality}; 8 use super::genesis::{build_genesis_block, utxos_after_genesis, validate_genesis_block}; 9 use super::ledger_ops::validate_genesis_allocations; 10 use super::ticket::genesis_tickets; 11 use super::{ 12 Amount, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MINE_REWARD, 13 OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, unix_now_ms, 14 }; 15 16 impl Ledger { 17 pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u64) -> Self { 18 Self::new_with_genesis_transactions( 19 genesis_allocations, 20 Vec::new(), 21 vdf_rounds, 22 LaunchProfile::default(), 23 ) 24 .expect("empty genesis transactions are valid") 25 } 26 27 pub fn new_with_genesis_burns( 28 genesis_allocations: BTreeMap<String, Amount>, 29 genesis_burns: Vec<GenesisBurn>, 30 vdf_rounds: u64, 31 ) -> Result<Self> { 32 Self::new_with_genesis_burns_and_profile( 33 genesis_allocations, 34 genesis_burns, 35 vdf_rounds, 36 LaunchProfile::default(), 37 ) 38 } 39 40 pub fn new_with_genesis_burns_and_profile( 41 genesis_allocations: BTreeMap<String, Amount>, 42 genesis_burns: Vec<GenesisBurn>, 43 vdf_rounds: u64, 44 launch_profile: LaunchProfile, 45 ) -> Result<Self> { 46 let transactions = genesis_burns 47 .into_iter() 48 .map(|burn| { 49 let allocation = genesis_allocations 50 .get(&burn.from) 51 .copied() 52 .unwrap_or_default(); 53 Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) 54 }) 55 .collect::<Result<Vec<_>>>()?; 56 Self::new_with_genesis_transactions( 57 genesis_allocations, 58 transactions, 59 vdf_rounds, 60 launch_profile, 61 ) 62 } 63 64 fn new_with_genesis_transactions( 65 genesis_allocations: BTreeMap<String, Amount>, 66 genesis_transactions: Vec<Transaction>, 67 vdf_rounds: u64, 68 launch_profile: LaunchProfile, 69 ) -> Result<Self> { 70 validate_genesis_allocations(&genesis_allocations)?; 71 let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); 72 let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; 73 let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; 74 let compact_block_context = if genesis_allocations.is_empty() { 75 CompactBlockContext::default() 76 } else { 77 CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))? 78 }; 79 let mined_transaction_ids = genesis 80 .transactions 81 .iter() 82 .map(|transaction| transaction.signature().to_string()) 83 .collect(); 84 Ok(Self { 85 chain: vec![genesis], 86 genesis_allocations: genesis_allocations.clone(), 87 utxos, 88 utxo_lineage: BTreeMap::new(), 89 lineage_values: BTreeMap::new(), 90 lineage_owners: BTreeMap::new(), 91 tickets, 92 mined_transaction_ids, 93 pending: Vec::new(), 94 orphans: Vec::new(), 95 pending_bytes: 0, 96 orphan_bytes: 0, 97 pending_v2: Vec::new(), 98 pending_v2_bytes: 0, 99 mine_reward: MINE_REWARD, 100 mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], 101 initial_vdf_rounds: vdf_rounds, 102 vdf_rounds, 103 launch_profile, 104 compact_block_context, 105 objective_finality_checkpoint: None, 106 }) 107 } 108 109 pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> { 110 Self::from_snapshot_at(snapshot, unix_now_ms()) 111 } 112 113 pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> { 114 Self::from_persisted_snapshot_revalidating_from(snapshot, Some(1)) 115 } 116 117 /// Restore a local snapshot and only revalidate blocks at or above the supplied height. 118 /// `None` trusts every persisted block while rebuilding its derived in-memory state. 119 pub fn from_persisted_snapshot_revalidating_from( 120 snapshot: ChainSnapshot, 121 revalidate_from_height: Option<u64>, 122 ) -> Result<Self> { 123 let verify_vdf_from_height = if cfg!(feature = "e2e") 124 && snapshot.launch_profile.profile_id == LaunchProfile::local_testnet().profile_id 125 { 126 None 127 } else { 128 revalidate_from_height 129 }; 130 let trusted_before_height = revalidate_from_height.unwrap_or(u64::MAX); 131 Self::from_snapshot_with_revalidation_policy( 132 snapshot, 133 Some(trusted_before_height), 134 revalidate_from_height, 135 verify_vdf_from_height, 136 u64::MAX, 137 ) 138 } 139 140 /// Restore state from a local snapshot already trusted under the current consensus ruleset. 141 pub fn from_locally_verified_snapshot(snapshot: ChainSnapshot) -> Result<Self> { 142 Self::from_persisted_snapshot_revalidating_from(snapshot, None) 143 } 144 145 pub(crate) fn from_preverified_snapshot(snapshot: ChainSnapshot) -> Result<Self> { 146 Self::from_preverified_snapshot_at(snapshot, u64::MAX) 147 } 148 149 pub(crate) fn from_preverified_snapshot_at( 150 snapshot: ChainSnapshot, 151 now_ms: u64, 152 ) -> Result<Self> { 153 Self::from_snapshot_with_vdf_policy(snapshot, false, now_ms) 154 } 155 156 pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> { 157 Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms) 158 } 159 160 pub(crate) fn from_snapshot_with_vdf_policy( 161 snapshot: ChainSnapshot, 162 verify_vdf: bool, 163 now_ms: u64, 164 ) -> Result<Self> { 165 Self::from_snapshot_with_revalidation_policy( 166 snapshot, 167 None, 168 Some(0), 169 verify_vdf.then_some(0), 170 now_ms, 171 ) 172 } 173 174 fn from_snapshot_with_revalidation_policy( 175 snapshot: ChainSnapshot, 176 trusted_before_height: Option<u64>, 177 revalidate_from_height: Option<u64>, 178 verify_vdf_from_height: Option<u64>, 179 now_ms: u64, 180 ) -> Result<Self> { 181 let ChainSnapshot { 182 genesis_allocations, 183 vdf_rounds, 184 launch_profile, 185 blocks, 186 } = snapshot; 187 188 if blocks.is_empty() { 189 bail!("chain snapshot is empty"); 190 } 191 192 validate_genesis_allocations(&genesis_allocations)?; 193 let genesis = blocks[0].clone(); 194 validate_genesis_block(&genesis)?; 195 let expected_genesis = 196 build_genesis_block(&genesis_allocations, genesis.transactions.clone()); 197 if genesis != expected_genesis { 198 bail!("chain snapshot genesis does not match its allocations and transactions"); 199 } 200 let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; 201 let compact_block_context = 202 CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?; 203 let mined_transaction_ids = genesis 204 .transactions 205 .iter() 206 .map(|transaction| transaction.signature().to_string()) 207 .collect(); 208 209 let mut ledger = Self { 210 chain: vec![genesis], 211 genesis_allocations, 212 utxos, 213 utxo_lineage: BTreeMap::new(), 214 lineage_values: BTreeMap::new(), 215 lineage_owners: BTreeMap::new(), 216 tickets: Vec::new(), 217 mined_transaction_ids, 218 pending: Vec::new(), 219 orphans: Vec::new(), 220 pending_bytes: 0, 221 orphan_bytes: 0, 222 pending_v2: Vec::new(), 223 pending_v2_bytes: 0, 224 mine_reward: MINE_REWARD, 225 mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], 226 initial_vdf_rounds: vdf_rounds, 227 vdf_rounds, 228 launch_profile, 229 compact_block_context, 230 objective_finality_checkpoint: None, 231 }; 232 ledger.tickets = genesis_tickets( 233 &ledger.genesis_allocations, 234 ledger.tip(), 235 &ledger.launch_profile, 236 )?; 237 238 for block in blocks.into_iter().skip(1) { 239 if trusted_before_height.is_some_and(|height| block.height < height) { 240 ledger.apply_trusted_block_at(block)?; 241 } else if revalidate_from_height.is_some_and(|height| block.height >= height) { 242 if verify_vdf_from_height.is_some_and(|height| block.height >= height) { 243 ledger.apply_block_at(block, now_ms)?; 244 } else { 245 ledger.apply_preverified_block_at(block, now_ms)?; 246 } 247 } else { 248 ledger.apply_preverified_block_at(block, now_ms)?; 249 } 250 } 251 Ok(ledger) 252 } 253 254 pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> { 255 self.extend_from_snapshot_with_vdf_policy(snapshot, true, unix_now_ms()) 256 } 257 258 /// Applies normal snapshot identity, consensus, finality, and fork-choice checks while 259 /// skipping only VDF verification. Accelerated e2e tests verify newly produced proofs 260 /// separately because their committed fixture uses the production VDF round count. 261 #[cfg(feature = "e2e")] 262 pub fn extend_from_preverified_snapshot_for_e2e( 263 &mut self, 264 snapshot: ChainSnapshot, 265 ) -> Result<bool> { 266 self.extend_from_preverified_snapshot_at(snapshot, u64::MAX) 267 } 268 269 /// Applies all normal snapshot and fork-choice validation while skipping only VDF proof 270 /// verification. Callers must verify every untrusted block proof separately before adopting 271 /// the returned ledger. 272 pub(crate) fn extend_from_preverified_snapshot_at( 273 &mut self, 274 snapshot: ChainSnapshot, 275 now_ms: u64, 276 ) -> Result<bool> { 277 self.extend_from_snapshot_with_vdf_policy(snapshot, false, now_ms) 278 } 279 280 #[allow(dead_code)] 281 pub(crate) fn extend_from_snapshot_at( 282 &mut self, 283 snapshot: ChainSnapshot, 284 now_ms: u64, 285 ) -> Result<bool> { 286 self.extend_from_snapshot_with_vdf_policy(snapshot, true, now_ms) 287 } 288 289 pub(crate) fn extend_from_snapshot_with_vdf_policy( 290 &mut self, 291 snapshot: ChainSnapshot, 292 verify_vdf: bool, 293 now_ms: u64, 294 ) -> Result<bool> { 295 self.validate_snapshot_identity(&snapshot)?; 296 let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, now_ms)?; 297 let fork_point = self.fork_point_with_candidate(&candidate)?; 298 299 if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal { 300 return Ok(false); 301 } 302 303 self.replace_with_better_chain(candidate, fork_point); 304 305 Ok(true) 306 } 307 308 fn validate_snapshot_identity(&self, snapshot: &ChainSnapshot) -> Result<u64> { 309 if snapshot.blocks.is_empty() { 310 bail!("chain snapshot is empty"); 311 } 312 if snapshot.vdf_rounds != self.initial_vdf_rounds { 313 bail!("chain snapshot initial VDF rounds do not match local chain"); 314 } 315 if snapshot.launch_profile != self.launch_profile { 316 bail!("chain snapshot launch profile does not match local chain"); 317 } 318 if snapshot.genesis_allocations != self.genesis_allocations { 319 bail!("chain snapshot genesis allocations do not match local chain"); 320 } 321 if snapshot.blocks[0].hash != self.genesis_hash() { 322 bail!("chain snapshot genesis does not match local chain"); 323 } 324 325 let remote_height = snapshot 326 .blocks 327 .last() 328 .map(|block| block.height) 329 .unwrap_or(0); 330 331 Ok(remote_height) 332 } 333 334 fn fork_point_with_candidate(&self, candidate: &Ledger) -> Result<ForkPoint> { 335 if candidate.genesis_hash() != self.genesis_hash() { 336 bail!("candidate chain has no common genesis block"); 337 } 338 let max_common_index = self.chain.len().min(candidate.chain.len()) - 1; 339 for index in 0..=max_common_index { 340 if self.chain[index] != candidate.chain[index] { 341 if index == 0 { 342 bail!("candidate chain has no common genesis block"); 343 } 344 return Ok(ForkPoint { 345 common_ancestor_height: index as u64 - 1, 346 }); 347 } 348 } 349 Ok(ForkPoint { 350 common_ancestor_height: max_common_index as u64, 351 }) 352 } 353 354 fn choose_fork(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkChoice { 355 let local_height = self.height(); 356 let remote_height = candidate.height(); 357 if remote_height == local_height && candidate.tip().hash == self.tip().hash { 358 return ForkChoice::KeepLocal; 359 } 360 361 if fork_point.first_diverging_height() < OBJECTIVE_FINALITY_ACTIVATION_HEIGHT { 362 if local_height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT 363 || fork_rewrites_finalized_history(local_height, fork_point.common_ancestor_height) 364 { 365 return ForkChoice::KeepLocal; 366 } 367 } else { 368 match objective_finality_quality( 369 self.objective_finality_checkpoint.as_ref(), 370 candidate.objective_finality_checkpoint.as_ref(), 371 ) { 372 ForkQuality::RemoteBetter => return ForkChoice::SwitchToCandidate, 373 ForkQuality::LocalBetter => return ForkChoice::KeepLocal, 374 ForkQuality::Equal => {} 375 } 376 } 377 378 if remote_height > local_height { 379 return ForkChoice::SwitchToCandidate; 380 } 381 if remote_height < local_height { 382 return ForkChoice::KeepLocal; 383 } 384 385 match self.fork_quality(candidate, fork_point) { 386 ForkQuality::RemoteBetter => ForkChoice::SwitchToCandidate, 387 ForkQuality::LocalBetter | ForkQuality::Equal => ForkChoice::KeepLocal, 388 } 389 } 390 391 fn fork_quality(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkQuality { 392 let local_fork = self 393 .chain 394 .iter() 395 .skip(fork_point.first_diverging_height() as usize); 396 let remote_fork = candidate 397 .chain 398 .iter() 399 .skip(fork_point.first_diverging_height() as usize); 400 for (local, remote) in local_fork.zip(remote_fork) { 401 match local.leader_score().cmp(&remote.leader_score()) { 402 std::cmp::Ordering::Equal => continue, 403 ordering => return ForkQuality::from(ordering), 404 } 405 } 406 ForkQuality::Equal 407 } 408 409 fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) { 410 let mut carry_forward = self.pending.clone(); 411 carry_forward.extend(self.orphans.clone()); 412 for block in self 413 .chain 414 .iter() 415 .skip(fork_point.first_diverging_height() as usize) 416 { 417 carry_forward.extend(block.transactions.clone()); 418 } 419 420 let mined_signatures = candidate 421 .chain 422 .iter() 423 .flat_map(|block| block.transactions.iter()) 424 .map(|tx| tx.signature().to_string()) 425 .collect::<BTreeSet<_>>(); 426 for transaction in carry_forward { 427 if !mined_signatures.contains(transaction.signature()) { 428 let _ = candidate.submit_transaction(transaction); 429 } 430 } 431 432 let mut carry_forward_v2 = Vec::new(); 433 for block in self 434 .chain 435 .iter() 436 .skip(fork_point.first_diverging_height() as usize) 437 { 438 for envelope in &block.transactions_v2 { 439 if let Ok(bytes) = super::decode_hex(envelope) 440 && let Ok(transaction) = self.decode_transaction_v2(&bytes) 441 { 442 carry_forward_v2.push(transaction); 443 } 444 } 445 } 446 carry_forward_v2.extend(self.pending_v2.clone()); 447 let candidate_domain = candidate.transaction_v2_domain().ok(); 448 for transaction in carry_forward_v2 { 449 let already_mined = candidate_domain.as_ref().is_some_and(|domain| { 450 transaction 451 .transaction_id(domain) 452 .map(super::hex_encode) 453 .is_ok_and(|id| candidate.mined_transaction_ids.contains(&id)) 454 }); 455 if !already_mined { 456 let _ = candidate.submit_transaction_v2(transaction); 457 } 458 } 459 460 *self = candidate; 461 } 462 } 463 464 fn objective_finality_quality( 465 local: Option<&FinalityCheckpoint>, 466 remote: Option<&FinalityCheckpoint>, 467 ) -> ForkQuality { 468 match (local, remote) { 469 (None, None) => ForkQuality::Equal, 470 (None, Some(_)) => ForkQuality::RemoteBetter, 471 (Some(_), None) => ForkQuality::LocalBetter, 472 (Some(local), Some(remote)) => match local.height.cmp(&remote.height) { 473 std::cmp::Ordering::Less => ForkQuality::RemoteBetter, 474 std::cmp::Ordering::Greater => ForkQuality::LocalBetter, 475 std::cmp::Ordering::Equal if local.hash == remote.hash => ForkQuality::Equal, 476 // A conflicting certificate is a safety failure. The canonical hash ordering 477 // nevertheless gives every honest node the same recovery decision. 478 std::cmp::Ordering::Equal if remote.hash < local.hash => ForkQuality::RemoteBetter, 479 std::cmp::Ordering::Equal => ForkQuality::LocalBetter, 480 }, 481 } 482 } 483 484 fn fork_rewrites_finalized_history(local_height: u64, common_ancestor_height: u64) -> bool { 485 let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH); 486 common_ancestor_height < finalized_floor 487 } 488 489 #[cfg(test)] 490 mod tests { 491 use std::collections::BTreeMap; 492 493 use super::{ 494 ForkChoice, ForkPoint, ForkQuality, fork_rewrites_finalized_history, 495 objective_finality_quality, 496 }; 497 use crate::domain::{ 498 FORK_FINALITY_DEPTH, FinalityCheckpoint, LaunchProfile, Ledger, StratumMineShare, 499 TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, Transaction, Wallet, 500 }; 501 502 fn profile(profile_id: &str) -> LaunchProfile { 503 LaunchProfile { 504 profile_id: profile_id.to_string(), 505 mine_difficulty_bits: 0, 506 ..LaunchProfile::default() 507 } 508 } 509 510 fn ledger_with_profile(allocations: BTreeMap<String, u64>, profile_id: &str) -> Ledger { 511 Ledger::new_with_genesis_burns_and_profile(allocations, Vec::new(), 1, profile(profile_id)) 512 .unwrap() 513 } 514 515 fn set_next_height(ledger: &mut Ledger, next_height: u64) { 516 ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1); 517 } 518 519 #[test] 520 fn forks_may_rewrite_six_blocks_but_not_seven() { 521 assert_eq!(FORK_FINALITY_DEPTH, 6); 522 assert!(!fork_rewrites_finalized_history(100, 94)); 523 assert!(fork_rewrites_finalized_history(100, 93)); 524 assert!(!fork_rewrites_finalized_history(5, 0)); 525 } 526 527 #[test] 528 fn higher_objective_checkpoint_wins_even_when_candidate_is_shorter() { 529 let mut local = Ledger::new(BTreeMap::new(), 1); 530 let mut candidate = local.clone(); 531 local.chain.last_mut().unwrap().height = 1_012; 532 local.chain.last_mut().unwrap().hash = "local-tip".to_string(); 533 local.objective_finality_checkpoint = Some(FinalityCheckpoint { 534 height: 1_004, 535 hash: "local-finalized".to_string(), 536 }); 537 candidate.chain.last_mut().unwrap().height = 1_008; 538 candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string(); 539 candidate.objective_finality_checkpoint = Some(FinalityCheckpoint { 540 height: 1_007, 541 hash: "remote-finalized".to_string(), 542 }); 543 544 assert_eq!( 545 local.choose_fork( 546 &candidate, 547 ForkPoint { 548 common_ancestor_height: 1_000, 549 }, 550 ), 551 ForkChoice::SwitchToCandidate 552 ); 553 } 554 555 #[test] 556 fn conflicting_same_height_certificates_have_deterministic_hash_tie_break() { 557 let local = FinalityCheckpoint { 558 height: 1_010, 559 hash: "bbbb".to_string(), 560 }; 561 let remote = FinalityCheckpoint { 562 height: 1_010, 563 hash: "aaaa".to_string(), 564 }; 565 566 assert_eq!( 567 objective_finality_quality(Some(&local), Some(&remote)), 568 ForkQuality::RemoteBetter 569 ); 570 assert_eq!( 571 objective_finality_quality(Some(&remote), Some(&local)), 572 ForkQuality::LocalBetter 573 ); 574 } 575 576 #[test] 577 fn objective_finality_partition_model_is_total_antisymmetric_and_transitive() { 578 let checkpoints = [ 579 None, 580 Some(FinalityCheckpoint { 581 height: 1_000, 582 hash: "aaaa".to_string(), 583 }), 584 Some(FinalityCheckpoint { 585 height: 1_000, 586 hash: "bbbb".to_string(), 587 }), 588 Some(FinalityCheckpoint { 589 height: 1_001, 590 hash: "aaaa".to_string(), 591 }), 592 Some(FinalityCheckpoint { 593 height: 1_020, 594 hash: "cccc".to_string(), 595 }), 596 ]; 597 598 for local in &checkpoints { 599 for remote in &checkpoints { 600 let forward = objective_finality_quality(local.as_ref(), remote.as_ref()); 601 let reverse = objective_finality_quality(remote.as_ref(), local.as_ref()); 602 assert!(matches!( 603 (forward, reverse), 604 (ForkQuality::Equal, ForkQuality::Equal) 605 | (ForkQuality::LocalBetter, ForkQuality::RemoteBetter) 606 | (ForkQuality::RemoteBetter, ForkQuality::LocalBetter) 607 )); 608 } 609 } 610 611 for first in &checkpoints { 612 for second in &checkpoints { 613 for third in &checkpoints { 614 let first_beats_second = 615 objective_finality_quality(first.as_ref(), second.as_ref()) 616 == ForkQuality::LocalBetter; 617 let second_beats_third = 618 objective_finality_quality(second.as_ref(), third.as_ref()) 619 == ForkQuality::LocalBetter; 620 if first_beats_second && second_beats_third { 621 assert_eq!( 622 objective_finality_quality(first.as_ref(), third.as_ref()), 623 ForkQuality::LocalBetter 624 ); 625 } 626 } 627 } 628 } 629 } 630 631 #[test] 632 fn activated_node_freezes_history_before_height_1000() { 633 let mut local = Ledger::new(BTreeMap::new(), 1); 634 let mut candidate = local.clone(); 635 local.chain.last_mut().unwrap().height = 1_010; 636 local.chain.last_mut().unwrap().hash = "local-tip".to_string(); 637 candidate.chain.last_mut().unwrap().height = 1_020; 638 candidate.chain.last_mut().unwrap().hash = "remote-tip".to_string(); 639 candidate.objective_finality_checkpoint = Some(FinalityCheckpoint { 640 height: 1_019, 641 hash: "remote-finalized".to_string(), 642 }); 643 644 assert_eq!( 645 local.choose_fork( 646 &candidate, 647 ForkPoint { 648 common_ancestor_height: 998, 649 }, 650 ), 651 ForkChoice::KeepLocal 652 ); 653 } 654 655 #[test] 656 fn transfer_and_burn_signatures_cannot_replay_between_chain_ids() { 657 let alice = Wallet::from_seed("chain-replay-alice"); 658 let bob = Wallet::from_seed("chain-replay-bob"); 659 let allocations = BTreeMap::from([(alice.address().to_string(), 100)]); 660 let chain_ids = [ 661 "iuna-mainnet-candidate", 662 "iuna-mainnet-v1", 663 "iuna-testnet-v1", 664 ]; 665 666 for foreign_chain_id in &chain_ids[1..] { 667 let mut source = ledger_with_profile(allocations.clone(), chain_ids[0]); 668 let mut foreign = ledger_with_profile(allocations.clone(), foreign_chain_id); 669 set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 670 set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 671 assert_eq!(source.genesis_hash(), foreign.genesis_hash()); 672 assert_eq!(source.utxos, foreign.utxos); 673 674 let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap(); 675 let transfer_error = foreign.submit_transaction(transfer).unwrap_err(); 676 assert!( 677 transfer_error 678 .to_string() 679 .contains("transaction signature is invalid") 680 ); 681 682 let burn = source.build_burn(&alice, 10, 1).unwrap(); 683 let burn_error = foreign.submit_transaction(burn).unwrap_err(); 684 assert!( 685 burn_error 686 .to_string() 687 .contains("transaction signature is invalid") 688 ); 689 } 690 } 691 692 #[test] 693 fn legacy_signatures_remain_compatible_before_height_1000() { 694 let alice = Wallet::from_seed("pre-activation-replay-alice"); 695 let bob = Wallet::from_seed("pre-activation-replay-bob"); 696 let allocations = BTreeMap::from([(alice.address().to_string(), 100)]); 697 let mut source = ledger_with_profile(allocations.clone(), "legacy-chain-a"); 698 let mut foreign = ledger_with_profile(allocations, "legacy-chain-b"); 699 set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1); 700 set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT - 1); 701 702 let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap(); 703 704 assert!(foreign.submit_transaction(transfer).unwrap()); 705 } 706 707 #[test] 708 fn signatures_cannot_replay_between_distinct_genesis_hashes() { 709 let alice = Wallet::from_seed("genesis-replay-alice"); 710 let bob = Wallet::from_seed("genesis-replay-bob"); 711 let base_allocations = BTreeMap::from([(alice.address().to_string(), 100)]); 712 let other_allocations = BTreeMap::from([ 713 (alice.address().to_string(), 100), 714 (bob.address().to_string(), 1), 715 ]); 716 let mut source = ledger_with_profile(base_allocations, "same-chain-id"); 717 let mut foreign = ledger_with_profile(other_allocations, "same-chain-id"); 718 set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 719 set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 720 assert_ne!(source.genesis_hash(), foreign.genesis_hash()); 721 722 let transfer = source.build_transfer(&alice, bob.address(), 10, 1).unwrap(); 723 let error = foreign.submit_transaction(transfer).unwrap_err(); 724 725 assert!( 726 error 727 .to_string() 728 .contains("transaction signature is invalid") 729 ); 730 } 731 732 #[test] 733 fn mine_proofs_cannot_replay_between_chain_ids() { 734 let miner = Wallet::from_seed("mine-replay-miner"); 735 let allocations = BTreeMap::from([(miner.address().to_string(), 100)]); 736 let mut source = ledger_with_profile(allocations.clone(), "mine-chain-a"); 737 let mut foreign = ledger_with_profile(allocations, "mine-chain-b"); 738 set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 739 set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 740 assert_eq!(source.genesis_hash(), foreign.genesis_hash()); 741 742 let mine = source.build_mine(miner.address()).unwrap(); 743 let error = foreign.submit_transaction(mine).unwrap_err(); 744 745 assert!( 746 error 747 .to_string() 748 .contains("mine transaction proof hash is invalid") 749 ); 750 } 751 752 #[test] 753 fn stratum_mine_proofs_cannot_replay_between_chain_ids() { 754 let miner = Wallet::from_seed("stratum-replay-miner"); 755 let allocations = BTreeMap::from([(miner.address().to_string(), 100)]); 756 let mut source = ledger_with_profile(allocations.clone(), "stratum-chain-a"); 757 let mut foreign = ledger_with_profile(allocations, "stratum-chain-b"); 758 set_next_height(&mut source, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 759 set_next_height(&mut foreign, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 760 let template = source 761 .stratum_mine_template( 762 miner.address(), 763 source.genesis_hash(), 764 7, 765 source.current_mine_difficulty_bits(), 766 ) 767 .unwrap(); 768 let mine = (0..u32::MAX) 769 .find_map(|nonce| { 770 source 771 .build_stratum_mine( 772 template.clone(), 773 StratumMineShare { 774 extranonce2: [0; 4], 775 header_nonce: nonce.to_le_bytes(), 776 }, 777 ) 778 .ok() 779 }) 780 .expect("test difficulty must yield a Stratum share"); 781 782 let error = foreign.submit_transaction(mine).unwrap_err(); 783 784 assert!(error.to_string().contains("proof header is invalid")); 785 } 786 787 #[test] 788 fn transaction_hex_casing_cannot_be_malleated_after_signing() { 789 let alice = Wallet::from_seed("hex-malleability-alice"); 790 let bob = Wallet::from_seed("hex-malleability-bob"); 791 let mut ledger = ledger_with_profile( 792 BTreeMap::from([(alice.address().to_string(), 100)]), 793 "hex-malleability-chain", 794 ); 795 set_next_height(&mut ledger, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT); 796 let transaction = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); 797 798 let reject = |mutated| { 799 let error = ledger 800 .clone() 801 .submit_transaction(mutated) 802 .expect_err("noncanonical transaction hex must be rejected"); 803 assert!( 804 format!("{error:#}").contains("canonical lowercase"), 805 "unexpected rejection: {error:#}" 806 ); 807 }; 808 809 let mut recipient = transaction.clone(); 810 let Transaction::Transfer { outputs, .. } = &mut recipient else { 811 unreachable!() 812 }; 813 outputs[0].address.make_ascii_uppercase(); 814 reject(recipient); 815 816 let mut change = transaction.clone(); 817 let Transaction::Transfer { outputs, .. } = &mut change else { 818 unreachable!() 819 }; 820 outputs[1].address.make_ascii_uppercase(); 821 reject(change); 822 823 let mut owner = transaction.clone(); 824 let Transaction::Transfer { inputs, .. } = &mut owner else { 825 unreachable!() 826 }; 827 inputs[0].owner.make_ascii_uppercase(); 828 reject(owner); 829 830 let mut outpoint = transaction.clone(); 831 let Transaction::Transfer { inputs, .. } = &mut outpoint else { 832 unreachable!() 833 }; 834 inputs[0].outpoint.txid.make_ascii_uppercase(); 835 reject(outpoint); 836 837 let mut signature = transaction; 838 let Transaction::Transfer { 839 inputs, 840 signature: transaction_signature, 841 .. 842 } = &mut signature 843 else { 844 unreachable!() 845 }; 846 transaction_signature.make_ascii_uppercase(); 847 for input in inputs { 848 input.signature.make_ascii_uppercase(); 849 } 850 reject(signature); 851 } 852 }