iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

main_tests.rs (59823B)


      1 use std::{collections::BTreeMap, sync::Arc, time::Duration};
      2 
      3 use iuna::{
      4     adapters::{
      5         chain_store::SqliteChainStore,
      6         config_store::{self, UiConfig},
      7         ui_data_store::SqliteUiDataStore,
      8         wallet_store,
      9     },
     10     app::{DEFAULT_BURN_PER_BLOCK, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, NodeCore},
     11     domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet},
     12 };
     13 use rusqlite::Connection;
     14 use tempfile::tempdir;
     15 use tokio::sync::Mutex;
     16 
     17 use super::{
     18     ChainMode, CliOptions, GENESIS_BOOTSTRAP_BALANCE, GENESIS_BOOTSTRAP_BURN_AMOUNT,
     19     GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet,
     20     apply_cli_p2p_config_overrides, apply_cli_stratum_config_overrides,
     21     apply_startup_mining_config_overrides, apply_startup_setup_config_override,
     22     apply_startup_wallet_endpoint_config_overrides, apply_startup_wallet_password_config,
     23     automatic_finalization_precheck_time, configured_p2p_announce_addr, configured_p2p_bind_addr,
     24     configured_stratum_addr, extrapolate_vdf_rounds, help_text, initial_burn_fee,
     25     initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds,
     26     parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value,
     27     persist_chain_snapshot, project_ui_data_store, restore_pending_transactions_v2,
     28     run_chain_persistence_with_interval, setup_ledger, should_defer_sync_checkpoint,
     29     should_log_automatic_finalization_skip, start_genesis_ledger, validate_wallet_for_mode,
     30 };
     31 
     32 fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
     33     CliOptions::parse_from(args.iter().map(|arg| arg.to_string()))
     34 }
     35 
     36 #[test]
     37 fn help_mentions_dev_seed_verify_bypass_env() {
     38     assert!(help_text().contains("IUNA_WALLET_PASSWORD=<password>"));
     39     assert!(help_text().contains("IUNA_POW_MINING_WORKERS=1..32"));
     40     assert!(help_text().contains("IUNA_SETUP_COMPLETE=true|false"));
     41     assert!(help_text().contains("IUNA_AUTOMATIC_BURN_ENABLED=true|false"));
     42     assert!(help_text().contains("IUNA_POW_MINING_ENABLED=true|false"));
     43     assert!(help_text().contains("IUNA_DEV_SKIP_SEED_VERIFY=1"));
     44     assert!(help_text().contains("skip seed verification"));
     45     assert!(help_text().contains("--stratum <addr:port>"));
     46     assert!(help_text().contains("--debug"));
     47 }
     48 
     49 #[test]
     50 fn active_sync_cannot_defer_chain_checkpoint_past_the_interval() {
     51     let interval = Duration::from_secs(30);
     52 
     53     assert!(should_defer_sync_checkpoint(
     54         true,
     55         Duration::from_secs(29),
     56         interval
     57     ));
     58     assert!(!should_defer_sync_checkpoint(true, interval, interval));
     59     assert!(!should_defer_sync_checkpoint(
     60         false,
     61         Duration::ZERO,
     62         interval
     63     ));
     64 }
     65 
     66 #[test]
     67 fn local_testnet_compose_uses_one_obvious_test_password() {
     68     let compose = include_str!("../docker-compose.yml");
     69     assert_eq!(
     70         compose
     71             .matches("IUNA_WALLET_PASSWORD: ${IUNA_TESTNET_PASSWORD:-testtesttest}")
     72             .count(),
     73         6
     74     );
     75     assert!(!compose.contains("IUNA_BOOTSTRAP_WALLET_PASSWORD"));
     76     assert!(!compose.contains("IUNA_NODE2_WALLET_PASSWORD"));
     77     assert!(!compose.contains("IUNA_NODE3_WALLET_PASSWORD"));
     78     assert!(!compose.contains("IUNA_NODE4_WALLET_PASSWORD"));
     79     assert!(!compose.contains("IUNA_NODE5_WALLET_PASSWORD"));
     80     assert!(!compose.contains("IUNA_NODE6_WALLET_PASSWORD"));
     81 }
     82 
     83 #[test]
     84 fn local_testnet_bootstrap_only_uses_genesis_for_an_empty_chain() {
     85     let compose = include_str!("../docker-compose.yml");
     86     assert!(compose.contains("if [ -s /data/chain.sqlite3 ]; then exec iuna --data-dir /data"));
     87     assert!(compose.contains("else exec iuna --genesis --data-dir /data"));
     88 }
     89 
     90 #[test]
     91 fn management_ui_warns_when_estimated_fees_exceed_amounts() {
     92     let html = include_str!("adapters/http/index_html.rs");
     93     let javascript = include_str!("../www/assets/iuna-ui.js");
     94     assert!(html.contains("feeExceedsAmount('transfer')"));
     95     assert!(html.contains("feeExceedsAmount('burn')"));
     96     assert!(javascript.contains("feeExceedsAmount(kind)"));
     97     assert!(javascript.contains("estimated fee IUNA ${this.amountLabel(fee)} exceeds"));
     98 }
     99 
    100 #[test]
    101 fn management_ui_opens_on_the_dashboard_and_exposes_chain_health_thresholds() {
    102     let html = include_str!("adapters/http/index_html.rs");
    103     let javascript = include_str!("../www/assets/iuna-ui.js");
    104     assert!(html.contains("@click=\"setTab('dashboard')\""));
    105     assert!(html.contains("Current balance"));
    106     assert!(html.contains("dashboardLastBlockLabel()"));
    107     assert!(html.contains("dashboardMiningLabel()"));
    108     assert!(html.contains("dashboardBurnLabel()"));
    109     assert!(html.contains(".dashboard-card.burning .dashboard-status"));
    110     assert!(html.contains("basicNetworkStatusLabel()"));
    111     assert!(javascript.contains("tab: \"dashboard\""));
    112     assert!(javascript.contains("ageMs < 15 * 60 * 1000"));
    113     assert!(javascript.contains("ageMs < 20 * 60 * 1000"));
    114     assert!(javascript.contains(
    115         "`${this.powMiningWorkers} worker${this.powMiningWorkers === 1 ? \"\" : \"s\"}`"
    116     ));
    117     assert!(javascript.contains("`IUNA ${this.amountLabel(this.burnAmount)} / blk`"));
    118     let dashboard_network_state = javascript
    119         .split_once("dashboardNetworkState()")
    120         .and_then(|(_, rest)| rest.split_once("\n    },"))
    121         .map(|(body, _)| body)
    122         .expect("dashboard network state helper");
    123     assert!(dashboard_network_state.contains("state === \"stale\""));
    124     assert!(dashboard_network_state.contains("state === \"peer errors\""));
    125     assert!(dashboard_network_state.contains("state === \"banned\""));
    126 }
    127 
    128 #[test]
    129 fn management_ui_exposes_complete_peer_details() {
    130     let html = include_str!("adapters/http/index_html.rs");
    131     let javascript = include_str!("../www/assets/iuna-ui.js");
    132     assert!(html.contains("@click=\"openPeerModal(peer)\""));
    133     assert!(html.contains("Last protocol hello"));
    134     assert!(html.contains("peerDetail().last_hello?.protocol_version"));
    135     assert!(html.contains("peerCapabilities(peerDetail())"));
    136     assert!(html.contains("Health and enforcement"));
    137     assert!(html.contains("x-show=\"peer.country_code\""));
    138     assert!(html.contains("peerDetail().country_code || '-'"));
    139     assert!(javascript.contains("selectedPeerAddress: null"));
    140     assert!(javascript.contains("peerDetail()"));
    141     assert!(javascript.contains("closePeerModal()"));
    142 }
    143 
    144 #[test]
    145 fn management_ui_uses_an_in_app_transfer_confirmation_and_marks_fee_errors() {
    146     let html = include_str!("adapters/http/index_html.rs");
    147     let javascript = include_str!("../www/assets/iuna-ui.js");
    148     assert!(html.contains("Confirm transfer"));
    149     assert!(html.contains("@click=\"confirmTransfer\""));
    150     assert!(html.contains("Transaction size"));
    151     assert!(html.contains("Network fee"));
    152     assert!(!html.contains("Verify recipient"));
    153     assert!(html.contains("error: feeEstimateError('transfer')"));
    154     assert!(javascript.contains("bytes: Number(estimate.bytes)"));
    155     assert!(javascript.contains("fee: this.microiunaAmount(estimate.fee)"));
    156     assert!(javascript.contains("fee_per_byte: transfer.feePerByte"));
    157     assert!(javascript.contains("async confirmTransfer()"));
    158     assert!(!javascript.contains("window.confirm"));
    159 }
    160 
    161 #[test]
    162 fn management_ui_matches_bech32m_contacts_to_canonical_transaction_addresses() {
    163     let javascript = include_str!("../www/assets/iuna-ui.js");
    164     assert!(javascript.contains("canonicalAddressKey(address)"));
    165     assert!(javascript.contains("this.canonicalAddressKey(savedAddress) === canonical"));
    166 }
    167 
    168 #[test]
    169 fn management_ui_opens_contact_editor_from_wallet_addresses() {
    170     let html = include_str!("adapters/http/index_html.rs");
    171     let javascript = include_str!("../www/assets/iuna-ui.js");
    172     assert!(html.contains("wallet-address-link"));
    173     assert!(html.contains("openAddressContact(txFrom(tx))"));
    174     assert!(html.contains("openBurnLeaderRanksModal(selectedBlock)"));
    175     assert!(html.contains("openAddressContact(rank.owner)"));
    176     assert!(javascript.contains("openAddressContact(address)"));
    177     assert!(javascript.contains("contactAddress(address)"));
    178     assert!(javascript.contains("this.addressBookEditingAddress = entry?.address || null"));
    179 }
    180 
    181 #[test]
    182 fn management_ui_prefers_the_live_rotating_receive_address() {
    183     let javascript = include_str!("../www/assets/iuna-ui.js");
    184     let receive_address = javascript
    185         .split_once("receiveAddress()")
    186         .and_then(|(_, rest)| rest.split_once("\n    },"))
    187         .map(|(body, _)| body)
    188         .expect("receive address helper");
    189 
    190     assert!(receive_address.contains(
    191         "return this.status.wallet_receive_address || this.setupWallet.address || \"-\";"
    192     ));
    193     assert!(javascript.contains("await navigator.clipboard.writeText(this.receiveAddress())"));
    194     assert!(javascript.contains("fundedWalletAddresses()"));
    195     assert!(javascript.contains("Funds are held across ${count} wallet address"));
    196     assert!(javascript.contains("openWalletAddressesModal()"));
    197     assert!(!javascript.contains("copyHybridAddress"));
    198 }
    199 
    200 #[test]
    201 fn management_ui_only_offers_wallet_optimization_when_relevant_or_requested() {
    202     let html = include_str!("adapters/http/index_html.rs");
    203     let javascript = include_str!("../www/assets/iuna-ui.js");
    204     assert_eq!(html.matches("@click=\"openOptimizeWallet\"").count(), 2);
    205     assert!(html.contains("x-show=\"showOptimizeSuggestion()\""));
    206     assert!(javascript.contains("this.walletUtxoPage.total < 500"));
    207     assert!(html.contains("id=\"wallet-utxos-title\""));
    208 }
    209 
    210 #[test]
    211 fn management_ui_blocks_interaction_while_the_node_is_syncing() {
    212     let html = include_str!("adapters/http/index_html.rs");
    213     let javascript = include_str!("../www/assets/iuna-ui.js");
    214     assert!(html.contains(":inert=\"syncingNode()\""));
    215     assert!(html.contains("Synchronizing blockchain"));
    216     assert!(html.contains("syncProgressPercent()"));
    217     assert!(javascript.contains("this.networkHealth.state === \"syncing\""));
    218     assert!(javascript.contains("this.networkHealth.sync_validated_height"));
    219     assert!(javascript.contains("this.syncingNode() ? 1000 : 5000"));
    220     assert!(javascript.contains("Syncing ${this.syncCurrentHeight().toLocaleString()} of"));
    221     assert!(javascript.contains("!this.showingNetworkMigration()"));
    222     assert!(javascript.contains("!this.chainResetModalOpen"));
    223     assert!(html.contains("Sync stuck? Reset local chain"));
    224     assert!(html.contains("@click=\"openChainResetModal\""));
    225 }
    226 
    227 #[test]
    228 fn management_ui_block_reward_opens_a_fee_flow_transaction() {
    229     let html = include_str!("adapters/http/index_html.rs");
    230     let javascript = include_str!("../www/assets/iuna-ui.js");
    231     assert!(html.contains("@click=\"openBlockRewardModal(selectedBlock)\""));
    232     assert!(html.contains("input.rewardFee ? `Paid fee ${index + 1}`"));
    233     assert!(javascript.contains("blockRewardTransaction(block)"));
    234     assert!(javascript.contains("blockRewardOutputs(block)"));
    235     assert!(javascript.contains("label: \"Finalizer reward\""));
    236     assert!(javascript.contains("label: `Committee reward (slot ${bundle.slot})`"));
    237 }
    238 
    239 #[test]
    240 fn management_ui_wallet_transactions_share_the_generic_detail_flow() {
    241     let html = include_str!("adapters/http/index_html.rs");
    242     let javascript = include_str!("../www/assets/iuna-ui.js");
    243     assert!(html.contains("openTransactionModal(tx, { source: 'Wallet' })"));
    244     assert!(javascript.contains("Array.isArray(tx.rewardFeeInputs)"));
    245     assert!(javascript.contains("Array.isArray(tx.rewardOutputs)"));
    246     assert!(javascript.contains("outpoint: { txid: input.signature, index: \"fee\" }"));
    247 }
    248 
    249 #[test]
    250 fn transaction_filters_default_to_every_transaction_type() {
    251     let management_javascript = include_str!("../www/assets/iuna-ui.js");
    252     let wallet_javascript = include_str!("../wallet/app.js");
    253     let all_filters = "{ transfer: true, mine: true, burn: true, reward: true }";
    254 
    255     assert!(management_javascript.contains(&format!("walletTxFilters: {all_filters}")));
    256     assert!(wallet_javascript.contains(&format!("const ALL_TRANSACTION_FILTERS = {all_filters}")));
    257 }
    258 
    259 #[test]
    260 fn management_ui_uses_the_native_desktop_updater_when_available() {
    261     let html = include_str!("adapters/http/index_html.rs");
    262     let javascript = include_str!("../www/assets/iuna-ui.js");
    263     let tauri_build = include_str!("../src-tauri/build.rs");
    264     let capability = include_str!("../src-tauri/capabilities/default.json");
    265     let check_permission =
    266         include_str!("../src-tauri/permissions/autogenerated/check_desktop_update.toml");
    267     let install_permission =
    268         include_str!("../src-tauri/permissions/autogenerated/install_desktop_update.toml");
    269 
    270     assert!(javascript.contains("invoke(\"check_desktop_update\")"));
    271     assert!(javascript.contains("invoke(\"install_desktop_update\")"));
    272     assert!(tauri_build.contains("\"check_desktop_update\", \"install_desktop_update\""));
    273     assert!(capability.contains("\"allow-check-desktop-update\""));
    274     assert!(capability.contains("\"allow-install-desktop-update\""));
    275     assert!(check_permission.contains("commands.allow = [\"check_desktop_update\"]"));
    276     assert!(install_permission.contains("commands.allow = [\"install_desktop_update\"]"));
    277     assert!(html.contains("Install and restart"));
    278     assert!(!javascript.contains("window.confirm"));
    279 }
    280 
    281 #[test]
    282 fn lightweight_wallet_recent_activity_does_not_use_view_all_filters() {
    283     let javascript = include_str!("../wallet/app.js");
    284 
    285     assert!(javascript.contains("fetchTransactions(0, 5, ALL_TRANSACTION_FILTERS)"));
    286     assert!(javascript.contains("activityList(5, false, state.recentTransactions)"));
    287 }
    288 
    289 fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger {
    290     let mut genesis = BTreeMap::new();
    291     genesis.insert(wallet.address().to_string(), 3);
    292     Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1).unwrap()
    293 }
    294 
    295 fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger {
    296     let mut ledger = ledger_with_one_spendable_iuna(wallet);
    297     let burn = ledger.build_burn(wallet, 1, 1).unwrap();
    298     ledger.submit_transaction(burn).unwrap();
    299     let block = ledger.mine_next_block(wallet, 1_000).unwrap();
    300     ledger.apply_locally_mined_block(block).unwrap();
    301     ledger
    302 }
    303 
    304 fn promoted_candidate_ledger() -> (Ledger, Vec<Wallet>, String) {
    305     let wallets = [
    306         Wallet::from_seed("promotion-candidate-alice"),
    307         Wallet::from_seed("promotion-candidate-bob"),
    308         Wallet::from_seed("promotion-candidate-carol"),
    309     ];
    310     let mut allocations = BTreeMap::new();
    311     for wallet in &wallets {
    312         allocations.insert(wallet.address().to_string(), 20 * MICRO_IUNA);
    313     }
    314     let mut ledger = Ledger::new_with_genesis_burns(
    315         allocations,
    316         wallets
    317             .iter()
    318             .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
    319             .collect(),
    320         1,
    321     )
    322     .unwrap();
    323     let finalizer_owner = ledger
    324         .burn_leader_ranks_for_block(ledger.height() + 1)
    325         .unwrap()
    326         .first()
    327         .unwrap()
    328         .owner
    329         .clone();
    330     let finalizer = wallets
    331         .iter()
    332         .find(|wallet| wallet.address() == finalizer_owner)
    333         .unwrap();
    334     let sender = wallets
    335         .iter()
    336         .find(|wallet| wallet.address() != finalizer.address())
    337         .unwrap();
    338     let recipient = wallets
    339         .iter()
    340         .find(|wallet| {
    341             wallet.address() != finalizer.address() && wallet.address() != sender.address()
    342         })
    343         .unwrap();
    344     let transfer = ledger
    345         .build_transfer(sender, recipient.address(), MICRO_IUNA, MICRO_IUNA)
    346         .unwrap();
    347     let transfer_signature = transfer.signature().to_string();
    348     ledger.submit_transaction(transfer).unwrap();
    349     let anchor = ledger
    350         .build_burn(finalizer, MICRO_IUNA, MICRO_IUNA)
    351         .unwrap();
    352     ledger.submit_transaction(anchor).unwrap();
    353     let block = ledger
    354         .mine_next_block(finalizer, VDF_TARGET_BLOCK_MS)
    355         .unwrap();
    356     ledger.apply_locally_mined_block(block).unwrap();
    357     assert!(
    358         ledger.has_transaction(&transfer_signature),
    359         "candidate rehearsal chain should contain a transfer before promotion"
    360     );
    361 
    362     (ledger, wallets.to_vec(), transfer_signature)
    363 }
    364 
    365 #[test]
    366 fn encrypted_startup_wallet_loads_as_locked_metadata() {
    367     let dir = tempdir().unwrap();
    368     let path = dir.path().join("wallet.json");
    369     let (wallet, _) =
    370         wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456")
    371             .unwrap();
    372 
    373     let startup = load_startup_wallet(&path, None).unwrap();
    374 
    375     match startup {
    376         StartupWallet::Locked { address } => assert_eq!(address, wallet.address()),
    377         StartupWallet::Unlocked { .. } => panic!("encrypted wallet should start locked"),
    378     }
    379 }
    380 
    381 #[test]
    382 fn startup_wallet_password_encrypts_new_wallet_and_configures_auth() {
    383     let dir = tempdir().unwrap();
    384     let wallet_path = dir.path().join("wallet.json");
    385     let config_path = dir.path().join("config.json");
    386     let password = "local-testnet-password";
    387     let mut config = UiConfig::default();
    388 
    389     let dirty =
    390         apply_startup_wallet_password_config(&config_path, &mut config, Some(password)).unwrap();
    391     config_store::save(&config_path, &config).unwrap();
    392     let startup = load_startup_wallet(&wallet_path, Some(password)).unwrap();
    393 
    394     assert!(dirty);
    395     assert!(config.auth_password_hash.is_some());
    396     assert!(
    397         wallet_store::metadata(&wallet_path)
    398             .unwrap()
    399             .unwrap()
    400             .encrypted
    401     );
    402     match startup {
    403         StartupWallet::Unlocked { wallet } => {
    404             let reloaded = wallet_store::load_with_password(&wallet_path, password).unwrap();
    405             assert_eq!(reloaded.address(), wallet.address());
    406         }
    407         StartupWallet::Locked { .. } => panic!("env password should unlock startup wallet"),
    408     }
    409 }
    410 
    411 #[test]
    412 fn startup_wallet_password_unlocks_existing_encrypted_wallet() {
    413     let dir = tempdir().unwrap();
    414     let wallet_path = dir.path().join("wallet.json");
    415     let password = "local-testnet-password";
    416     let (wallet, _) =
    417         wallet_store::replace_with_generated_seed_phrase_encrypted(&wallet_path, password).unwrap();
    418 
    419     let startup = load_startup_wallet(&wallet_path, Some(password)).unwrap();
    420 
    421     match startup {
    422         StartupWallet::Unlocked { wallet: startup } => {
    423             assert_eq!(startup.address(), wallet.address());
    424         }
    425         StartupWallet::Locked { .. } => panic!("env password should unlock encrypted wallet"),
    426     }
    427 }
    428 
    429 #[test]
    430 fn startup_wallet_password_mismatch_does_not_encrypt_plaintext_wallet() {
    431     let dir = tempdir().unwrap();
    432     let wallet_path = dir.path().join("wallet.json");
    433     let config_path = dir.path().join("config.json");
    434     let (_wallet, _seed) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap();
    435     let mut config = UiConfig {
    436         auth_password_hash: Some(
    437             iuna::adapters::http::hash_management_password("correct-password").unwrap(),
    438         ),
    439         ..UiConfig::default()
    440     };
    441 
    442     let error =
    443         apply_startup_wallet_password_config(&config_path, &mut config, Some("wrong-password"))
    444             .unwrap_err();
    445 
    446     assert!(error.to_string().contains("does not match"));
    447     assert!(
    448         !wallet_store::metadata(&wallet_path)
    449             .unwrap()
    450             .unwrap()
    451             .encrypted
    452     );
    453     assert!(wallet_store::load_or_create(&wallet_path).is_ok());
    454 }
    455 
    456 #[test]
    457 fn startup_mining_env_overrides_persisted_config() {
    458     let mut config = UiConfig {
    459         mining_enabled: false,
    460         pow_mining_enabled: true,
    461         ..UiConfig::default()
    462     };
    463 
    464     assert!(apply_startup_mining_config_overrides(
    465         &mut config,
    466         Some(true),
    467         Some(false),
    468         Some(1),
    469     ));
    470     assert!(config.mining_enabled);
    471     assert!(!config.pow_mining_enabled);
    472     assert_eq!(config.pow_mining_workers, 1);
    473 
    474     assert!(!apply_startup_mining_config_overrides(
    475         &mut config,
    476         Some(true),
    477         Some(false),
    478         Some(1),
    479     ));
    480 }
    481 
    482 #[test]
    483 fn startup_mining_env_can_override_genesis_defaults() {
    484     let mut config = UiConfig {
    485         mining_enabled: true,
    486         pow_mining_enabled: false,
    487         burn_per_block: GENESIS_INITIAL_BURN_PER_BLOCK,
    488         burn_fee: GENESIS_INITIAL_BURN_FEE,
    489         ..UiConfig::default()
    490     };
    491 
    492     assert!(apply_startup_mining_config_overrides(
    493         &mut config,
    494         Some(false),
    495         Some(true),
    496         Some(1),
    497     ));
    498     assert!(!config.mining_enabled);
    499     assert!(config.pow_mining_enabled);
    500     assert_eq!(config.burn_per_block, GENESIS_INITIAL_BURN_PER_BLOCK);
    501     assert_eq!(config.burn_fee, GENESIS_INITIAL_BURN_FEE);
    502 }
    503 
    504 #[test]
    505 fn startup_bool_env_parser_accepts_common_boolean_values() {
    506     for value in ["1", "true", "yes", "on"] {
    507         assert!(parse_startup_bool_env_value("TEST_BOOL", value).unwrap());
    508     }
    509     for value in ["0", "false", "no", "off"] {
    510         assert!(!parse_startup_bool_env_value("TEST_BOOL", value).unwrap());
    511     }
    512     assert!(parse_startup_bool_env_value("TEST_BOOL", "maybe").is_err());
    513 }
    514 
    515 #[test]
    516 fn startup_pow_worker_parser_enforces_supported_range() {
    517     assert_eq!(parse_startup_pow_mining_workers_env_value("1").unwrap(), 1);
    518     assert_eq!(
    519         parse_startup_pow_mining_workers_env_value("32").unwrap(),
    520         32
    521     );
    522     assert!(parse_startup_pow_mining_workers_env_value("0").is_err());
    523     assert!(parse_startup_pow_mining_workers_env_value("33").is_err());
    524     assert!(parse_startup_pow_mining_workers_env_value("many").is_err());
    525 }
    526 
    527 #[test]
    528 fn startup_setup_complete_env_marks_join_and_genesis_nodes_ready() {
    529     let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
    530     let mut join_config = UiConfig::default();
    531     assert!(
    532         apply_startup_setup_config_override(&join, false, &mut join_config, Some(true)).unwrap()
    533     );
    534     assert!(join_config.setup_complete);
    535 
    536     let genesis = parse(&["--genesis"]).unwrap().unwrap();
    537     let mut genesis_config = UiConfig {
    538         setup_complete: false,
    539         mining_enabled: true,
    540         pow_mining_enabled: false,
    541         burn_per_block: GENESIS_INITIAL_BURN_PER_BLOCK,
    542         burn_fee: GENESIS_INITIAL_BURN_FEE,
    543         ..UiConfig::default()
    544     };
    545     assert!(
    546         apply_startup_setup_config_override(&genesis, false, &mut genesis_config, Some(true))
    547             .unwrap()
    548     );
    549     assert!(genesis_config.setup_complete);
    550 }
    551 
    552 #[test]
    553 fn startup_setup_complete_env_rejects_empty_setup_node() {
    554     let setup = parse(&[]).unwrap().unwrap();
    555     let mut config = UiConfig::default();
    556     let error =
    557         apply_startup_setup_config_override(&setup, false, &mut config, Some(true)).unwrap_err();
    558 
    559     assert!(error.to_string().contains("requires --genesis, --join"));
    560     assert!(!config.setup_complete);
    561 }
    562 
    563 #[test]
    564 fn no_args_starts_setup_mode() {
    565     let opts = parse(&[]).unwrap().unwrap();
    566     assert_eq!(opts.chain_mode, ChainMode::Setup);
    567     assert!(opts.join_peers.is_empty());
    568 }
    569 
    570 #[test]
    571 fn setup_ledger_uses_the_requested_address_network_profile() {
    572     assert_eq!(
    573         setup_ledger(false).launch_profile().profile_id,
    574         "iuna-mainnet-candidate"
    575     );
    576     assert_eq!(
    577         setup_ledger(true).launch_profile().profile_id,
    578         if cfg!(feature = "e2e") {
    579             "iuna-local-e2e-5s-v1"
    580         } else {
    581             "iuna-local-testnet-v1"
    582         }
    583     );
    584 }
    585 
    586 #[test]
    587 fn stratum_port_can_be_configured() {
    588     let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap();
    589     assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap()));
    590 }
    591 
    592 #[test]
    593 fn wallet_endpoint_uses_a_separate_configurable_listener() {
    594     let opts = parse(&["--wallet-endpoint", "0.0.0.0:28662"])
    595         .unwrap()
    596         .unwrap();
    597     assert_eq!(
    598         opts.wallet_endpoint_addr,
    599         Some("0.0.0.0:28662".parse().unwrap())
    600     );
    601     assert!(help_text().contains("--wallet-endpoint <addr:port>"));
    602     assert!(help_text().contains("IUNA_WALLET_ENDPOINT_PORT"));
    603 }
    604 
    605 #[test]
    606 fn wallet_endpoint_rejects_zero_port() {
    607     let error = parse(&["--wallet-endpoint", "0.0.0.0:0"]).unwrap_err();
    608     assert!(error.to_string().contains("between 1 and 65535"));
    609 }
    610 
    611 #[test]
    612 fn wallet_endpoint_environment_settings_are_persistable() {
    613     let mut config = UiConfig::default();
    614     assert!(apply_startup_wallet_endpoint_config_overrides(
    615         &mut config,
    616         Some(true),
    617         Some(28662),
    618     ));
    619     assert!(config.wallet_endpoint_enabled);
    620     assert_eq!(config.wallet_endpoint_bind_port, 28662);
    621     assert!(!apply_startup_wallet_endpoint_config_overrides(
    622         &mut config,
    623         Some(true),
    624         Some(28662),
    625     ));
    626 }
    627 
    628 #[test]
    629 fn configured_stratum_addr_uses_settings_when_enabled() {
    630     let opts = parse(&[]).unwrap().unwrap();
    631     let config = UiConfig {
    632         stratum_enabled: true,
    633         stratum_bind_port: 3334,
    634         ..UiConfig::default()
    635     };
    636 
    637     assert_eq!(
    638         configured_stratum_addr(&opts, &config).unwrap().to_string(),
    639         "0.0.0.0:3334"
    640     );
    641 }
    642 
    643 #[test]
    644 fn configured_stratum_addr_prefers_cli_addr() {
    645     let opts = parse(&["--stratum", "127.0.0.1:3335"]).unwrap().unwrap();
    646     let config = UiConfig {
    647         stratum_enabled: true,
    648         stratum_bind_port: 3334,
    649         ..UiConfig::default()
    650     };
    651 
    652     assert_eq!(
    653         configured_stratum_addr(&opts, &config).unwrap().to_string(),
    654         "127.0.0.1:3335"
    655     );
    656 }
    657 
    658 #[test]
    659 fn debug_logging_can_be_enabled() {
    660     assert!(!parse(&[]).unwrap().unwrap().debug);
    661     assert!(parse(&["--debug"]).unwrap().unwrap().debug);
    662 }
    663 
    664 #[test]
    665 fn automatic_pow_worker_searches_outside_node_lock() {
    666     let main_rs = include_str!("main.rs");
    667     let worker = main_rs
    668         .split("async fn run_automatic_pow_miner")
    669         .nth(1)
    670         .expect("automatic PoW worker should exist");
    671 
    672     assert!(worker.contains("prepare_automatic_pow_mining_job"));
    673     assert!(worker.contains("tokio::task::spawn_blocking"));
    674     assert!(worker.contains("finish_automatic_pow_mining_job"));
    675     assert!(!worker.contains("prepare_automatic_pow_mining()"));
    676 }
    677 
    678 #[test]
    679 fn automatic_finalization_skip_logging_only_reports_state_changes() {
    680     let mut last = None;
    681 
    682     assert!(should_log_automatic_finalization_skip(
    683         &mut last,
    684         54,
    685         "skipped before VDF: block timestamp is too far in the future",
    686     ));
    687     assert!(!should_log_automatic_finalization_skip(
    688         &mut last,
    689         54,
    690         "skipped before VDF: block timestamp is too far in the future",
    691     ));
    692     assert!(should_log_automatic_finalization_skip(
    693         &mut last,
    694         55,
    695         "skipped before VDF: block timestamp is too far in the future",
    696     ));
    697     assert!(should_log_automatic_finalization_skip(
    698         &mut last,
    699         55,
    700         "waiting for burn bundles",
    701     ));
    702 }
    703 
    704 #[test]
    705 fn automatic_finalization_prechecks_future_fallback_at_its_publish_time() {
    706     let now_ms = 1_000;
    707     let publish_at_ms = now_ms + 20 * 60 * 1_000;
    708 
    709     assert_eq!(
    710         automatic_finalization_precheck_time(now_ms, publish_at_ms),
    711         publish_at_ms
    712     );
    713     assert_eq!(
    714         automatic_finalization_precheck_time(publish_at_ms, now_ms),
    715         publish_at_ms
    716     );
    717 }
    718 
    719 #[test]
    720 fn removed_wallet_seed_is_rejected() {
    721     let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err();
    722     assert!(error.to_string().contains("--wallet-seed was removed"));
    723 }
    724 
    725 #[test]
    726 fn runtime_configuration_flags_are_rejected() {
    727     for flag in [
    728         "--start",
    729         "--name",
    730         "--burn-per-block",
    731         "--peer",
    732         "--genesis-amount",
    733         "--vdf-rounds",
    734     ] {
    735         let error = parse(&["--genesis", flag, "value"]).unwrap_err();
    736         assert!(
    737             error.to_string().contains("unknown argument"),
    738             "{flag} should not be accepted"
    739         );
    740     }
    741 }
    742 
    743 #[test]
    744 fn genesis_mode_is_explicit() {
    745     let opts = parse(&["--genesis"]).unwrap().unwrap();
    746     assert_eq!(opts.chain_mode, ChainMode::Genesis);
    747     assert!(opts.join_peers.is_empty());
    748 }
    749 
    750 #[test]
    751 fn candidate_genesis_mode_cannot_create_a_second_network() {
    752     let wallet = Wallet::from_seed("second-candidate-genesis");
    753     let error = start_genesis_ledger(wallet.address(), false).unwrap_err();
    754 
    755     assert!(error.to_string().contains("genesis is pinned"));
    756     assert!(error.to_string().contains("use --join"));
    757 }
    758 
    759 #[test]
    760 fn join_mode_does_not_start_new_chain() {
    761     let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
    762     assert_eq!(opts.chain_mode, ChainMode::Join);
    763     assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]);
    764 }
    765 
    766 #[test]
    767 fn genesis_mode_starts_with_default_burn_rate_and_fee() {
    768     let genesis = parse(&["--genesis"]).unwrap().unwrap();
    769     let configured = UiConfig {
    770         burn_per_block: 50,
    771         burn_fee: 3,
    772         ..UiConfig::default()
    773     };
    774     assert_eq!(
    775         initial_burn_per_block(&genesis, &configured),
    776         UiConfig::default().burn_per_block
    777     );
    778     assert_eq!(
    779         initial_burn_fee(&genesis, &configured),
    780         UiConfig::default().burn_fee
    781     );
    782 }
    783 
    784 #[test]
    785 fn genesis_bootstrap_allocation_is_fully_burned_before_reward() {
    786     let wallet = Wallet::from_seed("fully-burned-genesis-bootstrap");
    787     let allocations = BTreeMap::from([(wallet.address().to_string(), GENESIS_BOOTSTRAP_BALANCE)]);
    788     let ledger = Ledger::new_with_genesis_burns(
    789         allocations,
    790         vec![GenesisBurn::new(
    791             wallet.address(),
    792             GENESIS_BOOTSTRAP_BURN_AMOUNT,
    793         )],
    794         1,
    795     )
    796     .unwrap();
    797 
    798     assert_eq!(GENESIS_BOOTSTRAP_BALANCE, GENESIS_BOOTSTRAP_BURN_AMOUNT);
    799     assert_eq!(ledger.balance_of(wallet.address()), BLOCK_REWARD);
    800 }
    801 
    802 #[test]
    803 fn genesis_default_auto_mining_keeps_burning_after_first_block() {
    804     let wallet = Wallet::from_seed("genesis-auto-burn-wallet");
    805     let mut genesis = BTreeMap::new();
    806     genesis.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    807     let ledger = Ledger::new_with_genesis_burns(
    808         genesis,
    809         vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    810         1,
    811     )
    812     .unwrap();
    813     let mut node = NodeCore::from_ledger_with_burn_fee(
    814         wallet.clone(),
    815         ledger,
    816         GENESIS_INITIAL_BURN_PER_BLOCK,
    817         GENESIS_INITIAL_BURN_FEE,
    818     );
    819 
    820     let first = node.automatic_mine_once(1_000);
    821     let second = node.automatic_mine_once(2_000);
    822     let third = node.automatic_mine_once(3_000);
    823 
    824     assert_eq!(
    825         first.burned.as_ref().map(|tx| tx.amount()),
    826         Some(GENESIS_INITIAL_BURN_PER_BLOCK)
    827     );
    828     assert!(first.block.is_some(), "{first:?}");
    829     assert_eq!(
    830         second.burned.as_ref().map(|tx| tx.amount()),
    831         Some(GENESIS_INITIAL_BURN_PER_BLOCK)
    832     );
    833     assert!(second.block.is_some(), "{second:?}");
    834     assert_eq!(
    835         third.burned.as_ref().map(|tx| tx.amount()),
    836         Some(GENESIS_INITIAL_BURN_PER_BLOCK)
    837     );
    838     assert!(third.block.is_some(), "{third:?}");
    839     assert!(
    840         second.skipped_reason.as_deref().is_none_or(|reason| {
    841             !reason.contains("block must include at least one burn transaction")
    842         }),
    843         "{second:?}"
    844     );
    845     assert!(
    846         node.ledger().balance_of(wallet.address())
    847             >= BLOCK_REWARD
    848                 .saturating_sub(3 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE))
    849     );
    850 }
    851 
    852 #[test]
    853 fn non_genesis_modes_start_with_configured_burn_rate_and_fee() {
    854     let configured = UiConfig {
    855         burn_per_block: 50,
    856         burn_fee: 3,
    857         ..UiConfig::default()
    858     };
    859 
    860     let setup = parse(&[]).unwrap().unwrap();
    861     assert_eq!(initial_burn_per_block(&setup, &configured), 50);
    862     assert_eq!(initial_burn_fee(&setup, &configured), 3);
    863 
    864     let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
    865     assert_eq!(initial_burn_per_block(&join, &configured), 50);
    866     assert_eq!(initial_burn_fee(&join, &configured), 3);
    867 
    868     assert_eq!(
    869         initial_burn_per_block(&setup, &UiConfig::default()),
    870         UiConfig::default().burn_per_block
    871     );
    872     assert_eq!(
    873         initial_burn_fee(&setup, &UiConfig::default()),
    874         UiConfig::default().burn_fee
    875     );
    876 }
    877 
    878 #[test]
    879 fn genesis_and_join_are_exclusive() {
    880     let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err();
    881     assert!(
    882         error
    883             .to_string()
    884             .contains("choose either --genesis or --join")
    885     );
    886 
    887     let error = parse(&["--join", "127.0.0.1:9444", "--genesis"]).unwrap_err();
    888     assert!(
    889         error
    890             .to_string()
    891             .contains("choose either --genesis or --join")
    892     );
    893 }
    894 
    895 #[test]
    896 fn http_management_port_can_be_configured() {
    897     let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"])
    898         .unwrap()
    899         .unwrap();
    900     assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443");
    901 }
    902 
    903 #[test]
    904 fn p2p_announce_port_can_be_configured() {
    905     let opts = parse(&["--p2p-announce", "203.0.113.10:9444"])
    906         .unwrap()
    907         .unwrap();
    908 
    909     assert_eq!(
    910         opts.p2p_announce_addr,
    911         Some("203.0.113.10:9444".parse().unwrap())
    912     );
    913 }
    914 
    915 #[test]
    916 fn configured_p2p_announce_addr_uses_cli_before_config() {
    917     let opts = parse(&["--p2p-announce", "203.0.113.20:9444"])
    918         .unwrap()
    919         .unwrap();
    920     let config = UiConfig {
    921         p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
    922         ..UiConfig::default()
    923     };
    924 
    925     assert_eq!(
    926         configured_p2p_announce_addr(&opts, &config).unwrap(),
    927         Some("203.0.113.20:9444".parse().unwrap())
    928     );
    929 }
    930 
    931 #[test]
    932 fn configured_p2p_announce_addr_reads_config_without_cli() {
    933     let opts = parse(&[]).unwrap().unwrap();
    934     let config = UiConfig {
    935         p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
    936         ..UiConfig::default()
    937     };
    938 
    939     assert_eq!(
    940         configured_p2p_announce_addr(&opts, &config).unwrap(),
    941         Some("203.0.113.10:9444".parse().unwrap())
    942     );
    943 }
    944 
    945 #[test]
    946 fn configured_p2p_announce_addr_rejects_invalid_config() {
    947     let opts = parse(&[]).unwrap().unwrap();
    948     let config = UiConfig {
    949         p2p_announce_addr: Some("not-an-address".to_string()),
    950         ..UiConfig::default()
    951     };
    952 
    953     let error = configured_p2p_announce_addr(&opts, &config).unwrap_err();
    954     assert!(
    955         error
    956             .to_string()
    957             .contains("invalid configured P2P announce address")
    958     );
    959 }
    960 
    961 #[test]
    962 fn configured_p2p_bind_addr_uses_configured_public_port() {
    963     let opts = parse(&[]).unwrap().unwrap();
    964     let config = UiConfig {
    965         p2p_accept_inbound: true,
    966         p2p_bind_port: 9555,
    967         ..UiConfig::default()
    968     };
    969 
    970     assert_eq!(
    971         configured_p2p_bind_addr(&opts, &config).to_string(),
    972         "0.0.0.0:9555"
    973     );
    974 }
    975 
    976 #[test]
    977 fn configured_p2p_bind_addr_uses_cli_port_after_config_override() {
    978     let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap();
    979     let config = UiConfig {
    980         p2p_accept_inbound: true,
    981         p2p_bind_port: 9555,
    982         ..UiConfig::default()
    983     };
    984 
    985     assert_eq!(
    986         configured_p2p_bind_addr(&opts, &config).to_string(),
    987         "0.0.0.0:9555"
    988     );
    989 }
    990 
    991 #[test]
    992 fn cli_p2p_port_overrides_config_bind_port() {
    993     let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap();
    994     let mut config = UiConfig {
    995         p2p_accept_inbound: true,
    996         p2p_bind_port: 9444,
    997         ..UiConfig::default()
    998     };
    999 
   1000     assert!(apply_cli_p2p_config_overrides(&opts, &mut config));
   1001     assert_eq!(config.p2p_bind_port, 9555);
   1002 }
   1003 
   1004 #[test]
   1005 fn cli_stratum_addr_enables_and_persists_config_port() {
   1006     let opts = parse(&["--stratum", "127.0.0.1:3335"]).unwrap().unwrap();
   1007     let mut config = UiConfig::default();
   1008 
   1009     assert!(apply_cli_stratum_config_overrides(&opts, &mut config));
   1010     assert!(config.stratum_enabled);
   1011     assert_eq!(config.stratum_bind_port, 3335);
   1012 }
   1013 
   1014 #[test]
   1015 fn http_management_port_defaults_to_iuna_port() {
   1016     let opts = parse(&[]).unwrap().unwrap();
   1017     assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661");
   1018 }
   1019 
   1020 #[test]
   1021 fn data_dir_defaults_under_home() {
   1022     let opts = parse(&[]).unwrap().unwrap();
   1023     assert_eq!(opts.data_dir, super::default_data_dir());
   1024     assert!(opts.data_dir.ends_with(".iuna"));
   1025 }
   1026 
   1027 #[test]
   1028 fn wallet_defaults_under_data_dir() {
   1029     let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
   1030         .unwrap()
   1031         .unwrap();
   1032     assert_eq!(
   1033         opts.wallet_path(),
   1034         std::path::PathBuf::from("tmp-node/wallet.json")
   1035     );
   1036 }
   1037 
   1038 #[test]
   1039 fn chain_db_defaults_under_data_dir() {
   1040     let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
   1041         .unwrap()
   1042         .unwrap();
   1043     assert_eq!(
   1044         opts.chain_db_path(),
   1045         std::path::PathBuf::from("tmp-node/chain.sqlite3")
   1046     );
   1047 }
   1048 
   1049 #[test]
   1050 fn config_defaults_under_data_dir() {
   1051     let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
   1052         .unwrap()
   1053         .unwrap();
   1054     assert_eq!(
   1055         opts.config_path(),
   1056         std::path::PathBuf::from("tmp-node/config.json")
   1057     );
   1058 }
   1059 
   1060 #[test]
   1061 fn wallet_path_can_be_explicit() {
   1062     let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"])
   1063         .unwrap()
   1064         .unwrap();
   1065     assert_eq!(
   1066         opts.wallet_path(),
   1067         std::path::PathBuf::from("alice-wallet.json")
   1068     );
   1069 }
   1070 
   1071 #[test]
   1072 fn chain_db_path_can_be_explicit() {
   1073     let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"])
   1074         .unwrap()
   1075         .unwrap();
   1076     assert_eq!(
   1077         opts.chain_db_path(),
   1078         std::path::PathBuf::from("alice-chain.sqlite3")
   1079     );
   1080 }
   1081 
   1082 #[test]
   1083 fn genesis_requires_fresh_wallet_path() {
   1084     let opts = parse(&["--genesis"]).unwrap().unwrap();
   1085     let wallet_path = std::path::Path::new("wallet.json");
   1086 
   1087     validate_wallet_for_mode(&opts, wallet_path, false).unwrap();
   1088     let error = validate_wallet_for_mode(&opts, wallet_path, true).unwrap_err();
   1089     assert!(error.to_string().contains("requires a fresh wallet path"));
   1090 
   1091     let setup = parse(&[]).unwrap().unwrap();
   1092     validate_wallet_for_mode(&setup, wallet_path, true).unwrap();
   1093 }
   1094 
   1095 #[test]
   1096 fn vdf_measurement_extrapolates_to_target() {
   1097     let rounds_per_target = 10_000_u64.saturating_mul(VDF_TARGET_BLOCK_MS) / 1_000;
   1098     let rounds_from_zero_elapsed = 10_000_u64
   1099         .saturating_mul(VDF_TARGET_BLOCK_MS)
   1100         .saturating_mul(1_000_000);
   1101     assert_eq!(
   1102         extrapolate_vdf_rounds(
   1103             10_000,
   1104             Duration::from_secs(1),
   1105             Duration::from_millis(VDF_TARGET_BLOCK_MS),
   1106         ),
   1107         rounds_per_target
   1108     );
   1109     assert_eq!(
   1110         extrapolate_vdf_rounds(
   1111             10_000,
   1112             Duration::from_secs(0),
   1113             Duration::from_millis(VDF_TARGET_BLOCK_MS),
   1114         ),
   1115         rounds_from_zero_elapsed
   1116     );
   1117 }
   1118 
   1119 #[test]
   1120 fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() {
   1121     let min_elapsed = Duration::from_millis(1);
   1122     let max_rounds = 1_000_000;
   1123     let (rounds, elapsed) =
   1124         measure_vdf_rounds("iuna-test-vdf-calibration", 1, min_elapsed, max_rounds);
   1125 
   1126     assert!(rounds >= 1);
   1127     assert!(elapsed >= min_elapsed || rounds >= max_rounds);
   1128     assert!(elapsed > Duration::ZERO);
   1129 }
   1130 
   1131 #[tokio::test]
   1132 async fn genesis_refuses_to_start_when_chain_database_exists() {
   1133     let dir = tempdir().unwrap();
   1134     let chain_path = dir.path().join("chain.sqlite3");
   1135     let store = SqliteChainStore::open(&chain_path).unwrap();
   1136     let persisted_wallet = Wallet::from_seed("persisted-chain-owner");
   1137     let persisted = ledger_with_one_mined_block(&persisted_wallet);
   1138     store.save(&persisted.snapshot()).unwrap();
   1139     let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
   1140     let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
   1141         .unwrap()
   1142         .unwrap();
   1143 
   1144     let error = initialize_ledger(
   1145         &opts,
   1146         fresh_wallet.address(),
   1147         &store,
   1148         opts.p2p_addr,
   1149         false,
   1150         false,
   1151     )
   1152     .await
   1153     .unwrap_err();
   1154 
   1155     assert!(
   1156         error.to_string().contains("already contains a blockchain"),
   1157         "{error:#}"
   1158     );
   1159 }
   1160 
   1161 #[tokio::test]
   1162 async fn startup_resumes_persisted_chain_without_genesis_flag() {
   1163     let dir = tempdir().unwrap();
   1164     let chain_path = dir.path().join("chain.sqlite3");
   1165     let store = SqliteChainStore::open(&chain_path).unwrap();
   1166     let persisted_wallet = Wallet::from_seed("persisted-chain-owner");
   1167     let persisted = ledger_with_one_mined_block(&persisted_wallet);
   1168     store.save(&persisted.snapshot()).unwrap();
   1169     let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
   1170     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1171         .unwrap()
   1172         .unwrap();
   1173 
   1174     let resumed = initialize_ledger(
   1175         &opts,
   1176         fresh_wallet.address(),
   1177         &store,
   1178         opts.p2p_addr,
   1179         false,
   1180         false,
   1181     )
   1182     .await
   1183     .unwrap();
   1184 
   1185     assert_eq!(resumed.status().height, 1);
   1186     assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
   1187     assert_eq!(resumed.genesis_hash(), persisted.genesis_hash());
   1188     assert_eq!(resumed.balance_of(fresh_wallet.address()), 0);
   1189 }
   1190 
   1191 #[tokio::test]
   1192 async fn startup_rejects_unpinned_candidate_genesis() {
   1193     let dir = tempdir().unwrap();
   1194     let chain_path = dir.path().join("chain.sqlite3");
   1195     let store = SqliteChainStore::open(&chain_path).unwrap();
   1196     let wallet = Wallet::from_seed("unpinned-candidate-genesis");
   1197     let persisted = ledger_with_one_mined_block(&wallet);
   1198     store.save(&persisted.snapshot()).unwrap();
   1199     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1200         .unwrap()
   1201         .unwrap();
   1202 
   1203     let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, true)
   1204         .await
   1205         .unwrap_err();
   1206 
   1207     assert!(error.to_string().contains("does not match pinned genesis"));
   1208 }
   1209 
   1210 #[tokio::test]
   1211 async fn startup_rebuilds_state_from_a_locally_trusted_chain() {
   1212     let dir = tempdir().unwrap();
   1213     let chain_path = dir.path().join("chain.sqlite3");
   1214     let store = SqliteChainStore::open(&chain_path).unwrap();
   1215     let wallet = Wallet::from_seed("trusted-persisted-chain-owner");
   1216     let persisted = ledger_with_one_mined_block(&wallet);
   1217     store.save_verified(&persisted.snapshot()).unwrap();
   1218     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1219         .unwrap()
   1220         .unwrap();
   1221 
   1222     let resumed = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false)
   1223         .await
   1224         .unwrap();
   1225 
   1226     assert_eq!(resumed.status(), persisted.status());
   1227     assert_eq!(
   1228         resumed.balance_of(wallet.address()),
   1229         persisted.balance_of(wallet.address())
   1230     );
   1231     assert_eq!(resumed.snapshot(), persisted.snapshot());
   1232 }
   1233 
   1234 #[test]
   1235 fn consensus_migration_revalidates_only_from_its_activation_height() {
   1236     let wallet = Wallet::from_seed("consensus-revalidation-boundary-owner");
   1237     let mut snapshot = ledger_with_one_mined_block(&wallet).snapshot();
   1238     snapshot.blocks[1].leader_proof.as_mut().unwrap().signature = "0".repeat(128);
   1239 
   1240     assert!(Ledger::from_persisted_snapshot_revalidating_from(snapshot.clone(), Some(1)).is_err());
   1241     let trusted_prefix =
   1242         Ledger::from_persisted_snapshot_revalidating_from(snapshot, Some(2)).unwrap();
   1243     assert_eq!(trusted_prefix.height(), 1);
   1244 }
   1245 
   1246 #[tokio::test]
   1247 async fn local_testnet_requests_reset_for_persisted_normal_launch_profile() {
   1248     let dir = tempdir().unwrap();
   1249     let chain_path = dir.path().join("chain.sqlite3");
   1250     let store = SqliteChainStore::open(&chain_path).unwrap();
   1251     let persisted_wallet = Wallet::from_seed("normal-profile-chain-owner");
   1252     let persisted = ledger_with_one_mined_block(&persisted_wallet);
   1253     store.save(&persisted.snapshot()).unwrap();
   1254     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1255         .unwrap()
   1256         .unwrap();
   1257 
   1258     let initialized = initialize_ledger(
   1259         &opts,
   1260         persisted_wallet.address(),
   1261         &store,
   1262         opts.p2p_addr,
   1263         true,
   1264         false,
   1265     )
   1266     .await
   1267     .unwrap();
   1268 
   1269     assert_eq!(
   1270         initialized.migration_from.as_deref(),
   1271         Some("iuna-mainnet-candidate")
   1272     );
   1273     assert!(initialized.ledger.is_setup_placeholder());
   1274 }
   1275 
   1276 #[tokio::test]
   1277 async fn startup_requests_reset_for_a_legacy_network_profile() {
   1278     let dir = tempdir().unwrap();
   1279     let chain_path = dir.path().join("chain.sqlite3");
   1280     let store = SqliteChainStore::open(&chain_path).unwrap();
   1281     let wallet = Wallet::from_seed("legacy-network-owner");
   1282     let mut snapshot = ledger_with_one_mined_block(&wallet).snapshot();
   1283     snapshot.launch_profile.profile_id = "iuna-devnet-v5".to_string();
   1284     store.save(&snapshot).unwrap();
   1285     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1286         .unwrap()
   1287         .unwrap();
   1288 
   1289     let initialized =
   1290         initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false)
   1291             .await
   1292             .unwrap();
   1293 
   1294     assert_eq!(
   1295         initialized.migration_from.as_deref(),
   1296         Some("iuna-devnet-v5")
   1297     );
   1298     assert!(initialized.ledger.is_setup_placeholder());
   1299     assert_eq!(
   1300         store.load().unwrap().unwrap().launch_profile.profile_id,
   1301         "iuna-devnet-v5"
   1302     );
   1303 }
   1304 
   1305 #[tokio::test]
   1306 async fn candidate_promotion_reuses_chain_data_and_can_continue_mining() {
   1307     assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate");
   1308     assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1");
   1309     assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID);
   1310     let dir = tempdir().unwrap();
   1311     let chain_path = dir.path().join("chain.sqlite3");
   1312     let store = SqliteChainStore::open(&chain_path).unwrap();
   1313     let (candidate, wallets, transfer_signature) = promoted_candidate_ledger();
   1314     let candidate_genesis = candidate.genesis_hash().to_string();
   1315     let candidate_tip = candidate.tip_hash().to_string();
   1316     let candidate_profile = candidate.launch_profile().clone();
   1317     let candidate_utxos = candidate.all_utxos();
   1318     let candidate_next_ranks = candidate
   1319         .burn_leader_ranks_for_block(candidate.height() + 1)
   1320         .unwrap();
   1321     store.save(&candidate.snapshot()).unwrap();
   1322     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1323         .unwrap()
   1324         .unwrap();
   1325 
   1326     let mut promoted = initialize_ledger(
   1327         &opts,
   1328         wallets[0].address(),
   1329         &store,
   1330         opts.p2p_addr,
   1331         false,
   1332         false,
   1333     )
   1334     .await
   1335     .unwrap();
   1336 
   1337     assert_eq!(promoted.genesis_hash(), candidate_genesis);
   1338     assert_eq!(promoted.tip_hash(), candidate_tip);
   1339     assert_eq!(promoted.launch_profile(), &candidate_profile);
   1340     assert_eq!(promoted.all_utxos(), candidate_utxos);
   1341     assert_eq!(
   1342         promoted
   1343             .burn_leader_ranks_for_block(promoted.height() + 1)
   1344             .unwrap(),
   1345         candidate_next_ranks
   1346     );
   1347     assert!(promoted.has_transaction(&transfer_signature));
   1348 
   1349     let next_owner = promoted
   1350         .burn_leader_ranks_for_block(promoted.height() + 1)
   1351         .unwrap()
   1352         .first()
   1353         .unwrap()
   1354         .owner
   1355         .clone();
   1356     let next_finalizer = wallets
   1357         .iter()
   1358         .find(|wallet| wallet.address() == next_owner)
   1359         .unwrap();
   1360     let anchor = promoted
   1361         .build_burn(next_finalizer, MICRO_IUNA, MICRO_IUNA)
   1362         .unwrap();
   1363     promoted.submit_transaction(anchor).unwrap();
   1364     let next_block = promoted
   1365         .mine_next_block(
   1366             next_finalizer,
   1367             promoted
   1368                 .chain()
   1369                 .last()
   1370                 .unwrap()
   1371                 .timestamp_ms
   1372                 .saturating_add(VDF_TARGET_BLOCK_MS),
   1373         )
   1374         .unwrap();
   1375     promoted.apply_locally_mined_block(next_block).unwrap();
   1376 
   1377     assert_eq!(promoted.genesis_hash(), candidate_genesis);
   1378     assert_eq!(promoted.height(), candidate.height() + 1);
   1379     assert_ne!(promoted.tip_hash(), candidate_tip);
   1380 }
   1381 
   1382 #[tokio::test]
   1383 async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() {
   1384     let dir = tempdir().unwrap();
   1385     let chain_path = dir.path().join("chain.sqlite3");
   1386     let store = SqliteChainStore::open(&chain_path).unwrap();
   1387     let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner");
   1388     let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet);
   1389     let burn = persisted.build_burn(&persisted_wallet, 1, 1).unwrap();
   1390     persisted.submit_transaction(burn).unwrap();
   1391     let future_tip_ms = iuna::app::now_ms().saturating_add(10 * 60 * 1_000);
   1392     let future_block = persisted
   1393         .mine_next_block(&persisted_wallet, future_tip_ms)
   1394         .unwrap();
   1395     let mut snapshot = persisted.snapshot();
   1396     snapshot.blocks.push(future_block);
   1397     assert!(
   1398         Ledger::from_snapshot(snapshot.clone())
   1399             .unwrap_err()
   1400             .to_string()
   1401             .contains("too far in the future")
   1402     );
   1403     store.save(&snapshot).unwrap();
   1404     let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
   1405     let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
   1406         .unwrap()
   1407         .unwrap();
   1408 
   1409     let resumed = initialize_ledger(
   1410         &opts,
   1411         fresh_wallet.address(),
   1412         &store,
   1413         opts.p2p_addr,
   1414         false,
   1415         false,
   1416     )
   1417     .await
   1418     .unwrap();
   1419 
   1420     assert_eq!(resumed.status().height, 1);
   1421     assert_eq!(
   1422         resumed.status().tip_hash,
   1423         snapshot.blocks.last().unwrap().hash
   1424     );
   1425 }
   1426 
   1427 #[tokio::test]
   1428 async fn persisted_chain_satisfies_join_mode_without_contacting_peer() {
   1429     let dir = tempdir().unwrap();
   1430     let chain_path = dir.path().join("chain.sqlite3");
   1431     let store = SqliteChainStore::open(&chain_path).unwrap();
   1432     let alice = Wallet::from_seed("offline-join-alice");
   1433     let persisted = ledger_with_one_mined_block(&alice);
   1434     store.save(&persisted.snapshot()).unwrap();
   1435     let bob = Wallet::from_seed("offline-join-bob");
   1436     let opts = parse(&[
   1437         "--join",
   1438         "127.0.0.1:1",
   1439         "--chain-db",
   1440         chain_path.to_str().unwrap(),
   1441     ])
   1442     .unwrap()
   1443     .unwrap();
   1444 
   1445     let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr, false, false)
   1446         .await
   1447         .unwrap();
   1448 
   1449     assert_eq!(resumed.status().height, 1);
   1450     assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
   1451 }
   1452 
   1453 #[tokio::test]
   1454 async fn invalid_persisted_chain_is_reported_and_never_replaced() {
   1455     let dir = tempdir().unwrap();
   1456     let chain_path = dir.path().join("chain.sqlite3");
   1457     let store = SqliteChainStore::open(&chain_path).unwrap();
   1458     let connection = Connection::open(&chain_path).unwrap();
   1459     connection
   1460         .execute(
   1461             r#"
   1462 INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms)
   1463 VALUES (1, 4, 'bad-tip', x'00010203', 0)
   1464 "#,
   1465             [],
   1466         )
   1467         .unwrap();
   1468     let wallet = Wallet::from_seed("bad-db-wallet");
   1469     let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
   1470         .unwrap()
   1471         .unwrap();
   1472 
   1473     let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false, false)
   1474         .await
   1475         .unwrap_err();
   1476 
   1477     assert!(
   1478         format!("{error:#}").contains("failed to parse compact chain snapshot from database"),
   1479         "{error:#}"
   1480     );
   1481 }
   1482 
   1483 #[test]
   1484 fn startup_prunes_a_persisted_v2_transaction_that_no_longer_validates() {
   1485     let dir = tempdir().unwrap();
   1486     let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
   1487     let wallet = Wallet::from_seed("stale-persisted-v2");
   1488     let ledger = ledger_with_one_spendable_iuna(&wallet);
   1489     let domain = ledger.transaction_v2_domain().unwrap();
   1490     let transaction = ledger.build_v2_migration_batch(&wallet, 1).unwrap();
   1491     let to_hex =
   1492         |bytes: &[u8]| -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() };
   1493     let transaction_id = to_hex(&transaction.transaction_id(&domain).unwrap());
   1494     let envelope = to_hex(&transaction.encode(&domain).unwrap());
   1495     store
   1496         .save_pending_transaction_v2(&transaction_id, &envelope)
   1497         .unwrap();
   1498     let mut node = NodeCore::from_ledger(wallet, ledger, DEFAULT_BURN_PER_BLOCK);
   1499 
   1500     let restored = restore_pending_transactions_v2(&mut node, &store).unwrap();
   1501 
   1502     assert_eq!(restored, 0);
   1503     assert!(node.pending_transaction_v2_envelopes().unwrap().is_empty());
   1504     assert!(store.load_pending_transactions_v2().unwrap().is_empty());
   1505 }
   1506 
   1507 #[tokio::test]
   1508 async fn persistence_loop_saves_new_tip_after_node_changes() {
   1509     let dir = tempdir().unwrap();
   1510     let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
   1511     let wallet = Wallet::from_seed("background-persistence");
   1512     let ledger = ledger_with_one_spendable_iuna(&wallet);
   1513     let node = Arc::new(Mutex::new(NodeCore::from_ledger(
   1514         wallet.clone(),
   1515         ledger,
   1516         DEFAULT_BURN_PER_BLOCK,
   1517     )));
   1518     let initial_snapshot = { node.lock().await.chain_snapshot() };
   1519     let initial_tip = initial_snapshot
   1520         .blocks
   1521         .last()
   1522         .map(|block| block.hash.clone());
   1523     persist_chain_snapshot(&store, initial_snapshot)
   1524         .await
   1525         .unwrap();
   1526     let ui_config = Arc::new(Mutex::new(UiConfig::default()));
   1527     let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   1528 
   1529     let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
   1530         Arc::clone(&node),
   1531         store.clone(),
   1532         ui_data_store.clone(),
   1533         ui_config,
   1534         Duration::from_millis(10),
   1535         initial_tip,
   1536         false,
   1537     ));
   1538     {
   1539         let mut node = node.lock().await;
   1540         let burn = node.ledger().build_burn(&wallet, 1, 1).unwrap();
   1541         node.receive_transaction(burn).unwrap();
   1542         node.mine_one_at(1_000).unwrap();
   1543     }
   1544 
   1545     let expected_tip = node.lock().await.ledger().status().tip_hash;
   1546     let (restored_tip, projected_tip) = tokio::time::timeout(Duration::from_secs(5), async {
   1547         loop {
   1548             let restored_tip = store
   1549                 .load()
   1550                 .unwrap()
   1551                 .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone()));
   1552             let projected_tip = Connection::open(ui_data_store.path())
   1553                 .unwrap()
   1554                 .query_row(
   1555                     "SELECT tip_hash FROM ui_cache_meta WHERE id = 1",
   1556                     [],
   1557                     |row| row.get::<_, String>(0),
   1558                 )
   1559                 .ok();
   1560             if restored_tip.as_deref() == Some(expected_tip.as_str())
   1561                 && projected_tip.as_deref() == Some(expected_tip.as_str())
   1562             {
   1563                 return (restored_tip, projected_tip);
   1564             }
   1565             tokio::time::sleep(Duration::from_millis(10)).await;
   1566         }
   1567     })
   1568     .await
   1569     .unwrap_or_else(|_| {
   1570         let restored_tip = store
   1571             .load()
   1572             .unwrap()
   1573             .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone()));
   1574         let projected_tip = Connection::open(ui_data_store.path())
   1575             .unwrap()
   1576             .query_row(
   1577                 "SELECT tip_hash FROM ui_cache_meta WHERE id = 1",
   1578                 [],
   1579                 |row| row.get::<_, String>(0),
   1580             )
   1581             .ok();
   1582         (restored_tip, projected_tip)
   1583     });
   1584     persistence_task.abort();
   1585 
   1586     assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str()));
   1587     assert_eq!(projected_tip.as_deref(), Some(expected_tip.as_str()));
   1588     assert!(ui_data_store.load_metrics().unwrap().is_empty());
   1589 }
   1590 
   1591 #[tokio::test]
   1592 async fn persistence_loop_skips_tip_already_projected_at_startup() {
   1593     let dir = tempdir().unwrap();
   1594     let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
   1595     let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   1596     let wallet = Wallet::from_seed("background-persistence-warmed");
   1597     let ledger = ledger_with_one_spendable_iuna(&wallet);
   1598     let node = Arc::new(Mutex::new(NodeCore::from_ledger(
   1599         wallet,
   1600         ledger,
   1601         DEFAULT_BURN_PER_BLOCK,
   1602     )));
   1603     let initial_snapshot = { node.lock().await.chain_snapshot() };
   1604     let initial_tip = initial_snapshot
   1605         .blocks
   1606         .last()
   1607         .map(|block| block.hash.clone());
   1608     persist_chain_snapshot(&store, initial_snapshot.clone())
   1609         .await
   1610         .unwrap();
   1611     project_ui_data_store(&ui_data_store, initial_snapshot, false)
   1612         .await
   1613         .unwrap();
   1614     let ui_data_connection = Connection::open(ui_data_store.path()).unwrap();
   1615     ui_data_connection
   1616         .execute(
   1617             "UPDATE ui_cache_meta SET updated_at_ms = 123 WHERE id = 1",
   1618             [],
   1619         )
   1620         .unwrap();
   1621     drop(ui_data_connection);
   1622     let ui_config = Arc::new(Mutex::new(UiConfig::default()));
   1623 
   1624     let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
   1625         Arc::clone(&node),
   1626         store,
   1627         ui_data_store.clone(),
   1628         ui_config,
   1629         Duration::from_millis(10),
   1630         initial_tip,
   1631         false,
   1632     ));
   1633     tokio::time::sleep(Duration::from_millis(50)).await;
   1634     persistence_task.abort();
   1635 
   1636     let ui_data_connection = Connection::open(ui_data_store.path()).unwrap();
   1637     let updated_at_ms: u64 = ui_data_connection
   1638         .query_row(
   1639             "SELECT updated_at_ms FROM ui_cache_meta WHERE id = 1",
   1640             [],
   1641             |row| row.get(0),
   1642         )
   1643         .unwrap();
   1644     assert_eq!(updated_at_ms, 123);
   1645 }
   1646 
   1647 #[tokio::test]
   1648 async fn persistence_loop_projects_metrics_when_mode_changes_without_a_new_tip() {
   1649     let dir = tempdir().unwrap();
   1650     let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
   1651     let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   1652     let wallet = Wallet::from_seed("background-metrics-mode-change");
   1653     let ledger = ledger_with_one_spendable_iuna(&wallet);
   1654     let node = Arc::new(Mutex::new(NodeCore::from_ledger(
   1655         wallet,
   1656         ledger,
   1657         DEFAULT_BURN_PER_BLOCK,
   1658     )));
   1659     let initial_snapshot = { node.lock().await.chain_snapshot() };
   1660     let initial_tip = initial_snapshot
   1661         .blocks
   1662         .last()
   1663         .map(|block| block.hash.clone());
   1664     persist_chain_snapshot(&store, initial_snapshot.clone())
   1665         .await
   1666         .unwrap();
   1667     project_ui_data_store(&ui_data_store, initial_snapshot, false)
   1668         .await
   1669         .unwrap();
   1670     let ui_config = Arc::new(Mutex::new(UiConfig::default()));
   1671 
   1672     let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
   1673         Arc::clone(&node),
   1674         store,
   1675         ui_data_store.clone(),
   1676         Arc::clone(&ui_config),
   1677         Duration::from_millis(10),
   1678         initial_tip,
   1679         false,
   1680     ));
   1681     ui_config.lock().await.keep_track_of_metrics = true;
   1682 
   1683     tokio::time::timeout(Duration::from_secs(5), async {
   1684         while ui_data_store.load_metrics().unwrap().is_empty() {
   1685             tokio::time::sleep(Duration::from_millis(10)).await;
   1686         }
   1687     })
   1688     .await
   1689     .unwrap();
   1690     persistence_task.abort();
   1691 
   1692     assert!(!ui_data_store.load_metrics().unwrap().is_empty());
   1693 }
   1694 
   1695 #[tokio::test]
   1696 async fn persistence_loop_skips_setup_placeholder_chain() {
   1697     let dir = tempdir().unwrap();
   1698     let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
   1699     let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
   1700     let wallet = Wallet::from_seed("background-persistence-setup");
   1701     let ledger = Ledger::new(BTreeMap::new(), 1);
   1702     let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
   1703     let ui_config = Arc::new(Mutex::new(UiConfig::default()));
   1704 
   1705     let persistence_task = tokio::spawn(run_chain_persistence_with_interval(
   1706         Arc::clone(&node),
   1707         store.clone(),
   1708         ui_data_store.clone(),
   1709         ui_config,
   1710         Duration::from_millis(10),
   1711         None,
   1712         false,
   1713     ));
   1714     tokio::time::sleep(Duration::from_millis(50)).await;
   1715     persistence_task.abort();
   1716 
   1717     assert!(store.load().unwrap().is_none());
   1718     assert!(ui_data_store.load_metrics().unwrap().is_empty());
   1719 }