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