app.rs (24113B)
1 use std::{ 2 collections::{BTreeMap, BTreeSet}, 3 sync::{ 4 Arc, 5 atomic::{AtomicBool, Ordering}, 6 }, 7 time::{SystemTime, UNIX_EPOCH}, 8 }; 9 10 use anyhow::Result; 11 use tokio::sync::Mutex; 12 13 use crate::domain::{ 14 Amount, BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, Ledger, 15 MINE_ACTIONS_PER_ANCHOR_LIMIT, PreparedBlock, RevealBundle, Transaction, run_vdf, 16 }; 17 18 mod automatic_mining; 19 mod gossip; 20 mod helpers; 21 mod in_memory_network; 22 mod ledger_view; 23 mod node_lifecycle; 24 mod owned_blinded; 25 mod peer_book; 26 mod receive; 27 mod status; 28 mod types; 29 mod wallet; 30 pub use in_memory_network::InMemoryNetwork; 31 pub use peer_book::{PeerBook, PeerDirection, PeerInfo}; 32 pub use types::{ 33 AutoMineOutcome, AutoMinePlan, BlockInventory, ExternalMineJob, FeeEstimate, GossipEnvelope, 34 LaunchProfileStatus, MiningStatus, NodeConfig, NodeStatus, ProtocolHello, StratumStatus, 35 }; 36 use wallet::NodeWallet; 37 38 pub type SharedNode = Arc<Mutex<NodeCore>>; 39 pub type SharedPeerBook = Arc<Mutex<PeerBook>>; 40 41 pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; 42 pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; 43 pub const PROTOCOL_VERSION: u32 = 1; 44 pub const NETWORK_ID: &str = "iuna-devnet-v3"; 45 pub const BLOCK_REQUEST_LIMIT: usize = 128; 46 pub const TRANSACTION_BATCH_LIMIT: usize = 128; 47 const IMPORT_REBROADCAST_LIMIT: usize = 128; 48 pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; 49 pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; 50 pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000; 51 const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000; 52 const AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK: u64 = 100_000; 53 const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000; 54 const REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000; 55 const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1; 56 const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 0; 57 static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); 58 59 pub fn set_debug_logging(enabled: bool) { 60 DEBUG_LOGGING.store(enabled, Ordering::Relaxed); 61 } 62 63 pub fn debug_logging_enabled() -> bool { 64 DEBUG_LOGGING.load(Ordering::Relaxed) 65 } 66 67 #[derive(Clone, Debug, Eq, PartialEq)] 68 struct AutoPowMineCursor { 69 anchor: String, 70 salt: u64, 71 next_nonce: u64, 72 searched: u64, 73 } 74 75 #[derive(Clone, Debug)] 76 pub struct AutoPowMineJob { 77 ledger: Ledger, 78 recipient: String, 79 anchor: String, 80 salt: u64, 81 start_nonce: u64, 82 max_attempts: u64, 83 } 84 85 impl AutoPowMineJob { 86 pub fn anchor(&self) -> &str { 87 &self.anchor 88 } 89 90 pub fn search(self) -> Result<(Self, crate::domain::MineSearchOutcome)> { 91 let outcome = self.ledger.search_mine( 92 self.recipient.clone(), 93 self.salt, 94 self.start_nonce, 95 self.max_attempts, 96 )?; 97 Ok((self, outcome)) 98 } 99 } 100 101 #[derive(Clone, Debug)] 102 pub struct NodeCore { 103 wallet: NodeWallet, 104 ledger: Ledger, 105 automatic_mining_enabled: bool, 106 pow_mining_enabled: bool, 107 pow_mining_workers: u8, 108 burn_per_block: Amount, 109 burn_fee: Amount, 110 recovery_vdf_top_rank_percent: u8, 111 last_auto_burn_height: Option<u64>, 112 last_auto_anchor_burn_height: Option<u64>, 113 last_auto_pow_mine_anchor: Option<String>, 114 last_auto_pow_mine_status: Option<String>, 115 auto_pow_mine_cursor: Option<AutoPowMineCursor>, 116 owned_blinded_transactions: BTreeMap<String, BlindedTransaction>, 117 owned_blinded_reveals: BTreeMap<String, BlindedReveal>, 118 owned_blinded_payloads: BTreeMap<String, Transaction>, 119 owned_blinded_outbox_version: u64, 120 reveal_bundles: BTreeMap<(u64, u8), RevealBundle>, 121 equivocated_reveal_bundle_slots: BTreeSet<(u64, u8)>, 122 reveal_bundle_collection_started: Option<(u64, u64)>, 123 local_block_anchor_burn: Option<(u64, Transaction)>, 124 outbox: Vec<GossipEnvelope>, 125 } 126 127 pub fn now_ms() -> u64 { 128 SystemTime::now() 129 .duration_since(UNIX_EPOCH) 130 .expect("system time is before unix epoch") 131 .as_millis() as u64 132 } 133 134 #[cfg(test)] 135 mod tests { 136 use std::collections::BTreeMap; 137 138 use crate::domain::{ 139 GenesisBurn, Ledger, MICRO_IUNA, OutPoint, Transaction, VDF_TARGET_BLOCK_MS, Wallet, 140 run_vdf, 141 }; 142 143 use super::{ 144 InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS, 145 helpers::transaction_input_outpoints, 146 }; 147 148 fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { 149 wallets 150 .iter() 151 .find(|wallet| wallet.address() == address) 152 .unwrap_or_else(|| panic!("missing wallet for address {address}")) 153 } 154 155 fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction { 156 node.set_pow_mining_enabled(true); 157 (0..10_000) 158 .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined) 159 .expect("test node should find a PoW mine action") 160 } 161 162 fn assert_block_has_mine_action(block: &crate::domain::Block) { 163 assert!( 164 block 165 .transactions 166 .iter() 167 .any(|transaction| matches!(transaction, Transaction::Mine { .. })), 168 "block {} should include a mine action", 169 block.height 170 ); 171 } 172 173 #[test] 174 fn automatic_finalization_includes_reveals_with_two_nodes_and_one_burner() { 175 let finalizer = Wallet::from_seed("single-burner-reveal-finalizer"); 176 let wallet = Wallet::from_seed("single-burner-reveal-wallet"); 177 let mut allocations = BTreeMap::new(); 178 allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); 179 allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); 180 let ledger = Ledger::new_with_genesis_burns( 181 allocations, 182 vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], 183 1, 184 ) 185 .unwrap(); 186 let mut network = InMemoryNetwork::default(); 187 network.insert( 188 "finalizer", 189 NodeCore::from_ledger_with_burn_fee_and_enabled( 190 finalizer.clone(), 191 ledger.clone(), 192 true, 193 MICRO_IUNA / 10, 194 1, 195 ), 196 ); 197 network.insert("wallet", NodeCore::from_ledger(wallet.clone(), ledger, 0)); 198 199 let blinded = network 200 .node_mut("wallet") 201 .unwrap() 202 .blinded_burn_with_fee(MICRO_IUNA / 10, 1, 4) 203 .unwrap(); 204 network.deliver_until_idle().unwrap(); 205 206 let commit_plan = network 207 .node_mut("finalizer") 208 .unwrap() 209 .prepare_automatic_finalization(1); 210 let commit_work = commit_plan 211 .work 212 .expect("finalizer should prepare commit block"); 213 let commit_vdf = run_vdf(commit_work.vdf_seed(), commit_work.vdf_rounds()); 214 let commit_block = network 215 .node_mut("finalizer") 216 .unwrap() 217 .complete_prepared_block_at(commit_work, commit_vdf, 1) 218 .unwrap(); 219 let wallet_commitment = blinded.commitment.clone(); 220 assert!( 221 commit_block 222 .blinded_transactions 223 .iter() 224 .any(|transaction| transaction.commitment == wallet_commitment), 225 "first block should commit the wallet's blinded burn" 226 ); 227 network.deliver_until_idle().unwrap(); 228 assert!( 229 network 230 .node("finalizer") 231 .unwrap() 232 .ledger() 233 .pending_blinded_reveals() 234 .iter() 235 .any(|reveal| reveal.commitment == wallet_commitment), 236 "finalizer should have received the reveal before building the next block" 237 ); 238 assert!( 239 network 240 .node("wallet") 241 .unwrap() 242 .ledger() 243 .pending_blinded_reveals() 244 .iter() 245 .any(|reveal| reveal.commitment == wallet_commitment), 246 "wallet node should also keep the reveal in its mempool" 247 ); 248 249 let reveal_plan = network 250 .node_mut("finalizer") 251 .unwrap() 252 .prepare_automatic_finalization(2); 253 assert!(reveal_plan.work.is_none()); 254 assert!( 255 reveal_plan 256 .skipped_reason 257 .as_deref() 258 .unwrap_or_default() 259 .contains("collecting blinded reveals") 260 ); 261 let reveal_plan = network 262 .node_mut("finalizer") 263 .unwrap() 264 .prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 2); 265 let reveal_work = reveal_plan 266 .work 267 .expect("finalizer should prepare reveal block"); 268 let reveal_vdf = run_vdf(reveal_work.vdf_seed(), reveal_work.vdf_rounds()); 269 let reveal_block = network 270 .node_mut("finalizer") 271 .unwrap() 272 .complete_prepared_block_at(reveal_work, reveal_vdf, 2) 273 .unwrap(); 274 275 assert!( 276 reveal_block 277 .all_blinded_reveals() 278 .iter() 279 .any(|reveal| reveal.commitment == wallet_commitment), 280 "automatic finalization should include the pending reveal without requiring an extra mempool poll" 281 ); 282 } 283 284 #[test] 285 fn genesis_transfer_arriving_during_vdf_with_peer_mines_does_not_stall_following_blocks() { 286 let miner = Wallet::from_seed("during-vdf-miner"); 287 let (_finalizer, finalizer_node, block2_work) = (0..1_000) 288 .find_map(|seed_index| { 289 let finalizer = Wallet::from_seed(&format!("during-vdf-finalizer-{seed_index}")); 290 let mut allocations = BTreeMap::new(); 291 allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); 292 let mut ledger = Ledger::new_with_genesis_burns( 293 allocations, 294 vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], 295 1, 296 ) 297 .unwrap(); 298 let split = ledger 299 .build_transfer(&finalizer, finalizer.address(), MICRO_IUNA / 10, 0) 300 .ok()?; 301 let split_change = OutPoint { 302 txid: split.signature().to_string(), 303 index: 1, 304 }; 305 ledger.submit_transaction(split).ok()?; 306 let burn = ledger 307 .build_burn_with_inputs(&finalizer, 1, 0, &[split_change]) 308 .ok()?; 309 ledger.submit_transaction(burn).ok()?; 310 let block1 = ledger.mine_next_block(&finalizer, 1).ok()?; 311 assert!(block1.blinded_transactions.is_empty()); 312 assert!( 313 !block1 314 .transactions 315 .iter() 316 .any(|transaction| matches!(transaction, Transaction::Mine { .. })), 317 "node B joins after block 1, so block 1 should not include B's mine action" 318 ); 319 ledger.apply_block(block1).ok()?; 320 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( 321 finalizer.clone(), 322 ledger, 323 true, 324 0, 325 100, 326 ); 327 let block2_plan = node.prepare_automatic_finalization(2); 328 let block2_work = block2_plan.work?; 329 let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; 330 let anchor_inputs = transaction_input_outpoints(&anchor_burn); 331 let anchor_total = node 332 .ledger() 333 .utxos_for_address(finalizer.address()) 334 .iter() 335 .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) 336 .map(|(_, output)| output.amount) 337 .sum::<u64>(); 338 if anchor_total >= 200_000 { 339 return None; 340 } 341 Some((finalizer, node, block2_work)) 342 }) 343 .expect("test should find a seed where block 2 anchor uses the small reward UTXO"); 344 let mut network = InMemoryNetwork::default(); 345 network.insert("finalizer", finalizer_node); 346 347 let miner_ledger = 348 Ledger::from_snapshot(network.node("finalizer").unwrap().chain_snapshot()).unwrap(); 349 network.insert( 350 "miner", 351 NodeCore::from_ledger(miner.clone(), miner_ledger, 0), 352 ); 353 354 queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); 355 network.deliver_until_idle().unwrap(); 356 network 357 .node_mut("finalizer") 358 .unwrap() 359 .transfer_with_fee_rate(miner.address(), MICRO_IUNA / 10, 100, &[]) 360 .unwrap(); 361 let blinded = network 362 .node("finalizer") 363 .unwrap() 364 .ledger() 365 .pending_blinded_transactions() 366 .last() 367 .cloned() 368 .expect("A should queue the A -> B blinded transfer while block 2 VDF is running"); 369 network.deliver_until_idle().unwrap(); 370 assert!( 371 network 372 .node("finalizer") 373 .unwrap() 374 .ledger() 375 .pending_blinded_transactions() 376 .iter() 377 .any(|tx| tx.commitment == blinded.commitment), 378 "the finalizer should receive the blinded tx while block 2 VDF is running" 379 ); 380 381 let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); 382 let block2 = network 383 .node_mut("finalizer") 384 .unwrap() 385 .complete_prepared_block_at(block2_work, block2_vdf, 2) 386 .unwrap(); 387 assert!( 388 block2.blinded_transactions.is_empty(), 389 "block 2 work was prepared before the blinded tx arrived" 390 ); 391 assert!( 392 !block2 393 .transactions 394 .iter() 395 .any(|transaction| matches!(transaction, Transaction::Mine { .. })), 396 "block 2 work was prepared before B's mine action arrived" 397 ); 398 network.deliver_until_idle().unwrap(); 399 400 let block3_outcome = network 401 .node_mut("finalizer") 402 .unwrap() 403 .automatic_mine_once(3); 404 assert!( 405 block3_outcome.block.is_some(), 406 "finalizer should keep producing after the during-VDF blinded tx: {:?}", 407 block3_outcome.skipped_reason 408 ); 409 let block3 = block3_outcome.block.unwrap(); 410 assert!( 411 block3 412 .transactions 413 .first() 414 .is_some_and(Transaction::is_burn), 415 "the mandatory anchor burn must be selected before during-VDF mempool items" 416 ); 417 let committed_blinded = block3 418 .blinded_transactions 419 .iter() 420 .any(|tx| tx.commitment == blinded.commitment); 421 assert_block_has_mine_action(&block3); 422 network.deliver_until_idle().unwrap(); 423 424 queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); 425 network.deliver_until_idle().unwrap(); 426 let block4_started_at = block3.timestamp_ms.saturating_add(1); 427 let mut block4_outcome = network 428 .node_mut("finalizer") 429 .unwrap() 430 .automatic_mine_once(block4_started_at); 431 if block4_outcome 432 .skipped_reason 433 .as_deref() 434 .is_some_and(|reason| reason.contains("collecting blinded reveals")) 435 { 436 block4_outcome = network.node_mut("finalizer").unwrap().automatic_mine_once( 437 block4_started_at.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1), 438 ); 439 } 440 let block4 = block4_outcome 441 .block 442 .expect("finalizer should keep producing the next block"); 443 if committed_blinded { 444 assert!( 445 block4 446 .all_blinded_reveals() 447 .iter() 448 .any(|reveal| reveal.commitment == blinded.commitment), 449 "the committed during-VDF blinded tx should reveal in a later block" 450 ); 451 } else { 452 assert!( 453 network 454 .node("finalizer") 455 .unwrap() 456 .ledger() 457 .pending_blinded_transactions() 458 .is_empty(), 459 "a conflicting during-VDF blinded tx should be pruned after the anchor burn spends its input" 460 ); 461 assert!( 462 network 463 .node("finalizer") 464 .unwrap() 465 .owned_blinded_transactions() 466 .is_empty(), 467 "owned blinded state should not keep rebroadcasting a pruned tx" 468 ); 469 assert!( 470 block4.all_blinded_reveals().is_empty(), 471 "a pruned blinded tx was never committed, so there should be no reveal" 472 ); 473 } 474 assert_block_has_mine_action(&block4); 475 } 476 477 #[test] 478 fn own_blinded_transaction_arriving_during_vdf_does_not_starve_next_anchor_burn() { 479 let recipient = Wallet::from_seed("during-vdf-own-recipient"); 480 let (mut node, block2_work, transfer_outpoint, transfer_amount) = (0..1_000) 481 .find_map(|seed_index| { 482 let finalizer = 483 Wallet::from_seed(&format!("during-vdf-own-finalizer-{seed_index}")); 484 let mut allocations = BTreeMap::new(); 485 allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); 486 let ledger = Ledger::new_with_genesis_burns( 487 allocations, 488 vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], 489 1, 490 ) 491 .unwrap(); 492 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( 493 finalizer.clone(), 494 ledger, 495 true, 496 0, 497 1_000, 498 ); 499 node.set_pow_mining_enabled(true); 500 let block1 = node.automatic_mine_once(1).block?; 501 assert!(block1.blinded_transactions.is_empty()); 502 503 let block2_plan = node.prepare_automatic_finalization(2); 504 let block2_work = block2_plan.work?; 505 let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; 506 let anchor_inputs = transaction_input_outpoints(&anchor_burn); 507 let utxos = node.ledger().utxos_for_address(finalizer.address()); 508 let anchor_total = utxos 509 .iter() 510 .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) 511 .map(|(_, output)| output.amount) 512 .sum::<u64>(); 513 let (transfer_outpoint, transfer_output) = 514 utxos.into_iter().find(|(outpoint, output)| { 515 !anchor_inputs.contains(outpoint) && output.amount > MICRO_IUNA 516 })?; 517 if anchor_total >= 2_000_000 { 518 return None; 519 } 520 Some(( 521 node, 522 block2_work, 523 transfer_outpoint, 524 transfer_output.amount.min(MICRO_IUNA) / 10, 525 )) 526 }) 527 .expect("test should find a seed with live-like small-anchor/large-change UTXOs"); 528 529 node.transfer_with_fee_spending( 530 recipient.address(), 531 transfer_amount, 532 1, 533 &[transfer_outpoint], 534 ) 535 .expect("wallet tx created while block 2 VDF is running"); 536 let blinded = node 537 .ledger() 538 .pending_blinded_transactions() 539 .last() 540 .cloned() 541 .expect("wallet tx should be queued as a blinded transaction"); 542 543 let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); 544 let block2 = node 545 .complete_prepared_block_at(block2_work, block2_vdf, 2) 546 .unwrap(); 547 assert!( 548 block2.blinded_transactions.is_empty(), 549 "block 2 work was prepared before the blinded tx arrived" 550 ); 551 assert!( 552 node.ledger() 553 .pending_blinded_transactions() 554 .iter() 555 .any(|tx| tx.commitment == blinded.commitment), 556 "the during-VDF blinded tx should remain pending for block 3" 557 ); 558 559 let block3_outcome = node.automatic_mine_once(3); 560 assert!( 561 block3_outcome.block.is_some(), 562 "pending own blinded tx must not starve the next anchor burn: {:?}", 563 block3_outcome.skipped_reason 564 ); 565 let block3 = block3_outcome.block.unwrap(); 566 assert!( 567 block3.blinded_transactions.is_empty() 568 || block3 569 .blinded_transactions 570 .iter() 571 .any(|tx| tx.commitment == blinded.commitment), 572 "block 3 may include the during-VDF tx, but must not stall when the anchor burn has priority" 573 ); 574 } 575 576 #[test] 577 fn locally_produced_blocks_import_on_independent_peer_ledger() { 578 let alice = Wallet::from_seed("producer-parity-alice"); 579 let bob = Wallet::from_seed("producer-parity-bob"); 580 let carol = Wallet::from_seed("producer-parity-carol"); 581 let wallets = [alice.clone(), bob.clone(), carol.clone()]; 582 let mut allocations = BTreeMap::new(); 583 for wallet in &wallets { 584 allocations.insert(wallet.address().to_string(), 20 * MICRO_IUNA); 585 } 586 let genesis_burns = wallets 587 .iter() 588 .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) 589 .collect(); 590 let mut producer_ledger = 591 Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap(); 592 let mut peer_ledger = producer_ledger.clone(); 593 594 for step in 0..8 { 595 assert_eq!( 596 producer_ledger.status().tip_hash, 597 peer_ledger.status().tip_hash 598 ); 599 let leader = producer_ledger 600 .expected_leader_for_next_block() 601 .expect("test chain should have an eligible leader"); 602 let leader_wallet = wallet_for_address(&wallets, &leader).clone(); 603 let timestamp_ms = (step + 1) as u64 * VDF_TARGET_BLOCK_MS; 604 let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( 605 leader_wallet.clone(), 606 producer_ledger.clone(), 607 true, 608 MICRO_IUNA / 10, 609 1, 610 ); 611 let plan = node.prepare_automatic_finalization(timestamp_ms); 612 assert!(plan.burned.is_some()); 613 614 match step % 3 { 615 0 => { 616 let _ = node.blinded_burn_with_fee(1, 0, node.chain_height() + 4); 617 } 618 1 => { 619 let recipient = wallets[(step + 1) % wallets.len()].address(); 620 let _ = 621 node.blinded_transfer_with_fee(recipient, 1, 0, node.chain_height() + 4); 622 } 623 _ => {} 624 } 625 626 let work = node 627 .prepare_next_block_with_local_anchor(timestamp_ms) 628 .unwrap(); 629 let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); 630 let block = node 631 .complete_prepared_block_at(work, vdf_output, timestamp_ms) 632 .unwrap(); 633 peer_ledger.apply_block_at(block, u64::MAX).unwrap(); 634 producer_ledger = node.clone_ledger(); 635 assert_eq!( 636 producer_ledger.status().tip_hash, 637 peer_ledger.status().tip_hash 638 ); 639 } 640 } 641 }