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 }