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;