main_tests.rs (23970B)
1 use std::{collections::BTreeMap, sync::Arc, time::Duration}; 2 3 use iuna::{ 4 adapters::{ 5 chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore, 6 wallet_store, 7 }, 8 app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, 9 domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet}, 10 }; 11 use rusqlite::Connection; 12 use tempfile::tempdir; 13 use tokio::sync::Mutex; 14 15 use super::{ 16 ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet, 17 apply_cli_p2p_config_overrides, configured_p2p_announce_addr, configured_p2p_bind_addr, 18 extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, 19 load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, project_ui_data_store, 20 run_chain_persistence_with_interval, validate_wallet_for_mode, 21 }; 22 23 fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { 24 CliOptions::parse_from(args.iter().map(|arg| arg.to_string())) 25 } 26 27 #[test] 28 fn help_mentions_dev_seed_verify_bypass_env() { 29 assert!(help_text().contains("IUNA_DEV_SKIP_SEED_VERIFY=1")); 30 assert!(help_text().contains("skip seed verification")); 31 assert!(help_text().contains("--stratum <addr:port>")); 32 assert!(help_text().contains("--debug")); 33 } 34 35 fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { 36 let mut genesis = BTreeMap::new(); 37 genesis.insert(wallet.address().to_string(), 2); 38 Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1).unwrap() 39 } 40 41 fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { 42 let mut ledger = ledger_with_one_spendable_iuna(wallet); 43 let burn = ledger.build_burn(wallet, 1, 0).unwrap(); 44 ledger.submit_transaction(burn).unwrap(); 45 let block = ledger.mine_next_block(wallet, 1_000).unwrap(); 46 ledger.apply_locally_mined_block(block).unwrap(); 47 ledger 48 } 49 50 #[test] 51 fn encrypted_startup_wallet_loads_as_locked_metadata() { 52 let dir = tempdir().unwrap(); 53 let path = dir.path().join("wallet.json"); 54 let (wallet, _) = 55 wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456") 56 .unwrap(); 57 58 let startup = load_startup_wallet(&path).unwrap(); 59 60 match startup { 61 StartupWallet::Locked { address } => assert_eq!(address, wallet.address()), 62 StartupWallet::Unlocked { .. } => panic!("encrypted wallet should start locked"), 63 } 64 } 65 66 #[test] 67 fn no_args_starts_setup_mode() { 68 let opts = parse(&[]).unwrap().unwrap(); 69 assert_eq!(opts.chain_mode, ChainMode::Setup); 70 assert!(opts.join_peers.is_empty()); 71 } 72 73 #[test] 74 fn stratum_port_can_be_configured() { 75 let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap(); 76 assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap())); 77 } 78 79 #[test] 80 fn debug_logging_can_be_enabled() { 81 assert!(!parse(&[]).unwrap().unwrap().debug); 82 assert!(parse(&["--debug"]).unwrap().unwrap().debug); 83 } 84 85 #[test] 86 fn automatic_pow_worker_searches_outside_node_lock() { 87 let main_rs = include_str!("main.rs"); 88 let worker = main_rs 89 .split("async fn run_automatic_pow_miner") 90 .nth(1) 91 .expect("automatic PoW worker should exist"); 92 93 assert!(worker.contains("prepare_automatic_pow_mining_job")); 94 assert!(worker.contains("tokio::task::spawn_blocking")); 95 assert!(worker.contains("finish_automatic_pow_mining_job")); 96 assert!(!worker.contains("prepare_automatic_pow_mining()")); 97 } 98 99 #[test] 100 fn removed_wallet_seed_is_rejected() { 101 let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err(); 102 assert!(error.to_string().contains("--wallet-seed was removed")); 103 } 104 105 #[test] 106 fn runtime_configuration_flags_are_rejected() { 107 for flag in [ 108 "--start", 109 "--name", 110 "--burn-per-block", 111 "--peer", 112 "--genesis-amount", 113 "--vdf-rounds", 114 ] { 115 let error = parse(&["--genesis", flag, "value"]).unwrap_err(); 116 assert!( 117 error.to_string().contains("unknown argument"), 118 "{flag} should not be accepted" 119 ); 120 } 121 } 122 123 #[test] 124 fn genesis_mode_is_explicit() { 125 let opts = parse(&["--genesis"]).unwrap().unwrap(); 126 assert_eq!(opts.chain_mode, ChainMode::Genesis); 127 assert!(opts.join_peers.is_empty()); 128 } 129 130 #[test] 131 fn join_mode_does_not_start_new_chain() { 132 let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); 133 assert_eq!(opts.chain_mode, ChainMode::Join); 134 assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]); 135 } 136 137 #[test] 138 fn genesis_mode_starts_with_default_burn_rate_and_fee() { 139 let genesis = parse(&["--genesis"]).unwrap().unwrap(); 140 let configured = UiConfig { 141 burn_per_block: 50, 142 burn_fee: 3, 143 ..UiConfig::default() 144 }; 145 assert_eq!( 146 initial_burn_per_block(&genesis, &configured), 147 UiConfig::default().burn_per_block 148 ); 149 assert_eq!( 150 initial_burn_fee(&genesis, &configured), 151 UiConfig::default().burn_fee 152 ); 153 } 154 155 #[test] 156 fn genesis_default_auto_mining_keeps_burning_after_first_block() { 157 let wallet = Wallet::from_seed("genesis-auto-burn-wallet"); 158 let mut genesis = BTreeMap::new(); 159 genesis.insert(wallet.address().to_string(), MICRO_IUNA); 160 let ledger = Ledger::new_with_genesis_burns( 161 genesis, 162 vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], 163 1, 164 ) 165 .unwrap(); 166 let mut node = NodeCore::from_ledger_with_burn_fee( 167 wallet.clone(), 168 ledger, 169 GENESIS_INITIAL_BURN_PER_BLOCK, 170 GENESIS_INITIAL_BURN_FEE, 171 ); 172 173 let first = node.automatic_mine_once(1_000); 174 let second = node.automatic_mine_once(2_000); 175 let third = node.automatic_mine_once(3_000); 176 177 assert_eq!(first.burned.as_ref().map(|tx| tx.amount()), Some(1)); 178 assert!(first.block.is_some(), "{first:?}"); 179 assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(1)); 180 assert!(second.block.is_some(), "{second:?}"); 181 assert_eq!(third.burned.as_ref().map(|tx| tx.amount()), Some(1)); 182 assert!(third.block.is_some(), "{third:?}"); 183 assert!( 184 second.skipped_reason.as_deref().is_none_or(|reason| { 185 !reason.contains("block must include at least one burn transaction") 186 }), 187 "{second:?}" 188 ); 189 assert!( 190 node.ledger().balance_of(wallet.address()) 191 >= BLOCK_REWARD - 3 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE) 192 ); 193 } 194 195 #[test] 196 fn non_genesis_modes_start_with_configured_burn_rate_and_fee() { 197 let configured = UiConfig { 198 burn_per_block: 50, 199 burn_fee: 3, 200 ..UiConfig::default() 201 }; 202 203 let setup = parse(&[]).unwrap().unwrap(); 204 assert_eq!(initial_burn_per_block(&setup, &configured), 50); 205 assert_eq!(initial_burn_fee(&setup, &configured), 3); 206 207 let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); 208 assert_eq!(initial_burn_per_block(&join, &configured), 50); 209 assert_eq!(initial_burn_fee(&join, &configured), 3); 210 211 assert_eq!( 212 initial_burn_per_block(&setup, &UiConfig::default()), 213 UiConfig::default().burn_per_block 214 ); 215 assert_eq!( 216 initial_burn_fee(&setup, &UiConfig::default()), 217 UiConfig::default().burn_fee 218 ); 219 } 220 221 #[test] 222 fn genesis_and_join_are_exclusive() { 223 let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err(); 224 assert!( 225 error 226 .to_string() 227 .contains("choose either --genesis or --join") 228 ); 229 230 let error = parse(&["--join", "127.0.0.1:9444", "--genesis"]).unwrap_err(); 231 assert!( 232 error 233 .to_string() 234 .contains("choose either --genesis or --join") 235 ); 236 } 237 238 #[test] 239 fn http_management_port_can_be_configured() { 240 let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"]) 241 .unwrap() 242 .unwrap(); 243 assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443"); 244 } 245 246 #[test] 247 fn p2p_announce_port_can_be_configured() { 248 let opts = parse(&["--p2p-announce", "203.0.113.10:9444"]) 249 .unwrap() 250 .unwrap(); 251 252 assert_eq!( 253 opts.p2p_announce_addr, 254 Some("203.0.113.10:9444".parse().unwrap()) 255 ); 256 } 257 258 #[test] 259 fn configured_p2p_announce_addr_uses_cli_before_config() { 260 let opts = parse(&["--p2p-announce", "203.0.113.20:9444"]) 261 .unwrap() 262 .unwrap(); 263 let config = UiConfig { 264 p2p_announce_addr: Some("203.0.113.10:9444".to_string()), 265 ..UiConfig::default() 266 }; 267 268 assert_eq!( 269 configured_p2p_announce_addr(&opts, &config).unwrap(), 270 Some("203.0.113.20:9444".parse().unwrap()) 271 ); 272 } 273 274 #[test] 275 fn configured_p2p_announce_addr_reads_config_without_cli() { 276 let opts = parse(&[]).unwrap().unwrap(); 277 let config = UiConfig { 278 p2p_announce_addr: Some("203.0.113.10:9444".to_string()), 279 ..UiConfig::default() 280 }; 281 282 assert_eq!( 283 configured_p2p_announce_addr(&opts, &config).unwrap(), 284 Some("203.0.113.10:9444".parse().unwrap()) 285 ); 286 } 287 288 #[test] 289 fn configured_p2p_announce_addr_rejects_invalid_config() { 290 let opts = parse(&[]).unwrap().unwrap(); 291 let config = UiConfig { 292 p2p_announce_addr: Some("not-an-address".to_string()), 293 ..UiConfig::default() 294 }; 295 296 let error = configured_p2p_announce_addr(&opts, &config).unwrap_err(); 297 assert!( 298 error 299 .to_string() 300 .contains("invalid configured P2P announce address") 301 ); 302 } 303 304 #[test] 305 fn configured_p2p_bind_addr_uses_configured_public_port() { 306 let opts = parse(&[]).unwrap().unwrap(); 307 let config = UiConfig { 308 p2p_accept_inbound: true, 309 p2p_bind_port: 9555, 310 ..UiConfig::default() 311 }; 312 313 assert_eq!( 314 configured_p2p_bind_addr(&opts, &config).to_string(), 315 "0.0.0.0:9555" 316 ); 317 } 318 319 #[test] 320 fn configured_p2p_bind_addr_uses_cli_port_after_config_override() { 321 let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); 322 let config = UiConfig { 323 p2p_accept_inbound: true, 324 p2p_bind_port: 9555, 325 ..UiConfig::default() 326 }; 327 328 assert_eq!( 329 configured_p2p_bind_addr(&opts, &config).to_string(), 330 "0.0.0.0:9555" 331 ); 332 } 333 334 #[test] 335 fn cli_p2p_port_overrides_config_bind_port() { 336 let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); 337 let mut config = UiConfig { 338 p2p_accept_inbound: true, 339 p2p_bind_port: 9444, 340 ..UiConfig::default() 341 }; 342 343 assert!(apply_cli_p2p_config_overrides(&opts, &mut config)); 344 assert_eq!(config.p2p_bind_port, 9555); 345 } 346 347 #[test] 348 fn http_management_port_defaults_to_iuna_port() { 349 let opts = parse(&[]).unwrap().unwrap(); 350 assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); 351 } 352 353 #[test] 354 fn data_dir_defaults_under_home() { 355 let opts = parse(&[]).unwrap().unwrap(); 356 assert_eq!(opts.data_dir, super::default_data_dir()); 357 assert!(opts.data_dir.ends_with(".iuna")); 358 } 359 360 #[test] 361 fn wallet_defaults_under_data_dir() { 362 let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) 363 .unwrap() 364 .unwrap(); 365 assert_eq!( 366 opts.wallet_path(), 367 std::path::PathBuf::from("tmp-node/wallet.json") 368 ); 369 } 370 371 #[test] 372 fn chain_db_defaults_under_data_dir() { 373 let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) 374 .unwrap() 375 .unwrap(); 376 assert_eq!( 377 opts.chain_db_path(), 378 std::path::PathBuf::from("tmp-node/chain.sqlite3") 379 ); 380 } 381 382 #[test] 383 fn config_defaults_under_data_dir() { 384 let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) 385 .unwrap() 386 .unwrap(); 387 assert_eq!( 388 opts.config_path(), 389 std::path::PathBuf::from("tmp-node/config.json") 390 ); 391 } 392 393 #[test] 394 fn wallet_path_can_be_explicit() { 395 let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"]) 396 .unwrap() 397 .unwrap(); 398 assert_eq!( 399 opts.wallet_path(), 400 std::path::PathBuf::from("alice-wallet.json") 401 ); 402 } 403 404 #[test] 405 fn chain_db_path_can_be_explicit() { 406 let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"]) 407 .unwrap() 408 .unwrap(); 409 assert_eq!( 410 opts.chain_db_path(), 411 std::path::PathBuf::from("alice-chain.sqlite3") 412 ); 413 } 414 415 #[test] 416 fn genesis_requires_fresh_wallet_path() { 417 let opts = parse(&["--genesis"]).unwrap().unwrap(); 418 let wallet_path = std::path::Path::new("wallet.json"); 419 420 validate_wallet_for_mode(&opts, wallet_path, false).unwrap(); 421 let error = validate_wallet_for_mode(&opts, wallet_path, true).unwrap_err(); 422 assert!(error.to_string().contains("requires a fresh wallet path")); 423 424 let setup = parse(&[]).unwrap().unwrap(); 425 validate_wallet_for_mode(&setup, wallet_path, true).unwrap(); 426 } 427 428 #[test] 429 fn vdf_measurement_extrapolates_to_target() { 430 assert_eq!( 431 extrapolate_vdf_rounds( 432 10_000, 433 Duration::from_secs(1), 434 Duration::from_millis(VDF_TARGET_BLOCK_MS), 435 ), 436 3_000_000 437 ); 438 assert_eq!( 439 extrapolate_vdf_rounds( 440 10_000, 441 Duration::from_secs(0), 442 Duration::from_millis(VDF_TARGET_BLOCK_MS), 443 ), 444 3_000_000_000_000_000 445 ); 446 } 447 448 #[test] 449 fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() { 450 let min_elapsed = Duration::from_millis(1); 451 let max_rounds = 1_000_000; 452 let (rounds, elapsed) = 453 measure_vdf_rounds("iuna-test-vdf-calibration", 1, min_elapsed, max_rounds); 454 455 assert!(rounds >= 1); 456 assert!(elapsed >= min_elapsed || rounds >= max_rounds); 457 assert!(elapsed > Duration::ZERO); 458 } 459 460 #[tokio::test] 461 async fn genesis_refuses_to_start_when_chain_database_exists() { 462 let dir = tempdir().unwrap(); 463 let chain_path = dir.path().join("chain.sqlite3"); 464 let store = SqliteChainStore::open(&chain_path).unwrap(); 465 let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); 466 let persisted = ledger_with_one_mined_block(&persisted_wallet); 467 store.save(&persisted.snapshot()).unwrap(); 468 let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); 469 let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) 470 .unwrap() 471 .unwrap(); 472 473 let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) 474 .await 475 .unwrap_err(); 476 477 assert!( 478 error.to_string().contains("already contains a blockchain"), 479 "{error:#}" 480 ); 481 } 482 483 #[tokio::test] 484 async fn startup_resumes_persisted_chain_without_genesis_flag() { 485 let dir = tempdir().unwrap(); 486 let chain_path = dir.path().join("chain.sqlite3"); 487 let store = SqliteChainStore::open(&chain_path).unwrap(); 488 let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); 489 let persisted = ledger_with_one_mined_block(&persisted_wallet); 490 store.save(&persisted.snapshot()).unwrap(); 491 let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); 492 let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) 493 .unwrap() 494 .unwrap(); 495 496 let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) 497 .await 498 .unwrap(); 499 500 assert_eq!(resumed.status().height, 1); 501 assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); 502 assert_eq!(resumed.genesis_hash(), persisted.genesis_hash()); 503 assert_eq!(resumed.balance_of(fresh_wallet.address()), 0); 504 } 505 506 #[tokio::test] 507 async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() { 508 let dir = tempdir().unwrap(); 509 let chain_path = dir.path().join("chain.sqlite3"); 510 let store = SqliteChainStore::open(&chain_path).unwrap(); 511 let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner"); 512 let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet); 513 let burn = persisted.build_burn(&persisted_wallet, 1, 0).unwrap(); 514 persisted.submit_transaction(burn).unwrap(); 515 let future_tip_ms = iuna::app::now_ms().saturating_add(VDF_TARGET_BLOCK_MS); 516 let future_block = persisted 517 .mine_next_block(&persisted_wallet, future_tip_ms) 518 .unwrap(); 519 let mut snapshot = persisted.snapshot(); 520 snapshot.blocks.push(future_block); 521 assert!( 522 Ledger::from_snapshot(snapshot.clone()) 523 .unwrap_err() 524 .to_string() 525 .contains("too far in the future") 526 ); 527 store.save(&snapshot).unwrap(); 528 let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); 529 let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) 530 .unwrap() 531 .unwrap(); 532 533 let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) 534 .await 535 .unwrap(); 536 537 assert_eq!(resumed.status().height, 1); 538 assert_eq!( 539 resumed.status().tip_hash, 540 snapshot.blocks.last().unwrap().hash 541 ); 542 } 543 544 #[tokio::test] 545 async fn persisted_chain_satisfies_join_mode_without_contacting_peer() { 546 let dir = tempdir().unwrap(); 547 let chain_path = dir.path().join("chain.sqlite3"); 548 let store = SqliteChainStore::open(&chain_path).unwrap(); 549 let alice = Wallet::from_seed("offline-join-alice"); 550 let persisted = ledger_with_one_mined_block(&alice); 551 store.save(&persisted.snapshot()).unwrap(); 552 let bob = Wallet::from_seed("offline-join-bob"); 553 let opts = parse(&[ 554 "--join", 555 "127.0.0.1:1", 556 "--chain-db", 557 chain_path.to_str().unwrap(), 558 ]) 559 .unwrap() 560 .unwrap(); 561 562 let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr) 563 .await 564 .unwrap(); 565 566 assert_eq!(resumed.status().height, 1); 567 assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); 568 } 569 570 #[tokio::test] 571 async fn invalid_persisted_chain_is_reported_and_never_replaced() { 572 let dir = tempdir().unwrap(); 573 let chain_path = dir.path().join("chain.sqlite3"); 574 let store = SqliteChainStore::open(&chain_path).unwrap(); 575 let connection = Connection::open(&chain_path).unwrap(); 576 connection 577 .execute( 578 r#" 579 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) 580 VALUES (1, 4, 'bad-tip', x'00010203', 0) 581 "#, 582 [], 583 ) 584 .unwrap(); 585 let wallet = Wallet::from_seed("bad-db-wallet"); 586 let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) 587 .unwrap() 588 .unwrap(); 589 590 let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr) 591 .await 592 .unwrap_err(); 593 594 assert!( 595 format!("{error:#}").contains("failed to parse compact chain snapshot from database"), 596 "{error:#}" 597 ); 598 } 599 600 #[tokio::test] 601 async fn persistence_loop_saves_new_tip_after_node_changes() { 602 let dir = tempdir().unwrap(); 603 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 604 let wallet = Wallet::from_seed("background-persistence"); 605 let ledger = ledger_with_one_spendable_iuna(&wallet); 606 let node = Arc::new(Mutex::new(NodeCore::from_ledger( 607 wallet.clone(), 608 ledger, 609 DEFAULT_BURN_PER_BLOCK, 610 ))); 611 let initial_snapshot = { node.lock().await.chain_snapshot() }; 612 let initial_tip = initial_snapshot 613 .blocks 614 .last() 615 .map(|block| block.hash.clone()); 616 persist_chain_snapshot(&store, initial_snapshot) 617 .await 618 .unwrap(); 619 let ui_config = Arc::new(Mutex::new(UiConfig::default())); 620 let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); 621 622 let persistence_task = tokio::spawn(run_chain_persistence_with_interval( 623 Arc::clone(&node), 624 store.clone(), 625 ui_data_store.clone(), 626 ui_config, 627 Duration::from_millis(10), 628 initial_tip, 629 )); 630 { 631 let mut node = node.lock().await; 632 let burn = node.ledger().build_burn(&wallet, 1, 0).unwrap(); 633 node.receive_transaction(burn).unwrap(); 634 node.mine_one_at(1_000).unwrap(); 635 } 636 637 let expected_tip = node.lock().await.ledger().status().tip_hash; 638 let mut restored_tip = None; 639 let mut projected_tip = None; 640 for _ in 0..100 { 641 if let Some(snapshot) = store.load().unwrap() { 642 restored_tip = snapshot.blocks.last().map(|block| block.hash.clone()); 643 } 644 projected_tip = Connection::open(ui_data_store.path()) 645 .unwrap() 646 .query_row( 647 "SELECT tip_hash FROM ui_cache_meta WHERE id = 1", 648 [], 649 |row| row.get::<_, String>(0), 650 ) 651 .ok(); 652 if restored_tip.as_deref() == Some(expected_tip.as_str()) 653 && projected_tip.as_deref() == Some(expected_tip.as_str()) 654 { 655 break; 656 } 657 tokio::time::sleep(Duration::from_millis(10)).await; 658 } 659 persistence_task.abort(); 660 661 assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str())); 662 assert_eq!(projected_tip.as_deref(), Some(expected_tip.as_str())); 663 assert!(ui_data_store.load_metrics().unwrap().is_empty()); 664 } 665 666 #[tokio::test] 667 async fn persistence_loop_skips_tip_already_projected_at_startup() { 668 let dir = tempdir().unwrap(); 669 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 670 let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); 671 let wallet = Wallet::from_seed("background-persistence-warmed"); 672 let ledger = ledger_with_one_spendable_iuna(&wallet); 673 let node = Arc::new(Mutex::new(NodeCore::from_ledger( 674 wallet, 675 ledger, 676 DEFAULT_BURN_PER_BLOCK, 677 ))); 678 let initial_snapshot = { node.lock().await.chain_snapshot() }; 679 let initial_tip = initial_snapshot 680 .blocks 681 .last() 682 .map(|block| block.hash.clone()); 683 persist_chain_snapshot(&store, initial_snapshot.clone()) 684 .await 685 .unwrap(); 686 project_ui_data_store(&ui_data_store, initial_snapshot, false) 687 .await 688 .unwrap(); 689 let ui_data_connection = Connection::open(ui_data_store.path()).unwrap(); 690 ui_data_connection 691 .execute( 692 "UPDATE ui_cache_meta SET updated_at_ms = 123 WHERE id = 1", 693 [], 694 ) 695 .unwrap(); 696 drop(ui_data_connection); 697 let ui_config = Arc::new(Mutex::new(UiConfig::default())); 698 699 let persistence_task = tokio::spawn(run_chain_persistence_with_interval( 700 Arc::clone(&node), 701 store, 702 ui_data_store.clone(), 703 ui_config, 704 Duration::from_millis(10), 705 initial_tip, 706 )); 707 tokio::time::sleep(Duration::from_millis(50)).await; 708 persistence_task.abort(); 709 710 let ui_data_connection = Connection::open(ui_data_store.path()).unwrap(); 711 let updated_at_ms: u64 = ui_data_connection 712 .query_row( 713 "SELECT updated_at_ms FROM ui_cache_meta WHERE id = 1", 714 [], 715 |row| row.get(0), 716 ) 717 .unwrap(); 718 assert_eq!(updated_at_ms, 123); 719 } 720 721 #[tokio::test] 722 async fn persistence_loop_skips_setup_placeholder_chain() { 723 let dir = tempdir().unwrap(); 724 let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); 725 let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); 726 let wallet = Wallet::from_seed("background-persistence-setup"); 727 let ledger = Ledger::new(BTreeMap::new(), 1); 728 let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); 729 let ui_config = Arc::new(Mutex::new(UiConfig::default())); 730 731 let persistence_task = tokio::spawn(run_chain_persistence_with_interval( 732 Arc::clone(&node), 733 store.clone(), 734 ui_data_store.clone(), 735 ui_config, 736 Duration::from_millis(10), 737 None, 738 )); 739 tokio::time::sleep(Duration::from_millis(50)).await; 740 persistence_task.abort(); 741 742 assert!(store.load().unwrap().is_none()); 743 assert!(ui_data_store.load_metrics().unwrap().is_empty()); 744 }