ledger_v2.rs (42781B)
1 use std::collections::BTreeMap; 2 3 use anyhow::{Context, Result, bail}; 4 5 use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block}; 6 use super::{ 7 AddressNetwork, AddressVersion, Amount, Ledger, LegacyTransactionId, LineageOwnerValues, 8 MAX_PENDING_POOL_BYTES, OutPoint, Transaction, TransactionSubmitOutcome, TransactionV2, 9 TransactionV2Domain, TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output, 10 TxOutput, UtxoLineageRoot, attach_existing_output_lineage, decode_hex, decode_hex_array, 11 encode_versioned_address, ensure_transaction_v2_active, hex_encode, newest_lineage_root, 12 remove_spent_output_lineage, 13 }; 14 15 impl Ledger { 16 pub fn transaction_v2_domain(&self) -> Result<TransactionV2Domain> { 17 TransactionV2Domain::new( 18 self.launch_profile.profile_id.clone(), 19 decode_hex_array::<32>(self.genesis_hash()) 20 .context("ledger genesis hash is not a 32-byte hexadecimal value")?, 21 ) 22 } 23 24 /// Decodes a v2 envelope and rejects a chain ID or genesis hash chosen by the sender. 25 pub fn decode_transaction_v2(&self, encoded: &[u8]) -> Result<TransactionV2> { 26 let (domain, transaction) = TransactionV2::decode(encoded)?; 27 if domain != self.transaction_v2_domain()? { 28 bail!("transaction v2 belongs to a different chain domain"); 29 } 30 Ok(transaction) 31 } 32 33 /// Validates the v2 rules against confirmed state at an explicit candidate block height. 34 /// Mempool and block code can share this method without consulting wall-clock or local config. 35 pub fn validate_transaction_v2_at_height( 36 &self, 37 transaction: &TransactionV2, 38 height: u64, 39 ) -> Result<()> { 40 self.validate_transaction_v2_anchor_for_block(transaction, height)?; 41 self.validated_v2_utxos_at_height(transaction, height)?; 42 Ok(()) 43 } 44 45 pub fn submit_transaction_v2( 46 &mut self, 47 transaction: TransactionV2, 48 ) -> Result<TransactionSubmitOutcome> { 49 self.submit_transaction_v2_at_height(transaction, self.height().saturating_add(1)) 50 } 51 52 fn submit_transaction_v2_at_height( 53 &mut self, 54 transaction: TransactionV2, 55 height: u64, 56 ) -> Result<TransactionSubmitOutcome> { 57 ensure_transaction_v2_active(height)?; 58 self.validate_transaction_v2_anchor_for_pending(&transaction)?; 59 let domain = self.transaction_v2_domain()?; 60 let transaction_id = transaction.transaction_id(&domain)?; 61 if self 62 .pending_v2 63 .iter() 64 .any(|pending| pending.transaction_id(&domain).ok() == Some(transaction_id)) 65 { 66 return Ok(TransactionSubmitOutcome::AlreadyKnown); 67 } 68 if transaction.fee() == 0 { 69 bail!("public transaction v2 fee must be greater than zero"); 70 } 71 let encoded = transaction.encode(&domain)?; 72 let transaction_bytes = encoded.len(); 73 let envelope = hex_encode(&encoded); 74 ensure_transaction_v2_fits_empty_block( 75 compact_block_context(self), 76 &envelope, 77 self.launch_profile.max_block_bytes, 78 )?; 79 if self.pending.len().saturating_add(self.pending_v2.len()) 80 >= self.launch_profile.max_pending_transactions 81 { 82 bail!("mempool is full"); 83 } 84 if self 85 .pending_bytes 86 .saturating_add(self.pending_v2_bytes) 87 .checked_add(transaction_bytes) 88 .is_none_or(|bytes| bytes > MAX_PENDING_POOL_BYTES) 89 { 90 bail!("mempool byte limit exceeded"); 91 } 92 93 let mut utxos = self.utxos_after_spendable_pending()?; 94 for pending in &self.pending_v2 { 95 apply_prevalidated_transaction_v2_to_utxos( 96 pending, 97 &domain, 98 AddressNetwork::from_profile_id(&self.launch_profile.profile_id), 99 &mut utxos, 100 )?; 101 } 102 apply_transaction_v2_to_utxos( 103 &transaction, 104 &domain, 105 AddressNetwork::from_profile_id(&self.launch_profile.profile_id), 106 &mut utxos, 107 )?; 108 self.pending_v2.push(transaction); 109 self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(transaction_bytes); 110 Ok(TransactionSubmitOutcome::Added) 111 } 112 113 pub(super) fn transaction_conflicts_with_pending_v2(&self, transaction: &Transaction) -> bool { 114 transaction.inputs().iter().any(|legacy_input| { 115 self.pending_v2.iter().any(|pending| { 116 let TransactionV2::Migration { inputs, .. } = pending else { 117 return false; 118 }; 119 inputs.iter().any(|v2_input| { 120 v2_input.outpoint_index == legacy_input.outpoint.index 121 && legacy_transaction_id_hex(&v2_input.outpoint_id) 122 == legacy_input.outpoint.txid 123 }) 124 }) 125 }) 126 } 127 128 pub(super) fn revalidate_pending_v2(&mut self) -> Result<()> { 129 self.revalidate_pending_v2_at_height(self.height().saturating_add(1)) 130 } 131 132 fn revalidate_pending_v2_at_height(&mut self, height: u64) -> Result<()> { 133 if ensure_transaction_v2_active(height).is_err() { 134 self.pending_v2.clear(); 135 self.pending_v2_bytes = 0; 136 return Ok(()); 137 } 138 let domain = self.transaction_v2_domain()?; 139 let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id); 140 let mut utxos = self.utxos_after_spendable_pending()?; 141 let pending = std::mem::take(&mut self.pending_v2); 142 self.pending_v2_bytes = 0; 143 for transaction in pending { 144 let bytes = transaction.encoded_size_bytes(&domain)?; 145 let mut candidate_utxos = utxos.clone(); 146 if self 147 .validate_transaction_v2_anchor_for_pending(&transaction) 148 .and_then(|()| { 149 apply_prevalidated_transaction_v2_to_utxos( 150 &transaction, 151 &domain, 152 network, 153 &mut candidate_utxos, 154 ) 155 }) 156 .is_ok() 157 { 158 utxos = candidate_utxos; 159 self.pending_v2.push(transaction); 160 self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(bytes); 161 } 162 } 163 Ok(()) 164 } 165 166 pub(super) fn validated_v2_utxos_at_height( 167 &self, 168 transaction: &TransactionV2, 169 height: u64, 170 ) -> Result<BTreeMap<OutPoint, TxOutput>> { 171 ensure_transaction_v2_active(height)?; 172 let domain = self.transaction_v2_domain()?; 173 let mut utxos = self.utxos.clone(); 174 apply_transaction_v2_to_utxos( 175 transaction, 176 &domain, 177 AddressNetwork::from_profile_id(&self.launch_profile.profile_id), 178 &mut utxos, 179 )?; 180 Ok(utxos) 181 } 182 183 pub(super) fn validate_transaction_v2_anchor_for_block( 184 &self, 185 transaction: &TransactionV2, 186 height: u64, 187 ) -> Result<()> { 188 if !transaction.is_burn() { 189 return Ok(()); 190 } 191 let anchor = transaction 192 .burn_anchor() 193 .context("transaction v2 burn is missing its chain anchor")?; 194 let expected = decode_hex_array::<32>(&self.tip().prev_hash) 195 .context("block grandparent hash is invalid")?; 196 if height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT && anchor != expected { 197 bail!("transaction v2 burn anchor does not match the block grandparent"); 198 } 199 Ok(()) 200 } 201 202 pub(super) fn validate_transaction_v2_anchor_for_pending( 203 &self, 204 transaction: &TransactionV2, 205 ) -> Result<()> { 206 if !transaction.is_burn() { 207 return Ok(()); 208 } 209 let anchor = transaction 210 .burn_anchor() 211 .context("transaction v2 burn is missing its chain anchor")?; 212 let tip = decode_hex_array::<32>(self.tip_hash()).context("tip hash is invalid")?; 213 let parent = 214 decode_hex_array::<32>(&self.tip().prev_hash).context("tip parent hash is invalid")?; 215 if anchor != tip && anchor != parent { 216 return Err(super::ValidationError::BurnAnchorOutsidePendingWindow.into()); 217 } 218 Ok(()) 219 } 220 221 pub(crate) fn transaction_v2_is_eligible_for_next_block( 222 &self, 223 transaction: &TransactionV2, 224 ) -> bool { 225 self.validate_transaction_v2_anchor_for_block(transaction, self.height().saturating_add(1)) 226 .is_ok() 227 } 228 } 229 230 #[allow(clippy::too_many_arguments)] 231 pub(super) fn apply_transaction_v2_with_lineage( 232 transaction: &TransactionV2, 233 domain: &TransactionV2Domain, 234 network: AddressNetwork, 235 utxos: &mut BTreeMap<OutPoint, TxOutput>, 236 utxo_lineage: &mut BTreeMap<OutPoint, UtxoLineageRoot>, 237 lineage_values: &mut BTreeMap<UtxoLineageRoot, Amount>, 238 lineage_owners: &mut LineageOwnerValues, 239 verify_authorizations: bool, 240 ) -> Result<()> { 241 let spent = transaction_v2_outpoints(transaction); 242 let spent_outputs = spent 243 .iter() 244 .map(|outpoint| { 245 utxos 246 .get(outpoint) 247 .cloned() 248 .map(|output| (outpoint.clone(), output)) 249 .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id())) 250 }) 251 .collect::<Result<Vec<_>>>()?; 252 253 apply_transaction_v2_to_utxos_with_policy( 254 transaction, 255 domain, 256 network, 257 utxos, 258 verify_authorizations, 259 )?; 260 261 let mut inherited_root = None; 262 for (outpoint, output) in &spent_outputs { 263 let root = remove_spent_output_lineage( 264 outpoint, 265 output, 266 utxo_lineage, 267 lineage_values, 268 lineage_owners, 269 )?; 270 inherited_root = newest_lineage_root(inherited_root, root); 271 } 272 if let Some(root) = inherited_root { 273 let transaction_id = hex_encode(transaction.transaction_id(domain)?); 274 for (index, output) in transaction_v2_outputs(transaction).iter().enumerate() { 275 let outpoint = OutPoint { 276 txid: transaction_id.clone(), 277 index: u32::try_from(index).context("transaction v2 output index exceeds u32")?, 278 }; 279 let internal = utxos 280 .get(&outpoint) 281 .context("transaction v2 output is missing after application")?; 282 attach_existing_output_lineage( 283 outpoint, 284 internal, 285 root.clone(), 286 utxo_lineage, 287 lineage_values, 288 lineage_owners, 289 )?; 290 debug_assert_eq!(internal.amount, output.amount); 291 } 292 } 293 Ok(()) 294 } 295 296 pub(super) fn decode_canonical_transaction_v2_envelope( 297 envelope: &str, 298 expected_domain: &TransactionV2Domain, 299 ) -> Result<TransactionV2> { 300 let bytes = decode_hex(envelope).context("transaction v2 envelope is not hexadecimal")?; 301 if hex_encode(&bytes) != envelope { 302 bail!("transaction v2 envelope is not canonical lowercase hexadecimal"); 303 } 304 let (domain, transaction) = TransactionV2::decode(&bytes)?; 305 if &domain != expected_domain { 306 bail!("transaction v2 belongs to a different chain domain"); 307 } 308 if transaction.encode(expected_domain)? != bytes { 309 bail!("transaction v2 envelope is not canonically encoded"); 310 } 311 Ok(transaction) 312 } 313 314 fn transaction_v2_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> { 315 match transaction { 316 TransactionV2::Migration { inputs, .. } => inputs 317 .iter() 318 .map(|input| OutPoint { 319 txid: legacy_transaction_id_hex(&input.outpoint_id), 320 index: input.outpoint_index, 321 }) 322 .collect(), 323 TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => inputs 324 .iter() 325 .map(|input| OutPoint { 326 txid: hex_encode(input.outpoint_txid), 327 index: input.outpoint_index, 328 }) 329 .collect(), 330 TransactionV2::Mine { .. } => Vec::new(), 331 } 332 } 333 334 fn transaction_v2_outputs(transaction: &TransactionV2) -> &[TransactionV2Output] { 335 match transaction { 336 TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { 337 outputs 338 } 339 TransactionV2::Burn { change, .. } => change, 340 TransactionV2::Mine { .. } => &[], 341 } 342 } 343 344 pub(super) fn apply_transaction_v2_to_utxos( 345 transaction: &TransactionV2, 346 domain: &TransactionV2Domain, 347 network: AddressNetwork, 348 utxos: &mut BTreeMap<OutPoint, TxOutput>, 349 ) -> Result<()> { 350 apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, true) 351 } 352 353 pub(super) fn apply_prevalidated_transaction_v2_to_utxos( 354 transaction: &TransactionV2, 355 domain: &TransactionV2Domain, 356 network: AddressNetwork, 357 utxos: &mut BTreeMap<OutPoint, TxOutput>, 358 ) -> Result<()> { 359 apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, false) 360 } 361 362 fn apply_transaction_v2_to_utxos_with_policy( 363 transaction: &TransactionV2, 364 domain: &TransactionV2Domain, 365 network: AddressNetwork, 366 utxos: &mut BTreeMap<OutPoint, TxOutput>, 367 verify_authorizations: bool, 368 ) -> Result<()> { 369 let (spent, outputs, fee, burned) = match transaction { 370 TransactionV2::Migration { 371 inputs, 372 outputs, 373 fee, 374 .. 375 } => ( 376 spend_legacy_inputs(inputs, utxos)?, 377 outputs.as_slice(), 378 *fee, 379 0, 380 ), 381 TransactionV2::Transfer { 382 inputs, 383 outputs, 384 fee, 385 .. 386 } => ( 387 spend_v2_inputs(inputs, network, utxos)?, 388 outputs.as_slice(), 389 *fee, 390 0, 391 ), 392 TransactionV2::Burn { 393 inputs, 394 change, 395 amount, 396 fee, 397 .. 398 } => { 399 transaction.burn_legacy_owner()?; 400 ( 401 spend_v2_inputs(inputs, network, utxos)?, 402 change.as_slice(), 403 *fee, 404 *amount, 405 ) 406 } 407 TransactionV2::Mine { .. } => { 408 bail!("transaction v2 mining is not integrated into live proof consensus") 409 } 410 }; 411 412 let credited = sum_outputs(outputs)?; 413 let required = credited 414 .checked_add(fee) 415 .context("transaction v2 output value plus fee overflows")? 416 .checked_add(burned) 417 .context("transaction v2 output value plus burn overflows")?; 418 if spent != required { 419 bail!("transaction v2 input value does not equal outputs plus fee"); 420 } 421 422 // Verify expensive signatures only for an untrusted candidate and only after cheap state and 423 // conservation checks. Pending entries are immutable and were verified on admission. 424 if verify_authorizations { 425 transaction.verify_authorizations(domain)?; 426 } 427 let transaction_id = hex_encode(transaction.transaction_id(domain)?); 428 for (index, output) in outputs.iter().enumerate() { 429 let index = u32::try_from(index).context("transaction v2 output index exceeds u32")?; 430 let outpoint = OutPoint { 431 txid: transaction_id.clone(), 432 index, 433 }; 434 if utxos 435 .insert( 436 outpoint, 437 TxOutput { 438 address: internal_address(output.address, network)?, 439 amount: output.amount, 440 }, 441 ) 442 .is_some() 443 { 444 bail!("transaction v2 recreates an existing outpoint"); 445 } 446 } 447 Ok(()) 448 } 449 450 fn spend_legacy_inputs( 451 inputs: &[TransactionV2LegacyInput], 452 utxos: &mut BTreeMap<OutPoint, TxOutput>, 453 ) -> Result<u64> { 454 let mut spent = 0_u64; 455 for input in inputs { 456 let outpoint = OutPoint { 457 txid: legacy_transaction_id_hex(&input.outpoint_id), 458 index: input.outpoint_index, 459 }; 460 let output = utxos 461 .remove(&outpoint) 462 .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?; 463 let expected_owner = internal_address(input.owner, AddressNetwork::Mainnet)?; 464 if output.address != expected_owner { 465 bail!("transaction v2 legacy input owner does not match the referenced output"); 466 } 467 spent = spent 468 .checked_add(output.amount) 469 .context("transaction v2 input value overflows")?; 470 } 471 Ok(spent) 472 } 473 474 fn legacy_transaction_id_hex(transaction_id: &LegacyTransactionId) -> String { 475 match transaction_id { 476 LegacyTransactionId::Hash(value) => hex_encode(value), 477 LegacyTransactionId::Signature(value) => hex_encode(value), 478 } 479 } 480 481 fn spend_v2_inputs( 482 inputs: &[TransactionV2Input], 483 network: AddressNetwork, 484 utxos: &mut BTreeMap<OutPoint, TxOutput>, 485 ) -> Result<u64> { 486 let mut spent = 0_u64; 487 for input in inputs { 488 let outpoint = OutPoint { 489 txid: hex_encode(input.outpoint_txid), 490 index: input.outpoint_index, 491 }; 492 let output = utxos 493 .remove(&outpoint) 494 .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?; 495 if output.address != internal_address(input.owner, network)? { 496 bail!("transaction v2 input owner does not match the referenced output"); 497 } 498 spent = spent 499 .checked_add(output.amount) 500 .context("transaction v2 input value overflows")?; 501 } 502 Ok(spent) 503 } 504 505 fn sum_outputs(outputs: &[TransactionV2Output]) -> Result<u64> { 506 outputs.iter().try_fold(0_u64, |total, output| { 507 total 508 .checked_add(output.amount) 509 .context("transaction v2 output value overflows") 510 }) 511 } 512 513 fn internal_address(address: super::VersionedAddress, network: AddressNetwork) -> Result<String> { 514 match address.version { 515 AddressVersion::Ed25519PublicKey => Ok(hex_encode(address.payload)), 516 AddressVersion::HybridKeyCommitment => encode_versioned_address(address, network), 517 } 518 } 519 520 #[cfg(test)] 521 mod tests { 522 use std::collections::BTreeMap; 523 524 use super::*; 525 use crate::domain::{ 526 GenesisBurn, LaunchProfile, SignatureScheme, TRANSACTION_V2_ACTIVATION_HEIGHT, 527 TransactionV2Output, Wallet, 528 }; 529 530 fn set_next_height(ledger: &mut Ledger, next_height: u64) { 531 ledger.chain.last_mut().unwrap().height = next_height.saturating_sub(1); 532 for ticket in &mut ledger.tickets { 533 ticket.eligible_from_height = next_height; 534 ticket.eligible_until_height = next_height; 535 } 536 } 537 538 fn post_activation_height() -> u64 { 539 TRANSACTION_V2_ACTIVATION_HEIGHT.unwrap().saturating_add(1) 540 } 541 542 #[test] 543 fn migration_validation_switches_at_3000_and_creates_a_hybrid_utxo() { 544 let wallet = Wallet::from_seed("v2-ledger-migration-wallet"); 545 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 546 let transaction = ledger.build_v2_migration(&wallet, 3).unwrap(); 547 548 assert!( 549 ledger 550 .validate_transaction_v2_at_height(&transaction, 2_999) 551 .is_err() 552 ); 553 ledger 554 .validate_transaction_v2_at_height(&transaction, 3_000) 555 .unwrap(); 556 557 let domain = ledger.transaction_v2_domain().unwrap(); 558 let transaction_id = hex_encode(transaction.transaction_id(&domain).unwrap()); 559 let utxos = ledger 560 .validated_v2_utxos_at_height(&transaction, 3_000) 561 .unwrap(); 562 assert_eq!( 563 utxos.get(&OutPoint { 564 txid: transaction_id, 565 index: 0, 566 }), 567 Some(&TxOutput { 568 address: wallet.hybrid_address(AddressNetwork::Mainnet), 569 amount: 97, 570 }) 571 ); 572 assert_eq!(utxos.len(), 1); 573 } 574 575 #[test] 576 fn hybrid_output_requires_both_signatures_when_spent() { 577 let wallet = Wallet::from_seed("v2-ledger-hybrid-spend-wallet"); 578 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 579 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 580 let domain = ledger.transaction_v2_domain().unwrap(); 581 let migration_id = migration.transaction_id(&domain).unwrap(); 582 let migrated_utxos = ledger 583 .validated_v2_utxos_at_height(&migration, 3_000) 584 .unwrap(); 585 let mut migrated_ledger = ledger.clone(); 586 migrated_ledger.utxos = migrated_utxos; 587 588 let mut transfer = TransactionV2::Transfer { 589 inputs: vec![TransactionV2Input { 590 outpoint_txid: migration_id, 591 outpoint_index: 0, 592 owner: wallet.hybrid_versioned_address(), 593 }], 594 outputs: vec![TransactionV2Output { 595 address: wallet.hybrid_versioned_address(), 596 amount: 96, 597 }], 598 fee: 1, 599 authorizations: Vec::new(), 600 }; 601 let payload = transfer.signing_bytes(&domain).unwrap(); 602 let authorization = wallet 603 .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload) 604 .unwrap(); 605 assert_eq!( 606 authorization.scheme(), 607 SignatureScheme::HybridEd25519MlDsa44 608 ); 609 if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { 610 authorizations.push(authorization); 611 } 612 migrated_ledger 613 .validate_transaction_v2_at_height(&transfer, 3_001) 614 .unwrap(); 615 616 if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { 617 let public_key = authorizations[0].public_key().clone(); 618 let classical_signature = authorizations[0].signature().as_bytes()[..64].to_vec(); 619 authorizations[0] = super::super::V2SpendingAuthorization::new( 620 super::super::ProtocolPublicKey::new( 621 SignatureScheme::Ed25519, 622 public_key.as_bytes()[..32].to_vec(), 623 ) 624 .unwrap(), 625 super::super::ProtocolSignature::new(SignatureScheme::Ed25519, classical_signature) 626 .unwrap(), 627 ) 628 .unwrap(); 629 } 630 assert!( 631 migrated_ledger 632 .validate_transaction_v2_at_height(&transfer, 3_001) 633 .is_err() 634 ); 635 } 636 637 #[test] 638 fn hybrid_wallet_can_build_select_and_create_a_ticket_from_a_v2_burn() { 639 let wallet = Wallet::from_seed("v2-burn-wallet"); 640 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 641 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 642 ledger.utxos = ledger 643 .validated_v2_utxos_at_height(&migration, 3_000) 644 .unwrap(); 645 let split = ledger 646 .build_v2_transfer(&wallet, wallet.hybrid_versioned_address(), 40, 1) 647 .unwrap(); 648 ledger.utxos = ledger.validated_v2_utxos_at_height(&split, 3_000).unwrap(); 649 set_next_height(&mut ledger, post_activation_height()); 650 651 let burn = ledger.build_v2_burn_for_next_block(&wallet, 9, 5).unwrap(); 652 assert_eq!( 653 burn.burn_legacy_owner().unwrap().as_deref(), 654 Some(wallet.address()) 655 ); 656 assert_eq!(burn.amount(), 9); 657 ledger.submit_transaction_v2(burn.clone()).unwrap(); 658 let mut low_fee_build_ledger = ledger.clone(); 659 low_fee_build_ledger 660 .utxos 661 .retain(|_, output| output.amount == 40); 662 let low_fee_burn = low_fee_build_ledger 663 .build_v2_burn_for_next_block(&wallet, 7, 1) 664 .unwrap(); 665 ledger.submit_transaction_v2(low_fee_burn.clone()).unwrap(); 666 667 let bundles = ledger.build_burn_bundles(&wallet).unwrap(); 668 assert!(!bundles.is_empty()); 669 let domain = ledger.transaction_v2_domain().unwrap(); 670 let expected_burns = vec![ 671 hex_encode(burn.encode(&domain).unwrap()), 672 hex_encode(low_fee_burn.encode(&domain).unwrap()), 673 ]; 674 assert!( 675 bundles 676 .iter() 677 .all(|bundle| bundle.burns_v2 == vec![expected_burns[0].clone()]) 678 ); 679 let recovery = ledger 680 .prepare_recovery_block_with_required_burn_and_burn_bundles( 681 wallet.address(), 682 None, 683 ledger.recovery_block_min_timestamp(), 684 Vec::new(), 685 None, 686 ) 687 .unwrap(); 688 assert_eq!(recovery.transactions_v2.len(), 2); 689 let section = ledger.burn_bundle_section_from_bundles(bundles).unwrap(); 690 assert_eq!(section.burns_v2.len(), 1); 691 let sorted_section = ledger 692 .burn_bundle_section_from_bundles(vec![super::super::BurnBundle { 693 height: ledger.height() + 1, 694 prev_hash: ledger.tip_hash().to_string(), 695 slot: 1, 696 member: wallet.address().to_string(), 697 reward_address: None, 698 burns: Vec::new(), 699 burns_v2: vec![expected_burns[1].clone(), expected_burns[0].clone()], 700 signature: "test-signature".to_string(), 701 }]) 702 .unwrap(); 703 assert_eq!( 704 sorted_section.expand(ledger.height() + 1, ledger.tip_hash())[0].burns_v2, 705 expected_burns 706 ); 707 708 let selection = ledger 709 .select_block_transactions_with_required_burn_owner( 710 Some(wallet.address()), 711 None, 712 super::super::FinalizerMode::Ticket, 713 §ion, 714 ) 715 .unwrap(); 716 assert!(selection.transactions.is_empty()); 717 assert_eq!(selection.transactions_v2.len(), 2); 718 719 let mut block = ledger.tip().clone(); 720 block.height = post_activation_height(); 721 block.transactions.clear(); 722 block.transactions_v2 = selection.transactions_v2; 723 let tickets = 724 super::super::ticket::tickets_created_by_block(&block, ledger.launch_profile()) 725 .unwrap(); 726 assert_eq!(tickets.len(), 2); 727 assert_eq!(tickets[0].owner, wallet.address()); 728 assert_eq!(tickets[0].amount, 9); 729 assert_eq!(tickets[1].owner, wallet.address()); 730 assert_eq!(tickets[1].amount, 7); 731 } 732 733 #[test] 734 fn decoder_rejects_an_attacker_selected_chain_domain() { 735 let wallet = Wallet::from_seed("v2-ledger-domain-wallet"); 736 let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 737 let transaction = ledger.build_v2_migration(&wallet, 1).unwrap(); 738 let foreign = TransactionV2Domain::new("foreign-chain", [0x44; 32]).unwrap(); 739 let encoded = transaction.encode(&foreign).unwrap(); 740 741 assert!(ledger.decode_transaction_v2(&encoded).is_err()); 742 } 743 744 #[test] 745 fn v2_mempool_enforces_activation_deduplication_and_legacy_conflicts() { 746 let wallet = Wallet::from_seed("v2-ledger-mempool-wallet"); 747 let recipient = Wallet::from_seed("v2-ledger-mempool-recipient"); 748 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 749 let transaction = ledger.build_v2_migration(&wallet, 3).unwrap(); 750 let conflicting_legacy = ledger 751 .build_transfer(&wallet, recipient.address(), 90, 1) 752 .unwrap(); 753 754 assert!( 755 ledger 756 .submit_transaction_v2_at_height(transaction.clone(), 2_999) 757 .is_err() 758 ); 759 assert!(ledger.pending_v2().is_empty()); 760 assert_eq!( 761 ledger 762 .submit_transaction_v2_at_height(transaction.clone(), 3_000) 763 .unwrap(), 764 TransactionSubmitOutcome::Added 765 ); 766 assert_eq!(ledger.pending_v2().len(), 1); 767 assert!(ledger.pending_v2_bytes > 0); 768 assert_eq!(ledger.status().pending_transactions, 1); 769 assert_eq!( 770 ledger 771 .submit_transaction_v2_at_height(transaction, 3_000) 772 .unwrap(), 773 TransactionSubmitOutcome::AlreadyKnown 774 ); 775 assert_eq!( 776 ledger 777 .submit_transaction_with_outcome(conflicting_legacy) 778 .unwrap(), 779 TransactionSubmitOutcome::ConflictsWithPending 780 ); 781 } 782 783 #[test] 784 fn v2_mempool_accepts_a_transfer_spending_a_pending_migration() { 785 let wallet = Wallet::from_seed("v2-ledger-dependent-mempool-wallet"); 786 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 787 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 788 let domain = ledger.transaction_v2_domain().unwrap(); 789 let migration_id = migration.transaction_id(&domain).unwrap(); 790 ledger 791 .submit_transaction_v2_at_height(migration, 3_000) 792 .unwrap(); 793 794 let mut transfer = TransactionV2::Transfer { 795 inputs: vec![TransactionV2Input { 796 outpoint_txid: migration_id, 797 outpoint_index: 0, 798 owner: wallet.hybrid_versioned_address(), 799 }], 800 outputs: vec![TransactionV2Output { 801 address: wallet.hybrid_versioned_address(), 802 amount: 96, 803 }], 804 fee: 1, 805 authorizations: Vec::new(), 806 }; 807 let payload = transfer.signing_bytes(&domain).unwrap(); 808 let authorization = wallet 809 .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload) 810 .unwrap(); 811 if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { 812 authorizations.push(authorization); 813 } 814 815 assert_eq!( 816 ledger 817 .submit_transaction_v2_at_height(transfer, 3_000) 818 .unwrap(), 819 TransactionSubmitOutcome::Added 820 ); 821 assert_eq!(ledger.pending_v2().len(), 2); 822 } 823 824 #[test] 825 fn v2_mempool_revalidation_drops_a_migration_spent_by_new_chain_state() { 826 let wallet = Wallet::from_seed("v2-ledger-revalidation-wallet"); 827 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 828 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 829 ledger 830 .submit_transaction_v2_at_height(migration, 3_000) 831 .unwrap(); 832 assert_eq!(ledger.pending_v2().len(), 1); 833 834 ledger.utxos.clear(); 835 ledger.revalidate_pending_v2_at_height(3_000).unwrap(); 836 assert!(ledger.pending_v2().is_empty()); 837 assert_eq!(ledger.pending_v2_bytes, 0); 838 } 839 840 #[test] 841 fn invalid_v2_candidate_does_not_mutate_the_mempool() { 842 let wallet = Wallet::from_seed("v2-ledger-invalid-candidate-wallet"); 843 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 844 let mut migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 845 if let TransactionV2::Migration { authorizations, .. } = &mut migration { 846 let public_key = authorizations[0].public_key().clone(); 847 let mut signature = authorizations[0].signature().as_bytes().to_vec(); 848 signature[0] ^= 1; 849 authorizations[0] = super::super::V2SpendingAuthorization::new( 850 public_key, 851 super::super::ProtocolSignature::new(SignatureScheme::Ed25519, signature).unwrap(), 852 ) 853 .unwrap(); 854 } 855 856 assert!( 857 ledger 858 .submit_transaction_v2_at_height(migration, 3_000) 859 .is_err() 860 ); 861 assert!(ledger.pending_v2().is_empty()); 862 assert_eq!(ledger.pending_v2_bytes, 0); 863 } 864 865 #[test] 866 fn v2_mempool_rejects_an_envelope_that_cannot_fit_with_block_overhead() { 867 let wallet = Wallet::from_seed("v2-empty-block-budget-wallet"); 868 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 869 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 870 let domain = ledger.transaction_v2_domain().unwrap(); 871 ledger.launch_profile.max_block_bytes = migration.encoded_size_bytes(&domain).unwrap(); 872 873 assert!( 874 ledger 875 .submit_transaction_v2_at_height(migration, 3_000) 876 .unwrap_err() 877 .to_string() 878 .contains("exceeds max block size") 879 ); 880 assert!(ledger.pending_v2().is_empty()); 881 } 882 883 #[test] 884 fn block_selection_includes_v2_transactions_at_activation() { 885 let wallet = Wallet::from_seed("v2-block-selection-wallet"); 886 let legacy_sender = Wallet::from_seed("v2-block-selection-legacy-sender"); 887 let mut ledger = Ledger::new( 888 BTreeMap::from([ 889 (wallet.address().to_string(), 100), 890 (legacy_sender.address().to_string(), 100), 891 ]), 892 1, 893 ); 894 ledger.chain[0].height = 2_999; 895 let migration = ledger.build_v2_migration(&wallet, 50).unwrap(); 896 let legacy = ledger 897 .build_transfer(&legacy_sender, wallet.address(), 50, 1) 898 .unwrap(); 899 ledger.submit_transaction(legacy).unwrap(); 900 ledger.submit_transaction_v2(migration.clone()).unwrap(); 901 ledger.launch_profile.max_block_transactions = 1; 902 903 let selection = ledger 904 .select_block_transactions_with_required_burn_owner( 905 None, 906 None, 907 super::super::FinalizerMode::Ticket, 908 &super::super::BurnBundleSection::default(), 909 ) 910 .unwrap(); 911 912 assert!(selection.transactions.is_empty()); 913 assert_eq!(selection.transactions_v2.len(), 1); 914 let encoded = decode_hex(&selection.transactions_v2[0]).unwrap(); 915 assert_eq!(ledger.decode_transaction_v2(&encoded).unwrap(), migration); 916 } 917 918 #[test] 919 fn block_application_preserves_legacy_output_lineage_through_migration() { 920 let wallet = Wallet::from_seed("v2-block-lineage-wallet"); 921 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); 922 let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); 923 let spent = ledger.utxos.keys().next().unwrap().clone(); 924 let root = UtxoLineageRoot { 925 outpoint: spent.clone(), 926 height: 1, 927 }; 928 ledger.utxo_lineage.insert(spent.clone(), root.clone()); 929 ledger.lineage_values.insert(root.clone(), 100); 930 ledger.lineage_owners.insert( 931 root.clone(), 932 BTreeMap::from([(wallet.address().to_string(), BTreeMap::from([(spent, 100)]))]), 933 ); 934 let domain = ledger.transaction_v2_domain().unwrap(); 935 936 apply_transaction_v2_with_lineage( 937 &migration, 938 &domain, 939 AddressNetwork::Mainnet, 940 &mut ledger.utxos, 941 &mut ledger.utxo_lineage, 942 &mut ledger.lineage_values, 943 &mut ledger.lineage_owners, 944 true, 945 ) 946 .unwrap(); 947 948 assert_eq!( 949 ledger.utxo_lineage.values().collect::<Vec<_>>(), 950 vec![&root] 951 ); 952 assert_eq!(ledger.lineage_values.get(&root), Some(&97)); 953 assert_eq!( 954 ledger 955 .lineage_owners 956 .get(&root) 957 .and_then(|owners| owners.get(&wallet.hybrid_address(AddressNetwork::Mainnet))) 958 .map(|outputs| outputs.values().copied().sum::<u64>()), 959 Some(97) 960 ); 961 } 962 963 #[test] 964 fn height_3000_block_selects_applies_and_rewards_a_v2_migration() { 965 let finalizer = Wallet::from_seed("v2-height-3000-finalizer"); 966 let migrator = Wallet::from_seed("v2-height-3000-migrator"); 967 let mut ledger = Ledger::new_with_genesis_burns_and_profile( 968 BTreeMap::from([ 969 (finalizer.address().to_string(), 100), 970 (migrator.address().to_string(), 100), 971 ]), 972 vec![GenesisBurn::new(finalizer.address(), 10)], 973 1, 974 LaunchProfile::local_testnet(), 975 ) 976 .unwrap(); 977 ledger.chain[0].height = 2_999; 978 for ticket in &mut ledger.tickets { 979 ticket.eligible_from_height = 3_000; 980 ticket.eligible_until_height = 3_000; 981 } 982 let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap(); 983 ledger.submit_transaction(anchor).unwrap(); 984 let migration = ledger.build_v2_migration(&migrator, 3).unwrap(); 985 ledger.submit_transaction_v2(migration.clone()).unwrap(); 986 let timestamp_ms = ledger.tip().timestamp_ms.saturating_add(1); 987 988 let prepared = ledger 989 .prepare_next_block(finalizer.address(), timestamp_ms) 990 .unwrap(); 991 assert_eq!(prepared.transactions_v2.len(), 1); 992 let block = prepared.finish(&finalizer, "preverified-vdf".to_string()); 993 assert_eq!(block.reward, 4); 994 ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); 995 996 assert_eq!(ledger.height(), 3_000); 997 assert_eq!( 998 ledger.balance_of(&migrator.hybrid_address(AddressNetwork::Testnet)), 999 97 1000 ); 1001 assert!(ledger.pending_v2().is_empty()); 1002 } 1003 1004 #[test] 1005 fn normal_post_activation_block_flow_shares_the_transaction_count_budget() { 1006 let finalizer = Wallet::from_seed("v2-post-activation-count-finalizer"); 1007 let recipient = Wallet::from_seed("v2-post-activation-count-recipient"); 1008 let migrators = (0..4) 1009 .map(|index| Wallet::from_seed(&format!("v2-post-activation-count-{index}"))) 1010 .collect::<Vec<_>>(); 1011 let legacy_senders = (0..2) 1012 .map(|index| Wallet::from_seed(&format!("legacy-post-activation-count-{index}"))) 1013 .collect::<Vec<_>>(); 1014 let mut allocations = BTreeMap::from([ 1015 (finalizer.address().to_string(), 100), 1016 (recipient.address().to_string(), 100), 1017 ]); 1018 for wallet in migrators.iter().chain(&legacy_senders) { 1019 allocations.insert(wallet.address().to_string(), 100); 1020 } 1021 let mut ledger = Ledger::new_with_genesis_burns_and_profile( 1022 allocations, 1023 vec![GenesisBurn::new(finalizer.address(), 10)], 1024 1, 1025 LaunchProfile::local_testnet(), 1026 ) 1027 .unwrap(); 1028 let next_height = post_activation_height(); 1029 set_next_height(&mut ledger, next_height); 1030 ledger.launch_profile.max_block_transactions = 4; 1031 1032 let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap(); 1033 ledger.submit_transaction(anchor.clone()).unwrap(); 1034 for (index, wallet) in migrators.iter().enumerate() { 1035 let migration = ledger 1036 .build_v2_migration(wallet, 10 + index as u64) 1037 .unwrap(); 1038 ledger.submit_transaction_v2(migration).unwrap(); 1039 } 1040 for wallet in &legacy_senders { 1041 let transfer = ledger 1042 .build_transfer(wallet, recipient.address(), 10, 1) 1043 .unwrap(); 1044 ledger.submit_transaction(transfer).unwrap(); 1045 } 1046 1047 let prepared = ledger 1048 .prepare_next_block( 1049 finalizer.address(), 1050 ledger.tip().timestamp_ms.saturating_add(1), 1051 ) 1052 .unwrap(); 1053 assert_eq!( 1054 prepared.transactions.len() + prepared.transactions_v2.len(), 1055 ledger.launch_profile.max_block_transactions 1056 ); 1057 assert!( 1058 prepared 1059 .transactions 1060 .iter() 1061 .any(|transaction| transaction.signature() == anchor.signature()) 1062 ); 1063 assert!(!prepared.transactions_v2.is_empty()); 1064 let block = prepared.finish(&finalizer, "preverified-vdf".to_string()); 1065 ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); 1066 1067 assert_eq!(ledger.height(), next_height); 1068 assert!( 1069 ledger.pending().len() + ledger.pending_v2().len() > 0, 1070 "transactions over the shared block limit must remain pending" 1071 ); 1072 } 1073 1074 #[test] 1075 fn normal_post_activation_v2_block_enforces_the_exact_byte_boundary() { 1076 let finalizer = Wallet::from_seed("v2-post-activation-bytes-finalizer"); 1077 let migrator = Wallet::from_seed("v2-post-activation-bytes-migrator"); 1078 let mut ledger = Ledger::new_with_genesis_burns_and_profile( 1079 BTreeMap::from([ 1080 (finalizer.address().to_string(), 100), 1081 (migrator.address().to_string(), 100), 1082 ]), 1083 vec![GenesisBurn::new(finalizer.address(), 10)], 1084 1, 1085 LaunchProfile::local_testnet(), 1086 ) 1087 .unwrap(); 1088 let next_height = post_activation_height(); 1089 set_next_height(&mut ledger, next_height); 1090 let anchor = ledger.build_burn_for_next_block(&finalizer, 1, 1).unwrap(); 1091 ledger.submit_transaction(anchor).unwrap(); 1092 let migration = ledger.build_v2_migration(&migrator, 3).unwrap(); 1093 ledger.submit_transaction_v2(migration).unwrap(); 1094 let prepared = ledger 1095 .prepare_next_block( 1096 finalizer.address(), 1097 ledger.tip().timestamp_ms.saturating_add(1), 1098 ) 1099 .unwrap(); 1100 assert_eq!(prepared.transactions_v2.len(), 1); 1101 let block = prepared.finish(&finalizer, "preverified-vdf".to_string()); 1102 let block_bytes = ledger.consensus_block_size_bytes(&block).unwrap(); 1103 1104 let mut exact = ledger.clone(); 1105 exact.launch_profile.max_block_bytes = block_bytes; 1106 exact 1107 .apply_preverified_block_at(block.clone(), u64::MAX) 1108 .expect("post-activation v2 block at the byte limit should validate"); 1109 1110 let mut one_byte_over = ledger; 1111 one_byte_over.launch_profile.max_block_bytes = block_bytes.saturating_sub(1); 1112 assert!( 1113 one_byte_over 1114 .apply_preverified_block_at(block, u64::MAX) 1115 .is_err(), 1116 "post-activation v2 block one byte over the limit validated" 1117 ); 1118 } 1119 }