iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

automatic_mining.rs (18348B)


      1 use anyhow::{Context, Result};
      2 
      3 use super::helpers::{allowed_recovery_vdf_rank_count, recovery_vdf_sample_percent};
      4 use super::{
      5     AUTO_BLOCK_ANCHOR_BURN_AMOUNT, AUTO_BLOCK_ANCHOR_BURN_FEE,
      6     AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, BuiltBlindedTransaction,
      7     Ledger, NodeCore, PreparedBlock, REVEAL_BUNDLE_COLLECTION_MS, Transaction, run_vdf,
      8 };
      9 use crate::domain::Amount;
     10 
     11 mod pow;
     12 
     13 impl NodeCore {
     14     pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome {
     15         let plan = self.prepare_automatic_mining(timestamp_ms);
     16         let mut outcome = AutoMineOutcome {
     17             pow_mined: plan.pow_mined,
     18             burned: plan.burned,
     19             block: None,
     20             skipped_reason: plan.skipped_reason,
     21         };
     22 
     23         let Some(work) = plan.work else {
     24             return outcome;
     25         };
     26         let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds());
     27         match self.complete_prepared_block_at(work, vdf_output, timestamp_ms) {
     28             Ok(block) => {
     29                 outcome.block = Some(block);
     30                 outcome.skipped_reason = None;
     31             }
     32             Err(error) => {
     33                 outcome.skipped_reason = Some(format!("{error:#}"));
     34             }
     35         }
     36 
     37         outcome
     38     }
     39 
     40     pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan {
     41         let mut plan = AutoMinePlan {
     42             pow_mined: None,
     43             burned: None,
     44             work: None,
     45             skipped_reason: None,
     46         };
     47 
     48         if self.wallet.is_locked() {
     49             if self.pow_mining_enabled {
     50                 self.last_auto_pow_mine_status = Some("wallet is locked".to_string());
     51             }
     52             return AutoMinePlan {
     53                 pow_mined: None,
     54                 burned: None,
     55                 work: None,
     56                 skipped_reason: Some("wallet is locked".to_string()),
     57             };
     58         }
     59 
     60         let pow_error = match self.prepare_automatic_pow_mining() {
     61             Ok(tx) => {
     62                 plan.pow_mined = tx;
     63                 None
     64             }
     65             Err(error) => {
     66                 let message = format!("automatic PoW mining failed: {error:#}");
     67                 self.last_auto_pow_mine_status = Some(message.clone());
     68                 Some(message)
     69             }
     70         };
     71 
     72         if !self.automatic_mining_enabled {
     73             plan.skipped_reason =
     74                 Some(pow_error.unwrap_or_else(|| "automatic mining is off".to_string()));
     75             return plan;
     76         }
     77 
     78         if let Some(error) = pow_error {
     79             plan.skipped_reason = Some(error);
     80             return plan;
     81         }
     82 
     83         match self.prepare_automatic_burn(timestamp_ms) {
     84             Ok(tx) => plan.burned = tx,
     85             Err(error) => {
     86                 plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
     87                 return plan;
     88             }
     89         }
     90 
     91         let wallet_rank = self
     92             .ledger
     93             .finalizer_rank_for_next_block(self.wallet.address());
     94         let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank));
     95         let will_run_recovery_vdf =
     96             wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms);
     97         if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms(
     98             timestamp_ms,
     99             will_run_ticket_vdf || will_run_recovery_vdf,
    100         ) {
    101             plan.skipped_reason = Some(format!(
    102                 "collecting blinded reveals for next block ({:.1}s remaining)",
    103                 wait_ms as f64 / 1000.0
    104             ));
    105             return plan;
    106         }
    107         if let Err(error) = self.publish_reveal_bundle_for_next_block() {
    108             plan.skipped_reason = Some(format!("{error:#}"));
    109             return plan;
    110         }
    111 
    112         if let Some(rank) = wallet_rank {
    113             if !self.wallet_rank_runs_vdf(rank) {
    114                 plan.skipped_reason = Some(format!(
    115                     "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
    116                     self.recovery_vdf_top_rank_percent
    117                 ));
    118                 return plan;
    119             }
    120         } else {
    121             if self.should_prepare_recovery_vdf(timestamp_ms) {
    122                 match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
    123                     Ok(work) => {
    124                         plan.work = Some(work);
    125                     }
    126                     Err(error) => {
    127                         plan.skipped_reason = Some(format!("{error:#}"));
    128                     }
    129                 }
    130             } else {
    131                 let selected_leader = self.ledger.expected_leader_for_next_block();
    132                 plan.skipped_reason = selected_leader.map(|leader| {
    133                     format!("wallet is waiting for selected finalizer {leader} to finish the VDF")
    134                 });
    135             }
    136             return plan;
    137         }
    138 
    139         match self.prepare_next_block_with_local_anchor(timestamp_ms) {
    140             Ok(work) => {
    141                 plan.work = Some(work);
    142             }
    143             Err(error) => {
    144                 plan.skipped_reason = Some(format!("{error:#}"));
    145             }
    146         }
    147 
    148         plan
    149     }
    150 
    151     pub fn prepare_automatic_finalization(&mut self, timestamp_ms: u64) -> AutoMinePlan {
    152         let mut plan = AutoMinePlan {
    153             pow_mined: None,
    154             burned: None,
    155             work: None,
    156             skipped_reason: None,
    157         };
    158 
    159         if self.wallet.is_locked() {
    160             plan.skipped_reason = Some("wallet is locked".to_string());
    161             return plan;
    162         }
    163 
    164         if !self.automatic_mining_enabled {
    165             plan.skipped_reason = Some("automatic mining is off".to_string());
    166             return plan;
    167         }
    168 
    169         match self.prepare_automatic_burn(timestamp_ms) {
    170             Ok(tx) => plan.burned = tx,
    171             Err(error) => {
    172                 plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
    173                 return plan;
    174             }
    175         }
    176 
    177         let wallet_rank = self
    178             .ledger
    179             .finalizer_rank_for_next_block(self.wallet.address());
    180         let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank));
    181         let will_run_recovery_vdf =
    182             wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms);
    183         if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms(
    184             timestamp_ms,
    185             will_run_ticket_vdf || will_run_recovery_vdf,
    186         ) {
    187             plan.skipped_reason = Some(format!(
    188                 "collecting blinded reveals for next block ({:.1}s remaining)",
    189                 wait_ms as f64 / 1000.0
    190             ));
    191             return plan;
    192         }
    193         if let Err(error) = self.publish_reveal_bundle_for_next_block() {
    194             plan.skipped_reason = Some(format!("{error:#}"));
    195             return plan;
    196         }
    197 
    198         if let Some(rank) = wallet_rank {
    199             if !self.wallet_rank_runs_vdf(rank) {
    200                 plan.skipped_reason = Some(format!(
    201                     "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
    202                     self.recovery_vdf_top_rank_percent
    203                 ));
    204                 return plan;
    205             }
    206         } else {
    207             if self.should_prepare_recovery_vdf(timestamp_ms) {
    208                 match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
    209                     Ok(work) => {
    210                         plan.work = Some(work);
    211                     }
    212                     Err(error) => {
    213                         plan.skipped_reason = Some(format!("{error:#}"));
    214                     }
    215                 }
    216             } else {
    217                 let selected_leader = self.ledger.expected_leader_for_next_block();
    218                 plan.skipped_reason = selected_leader.map(|leader| {
    219                     format!("wallet is waiting for selected finalizer {leader} to finish the VDF")
    220                 });
    221             }
    222             return plan;
    223         }
    224 
    225         match self.prepare_next_block_with_local_anchor(timestamp_ms) {
    226             Ok(work) => {
    227                 plan.work = Some(work);
    228             }
    229             Err(error) => {
    230                 plan.skipped_reason = Some(format!("{error:#}"));
    231             }
    232         }
    233 
    234         plan
    235     }
    236 
    237     pub(super) fn prepare_automatic_burn(
    238         &mut self,
    239         timestamp_ms: u64,
    240     ) -> Result<Option<Transaction>> {
    241         let current_height = self.ledger.height();
    242         if !self.automatic_mining_enabled {
    243             return Ok(None);
    244         }
    245         let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?;
    246         if self.burn_per_block == 0 {
    247             self.last_auto_burn_height = Some(current_height);
    248             return Ok(anchor_burn);
    249         }
    250         if self.last_auto_burn_height == Some(current_height) {
    251             return Ok(anchor_burn);
    252         }
    253 
    254         let fee_per_byte = self.burn_fee;
    255         let balance = self.ledger.balance_of(self.wallet.address());
    256         let ledger = self.wallet_build_ledger()?;
    257         let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance);
    258         let Some(tx) = best else {
    259             self.last_auto_burn_height = Some(current_height);
    260             return Ok(anchor_burn);
    261         };
    262         let burn = tx.payload.clone();
    263         self.submit_owned_blinded_transaction(tx)?;
    264         self.last_auto_burn_height = Some(current_height);
    265         Ok(Some(burn))
    266     }
    267 
    268     fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> {
    269         let current_height = self.ledger.height();
    270         if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) {
    271             return Ok(None);
    272         }
    273         if self
    274             .local_block_anchor_burn
    275             .as_ref()
    276             .is_some_and(|(height, _)| *height == current_height)
    277         {
    278             return Ok(None);
    279         }
    280         if self.last_auto_anchor_burn_height == Some(current_height) {
    281             return Ok(None);
    282         }
    283 
    284         let ledger = self.wallet_anchor_build_ledger()?;
    285         let wallet = self.wallet.unlocked()?;
    286         let required = AUTO_BLOCK_ANCHOR_BURN_AMOUNT
    287             .checked_add(AUTO_BLOCK_ANCHOR_BURN_FEE)
    288             .context("automatic finalizer anchor burn amount plus fee overflows")?;
    289         let outpoint = ledger
    290             .available_utxos_for_address(wallet.address())?
    291             .into_iter()
    292             .filter(|(_, output)| output.amount >= required)
    293             .min_by_key(|(_, output)| output.amount)
    294             .map(|(outpoint, _)| outpoint);
    295         let burn = match outpoint {
    296             Some(outpoint) => ledger.build_burn_with_inputs(
    297                 wallet,
    298                 AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
    299                 AUTO_BLOCK_ANCHOR_BURN_FEE,
    300                 &[outpoint],
    301             ),
    302             None => ledger.build_burn(
    303                 wallet,
    304                 AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
    305                 AUTO_BLOCK_ANCHOR_BURN_FEE,
    306             ),
    307         };
    308         let burn = match burn {
    309             Ok(burn) => burn,
    310             Err(error) => {
    311                 self.last_auto_anchor_burn_height = Some(current_height);
    312                 return Err(error).context("automatic finalizer anchor burn failed");
    313             }
    314         };
    315         self.local_block_anchor_burn = Some((current_height, burn.clone()));
    316         self.last_auto_anchor_burn_height = Some(current_height);
    317         Ok(Some(burn))
    318     }
    319 
    320     fn best_automatic_burn_on_ledger(
    321         &self,
    322         ledger: &Ledger,
    323         fee_per_byte: Amount,
    324         balance: Amount,
    325     ) -> Option<BuiltBlindedTransaction> {
    326         let target = self.burn_per_block.min(balance);
    327         if target == 0 {
    328             return None;
    329         }
    330         let exact_at_fee_rate =
    331             self.build_blinded_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte);
    332         if let Ok((built, estimate)) = exact_at_fee_rate {
    333             if target
    334                 .checked_add(estimate.fee)
    335                 .is_some_and(|required| required <= balance)
    336             {
    337                 return Some(built);
    338             }
    339         }
    340         if self.burn_per_block <= balance {
    341             let affordable_fee = balance.saturating_sub(target);
    342             if let Ok(built) =
    343                 self.build_blinded_burn_with_fee_on_ledger(ledger, target, affordable_fee)
    344             {
    345                 return Some(built);
    346             }
    347         }
    348 
    349         let mut low = 1;
    350         let mut high = target;
    351         let mut best = None;
    352         while low <= high {
    353             let amount = low + (high - low) / 2;
    354             match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) {
    355                 Ok((built, estimate)) => {
    356                     let fits = amount
    357                         .checked_add(estimate.fee)
    358                         .is_some_and(|required| required <= balance);
    359                     if fits {
    360                         best = Some(built);
    361                         if amount == Amount::MAX {
    362                             break;
    363                         }
    364                         low = amount + 1;
    365                     } else {
    366                         high = amount.saturating_sub(1);
    367                     }
    368                 }
    369                 Err(_) => {
    370                     high = amount.saturating_sub(1);
    371                 }
    372             }
    373         }
    374         best
    375     }
    376 
    377     fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool {
    378         self.ledger
    379             .finalizer_rank_for_next_block(self.wallet.address())
    380             .is_some_and(|rank| self.wallet_rank_runs_vdf(rank))
    381             || self.should_prepare_recovery_vdf(timestamp_ms)
    382             || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS)
    383                 >= self.ledger.recovery_block_min_timestamp()
    384     }
    385 
    386     fn wallet_rank_runs_vdf(&self, rank: u32) -> bool {
    387         let rank_count = self.ledger.finalizer_rank_count_for_next_block();
    388         let allowed =
    389             allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent);
    390         usize::try_from(rank).is_ok_and(|rank| rank < allowed)
    391     }
    392 
    393     fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool {
    394         if !self.ledger.recovery_block_available_at(timestamp_ms) {
    395             return false;
    396         }
    397         if self.recovery_vdf_top_rank_percent == 100 {
    398             return true;
    399         }
    400         if self.recovery_vdf_top_rank_percent == 0 {
    401             return false;
    402         }
    403         if self.ledger.finalizer_rank_count_for_next_block() > 0 {
    404             return false;
    405         }
    406         recovery_vdf_sample_percent(self.wallet.address(), self.ledger.tip_hash())
    407             < self.recovery_vdf_top_rank_percent
    408     }
    409 
    410     fn reveal_bundle_collection_wait_ms(
    411         &mut self,
    412         timestamp_ms: u64,
    413         will_run_vdf: bool,
    414     ) -> Option<u64> {
    415         let next_height = self.ledger.height().saturating_add(1);
    416         let has_pending_reveals = !self.ledger.pending_blinded_reveals().is_empty();
    417         let wallet_is_committee_member = self
    418             .ledger
    419             .reveal_committee_for_next_block()
    420             .iter()
    421             .any(|member| member.owner == self.wallet.address());
    422         if !has_pending_reveals || (!wallet_is_committee_member && !will_run_vdf) {
    423             if !has_pending_reveals {
    424                 self.reveal_bundle_collection_started = None;
    425             }
    426             return None;
    427         }
    428 
    429         let started_at = match self.reveal_bundle_collection_started {
    430             Some((height, started_at)) if height == next_height => started_at,
    431             _ => {
    432                 self.reveal_bundle_collection_started = Some((next_height, timestamp_ms));
    433                 timestamp_ms
    434             }
    435         };
    436         let elapsed = timestamp_ms.saturating_sub(started_at);
    437         (elapsed < REVEAL_BUNDLE_COLLECTION_MS)
    438             .then(|| REVEAL_BUNDLE_COLLECTION_MS.saturating_sub(elapsed))
    439     }
    440 
    441     pub(super) fn prepare_next_block_with_local_anchor(
    442         &self,
    443         timestamp_ms: u64,
    444     ) -> Result<PreparedBlock> {
    445         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    446         ledger.prepare_next_block_with_required_burn_and_reveal_bundles(
    447             self.wallet.address(),
    448             timestamp_ms,
    449             self.usable_reveal_bundles(),
    450             required_burn_signature.as_deref(),
    451         )
    452     }
    453 
    454     fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
    455         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    456         ledger.prepare_recovery_block_with_required_burn_and_reveal_bundles(
    457             self.wallet.address(),
    458             timestamp_ms,
    459             self.usable_reveal_bundles(),
    460             required_burn_signature.as_deref(),
    461         )
    462     }
    463 
    464     fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) {
    465         let mut ledger = self.ledger.clone();
    466         let Some((height, burn)) = &self.local_block_anchor_burn else {
    467             return (ledger, None);
    468         };
    469         if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
    470             ledger.drop_pending_blinded_conflicting_with_transaction(burn);
    471             if ledger.submit_transaction(burn.clone()).is_ok() {
    472                 return (ledger, Some(burn.signature().to_string()));
    473             }
    474         }
    475         (ledger, None)
    476     }
    477 
    478     pub(super) fn clear_stale_local_block_anchor(&mut self) {
    479         if self
    480             .local_block_anchor_burn
    481             .as_ref()
    482             .is_some_and(|(height, _)| *height != self.ledger.height())
    483         {
    484             self.local_block_anchor_burn = None;
    485         }
    486     }
    487 
    488     pub(super) fn clear_stale_reveal_bundle_collection(&mut self) {
    489         let current_next_height = self.ledger.height().saturating_add(1);
    490         if self
    491             .reveal_bundle_collection_started
    492             .is_some_and(|(height, _)| height != current_next_height)
    493         {
    494             self.reveal_bundle_collection_started = None;
    495         }
    496         if self.ledger.pending_blinded_reveals().is_empty() {
    497             self.reveal_bundle_collection_started = None;
    498         }
    499     }
    500 }
    501 
    502 #[cfg(test)]
    503 mod tests;