iuna

iuna

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

tests.rs (85643B)


      1 use std::{collections::BTreeMap, sync::Arc};
      2 
      3 use axum::{
      4     Json, Router,
      5     body::{Body, to_bytes},
      6     extract::{Query, State},
      7     http::{HeaderMap, Method, Request, StatusCode, header},
      8     middleware,
      9     routing::{get, post},
     10 };
     11 use tokio::sync::Mutex;
     12 use tower::ServiceExt;
     13 
     14 use crate::{
     15     adapters::{
     16         chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork,
     17         ui_data_store::SqliteUiDataStore, wallet_store,
     18     },
     19     app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
     20     domain::{
     21         Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot,
     22         GenesisBurn, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal,
     23         OutPoint, RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput,
     24         Wallet,
     25     },
     26 };
     27 
     28 use super::{
     29     AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters,
     30     WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form,
     31     api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed,
     32     hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config,
     33     persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn,
     34     same_origin_request, validate_password, validate_transfer_form, verify_password,
     35     wallet_transaction_rows, wallet_utxo_rows,
     36 };
     37 
     38 #[test]
     39 fn dev_seed_verify_bypass_requires_env_flag() {
     40     assert!(dev_seed_verify_bypass_allowed(true));
     41     assert!(!dev_seed_verify_bypass_allowed(false));
     42 }
     43 
     44 #[test]
     45 fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() {
     46     let commitment = "a".repeat(64);
     47     let owner = "c".repeat(64);
     48     let input_outpoint = OutPoint {
     49         txid: "d".repeat(64),
     50         index: 0,
     51     };
     52     let blinded = BlindedTransaction {
     53         commitment: commitment.clone(),
     54         inputs: vec![TxInput {
     55             outpoint: input_outpoint.clone(),
     56             owner: owner.clone(),
     57             signature: "e".repeat(128),
     58         }],
     59         fee: 7,
     60         encrypted_size: 123,
     61         expires_at_height: 42,
     62         nonce: "00".repeat(12),
     63         ciphertext: "11".repeat(123),
     64         payload_hash: "b".repeat(64),
     65     };
     66     let reveal = BlindedReveal {
     67         commitment: commitment.clone(),
     68         key: "22".repeat(32),
     69     };
     70     let outputs = BTreeMap::from([(
     71         input_outpoint.clone(),
     72         TxOutput {
     73             address: owner.clone(),
     74             amount: 99,
     75         },
     76     )]);
     77 
     78     let blinded_row = super::ui_blinded_transaction(&blinded, &outputs);
     79     let reveal_row = super::ui_blinded_reveal(&reveal);
     80     let revealed_row = super::ui_pending_revealed_transaction(
     81         &crate::domain::RevealedBlindedTransaction {
     82             height: 2,
     83             commitment: reveal.commitment.clone(),
     84             included_by: owner.clone(),
     85             transaction: Transaction::Burn {
     86                 inputs: blinded.inputs.clone(),
     87                 change: Vec::new(),
     88                 amount: 12,
     89                 fee: blinded.fee,
     90                 signature: "f".repeat(128),
     91             },
     92         },
     93         &outputs,
     94     );
     95 
     96     assert_eq!(blinded_row.kind, "blinded");
     97     assert_eq!(blinded_row.from, owner);
     98     assert_eq!(blinded_row.inputs.len(), 1);
     99     assert_eq!(blinded_row.inputs[0].amount, Some(99));
    100     assert_eq!(blinded_row.signature, commitment);
    101     assert_eq!(blinded_row.encrypted_size, Some(123));
    102     assert_eq!(blinded_row.expires_at_height, Some(42));
    103     assert_eq!(reveal_row.kind, "reveal");
    104     assert_eq!(reveal_row.commitment, blinded_row.commitment);
    105     assert_eq!(revealed_row.kind, "burn");
    106     assert!(revealed_row.revealed);
    107     assert_eq!(revealed_row.commitment, Some(reveal.commitment));
    108     assert_eq!(revealed_row.amount, 12);
    109     assert_eq!(revealed_row.fee, 7);
    110     assert_eq!(revealed_row.inputs[0].amount, Some(99));
    111 }
    112 
    113 #[test]
    114 fn block_detail_transactions_include_blinded_and_revealed_items() {
    115     let alice = Wallet::from_seed("block-detail-blind-alice");
    116     let bob = Wallet::from_seed("block-detail-blind-bob");
    117     let mut allocations = BTreeMap::new();
    118     allocations.insert(alice.address().to_string(), 100);
    119     allocations.insert(bob.address().to_string(), 100);
    120     let ledger = Ledger::new(allocations.clone(), 1);
    121     let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap();
    122     let built = ledger
    123         .build_blinded_transaction(&alice, transfer.clone(), 20)
    124         .unwrap();
    125     let mut block = fake_block(7, Vec::new());
    126     block.blinded_transactions = vec![built.transaction.clone()];
    127 
    128     let revealed = crate::domain::RevealedBlindedTransaction {
    129         height: 7,
    130         commitment: built.transaction.commitment.clone(),
    131         included_by: "miner".to_string(),
    132         transaction: transfer,
    133     };
    134     let ui_block = super::ui_block(block, &BTreeMap::new(), &BTreeMap::new(), &[revealed]);
    135 
    136     assert_eq!(ui_block.transactions.len(), 2);
    137     assert_eq!(ui_block.transactions[0].kind, "blinded");
    138     assert_eq!(
    139         ui_block.transactions[0].commitment.as_deref(),
    140         Some(built.transaction.commitment.as_str())
    141     );
    142     assert!(!ui_block.transactions[0].revealed);
    143     assert_eq!(ui_block.transactions[1].kind, "transfer");
    144     assert!(ui_block.transactions[1].revealed);
    145     assert_eq!(ui_block.revealed_transactions.len(), 1);
    146     assert!(ui_block.revealed_transactions[0].revealed);
    147     assert!(ui_block.total_bytes > 0);
    148     assert!(ui_block.blinded_transaction_bytes > 0);
    149     assert_eq!(ui_block.reveal_bundle_bytes, 0);
    150 
    151     let burn = ledger.build_burn(&alice, 5, 1).unwrap();
    152     let mine = Transaction::Mine {
    153         recipient: bob.address().to_string(),
    154         anchor: "a".repeat(64),
    155         salt: 1,
    156         nonce: 2,
    157         difficulty_bits: 12,
    158         proof_header: None,
    159         signature: "b".repeat(64),
    160     };
    161     let typed_block = super::ui_block(
    162         fake_block(
    163             8,
    164             vec![
    165                 ledger.build_transfer(&alice, bob.address(), 7, 1).unwrap(),
    166                 burn,
    167                 mine,
    168             ],
    169         ),
    170         &BTreeMap::new(),
    171         &BTreeMap::new(),
    172         &[],
    173     );
    174     let typed_bytes = typed_block
    175         .transaction_byte_breakdown
    176         .iter()
    177         .map(|row| (row.label, row.bytes))
    178         .collect::<BTreeMap<_, _>>();
    179     assert!(typed_bytes["transfer"] > 0);
    180     assert!(typed_bytes["burn"] > 0);
    181     assert!(typed_bytes["mine"] > 0);
    182 }
    183 
    184 #[test]
    185 fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() {
    186     let alice = Wallet::from_seed("block-detail-reveal-alice");
    187     let bob = Wallet::from_seed("block-detail-reveal-bob");
    188     let mut allocations = BTreeMap::new();
    189     allocations.insert(alice.address().to_string(), 100);
    190     allocations.insert(bob.address().to_string(), 100);
    191     let ledger = Ledger::new(allocations.clone(), 1);
    192     let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap();
    193     let built = ledger
    194         .build_blinded_transaction(&alice, transfer.clone(), 20)
    195         .unwrap();
    196     let mut commit_block = fake_block(7, Vec::new());
    197     commit_block.blinded_transactions = vec![built.transaction.clone()];
    198     let mut reveal_block = fake_block(8, Vec::new());
    199     reveal_block.transactions = vec![Transaction::Mine {
    200         recipient: bob.address().to_string(),
    201         anchor: "a".repeat(64),
    202         salt: 1,
    203         nonce: 2,
    204         difficulty_bits: 12,
    205         proof_header: None,
    206         signature: "b".repeat(64),
    207     }];
    208     reveal_block.reveal_bundle_section = RevealBundleSection {
    209         signatures: vec![RevealBundleSignature {
    210             slot: 0,
    211             member: reveal_block.miner.clone(),
    212             signature: "11".repeat(64),
    213         }],
    214         reveals: vec![MaskedBlindedReveal {
    215             reveal: built.reveal.clone(),
    216             bundle_mask: 1,
    217         }],
    218     };
    219     let snapshot = fake_snapshot(
    220         allocations,
    221         vec![commit_block.clone(), reveal_block.clone()],
    222     );
    223     let mut burn_leader_ranks = BTreeMap::new();
    224     burn_leader_ranks.insert(
    225         reveal_block.hash.clone(),
    226         vec![
    227             BurnLeaderRank {
    228                 rank: 0,
    229                 ticket_id: "ticket-0".to_string(),
    230                 owner: alice.address().to_string(),
    231                 amount: 1,
    232                 eligible_from_height: 8,
    233                 eligible_until_height: 8,
    234             },
    235             BurnLeaderRank {
    236                 rank: 1,
    237                 ticket_id: "ticket-1".to_string(),
    238                 owner: bob.address().to_string(),
    239                 amount: 1,
    240                 eligible_from_height: 8,
    241                 eligible_until_height: 8,
    242             },
    243             BurnLeaderRank {
    244                 rank: 2,
    245                 ticket_id: "ticket-2".to_string(),
    246                 owner: "carol".to_string(),
    247                 amount: 1,
    248                 eligible_from_height: 8,
    249                 eligible_until_height: 8,
    250             },
    251         ],
    252     );
    253 
    254     let blocks = super::ui_blocks(
    255         vec![commit_block, reveal_block],
    256         &snapshot,
    257         &[],
    258         &burn_leader_ranks,
    259     );
    260 
    261     assert_eq!(blocks[0].transactions.len(), 1);
    262     assert_eq!(blocks[0].transactions[0].kind, "blinded");
    263     assert_eq!(
    264         blocks[0].transactions[0].commitment.as_deref(),
    265         Some(built.transaction.commitment.as_str())
    266     );
    267     assert_eq!(blocks[1].transactions.len(), 2);
    268     assert_eq!(blocks[1].transactions[0].kind, "mine");
    269     assert_eq!(blocks[1].transactions[1].kind, "transfer");
    270     assert!(blocks[1].transactions[1].revealed);
    271     assert_eq!(blocks[1].transactions[1].amount, transfer.amount());
    272     assert_eq!(blocks[1].transactions[1].to.as_deref(), Some(bob.address()));
    273     assert_eq!(blocks[1].revealed_transactions.len(), 1);
    274     assert_eq!(blocks[1].reveal_fee_penalty.reveal_lists_included, 1);
    275     assert_eq!(blocks[1].reveal_fee_penalty.committee_size, 3);
    276     assert_eq!(blocks[1].total_fees, transfer.fee() + MINE_FINALIZER_FEE);
    277     assert_eq!(
    278         blocks[1].reveal_fee_penalty.fee_penalty,
    279         ((transfer.fee() + MINE_FINALIZER_FEE) as u128 * 2 / 3) as u64
    280     );
    281 }
    282 
    283 #[test]
    284 fn block_detail_markup_uses_blinded_and_revealed_labels() {
    285     let app_js = include_str!("../../../www/assets/iuna-ui.js");
    286 
    287     assert!(super::INDEX_HTML.contains("txPillLabel(tx)"));
    288     assert!(super::INDEX_HTML.contains("commitCountLabel(block)"));
    289     assert!(super::INDEX_HTML.contains(".pill.blinded"));
    290     assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed"));
    291     assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\""));
    292     assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block"));
    293     assert!(super::INDEX_HTML.contains(":class=\"row[2]\""));
    294     assert!(super::INDEX_HTML.contains("New since last block"));
    295     assert!(super::INDEX_HTML.contains("class=\"mempool-top\""));
    296     assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)"));
    297     assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">"));
    298     assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">"));
    299     assert!(super::INDEX_HTML.contains("fee-penalty-value"));
    300     assert!(
    301         super::INDEX_HTML
    302             .contains(":class=\"{ penalty: blockRevealFeePenaltyAmount(selectedBlock) > 0 }\"")
    303     );
    304     assert!(super::INDEX_HTML.contains("Commitment"));
    305     assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>"));
    306     assert!(app_js.contains("tx?.revealed ? \"revealed\""));
    307     assert!(app_js.contains("transactions.some((tx) => tx?.revealed)"));
    308     assert!(app_js.contains("blockCommitCount(block)"));
    309     assert!(app_js.contains("blockTransactionByteBreakdown(block)"));
    310     assert!(app_js.contains("[label, Number(row.bytes ?? 0), label]"));
    311 }
    312 
    313 #[test]
    314 fn password_policy_rejects_short_or_excessive_passwords() {
    315     let short = validate_password("too-short").unwrap_err();
    316     assert!(short.to_string().contains("at least 12"));
    317 
    318     let long_password = "x".repeat(1025);
    319     let long = validate_password(&long_password).unwrap_err();
    320     assert!(long.to_string().contains("too long"));
    321 
    322     validate_password("correct horse battery staple").unwrap();
    323 }
    324 
    325 #[test]
    326 fn password_hash_round_trips_without_storing_plaintext() {
    327     let password = "correct horse battery staple";
    328     let encoded = hash_password(password).unwrap();
    329 
    330     assert!(!encoded.contains(password));
    331     assert!(verify_password(password, &encoded).unwrap());
    332     assert!(!verify_password("wrong horse battery staple", &encoded).unwrap());
    333 }
    334 
    335 #[test]
    336 fn pbkdf2_sha256_matches_known_vectors() {
    337     let one_iteration = pbkdf2_sha256(b"password", b"salt", 1);
    338     assert_eq!(
    339         hex_encode(one_iteration),
    340         "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"
    341     );
    342 
    343     let two_iterations = pbkdf2_sha256(b"password", b"salt", 2);
    344     assert_eq!(
    345         hex_encode(two_iterations),
    346         "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"
    347     );
    348 }
    349 
    350 #[test]
    351 fn same_origin_check_accepts_forwarded_host_and_rejects_cross_site_origin() {
    352     let mut headers = HeaderMap::new();
    353     headers.insert(header::HOST, "127.0.0.1:18661".parse().unwrap());
    354     headers.insert("x-forwarded-host", "iuna.example".parse().unwrap());
    355     headers.insert(header::ORIGIN, "https://iuna.example".parse().unwrap());
    356     assert!(same_origin_request(&headers));
    357 
    358     headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap());
    359     assert!(!same_origin_request(&headers));
    360 }
    361 
    362 #[test]
    363 fn auth_client_key_trusts_forwarded_headers_only_from_private_or_local_peers() {
    364     let mut headers = HeaderMap::new();
    365     headers.insert("x-forwarded-for", "198.51.100.99".parse().unwrap());
    366     let socket = Some("203.0.113.10:51234".parse().unwrap());
    367 
    368     assert_eq!(auth_client_key(&headers, socket), "203.0.113.10");
    369     assert_eq!(
    370         auth_client_key(&headers, Some("127.0.0.1:51234".parse().unwrap())),
    371         "198.51.100.99"
    372     );
    373     assert_eq!(
    374         auth_client_key(&headers, Some("10.42.1.12:51234".parse().unwrap())),
    375         "198.51.100.99"
    376     );
    377     assert_eq!(
    378         auth_client_key(&headers, Some("172.20.4.8:51234".parse().unwrap())),
    379         "198.51.100.99"
    380     );
    381     assert_eq!(auth_client_key(&headers, None), "198.51.100.99");
    382 }
    383 
    384 #[tokio::test]
    385 async fn protected_endpoints_require_authentication_setup() {
    386     let dir = tempfile::tempdir().unwrap();
    387     let state = auth_test_state(
    388         dir.path().join("config.json"),
    389         UiConfig {
    390             auth_password_hash: None,
    391             ..UiConfig::default()
    392         },
    393     )
    394     .await;
    395     let app = auth_test_app(state);
    396 
    397     let protected = http_request(app.clone(), Method::GET, "/api/protected", None, "").await;
    398     assert_eq!(protected.status, StatusCode::UNAUTHORIZED);
    399     assert!(protected.body.contains("authentication setup is required"));
    400 
    401     let status = http_request(app, Method::GET, "/api/auth/status", None, "").await;
    402     assert_eq!(status.status, StatusCode::OK);
    403     assert!(status.body.contains("\"configured\":false"));
    404 }
    405 
    406 #[tokio::test]
    407 async fn favicon_is_public_before_authentication_setup() {
    408     let dir = tempfile::tempdir().unwrap();
    409     let state = auth_test_state(
    410         dir.path().join("config.json"),
    411         UiConfig {
    412             auth_password_hash: None,
    413             ..UiConfig::default()
    414         },
    415     )
    416     .await;
    417     let app = auth_test_app(state);
    418 
    419     let response = http_request(app, Method::GET, "/favicon.ico", None, "").await;
    420 
    421     assert_eq!(response.status, StatusCode::NO_CONTENT);
    422 }
    423 
    424 #[tokio::test]
    425 async fn protected_endpoints_require_valid_session_after_authentication_setup() {
    426     let dir = tempfile::tempdir().unwrap();
    427     let password = "correct horse battery staple";
    428     let state = auth_test_state(
    429         dir.path().join("config.json"),
    430         UiConfig {
    431             auth_password_hash: Some(hash_password(password).unwrap()),
    432             ..UiConfig::default()
    433         },
    434     )
    435     .await;
    436     let app = auth_test_app(state);
    437 
    438     let missing_cookie = http_request(app.clone(), Method::GET, "/api/protected", None, "").await;
    439     assert_eq!(missing_cookie.status, StatusCode::UNAUTHORIZED);
    440     assert!(missing_cookie.body.contains("authentication required"));
    441 
    442     let bad_cookie = http_request(
    443         app.clone(),
    444         Method::GET,
    445         "/api/protected",
    446         Some("iuna_session=bogus"),
    447         "",
    448     )
    449     .await;
    450     assert_eq!(bad_cookie.status, StatusCode::UNAUTHORIZED);
    451 
    452     let login = http_request(
    453         app.clone(),
    454         Method::POST,
    455         "/api/auth/login",
    456         None,
    457         "password=correct+horse+battery+staple",
    458     )
    459     .await;
    460     assert_eq!(login.status, StatusCode::OK);
    461     assert!(login.body.contains("\"ok\":true"));
    462     let cookie = set_cookie_pair(&login.headers);
    463     assert!(cookie.starts_with(AUTH_COOKIE_NAME));
    464 
    465     let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await;
    466     assert_eq!(protected.status, StatusCode::OK);
    467     assert_eq!(protected.body, "protected");
    468 }
    469 
    470 #[tokio::test]
    471 async fn auth_posts_require_same_origin_headers() {
    472     let dir = tempfile::tempdir().unwrap();
    473     let password = "correct horse battery staple";
    474     let state = auth_test_state(
    475         dir.path().join("config.json"),
    476         UiConfig {
    477             auth_password_hash: Some(hash_password(password).unwrap()),
    478             ..UiConfig::default()
    479         },
    480     )
    481     .await;
    482     let app = auth_test_app(state);
    483 
    484     let response = app
    485         .oneshot(
    486             Request::builder()
    487                 .method(Method::POST)
    488                 .uri("/api/auth/login")
    489                 .header(header::HOST, "127.0.0.1:18661")
    490                 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
    491                 .body(Body::from("password=correct+horse+battery+staple"))
    492                 .unwrap(),
    493         )
    494         .await
    495         .unwrap();
    496     assert_eq!(response.status(), StatusCode::FORBIDDEN);
    497     let body = String::from_utf8(
    498         to_bytes(response.into_body(), usize::MAX)
    499             .await
    500             .unwrap()
    501             .to_vec(),
    502     )
    503     .unwrap();
    504     assert!(body.contains("same-origin request required"));
    505 }
    506 
    507 #[tokio::test]
    508 async fn login_authentication_locks_out_after_repeated_failures() {
    509     let dir = tempfile::tempdir().unwrap();
    510     let password = "correct horse battery staple";
    511     let state = auth_test_state(
    512         dir.path().join("config.json"),
    513         UiConfig {
    514             auth_password_hash: Some(hash_password(password).unwrap()),
    515             ..UiConfig::default()
    516         },
    517     )
    518     .await;
    519     let client_a = "198.51.100.10";
    520     let client_b = "198.51.100.11";
    521 
    522     for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS {
    523         let error = super::login_auth_password(&state, "wrong horse battery staple", client_a)
    524             .await
    525             .unwrap_err();
    526         assert!(format!("{error:#}").contains("invalid password"));
    527     }
    528 
    529     let locked = super::login_auth_password(&state, password, client_a)
    530         .await
    531         .unwrap_err();
    532     assert!(format!("{locked:#}").contains("too many failed login attempts"));
    533 
    534     let other_client_cookie = super::login_auth_password(&state, password, client_b)
    535         .await
    536         .unwrap();
    537     assert!(other_client_cookie.starts_with(AUTH_COOKIE_NAME));
    538 
    539     state
    540         .auth_backoff
    541         .lock()
    542         .await
    543         .get_mut(client_a)
    544         .unwrap()
    545         .locked_until_ms = Some(crate::app::now_ms().saturating_sub(1));
    546     let cookie = super::login_auth_password(&state, password, client_a)
    547         .await
    548         .unwrap();
    549     assert!(cookie.starts_with(AUTH_COOKIE_NAME));
    550     assert!(!state.auth_backoff.lock().await.contains_key(client_a));
    551 }
    552 
    553 #[tokio::test]
    554 async fn password_setup_creates_session_for_protected_endpoints() {
    555     let dir = tempfile::tempdir().unwrap();
    556     let config_path = dir.path().join("config.json");
    557     let state = auth_test_state(config_path.clone(), UiConfig::default()).await;
    558     let app = auth_test_app(state);
    559 
    560     let setup = http_request(
    561         app.clone(),
    562         Method::POST,
    563         "/api/auth/setup",
    564         None,
    565         "password=correct+horse+battery+staple",
    566     )
    567     .await;
    568     assert_eq!(setup.status, StatusCode::OK);
    569     assert!(setup.body.contains("\"ok\":true"));
    570     let cookie = set_cookie_pair(&setup.headers);
    571 
    572     let stored = config_store::load_or_create(&config_path).unwrap();
    573     assert!(stored.auth_password_hash.is_some());
    574     let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await;
    575     assert_eq!(protected.status, StatusCode::OK);
    576     assert_eq!(protected.body, "protected");
    577 }
    578 
    579 #[tokio::test]
    580 async fn password_change_reencrypts_wallet_and_replaces_login_password() {
    581     let dir = tempfile::tempdir().unwrap();
    582     let config_path = dir.path().join("config.json");
    583     let wallet_path = config_path.with_file_name("wallet.json");
    584     let old_password = "correct horse battery staple";
    585     let new_password = "new correct battery staple";
    586     let state = auth_test_state(
    587         config_path.clone(),
    588         UiConfig {
    589             auth_password_hash: Some(hash_password(old_password).unwrap()),
    590             setup_complete: true,
    591             ..UiConfig::default()
    592         },
    593     )
    594     .await;
    595     wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap();
    596     let app = auth_test_app(state);
    597 
    598     let login = http_request(
    599         app.clone(),
    600         Method::POST,
    601         "/api/auth/login",
    602         None,
    603         "password=correct+horse+battery+staple",
    604     )
    605     .await;
    606     assert_eq!(login.status, StatusCode::OK);
    607     let cookie = set_cookie_pair(&login.headers);
    608 
    609     let change = http_request(
    610         app.clone(),
    611         Method::POST,
    612         "/api/auth/change-password",
    613         Some(&cookie),
    614         "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple",
    615     )
    616     .await;
    617     assert_eq!(change.status, StatusCode::OK);
    618     assert!(change.body.contains("\"ok\":true"));
    619 
    620     let stored = config_store::load_or_create(&config_path).unwrap();
    621     let stored_hash = stored.auth_password_hash.unwrap();
    622     assert!(!verify_password(old_password, &stored_hash).unwrap());
    623     assert!(verify_password(new_password, &stored_hash).unwrap());
    624     assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err());
    625     assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok());
    626 
    627     let old_login = http_request(
    628         app.clone(),
    629         Method::POST,
    630         "/api/auth/login",
    631         None,
    632         "password=correct+horse+battery+staple",
    633     )
    634     .await;
    635     assert!(old_login.body.contains("invalid password"));
    636 
    637     let new_login = http_request(
    638         app,
    639         Method::POST,
    640         "/api/auth/login",
    641         None,
    642         "password=new+correct+battery+staple",
    643     )
    644     .await;
    645     assert_eq!(new_login.status, StatusCode::OK);
    646 }
    647 
    648 #[tokio::test]
    649 async fn peer_management_updates_config_file() {
    650     let dir = tempfile::tempdir().unwrap();
    651     let config_path = dir.path().join("config.json");
    652     let state = auth_test_state(
    653         config_path.clone(),
    654         UiConfig {
    655             setup_complete: true,
    656             ..UiConfig::default()
    657         },
    658     )
    659     .await;
    660 
    661     super::add_peer(&state, " 127.0.0.1:9445 ".to_string())
    662         .await
    663         .unwrap();
    664     let config = config_store::load_or_create(&config_path).unwrap();
    665     assert_eq!(config.peers, vec!["127.0.0.1:9445"]);
    666     assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]);
    667 
    668     super::remove_peer(&state, "127.0.0.1:9445".to_string())
    669         .await
    670         .unwrap();
    671     let config = config_store::load_or_create(&config_path).unwrap();
    672     assert!(config.peers.is_empty());
    673     assert!(state.peers.lock().await.addresses().is_empty());
    674 }
    675 
    676 #[tokio::test]
    677 async fn address_book_updates_config_file() {
    678     let dir = tempfile::tempdir().unwrap();
    679     let config_path = dir.path().join("config.json");
    680     let state = auth_test_state(
    681         config_path.clone(),
    682         UiConfig {
    683             setup_complete: true,
    684             ..UiConfig::default()
    685         },
    686     )
    687     .await;
    688     let alice = Wallet::from_seed("address-book-alice");
    689     let bob = Wallet::from_seed("address-book-bob");
    690 
    691     super::upsert_address_book_entry(
    692         &state,
    693         format!(" {} ", alice.address().to_ascii_uppercase()),
    694         " Alice ".to_string(),
    695         None,
    696     )
    697     .await
    698     .unwrap();
    699     let config = config_store::load_or_create(&config_path).unwrap();
    700     assert_eq!(
    701         config.address_book.get(alice.address()),
    702         Some(&"Alice".to_string())
    703     );
    704 
    705     super::upsert_address_book_entry(
    706         &state,
    707         alice.address().to_string(),
    708         "Alice Prime".to_string(),
    709         Some(alice.address().to_string()),
    710     )
    711     .await
    712     .unwrap();
    713     let config = config_store::load_or_create(&config_path).unwrap();
    714     assert_eq!(
    715         config.address_book.get(alice.address()),
    716         Some(&"Alice Prime".to_string())
    717     );
    718 
    719     let error = super::upsert_address_book_entry(
    720         &state,
    721         alice.address().to_string(),
    722         "Alice Duplicate".to_string(),
    723         None,
    724     )
    725     .await
    726     .unwrap_err();
    727     assert!(format!("{error:#}").contains("address is already saved"));
    728 
    729     let carol = Wallet::from_seed("address-book-carol");
    730     super::upsert_address_book_entry(
    731         &state,
    732         carol.address().to_string(),
    733         "Carol".to_string(),
    734         None,
    735     )
    736     .await
    737     .unwrap();
    738     let error = super::upsert_address_book_entry(
    739         &state,
    740         carol.address().to_string(),
    741         "Bob As Carol".to_string(),
    742         Some(alice.address().to_string()),
    743     )
    744     .await
    745     .unwrap_err();
    746     assert!(format!("{error:#}").contains("address is already saved"));
    747 
    748     super::upsert_address_book_entry(
    749         &state,
    750         bob.address().to_string(),
    751         "Bob".to_string(),
    752         Some(alice.address().to_string()),
    753     )
    754     .await
    755     .unwrap();
    756     let config = config_store::load_or_create(&config_path).unwrap();
    757     assert!(!config.address_book.contains_key(alice.address()));
    758     assert_eq!(
    759         config.address_book.get(bob.address()),
    760         Some(&"Bob".to_string())
    761     );
    762     assert_eq!(
    763         config.address_book.get(carol.address()),
    764         Some(&"Carol".to_string())
    765     );
    766 
    767     let error = super::upsert_address_book_entry(
    768         &state,
    769         "iuna-address".to_string(),
    770         "Not Alice".to_string(),
    771         None,
    772     )
    773     .await
    774     .unwrap_err();
    775     assert!(format!("{error:#}").contains("invalid address book address"));
    776 
    777     super::remove_address_book_entry(&state, bob.address().to_string())
    778         .await
    779         .unwrap();
    780     let config = config_store::load_or_create(&config_path).unwrap();
    781     assert!(!config.address_book.contains_key(bob.address()));
    782     assert!(config.address_book.contains_key(carol.address()));
    783 }
    784 
    785 #[tokio::test]
    786 async fn p2p_announce_setting_persists_config_and_waits_for_public_node() {
    787     let dir = tempfile::tempdir().unwrap();
    788     let config_path = dir.path().join("config.json");
    789     let state = auth_test_state(
    790         config_path.clone(),
    791         UiConfig {
    792             setup_complete: true,
    793             ..UiConfig::default()
    794         },
    795     )
    796     .await;
    797 
    798     super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string())
    799         .await
    800         .unwrap();
    801 
    802     let config = config_store::load_or_create(&config_path).unwrap();
    803     assert_eq!(
    804         config.p2p_announce_addr.as_deref(),
    805         Some("203.0.113.10:9444")
    806     );
    807     match state.gossip.peer_exchange().await {
    808         GossipEnvelope::PeerList { peers } => {
    809             assert!(!peers.contains(&"203.0.113.10:9444".to_string()));
    810         }
    811         other => panic!("expected peer list, got {other:?}"),
    812     }
    813 
    814     super::set_p2p_accept_inbound(&state, true, Some(9555))
    815         .await
    816         .unwrap();
    817     let config = config_store::load_or_create(&config_path).unwrap();
    818     assert!(config.p2p_accept_inbound);
    819     assert_eq!(config.p2p_bind_port, 9555);
    820     assert!(!state.gossip.accepts_inbound().await);
    821     match state.gossip.peer_exchange().await {
    822         GossipEnvelope::PeerList { peers } => {
    823             assert!(!peers.contains(&"203.0.113.10:9444".to_string()));
    824         }
    825         other => panic!("expected peer list, got {other:?}"),
    826     }
    827 
    828     super::set_p2p_accept_inbound(&state, false, None)
    829         .await
    830         .unwrap();
    831     let config = config_store::load_or_create(&config_path).unwrap();
    832     assert!(!config.p2p_accept_inbound);
    833     assert!(!state.gossip.accepts_inbound().await);
    834 
    835     super::set_p2p_announce_addr(&state, " ".to_string())
    836         .await
    837         .unwrap();
    838     let config = config_store::load_or_create(&config_path).unwrap();
    839     assert!(config.p2p_announce_addr.is_none());
    840 }
    841 
    842 #[tokio::test]
    843 async fn p2p_announce_setting_rejects_invalid_address() {
    844     let dir = tempfile::tempdir().unwrap();
    845     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
    846 
    847     let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string())
    848         .await
    849         .unwrap_err();
    850 
    851     assert!(format!("{error:#}").contains("invalid P2P announce address"));
    852 }
    853 
    854 #[tokio::test]
    855 async fn setup_config_form_can_add_bootstrap_peer() {
    856     let dir = tempfile::tempdir().unwrap();
    857     let config_path = dir.path().join("config.json");
    858     let state = auth_test_state(config_path.clone(), UiConfig::default()).await;
    859 
    860     super::apply_config_form(
    861         &state,
    862         super::ConfigForm {
    863             setup_complete: true,
    864             peer: " iuna.jhx.app:9444 ".to_string(),
    865         },
    866     )
    867     .await
    868     .unwrap();
    869 
    870     let config = config_store::load_or_create(&config_path).unwrap();
    871     assert!(config.setup_complete);
    872     assert_eq!(config.peers, vec!["iuna.jhx.app:9444"]);
    873     assert_eq!(
    874         state.peers.lock().await.addresses(),
    875         vec!["iuna.jhx.app:9444"]
    876     );
    877 }
    878 
    879 #[tokio::test]
    880 async fn setup_config_form_requires_peer_for_placeholder_chain() {
    881     let dir = tempfile::tempdir().unwrap();
    882     let config_path = dir.path().join("config.json");
    883     let state = auth_test_state(config_path.clone(), UiConfig::default()).await;
    884 
    885     let error = super::apply_config_form(
    886         &state,
    887         super::ConfigForm {
    888             setup_complete: true,
    889             peer: " ".to_string(),
    890         },
    891     )
    892     .await
    893     .unwrap_err();
    894 
    895     assert!(format!("{error:#}").contains("add a bootstrap peer"));
    896     let config = config_store::load_or_create(&config_path).unwrap();
    897     assert!(!config.setup_complete);
    898 }
    899 
    900 #[tokio::test]
    901 async fn peer_management_rejects_empty_and_inbound_removal() {
    902     let dir = tempfile::tempdir().unwrap();
    903     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
    904 
    905     assert!(super::add_peer(&state, "   ".to_string()).await.is_err());
    906     state
    907         .peers
    908         .lock()
    909         .await
    910         .record_received("127.0.0.1:9555", 1);
    911 
    912     let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await;
    913     assert!(result.is_err());
    914     assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new());
    915 }
    916 
    917 #[tokio::test]
    918 async fn network_health_summarizes_sync_and_peer_errors() {
    919     let dir = tempfile::tempdir().unwrap();
    920     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
    921     let status = state.node.lock().await.status();
    922     let local = super::NetworkHealthLocalState {
    923         height: status.chain.height,
    924         pending_transactions: status.chain.pending_transactions,
    925     };
    926     let mempool = super::MempoolCounts {
    927         plain_transactions: 1,
    928         blinded_transactions: 2,
    929         blinded_reveals: 3,
    930     };
    931 
    932     let isolated = super::network_health(local, &[], mempool);
    933     assert!(!isolated.ok);
    934     assert_eq!(isolated.state, "isolated");
    935     assert_eq!(isolated.local_height, 0);
    936     assert_eq!(isolated.best_known_height, 0);
    937     assert_eq!(isolated.pending_plain_transactions, 1);
    938     assert_eq!(isolated.pending_blinded_transactions, 2);
    939     assert_eq!(isolated.pending_blinded_reveals, 3);
    940 
    941     let mut clock_peers = PeerBook::from_addresses(vec![
    942         "127.0.0.1:9450".to_string(),
    943         "127.0.0.1:9451".to_string(),
    944     ]);
    945     clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string());
    946     clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string());
    947     clock_peers.record_clock_observation("127.0.0.1:9450", PeerDirection::Outbound, 10_500, 10_000);
    948     clock_peers.record_clock_observation(
    949         "127.0.0.1:9451",
    950         PeerDirection::Outbound,
    951         11 * 60 * 1_000,
    952         10_000,
    953     );
    954     let clock_health = super::network_health_at(local, &clock_peers.list(), mempool, 10_000);
    955     assert_eq!(clock_health.network_time_offset_ms, Some(500));
    956     assert_eq!(clock_health.bad_clock_peers, 1);
    957 
    958     let syncing = super::network_health(
    959         local,
    960         &[PeerInfo {
    961             address: "127.0.0.1:9445".to_string(),
    962             direction: PeerDirection::Outbound,
    963             messages_sent: 1,
    964             messages_received: 1,
    965             last_known_height: Some(3),
    966             last_known_tip_hash: Some("remote-tip".to_string()),
    967             last_clock_offset_ms: None,
    968             last_clock_offset_accepted: None,
    969             last_clock_observed_ms: None,
    970             last_error: None,
    971             last_contact_ms: Some(10_000),
    972             last_success_ms: Some(10_000),
    973             last_error_ms: None,
    974             misbehavior_score: 0,
    975             banned_until_ms: None,
    976             ban_reason: None,
    977         }],
    978         mempool,
    979     );
    980     assert!(!syncing.ok);
    981     assert_eq!(syncing.state, "syncing");
    982     assert_eq!(syncing.best_known_height, 3);
    983     assert_eq!(syncing.lag_blocks, 3);
    984 
    985     let peer_errors = super::network_health(
    986         local,
    987         &[PeerInfo {
    988             address: "127.0.0.1:9446".to_string(),
    989             direction: PeerDirection::Outbound,
    990             messages_sent: 0,
    991             messages_received: 0,
    992             last_known_height: None,
    993             last_known_tip_hash: None,
    994             last_clock_offset_ms: None,
    995             last_clock_offset_accepted: None,
    996             last_clock_observed_ms: None,
    997             last_error: Some("connection refused".to_string()),
    998             last_contact_ms: Some(10_000),
    999             last_success_ms: None,
   1000             last_error_ms: Some(10_000),
   1001             misbehavior_score: 1,
   1002             banned_until_ms: None,
   1003             ban_reason: Some("connection refused".to_string()),
   1004         }],
   1005         mempool,
   1006     );
   1007     assert!(!peer_errors.ok);
   1008     assert_eq!(peer_errors.state, "peer errors");
   1009     assert_eq!(
   1010         peer_errors.last_error.as_deref(),
   1011         Some("127.0.0.1:9446: connection refused")
   1012     );
   1013 
   1014     let stale = super::network_health_at(
   1015         local,
   1016         &[PeerInfo {
   1017             address: "127.0.0.1:9447".to_string(),
   1018             direction: PeerDirection::Outbound,
   1019             messages_sent: 1,
   1020             messages_received: 1,
   1021             last_known_height: Some(0),
   1022             last_known_tip_hash: Some("tip".to_string()),
   1023             last_clock_offset_ms: None,
   1024             last_clock_offset_accepted: None,
   1025             last_clock_observed_ms: None,
   1026             last_error: None,
   1027             last_contact_ms: Some(1),
   1028             last_success_ms: Some(1),
   1029             last_error_ms: None,
   1030             misbehavior_score: 0,
   1031             banned_until_ms: None,
   1032             ban_reason: None,
   1033         }],
   1034         mempool,
   1035         PEER_STALE_AFTER_MS + 2,
   1036     );
   1037     assert!(!stale.ok);
   1038     assert_eq!(stale.state, "stale");
   1039     assert_eq!(stale.stale_peers, 1);
   1040 
   1041     let banned = super::network_health_at(
   1042         local,
   1043         &[PeerInfo {
   1044             address: "127.0.0.1:9448".to_string(),
   1045             direction: PeerDirection::Outbound,
   1046             messages_sent: 0,
   1047             messages_received: 0,
   1048             last_known_height: None,
   1049             last_known_tip_hash: None,
   1050             last_clock_offset_ms: None,
   1051             last_clock_offset_accepted: None,
   1052             last_clock_observed_ms: None,
   1053             last_error: Some("invalid block".to_string()),
   1054             last_contact_ms: Some(10),
   1055             last_success_ms: None,
   1056             last_error_ms: Some(10),
   1057             misbehavior_score: 3,
   1058             banned_until_ms: Some(1_000),
   1059             ban_reason: Some("invalid block".to_string()),
   1060         }],
   1061         mempool,
   1062         20,
   1063     );
   1064     assert!(!banned.ok);
   1065     assert_eq!(banned.state, "banned");
   1066     assert_eq!(banned.banned_peers, 1);
   1067 }
   1068 
   1069 #[test]
   1070 fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() {
   1071     let alice = Wallet::from_seed("wallet-history-alice");
   1072     let bob = Wallet::from_seed("wallet-history-bob");
   1073     let carol = Wallet::from_seed("wallet-history-carol");
   1074     let mut allocations = BTreeMap::new();
   1075     allocations.insert(alice.address().to_string(), 100);
   1076     allocations.insert(bob.address().to_string(), 100);
   1077     allocations.insert(carol.address().to_string(), 100);
   1078     let ledger = crate::domain::Ledger::new(allocations.clone(), 1);
   1079     let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap();
   1080     let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap();
   1081     let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap();
   1082     let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap();
   1083     let chain = vec![
   1084         fake_block(30, vec![carol_transfer]),
   1085         fake_block(31, vec![old_received.clone()]),
   1086         fake_block(32, vec![carol_burn]),
   1087     ];
   1088 
   1089     let snapshot = fake_snapshot(allocations, chain.clone());
   1090     let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn));
   1091     let rows = wallet_transaction_rows(
   1092         alice.address(),
   1093         vec![pending_burn.clone()],
   1094         Vec::new(),
   1095         &chain,
   1096         &BTreeMap::new(),
   1097         &outputs,
   1098         WalletTransactionFilters::default(),
   1099     );
   1100 
   1101     assert_eq!(rows.len(), 1);
   1102     assert_ne!(rows[0].signature, pending_burn.signature());
   1103     assert_eq!(rows[0].signature, old_received.signature());
   1104     assert_eq!(rows[0].inputs[0].amount, Some(100));
   1105     assert_eq!(rows[0].status, "confirmed");
   1106     assert_eq!(rows[0].block_height, Some(31));
   1107     assert_eq!(rows[0].timestamp_ms, Some(31));
   1108     assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner"));
   1109     assert_eq!(rows[0].direction, "received");
   1110 }
   1111 
   1112 #[test]
   1113 fn wallet_transactions_show_owned_blinded_payloads_as_pending_blind() {
   1114     let alice = Wallet::from_seed("wallet-blind-pending-alice");
   1115     let bob = Wallet::from_seed("wallet-blind-pending-bob");
   1116     let mut allocations = BTreeMap::new();
   1117     allocations.insert(alice.address().to_string(), 100);
   1118     allocations.insert(bob.address().to_string(), 100);
   1119     let ledger = Ledger::new(allocations.clone(), 1);
   1120     let pending_blind = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap();
   1121     let snapshot = fake_snapshot(allocations, Vec::new());
   1122     let outputs = super::known_output_index(&snapshot, &[]);
   1123 
   1124     let rows = wallet_transaction_rows(
   1125         alice.address(),
   1126         Vec::new(),
   1127         vec![pending_blind.clone()],
   1128         &[],
   1129         &BTreeMap::new(),
   1130         &outputs,
   1131         WalletTransactionFilters::default(),
   1132     );
   1133 
   1134     assert_eq!(rows.len(), 1);
   1135     assert_eq!(rows[0].kind, "transfer");
   1136     assert_eq!(rows[0].status, "pending");
   1137     assert_eq!(rows[0].timestamp_ms, None);
   1138     assert!(rows[0].blinded);
   1139     assert_eq!(rows[0].direction, "sent");
   1140     assert_eq!(rows[0].to.as_deref(), Some(bob.address()));
   1141     assert_eq!(rows[0].amount, 12);
   1142     assert_eq!(rows[0].fee, 3);
   1143     assert_eq!(rows[0].signature, pending_blind.signature());
   1144 }
   1145 
   1146 #[test]
   1147 fn wallet_transaction_query_defaults_to_tx_only() {
   1148     assert_eq!(
   1149         WalletTransactionFilters::from_query(WalletTransactionsQuery::default()),
   1150         WalletTransactionFilters::default()
   1151     );
   1152     assert_eq!(
   1153         WalletTransactionFilters::from_query(WalletTransactionsQuery {
   1154             tx: Some(false),
   1155             mine: Some(true),
   1156             burn: Some(true),
   1157             offset: None,
   1158             limit: None,
   1159         }),
   1160         WalletTransactionFilters {
   1161             transfer: false,
   1162             mine: true,
   1163             burn: true,
   1164         }
   1165     );
   1166 }
   1167 
   1168 #[test]
   1169 fn page_items_returns_bounded_slices_with_next_offset() {
   1170     let page = super::page_items(
   1171         vec![1, 2, 3, 4, 5],
   1172         super::PageQuery {
   1173             offset: Some(1),
   1174             limit: Some(2),
   1175         },
   1176     );
   1177 
   1178     assert_eq!(page.items, vec![2, 3]);
   1179     assert_eq!(page.offset, 1);
   1180     assert_eq!(page.limit, 2);
   1181     assert_eq!(page.total, 5);
   1182     assert!(page.has_more);
   1183     assert_eq!(page.next_offset, Some(3));
   1184 }
   1185 
   1186 #[test]
   1187 fn page_items_clamps_limit_and_empty_tail() {
   1188     let page = super::page_items(
   1189         vec![1, 2],
   1190         super::PageQuery {
   1191             offset: Some(20),
   1192             limit: Some(0),
   1193         },
   1194     );
   1195 
   1196     assert!(page.items.is_empty());
   1197     assert_eq!(page.offset, 2);
   1198     assert_eq!(page.limit, 1);
   1199     assert_eq!(page.total, 2);
   1200     assert!(!page.has_more);
   1201     assert_eq!(page.next_offset, None);
   1202 }
   1203 
   1204 #[test]
   1205 fn mine_transaction_views_include_protocol_finalizer_fee() {
   1206     let alice = Wallet::from_seed("wallet-mine-fee-alice");
   1207     let ledger = Ledger::new(BTreeMap::new(), 1);
   1208     let mine = ledger.build_mine(alice.address()).unwrap();
   1209     let chain = vec![fake_block(1, vec![mine.clone()])];
   1210     let snapshot = fake_snapshot(BTreeMap::new(), chain.clone());
   1211     let outputs = super::known_output_index(&snapshot, &[]);
   1212 
   1213     let rows = wallet_transaction_rows(
   1214         alice.address(),
   1215         Vec::new(),
   1216         Vec::new(),
   1217         &chain,
   1218         &BTreeMap::new(),
   1219         &outputs,
   1220         WalletTransactionFilters {
   1221             transfer: false,
   1222             mine: true,
   1223             burn: false,
   1224         },
   1225     );
   1226     let transaction = super::ui_transaction(&mine, &outputs);
   1227 
   1228     assert_eq!(rows.len(), 1);
   1229     assert_eq!(rows[0].amount, mine.amount());
   1230     assert_eq!(rows[0].fee, MINE_FINALIZER_FEE);
   1231     assert_eq!(transaction.amount, mine.amount());
   1232     assert_eq!(transaction.fee, MINE_FINALIZER_FEE);
   1233 }
   1234 
   1235 #[test]
   1236 fn wallet_transactions_include_public_mine_actions() {
   1237     let alice = Wallet::from_seed("wallet-revealed-mine-alice");
   1238     let ledger = Ledger::new(
   1239         BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]),
   1240         1,
   1241     );
   1242     let mine = ledger.build_mine(alice.address()).unwrap();
   1243     let mut mine_block = fake_block(8, vec![mine.clone()]);
   1244     mine_block.reward = mine.fee();
   1245     let chain = vec![mine_block.clone()];
   1246     let snapshot = fake_snapshot(BTreeMap::new(), chain.clone());
   1247     let revealed_by_height = super::revealed_transactions_by_height(&snapshot);
   1248     let outputs = super::known_output_index(&snapshot, &[]);
   1249 
   1250     let rows = wallet_transaction_rows(
   1251         alice.address(),
   1252         Vec::new(),
   1253         Vec::new(),
   1254         &chain,
   1255         &revealed_by_height,
   1256         &outputs,
   1257         WalletTransactionFilters {
   1258             transfer: false,
   1259             mine: true,
   1260             burn: false,
   1261         },
   1262     );
   1263 
   1264     assert_eq!(rows.len(), 1);
   1265     assert_eq!(rows[0].kind, "mine");
   1266     assert_eq!(rows[0].status, "confirmed");
   1267     assert_eq!(rows[0].block_height, Some(8));
   1268     assert_eq!(rows[0].timestamp_ms, Some(8));
   1269     assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner"));
   1270     assert_eq!(rows[0].direction, "received");
   1271     assert_eq!(rows[0].amount, mine.amount());
   1272     assert_eq!(rows[0].fee, MINE_FINALIZER_FEE);
   1273     assert!(!rows[0].blinded);
   1274     assert_eq!(rows[0].signature, mine.signature());
   1275 }
   1276 
   1277 #[test]
   1278 fn burn_wallet_transactions_require_burn_filter() {
   1279     let alice = Wallet::from_seed("wallet-burn-filter-alice");
   1280     let mut allocations = BTreeMap::new();
   1281     allocations.insert(alice.address().to_string(), 10);
   1282     let ledger = Ledger::new(allocations.clone(), 1);
   1283     let burn = ledger.build_burn(&alice, 3, 1).unwrap();
   1284     let snapshot = fake_snapshot(allocations, Vec::new());
   1285     let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn));
   1286 
   1287     let default_rows = wallet_transaction_rows(
   1288         alice.address(),
   1289         vec![burn.clone()],
   1290         Vec::new(),
   1291         &[],
   1292         &BTreeMap::new(),
   1293         &outputs,
   1294         WalletTransactionFilters::default(),
   1295     );
   1296     let burn_rows = wallet_transaction_rows(
   1297         alice.address(),
   1298         vec![burn.clone()],
   1299         Vec::new(),
   1300         &[],
   1301         &BTreeMap::new(),
   1302         &outputs,
   1303         WalletTransactionFilters {
   1304             transfer: false,
   1305             mine: false,
   1306             burn: true,
   1307         },
   1308     );
   1309 
   1310     assert!(default_rows.is_empty());
   1311     assert_eq!(burn_rows.len(), 1);
   1312     assert_eq!(burn_rows[0].kind, "burn");
   1313     assert_eq!(burn_rows[0].direction, "burned");
   1314     assert_eq!(burn_rows[0].amount, 3);
   1315     assert_eq!(burn_rows[0].fee, 1);
   1316 }
   1317 
   1318 #[test]
   1319 fn wallet_utxo_rows_include_pending_spent_outputs_as_disabled() {
   1320     let alice = Wallet::from_seed("wallet-utxo-pending-alice");
   1321     let bob = Wallet::from_seed("wallet-utxo-pending-bob");
   1322     let mut allocations = BTreeMap::new();
   1323     allocations.insert(alice.address().to_string(), 10);
   1324     let mut ledger = Ledger::new(allocations, 1);
   1325     let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap();
   1326     let Transaction::Transfer { inputs, .. } = &pending else {
   1327         panic!("expected transfer");
   1328     };
   1329     let spent_outpoint = inputs[0].outpoint.clone();
   1330 
   1331     ledger.submit_transaction(pending).unwrap();
   1332     let rows = wallet_utxo_rows(&ledger, alice.address());
   1333 
   1334     assert!(rows.iter().any(|row| row.outpoint == spent_outpoint));
   1335     assert!(
   1336         rows.iter()
   1337             .any(|row| row.outpoint == spent_outpoint && !row.spendable)
   1338     );
   1339 }
   1340 
   1341 #[test]
   1342 fn selectable_wallet_utxo_rows_include_only_spendable_outputs() {
   1343     let alice = Wallet::from_seed("wallet-utxo-selectable-alice");
   1344     let bob = Wallet::from_seed("wallet-utxo-selectable-bob");
   1345     let mut allocations = BTreeMap::new();
   1346     allocations.insert(alice.address().to_string(), 10);
   1347     let mut ledger = Ledger::new(allocations, 1);
   1348     let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap();
   1349     let Transaction::Transfer { inputs, .. } = &pending else {
   1350         panic!("expected transfer");
   1351     };
   1352     let spent_outpoint = inputs[0].outpoint.clone();
   1353 
   1354     ledger.submit_transaction(pending).unwrap();
   1355     let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address());
   1356 
   1357     assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint));
   1358     assert!(rows.iter().all(|row| row.spendable));
   1359 }
   1360 
   1361 #[test]
   1362 fn wallet_utxo_rows_treat_owned_blinded_spends_as_pending() {
   1363     let alice = Wallet::from_seed("wallet-utxo-blind-pending-alice");
   1364     let bob = Wallet::from_seed("wallet-utxo-blind-pending-bob");
   1365     let mut allocations = BTreeMap::new();
   1366     allocations.insert(alice.address().to_string(), 10);
   1367     let ledger = Ledger::new(allocations, 1);
   1368     let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0);
   1369     let pending = node.transfer_with_fee(bob.address(), 3, 0).unwrap();
   1370     let Transaction::Transfer { inputs, .. } = &pending else {
   1371         panic!("expected transfer");
   1372     };
   1373     let spent_outpoint = inputs[0].outpoint.clone();
   1374     let wallet_view = node.wallet_view_ledger().unwrap();
   1375 
   1376     let rows = wallet_utxo_rows(&wallet_view, alice.address());
   1377     let selectable = super::selectable_wallet_utxo_rows(&wallet_view, alice.address());
   1378 
   1379     assert!(
   1380         rows.iter()
   1381             .any(|row| row.outpoint == spent_outpoint && !row.spendable)
   1382     );
   1383     assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint));
   1384 }
   1385 
   1386 #[test]
   1387 fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() {
   1388     let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice");
   1389     let mut allocations = BTreeMap::new();
   1390     allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
   1391     let ledger = Ledger::new_with_genesis_burns(
   1392         allocations,
   1393         vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
   1394         1,
   1395     )
   1396     .unwrap();
   1397     let mut node =
   1398         NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0);
   1399 
   1400     let plan = node.prepare_automatic_finalization(1);
   1401     assert!(plan.burned.is_some());
   1402     let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address());
   1403 
   1404     assert!(!rows.is_empty());
   1405     assert!(rows.iter().any(|row| !row.spendable));
   1406 }
   1407 
   1408 fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block {
   1409     Block {
   1410         height,
   1411         prev_hash: format!("prev-{height}"),
   1412         timestamp_ms: height,
   1413         miner: "miner".to_string(),
   1414         finalizer_mode: crate::domain::FinalizerMode::Ticket,
   1415         finalizer_rank: 0,
   1416         reward: 100,
   1417         vdf_rounds: 0,
   1418         vdf_output: "vdf".to_string(),
   1419         leader_proof: None,
   1420         blinded_transactions: Vec::new(),
   1421         reveal_bundle_section: RevealBundleSection::default(),
   1422         transactions,
   1423         hash: format!("hash-{height}"),
   1424     }
   1425 }
   1426 
   1427 fn fake_snapshot(
   1428     genesis_allocations: BTreeMap<String, Amount>,
   1429     blocks: Vec<Block>,
   1430 ) -> ChainSnapshot {
   1431     ChainSnapshot {
   1432         genesis_allocations,
   1433         vdf_rounds: 1,
   1434         launch_profile: LaunchProfile::default(),
   1435         blocks,
   1436     }
   1437 }
   1438 
   1439 fn metric_row(
   1440     height: u64,
   1441     block_time_ms: Option<u64>,
   1442     vdf_rounds: u64,
   1443 ) -> crate::adapters::ui_data_store::BlockMetricRow {
   1444     crate::adapters::ui_data_store::BlockMetricRow {
   1445         height,
   1446         block_hash: format!("hash-{height}"),
   1447         timestamp_ms: height,
   1448         block_time_ms,
   1449         mine_difficulty_bits: 12,
   1450         circulating_supply: 100,
   1451         known_wallet_addresses: 1,
   1452         transaction_count: 0,
   1453         transfer_count: 0,
   1454         burn_count: 0,
   1455         mine_count: 0,
   1456         burned_amount: 0,
   1457         total_burned_amount: 0,
   1458         fees_amount: 0,
   1459         reward_amount: 0,
   1460         vdf_rounds,
   1461         finalizer_rank: 0,
   1462     }
   1463 }
   1464 
   1465 #[test]
   1466 fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() {
   1467     let response = super::metrics_response(
   1468         true,
   1469         vec![
   1470             metric_row(0, None, 0),
   1471             metric_row(1, Some(1_764_000_000_000), 0),
   1472             metric_row(2, Some(600_000), 120),
   1473             metric_row(3, Some(610_000), 130),
   1474         ],
   1475         super::types::MetricsLeaderboards::default(),
   1476     );
   1477 
   1478     let block_time = response
   1479         .charts
   1480         .iter()
   1481         .find(|chart| chart.id == "block-time")
   1482         .expect("block time chart should exist");
   1483     assert_eq!(
   1484         block_time
   1485             .points
   1486             .iter()
   1487             .map(|point| (point.height, point.value))
   1488             .collect::<Vec<_>>(),
   1489         vec![(2, 600.0), (3, 610.0)]
   1490     );
   1491 
   1492     let vdf_rounds = response
   1493         .charts
   1494         .iter()
   1495         .find(|chart| chart.id == "vdf-rounds")
   1496         .expect("VDF rounds chart should exist");
   1497     assert_eq!(
   1498         vdf_rounds
   1499             .points
   1500             .iter()
   1501             .map(|point| (point.height, point.value))
   1502             .collect::<Vec<_>>(),
   1503         vec![(2, 120.0), (3, 130.0)]
   1504     );
   1505 
   1506     let known_wallet_addresses = response
   1507         .charts
   1508         .iter()
   1509         .find(|chart| chart.id == "known-wallet-addresses")
   1510         .expect("known wallet addresses chart should exist");
   1511     assert_eq!(
   1512         known_wallet_addresses
   1513             .points
   1514             .iter()
   1515             .map(|point| (point.height, point.value))
   1516             .collect::<Vec<_>>(),
   1517         vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)]
   1518     );
   1519 }
   1520 
   1521 #[test]
   1522 fn metrics_screen_includes_block_range_filter() {
   1523     assert!(super::INDEX_HTML.contains("iuna-ui.js?v=103"));
   1524     assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\""));
   1525     assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
   1526     assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
   1527     assert!(super::INDEX_HTML.contains("setMetricsRange('all')"));
   1528     assert!(super::INDEX_HTML.contains("Known addresses"));
   1529     assert!(super::INDEX_HTML.contains("knownWalletAddresses"));
   1530     assert!(super::INDEX_HTML.contains("metric-chart-hover-point"));
   1531     assert!(!super::INDEX_HTML.contains("metric-chart-point-hit"));
   1532     assert!(!super::INDEX_HTML.contains("metric-chart-hover-point\" x-show"));
   1533     assert!(super::INDEX_HTML.contains("<template x-if=\"metricHover?.chartId === chart.id\">"));
   1534     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1535     assert!(app_js.contains("shellRefreshPromise: null"));
   1536     assert!(app_js.contains("metricsRequestSeq: 0"));
   1537     assert!(app_js.contains("await this.refreshMetrics(options);"));
   1538     assert!(app_js.contains("this.refreshShellState({ addressBookVersion, silent: true });"));
   1539     assert!(app_js.contains("async refreshShellState(options = {})"));
   1540     assert!(app_js.contains("this.refreshMetrics();"));
   1541     assert!(!app_js.contains("this.blockchainMetrics = { enabled: this.blockchainMetrics?.enabled ?? true, latest: this.blockchainMetrics?.latest ?? null, charts: [] };\n        this.refresh({ force: true });"));
   1542     assert!(app_js.contains("async fetchMetricsResponse(range = this.metricsRange)"));
   1543     assert!(app_js.contains("prepareMetricsResponse(metrics)"));
   1544     assert!(app_js.contains("_linePoints: linePoints"));
   1545     assert!(app_js.contains("_gridPath: gridPath"));
   1546     assert!(!app_js.contains("label: this.metricPointLabel(chart, point)"));
   1547     assert!(app_js.contains("label: this.metricPointLabel(chart, marker)"));
   1548     assert!(app_js.contains("metricHoverPointStyle(chart)"));
   1549 }
   1550 
   1551 #[test]
   1552 fn version_update_check_normalizes_tags_before_comparing() {
   1553     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1554     let update_available = app_js
   1555         .split("updateAvailable()")
   1556         .nth(1)
   1557         .expect("updateAvailable should exist")
   1558         .split("versionPanelTitle()")
   1559         .next()
   1560         .expect("updateAvailable should precede versionPanelTitle");
   1561 
   1562     assert!(
   1563         update_available
   1564             .contains("const current = this.normalizeVersion(this.status.app_version);")
   1565     );
   1566     assert!(
   1567         update_available.contains("const latest = this.normalizeVersion(this.latestRelease?.tag);")
   1568     );
   1569     assert!(update_available.contains("if (current === latest) return false;"));
   1570 }
   1571 
   1572 #[test]
   1573 fn blocks_endpoint_uses_cached_block_projection_without_cloning_full_ui_index() {
   1574     let api_rs = include_str!("api.rs");
   1575     assert!(api_rs.contains("cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks)"));
   1576     assert!(!api_rs.contains("let mut outputs = view.outputs;\n    add_pending_outputs(&mut outputs, &pending);\n    Json(ui_blocks_from_indexes(\n        blocks,\n        &outputs,"));
   1577 }
   1578 
   1579 #[test]
   1580 fn mempool_and_wallet_transaction_endpoints_use_narrow_output_lookups() {
   1581     let api_rs = include_str!("api.rs");
   1582     assert!(api_rs.contains("load_outputs_for_outpoints(&state, required_outputs)"));
   1583     assert!(api_rs.contains("load_outputs_for_outpoints(&state, pending_required_outputs)"));
   1584     assert!(api_rs.contains("load_outputs_for_outpoints(&state, confirmed_required_outputs)"));
   1585     assert!(!api_rs.contains("let mut outputs = view.outputs;"));
   1586 }
   1587 
   1588 #[test]
   1589 fn http_server_logs_slow_api_requests() {
   1590     let http_rs = include_str!("../http.rs");
   1591     assert!(http_rs.contains("const SLOW_UI_REQUEST_LOG_MS: u128 = 250;"));
   1592     assert!(http_rs.contains("async fn log_slow_api_request"));
   1593     assert!(http_rs.contains("slow UI API request: {method} {path}"));
   1594 }
   1595 
   1596 #[test]
   1597 fn fee_estimate_polling_is_tab_scoped() {
   1598     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1599     let refresh = app_js
   1600         .split("async refreshFeeEstimates()")
   1601         .nth(1)
   1602         .expect("refreshFeeEstimates should exist")
   1603         .split("async refreshBurnFeeEstimate()")
   1604         .next()
   1605         .expect("refreshFeeEstimates body should precede burn fee estimate");
   1606 
   1607     assert!(refresh.contains("if (this.tab === \"wallet\")"));
   1608     assert!(refresh.contains("await this.refreshTransferFeeEstimate();"));
   1609     assert!(refresh.contains("if (this.tab === \"mining\")"));
   1610     assert!(refresh.contains("this.refreshBurnFeeEstimate()"));
   1611     assert!(refresh.contains("this.refreshMineFeeEstimate()"));
   1612 }
   1613 
   1614 #[test]
   1615 fn transfer_utxo_selection_supports_shift_click_ranges() {
   1616     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1617     assert!(
   1618         super::INDEX_HTML
   1619             .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\"")
   1620     );
   1621     assert!(super::INDEX_HTML.contains(".send-utxo-option {"));
   1622     assert!(super::INDEX_HTML.contains("user-select: none;"));
   1623     assert!(super::INDEX_HTML.contains(".send-utxo-option input {"));
   1624     assert!(super::INDEX_HTML.contains("pointer-events: none;"));
   1625     assert!(
   1626         !super::INDEX_HTML
   1627             .contains("x-model=\"selectedTransferUtxos\" @click=\"toggleTransferUtxoSelection")
   1628     );
   1629     assert!(!super::INDEX_HTML.contains("@change=\"toggleTransferUtxoSelection($event, utxo)\""));
   1630     assert!(
   1631         super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"")
   1632     );
   1633     assert!(
   1634         !super::INDEX_HTML
   1635             .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=")
   1636     );
   1637     assert!(app_js.contains("lastSelectedTransferUtxo: null"));
   1638     assert!(app_js.contains("toggleTransferUtxoSelection(event, utxo)"));
   1639     assert!(app_js.contains("const checked = !selected.has(outpoint);"));
   1640     assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0"));
   1641     assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort"));
   1642 }
   1643 
   1644 #[test]
   1645 fn reveal_mempool_items_show_unknown_fee_label() {
   1646     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1647     assert!(app_js.contains("txFeeLabel(tx)"));
   1648     assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\""));
   1649     assert!(app_js.contains("unknown until reveal"));
   1650     assert!(
   1651         app_js.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")")
   1652     );
   1653     assert!(app_js.contains("mempoolFirstSeenHeights"));
   1654     assert!(app_js.contains("mempoolFirstSeenAt"));
   1655     assert!(
   1656         app_js.contains("this.trackMempoolFirstSeenHeights({ append: options.replace !== true })")
   1657     );
   1658     assert!(app_js.contains("return rightSeenAt - leftSeenAt"));
   1659     assert!(app_js.contains("syncMempoolBlockMarker"));
   1660     assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight"));
   1661     assert!(app_js.contains("mempoolItemClass"));
   1662     assert!(app_js.contains("walletTxTimeLabel(tx)"));
   1663     assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms"));
   1664     assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\""));
   1665     assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\""));
   1666     assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>"));
   1667     assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\""));
   1668 }
   1669 
   1670 #[test]
   1671 fn wallet_screen_includes_address_book_alias_controls() {
   1672     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1673     assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>"));
   1674     assert!(super::INDEX_HTML.contains("saveAddressBookEntry"));
   1675     assert!(super::INDEX_HTML.contains("addressBookEntries()"));
   1676     assert!(super::INDEX_HTML.contains("openAddressBookModal()"));
   1677     assert!(super::INDEX_HTML.contains("addressBookModalOpen"));
   1678     assert!(super::INDEX_HTML.contains("openAddressBookPicker()"));
   1679     assert!(super::INDEX_HTML.contains("addressBookPickerOpen"));
   1680     assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)"));
   1681     assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)"));
   1682     assert!(
   1683         super::INDEX_HTML
   1684             .contains("x-show=\"!addressBookModalOpen\" @click=\"openAddressBookModal()\"")
   1685     );
   1686     assert!(
   1687         super::INDEX_HTML.contains("class=\"address-book-form\" x-show=\"addressBookModalOpen\"")
   1688     );
   1689     assert!(
   1690         super::INDEX_HTML.contains("removeAddressBookEntry({ address: addressBookEditingAddress")
   1691     );
   1692     assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\""));
   1693     assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\""));
   1694     assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)"));
   1695     assert!(super::INDEX_HTML.contains("addressLabel(input.owner)"));
   1696     assert!(app_js.contains("addressBook: {}"));
   1697     assert!(app_js.contains("addressBookVersion: 0"));
   1698     assert!(app_js.contains("addressBookPickerOpen: false"));
   1699     assert!(app_js.contains("this.config.address_book || this.config.addressBook"));
   1700     assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion"));
   1701     assert!(app_js.contains("async saveAddressBookEntry()"));
   1702     assert!(app_js.contains("Address is already saved"));
   1703     assert!(app_js.contains("validAddressBookAddress(address)"));
   1704     assert!(app_js.contains("openAddressBookPicker()"));
   1705     assert!(app_js.contains("this.addressBookPickerOpen = true;"));
   1706     assert!(!app_js.contains("No contacts saved yet"));
   1707     assert!(app_js.contains("await this.submitForm(\"/api/address-book\""));
   1708     assert!(app_js.contains("\"/api/address-book\""));
   1709     assert!(app_js.contains("addressLabel(address)"));
   1710     assert!(app_js.contains("shortAddressLabel(address)"));
   1711 }
   1712 
   1713 #[test]
   1714 fn initial_setup_includes_node_mode_choices() {
   1715     assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\""));
   1716     assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')"));
   1717     assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')"));
   1718     assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')"));
   1719     assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'"));
   1720     assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\""));
   1721     assert!(super::INDEX_HTML.contains("Change later in Settings"));
   1722 }
   1723 
   1724 #[test]
   1725 fn setup_completion_refreshes_chain_data() {
   1726     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1727     assert!(
   1728         app_js.contains("await this.refresh({ force: true });\n        this.setupFeedback = null;")
   1729     );
   1730     assert!(
   1731         !app_js.contains(
   1732             "await this.refreshConfig();\n        await this.resetPagedDataset(\"peer\");"
   1733         )
   1734     );
   1735 }
   1736 
   1737 #[test]
   1738 fn setup_completion_applies_selected_node_mode() {
   1739     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1740     assert!(app_js.contains("setupNodeMode: \"wallet\""));
   1741     assert!(app_js.contains("async applySetupNodeMode()"));
   1742     assert!(app_js.contains("await this.applySetupNodeMode();"));
   1743     assert!(app_js.contains("\"/api/settings/p2p-inbound\""));
   1744     assert!(app_js.contains("bind_port: this.p2pBindPortValue()"));
   1745     assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")"));
   1746 }
   1747 
   1748 #[test]
   1749 fn p2p_bind_port_changes_show_global_restart_notice() {
   1750     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1751     assert!(super::INDEX_HTML.contains("Bind port"));
   1752     assert!(super::INDEX_HTML.contains("persistent-banner"));
   1753     assert!(super::INDEX_HTML.contains("p2pRestartRequired()"));
   1754     assert!(app_js.contains("p2pBindPort: 9444"));
   1755     assert!(app_js.contains("p2pConfiguredBindAddr()"));
   1756     assert!(app_js.contains("p2pRestartMessage()"));
   1757     assert!(app_js.contains("Restart iuna to close the public P2P listener."));
   1758     assert!(app_js.contains("0.0.0.0:${port}"));
   1759 }
   1760 
   1761 #[test]
   1762 fn settings_includes_dangerous_chain_reset_flow() {
   1763     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1764     assert!(super::INDEX_HTML.contains("Danger Zone"));
   1765     assert!(super::INDEX_HTML.contains("Delete local chain"));
   1766     assert!(super::INDEX_HTML.contains("Type <strong>RESET</strong> to confirm."));
   1767     assert!(super::INDEX_HTML.contains("class=\"danger\""));
   1768     assert!(super::INDEX_HTML.contains("chainResetModalOpen"));
   1769     assert!(app_js.contains("chainResetModalOpen: false"));
   1770     assert!(app_js.contains("async resetLocalChain()"));
   1771     assert!(app_js.contains("\"/api/settings/chain-reset\""));
   1772     assert!(app_js.contains("confirm: this.chainResetConfirm"));
   1773     assert!(app_js.contains(
   1774         "this.showFlash(\"Local chain deleted. Sync requested from peers.\", \"success\")"
   1775     ));
   1776     assert!(!app_js.contains("this.showSettingsFeedback(\"Local chain deleted."));
   1777     assert!(!app_js.contains("this.showSettingsFeedback(error.message, \"error\");\n      } finally {\n        this.chainResetBusy = false;"));
   1778 }
   1779 
   1780 #[tokio::test]
   1781 async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() {
   1782     let dir = tempfile::tempdir().unwrap();
   1783     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
   1784     let wallet = Wallet::from_seed("http-chain-reset-alice");
   1785     let mut genesis = BTreeMap::new();
   1786     genesis.insert(wallet.address().to_string(), 10);
   1787     let ledger =
   1788         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
   1789             .unwrap();
   1790     state.chain_store.save(&ledger.snapshot()).unwrap();
   1791     state
   1792         .ui_data_store
   1793         .project_snapshot(&ledger.snapshot(), true)
   1794         .unwrap();
   1795     {
   1796         let mut node = state.node.lock().await;
   1797         *node = NodeCore::from_ledger(wallet.clone(), ledger, 1);
   1798     }
   1799     {
   1800         let mut cache = state.ui_cache.lock().await;
   1801         cache.tip_hash = Some("stale-tip".to_string());
   1802     }
   1803 
   1804     let error = super::reset_local_chain(&state, "nope").await.unwrap_err();
   1805     assert!(error.to_string().contains("type RESET"));
   1806     assert!(state.chain_store.load().unwrap().is_some());
   1807     assert!(state.node.lock().await.has_real_chain());
   1808 
   1809     super::reset_local_chain(&state, "RESET").await.unwrap();
   1810 
   1811     let node = state.node.lock().await;
   1812     assert!(node.ledger().is_setup_placeholder());
   1813     assert_eq!(node.status().wallet_address, wallet.address());
   1814     drop(node);
   1815     assert!(state.chain_store.load().unwrap().is_none());
   1816     assert!(state.ui_data_store.load_metrics().unwrap().is_empty());
   1817     assert!(state.ui_cache.lock().await.tip_hash.is_none());
   1818 }
   1819 
   1820 #[test]
   1821 fn polling_refreshes_paged_datasets_without_visible_loaders() {
   1822     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1823     assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)"));
   1824     assert!(
   1825             app_js.contains(
   1826                 "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;"
   1827             )
   1828         );
   1829     assert!(app_js.contains(
   1830         "async refreshNow(options = {}) {\n      if (!this.canUseProtectedApi()) return;"
   1831     ));
   1832     assert!(app_js.contains("refreshPromise: null"));
   1833     assert!(app_js.contains("networkHealthPromise: null"));
   1834     assert!(app_js.contains("if (this.refreshPromise)"));
   1835     assert!(app_js.contains("return this.refreshPromise;"));
   1836     assert!(app_js.contains("options.force === true"));
   1837     assert!(app_js.contains("this.setTab(this.tabFromHash());"));
   1838     assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";"));
   1839     assert!(
   1840         app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\" && this.developmentMode();")
   1841     );
   1842     assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";"));
   1843     assert!(
   1844         app_js
   1845             .contains("if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");")
   1846     );
   1847     assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");"));
   1848     assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");"));
   1849     let merge_blocks_index = app_js
   1850         .find("if (blocks) this.mergeFreshBlocks(blocks, { animateHead: true });")
   1851         .expect("fresh blocks should be merged during refresh");
   1852     let paged_dataset_index = app_js
   1853         .find("this.refreshPagedDataset(kind, { silent: options.silent === true })")
   1854         .expect("paged datasets should refresh during refresh");
   1855     assert!(merge_blocks_index < paged_dataset_index);
   1856     assert!(app_js.contains("this.refreshNetworkHealth({ silent: options.silent === true });"));
   1857     assert!(app_js.contains("async refreshNetworkHealth(options = {})"));
   1858     assert!(app_js.contains("if (this.networkHealthPromise) return this.networkHealthPromise;"));
   1859     assert!(app_js.contains("cache: \"no-store\""));
   1860     assert!(app_js.contains("async fetchWithTimeout(path, options = {})"));
   1861     assert!(app_js.contains("controller.abort()"));
   1862     assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n      if (!this.canUseProtectedApi()) return;"));
   1863     assert!(
   1864         app_js
   1865             .contains("async loadNextPage(kind) {\n      if (!this.canUseProtectedApi()) return;")
   1866     );
   1867     assert!(
   1868         app_js.contains("async loadOlderBlocks() {\n      if (!this.canUseProtectedApi()) return;")
   1869     );
   1870     assert!(app_js.contains("this.stopPolling();\n          await this.refreshAuth();"));
   1871     assert!(app_js.contains("backgroundLoading"));
   1872     assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\""));
   1873 }
   1874 
   1875 #[test]
   1876 fn wallet_pending_blinded_transactions_are_labeled() {
   1877     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1878     assert!(app_js.contains("Pending blind"));
   1879     assert!(app_js.contains("tx.blinded"));
   1880 }
   1881 
   1882 #[test]
   1883 fn mine_screen_shows_fixed_pow_reward_without_burn_slider() {
   1884     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1885     assert!(app_js.contains("powMineReward()"));
   1886     assert!(super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward."));
   1887     assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())"));
   1888     assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\""));
   1889     assert!(super::INDEX_HTML.contains("PoB State"));
   1890     assert!(super::INDEX_HTML.contains("PoW State"));
   1891     assert!(super::INDEX_HTML.contains("Workers"));
   1892     assert!(super::INDEX_HTML.contains("Selected Finalizer"));
   1893     assert!(app_js.contains("pobStatusLabel()"));
   1894     assert!(app_js.contains("powStatusShortLabel()"));
   1895     assert!(app_js.contains("setPowMiningWorkers(workers)"));
   1896     assert!(app_js.contains("localMiningMempoolLabel()"));
   1897     assert!(app_js.contains("miningEventLog()"));
   1898     assert!(app_js.contains("miningEventLimit: 1000"));
   1899     assert!(app_js.contains("slice(0, this.miningEventLimit)"));
   1900     assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\""));
   1901     assert!(super::INDEX_HTML.contains("miningEventLog().length === 0"));
   1902     assert!(super::INDEX_HTML.contains("mining-event-empty"));
   1903     assert!(app_js.contains("Resource budget:"));
   1904     assert!(app_js.contains("isPowMineSuccessStatus"));
   1905     assert!(app_js.contains("You mined a PoW action"));
   1906     assert!(app_js.contains("Waiting for a finalizer to include it in a block."));
   1907     assert!(app_js.contains("Observed block"));
   1908     assert!(app_js.contains("Finalized by"));
   1909     assert!(app_js.contains("You finalized block"));
   1910     assert!(app_js.contains("if (!locallyFinalized)"));
   1911     assert!(app_js.contains("last?.title === title"));
   1912     assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";"));
   1913     assert!(app_js.contains("Automatic burn prepared at height"));
   1914     assert!(app_js.contains("Eligible for the next block opportunity."));
   1915     assert!(app_js.contains("!Number.isFinite(timestampMs)"));
   1916     assert!(!super::INDEX_HTML.contains("Needs burns"));
   1917 }
   1918 
   1919 #[test]
   1920 fn block_detail_finalizer_opens_burn_leader_ranks_modal() {
   1921     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1922     assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)"));
   1923     assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\""));
   1924     assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)"));
   1925     assert!(app_js.contains("selectedBurnLeaderBlock"));
   1926     assert!(app_js.contains("burnLeaderRankLabel(rank)"));
   1927     assert!(app_js.contains("block?.burn_leader_ranks"));
   1928     assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId"));
   1929 }
   1930 
   1931 #[test]
   1932 fn block_loading_skeleton_matches_block_card_layout() {
   1933     let app_js = include_str!("../../../www/assets/iuna-ui.js");
   1934     assert!(super::INDEX_HTML.contains("block-skeleton-group"));
   1935     assert!(super::INDEX_HTML.contains("block-card block-card-skeleton skeleton-card"));
   1936     assert!(super::INDEX_HTML.contains("skeleton-block-height"));
   1937     assert!(super::INDEX_HTML.contains("skeleton-block-meta"));
   1938     assert!(super::INDEX_HTML.contains("skeleton-block-miner"));
   1939     assert!(app_js.contains("const hadBlocks = this.blocks.length > 0;"));
   1940     assert!(app_js.contains("const previousScrollWidth = hadBlocks ? rail?.scrollWidth ?? 0 : 0;"));
   1941     assert!(app_js.contains("&& hadBlocks"));
   1942     assert!(app_js.contains("this.$nextTick(() => this.resetBlockRailPosition());"));
   1943     assert!(app_js.contains("resetBlockRailPosition()"));
   1944 }
   1945 
   1946 #[tokio::test]
   1947 async fn startup_prewarm_populates_chain_view_cache_before_first_request() {
   1948     let dir = tempfile::tempdir().unwrap();
   1949     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
   1950     let expected_tip = {
   1951         let node = state.node.lock().await;
   1952         node.chain_snapshot()
   1953             .blocks
   1954             .last()
   1955             .map(|block| block.hash.clone())
   1956     };
   1957     let sentinel = OutPoint {
   1958         txid: "persisted-ui-index-sentinel".to_string(),
   1959         index: 7,
   1960     };
   1961     let connection = rusqlite::Connection::open(state.ui_data_store.path()).unwrap();
   1962     connection
   1963         .execute(
   1964             r#"
   1965 INSERT INTO ui_cache_meta (id, schema_version, tip_hash, updated_at_ms)
   1966 VALUES (1, 1, ?1, 0)
   1967 "#,
   1968             rusqlite::params![expected_tip.as_ref().unwrap()],
   1969         )
   1970         .unwrap();
   1971     connection
   1972         .execute(
   1973             "INSERT INTO ui_burn_leader_rank_blocks (block_hash) VALUES (?1)",
   1974             rusqlite::params![expected_tip.as_ref().unwrap()],
   1975         )
   1976         .unwrap();
   1977     connection
   1978         .execute(
   1979             r#"
   1980 INSERT INTO ui_output_index (txid, output_index, address, amount)
   1981 VALUES (?1, ?2, ?3, ?4)
   1982 "#,
   1983             rusqlite::params![&sentinel.txid, sentinel.index, "cached-address", 123_u64],
   1984         )
   1985         .unwrap();
   1986     assert!(state.ui_cache.lock().await.tip_hash.is_none());
   1987 
   1988     super::prewarm_chain_view_cache(state.clone())
   1989         .await
   1990         .unwrap();
   1991 
   1992     let cache = state.ui_cache.lock().await;
   1993     assert_eq!(cache.tip_hash, expected_tip);
   1994     assert!(
   1995         cache
   1996             .tip_hash
   1997             .as_ref()
   1998             .is_some_and(|tip_hash| cache.burn_leader_ranks_by_hash.contains_key(tip_hash))
   1999     );
   2000     assert_eq!(
   2001         cache.outputs.get(&sentinel),
   2002         Some(&TxOutput {
   2003             address: "cached-address".to_string(),
   2004             amount: 123,
   2005         })
   2006     );
   2007 }
   2008 
   2009 #[tokio::test]
   2010 async fn ui_data_endpoint_catches_up_stale_projection_before_reading() {
   2011     let dir = tempfile::tempdir().unwrap();
   2012     let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
   2013     let wallet = Wallet::from_seed("http-ui-data-catch-up-wallet");
   2014     let mut genesis = BTreeMap::new();
   2015     genesis.insert(wallet.address().to_string(), 10);
   2016     let mut ledger =
   2017         Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1)
   2018             .unwrap();
   2019     let burn = ledger.build_burn(&wallet, 1, 0).unwrap();
   2020     ledger.submit_transaction(burn).unwrap();
   2021     let block = ledger.mine_next_block(&wallet, 1_000).unwrap();
   2022     ledger.apply_locally_mined_block(block).unwrap();
   2023     let expected_tip = ledger.status().tip_hash;
   2024     {
   2025         let mut node = state.node.lock().await;
   2026         *node = NodeCore::from_ledger(wallet.clone(), ledger, 0);
   2027     }
   2028 
   2029     assert!(state.chain_store.load().unwrap().is_none());
   2030     assert!(!state.ui_data_store.is_projected_to(&expected_tip).unwrap());
   2031 
   2032     let Json(page) =
   2033         super::api_wallet_utxos(State(state.clone()), Query(super::PageQuery::default())).await;
   2034 
   2035     assert!(
   2036         page.items
   2037             .iter()
   2038             .any(|utxo| utxo.address == wallet.address())
   2039     );
   2040     let persisted_tip = state
   2041         .chain_store
   2042         .load()
   2043         .unwrap()
   2044         .and_then(|snapshot| snapshot.blocks.last().map(|block| block.hash.clone()));
   2045     assert_eq!(persisted_tip.as_deref(), Some(expected_tip.as_str()));
   2046     assert!(state.ui_data_store.is_projected_to(&expected_tip).unwrap());
   2047 }
   2048 
   2049 async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState {
   2050     config_store::save(&config_path, &config).unwrap();
   2051     let wallet_path = config_path.with_file_name("wallet.json");
   2052     let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap();
   2053     let ledger = Ledger::new(BTreeMap::new(), 1);
   2054     let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0)));
   2055     let peers = Arc::new(Mutex::new(PeerBook::default()));
   2056     let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone());
   2057     let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3"))
   2058         .expect("test chain store should open");
   2059     let ui_data_store = SqliteUiDataStore::open(config_path.with_file_name("ui_data.sqlite3"))
   2060         .expect("test UI data store should open");
   2061     HttpState {
   2062         node,
   2063         peers,
   2064         gossip,
   2065         ui_config: Arc::new(Mutex::new(
   2066             config_store::load_or_create(&config_path).unwrap(),
   2067         )),
   2068         config_path,
   2069         chain_store,
   2070         ui_data_store,
   2071         wallet_path,
   2072         stratum: StratumStatus {
   2073             enabled: false,
   2074             listen_addr: None,
   2075         },
   2076         auth_sessions: Arc::new(Mutex::new(BTreeMap::new())),
   2077         auth_backoff: Arc::new(Mutex::new(BTreeMap::new())),
   2078         ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())),
   2079         ui_data_refresh: Arc::new(Mutex::new(())),
   2080     }
   2081 }
   2082 
   2083 fn auth_test_app(state: HttpState) -> Router {
   2084     Router::new()
   2085         .route("/api/auth/status", get(api_auth_status))
   2086         .route("/api/auth/setup", post(api_auth_setup_form))
   2087         .route("/api/auth/login", post(api_auth_login_form))
   2088         .route(
   2089             "/api/auth/change-password",
   2090             post(api_auth_change_password_form),
   2091         )
   2092         .route("/favicon.ico", get(super::favicon))
   2093         .route("/api/protected", get(protected_auth_test_endpoint))
   2094         .layer(middleware::from_fn_with_state(
   2095             state.clone(),
   2096             require_auth_middleware,
   2097         ))
   2098         .with_state(state)
   2099 }
   2100 
   2101 async fn protected_auth_test_endpoint() -> &'static str {
   2102     "protected"
   2103 }
   2104 
   2105 struct TestHttpResponse {
   2106     status: StatusCode,
   2107     headers: HeaderMap,
   2108     body: String,
   2109 }
   2110 
   2111 async fn http_request(
   2112     app: Router,
   2113     method: Method,
   2114     path: &str,
   2115     cookie: Option<&str>,
   2116     body: &str,
   2117 ) -> TestHttpResponse {
   2118     let mut builder = Request::builder()
   2119         .method(method.clone())
   2120         .uri(path)
   2121         .header(header::ACCEPT, "application/json")
   2122         .header(header::HOST, "127.0.0.1:18661")
   2123         .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
   2124     if matches!(method, Method::POST | Method::DELETE) {
   2125         builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661");
   2126     }
   2127     if let Some(cookie) = cookie {
   2128         builder = builder.header(header::COOKIE, cookie);
   2129     }
   2130     let response = app
   2131         .oneshot(builder.body(Body::from(body.to_string())).unwrap())
   2132         .await
   2133         .unwrap();
   2134     let status = response.status();
   2135     let headers = response.headers().clone();
   2136     let body = String::from_utf8(
   2137         to_bytes(response.into_body(), usize::MAX)
   2138             .await
   2139             .unwrap()
   2140             .to_vec(),
   2141     )
   2142     .unwrap();
   2143     TestHttpResponse {
   2144         status,
   2145         headers,
   2146         body,
   2147     }
   2148 }
   2149 
   2150 fn set_cookie_pair(headers: &HeaderMap) -> String {
   2151     let header = headers
   2152         .get(header::SET_COOKIE)
   2153         .and_then(|value| value.to_str().ok())
   2154         .expect("response should include Set-Cookie header");
   2155     header.split(';').next().unwrap().to_string()
   2156 }
   2157 
   2158 #[tokio::test]
   2159 async fn burn_settings_config_persistence_updates_config_file() {
   2160     let dir = tempfile::tempdir().unwrap();
   2161     let config_path = dir.path().join("config.json");
   2162     let ui_config = Arc::new(Mutex::new(UiConfig {
   2163         setup_complete: true,
   2164         ..UiConfig::default()
   2165     }));
   2166     let initial_config = ui_config.lock().await.clone();
   2167     config_store::save(&config_path, &initial_config).expect("initial config should save");
   2168 
   2169     persist_burn_settings_config(
   2170         &ui_config,
   2171         &config_path,
   2172         true,
   2173         50 * MICRO_IUNA,
   2174         3 * MICRO_IUNA,
   2175     )
   2176     .await
   2177     .unwrap();
   2178     let config = config_store::load_or_create(&config_path).unwrap();
   2179 
   2180     assert!(config.mining_enabled);
   2181     assert_eq!(config.burn_per_block, 50 * MICRO_IUNA);
   2182     assert_eq!(config.burn_fee, 3 * MICRO_IUNA);
   2183 }
   2184 
   2185 #[tokio::test]
   2186 async fn enabled_burn_settings_reject_zero_amount() {
   2187     let dir = tempfile::tempdir().unwrap();
   2188     let state = auth_test_state(
   2189         dir.path().join("config.json"),
   2190         UiConfig {
   2191             setup_complete: true,
   2192             ..UiConfig::default()
   2193         },
   2194     )
   2195     .await;
   2196 
   2197     let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA)
   2198         .await
   2199         .unwrap_err();
   2200 
   2201     assert!(format!("{error:#}").contains("greater than zero"));
   2202 }
   2203 
   2204 #[tokio::test]
   2205 async fn metrics_setting_persists_config_and_clears_rows_when_disabled() {
   2206     let dir = tempfile::tempdir().unwrap();
   2207     let config_path = dir.path().join("config.json");
   2208     let state = auth_test_state(
   2209         config_path.clone(),
   2210         UiConfig {
   2211             setup_complete: true,
   2212             ..UiConfig::default()
   2213         },
   2214     )
   2215     .await;
   2216     let wallet = Wallet::from_seed("metrics-setting-wallet");
   2217     let mut genesis = BTreeMap::new();
   2218     genesis.insert(wallet.address().to_string(), 10);
   2219     let ledger = Ledger::new_with_genesis_burns(
   2220         genesis,
   2221         vec![crate::domain::GenesisBurn::new(wallet.address(), 1)],
   2222         1,
   2223     )
   2224     .unwrap();
   2225     *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0);
   2226 
   2227     super::set_keep_track_of_metrics(&state, true)
   2228         .await
   2229         .unwrap();
   2230     let config = config_store::load_or_create(&config_path).unwrap();
   2231     assert!(config.keep_track_of_metrics);
   2232     assert!(!state.ui_data_store.load_metrics().unwrap().is_empty());
   2233 
   2234     super::set_keep_track_of_metrics(&state, false)
   2235         .await
   2236         .unwrap();
   2237     let config = config_store::load_or_create(&config_path).unwrap();
   2238     assert!(!config.keep_track_of_metrics);
   2239     assert!(state.ui_data_store.load_metrics().unwrap().is_empty());
   2240 }
   2241 
   2242 #[tokio::test]
   2243 async fn pow_mining_config_persistence_updates_config_file() {
   2244     let dir = tempfile::tempdir().unwrap();
   2245     let config_path = dir.path().join("config.json");
   2246     let ui_config = Arc::new(Mutex::new(UiConfig {
   2247         setup_complete: true,
   2248         ..UiConfig::default()
   2249     }));
   2250     let initial_config = ui_config.lock().await.clone();
   2251     config_store::save(&config_path, &initial_config).expect("initial config should save");
   2252 
   2253     persist_pow_mining_config(&ui_config, &config_path, true, 4)
   2254         .await
   2255         .unwrap();
   2256     let config = config_store::load_or_create(&config_path).unwrap();
   2257 
   2258     assert!(config.pow_mining_enabled);
   2259     assert_eq!(config.pow_mining_workers, 4);
   2260 }
   2261 
   2262 #[test]
   2263 fn transfer_form_requires_recipient_amount_and_fee() {
   2264     let error = validate_transfer_form(TransferForm {
   2265         to: " ".to_string(),
   2266         amount: 1,
   2267         fee_per_byte: Some(1),
   2268         utxos: String::new(),
   2269     })
   2270     .unwrap_err();
   2271     assert!(error.to_string().contains("recipient is required"));
   2272 
   2273     let error = validate_transfer_form(TransferForm {
   2274         to: "abc".to_string(),
   2275         amount: 0,
   2276         fee_per_byte: Some(1),
   2277         utxos: String::new(),
   2278     })
   2279     .unwrap_err();
   2280     assert!(
   2281         error
   2282             .to_string()
   2283             .contains("amount must be greater than zero")
   2284     );
   2285 
   2286     let error = validate_transfer_form(TransferForm {
   2287         to: "abc".to_string(),
   2288         amount: 1,
   2289         fee_per_byte: None,
   2290         utxos: String::new(),
   2291     })
   2292     .unwrap_err();
   2293     assert!(error.to_string().contains("fee per byte is required"));
   2294 }
   2295 
   2296 #[test]
   2297 fn burn_and_mine_forms_require_fee_per_byte() {
   2298     let burn = required_fee_per_byte_burn(&super::BurnSettingsForm {
   2299         enabled: Some(true),
   2300         amount: 1,
   2301         fee_per_byte: None,
   2302     })
   2303     .unwrap_err();
   2304     assert!(burn.to_string().contains("fee per byte is required"));
   2305 }
   2306 
   2307 #[test]
   2308 fn transfer_form_trims_recipient() {
   2309     let (to, amount, fee, utxos) = validate_transfer_form(TransferForm {
   2310         to: "  abc  ".to_string(),
   2311         amount: 2,
   2312         fee_per_byte: Some(3),
   2313         utxos: String::new(),
   2314     })
   2315     .unwrap();
   2316 
   2317     assert_eq!(to, "abc");
   2318     assert_eq!(amount, 2);
   2319     assert_eq!(fee, 3);
   2320     assert!(utxos.is_empty());
   2321 }
   2322 
   2323 #[test]
   2324 fn transfer_form_parses_selected_utxos() {
   2325     let (_, _, _, utxos) = validate_transfer_form(TransferForm {
   2326         to: "abc".to_string(),
   2327         amount: 2,
   2328         fee_per_byte: Some(3),
   2329         utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(),
   2330     })
   2331     .unwrap();
   2332 
   2333     assert_eq!(
   2334         utxos,
   2335         vec![
   2336             OutPoint {
   2337                 txid: "tx-one".to_string(),
   2338                 index: 0
   2339             },
   2340             OutPoint {
   2341                 txid: "tx:with:colons".to_string(),
   2342                 index: 7
   2343             }
   2344         ]
   2345     );
   2346 }