iuna

iuna

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

automatic_mining.rs (74148B)


      1 use anyhow::{Context, Result, bail};
      2 
      3 use super::helpers::{allowed_fallback_vdf_rank_count, converge_fee_by_byte};
      4 use super::{
      5     AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan,
      6     BURN_BUNDLE_COLLECTION_MS, GossipEnvelope, Ledger, MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT, NodeCore,
      7     PreparedBlock, Transaction, run_vdf,
      8 };
      9 use crate::domain::{
     10     Amount, BurnCommitteeMember, FinalizerMode, HYBRID_REWARD_ACTIVATION_HEIGHT,
     11     transaction_v2_is_active,
     12 };
     13 
     14 mod pow;
     15 
     16 impl NodeCore {
     17     pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome {
     18         let plan = self.prepare_automatic_mining(timestamp_ms);
     19         let mut outcome = AutoMineOutcome {
     20             pow_mined: plan.pow_mined,
     21             burned: plan.burned,
     22             block: None,
     23             skipped_reason: plan.skipped_reason,
     24         };
     25 
     26         let Some(work) = plan.work else {
     27             return outcome;
     28         };
     29         let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
     30         match self.complete_prepared_block_at(work, vdf_output, timestamp_ms) {
     31             Ok(block) => {
     32                 outcome.block = Some(block);
     33                 outcome.skipped_reason = None;
     34             }
     35             Err(error) => {
     36                 outcome.skipped_reason = Some(format!("{error:#}"));
     37             }
     38         }
     39 
     40         outcome
     41     }
     42 
     43     pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan {
     44         let mut plan = AutoMinePlan {
     45             pow_mined: None,
     46             burned: None,
     47             work: None,
     48             skipped_reason: None,
     49         };
     50 
     51         if self.wallet.is_locked() {
     52             if self.pow_mining_enabled {
     53                 self.last_auto_pow_mine_status = Some("wallet is locked".to_string());
     54             }
     55             return AutoMinePlan {
     56                 pow_mined: None,
     57                 burned: None,
     58                 work: None,
     59                 skipped_reason: Some("wallet is locked".to_string()),
     60             };
     61         }
     62 
     63         let pow_error = match self.prepare_automatic_pow_mining() {
     64             Ok(tx) => {
     65                 plan.pow_mined = tx;
     66                 None
     67             }
     68             Err(error) => {
     69                 let message = format!("automatic PoW mining failed: {error:#}");
     70                 self.last_auto_pow_mine_status = Some(message.clone());
     71                 Some(message)
     72             }
     73         };
     74 
     75         if !self.automatic_mining_enabled {
     76             plan.skipped_reason =
     77                 Some(pow_error.unwrap_or_else(|| "automatic mining is off".to_string()));
     78             return plan;
     79         }
     80 
     81         if let Some(error) = pow_error {
     82             plan.skipped_reason = Some(error);
     83             return plan;
     84         }
     85 
     86         match self.prepare_automatic_burn(timestamp_ms) {
     87             Ok(tx) => plan.burned = tx,
     88             Err(error) => {
     89                 plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
     90                 return plan;
     91             }
     92         }
     93 
     94         let wallet_rank = self
     95             .ledger
     96             .finalizer_rank_for_next_block(self.wallet.address());
     97         let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank));
     98         let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms);
     99         if let Some(wait_ms) = self.burn_bundle_collection_wait_ms(
    100             timestamp_ms,
    101             will_run_ticket_vdf || will_run_recovery_vdf,
    102         ) {
    103             self.request_missing_burn_bundles_for_next_block(timestamp_ms);
    104             plan.skipped_reason = Some(format!(
    105                 "collecting burns for next block ({:.1}s remaining)",
    106                 wait_ms as f64 / 1000.0
    107             ));
    108             return plan;
    109         }
    110         if let Err(error) = self.publish_burn_bundle_for_next_block() {
    111             plan.skipped_reason = Some(format!("{error:#}"));
    112             return plan;
    113         }
    114 
    115         if will_run_recovery_vdf {
    116             match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
    117                 Ok(work) => {
    118                     plan.work = Some(work);
    119                 }
    120                 Err(error) => {
    121                     plan.skipped_reason = Some(format!("{error:#}"));
    122                 }
    123             }
    124             return plan;
    125         }
    126 
    127         if let Some(rank) = wallet_rank {
    128             if !self.wallet_rank_runs_vdf(rank) {
    129                 plan.skipped_reason = Some(format!(
    130                     "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
    131                     self.recovery_vdf_top_rank_percent
    132                 ));
    133                 return plan;
    134             }
    135         } else {
    136             let selected_leader = self.ledger.expected_leader_for_next_block();
    137             plan.skipped_reason = Some(selected_leader.map_or_else(
    138                 || {
    139                     let wait_ms = self
    140                         .ledger
    141                         .recovery_block_min_timestamp()
    142                         .saturating_sub(timestamp_ms);
    143                     format!(
    144                         "wallet is waiting for recovery to become available ({:.1}s remaining)",
    145                         wait_ms as f64 / 1000.0
    146                     )
    147                 },
    148                 |leader| {
    149                     format!("wallet is waiting for selected finalizer {leader} to finish the VDF")
    150                 },
    151             ));
    152             return plan;
    153         }
    154 
    155         match self.prepare_next_block_with_local_anchor(timestamp_ms) {
    156             Ok(work) => {
    157                 plan.work = Some(work);
    158             }
    159             Err(error) => {
    160                 plan.skipped_reason = Some(format!("{error:#}"));
    161             }
    162         }
    163 
    164         plan
    165     }
    166 
    167     pub fn prepare_automatic_finalization(&mut self, timestamp_ms: u64) -> AutoMinePlan {
    168         let mut plan = AutoMinePlan {
    169             pow_mined: None,
    170             burned: None,
    171             work: None,
    172             skipped_reason: None,
    173         };
    174 
    175         if self.wallet.is_locked() {
    176             plan.skipped_reason = Some("wallet is locked".to_string());
    177             self.last_auto_finalization_status = plan.skipped_reason.clone();
    178             return plan;
    179         }
    180 
    181         if !self.automatic_mining_enabled {
    182             if let Err(error) = self.publish_burn_bundle_for_next_block() {
    183                 plan.skipped_reason = Some(format!("burn committee signing failed: {error:#}"));
    184                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    185                 return plan;
    186             }
    187             plan.skipped_reason = Some("automatic mining is off".to_string());
    188             self.last_auto_finalization_status = plan.skipped_reason.clone();
    189             return plan;
    190         }
    191 
    192         match self.prepare_automatic_burn(timestamp_ms) {
    193             Ok(tx) => plan.burned = tx,
    194             Err(error) => {
    195                 plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
    196                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    197                 return plan;
    198             }
    199         }
    200 
    201         let wallet_rank = self
    202             .ledger
    203             .finalizer_rank_for_next_block(self.wallet.address());
    204         let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank));
    205         let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms);
    206         if let Some(wait_ms) = self.burn_bundle_collection_wait_ms(
    207             timestamp_ms,
    208             will_run_ticket_vdf || will_run_recovery_vdf,
    209         ) {
    210             self.request_missing_burn_bundles_for_next_block(timestamp_ms);
    211             plan.skipped_reason = Some(format!(
    212                 "collecting burns for next block ({:.1}s remaining)",
    213                 wait_ms as f64 / 1000.0
    214             ));
    215             self.last_auto_finalization_status = plan.skipped_reason.clone();
    216             return plan;
    217         }
    218         if let Err(error) = self.publish_burn_bundle_for_next_block() {
    219             plan.skipped_reason = Some(format!("{error:#}"));
    220             self.last_auto_finalization_status = plan.skipped_reason.clone();
    221             return plan;
    222         }
    223 
    224         if will_run_recovery_vdf {
    225             match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
    226                 Ok(work) => {
    227                     self.last_auto_finalization_status = Some(format!(
    228                         "running recovery VDF for candidate block {} ({} rounds)",
    229                         work.height(),
    230                         work.vdf_rounds()
    231                     ));
    232                     plan.work = Some(work);
    233                 }
    234                 Err(error) => {
    235                     plan.skipped_reason = Some(format!("{error:#}"));
    236                     self.last_auto_finalization_status = plan.skipped_reason.clone();
    237                 }
    238             }
    239             return plan;
    240         }
    241 
    242         if let Some(rank) = wallet_rank {
    243             if !self.wallet_rank_runs_vdf(rank) {
    244                 plan.skipped_reason = Some(format!(
    245                     "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
    246                     self.recovery_vdf_top_rank_percent
    247                 ));
    248                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    249                 return plan;
    250             }
    251         } else {
    252             let selected_leader = self.ledger.expected_leader_for_next_block();
    253             plan.skipped_reason = Some(selected_leader.map_or_else(
    254                 || {
    255                     let wait_ms = self
    256                         .ledger
    257                         .recovery_block_min_timestamp()
    258                         .saturating_sub(timestamp_ms);
    259                     format!(
    260                         "wallet is waiting for recovery to become available ({:.1}s remaining)",
    261                         wait_ms as f64 / 1000.0
    262                     )
    263                 },
    264                 |leader| {
    265                     format!("wallet is waiting for selected finalizer {leader} to finish the VDF")
    266                 },
    267             ));
    268             self.last_auto_finalization_status = plan.skipped_reason.clone();
    269             return plan;
    270         }
    271 
    272         match self.prepare_next_block_with_local_anchor(timestamp_ms) {
    273             Ok(work) => {
    274                 self.last_auto_finalization_status = Some(format!(
    275                     "running VDF for candidate block {} ({} rounds)",
    276                     work.height(),
    277                     work.vdf_rounds()
    278                 ));
    279                 plan.work = Some(work);
    280             }
    281             Err(error) => {
    282                 plan.skipped_reason = Some(format!("{error:#}"));
    283                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    284             }
    285         }
    286 
    287         plan
    288     }
    289 
    290     pub(super) fn prepare_automatic_burn(
    291         &mut self,
    292         timestamp_ms: u64,
    293     ) -> Result<Option<Transaction>> {
    294         let current_height = self.ledger.height();
    295         if !self.automatic_mining_enabled {
    296             return Ok(None);
    297         }
    298         let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?;
    299         if self.burn_per_block == 0 {
    300             self.last_auto_burn_height = Some(current_height);
    301             return Ok(anchor_burn);
    302         }
    303         if anchor_burn
    304             .as_ref()
    305             .is_some_and(|burn| burn.amount() >= self.burn_per_block)
    306         {
    307             self.last_auto_burn_height = Some(current_height);
    308             return Ok(anchor_burn);
    309         }
    310         let hybrid_anchor_amount = self
    311             .ledger
    312             .pending_v2()
    313             .iter()
    314             .filter(|transaction| {
    315                 transaction.is_burn()
    316                     && self
    317                         .ledger
    318                         .transaction_v2_is_eligible_for_next_block(transaction)
    319                     && transaction.burn_legacy_owner().ok().flatten().as_deref()
    320                         == Some(self.wallet.address())
    321             })
    322             .map(crate::domain::TransactionV2::amount)
    323             .max()
    324             .unwrap_or(0);
    325         if hybrid_anchor_amount >= self.burn_per_block {
    326             self.last_auto_burn_height = Some(current_height);
    327             return Ok(anchor_burn);
    328         }
    329         if self.last_auto_burn_height == Some(current_height) {
    330             return Ok(anchor_burn);
    331         }
    332 
    333         let fee_per_byte = self.burn_fee;
    334         let ledger = self.wallet_build_ledger()?;
    335         let next_height = current_height.saturating_add(1);
    336         if transaction_v2_is_active(next_height) {
    337             let hybrid_balance = self
    338                 .ledger
    339                 .wallet_owned_hybrid_encoded_addresses(self.wallet.unlocked()?)?
    340                 .iter()
    341                 .fold(0_u64, |total, address| {
    342                     total.saturating_add(self.ledger.balance_of(address))
    343                 });
    344             if let Some((transaction, _)) =
    345                 self.best_automatic_v2_burn_on_ledger(&ledger, fee_per_byte, hybrid_balance, false)
    346             {
    347                 self.submit_public_transaction_v2(transaction)?;
    348                 self.last_auto_burn_height = Some(current_height);
    349                 return Ok(anchor_burn);
    350             }
    351             if next_height >= HYBRID_REWARD_ACTIVATION_HEIGHT {
    352                 bail!("automatic hybrid burn has insufficient confirmed funds");
    353             }
    354         }
    355 
    356         let balance = self.ledger.balance_of(self.wallet.address());
    357         let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance);
    358         let Some(tx) = best else {
    359             self.last_auto_burn_height = Some(current_height);
    360             return Ok(anchor_burn);
    361         };
    362         let burn = tx.clone();
    363         self.submit_public_transaction(tx)?;
    364         self.last_auto_burn_height = Some(current_height);
    365         Ok(Some(burn))
    366     }
    367 
    368     fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> {
    369         let current_height = self.ledger.height();
    370         if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) {
    371             return Ok(None);
    372         }
    373         if self
    374             .local_block_anchor_burn
    375             .as_ref()
    376             .is_some_and(|(height, _)| *height == current_height)
    377         {
    378             return Ok(None);
    379         }
    380         if self.last_auto_anchor_burn_height == Some(current_height) {
    381             return Ok(None);
    382         }
    383         if self.ledger.pending_v2().iter().any(|transaction| {
    384             transaction.is_burn()
    385                 && self
    386                     .ledger
    387                     .transaction_v2_is_eligible_for_next_block(transaction)
    388                 && transaction.burn_legacy_owner().ok().flatten().as_deref()
    389                     == Some(self.wallet.address())
    390         }) {
    391             // A journal-restored hybrid anchor is already available for this
    392             // height. Reuse it instead of trying to spend the same confirmed
    393             // hybrid inputs again after a restart.
    394             self.last_auto_anchor_burn_height = Some(current_height);
    395             return Ok(None);
    396         }
    397 
    398         let anchor_burn_amount = self.burn_per_block.max(MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT);
    399         let next_height = current_height.saturating_add(1);
    400         let mut hybrid_error = None;
    401         if transaction_v2_is_active(next_height) {
    402             let ledger = self.wallet_anchor_build_ledger()?;
    403             match self.build_v2_burn_with_fee_rate_on_ledger(
    404                 &ledger,
    405                 anchor_burn_amount,
    406                 self.burn_fee,
    407                 true,
    408             ) {
    409                 Ok((transaction, _)) => {
    410                     self.submit_prioritized_public_transaction_v2(transaction)?;
    411                     self.last_auto_anchor_burn_height = Some(current_height);
    412                     return Ok(None);
    413                 }
    414                 Err(error) if next_height >= HYBRID_REWARD_ACTIVATION_HEIGHT => {
    415                     return Err(error).context("automatic hybrid finalizer anchor burn failed");
    416                 }
    417                 Err(error) => hybrid_error = Some(error),
    418             }
    419         }
    420 
    421         // Pending wallet burns can themselves satisfy the block-anchor requirement.
    422         // Pending transfers cannot, so keep their confirmed inputs reserved.
    423         let pending_transfer_spent_outpoints = self
    424             .ledger
    425             .pending()
    426             .iter()
    427             .filter_map(|transaction| match transaction {
    428                 Transaction::Transfer { inputs, .. } => Some(inputs),
    429                 Transaction::Burn { .. } | Transaction::Mine { .. } => None,
    430             })
    431             .flatten()
    432             .map(|input| input.outpoint.clone())
    433             .collect::<std::collections::BTreeSet<_>>();
    434         let ledger = self.wallet_anchor_build_ledger()?;
    435         let wallet = self.wallet.unlocked()?;
    436         let available_utxos = ledger.available_utxos_for_address(wallet.address())?;
    437         // The plaintext anchor is the block's automatic burn when one is configured.
    438         // Keep a one-micro-IUNA anchor when automatic finalization is enabled with a
    439         // zero target, because the finalizer still needs a local burn to anchor.
    440         let burn = match converge_fee_by_byte(self.burn_fee, |fee| {
    441             let required = anchor_burn_amount
    442                 .checked_add(fee)
    443                 .context("automatic finalizer anchor burn amount plus fee overflows")?;
    444             let outpoint = available_utxos
    445                 .iter()
    446                 .filter(|(outpoint, _)| !pending_transfer_spent_outpoints.contains(outpoint))
    447                 .filter(|(_, output)| output.amount >= required)
    448                 .min_by_key(|(_, output)| output.amount)
    449                 .map(|(outpoint, _)| outpoint);
    450             match outpoint {
    451                 Some(outpoint) => ledger.build_burn_for_next_block_with_inputs(
    452                     wallet,
    453                     anchor_burn_amount,
    454                     fee,
    455                     std::slice::from_ref(outpoint),
    456                 ),
    457                 None => {
    458                     let mut total = 0_u64;
    459                     let outpoints = available_utxos
    460                         .iter()
    461                         .filter(|(outpoint, _)| {
    462                             !pending_transfer_spent_outpoints.contains(outpoint)
    463                         })
    464                         .take_while(|(_, output)| {
    465                             if total >= required {
    466                                 return false;
    467                             }
    468                             total = total.saturating_add(output.amount);
    469                             true
    470                         })
    471                         .map(|(outpoint, _)| outpoint.clone())
    472                         .collect::<Vec<_>>();
    473                     if total < required {
    474                         bail!(
    475                             "automatic finalizer anchor burn has insufficient confirmed funds outside pending transfers"
    476                         );
    477                     }
    478                     ledger.build_burn_for_next_block_with_inputs(
    479                         wallet,
    480                         anchor_burn_amount,
    481                         fee,
    482                         &outpoints,
    483                     )
    484                 }
    485             }
    486         }) {
    487             Ok((burn, _)) => burn,
    488             Err(error) => {
    489                 if let Some(hybrid_error) = hybrid_error {
    490                     return Err(error).with_context(|| {
    491                         format!(
    492                             "automatic finalizer anchor burn failed; hybrid path: {hybrid_error:#}"
    493                         )
    494                     });
    495                 }
    496                 return Err(error).context("automatic legacy finalizer anchor burn failed");
    497             }
    498         };
    499         self.local_block_anchor_burn = Some((current_height, burn.clone()));
    500         self.last_auto_anchor_burn_height = Some(current_height);
    501         Ok(Some(burn))
    502     }
    503 
    504     fn best_automatic_burn_on_ledger(
    505         &self,
    506         ledger: &Ledger,
    507         fee_per_byte: Amount,
    508         balance: Amount,
    509     ) -> Option<Transaction> {
    510         let target = self.burn_per_block.min(balance);
    511         if target == 0 {
    512             return None;
    513         }
    514         let exact_at_fee_rate =
    515             self.build_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte);
    516         if let Ok((built, estimate)) = exact_at_fee_rate {
    517             if target
    518                 .checked_add(estimate.fee)
    519                 .is_some_and(|required| required <= balance)
    520             {
    521                 return Some(built);
    522             }
    523         }
    524         if self.burn_per_block <= balance {
    525             let affordable_fee = balance.saturating_sub(target);
    526             if let Ok(built) =
    527                 ledger.build_burn(self.wallet.unlocked().ok()?, target, affordable_fee)
    528             {
    529                 return Some(built);
    530             }
    531         }
    532 
    533         let mut low = 1;
    534         let mut high = target;
    535         let mut best = None;
    536         while low <= high {
    537             let amount = low + (high - low) / 2;
    538             match self.build_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) {
    539                 Ok((built, estimate)) => {
    540                     let fits = amount
    541                         .checked_add(estimate.fee)
    542                         .is_some_and(|required| required <= balance);
    543                     if fits {
    544                         best = Some(built);
    545                         if amount == Amount::MAX {
    546                             break;
    547                         }
    548                         low = amount + 1;
    549                     } else {
    550                         high = amount.saturating_sub(1);
    551                     }
    552                 }
    553                 Err(_) => {
    554                     high = amount.saturating_sub(1);
    555                 }
    556             }
    557         }
    558         best
    559     }
    560 
    561     fn best_automatic_v2_burn_on_ledger(
    562         &self,
    563         ledger: &Ledger,
    564         fee_per_byte: Amount,
    565         balance: Amount,
    566         for_next_block: bool,
    567     ) -> Option<(crate::domain::TransactionV2, super::FeeEstimate)> {
    568         let target = self.burn_per_block.min(balance);
    569         if target == 0 {
    570             return None;
    571         }
    572         if let Ok(candidate) =
    573             self.build_v2_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte, for_next_block)
    574         {
    575             if target
    576                 .checked_add(candidate.1.fee)
    577                 .is_some_and(|required| required <= balance)
    578             {
    579                 return Some(candidate);
    580             }
    581         }
    582 
    583         let mut low = 1;
    584         let mut high = target;
    585         let mut best = None;
    586         while low <= high {
    587             let amount = low + (high - low) / 2;
    588             match self.build_v2_burn_with_fee_rate_on_ledger(
    589                 ledger,
    590                 amount,
    591                 fee_per_byte,
    592                 for_next_block,
    593             ) {
    594                 Ok(candidate)
    595                     if amount
    596                         .checked_add(candidate.1.fee)
    597                         .is_some_and(|required| required <= balance) =>
    598                 {
    599                     best = Some(candidate);
    600                     if amount == Amount::MAX {
    601                         break;
    602                     }
    603                     low = amount + 1;
    604                 }
    605                 Ok(_) | Err(_) => high = amount.saturating_sub(1),
    606             }
    607         }
    608         best
    609     }
    610 
    611     fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool {
    612         self.ledger
    613             .finalizer_rank_for_next_block(self.wallet.address())
    614             .is_some_and(|rank| self.wallet_rank_runs_vdf(rank))
    615             || self.should_prepare_recovery_vdf(timestamp_ms)
    616             || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS)
    617                 >= self.ledger.recovery_block_min_timestamp()
    618     }
    619 
    620     fn wallet_rank_runs_vdf(&self, rank: u32) -> bool {
    621         if rank == 0 {
    622             return true;
    623         }
    624         let rank_count = self.ledger.finalizer_rank_count_for_next_block();
    625         let allowed =
    626             allowed_fallback_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent);
    627         usize::try_from(rank).is_ok_and(|rank| rank < allowed)
    628     }
    629 
    630     fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool {
    631         self.ledger.recovery_block_available_at(timestamp_ms)
    632     }
    633 
    634     fn burn_bundle_collection_wait_ms(
    635         &mut self,
    636         timestamp_ms: u64,
    637         will_run_vdf: bool,
    638     ) -> Option<u64> {
    639         let next_height = self.ledger.height().saturating_add(1);
    640         let (attestation_ledger, _) = self.ledger_with_local_block_anchor();
    641         let explicit_signatures_required = if will_run_vdf {
    642             let wallet_rank =
    643                 attestation_ledger.finalizer_rank_for_next_block(self.wallet.address());
    644             let finalizer_mode = if self.should_prepare_recovery_vdf(timestamp_ms) {
    645                 FinalizerMode::Recovery
    646             } else {
    647                 FinalizerMode::Ticket
    648             };
    649             let finalizer_rank = if matches!(finalizer_mode, FinalizerMode::Ticket) {
    650                 wallet_rank.unwrap_or(0)
    651             } else {
    652                 0
    653             };
    654             attestation_ledger.explicit_burn_bundle_signatures_required_for_next_block(
    655                 finalizer_mode,
    656                 finalizer_rank,
    657                 self.wallet.address(),
    658             )
    659         } else {
    660             0
    661         };
    662         let wallet_is_committee_member = !self
    663             .ledger
    664             .burn_committee_memberships_for_next_block(self.wallet.address())
    665             .is_empty();
    666         if explicit_signatures_required == 0 || (!wallet_is_committee_member && !will_run_vdf) {
    667             if explicit_signatures_required == 0 {
    668                 self.burn_bundle_collection_started = None;
    669             }
    670             return None;
    671         }
    672 
    673         let started_at = match self.burn_bundle_collection_started {
    674             Some((height, started_at)) if height == next_height => started_at,
    675             _ => {
    676                 self.burn_bundle_collection_started = Some((next_height, timestamp_ms));
    677                 timestamp_ms
    678             }
    679         };
    680         burn_bundle_collection_remaining_ms(started_at, timestamp_ms)
    681     }
    682 
    683     fn request_missing_burn_bundles_for_next_block(&mut self, _timestamp_ms: u64) {
    684         let (attestation_ledger, _) = self.ledger_with_local_block_anchor();
    685         let Some(finalizer_rank) =
    686             attestation_ledger.finalizer_rank_for_next_block(self.wallet.address())
    687         else {
    688             return;
    689         };
    690         if !self.wallet_rank_runs_vdf(finalizer_rank) {
    691             return;
    692         }
    693         let required = attestation_ledger.explicit_burn_bundle_signatures_required_for_next_block(
    694             FinalizerMode::Ticket,
    695             finalizer_rank,
    696             self.wallet.address(),
    697         );
    698         if required == 0 {
    699             return;
    700         }
    701 
    702         let present_slots = self
    703             .usable_burn_bundles_for_finalizer_rank(finalizer_rank)
    704             .into_iter()
    705             .map(|bundle| bundle.slot)
    706             .collect::<std::collections::BTreeSet<_>>();
    707         if present_slots.len() >= required {
    708             return;
    709         }
    710 
    711         self.enqueue_missing_burn_bundle_request(
    712             self.ledger.height().saturating_add(1),
    713             self.ledger.tip_hash().to_string(),
    714             required,
    715             attestation_ledger.burn_committee_for_next_ticket_block(finalizer_rank),
    716             &present_slots,
    717         );
    718     }
    719 
    720     fn enqueue_missing_burn_bundle_request(
    721         &mut self,
    722         height: u64,
    723         prev_hash: String,
    724         required: usize,
    725         committee: Vec<BurnCommitteeMember>,
    726         present_slots: &std::collections::BTreeSet<u8>,
    727     ) {
    728         if present_slots.len() >= required {
    729             return;
    730         }
    731         let slots = committee
    732             .into_iter()
    733             .map(|member| member.slot)
    734             .filter(|slot| *slot != 0 && !present_slots.contains(slot))
    735             .collect::<Vec<_>>();
    736         if slots.is_empty() {
    737             return;
    738         }
    739 
    740         self.outbox.push(GossipEnvelope::BurnBundleRequest {
    741             height,
    742             prev_hash,
    743             slots,
    744         });
    745     }
    746 
    747     pub(super) fn prepare_next_block_with_local_anchor(
    748         &self,
    749         timestamp_ms: u64,
    750     ) -> Result<PreparedBlock> {
    751         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    752         let finalizer_rank = ledger
    753             .finalizer_rank_for_next_block(self.wallet.address())
    754             .context("cannot prepare ticket block without a mature burn ticket")?;
    755         let next_height = ledger.height().saturating_add(1);
    756         let reward_address = if next_height >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT {
    757             Some(ledger.wallet_reward_address(self.wallet.unlocked()?, next_height))
    758         } else {
    759             None
    760         };
    761         ledger.prepare_next_block_with_required_burn_and_burn_bundles(
    762             self.wallet.address(),
    763             reward_address.as_deref(),
    764             timestamp_ms,
    765             self.usable_burn_bundles_for_finalizer_rank(finalizer_rank),
    766             required_burn_signature.as_deref(),
    767         )
    768     }
    769 
    770     fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
    771         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    772         let next_height = ledger.height().saturating_add(1);
    773         let reward_address = if next_height >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT {
    774             Some(ledger.wallet_reward_address(self.wallet.unlocked()?, next_height))
    775         } else {
    776             None
    777         };
    778         ledger.prepare_recovery_block_with_required_burn_and_burn_bundles(
    779             self.wallet.address(),
    780             reward_address.as_deref(),
    781             timestamp_ms,
    782             Vec::new(),
    783             required_burn_signature.as_deref(),
    784         )
    785     }
    786 
    787     pub(super) fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) {
    788         let mut ledger = self.ledger.clone();
    789         let Some((height, burn)) = &self.local_block_anchor_burn else {
    790             return (ledger, None);
    791         };
    792         let anchor_is_pending = ledger
    793             .pending()
    794             .iter()
    795             .any(|transaction| transaction.signature() == burn.signature());
    796         if *height == ledger.height() && !anchor_is_pending {
    797             // `has_transaction` also includes orphans, which cannot anchor a
    798             // block. Try to promote the local burn, prefer another eligible
    799             // wallet burn, or rebuild this cloned mempool with the liveness
    800             // anchor first when a pending transaction spends the same input.
    801             if ledger.submit_transaction(burn.clone()).unwrap_or(false) {
    802                 return (ledger, Some(burn.signature().to_string()));
    803             }
    804             if ledger.pending().iter().any(|transaction| {
    805                 transaction.is_burn()
    806                     && transaction.sender() == self.wallet.address()
    807                     && ledger.transaction_is_eligible_for_next_block(transaction)
    808             }) {
    809                 return (ledger, None);
    810             }
    811             if ledger
    812                 .prioritize_transaction_for_block_building(burn.clone())
    813                 .unwrap_or(false)
    814             {
    815                 return (ledger, Some(burn.signature().to_string()));
    816             }
    817         }
    818         (ledger, None)
    819     }
    820 
    821     pub(super) fn clear_stale_local_block_anchor(&mut self) {
    822         if self
    823             .local_block_anchor_burn
    824             .as_ref()
    825             .is_some_and(|(height, _)| *height != self.ledger.height())
    826         {
    827             self.local_block_anchor_burn = None;
    828         }
    829     }
    830 
    831     pub(super) fn clear_stale_burn_bundle_collection(&mut self) {
    832         let current_next_height = self.ledger.height().saturating_add(1);
    833         if self
    834             .burn_bundle_collection_started
    835             .is_some_and(|(height, _)| height != current_next_height)
    836         {
    837             self.burn_bundle_collection_started = None;
    838         }
    839     }
    840 }
    841 
    842 fn burn_bundle_collection_remaining_ms(started_at_ms: u64, now_ms: u64) -> Option<u64> {
    843     let elapsed = now_ms.saturating_sub(started_at_ms);
    844     (elapsed < BURN_BUNDLE_COLLECTION_MS).then(|| BURN_BUNDLE_COLLECTION_MS.saturating_sub(elapsed))
    845 }
    846 
    847 #[cfg(test)]
    848 mod tests {
    849     use std::collections::BTreeMap;
    850 
    851     use crate::{
    852         adapters::chain_store::SqliteChainStore,
    853         app::{GossipEnvelope, InMemoryNetwork},
    854         domain::{
    855             BurnBundle, BurnCommitteeMember, FinalizerMode, GenesisBurn,
    856             HYBRID_REWARD_ACTIVATION_HEIGHT, LaunchProfile, Ledger, MICRO_IUNA, Transaction,
    857             TransactionV2, Wallet, run_vdf,
    858         },
    859     };
    860     use tempfile::tempdir;
    861 
    862     use super::{BURN_BUNDLE_COLLECTION_MS, NodeCore, burn_bundle_collection_remaining_ms};
    863 
    864     #[test]
    865     fn burn_bundle_collection_waits_for_configured_window() {
    866         let started_at_ms = 1_000;
    867         let last_waiting_ms = started_at_ms + BURN_BUNDLE_COLLECTION_MS - 1;
    868         let finished_ms = started_at_ms + BURN_BUNDLE_COLLECTION_MS;
    869 
    870         assert_eq!(
    871             burn_bundle_collection_remaining_ms(started_at_ms, started_at_ms),
    872             Some(BURN_BUNDLE_COLLECTION_MS)
    873         );
    874         assert_eq!(
    875             burn_bundle_collection_remaining_ms(started_at_ms, last_waiting_ms),
    876             Some(1)
    877         );
    878         assert_eq!(
    879             burn_bundle_collection_remaining_ms(started_at_ms, finished_ms),
    880             None
    881         );
    882     }
    883 
    884     fn funded_ledger(wallets: &[Wallet]) -> Ledger {
    885         let allocations = wallets
    886             .iter()
    887             .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA))
    888             .collect::<BTreeMap<_, _>>();
    889         let genesis_burns = wallets
    890             .iter()
    891             .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
    892             .collect::<Vec<_>>();
    893         Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap()
    894     }
    895 
    896     fn selected_finalizer(ledger: &Ledger, wallets: &[Wallet]) -> Wallet {
    897         let leader = ledger
    898             .expected_leader_for_next_block()
    899             .expect("test ledger should have a next finalizer");
    900         wallets
    901             .iter()
    902             .find(|wallet| wallet.address() == leader)
    903             .unwrap_or_else(|| panic!("missing wallet for selected finalizer {leader}"))
    904             .clone()
    905     }
    906 
    907     fn non_finalizer_wallet<'a>(wallets: &'a [Wallet], finalizer: &Wallet) -> &'a Wallet {
    908         wallets
    909             .iter()
    910             .find(|wallet| wallet.address() != finalizer.address())
    911             .expect("test fixture should include a non-finalizer wallet")
    912     }
    913 
    914     #[test]
    915     fn automatic_mining_uses_recovery_after_delay_even_for_runnable_ticket_finalizer() {
    916         let wallet = Wallet::from_seed("ticket-before-recovery-wallet");
    917         let ledger = funded_ledger(std::slice::from_ref(&wallet));
    918         assert_eq!(
    919             ledger.finalizer_rank_for_next_block(wallet.address()),
    920             Some(0)
    921         );
    922         let timestamp_ms = ledger.recovery_block_min_timestamp();
    923         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 0, 1);
    924         node.set_recovery_vdf_top_rank_percent(0);
    925 
    926         let outcome = node.automatic_mine_once(timestamp_ms);
    927         let block = outcome
    928             .block
    929             .expect("every node should be able to produce recovery after the delay");
    930 
    931         assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
    932         assert_eq!(block.finalizer_rank, 0);
    933         assert!(block.leader_proof.is_none());
    934     }
    935 
    936     #[test]
    937     fn automatic_mining_allows_recovery_without_a_ticket_or_fallback_participation() {
    938         let ticket_owner = Wallet::from_seed("recovery-ticket-owner");
    939         let recovery_wallet = Wallet::from_seed("recovery-without-ticket-wallet");
    940         let allocations = [ticket_owner.clone(), recovery_wallet.clone()]
    941             .into_iter()
    942             .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA))
    943             .collect::<BTreeMap<_, _>>();
    944         let ledger = Ledger::new_with_genesis_burns(
    945             allocations,
    946             vec![GenesisBurn::new(ticket_owner.address(), MICRO_IUNA)],
    947             1,
    948         )
    949         .unwrap();
    950         assert_eq!(
    951             ledger.finalizer_rank_for_next_block(recovery_wallet.address()),
    952             None
    953         );
    954         let timestamp_ms = ledger.recovery_block_min_timestamp();
    955         let mut node =
    956             NodeCore::from_ledger_with_burn_fee_and_enabled(recovery_wallet, ledger, true, 0, 1);
    957         node.set_recovery_vdf_top_rank_percent(0);
    958 
    959         let outcome = node.automatic_mine_once(timestamp_ms);
    960         let block = outcome
    961             .block
    962             .expect("a node without a ticket should still produce recovery after the delay");
    963 
    964         assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
    965         assert!(block.leader_proof.is_none());
    966     }
    967 
    968     #[test]
    969     fn automatic_finalization_keeps_an_explicit_wait_status_without_a_local_ticket() {
    970         let ticket_owner = Wallet::from_seed("wait-status-ticket-owner");
    971         let wallet = Wallet::from_seed("wait-status-without-ticket-wallet");
    972         let allocations = [ticket_owner.clone(), wallet.clone()]
    973             .into_iter()
    974             .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA))
    975             .collect::<BTreeMap<_, _>>();
    976         let ledger = Ledger::new_with_genesis_burns(
    977             allocations,
    978             vec![GenesisBurn::new(ticket_owner.address(), MICRO_IUNA)],
    979             1,
    980         )
    981         .unwrap();
    982         assert_eq!(ledger.finalizer_rank_for_next_block(wallet.address()), None);
    983         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 0, 1);
    984 
    985         let plan = node.prepare_automatic_finalization(1);
    986 
    987         assert!(plan.work.is_none());
    988         assert!(
    989             plan.skipped_reason.as_deref().is_some_and(
    990                 |reason| reason.starts_with("wallet is waiting for selected finalizer")
    991             )
    992         );
    993         assert!(node.status().mining.last_auto_finalization_status.is_some());
    994     }
    995 
    996     #[test]
    997     fn failed_anchor_burn_is_retried_at_the_same_height() {
    998         let wallet = Wallet::from_seed("retry-failed-anchor-burn");
    999         let ledger = Ledger::new(BTreeMap::new(), 1);
   1000         let timestamp_ms = ledger.recovery_block_min_timestamp();
   1001         let mut node =
   1002             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 100, 1);
   1003 
   1004         assert!(node.prepare_automatic_anchor_burn(timestamp_ms).is_err());
   1005         assert_eq!(node.last_auto_anchor_burn_height, None);
   1006         assert!(node.prepare_automatic_anchor_burn(timestamp_ms).is_err());
   1007         assert_eq!(node.last_auto_anchor_burn_height, None);
   1008     }
   1009 
   1010     #[test]
   1011     fn first_migration_block_can_bootstrap_with_a_legacy_anchor() {
   1012         let wallet = Wallet::from_seed("migration-bootstrap-anchor-wallet");
   1013         let mut ledger = Ledger::new(
   1014             BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]),
   1015             1,
   1016         );
   1017         ledger.set_tip_height_for_test(2_999);
   1018         let timestamp_ms = ledger.recovery_block_min_timestamp();
   1019         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1);
   1020 
   1021         let anchor = node
   1022             .prepare_automatic_anchor_burn(timestamp_ms)
   1023             .unwrap()
   1024             .expect("the first migration block may still use a legacy bootstrap anchor");
   1025 
   1026         assert!(anchor.is_burn());
   1027         assert!(node.local_block_anchor_burn.is_some());
   1028         assert!(node.ledger().pending_v2().is_empty());
   1029     }
   1030 
   1031     #[test]
   1032     fn height_3750_rejects_legacy_anchor_fallback() {
   1033         let wallet = Wallet::from_seed("hybrid-only-anchor-wallet");
   1034         let mut ledger = Ledger::new(
   1035             BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]),
   1036             1,
   1037         );
   1038         ledger.set_tip_height_for_test(HYBRID_REWARD_ACTIVATION_HEIGHT - 1);
   1039         let timestamp_ms = ledger.recovery_block_min_timestamp();
   1040         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1);
   1041 
   1042         let error = node
   1043             .prepare_automatic_anchor_burn(timestamp_ms)
   1044             .unwrap_err();
   1045 
   1046         assert!(format!("{error:#}").contains("automatic hybrid finalizer anchor burn failed"));
   1047         assert!(node.local_block_anchor_burn.is_none());
   1048         assert_eq!(node.last_auto_anchor_burn_height, None);
   1049     }
   1050 
   1051     #[test]
   1052     fn post_activation_recovery_uses_hybrid_anchor_alongside_pending_migration() {
   1053         let wallet = Wallet::from_seed("hybrid-recovery-anchor-wallet");
   1054         let bootstrap = Wallet::from_seed("hybrid-recovery-bootstrap-wallet");
   1055         let migrator = Wallet::from_seed("hybrid-recovery-pending-migrator");
   1056         let allocations = [&wallet, &bootstrap, &migrator]
   1057             .into_iter()
   1058             .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA))
   1059             .collect::<BTreeMap<_, _>>();
   1060         let mut profile = LaunchProfile::local_testnet();
   1061         profile.ticket_maturity_delay_heights = 0;
   1062         profile.ticket_expiry_window_heights = 4_000;
   1063         let mut ledger = Ledger::new_with_genesis_burns_and_profile(
   1064             allocations,
   1065             vec![GenesisBurn::new(bootstrap.address(), MICRO_IUNA)],
   1066             1,
   1067             profile,
   1068         )
   1069         .unwrap();
   1070         ledger.set_tip_height_for_test(2_999);
   1071 
   1072         let bootstrap_anchor = ledger.build_burn_for_next_block(&bootstrap, 1, 1).unwrap();
   1073         ledger.submit_transaction(bootstrap_anchor).unwrap();
   1074         let wallet_migration = ledger.build_v2_migration(&wallet, 1).unwrap();
   1075         ledger.submit_transaction_v2(wallet_migration).unwrap();
   1076         let migration_block = ledger
   1077             .mine_recovery_block(&bootstrap, ledger.recovery_block_min_timestamp())
   1078             .unwrap();
   1079         ledger
   1080             .apply_preverified_block_at(migration_block, u64::MAX)
   1081             .unwrap();
   1082 
   1083         let queued_wallet_burn = ledger.build_v2_burn(&wallet, 1, 1).unwrap();
   1084         ledger
   1085             .submit_transaction_v2(queued_wallet_burn.clone())
   1086             .unwrap();
   1087         let pending_migration = ledger.build_v2_migration(&migrator, 1).unwrap();
   1088         ledger.submit_transaction_v2(pending_migration).unwrap();
   1089         let timestamp_ms = ledger.recovery_block_min_timestamp();
   1090         let mut node =
   1091             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
   1092 
   1093         assert_eq!(
   1094             node.prepare_automatic_anchor_burn(timestamp_ms).unwrap(),
   1095             None
   1096         );
   1097         assert!(node.local_block_anchor_burn.is_none());
   1098         assert!(!node.ledger().pending_v2().contains(&queued_wallet_burn));
   1099         assert_eq!(
   1100             node.ledger()
   1101                 .pending_v2()
   1102                 .iter()
   1103                 .filter(|transaction| transaction.is_burn())
   1104                 .count(),
   1105             1
   1106         );
   1107         assert!(
   1108             node.ledger()
   1109                 .pending_v2()
   1110                 .iter()
   1111                 .any(|transaction| { matches!(transaction, TransactionV2::Migration { .. }) })
   1112         );
   1113 
   1114         let prepared = node
   1115             .prepare_recovery_block_with_local_anchor(timestamp_ms)
   1116             .unwrap();
   1117         let block = prepared.finish(&wallet, "test-vdf-output".to_string());
   1118         assert!(!block.transactions.iter().any(Transaction::is_burn));
   1119         let hybrid_owner = wallet.hybrid_versioned_address();
   1120         assert!(block.transactions_v2.iter().any(|envelope| {
   1121             let bytes = crate::domain::decode_hex(envelope).unwrap();
   1122             let (_, transaction) = TransactionV2::decode(&bytes).unwrap();
   1123             matches!(transaction, TransactionV2::Burn { ref inputs, .. }
   1124                 if inputs.iter().all(|input| input.owner == hybrid_owner))
   1125         }));
   1126     }
   1127 
   1128     #[test]
   1129     fn configured_top_percentage_limits_fallback_ticket_vdfs() {
   1130         let wallets = (0..4)
   1131             .map(|index| Wallet::from_seed(&format!("fallback-percent-wallet-{index}")))
   1132             .collect::<Vec<_>>();
   1133         let ledger = funded_ledger(&wallets);
   1134         assert_eq!(ledger.finalizer_rank_count_for_next_block(), wallets.len());
   1135 
   1136         let rank_one = wallets
   1137             .iter()
   1138             .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1))
   1139             .expect("fixture should contain the rank-one fallback")
   1140             .clone();
   1141         let mut node =
   1142             NodeCore::from_ledger_with_burn_fee_and_enabled(rank_one, ledger, true, 0, 1);
   1143 
   1144         node.set_recovery_vdf_top_rank_percent(25);
   1145         assert!(!node.wallet_rank_runs_vdf(1));
   1146         let excluded = node.prepare_automatic_finalization(1);
   1147         assert!(excluded.work.is_none());
   1148         assert_eq!(
   1149             excluded.skipped_reason.as_deref(),
   1150             Some("wallet finalizer rank 1 is outside the top 25% VDF threshold")
   1151         );
   1152 
   1153         node.set_recovery_vdf_top_rank_percent(50);
   1154         assert!(node.wallet_rank_runs_vdf(1));
   1155 
   1156         node.set_recovery_vdf_top_rank_percent(0);
   1157         assert!(node.wallet_rank_runs_vdf(0));
   1158         assert!(!node.wallet_rank_runs_vdf(1));
   1159     }
   1160 
   1161     #[test]
   1162     fn same_slot_bundles_for_different_members_do_not_conflict_locally() {
   1163         let wallet = Wallet::from_seed("rank-bundle-cache-node");
   1164         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1165         let mut node = NodeCore::from_ledger(wallet, ledger, 0);
   1166         let next_height = node.ledger.height() + 1;
   1167         let alpha = BurnBundle {
   1168             height: next_height,
   1169             prev_hash: node.ledger.tip_hash().to_string(),
   1170             slot: 1,
   1171             member: "alpha".to_string(),
   1172             reward_address: None,
   1173             burns: Vec::new(),
   1174             burns_v2: Vec::new(),
   1175             signature: "sig-alpha".to_string(),
   1176         };
   1177         let beta = BurnBundle {
   1178             height: next_height,
   1179             prev_hash: node.ledger.tip_hash().to_string(),
   1180             slot: 1,
   1181             member: "beta".to_string(),
   1182             reward_address: None,
   1183             burns: Vec::new(),
   1184             burns_v2: Vec::new(),
   1185             signature: "sig-beta".to_string(),
   1186         };
   1187 
   1188         node.burn_bundles
   1189             .insert((alpha.height, alpha.slot, alpha.member.clone()), alpha);
   1190         node.burn_bundles
   1191             .insert((beta.height, beta.slot, beta.member.clone()), beta);
   1192         node.equivocated_burn_bundle_slots
   1193             .insert((next_height, 1, "alpha".to_string()));
   1194 
   1195         let usable = node.usable_burn_bundles();
   1196         assert!(
   1197             usable
   1198                 .iter()
   1199                 .all(|bundle| bundle.slot != 1 || bundle.member != "alpha")
   1200         );
   1201         assert!(
   1202             usable
   1203                 .iter()
   1204                 .any(|bundle| bundle.slot == 1 && bundle.member == "beta")
   1205         );
   1206     }
   1207 
   1208     #[test]
   1209     fn in_memory_network_survives_adversarial_gossip_restart_and_converges() {
   1210         let alice = Wallet::from_seed("network-adversarial-alice");
   1211         let bob = Wallet::from_seed("network-adversarial-bob");
   1212         let carol = Wallet::from_seed("network-adversarial-carol");
   1213         let wallets = [alice.clone(), bob.clone(), carol.clone()];
   1214         let ledger = funded_ledger(&wallets);
   1215         let finalizer = selected_finalizer(&ledger, &wallets);
   1216         let mut burners = wallets
   1217             .iter()
   1218             .filter(|wallet| wallet.address() != finalizer.address());
   1219         let burn_a = ledger.build_burn(burners.next().unwrap(), 1, 1).unwrap();
   1220         let burn_b = ledger.build_burn(burners.next().unwrap(), 2, 1).unwrap();
   1221 
   1222         let mut alpha = NodeCore::from_ledger_with_burn_fee_and_enabled(
   1223             finalizer.clone(),
   1224             ledger.clone(),
   1225             true,
   1226             0,
   1227             1,
   1228         );
   1229         let beta = NodeCore::from_ledger_with_burn_fee_and_enabled(
   1230             finalizer.clone(),
   1231             ledger.clone(),
   1232             true,
   1233             0,
   1234             1,
   1235         );
   1236         let gamma = NodeCore::from_ledger(finalizer.clone(), ledger.clone(), 0);
   1237         alpha.receive_transaction(burn_a.clone()).unwrap();
   1238         alpha.receive_transaction(burn_b.clone()).unwrap();
   1239 
   1240         let mut network = InMemoryNetwork::default();
   1241         network.insert("alpha", alpha);
   1242         network.insert("beta", beta);
   1243         network.insert("gamma", gamma);
   1244 
   1245         network
   1246             .gossip_mempools_once_filtered(|_, to, envelope| {
   1247                 !(to == "gamma" && matches!(envelope, GossipEnvelope::Transactions { .. }))
   1248             })
   1249             .unwrap();
   1250         assert!(
   1251             network
   1252                 .node("beta")
   1253                 .unwrap()
   1254                 .pending_transactions()
   1255                 .iter()
   1256                 .any(|tx| tx.signature() == burn_a.signature())
   1257         );
   1258         assert!(
   1259             !network
   1260                 .node("gamma")
   1261                 .unwrap()
   1262                 .pending_transactions()
   1263                 .iter()
   1264                 .any(|tx| tx.signature() == burn_a.signature())
   1265         );
   1266 
   1267         network.gossip_mempools_once().unwrap();
   1268         assert!(
   1269             network
   1270                 .node("gamma")
   1271                 .unwrap()
   1272                 .pending_transactions()
   1273                 .iter()
   1274                 .any(|tx| tx.signature() == burn_b.signature())
   1275         );
   1276 
   1277         network
   1278             .node_mut("alpha")
   1279             .unwrap()
   1280             .publish_burn_bundle_for_next_block()
   1281             .unwrap();
   1282         network
   1283             .deliver_until_idle_filtered(|_, to, envelope| {
   1284                 !(to == "gamma" && matches!(envelope, GossipEnvelope::BurnBundle(_)))
   1285             })
   1286             .unwrap();
   1287         let bundle = network
   1288             .node("beta")
   1289             .unwrap()
   1290             .usable_burn_bundles()
   1291             .into_iter()
   1292             .find(|bundle| bundle.member == finalizer.address())
   1293             .expect("beta should receive alpha's burn bundle");
   1294         assert!(
   1295             network
   1296                 .node("gamma")
   1297                 .unwrap()
   1298                 .usable_burn_bundles()
   1299                 .is_empty()
   1300         );
   1301 
   1302         let conflicting_bundle = ledger.test_burn_bundle(&finalizer, vec![burn_b.clone()]);
   1303         assert_ne!(bundle.canonical(), conflicting_bundle.canonical());
   1304         network
   1305             .node_mut("beta")
   1306             .unwrap()
   1307             .receive(GossipEnvelope::BurnBundle(conflicting_bundle))
   1308             .unwrap();
   1309         assert!(
   1310             network
   1311                 .node("beta")
   1312                 .unwrap()
   1313                 .usable_burn_bundles()
   1314                 .into_iter()
   1315                 .all(|candidate| candidate.slot != bundle.slot || candidate.member != bundle.member)
   1316         );
   1317 
   1318         let block = {
   1319             let alpha = network.node_mut("alpha").unwrap();
   1320             alpha.prepare_automatic_burn(2).unwrap();
   1321             let work = alpha.prepare_next_block_with_local_anchor(2).unwrap();
   1322             alpha
   1323                 .complete_prepared_block_at(
   1324                     work.clone(),
   1325                     run_vdf(work.vdf_seed(), work.vdf_rounds()),
   1326                     2,
   1327                 )
   1328                 .unwrap()
   1329         };
   1330         assert!(
   1331             block
   1332                 .burn_bundle_section
   1333                 .burns
   1334                 .iter()
   1335                 .any(|masked| masked.burn.signature() == burn_a.signature())
   1336         );
   1337         let stale_gamma_height = network.node("gamma").unwrap().chain_height();
   1338 
   1339         network
   1340             .deliver_until_idle_filtered(|_, to, envelope| {
   1341                 !(to == "gamma"
   1342                     && matches!(
   1343                         envelope,
   1344                         GossipEnvelope::Block(_)
   1345                             | GossipEnvelope::Blocks { .. }
   1346                             | GossipEnvelope::ChainBootstrap(_)
   1347                     ))
   1348             })
   1349             .unwrap();
   1350         assert_eq!(
   1351             network.node("alpha").unwrap().chain_tip_hash(),
   1352             network.node("beta").unwrap().chain_tip_hash()
   1353         );
   1354         assert_eq!(
   1355             network.node("gamma").unwrap().chain_height(),
   1356             stale_gamma_height
   1357         );
   1358 
   1359         let dir = tempdir().unwrap();
   1360         let store = SqliteChainStore::open(dir.path().join("gamma.sqlite3")).unwrap();
   1361         store
   1362             .save(&network.node("gamma").unwrap().chain_snapshot())
   1363             .unwrap();
   1364         let restored_ledger =
   1365             Ledger::from_persisted_snapshot(store.load().unwrap().unwrap()).unwrap();
   1366         network.insert(
   1367             "gamma",
   1368             NodeCore::from_ledger(finalizer.clone(), restored_ledger, 0),
   1369         );
   1370         assert_eq!(
   1371             network.node("gamma").unwrap().chain_height(),
   1372             stale_gamma_height
   1373         );
   1374 
   1375         assert!(network.sync_node_from_peer("alpha", "gamma", 128).unwrap());
   1376         network.deliver_until_idle().unwrap();
   1377         let tip = network.node("alpha").unwrap().chain_tip_hash();
   1378         assert_eq!(network.node("beta").unwrap().chain_tip_hash(), tip);
   1379         assert_eq!(network.node("gamma").unwrap().chain_tip_hash(), tip);
   1380     }
   1381 
   1382     #[test]
   1383     fn local_anchor_burn_is_included_when_publishing_bundle() {
   1384         let wallet = Wallet::from_seed("local-anchor-bundle-wallet");
   1385         let mut allocations = BTreeMap::new();
   1386         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   1387         let ledger = Ledger::new_with_genesis_burns(
   1388             allocations,
   1389             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
   1390             1,
   1391         )
   1392         .unwrap();
   1393         assert!(
   1394             ledger
   1395                 .finalizer_rank_for_next_block(wallet.address())
   1396                 .is_some()
   1397         );
   1398         let mut node =
   1399             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 0, 1);
   1400 
   1401         node.prepare_automatic_burn(1).unwrap();
   1402 
   1403         assert_eq!(node.burn_bundle_collection_wait_ms(1, true), None);
   1404 
   1405         node.publish_burn_bundle_for_next_block().unwrap();
   1406 
   1407         let bundles = node.usable_burn_bundles();
   1408         let bundled_anchor = bundles
   1409             .iter()
   1410             .find(|bundle| bundle.member == wallet.address())
   1411             .and_then(|bundle| bundle.burns.first())
   1412             .expect("the local anchor should be exposed through the committee bundle");
   1413         assert_eq!(
   1414             bundled_anchor.fee(),
   1415             bundled_anchor.economic_size_bytes() as u64
   1416         );
   1417     }
   1418 
   1419     #[test]
   1420     fn local_anchor_burn_stores_total_fee_derived_from_configured_fee_rate() {
   1421         let wallet = Wallet::from_seed("local-anchor-fee-rate-wallet");
   1422         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1423         let fee_per_byte = 100;
   1424         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(
   1425             wallet,
   1426             ledger,
   1427             true,
   1428             3_000,
   1429             fee_per_byte,
   1430         );
   1431 
   1432         node.prepare_automatic_burn(1).unwrap();
   1433 
   1434         let burn = node
   1435             .local_block_anchor_burn
   1436             .as_ref()
   1437             .map(|(_, burn)| burn)
   1438             .expect("selected finalizer should prepare a local anchor");
   1439         assert_eq!(burn.amount(), 3_000);
   1440         assert_eq!(burn.fee(), fee_per_byte * burn.economic_size_bytes() as u64);
   1441         assert_ne!(burn.fee(), fee_per_byte);
   1442         let required = burn.amount().checked_add(burn.fee()).unwrap();
   1443         let expected_outpoint = node
   1444             .ledger()
   1445             .available_utxos_for_address(node.wallet_address())
   1446             .unwrap()
   1447             .into_iter()
   1448             .filter(|(_, output)| output.amount >= required)
   1449             .min_by_key(|(_, output)| output.amount)
   1450             .map(|(outpoint, _)| outpoint)
   1451             .expect("fixture should contain a single UTXO large enough for the burn");
   1452         let Transaction::Burn { inputs, .. } = burn else {
   1453             panic!("local anchor should be a burn transaction");
   1454         };
   1455         assert_eq!(inputs.len(), 1);
   1456         assert_eq!(inputs[0].outpoint, expected_outpoint);
   1457     }
   1458 
   1459     #[test]
   1460     fn conflicting_local_anchor_falls_back_to_pending_wallet_burn() {
   1461         let wallet = Wallet::from_seed("conflicting-local-anchor-wallet");
   1462         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1463         let pending_burn = ledger.build_burn(&wallet, 2, 1).unwrap();
   1464         let mut node =
   1465             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
   1466         node.receive_transaction(pending_burn.clone()).unwrap();
   1467 
   1468         node.prepare_automatic_burn(1).unwrap();
   1469         let local_anchor = node
   1470             .local_block_anchor_burn
   1471             .as_ref()
   1472             .map(|(_, burn)| burn)
   1473             .expect("selected finalizer should prepare a local anchor");
   1474         assert_ne!(local_anchor.signature(), pending_burn.signature());
   1475 
   1476         let (candidate, required_burn_signature) = node.ledger_with_local_block_anchor();
   1477         assert_eq!(required_burn_signature, None);
   1478         assert!(
   1479             candidate
   1480                 .pending()
   1481                 .iter()
   1482                 .any(|transaction| transaction.signature() == pending_burn.signature())
   1483         );
   1484 
   1485         let prepared = node.prepare_next_block_with_local_anchor(1).unwrap();
   1486         let block = prepared.finish(&wallet, "test-vdf-output".to_string());
   1487         assert!(
   1488             block
   1489                 .transactions
   1490                 .iter()
   1491                 .any(|transaction| transaction.signature() == pending_burn.signature())
   1492         );
   1493     }
   1494 
   1495     #[test]
   1496     fn local_anchor_preserves_pending_transfer_when_another_utxo_is_available() {
   1497         let wallet = Wallet::from_seed("priority-local-anchor-wallet");
   1498         let recipient = Wallet::from_seed("priority-local-anchor-recipient");
   1499         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1500         let pending_transfer = ledger
   1501             .build_transfer(&wallet, recipient.address(), 2, 1)
   1502             .unwrap();
   1503         let mut node =
   1504             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
   1505         node.receive_transaction(pending_transfer.clone()).unwrap();
   1506 
   1507         node.prepare_automatic_burn(1).unwrap();
   1508         let local_anchor_signature = node
   1509             .local_block_anchor_burn
   1510             .as_ref()
   1511             .map(|(_, burn)| burn.signature().to_string())
   1512             .expect("selected finalizer should prepare a local anchor");
   1513         let (candidate, required_burn_signature) = node.ledger_with_local_block_anchor();
   1514 
   1515         assert_eq!(
   1516             required_burn_signature,
   1517             Some(local_anchor_signature.clone())
   1518         );
   1519         assert!(
   1520             candidate
   1521                 .pending()
   1522                 .iter()
   1523                 .any(|transaction| transaction.signature() == local_anchor_signature)
   1524         );
   1525         assert!(
   1526             candidate
   1527                 .pending()
   1528                 .iter()
   1529                 .any(|transaction| transaction.signature() == pending_transfer.signature())
   1530         );
   1531         let prepared = node.prepare_next_block_with_local_anchor(1).unwrap();
   1532         let block = prepared.finish(&wallet, "test-vdf-output".to_string());
   1533         assert!(
   1534             block
   1535                 .transactions
   1536                 .iter()
   1537                 .any(|transaction| transaction.signature() == pending_transfer.signature())
   1538         );
   1539     }
   1540 
   1541     #[test]
   1542     fn local_anchor_uses_unreserved_utxo_instead_of_displacing_pending_transfer() {
   1543         let wallet = Wallet::from_seed("non-conflicting-local-anchor-wallet");
   1544         let recipient = Wallet::from_seed("non-conflicting-local-anchor-recipient");
   1545         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1546         let timestamp_ms = ledger.recovery_block_min_timestamp();
   1547         let mut node =
   1548             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
   1549 
   1550         let first = node.automatic_mine_once(timestamp_ms);
   1551         assert!(
   1552             first.block.is_some(),
   1553             "fixture should create multiple wallet UTXOs"
   1554         );
   1555         let available = node
   1556             .ledger()
   1557             .available_utxos_for_address(wallet.address())
   1558             .unwrap();
   1559         assert!(available.len() >= 2);
   1560         let transfer_outpoint = available
   1561             .iter()
   1562             .filter(|(_, output)| output.amount >= 2)
   1563             .min_by_key(|(_, output)| output.amount)
   1564             .map(|(outpoint, _)| outpoint.clone())
   1565             .expect("fixture should have a transfer input");
   1566         let pending_transfer = node
   1567             .ledger()
   1568             .build_transfer_with_inputs(
   1569                 &wallet,
   1570                 recipient.address(),
   1571                 1,
   1572                 1,
   1573                 std::slice::from_ref(&transfer_outpoint),
   1574             )
   1575             .unwrap();
   1576         node.receive_transaction(pending_transfer.clone()).unwrap();
   1577 
   1578         node.prepare_automatic_burn(timestamp_ms + 1).unwrap();
   1579         let anchor = node
   1580             .local_block_anchor_burn
   1581             .as_ref()
   1582             .map(|(_, burn)| burn)
   1583             .expect("selected finalizer should prepare a local anchor");
   1584         let Transaction::Burn { inputs, .. } = anchor else {
   1585             panic!("local anchor should be a burn transaction");
   1586         };
   1587         assert!(
   1588             inputs
   1589                 .iter()
   1590                 .all(|input| input.outpoint != transfer_outpoint)
   1591         );
   1592         let anchor_signature = anchor.signature().to_string();
   1593 
   1594         let prepared = node
   1595             .prepare_next_block_with_local_anchor(timestamp_ms + 1)
   1596             .unwrap();
   1597         let block = prepared.finish(&wallet, "test-vdf-output".to_string());
   1598         assert!(
   1599             block
   1600                 .transactions
   1601                 .iter()
   1602                 .any(|transaction| { transaction.signature() == pending_transfer.signature() })
   1603         );
   1604         assert!(
   1605             block
   1606                 .transactions
   1607                 .iter()
   1608                 .any(|transaction| transaction.signature() == anchor_signature)
   1609         );
   1610     }
   1611 
   1612     #[test]
   1613     fn local_anchor_waits_when_all_confirmed_utxos_are_reserved_by_pending_transfer() {
   1614         let wallet = Wallet::from_seed("reserved-local-anchor-wallet");
   1615         let recipient = Wallet::from_seed("reserved-local-anchor-recipient");
   1616         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1617         let outpoints = ledger
   1618             .available_utxos_for_address(wallet.address())
   1619             .unwrap()
   1620             .into_iter()
   1621             .map(|(outpoint, _)| outpoint)
   1622             .collect::<Vec<_>>();
   1623         let pending_transfer = ledger
   1624             .build_transfer_with_inputs(&wallet, recipient.address(), 1, 1, &outpoints)
   1625             .unwrap();
   1626         let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1);
   1627         node.receive_transaction(pending_transfer.clone()).unwrap();
   1628 
   1629         let error = node.prepare_automatic_burn(1).unwrap_err();
   1630 
   1631         assert!(format!("{error:#}").contains(
   1632             "automatic finalizer anchor burn has insufficient confirmed funds outside pending transfers"
   1633         ));
   1634         assert!(node.local_block_anchor_burn.is_none());
   1635         assert!(
   1636             node.ledger()
   1637                 .pending()
   1638                 .iter()
   1639                 .any(|transaction| transaction.signature() == pending_transfer.signature())
   1640         );
   1641     }
   1642 
   1643     #[test]
   1644     fn automatic_finalization_disabled_node_still_publishes_committee_bundle() {
   1645         let wallet = Wallet::from_seed("disabled-finalizer-committee-wallet");
   1646         let mut allocations = BTreeMap::new();
   1647         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   1648         let ledger = Ledger::new_with_genesis_burns(
   1649             allocations,
   1650             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
   1651             1,
   1652         )
   1653         .unwrap();
   1654         let mut node =
   1655             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, false, 0, 1);
   1656 
   1657         let plan = node.prepare_automatic_finalization(1);
   1658 
   1659         assert_eq!(
   1660             plan.skipped_reason.as_deref(),
   1661             Some("automatic mining is off")
   1662         );
   1663         assert!(node.drain_outbox().into_iter().any(|envelope| {
   1664             matches!(
   1665                 envelope,
   1666                 GossipEnvelope::BurnBundle(bundle) if bundle.member == wallet.address()
   1667             )
   1668         }));
   1669     }
   1670 
   1671     #[test]
   1672     fn publish_burn_bundle_rebroadcasts_existing_local_bundle() {
   1673         let wallet = Wallet::from_seed("rebroadcast-existing-bundle-wallet");
   1674         let mut allocations = BTreeMap::new();
   1675         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
   1676         let ledger = Ledger::new_with_genesis_burns(
   1677             allocations,
   1678             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
   1679             1,
   1680         )
   1681         .unwrap();
   1682         assert!(
   1683             ledger
   1684                 .burn_committee_memberships_for_next_block(wallet.address())
   1685                 .iter()
   1686                 .any(|member| member.owner == wallet.address())
   1687         );
   1688         let mut node =
   1689             NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 0, 1);
   1690 
   1691         node.publish_burn_bundle_for_next_block().unwrap();
   1692         let first = node
   1693             .drain_outbox()
   1694             .into_iter()
   1695             .find_map(|envelope| match envelope {
   1696                 GossipEnvelope::BurnBundle(bundle) => Some(bundle),
   1697                 _ => None,
   1698             })
   1699             .expect("first publish should gossip the local burn bundle");
   1700 
   1701         node.publish_burn_bundle_for_next_block().unwrap();
   1702         let rebroadcast = node
   1703             .drain_outbox()
   1704             .into_iter()
   1705             .find_map(|envelope| match envelope {
   1706                 GossipEnvelope::BurnBundle(bundle) => Some(bundle),
   1707                 _ => None,
   1708             })
   1709             .expect("second publish should rebroadcast the existing burn bundle");
   1710 
   1711         assert_eq!(rebroadcast.canonical(), first.canonical());
   1712     }
   1713 
   1714     #[test]
   1715     fn finalizer_requests_missing_burn_bundles_while_collecting() {
   1716         let wallet = Wallet::from_seed("missing-bundle-request-wallet");
   1717         let ledger = funded_ledger(std::slice::from_ref(&wallet));
   1718         let mut node = NodeCore::from_ledger(wallet, ledger, 0);
   1719         let present_slots = std::collections::BTreeSet::from([1]);
   1720 
   1721         node.enqueue_missing_burn_bundle_request(
   1722             42,
   1723             "parent-hash".to_string(),
   1724             2,
   1725             vec![
   1726                 BurnCommitteeMember {
   1727                     slot: 0,
   1728                     root: "root-0".to_string(),
   1729                     owner: "finalizer".to_string(),
   1730                     weight: 1,
   1731                 },
   1732                 BurnCommitteeMember {
   1733                     slot: 1,
   1734                     root: "root-1".to_string(),
   1735                     owner: "present".to_string(),
   1736                     weight: 1,
   1737                 },
   1738                 BurnCommitteeMember {
   1739                     slot: 2,
   1740                     root: "root-2".to_string(),
   1741                     owner: "missing".to_string(),
   1742                     weight: 1,
   1743                 },
   1744             ],
   1745             &present_slots,
   1746         );
   1747         let request = node
   1748             .drain_outbox()
   1749             .into_iter()
   1750             .find_map(|envelope| match envelope {
   1751                 GossipEnvelope::BurnBundleRequest {
   1752                     height,
   1753                     prev_hash,
   1754                     slots,
   1755                 } => Some((height, prev_hash, slots)),
   1756                 _ => None,
   1757             })
   1758             .expect("collecting finalizer should request missing burn bundles");
   1759 
   1760         assert_eq!(request.0, 42);
   1761         assert_eq!(request.1, "parent-hash");
   1762         assert_eq!(request.2, vec![2]);
   1763     }
   1764 
   1765     #[test]
   1766     fn burn_bundle_request_response_only_returns_matching_slots() {
   1767         let alice = Wallet::from_seed("bundle-request-response-alice");
   1768         let bob = Wallet::from_seed("bundle-request-response-bob");
   1769         let wallets = [alice.clone(), bob.clone()];
   1770         let ledger = funded_ledger(&wallets);
   1771         let signer = selected_finalizer(&ledger, &wallets);
   1772         let mut node = NodeCore::from_ledger(signer.clone(), ledger.clone(), 0);
   1773 
   1774         node.publish_burn_bundle_for_next_block().unwrap();
   1775         let bundle = node
   1776             .usable_burn_bundles()
   1777             .into_iter()
   1778             .find(|bundle| bundle.member == signer.address())
   1779             .expect("signer should have a local burn bundle");
   1780 
   1781         let matching =
   1782             node.burn_bundles_for_request(bundle.height, &bundle.prev_hash, &[bundle.slot]);
   1783         let wrong_parent = node.burn_bundles_for_request(bundle.height, "wrong-parent", &[]);
   1784         let wrong_slot = node.burn_bundles_for_request(
   1785             bundle.height,
   1786             &bundle.prev_hash,
   1787             &[bundle.slot.saturating_add(1)],
   1788         );
   1789 
   1790         assert_eq!(matching.len(), 1);
   1791         assert_eq!(matching[0].canonical(), bundle.canonical());
   1792         assert!(wrong_parent.is_empty());
   1793         assert!(wrong_slot.is_empty());
   1794     }
   1795 
   1796     #[test]
   1797     fn burn_bundle_gossip_reaches_peer_with_matching_mempool() {
   1798         let alice = Wallet::from_seed("bundle-gossip-alice");
   1799         let bob = Wallet::from_seed("bundle-gossip-bob");
   1800         let wallets = [alice.clone(), bob.clone()];
   1801         let ledger = funded_ledger(&wallets);
   1802         let finalizer = selected_finalizer(&ledger, &wallets);
   1803         let burner = non_finalizer_wallet(&wallets, &finalizer);
   1804         let burn = ledger.build_burn(burner, 1, 1).unwrap();
   1805         let mut sender = NodeCore::from_ledger(finalizer.clone(), ledger.clone(), 0);
   1806         let mut receiver = NodeCore::from_ledger(finalizer.clone(), ledger, 0);
   1807         sender.receive_transaction(burn.clone()).unwrap();
   1808         receiver.receive_transaction(burn).unwrap();
   1809 
   1810         sender.publish_burn_bundle_for_next_block().unwrap();
   1811         let mut network = InMemoryNetwork::default();
   1812         network.insert("sender", sender);
   1813         network.insert("receiver", receiver);
   1814 
   1815         network.deliver_until_idle().unwrap();
   1816 
   1817         assert!(
   1818             network
   1819                 .node("receiver")
   1820                 .unwrap()
   1821                 .usable_burn_bundles()
   1822                 .iter()
   1823                 .any(|bundle| bundle.member == finalizer.address() && !bundle.burns.is_empty())
   1824         );
   1825     }
   1826 
   1827     #[test]
   1828     fn block_with_burn_bundle_imports_on_independent_peer_ledger() {
   1829         let alice = Wallet::from_seed("bundle-import-alice");
   1830         let bob = Wallet::from_seed("bundle-import-bob");
   1831         let wallets = [alice.clone(), bob.clone()];
   1832         let ledger = funded_ledger(&wallets);
   1833         let finalizer = selected_finalizer(&ledger, &wallets);
   1834         let burner = non_finalizer_wallet(&wallets, &finalizer);
   1835         let burn = ledger.build_burn(burner, 1, 1).unwrap();
   1836         let mut producer = NodeCore::from_ledger_with_burn_fee_and_enabled(
   1837             finalizer.clone(),
   1838             ledger.clone(),
   1839             true,
   1840             0,
   1841             1,
   1842         );
   1843         let mut peer = NodeCore::from_ledger(finalizer.clone(), ledger, 0);
   1844         producer.receive_transaction(burn.clone()).unwrap();
   1845         peer.receive_transaction(burn.clone()).unwrap();
   1846         producer.prepare_automatic_burn(1).unwrap();
   1847         producer.publish_burn_bundle_for_next_block().unwrap();
   1848 
   1849         let work = producer.prepare_next_block_with_local_anchor(1).unwrap();
   1850         let block = producer
   1851             .complete_prepared_block_at(
   1852                 work.clone(),
   1853                 run_vdf(work.vdf_seed(), work.vdf_rounds()),
   1854                 1,
   1855             )
   1856             .unwrap();
   1857         assert!(!block.burn_bundle_section.burns.is_empty());
   1858 
   1859         peer.receive(super::super::GossipEnvelope::Block(block))
   1860             .unwrap();
   1861 
   1862         assert_eq!(producer.chain_tip_hash(), peer.chain_tip_hash());
   1863         assert!(
   1864             !peer
   1865                 .pending_transactions()
   1866                 .iter()
   1867                 .any(|transaction| transaction.signature() == burn.signature())
   1868         );
   1869     }
   1870 
   1871     #[test]
   1872     fn burn_bundle_contents_change_prepared_vdf_seed() {
   1873         let alice = Wallet::from_seed("bundle-seed-alice");
   1874         let bob = Wallet::from_seed("bundle-seed-bob");
   1875         let wallets = [alice.clone(), bob.clone()];
   1876         let ledger = funded_ledger(&wallets);
   1877         let finalizer = selected_finalizer(&ledger, &wallets);
   1878         let burner = non_finalizer_wallet(&wallets, &finalizer);
   1879         let burn = ledger.build_burn(burner, 1, 1).unwrap();
   1880         let mut node =
   1881             NodeCore::from_ledger_with_burn_fee_and_enabled(finalizer, ledger, true, 0, 1);
   1882         node.receive_transaction(burn).unwrap();
   1883         node.prepare_automatic_burn(1).unwrap();
   1884 
   1885         let (ledger_with_anchor, required_burn_signature) = node.ledger_with_local_block_anchor();
   1886         let without_bundle = ledger_with_anchor
   1887             .prepare_next_block_with_required_burn_and_burn_bundles(
   1888                 node.wallet_address(),
   1889                 None,
   1890                 1,
   1891                 Vec::new(),
   1892                 required_burn_signature.as_deref(),
   1893             )
   1894             .unwrap();
   1895 
   1896         node.publish_burn_bundle_for_next_block().unwrap();
   1897         let with_bundle = node.prepare_next_block_with_local_anchor(1).unwrap();
   1898 
   1899         assert_ne!(without_bundle.vdf_seed(), with_bundle.vdf_seed());
   1900         assert_ne!(
   1901             without_bundle
   1902                 .finish_at(
   1903                     node.wallet.unlocked().unwrap(),
   1904                     "precheck-vdf-output".to_string(),
   1905                     1,
   1906                 )
   1907                 .burn_bundle_hashes(),
   1908             with_bundle
   1909                 .finish_at(
   1910                     node.wallet.unlocked().unwrap(),
   1911                     "precheck-vdf-output".to_string(),
   1912                     1,
   1913                 )
   1914                 .burn_bundle_hashes()
   1915         );
   1916     }
   1917 }