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 }