iuna

iuna

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

automatic_mining.rs (19645B)


      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             self.last_auto_finalization_status = plan.skipped_reason.clone();
    162             return plan;
    163         }
    164 
    165         if !self.automatic_mining_enabled {
    166             plan.skipped_reason = Some("automatic mining is off".to_string());
    167             self.last_auto_finalization_status = plan.skipped_reason.clone();
    168             return plan;
    169         }
    170 
    171         match self.prepare_automatic_burn(timestamp_ms) {
    172             Ok(tx) => plan.burned = tx,
    173             Err(error) => {
    174                 plan.skipped_reason = Some(format!("automatic burn failed: {error:#}"));
    175                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    176                 return plan;
    177             }
    178         }
    179 
    180         let wallet_rank = self
    181             .ledger
    182             .finalizer_rank_for_next_block(self.wallet.address());
    183         let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank));
    184         let will_run_recovery_vdf =
    185             wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms);
    186         if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms(
    187             timestamp_ms,
    188             will_run_ticket_vdf || will_run_recovery_vdf,
    189         ) {
    190             plan.skipped_reason = Some(format!(
    191                 "collecting blinded reveals for next block ({:.1}s remaining)",
    192                 wait_ms as f64 / 1000.0
    193             ));
    194             self.last_auto_finalization_status = plan.skipped_reason.clone();
    195             return plan;
    196         }
    197         if let Err(error) = self.publish_reveal_bundle_for_next_block() {
    198             plan.skipped_reason = Some(format!("{error:#}"));
    199             self.last_auto_finalization_status = plan.skipped_reason.clone();
    200             return plan;
    201         }
    202 
    203         if let Some(rank) = wallet_rank {
    204             if !self.wallet_rank_runs_vdf(rank) {
    205                 plan.skipped_reason = Some(format!(
    206                     "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
    207                     self.recovery_vdf_top_rank_percent
    208                 ));
    209                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    210                 return plan;
    211             }
    212         } else {
    213             if self.should_prepare_recovery_vdf(timestamp_ms) {
    214                 match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
    215                     Ok(work) => {
    216                         self.last_auto_finalization_status = Some(format!(
    217                             "running recovery VDF for candidate block {} ({} rounds)",
    218                             work.height(),
    219                             work.vdf_rounds()
    220                         ));
    221                         plan.work = Some(work);
    222                     }
    223                     Err(error) => {
    224                         plan.skipped_reason = Some(format!("{error:#}"));
    225                         self.last_auto_finalization_status = plan.skipped_reason.clone();
    226                     }
    227                 }
    228             } else {
    229                 let selected_leader = self.ledger.expected_leader_for_next_block();
    230                 plan.skipped_reason = selected_leader.map(|leader| {
    231                     format!("wallet is waiting for selected finalizer {leader} to finish the VDF")
    232                 });
    233                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    234             }
    235             return plan;
    236         }
    237 
    238         match self.prepare_next_block_with_local_anchor(timestamp_ms) {
    239             Ok(work) => {
    240                 self.last_auto_finalization_status = Some(format!(
    241                     "running VDF for candidate block {} ({} rounds)",
    242                     work.height(),
    243                     work.vdf_rounds()
    244                 ));
    245                 plan.work = Some(work);
    246             }
    247             Err(error) => {
    248                 plan.skipped_reason = Some(format!("{error:#}"));
    249                 self.last_auto_finalization_status = plan.skipped_reason.clone();
    250             }
    251         }
    252 
    253         plan
    254     }
    255 
    256     pub(super) fn prepare_automatic_burn(
    257         &mut self,
    258         timestamp_ms: u64,
    259     ) -> Result<Option<Transaction>> {
    260         let current_height = self.ledger.height();
    261         if !self.automatic_mining_enabled {
    262             return Ok(None);
    263         }
    264         let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?;
    265         if self.burn_per_block == 0 {
    266             self.last_auto_burn_height = Some(current_height);
    267             return Ok(anchor_burn);
    268         }
    269         if self.last_auto_burn_height == Some(current_height) {
    270             return Ok(anchor_burn);
    271         }
    272 
    273         let fee_per_byte = self.burn_fee;
    274         let balance = self.ledger.balance_of(self.wallet.address());
    275         let ledger = self.wallet_build_ledger()?;
    276         let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance);
    277         let Some(tx) = best else {
    278             self.last_auto_burn_height = Some(current_height);
    279             return Ok(anchor_burn);
    280         };
    281         let burn = tx.payload.clone();
    282         self.submit_owned_blinded_transaction(tx)?;
    283         self.last_auto_burn_height = Some(current_height);
    284         Ok(Some(burn))
    285     }
    286 
    287     fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> {
    288         let current_height = self.ledger.height();
    289         if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) {
    290             return Ok(None);
    291         }
    292         if self
    293             .local_block_anchor_burn
    294             .as_ref()
    295             .is_some_and(|(height, _)| *height == current_height)
    296         {
    297             return Ok(None);
    298         }
    299         if self.last_auto_anchor_burn_height == Some(current_height) {
    300             return Ok(None);
    301         }
    302 
    303         let ledger = self.wallet_anchor_build_ledger()?;
    304         let wallet = self.wallet.unlocked()?;
    305         let required = AUTO_BLOCK_ANCHOR_BURN_AMOUNT
    306             .checked_add(AUTO_BLOCK_ANCHOR_BURN_FEE)
    307             .context("automatic finalizer anchor burn amount plus fee overflows")?;
    308         let outpoint = ledger
    309             .available_utxos_for_address(wallet.address())?
    310             .into_iter()
    311             .filter(|(_, output)| output.amount >= required)
    312             .min_by_key(|(_, output)| output.amount)
    313             .map(|(outpoint, _)| outpoint);
    314         let burn = match outpoint {
    315             Some(outpoint) => ledger.build_burn_with_inputs(
    316                 wallet,
    317                 AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
    318                 AUTO_BLOCK_ANCHOR_BURN_FEE,
    319                 &[outpoint],
    320             ),
    321             None => ledger.build_burn(
    322                 wallet,
    323                 AUTO_BLOCK_ANCHOR_BURN_AMOUNT,
    324                 AUTO_BLOCK_ANCHOR_BURN_FEE,
    325             ),
    326         };
    327         let burn = match burn {
    328             Ok(burn) => burn,
    329             Err(error) => {
    330                 self.last_auto_anchor_burn_height = Some(current_height);
    331                 return Err(error).context("automatic finalizer anchor burn failed");
    332             }
    333         };
    334         self.local_block_anchor_burn = Some((current_height, burn.clone()));
    335         self.last_auto_anchor_burn_height = Some(current_height);
    336         Ok(Some(burn))
    337     }
    338 
    339     fn best_automatic_burn_on_ledger(
    340         &self,
    341         ledger: &Ledger,
    342         fee_per_byte: Amount,
    343         balance: Amount,
    344     ) -> Option<BuiltBlindedTransaction> {
    345         let target = self.burn_per_block.min(balance);
    346         if target == 0 {
    347             return None;
    348         }
    349         let exact_at_fee_rate =
    350             self.build_blinded_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte);
    351         if let Ok((built, estimate)) = exact_at_fee_rate {
    352             if target
    353                 .checked_add(estimate.fee)
    354                 .is_some_and(|required| required <= balance)
    355             {
    356                 return Some(built);
    357             }
    358         }
    359         if self.burn_per_block <= balance {
    360             let affordable_fee = balance.saturating_sub(target);
    361             if let Ok(built) =
    362                 self.build_blinded_burn_with_fee_on_ledger(ledger, target, affordable_fee)
    363             {
    364                 return Some(built);
    365             }
    366         }
    367 
    368         let mut low = 1;
    369         let mut high = target;
    370         let mut best = None;
    371         while low <= high {
    372             let amount = low + (high - low) / 2;
    373             match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) {
    374                 Ok((built, estimate)) => {
    375                     let fits = amount
    376                         .checked_add(estimate.fee)
    377                         .is_some_and(|required| required <= balance);
    378                     if fits {
    379                         best = Some(built);
    380                         if amount == Amount::MAX {
    381                             break;
    382                         }
    383                         low = amount + 1;
    384                     } else {
    385                         high = amount.saturating_sub(1);
    386                     }
    387                 }
    388                 Err(_) => {
    389                     high = amount.saturating_sub(1);
    390                 }
    391             }
    392         }
    393         best
    394     }
    395 
    396     fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool {
    397         self.ledger
    398             .finalizer_rank_for_next_block(self.wallet.address())
    399             .is_some_and(|rank| self.wallet_rank_runs_vdf(rank))
    400             || self.should_prepare_recovery_vdf(timestamp_ms)
    401             || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS)
    402                 >= self.ledger.recovery_block_min_timestamp()
    403     }
    404 
    405     fn wallet_rank_runs_vdf(&self, rank: u32) -> bool {
    406         if rank == 0 {
    407             return true;
    408         }
    409         let rank_count = self.ledger.finalizer_rank_count_for_next_block();
    410         let allowed =
    411             allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent);
    412         usize::try_from(rank).is_ok_and(|rank| rank < allowed)
    413     }
    414 
    415     fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool {
    416         if !self.ledger.recovery_block_available_at(timestamp_ms) {
    417             return false;
    418         }
    419         if self.recovery_vdf_top_rank_percent == 100 {
    420             return true;
    421         }
    422         if self.recovery_vdf_top_rank_percent == 0 {
    423             return false;
    424         }
    425         if self.ledger.finalizer_rank_count_for_next_block() > 0 {
    426             return false;
    427         }
    428         recovery_vdf_sample_percent(self.wallet.address(), self.ledger.tip_hash())
    429             < self.recovery_vdf_top_rank_percent
    430     }
    431 
    432     fn reveal_bundle_collection_wait_ms(
    433         &mut self,
    434         timestamp_ms: u64,
    435         will_run_vdf: bool,
    436     ) -> Option<u64> {
    437         let next_height = self.ledger.height().saturating_add(1);
    438         let has_pending_reveals = !self.ledger.pending_blinded_reveals().is_empty();
    439         let wallet_is_committee_member = self
    440             .ledger
    441             .reveal_committee_for_next_block()
    442             .iter()
    443             .any(|member| member.owner == self.wallet.address());
    444         if !has_pending_reveals || (!wallet_is_committee_member && !will_run_vdf) {
    445             if !has_pending_reveals {
    446                 self.reveal_bundle_collection_started = None;
    447             }
    448             return None;
    449         }
    450 
    451         let started_at = match self.reveal_bundle_collection_started {
    452             Some((height, started_at)) if height == next_height => started_at,
    453             _ => {
    454                 self.reveal_bundle_collection_started = Some((next_height, timestamp_ms));
    455                 timestamp_ms
    456             }
    457         };
    458         let elapsed = timestamp_ms.saturating_sub(started_at);
    459         (elapsed < REVEAL_BUNDLE_COLLECTION_MS)
    460             .then(|| REVEAL_BUNDLE_COLLECTION_MS.saturating_sub(elapsed))
    461     }
    462 
    463     pub(super) fn prepare_next_block_with_local_anchor(
    464         &self,
    465         timestamp_ms: u64,
    466     ) -> Result<PreparedBlock> {
    467         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    468         ledger.prepare_next_block_with_required_burn_and_reveal_bundles(
    469             self.wallet.address(),
    470             timestamp_ms,
    471             self.usable_reveal_bundles(),
    472             required_burn_signature.as_deref(),
    473         )
    474     }
    475 
    476     fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
    477         let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor();
    478         ledger.prepare_recovery_block_with_required_burn_and_reveal_bundles(
    479             self.wallet.address(),
    480             timestamp_ms,
    481             self.usable_reveal_bundles(),
    482             required_burn_signature.as_deref(),
    483         )
    484     }
    485 
    486     fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) {
    487         let mut ledger = self.ledger.clone();
    488         let Some((height, burn)) = &self.local_block_anchor_burn else {
    489             return (ledger, None);
    490         };
    491         if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
    492             ledger.drop_pending_blinded_conflicting_with_transaction(burn);
    493             if ledger.submit_transaction(burn.clone()).is_ok() {
    494                 return (ledger, Some(burn.signature().to_string()));
    495             }
    496         }
    497         (ledger, None)
    498     }
    499 
    500     pub(super) fn clear_stale_local_block_anchor(&mut self) {
    501         if self
    502             .local_block_anchor_burn
    503             .as_ref()
    504             .is_some_and(|(height, _)| *height != self.ledger.height())
    505         {
    506             self.local_block_anchor_burn = None;
    507         }
    508     }
    509 
    510     pub(super) fn clear_stale_reveal_bundle_collection(&mut self) {
    511         let current_next_height = self.ledger.height().saturating_add(1);
    512         if self
    513             .reveal_bundle_collection_started
    514             .is_some_and(|(height, _)| height != current_next_height)
    515         {
    516             self.reveal_bundle_collection_started = None;
    517         }
    518         if self.ledger.pending_blinded_reveals().is_empty() {
    519             self.reveal_bundle_collection_started = None;
    520         }
    521     }
    522 }
    523 
    524 #[cfg(test)]
    525 mod tests;