tests.rs (19292B)
1 use std::collections::BTreeMap; 2 3 use crate::{ 4 adapters::config_store::{DEFAULT_POW_MINING_WORKERS, MAX_POW_MINING_WORKERS}, 5 app::{GossipEnvelope, NodeConfig, NodeCore}, 6 domain::{ 7 FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, 8 MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, Wallet, 9 run_vdf, 10 }, 11 }; 12 13 #[test] 14 fn same_height_verified_import_does_not_reset_auto_burn_guard() { 15 let alice = Wallet::from_seed("same-height-import-alice"); 16 let mut allocations = BTreeMap::new(); 17 allocations.insert(alice.address().to_string(), MICRO_IUNA); 18 let mut node = NodeCore::new(NodeConfig { 19 wallet: alice, 20 genesis_allocations: allocations, 21 vdf_rounds: 10, 22 burn_per_block: 1, 23 burn_fee: 1, 24 pow_mining_workers: 1, 25 recovery_vdf_top_rank_percent: 100, 26 }); 27 28 let first = node.prepare_automatic_mining(1); 29 assert!(first.burned.is_some()); 30 assert_eq!(node.last_auto_burn_height, Some(0)); 31 32 let same_height_ledger = node.clone_ledger(); 33 assert!(!node.import_verified_ledger(same_height_ledger).unwrap()); 34 assert_eq!(node.last_auto_burn_height, Some(0)); 35 36 let second = node.prepare_automatic_mining(2); 37 assert!(second.burned.is_none()); 38 } 39 40 #[test] 41 fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { 42 let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); 43 let ledger = Ledger::new_with_genesis_burns( 44 BTreeMap::from([(wallet.address().to_string(), 1)]), 45 vec![GenesisBurn::new(wallet.address(), 1)], 46 10, 47 ) 48 .unwrap(); 49 let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0); 50 51 let disabled = node.prepare_automatic_mining(1); 52 assert!(disabled.pow_mined.is_none()); 53 assert_eq!( 54 disabled.skipped_reason.as_deref(), 55 Some("automatic mining is off") 56 ); 57 58 node.set_pow_mining_enabled(true); 59 let first = node.prepare_automatic_mining(2); 60 assert!(node.ledger().pending_blinded_transactions().len() <= 1); 61 let first = std::iter::once(first) 62 .chain((3..10_000).map(|timestamp| node.prepare_automatic_mining(timestamp))) 63 .find(|plan| plan.pow_mined.is_some()) 64 .expect("bounded PoW search should eventually find a proof"); 65 let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); 66 let Transaction::Mine { 67 anchor, 68 recipient, 69 difficulty_bits, 70 .. 71 } = first_mine 72 else { 73 panic!("expected mine transaction"); 74 }; 75 assert_eq!(anchor, &node.chain().last().unwrap().hash); 76 assert_eq!(recipient, wallet.address()); 77 assert_eq!( 78 *difficulty_bits, 79 node.ledger().current_mine_difficulty_bits() 80 ); 81 let first_pending = node.ledger().pending().len(); 82 assert!(first_pending >= 1); 83 assert!(node.ledger().pending_blinded_transactions().is_empty()); 84 assert!(node.drain_outbox().iter().any(|envelope| { 85 matches!(envelope, GossipEnvelope::MineAction(tx) if tx.signature() == first_mine.signature()) 86 })); 87 assert!( 88 node.status() 89 .mining 90 .last_auto_pow_mine_status 91 .as_deref() 92 .unwrap_or_default() 93 .contains("queued") 94 ); 95 96 let second = (10_000..20_000) 97 .map(|timestamp| node.prepare_automatic_mining(timestamp)) 98 .find(|plan| plan.pow_mined.is_some()) 99 .expect("automatic PoW should allow a second proof for the same tip"); 100 let second_mine = second.pow_mined.as_ref().expect("PoW should be queued"); 101 assert_ne!(second_mine.signature(), first_mine.signature()); 102 assert_eq!(node.ledger().pending().len(), first_pending + 1); 103 104 for timestamp in 20_000..20_010 { 105 assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); 106 } 107 assert_eq!(node.ledger().pending().len(), first_pending + 1); 108 assert_eq!( 109 node.status().mining.last_auto_pow_mine_status.as_deref(), 110 Some("waiting for next chain tip after queued mine actions") 111 ); 112 assert!(node.ledger().pending_blinded_transactions().is_empty()); 113 } 114 115 #[test] 116 fn automatic_pow_mining_waits_after_queueing_anchor_limit_for_tip() { 117 let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); 118 let mut allocations = BTreeMap::new(); 119 allocations.insert(wallet.address().to_string(), 1); 120 let mut node = NodeCore::new(NodeConfig { 121 wallet, 122 genesis_allocations: allocations, 123 vdf_rounds: 10, 124 burn_per_block: 0, 125 burn_fee: 0, 126 pow_mining_workers: 1, 127 recovery_vdf_top_rank_percent: 100, 128 }); 129 130 node.set_pow_mining_enabled(true); 131 let first_mined = (1..10_000) 132 .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) 133 .expect("PoW should eventually queue a mine action"); 134 let anchor = match first_mined { 135 Transaction::Mine { ref anchor, .. } => anchor.clone(), 136 _ => panic!("expected mine action"), 137 }; 138 assert_eq!(node.ledger().pending_mine_count_for_anchor(&anchor), 1); 139 140 (1..10_000) 141 .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) 142 .expect("PoW should allow a second mine action for the same tip"); 143 assert_eq!( 144 node.ledger().pending_mine_count_for_anchor(&anchor), 145 MINE_ACTIONS_PER_ANCHOR_LIMIT 146 ); 147 assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); 148 assert!(node.auto_pow_mine_cursor.is_none()); 149 } 150 151 #[test] 152 fn disabling_automatic_pow_mining_clears_local_work() { 153 let wallet = Wallet::from_seed("automatic-pow-disable-wallet"); 154 let mut allocations = BTreeMap::new(); 155 allocations.insert(wallet.address().to_string(), 1); 156 let mut node = NodeCore::new(NodeConfig { 157 wallet, 158 genesis_allocations: allocations, 159 vdf_rounds: 10, 160 burn_per_block: 0, 161 burn_fee: 0, 162 pow_mining_workers: 1, 163 recovery_vdf_top_rank_percent: 100, 164 }); 165 166 node.set_pow_mining_enabled(true); 167 assert!(node.pow_mining_enabled()); 168 node.prepare_automatic_pow_mining().unwrap(); 169 assert!(node.auto_pow_mine_cursor.is_some()); 170 assert!(node.status().mining.last_auto_pow_mine_status.is_some()); 171 172 node.set_pow_mining_enabled(false); 173 174 assert!(!node.pow_mining_enabled()); 175 assert!(node.auto_pow_mine_cursor.is_none()); 176 assert!(node.status().mining.last_auto_pow_mine_status.is_none()); 177 } 178 179 #[test] 180 fn automatic_pow_mining_workers_are_clamped_and_reported() { 181 let wallet = Wallet::from_seed("automatic-pow-workers-wallet"); 182 let mut node = NodeCore::new(NodeConfig { 183 wallet, 184 genesis_allocations: BTreeMap::new(), 185 vdf_rounds: 10, 186 burn_per_block: 0, 187 burn_fee: 0, 188 pow_mining_workers: 99, 189 recovery_vdf_top_rank_percent: 100, 190 }); 191 192 assert_eq!(node.pow_mining_workers(), MAX_POW_MINING_WORKERS); 193 assert_eq!( 194 node.status().mining.max_pow_mining_workers, 195 MAX_POW_MINING_WORKERS 196 ); 197 198 node.set_pow_mining_workers(0); 199 200 assert_eq!(node.pow_mining_workers(), DEFAULT_POW_MINING_WORKERS); 201 assert_eq!( 202 node.status().mining.pow_mining_workers, 203 DEFAULT_POW_MINING_WORKERS 204 ); 205 } 206 207 #[test] 208 fn automatic_pow_mining_skips_unspendable_owned_blinded_payloads() { 209 let alice = Wallet::from_seed("automatic-pow-stale-owned-blind-alice"); 210 let bob = Wallet::from_seed("automatic-pow-stale-owned-blind-bob"); 211 let mut allocations = BTreeMap::new(); 212 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 213 allocations.insert(bob.address().to_string(), MICRO_IUNA); 214 let ledger = Ledger::new_with_genesis_burns( 215 allocations, 216 vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], 217 10, 218 ) 219 .unwrap(); 220 let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); 221 222 let blinded = node 223 .blinded_burn_with_fee(MICRO_IUNA / 10, 7, node.chain_height() + 4) 224 .unwrap(); 225 let mut finalizer_ledger = node.ledger().clone(); 226 let leader_burn = finalizer_ledger.build_burn(&bob, 1, 0).unwrap(); 227 finalizer_ledger.submit_transaction(leader_burn).unwrap(); 228 let commit_block = finalizer_ledger.mine_next_block(&bob, 1).unwrap(); 229 assert!( 230 commit_block 231 .blinded_transactions 232 .iter() 233 .any(|tx| tx.commitment == blinded.commitment) 234 ); 235 finalizer_ledger.apply_block(commit_block).unwrap(); 236 assert!(node.import_verified_ledger(finalizer_ledger).unwrap()); 237 assert!( 238 node.ledger() 239 .has_unrevealed_blinded_transaction(&blinded.commitment) 240 ); 241 242 node.set_pow_mining_enabled(true); 243 244 assert!(node.prepare_automatic_pow_mining().is_ok()); 245 } 246 247 #[test] 248 fn automatic_pow_mining_skips_stale_local_anchor_reservation() { 249 let wallet = Wallet::from_seed("automatic-pow-stale-anchor-wallet"); 250 let mut stale_allocations = BTreeMap::new(); 251 stale_allocations.insert(wallet.address().to_string(), MICRO_IUNA); 252 let stale_ledger = Ledger::new(stale_allocations, 10); 253 let stale_anchor = stale_ledger.build_burn(&wallet, 1, 0).unwrap(); 254 let live_ledger = Ledger::new(BTreeMap::new(), 10); 255 let mut node = NodeCore::from_ledger(wallet.clone(), live_ledger, 0); 256 node.local_block_anchor_burn = Some((node.chain_height(), stale_anchor)); 257 node.set_pow_mining_enabled(true); 258 259 assert!(node.prepare_automatic_pow_mining().is_ok()); 260 } 261 262 #[test] 263 fn automatic_finalization_does_not_tick_pow_mining() { 264 let wallet = Wallet::from_seed("automatic-pow-separated-finalizer-wallet"); 265 let mut node = NodeCore::new(NodeConfig { 266 wallet, 267 genesis_allocations: BTreeMap::new(), 268 vdf_rounds: 10, 269 burn_per_block: 0, 270 burn_fee: 0, 271 pow_mining_workers: 1, 272 recovery_vdf_top_rank_percent: 100, 273 }); 274 275 node.set_pow_mining_enabled(true); 276 let _ = node.prepare_automatic_finalization(1); 277 278 assert!(node.auto_pow_mine_cursor.is_none()); 279 } 280 281 #[test] 282 fn automatic_finalization_prepares_recovery_after_ticket_timeout() { 283 let alice = Wallet::from_seed("automatic-recovery-alice"); 284 let bob = Wallet::from_seed("automatic-recovery-bob"); 285 let mut allocations = BTreeMap::new(); 286 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 287 allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); 288 let ledger = 289 Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) 290 .unwrap(); 291 let mut node = NodeCore::from_ledger(bob, ledger, 1); 292 293 let early = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS - 1); 294 assert!(early.work.is_none()); 295 assert!( 296 early 297 .skipped_reason 298 .as_deref() 299 .unwrap_or_default() 300 .contains("waiting for selected finalizer") 301 ); 302 303 let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); 304 let work = recovery.work.expect("recovery work should be prepared"); 305 let block = work.finish( 306 node.wallet.unlocked().unwrap(), 307 "preverified-vdf".to_string(), 308 ); 309 310 assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); 311 assert!(block.leader_proof.is_none()); 312 } 313 314 #[test] 315 fn automatic_finalization_respects_zero_recovery_vdf_threshold() { 316 let alice = Wallet::from_seed("automatic-recovery-zero-alice"); 317 let bob = Wallet::from_seed("automatic-recovery-zero-bob"); 318 let mut allocations = BTreeMap::new(); 319 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 320 allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); 321 let ledger = 322 Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) 323 .unwrap(); 324 let mut node = NodeCore::from_ledger(bob, ledger, 1); 325 node.set_recovery_vdf_top_rank_percent(0); 326 327 let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); 328 329 assert!(recovery.work.is_none()); 330 } 331 332 #[test] 333 fn automatic_non_leader_burn_is_queued_as_blinded() { 334 let alice = Wallet::from_seed("auto-blinded-burn-alice"); 335 let bob = Wallet::from_seed("auto-blinded-burn-bob"); 336 let carol = Wallet::from_seed("auto-blinded-burn-carol"); 337 let finalizers = [alice.clone(), bob.clone()]; 338 let mut allocations = BTreeMap::new(); 339 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 340 allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); 341 allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); 342 let ledger = Ledger::new_with_genesis_burns( 343 allocations, 344 finalizers 345 .iter() 346 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 347 .collect(), 348 1, 349 ) 350 .unwrap(); 351 assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); 352 let mut node = 353 NodeCore::from_ledger_with_burn_fee_and_enabled(carol, ledger, true, MICRO_IUNA / 10, 1); 354 355 let plan = node.prepare_automatic_finalization(1); 356 let outbox = node.drain_outbox(); 357 358 assert!(plan.burned.is_some()); 359 assert!(node.ledger().pending().is_empty()); 360 assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); 361 assert!( 362 outbox 363 .iter() 364 .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) 365 ); 366 } 367 368 #[test] 369 fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() { 370 let alice = Wallet::from_seed("auto-fallback-burn-alice"); 371 let bob = Wallet::from_seed("auto-fallback-burn-bob"); 372 let finalizers = [alice.clone(), bob.clone()]; 373 let mut allocations = BTreeMap::new(); 374 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 375 allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); 376 let ledger = Ledger::new_with_genesis_burns( 377 allocations, 378 finalizers 379 .iter() 380 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 381 .collect(), 382 1, 383 ) 384 .unwrap(); 385 let fallback = finalizers 386 .iter() 387 .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) 388 .unwrap() 389 .clone(); 390 let mut node = 391 NodeCore::from_ledger_with_burn_fee_and_enabled(fallback, ledger, true, MICRO_IUNA / 10, 1); 392 393 let plan = node.prepare_automatic_finalization(1); 394 let outbox = node.drain_outbox(); 395 396 assert!(plan.burned.is_some()); 397 assert!( 398 plan.skipped_reason.is_none(), 399 "fallback should not be skipped: {:?}", 400 plan.skipped_reason 401 ); 402 assert!(node.ledger().pending().is_empty()); 403 assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); 404 let (_, anchor_burn) = node 405 .local_block_anchor_burn 406 .as_ref() 407 .expect("fallback anchor burn should be held locally"); 408 let anchor_signature = anchor_burn.signature().to_string(); 409 assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); 410 assert!( 411 outbox 412 .iter() 413 .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) 414 ); 415 let work = plan.work.expect("fallback work should be prepared"); 416 let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); 417 let block = node 418 .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) 419 .unwrap(); 420 421 assert_eq!(block.finalizer_rank, 1); 422 assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); 423 assert!(block.transactions.iter().any(|transaction| { 424 transaction.is_burn() && transaction.amount() == super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT 425 })); 426 assert_eq!( 427 block 428 .transactions 429 .first() 430 .map(|transaction| transaction.signature()), 431 Some(anchor_signature.as_str()) 432 ); 433 assert!(!block.blinded_transactions.is_empty()); 434 assert_eq!(node.ledger().height(), 1); 435 } 436 437 #[test] 438 fn automatic_leader_prepares_anchor_and_blinded_burn() { 439 let alice = Wallet::from_seed("auto-plaintext-burn-alice"); 440 let bob = Wallet::from_seed("auto-plaintext-burn-bob"); 441 let finalizers = [alice.clone(), bob.clone()]; 442 let mut allocations = BTreeMap::new(); 443 allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); 444 allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); 445 let mut ledger = Ledger::new_with_genesis_burns( 446 allocations, 447 finalizers 448 .iter() 449 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 450 .collect(), 451 1, 452 ) 453 .unwrap(); 454 let leader = ledger.expected_leader_for_next_block().unwrap(); 455 let leader_wallet = finalizers 456 .iter() 457 .find(|wallet| wallet.address() == leader) 458 .unwrap() 459 .clone(); 460 for wallet in &finalizers { 461 let split = ledger 462 .build_transfer(wallet, wallet.address(), MICRO_IUNA, 0) 463 .unwrap(); 464 ledger.submit_transaction(split).unwrap(); 465 } 466 let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); 467 ledger.submit_transaction(anchor).unwrap(); 468 let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); 469 ledger.apply_block(split_block).unwrap(); 470 let leader = ledger.expected_leader_for_next_block().unwrap(); 471 let leader_wallet = finalizers 472 .iter() 473 .find(|wallet| wallet.address() == leader) 474 .unwrap() 475 .clone(); 476 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( 477 leader_wallet, 478 ledger, 479 true, 480 MICRO_IUNA / 10, 481 1, 482 ); 483 484 let plan = node.prepare_automatic_finalization(1); 485 let outbox = node.drain_outbox(); 486 487 assert!(plan.burned.is_some()); 488 assert!(node.ledger().pending().is_empty()); 489 assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); 490 let (_, anchor_burn) = node 491 .local_block_anchor_burn 492 .as_ref() 493 .expect("leader anchor burn should be held locally"); 494 assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); 495 assert!( 496 outbox 497 .iter() 498 .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) 499 ); 500 assert!(node.prepare_automatic_finalization(1).work.is_some()); 501 } 502 503 #[test] 504 fn automatic_pow_mining_uses_protocol_finalizer_fee() { 505 let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); 506 let ledger = Ledger::new_with_genesis_burns( 507 BTreeMap::from([(wallet.address().to_string(), MICRO_IUNA)]), 508 vec![GenesisBurn::new(wallet.address(), 1)], 509 10, 510 ) 511 .unwrap(); 512 let mut node = NodeCore::from_ledger(wallet, ledger, 0); 513 514 node.set_pow_mining_enabled(true); 515 let plan = (1..10_000) 516 .map(|timestamp| node.prepare_automatic_mining(timestamp)) 517 .find(|plan| plan.pow_mined.is_some()) 518 .expect("bounded PoW search should eventually find a proof"); 519 let mine = plan.pow_mined.expect("PoW should be queued"); 520 521 assert_eq!(mine.fee(), MINE_FINALIZER_FEE); 522 assert_eq!(mine.amount(), crate::domain::MINE_REWARD); 523 assert_eq!( 524 node.status().mining.automatic_pow_mine_fee, 525 MINE_FINALIZER_FEE 526 ); 527 }