ledger_pending.rs (40869B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use anyhow::{Context, Result, bail}; 4 5 use super::ledger_mempool::pending_pool_item_bytes; 6 use super::ledger_ops::{ 7 apply_spendable_pending_transaction, apply_transaction, best_selectable_burn_from_index, 8 best_selectable_transaction_index, compact_block_context, ensure_transaction_fits_empty_block, 9 estimated_block_selection_size_bytes, transaction_has_missing_inputs, 10 validate_transaction_inputs, validate_transaction_outputs, 11 }; 12 use super::ledger_v2::apply_prevalidated_transaction_v2_to_utxos; 13 use super::mine_policy::{ 14 MINE_MAX_ANCHOR_AGE_BLOCKS, mine_anchor, mine_anchor_count_before_height, 15 }; 16 use super::selection::{BlockSelection, TransactionKind, fee_rate_key}; 17 use super::transaction::{ 18 UnsignedTxInput, transaction_inputs_available, transaction_inputs_spent_by, 19 }; 20 use super::validation::{ 21 validate_address, validate_hash, validate_signature, validate_stratum_header, 22 }; 23 use super::{ 24 AddressNetwork, Amount, BurnBundleSection, FinalizerMode, Ledger, MAX_PENDING_POOL_BYTES, 25 MAX_PENDING_TRANSACTIONS, MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput, 26 hex_encode, transaction_v2_is_active, 27 }; 28 29 impl Ledger { 30 pub(super) fn valid_pending_transactions(&self) -> Vec<Transaction> { 31 let mut utxos = self.utxos.clone(); 32 let mut valid = Vec::new(); 33 let mut remaining = self.pending.iter().collect::<Vec<_>>(); 34 let mut selected_mine_anchor_counts = BTreeMap::new(); 35 36 while !remaining.is_empty() { 37 let mut progressed = false; 38 let mut still_pending = Vec::new(); 39 40 for tx in remaining { 41 if let Some(anchor) = mine_anchor(tx) { 42 let selected = selected_mine_anchor_counts 43 .get(anchor) 44 .copied() 45 .unwrap_or_default(); 46 if mine_anchor_count_before_height(&self.chain, anchor, self.height()) 47 .saturating_add(selected) 48 >= MINE_ACTIONS_PER_ANCHOR_LIMIT 49 { 50 continue; 51 } 52 } 53 if transaction_inputs_available(tx, &utxos) 54 && self.validate_transaction_terms(tx).is_ok() 55 && self.validate_transaction_anchor_for_pending(tx).is_ok() 56 && apply_transaction( 57 tx, 58 &mut utxos, 59 &self.transaction_signing_domain_for_pending(tx), 60 ) 61 .is_ok() 62 { 63 if let Some(anchor) = mine_anchor(tx) { 64 selected_mine_anchor_counts 65 .entry(anchor) 66 .and_modify(|count| *count += 1) 67 .or_insert(1); 68 } 69 valid.push(tx.clone()); 70 progressed = true; 71 } else { 72 still_pending.push(tx); 73 } 74 } 75 76 if !progressed { 77 break; 78 } 79 80 remaining = still_pending; 81 } 82 83 valid 84 } 85 86 pub(super) fn select_block_transactions_with_burn_section( 87 &self, 88 miner: &str, 89 required_burn_signature: Option<&str>, 90 burn_bundle_section: &BurnBundleSection, 91 ) -> Result<BlockSelection> { 92 self.select_block_transactions_with_required_burn_owner( 93 Some(miner), 94 required_burn_signature, 95 FinalizerMode::Ticket, 96 burn_bundle_section, 97 ) 98 } 99 100 pub(super) fn select_recovery_block_transactions_with_burn_section( 101 &self, 102 miner: &str, 103 required_burn_signature: Option<&str>, 104 burn_bundle_section: &BurnBundleSection, 105 ) -> Result<BlockSelection> { 106 self.select_block_transactions_with_required_burn_owner( 107 Some(miner), 108 required_burn_signature, 109 FinalizerMode::Recovery, 110 burn_bundle_section, 111 ) 112 } 113 114 pub(super) fn select_block_transactions_with_required_burn_owner( 115 &self, 116 required_burn_owner: Option<&str>, 117 required_burn_signature: Option<&str>, 118 finalizer_mode: FinalizerMode, 119 burn_bundle_section: &BurnBundleSection, 120 ) -> Result<BlockSelection> { 121 let block_context = compact_block_context(self); 122 let signing_domain = self.transaction_signing_domain(); 123 let mut utxos = self.utxos.clone(); 124 let mut remaining = self 125 .valid_pending_transactions() 126 .into_iter() 127 .filter(|transaction| self.transaction_is_eligible_for_next_block(transaction)) 128 .collect::<Vec<_>>(); 129 let mut selected = Vec::new(); 130 let height = self.height().saturating_add(1); 131 let domain = self.transaction_v2_domain()?; 132 let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id); 133 let mut remaining_v2 = if transaction_v2_is_active(height) { 134 self.pending_v2 135 .iter() 136 .filter(|transaction| self.transaction_v2_is_eligible_for_next_block(transaction)) 137 .collect::<Vec<_>>() 138 } else { 139 Vec::new() 140 }; 141 let mut selected_v2 = Vec::new(); 142 143 let required_burn_signatures = burn_bundle_section 144 .required_burns() 145 .into_iter() 146 .map(|burn| burn.signature().to_string()) 147 .collect::<BTreeSet<_>>(); 148 let required_burns_v2 = burn_bundle_section 149 .required_burns_v2() 150 .into_iter() 151 .map(str::to_string) 152 .collect::<BTreeSet<_>>(); 153 let mut selected_required_burn_signatures = BTreeSet::new(); 154 let mut selected_required_burns_v2 = BTreeSet::new(); 155 156 let mut anchor_v2_index = None; 157 let anchor_index = if let Some(signature) = required_burn_signature { 158 let legacy = remaining 159 .iter() 160 .position(|transaction| transaction.signature() == signature); 161 if legacy.is_none() { 162 anchor_v2_index = remaining_v2.iter().position(|transaction| { 163 transaction 164 .transaction_id(&domain) 165 .ok() 166 .is_some_and(|id| hex_encode(id) == signature) 167 }); 168 } 169 if legacy.is_none() && anchor_v2_index.is_none() { 170 bail!("required burn {signature} is not pending"); 171 } 172 legacy 173 } else if let Some(owner) = required_burn_owner { 174 let legacy = 175 best_selectable_burn_from_index(&remaining, &utxos, owner, &signing_domain); 176 if legacy.is_none() { 177 anchor_v2_index = remaining_v2.iter().position(|transaction| { 178 transaction.is_burn() 179 && transaction 180 .burn_legacy_owner() 181 .ok() 182 .flatten() 183 .is_some_and(|candidate| candidate == owner) 184 }); 185 } 186 legacy 187 } else { 188 let legacy = best_selectable_transaction_index( 189 &remaining, 190 &utxos, 191 Some(TransactionKind::Burn), 192 &signing_domain, 193 ); 194 if legacy.is_none() { 195 anchor_v2_index = remaining_v2 196 .iter() 197 .position(|transaction| transaction.is_burn()); 198 } 199 legacy 200 }; 201 if let Some(index) = anchor_index { 202 let tx = remaining.remove(index); 203 let signature = tx.signature().to_string(); 204 self.select_required_anchor_burn(tx, required_burn_owner, &mut utxos, &mut selected)?; 205 if required_burn_signatures.contains(&signature) { 206 selected_required_burn_signatures.insert(signature); 207 } 208 } 209 if let Some(index) = anchor_v2_index { 210 let transaction = remaining_v2.remove(index); 211 if !transaction.is_burn() { 212 bail!("required block anchor must be a burn transaction"); 213 } 214 if let Some(owner) = required_burn_owner { 215 if transaction.burn_legacy_owner()?.as_deref() != Some(owner) { 216 bail!("required block anchor burn must be from the recovery finalizer"); 217 } 218 } 219 apply_prevalidated_transaction_v2_to_utxos(transaction, &domain, network, &mut utxos) 220 .context("required transaction v2 anchor burn is not spendable")?; 221 let envelope = hex_encode(transaction.encode(&domain)?); 222 if required_burns_v2.contains(&envelope) { 223 selected_required_burns_v2.insert(envelope.clone()); 224 } 225 selected_v2.push(envelope); 226 } 227 228 let mut index = 0; 229 while index < remaining.len() 230 && selected_required_burn_signatures.len() < required_burn_signatures.len() 231 { 232 if !required_burn_signatures.contains(remaining[index].signature()) { 233 index += 1; 234 continue; 235 } 236 let tx = remaining.remove(index); 237 if !tx.is_burn() { 238 bail!("attested transaction must be a burn"); 239 } 240 if selected.len() >= self.launch_profile.max_block_transactions { 241 bail!("attested burns do not fit within the block transaction count limit"); 242 } 243 let signature = tx.signature().to_string(); 244 apply_transaction(&tx, &mut utxos, &signing_domain) 245 .context("attested burn is not spendable")?; 246 selected.push(tx); 247 selected_required_burn_signatures.insert(signature); 248 } 249 if selected_required_burn_signatures.len() != required_burn_signatures.len() { 250 let missing = required_burn_signatures 251 .difference(&selected_required_burn_signatures) 252 .next() 253 .expect("required burn set differs"); 254 bail!("attested burn {missing} is not pending"); 255 } 256 let mut index = 0; 257 while index < remaining_v2.len() 258 && selected_required_burns_v2.len() < required_burns_v2.len() 259 { 260 let envelope = hex_encode(remaining_v2[index].encode(&domain)?); 261 if !required_burns_v2.contains(&envelope) { 262 index += 1; 263 continue; 264 } 265 let transaction = remaining_v2.remove(index); 266 if !transaction.is_burn() { 267 bail!("attested transaction v2 must be a burn"); 268 } 269 if selected.len().saturating_add(selected_v2.len()) 270 >= self.launch_profile.max_block_transactions 271 { 272 bail!( 273 "attested transaction v2 burns do not fit within the block transaction count limit" 274 ); 275 } 276 apply_prevalidated_transaction_v2_to_utxos(transaction, &domain, network, &mut utxos) 277 .context("attested transaction v2 burn is not spendable")?; 278 selected_required_burns_v2.insert(envelope.clone()); 279 selected_v2.push(envelope); 280 } 281 if selected_required_burns_v2.len() != required_burns_v2.len() { 282 bail!("attested transaction v2 burn is not pending"); 283 } 284 285 let required_selection = BlockSelection { 286 transactions: selected.clone(), 287 transactions_v2: selected_v2.clone(), 288 }; 289 if estimated_block_selection_size_bytes( 290 block_context, 291 &required_selection, 292 finalizer_mode, 293 burn_bundle_section, 294 )? > self.launch_profile.max_block_bytes 295 { 296 bail!("required block content does not fit in the block"); 297 } 298 299 let mut selection = BlockSelection { 300 transactions: selected, 301 transactions_v2: selected_v2, 302 }; 303 304 loop { 305 if selection 306 .transactions 307 .len() 308 .saturating_add(selection.transactions_v2.len()) 309 >= self.launch_profile.max_block_transactions 310 { 311 break; 312 } 313 314 let legacy = 315 best_selectable_transaction_index(&remaining, &utxos, None, &signing_domain) 316 .map(|index| (index, fee_rate_key(&remaining[index]))); 317 let v2 = remaining_v2 318 .iter() 319 .enumerate() 320 .filter_map(|(index, transaction)| { 321 let mut candidate_utxos = utxos.clone(); 322 apply_prevalidated_transaction_v2_to_utxos( 323 transaction, 324 &domain, 325 network, 326 &mut candidate_utxos, 327 ) 328 .ok()?; 329 let bytes = transaction.encoded_size_bytes(&domain).ok()?; 330 let rate = if bytes == 0 { 331 0 332 } else { 333 u128::from(transaction.fee()) * 1_000_000 / bytes as u128 334 }; 335 Some((index, rate, candidate_utxos)) 336 }) 337 .max_by_key(|(index, rate, _)| (*rate, std::cmp::Reverse(*index))); 338 339 if legacy.is_none() && v2.is_none() { 340 break; 341 } 342 if v2 343 .as_ref() 344 .is_some_and(|(_, v2_rate, _)| legacy.is_none_or(|(_, rate)| *v2_rate > rate)) 345 { 346 let (index, _, candidate_utxos) = v2.expect("v2 candidate was selected"); 347 let transaction = remaining_v2.remove(index); 348 let mut candidate = selection.clone(); 349 candidate 350 .transactions_v2 351 .push(hex_encode(transaction.encode(&domain)?)); 352 if estimated_block_selection_size_bytes( 353 block_context, 354 &candidate, 355 finalizer_mode, 356 burn_bundle_section, 357 )? <= self.launch_profile.max_block_bytes 358 { 359 utxos = candidate_utxos; 360 selection = candidate; 361 } 362 } else { 363 let (index, _) = legacy.expect("legacy candidate was selected"); 364 let transaction = remaining.remove(index); 365 let mut candidate = selection.clone(); 366 candidate.transactions.push(transaction.clone()); 367 if estimated_block_selection_size_bytes( 368 block_context, 369 &candidate, 370 finalizer_mode, 371 burn_bundle_section, 372 )? <= self.launch_profile.max_block_bytes 373 { 374 apply_transaction(&transaction, &mut utxos, &signing_domain)?; 375 selection = candidate; 376 } 377 } 378 } 379 Ok(selection) 380 } 381 382 fn select_required_anchor_burn( 383 &self, 384 tx: Transaction, 385 required_burn_owner: Option<&str>, 386 utxos: &mut BTreeMap<OutPoint, TxOutput>, 387 selected: &mut Vec<Transaction>, 388 ) -> Result<()> { 389 if !tx.is_burn() { 390 bail!("required block anchor must be a burn transaction"); 391 } 392 if let Some(owner) = required_burn_owner { 393 if tx.sender() != owner { 394 bail!("required block anchor burn must be from the recovery finalizer"); 395 } 396 } 397 if selected.len() >= self.launch_profile.max_block_transactions { 398 bail!("required block anchor burn does not fit within the transaction count limit"); 399 } 400 apply_transaction(&tx, utxos, &self.transaction_signing_domain()) 401 .context("required block anchor burn is not spendable")?; 402 selected.push(tx); 403 Ok(()) 404 } 405 406 pub(super) fn select_inputs( 407 &self, 408 address: &str, 409 amount: Amount, 410 ) -> Result<(Vec<UnsignedTxInput>, Amount)> { 411 let utxos = self.utxos_after_spendable_pending()?; 412 let mut selected = Vec::new(); 413 let mut total = 0_u64; 414 for (outpoint, output) in &utxos { 415 if output.address != address { 416 continue; 417 } 418 selected.push(UnsignedTxInput { 419 outpoint: outpoint.clone(), 420 owner: address.to_string(), 421 }); 422 total = total 423 .checked_add(output.amount) 424 .context("selected input total overflows")?; 425 if total >= amount { 426 return Ok((selected, total)); 427 } 428 } 429 bail!("insufficient funds for {address}") 430 } 431 432 pub(super) fn select_inputs_by_outpoint( 433 &self, 434 address: &str, 435 amount: Amount, 436 outpoints: &[OutPoint], 437 ) -> Result<(Vec<UnsignedTxInput>, Amount)> { 438 if outpoints.is_empty() { 439 bail!("at least one UTXO must be selected"); 440 } 441 let utxos = self.utxos_after_spendable_pending()?; 442 let mut seen = BTreeSet::new(); 443 let mut selected = Vec::new(); 444 let mut total = 0_u64; 445 for outpoint in outpoints { 446 if !seen.insert(outpoint.clone()) { 447 bail!("selected UTXO {} is duplicated", outpoint.id()); 448 } 449 let output = utxos 450 .get(outpoint) 451 .with_context(|| format!("selected UTXO {} is not spendable", outpoint.id()))?; 452 if output.address != address { 453 bail!("selected UTXO {} is not owned by {address}", outpoint.id()); 454 } 455 selected.push(UnsignedTxInput { 456 outpoint: outpoint.clone(), 457 owner: address.to_string(), 458 }); 459 total = total 460 .checked_add(output.amount) 461 .context("selected input total overflows")?; 462 } 463 if total < amount { 464 bail!("selected UTXOs do not cover amount plus fee"); 465 } 466 Ok((selected, total)) 467 } 468 469 pub(super) fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { 470 self.validate_transaction_terms(transaction)?; 471 self.validate_transaction_anchor_for_pending(transaction)?; 472 ensure_transaction_fits_empty_block( 473 compact_block_context(self), 474 transaction, 475 self.launch_profile.max_block_bytes, 476 )?; 477 self.validate_mine_anchor_available(transaction)?; 478 let mut utxos = self.utxos_after_spendable_pending()?; 479 apply_transaction( 480 transaction, 481 &mut utxos, 482 &self.transaction_signing_domain_for_pending(transaction), 483 ) 484 } 485 486 pub(super) fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { 487 if let Some(anchor) = mine_anchor(transaction) { 488 let known_count = mine_anchor_count_before_height(&self.chain, anchor, self.height()) 489 .saturating_add( 490 self.pending 491 .iter() 492 .filter(|tx| mine_anchor(tx) == Some(anchor)) 493 .count(), 494 ) 495 .saturating_add( 496 self.orphans 497 .iter() 498 .filter(|tx| { 499 mine_anchor(tx) == Some(anchor) 500 && tx.signature() != transaction.signature() 501 }) 502 .count(), 503 ); 504 if known_count >= MINE_ACTIONS_PER_ANCHOR_LIMIT { 505 return Err(super::ValidationError::MineAnchorLimitReached.into()); 506 } 507 } 508 Ok(()) 509 } 510 511 pub(super) fn promote_orphan_transactions(&mut self) -> Result<()> { 512 loop { 513 if self.pending.len() >= MAX_PENDING_TRANSACTIONS { 514 return Ok(()); 515 } 516 let mut promoted = None; 517 let mut utxos = self.utxos_after_valid_pending()?; 518 for (index, transaction) in self.orphans.iter().enumerate() { 519 if transaction_inputs_spent_by(transaction, &self.pending) { 520 continue; 521 } 522 if transaction_has_missing_inputs(transaction, &utxos) { 523 continue; 524 } 525 if self.validate_new_transaction(transaction).is_ok() 526 && apply_transaction( 527 transaction, 528 &mut utxos, 529 &self.transaction_signing_domain_for_pending(transaction), 530 ) 531 .is_ok() 532 { 533 let transaction_bytes = pending_pool_item_bytes(transaction)?; 534 let promoted_bytes = self 535 .pending_bytes 536 .checked_add(transaction_bytes) 537 .context("pending pool byte size overflow")?; 538 if promoted_bytes > MAX_PENDING_POOL_BYTES { 539 continue; 540 } 541 promoted = Some((index, transaction_bytes)); 542 break; 543 } 544 } 545 546 let Some((index, transaction_bytes)) = promoted else { 547 return Ok(()); 548 }; 549 let transaction = self.orphans.remove(index); 550 self.orphan_bytes = self.orphan_bytes.saturating_sub(transaction_bytes); 551 self.pending.push(transaction); 552 self.pending_bytes = self.pending_bytes.saturating_add(transaction_bytes); 553 } 554 } 555 556 pub(super) fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> { 557 match transaction { 558 Transaction::Transfer { 559 inputs, 560 outputs, 561 fee, 562 signature, 563 .. 564 } => { 565 if *fee == 0 { 566 bail!("transfer transaction fee must be greater than zero"); 567 } 568 validate_transaction_inputs(inputs)?; 569 validate_transaction_outputs(outputs)?; 570 validate_signature(signature, "transaction signature")?; 571 } 572 Transaction::Burn { 573 inputs, 574 change, 575 fee, 576 anchor, 577 signature, 578 .. 579 } => { 580 if *fee == 0 { 581 bail!("burn transaction fee must be greater than zero"); 582 } 583 validate_transaction_inputs(inputs)?; 584 validate_transaction_outputs(change)?; 585 if let Some(anchor) = anchor { 586 validate_hash(anchor, "burn transaction anchor")?; 587 } 588 validate_signature(signature, "transaction signature")?; 589 } 590 Transaction::Mine { 591 recipient, 592 anchor, 593 difficulty_bits, 594 proof_header, 595 signature, 596 .. 597 } => { 598 let next_height = self.height().saturating_add(1); 599 if next_height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT { 600 let address = super::decode_versioned_address( 601 recipient, 602 super::AddressNetwork::from_profile_id(&self.launch_profile.profile_id), 603 )?; 604 if address.version != super::AddressVersion::HybridKeyCommitment { 605 bail!("mine reward must use a hybrid address at height {next_height}"); 606 } 607 } else { 608 validate_address(recipient, "mine recipient")?; 609 } 610 validate_hash(anchor, "mine transaction anchor")?; 611 validate_hash(signature, "mine transaction proof hash")?; 612 if let Some(proof_header) = proof_header { 613 validate_stratum_header(proof_header)?; 614 } 615 let anchor_block = self 616 .chain 617 .iter() 618 .find(|block| block.hash == *anchor) 619 .ok_or(super::ValidationError::MineAnchorNotOnChain)?; 620 let anchor_age = self.tip().height.saturating_sub(anchor_block.height); 621 if anchor_age > MINE_MAX_ANCHOR_AGE_BLOCKS { 622 bail!("mine transaction anchor is too old"); 623 } 624 let required_difficulty = 625 self.mine_difficulty_bits_for_anchor_height(anchor_block.height); 626 if *difficulty_bits != required_difficulty { 627 bail!( 628 "mine transaction difficulty is invalid: got {}, required {} at anchor height {}", 629 difficulty_bits, 630 required_difficulty, 631 anchor_block.height 632 ); 633 } 634 } 635 } 636 Ok(()) 637 } 638 639 pub(super) fn validate_transaction_anchor_for_block( 640 &self, 641 transaction: &Transaction, 642 height: u64, 643 ) -> Result<()> { 644 if !transaction.is_burn() { 645 return Ok(()); 646 } 647 if height < super::TIP_BOUND_BURN_ACTIVATION_HEIGHT { 648 if transaction.burn_anchor().is_some() { 649 bail!("burn transaction anchor is not active yet"); 650 } 651 return Ok(()); 652 } 653 let anchor = transaction 654 .burn_anchor() 655 .context("burn transaction is missing its chain anchor")?; 656 if anchor != self.tip().prev_hash { 657 bail!("burn transaction anchor does not match the block grandparent"); 658 } 659 Ok(()) 660 } 661 662 pub(super) fn validate_transaction_anchor_for_pending( 663 &self, 664 transaction: &Transaction, 665 ) -> Result<()> { 666 if !transaction.is_burn() { 667 return Ok(()); 668 } 669 670 let next_height = self.height().saturating_add(1); 671 let following_height = self.height().saturating_add(2); 672 let anchor = transaction.burn_anchor(); 673 674 // Keep both pipeline stages: burns anchored to the tip's parent are 675 // eligible now, while burns anchored to the tip wait one more block. 676 if next_height < super::TIP_BOUND_BURN_ACTIVATION_HEIGHT && anchor.is_none() { 677 return Ok(()); 678 } 679 if next_height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT 680 && anchor == Some(self.tip().prev_hash.as_str()) 681 { 682 return Ok(()); 683 } 684 if following_height >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT 685 && anchor == Some(self.tip_hash()) 686 { 687 return Ok(()); 688 } 689 690 Err(super::ValidationError::BurnAnchorOutsidePendingWindow.into()) 691 } 692 693 pub(crate) fn transaction_is_eligible_for_next_block(&self, transaction: &Transaction) -> bool { 694 self.validate_transaction_anchor_for_block(transaction, self.height().saturating_add(1)) 695 .is_ok() 696 } 697 698 pub(super) fn transaction_signing_domain_for_pending( 699 &self, 700 transaction: &Transaction, 701 ) -> super::TransactionSigningDomain { 702 let height = if transaction.is_burn() 703 && transaction.burn_anchor() == Some(self.tip_hash()) 704 && self.height().saturating_add(2) >= super::TIP_BOUND_BURN_ACTIVATION_HEIGHT 705 { 706 self.height().saturating_add(2) 707 } else { 708 self.height().saturating_add(1) 709 }; 710 self.transaction_signing_domain_at(height) 711 } 712 713 pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { 714 let mut utxos = self.utxos.clone(); 715 for pending in self.valid_pending_transactions() { 716 apply_transaction( 717 &pending, 718 &mut utxos, 719 &self.transaction_signing_domain_for_pending(&pending), 720 )?; 721 } 722 Ok(utxos) 723 } 724 725 pub(super) fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { 726 let mut utxos = self.utxos.clone(); 727 for pending in self.valid_pending_transactions() { 728 if matches!(pending, Transaction::Mine { .. }) { 729 continue; 730 } 731 if apply_spendable_pending_transaction( 732 &pending, 733 &mut utxos, 734 &self.transaction_signing_domain_for_pending(&pending), 735 ) 736 .is_err() 737 { 738 continue; 739 } 740 } 741 Ok(utxos) 742 } 743 } 744 745 #[cfg(test)] 746 mod tests { 747 use std::collections::BTreeMap; 748 749 use super::*; 750 use crate::domain::{BurnBundleSection, FinalizerMode, MaskedBurn, Wallet}; 751 752 fn extend_synthetic_chain_to(ledger: &mut Ledger, target_height: u64) { 753 while ledger.height() < target_height { 754 let parent = ledger.tip().clone(); 755 let height = parent.height + 1; 756 let mut block = parent; 757 block.height = height; 758 block.prev_hash = block.hash.clone(); 759 block.hash = format!("{height:064x}"); 760 block.timestamp_ms = height; 761 block.finalizer_mode = FinalizerMode::Ticket; 762 block.finalizer_rank = 0; 763 block.burn_bundle_section = BurnBundleSection::default(); 764 block.transactions.clear(); 765 ledger.chain.push(block); 766 } 767 } 768 769 #[test] 770 fn mine_actions_expire_after_ten_blocks() { 771 let wallet = Wallet::from_seed("mine-anchor-expiry-wallet"); 772 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); 773 let anchor = ledger.genesis_hash().to_string(); 774 let mine = Transaction::Mine { 775 recipient: wallet.address().to_string(), 776 anchor, 777 salt: 1, 778 nonce: 1, 779 difficulty_bits: ledger.mine_difficulty_bits_for_anchor_height(0), 780 proof_header: None, 781 signature: "a".repeat(64), 782 }; 783 784 extend_synthetic_chain_to(&mut ledger, MINE_MAX_ANCHOR_AGE_BLOCKS); 785 assert!(ledger.validate_transaction_terms(&mine).is_ok()); 786 787 extend_synthetic_chain_to(&mut ledger, MINE_MAX_ANCHOR_AGE_BLOCKS + 1); 788 assert!( 789 ledger 790 .validate_transaction_terms(&mine) 791 .unwrap_err() 792 .to_string() 793 .contains("anchor is too old") 794 ); 795 } 796 797 #[test] 798 fn burns_queue_for_one_block_then_expire_after_their_inclusion_height() { 799 let wallet = Wallet::from_seed("tip-bound-burn-wallet"); 800 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); 801 802 extend_synthetic_chain_to( 803 &mut ledger, 804 super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 3, 805 ); 806 let legacy_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); 807 assert_eq!(legacy_burn.burn_anchor(), None); 808 809 extend_synthetic_chain_to( 810 &mut ledger, 811 super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 2, 812 ); 813 let anchored_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); 814 assert_eq!(anchored_burn.burn_anchor(), Some(ledger.tip_hash())); 815 assert!(ledger.submit_transaction(anchored_burn.clone()).unwrap()); 816 assert_eq!( 817 ledger.valid_pending_transactions(), 818 vec![anchored_burn.clone()] 819 ); 820 assert!( 821 ledger 822 .select_block_transactions_with_required_burn_owner( 823 None, 824 None, 825 FinalizerMode::Ticket, 826 &BurnBundleSection::default(), 827 ) 828 .unwrap() 829 .transactions 830 .is_empty() 831 ); 832 833 extend_synthetic_chain_to( 834 &mut ledger, 835 super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, 836 ); 837 assert_eq!( 838 ledger.valid_pending_transactions(), 839 vec![anchored_burn.clone()] 840 ); 841 assert_eq!( 842 ledger 843 .select_block_transactions_with_required_burn_owner( 844 None, 845 None, 846 FinalizerMode::Ticket, 847 &BurnBundleSection::default(), 848 ) 849 .unwrap() 850 .transactions, 851 vec![anchored_burn] 852 ); 853 854 extend_synthetic_chain_to(&mut ledger, super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT); 855 assert!(ledger.valid_pending_transactions().is_empty()); 856 } 857 858 #[test] 859 fn finalizer_burn_for_next_block_anchors_to_the_current_tip_parent() { 860 let wallet = Wallet::from_seed("next-block-burn-wallet"); 861 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); 862 extend_synthetic_chain_to( 863 &mut ledger, 864 super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, 865 ); 866 867 let future_burn = ledger.build_burn(&wallet, 1, 1).unwrap(); 868 let next_block_burn = ledger.build_burn_for_next_block(&wallet, 1, 1).unwrap(); 869 870 assert_eq!(future_burn.burn_anchor(), Some(ledger.tip_hash())); 871 assert_eq!( 872 next_block_burn.burn_anchor(), 873 Some(ledger.tip().prev_hash.as_str()) 874 ); 875 assert!(!ledger.transaction_is_eligible_for_next_block(&future_burn)); 876 assert!(ledger.transaction_is_eligible_for_next_block(&next_block_burn)); 877 } 878 879 #[test] 880 fn burn_signature_commits_to_parent_anchor() { 881 let wallet = Wallet::from_seed("tip-bound-burn-signature-wallet"); 882 let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 10)]), 1); 883 extend_synthetic_chain_to( 884 &mut ledger, 885 super::super::TIP_BOUND_BURN_ACTIVATION_HEIGHT - 1, 886 ); 887 let mut burn = ledger.build_burn(&wallet, 1, 1).unwrap(); 888 let Transaction::Burn { anchor, .. } = &mut burn else { 889 unreachable!(); 890 }; 891 *anchor = Some("f".repeat(64)); 892 893 assert!( 894 burn.verify_signature(&ledger.transaction_signing_domain()) 895 .unwrap_err() 896 .to_string() 897 .contains("signature is invalid") 898 ); 899 } 900 901 #[test] 902 fn block_selection_reserves_anchor_first_then_orders_remaining_by_fee_rate() { 903 let finalizer = Wallet::from_seed("selection-finalizer"); 904 let high_fee_sender = Wallet::from_seed("selection-high-fee"); 905 let medium_fee_sender = Wallet::from_seed("selection-medium-fee"); 906 let recipient = Wallet::from_seed("selection-recipient"); 907 let mut ledger = Ledger::new( 908 BTreeMap::from([ 909 (finalizer.address().to_string(), 1_000), 910 (high_fee_sender.address().to_string(), 1_000), 911 (medium_fee_sender.address().to_string(), 1_000), 912 ]), 913 1, 914 ); 915 let anchor = ledger.build_burn(&finalizer, 1, 1).unwrap(); 916 let high_fee = ledger 917 .build_transfer(&high_fee_sender, recipient.address(), 1, 20) 918 .unwrap(); 919 let medium_fee = ledger.build_burn(&medium_fee_sender, 1, 5).unwrap(); 920 for transaction in [&medium_fee, &anchor, &high_fee] { 921 ledger.submit_transaction(transaction.clone()).unwrap(); 922 } 923 924 let selection = ledger 925 .select_block_transactions_with_required_burn_owner( 926 Some(finalizer.address()), 927 Some(anchor.signature()), 928 FinalizerMode::Ticket, 929 &BurnBundleSection::default(), 930 ) 931 .unwrap(); 932 933 assert_eq!(selection.transactions[0].signature(), anchor.signature()); 934 assert!(selection.transactions[0].is_burn()); 935 assert!(selection.transactions[1..].windows(2).all(|pair| { 936 super::super::selection::fee_rate_key(&pair[0]) 937 >= super::super::selection::fee_rate_key(&pair[1]) 938 })); 939 assert!( 940 selection 941 .transactions 942 .iter() 943 .any(|transaction| transaction.signature() == high_fee.signature()) 944 ); 945 } 946 947 #[test] 948 fn ticket_block_selection_reserves_space_for_leader_proof() { 949 let finalizer = Wallet::from_seed("ticket-selection-leader-proof-finalizer"); 950 let mut ledger = Ledger::new(BTreeMap::from([(finalizer.address().to_string(), 10)]), 1); 951 let anchor = ledger.build_burn(&finalizer, 1, 1).unwrap(); 952 ledger.submit_transaction(anchor.clone()).unwrap(); 953 954 let required_selection = BlockSelection { 955 transactions: vec![anchor.clone()], 956 transactions_v2: Vec::new(), 957 }; 958 let ticket_bytes = estimated_block_selection_size_bytes( 959 compact_block_context(&ledger), 960 &required_selection, 961 FinalizerMode::Ticket, 962 &BurnBundleSection::default(), 963 ) 964 .unwrap(); 965 let recovery_bytes = estimated_block_selection_size_bytes( 966 compact_block_context(&ledger), 967 &required_selection, 968 FinalizerMode::Recovery, 969 &BurnBundleSection::default(), 970 ) 971 .unwrap(); 972 assert!(ticket_bytes > recovery_bytes); 973 974 ledger.launch_profile.max_block_bytes = ticket_bytes - 1; 975 assert!(recovery_bytes <= ledger.launch_profile.max_block_bytes); 976 977 let error = ledger 978 .select_block_transactions_with_burn_section( 979 finalizer.address(), 980 Some(anchor.signature()), 981 &BurnBundleSection::default(), 982 ) 983 .unwrap_err(); 984 assert!( 985 error 986 .to_string() 987 .contains("required block content does not fit in the block") 988 ); 989 990 let recovery_selection = ledger 991 .select_recovery_block_transactions_with_burn_section( 992 finalizer.address(), 993 Some(anchor.signature()), 994 &BurnBundleSection::default(), 995 ) 996 .unwrap(); 997 assert_eq!(recovery_selection.transactions, vec![anchor]); 998 } 999 1000 #[test] 1001 fn public_transfers_and_burns_require_nonzero_fees() { 1002 let alice = Wallet::from_seed("zero-fee-alice"); 1003 let bob = Wallet::from_seed("zero-fee-bob"); 1004 let ledger = Ledger::new(BTreeMap::from([(alice.address().to_string(), 10)]), 1); 1005 1006 assert!( 1007 ledger 1008 .build_transfer(&alice, bob.address(), 1, 0) 1009 .unwrap_err() 1010 .to_string() 1011 .contains("fee must be greater than zero") 1012 ); 1013 assert!( 1014 ledger 1015 .build_burn(&alice, 1, 0) 1016 .unwrap_err() 1017 .to_string() 1018 .contains("fee must be greater than zero") 1019 ); 1020 } 1021 1022 #[test] 1023 fn required_anchor_and_attested_burns_must_fit_transaction_count_limit() { 1024 let finalizer = Wallet::from_seed("count-limit-finalizer"); 1025 let committee_sender = Wallet::from_seed("count-limit-committee"); 1026 let mut ledger = Ledger::new( 1027 BTreeMap::from([ 1028 (finalizer.address().to_string(), 10), 1029 (committee_sender.address().to_string(), 10), 1030 ]), 1031 1, 1032 ); 1033 let anchor = ledger.build_burn(&finalizer, 1, 1).unwrap(); 1034 let attested = ledger.build_burn(&committee_sender, 1, 1).unwrap(); 1035 ledger.submit_transaction(anchor.clone()).unwrap(); 1036 ledger.submit_transaction(attested.clone()).unwrap(); 1037 ledger.launch_profile.max_block_transactions = 1; 1038 let section = BurnBundleSection { 1039 signatures: Vec::new(), 1040 burns: vec![MaskedBurn { 1041 burn: attested, 1042 bundle_mask: 0, 1043 }], 1044 burns_v2: Vec::new(), 1045 }; 1046 1047 assert!( 1048 ledger 1049 .select_block_transactions_with_required_burn_owner( 1050 Some(finalizer.address()), 1051 Some(anchor.signature()), 1052 FinalizerMode::Ticket, 1053 §ion, 1054 ) 1055 .unwrap_err() 1056 .to_string() 1057 .contains("transaction count limit") 1058 ); 1059 } 1060 }