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;