iuna

iuna

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

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                     &section,
   1054                 )
   1055                 .unwrap_err()
   1056                 .to_string()
   1057                 .contains("transaction count limit")
   1058         );
   1059     }
   1060 }