iuna

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

tests.rs (82547B)


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