iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }