iuna

iuna

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

actions.rs (24363B)


      1 use std::{net::SocketAddr, path::Path, sync::Arc};
      2 
      3 use anyhow::{Context, Result, bail};
      4 use axum::{
      5     Form, Json,
      6     extract::State,
      7     http::HeaderMap,
      8     response::{IntoResponse, Redirect, Response},
      9 };
     10 use secrecy::ExposeSecret;
     11 use tokio::sync::Mutex;
     12 
     13 use super::types::{
     14     ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ChainResetForm,
     15     ConfigForm, FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm,
     16     PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, StratumSettingsForm,
     17     TransferForm, WalletEndpointSettingsForm, WalletSetupResponse,
     18 };
     19 use super::{
     20     HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee,
     21     estimate_transfer_fee, fee_estimate_json, required_fee_per_byte_burn, transfer,
     22     wallet_setup_json,
     23 };
     24 use crate::{
     25     adapters::{
     26         chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore,
     27     },
     28     app::{GossipEnvelope, NodeCore},
     29     domain::Amount,
     30 };
     31 
     32 const CHAIN_RESET_CONFIRMATION: &str = "RESET";
     33 
     34 pub(super) async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> {
     35     let peer = form.peer.trim();
     36     if !peer.is_empty() {
     37         add_peer(state, peer.to_string()).await?;
     38     }
     39     if form.setup_complete && super::setup_requires_peer(state).await {
     40         let has_peer = !state.peers.lock().await.addresses().is_empty();
     41         if !has_peer {
     42             bail!("add a bootstrap peer before completing setup");
     43         }
     44     }
     45     let mut config = state.ui_config.lock().await;
     46     config.setup_complete = form.setup_complete;
     47     config_store::save(&state.config_path, &config)
     48 }
     49 
     50 pub(super) async fn api_wallet_generate_form(
     51     State(state): State<HttpState>,
     52     headers: HeaderMap,
     53 ) -> Json<WalletSetupResponse> {
     54     wallet_setup_json(super::replace_setup_wallet_with_generated_seed(&state, &headers).await)
     55 }
     56 
     57 pub(super) async fn api_wallet_import_form(
     58     State(state): State<HttpState>,
     59     headers: HeaderMap,
     60     Form(form): Form<SeedPhraseForm>,
     61 ) -> Json<WalletSetupResponse> {
     62     wallet_setup_json(
     63         super::import_setup_wallet_seed(&state, &headers, form.seed_phrase.expose_secret()).await,
     64     )
     65 }
     66 
     67 pub(super) async fn api_transfer_fee_estimate_form(
     68     State(state): State<HttpState>,
     69     Form(form): Form<TransferForm>,
     70 ) -> Json<FeeEstimateResponse> {
     71     fee_estimate_json(estimate_transfer_fee(&state, form).await)
     72 }
     73 
     74 pub(super) async fn api_burn_fee_estimate_form(
     75     State(state): State<HttpState>,
     76     Form(form): Form<BurnSettingsForm>,
     77 ) -> Json<FeeEstimateResponse> {
     78     fee_estimate_json(estimate_burn_fee(&state, form).await)
     79 }
     80 
     81 pub(super) async fn api_mine_fee_estimate_form(
     82     State(state): State<HttpState>,
     83     Form(_form): Form<std::collections::BTreeMap<String, String>>,
     84 ) -> Json<FeeEstimateResponse> {
     85     fee_estimate_json(estimate_mine_fee(&state).await)
     86 }
     87 
     88 pub(super) async fn api_burn_per_block_form(
     89     State(state): State<HttpState>,
     90     Form(form): Form<BurnSettingsForm>,
     91 ) -> Json<ActionResponse> {
     92     let enabled = form.enabled.unwrap_or(form.amount > 0);
     93     let result = match required_fee_per_byte_burn(&form) {
     94         Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
     95         Err(error) => Err(error),
     96     };
     97     action_json(result)
     98 }
     99 
    100 pub(super) async fn api_pow_mining_form(
    101     State(state): State<HttpState>,
    102     Form(form): Form<PowMiningForm>,
    103 ) -> Json<ActionResponse> {
    104     action_json(set_pow_mining(&state, form.enabled, form.workers).await)
    105 }
    106 
    107 pub(super) async fn api_metrics_settings_form(
    108     State(state): State<HttpState>,
    109     Form(form): Form<MetricsSettingsForm>,
    110 ) -> Json<ActionResponse> {
    111     action_json(set_keep_track_of_metrics(&state, form.enabled).await)
    112 }
    113 
    114 pub(super) async fn api_recovery_vdf_settings_form(
    115     State(state): State<HttpState>,
    116     Form(form): Form<RecoveryVdfSettingsForm>,
    117 ) -> Json<ActionResponse> {
    118     action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await)
    119 }
    120 
    121 pub(super) async fn api_chain_reset_form(
    122     State(state): State<HttpState>,
    123     Form(form): Form<ChainResetForm>,
    124 ) -> Json<ActionResponse> {
    125     action_json(reset_local_chain(&state, &form.confirm).await)
    126 }
    127 
    128 pub(super) async fn api_p2p_announce_form(
    129     State(state): State<HttpState>,
    130     Form(form): Form<P2pAnnounceForm>,
    131 ) -> Json<ActionResponse> {
    132     action_json(set_p2p_announce_addr(&state, form.addr).await)
    133 }
    134 
    135 pub(super) async fn api_p2p_inbound_form(
    136     State(state): State<HttpState>,
    137     Form(form): Form<P2pInboundForm>,
    138 ) -> Json<ActionResponse> {
    139     action_json(set_p2p_accept_inbound(&state, form.enabled, form.bind_port).await)
    140 }
    141 
    142 pub(super) async fn api_stratum_settings_form(
    143     State(state): State<HttpState>,
    144     Form(form): Form<StratumSettingsForm>,
    145 ) -> Json<ActionResponse> {
    146     action_json(set_stratum_settings(&state, form.enabled, form.bind_port).await)
    147 }
    148 
    149 pub(super) async fn api_wallet_endpoint_settings_form(
    150     State(state): State<HttpState>,
    151     Form(form): Form<WalletEndpointSettingsForm>,
    152 ) -> Json<ActionResponse> {
    153     action_json(set_wallet_endpoint_settings(&state, form.enabled, form.bind_port).await)
    154 }
    155 
    156 pub(super) async fn burn_per_block_form(
    157     State(state): State<HttpState>,
    158     Form(form): Form<BurnSettingsForm>,
    159 ) -> Response {
    160     let enabled = form.enabled.unwrap_or(form.amount > 0);
    161     let result = match required_fee_per_byte_burn(&form) {
    162         Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
    163         Err(error) => Err(error),
    164     };
    165     match result {
    166         Ok(_) => Redirect::to("/").into_response(),
    167         Err(error) => api_error(error).into_response(),
    168     }
    169 }
    170 
    171 pub(super) async fn api_transfer_form(
    172     State(state): State<HttpState>,
    173     Form(form): Form<TransferForm>,
    174 ) -> Json<ActionResponse> {
    175     let result = transfer(&state, form).await;
    176     action_json(result)
    177 }
    178 
    179 pub(super) async fn transfer_form(
    180     State(state): State<HttpState>,
    181     Form(form): Form<TransferForm>,
    182 ) -> Response {
    183     match transfer(&state, form).await {
    184         Ok(_) => Redirect::to("/").into_response(),
    185         Err(error) => api_error(error).into_response(),
    186     }
    187 }
    188 
    189 pub(super) async fn api_peer_form(
    190     State(state): State<HttpState>,
    191     Form(form): Form<PeerForm>,
    192 ) -> Json<ActionResponse> {
    193     let result = add_peer(&state, form.peer).await;
    194     action_json(result)
    195 }
    196 
    197 pub(super) async fn api_peer_delete_form(
    198     State(state): State<HttpState>,
    199     Form(form): Form<PeerForm>,
    200 ) -> Json<ActionResponse> {
    201     let result = remove_peer(&state, form.peer).await;
    202     action_json(result)
    203 }
    204 
    205 pub(super) async fn api_address_book_form(
    206     State(state): State<HttpState>,
    207     Form(form): Form<AddressBookForm>,
    208 ) -> Json<ActionResponse> {
    209     action_json(upsert_address_book_entry(&state, form.address, form.name, form.old_address).await)
    210 }
    211 
    212 pub(super) async fn api_address_book_delete_form(
    213     State(state): State<HttpState>,
    214     Form(form): Form<AddressBookDeleteForm>,
    215 ) -> Json<ActionResponse> {
    216     action_json(remove_address_book_entry(&state, form.address).await)
    217 }
    218 
    219 pub(super) async fn peer_form(
    220     State(state): State<HttpState>,
    221     Form(form): Form<PeerForm>,
    222 ) -> Response {
    223     match add_peer(&state, form.peer).await {
    224         Ok(()) => Redirect::to("/").into_response(),
    225         Err(error) => api_error(error).into_response(),
    226     }
    227 }
    228 
    229 pub(super) async fn set_burn_settings(
    230     state: &HttpState,
    231     enabled: bool,
    232     amount: Amount,
    233     fee: Amount,
    234 ) -> Result<()> {
    235     if enabled && amount == 0 {
    236         bail!("IUNA per block must be greater than zero when finalization burns are on");
    237     }
    238     let result = {
    239         let mut node = state.node.lock().await;
    240         let result = node.set_automatic_burn_settings(enabled, amount, fee);
    241         let outbox = node.drain_outbox();
    242         (result, outbox)
    243     };
    244 
    245     match result.0 {
    246         Ok(_) => {
    247             persist_burn_settings_config(
    248                 &state.ui_config,
    249                 &state.config_path,
    250                 enabled,
    251                 amount,
    252                 fee,
    253             )
    254             .await?;
    255             state.gossip.broadcast(result.1).await
    256         }
    257         Err(error) => Err(error),
    258     }
    259 }
    260 
    261 pub(super) async fn persist_burn_settings_config(
    262     ui_config: &Arc<Mutex<UiConfig>>,
    263     config_path: &Path,
    264     enabled: bool,
    265     amount: Amount,
    266     fee: Amount,
    267 ) -> Result<()> {
    268     let mut config = ui_config.lock().await;
    269     config.mining_enabled = enabled;
    270     config.burn_per_block = amount;
    271     config.burn_fee = fee;
    272     config_store::save(config_path, &config)
    273 }
    274 
    275 pub(super) async fn set_pow_mining(
    276     state: &HttpState,
    277     enabled: bool,
    278     workers: Option<u8>,
    279 ) -> Result<()> {
    280     let workers = match workers {
    281         Some(workers) => workers,
    282         None => state.ui_config.lock().await.pow_mining_workers,
    283     };
    284     {
    285         let mut node = state.node.lock().await;
    286         node.set_pow_mining_workers(workers);
    287         node.set_pow_mining_enabled(enabled);
    288     }
    289     persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, workers).await
    290 }
    291 
    292 pub(super) async fn persist_pow_mining_config(
    293     ui_config: &Arc<Mutex<UiConfig>>,
    294     config_path: &Path,
    295     enabled: bool,
    296     workers: u8,
    297 ) -> Result<()> {
    298     let mut config = ui_config.lock().await;
    299     config.pow_mining_enabled = enabled;
    300     config.pow_mining_workers = config_store::clamp_pow_mining_workers(workers);
    301     config_store::save(config_path, &config)
    302 }
    303 
    304 pub(super) async fn set_recovery_vdf_top_rank_percent(
    305     state: &HttpState,
    306     percent: u8,
    307 ) -> Result<()> {
    308     let percent = percent.min(100);
    309     {
    310         let mut node = state.node.lock().await;
    311         node.set_recovery_vdf_top_rank_percent(percent);
    312     }
    313     let mut config = state.ui_config.lock().await;
    314     config.recovery_vdf_top_rank_percent = percent;
    315     config_store::save(&state.config_path, &config)
    316 }
    317 
    318 pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> {
    319     let mut config = state.ui_config.lock().await;
    320     let mut next_config = config.clone();
    321     next_config.keep_track_of_metrics = enabled;
    322     config_store::save(&state.config_path, &next_config)?;
    323     *config = next_config;
    324     Ok(())
    325 }
    326 
    327 pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> Result<()> {
    328     if confirmation.trim() != CHAIN_RESET_CONFIRMATION {
    329         bail!("type RESET to confirm deleting the local chain");
    330     }
    331 
    332     {
    333         let mut node = state.node.lock().await;
    334         state.gossip.invalidate_sync_progress();
    335         node.reset_chain_to_setup_placeholder();
    336     }
    337     clear_chain(&state.chain_store).await?;
    338     clear_ui_data(&state.ui_data_store).await?;
    339     state.node.lock().await.complete_network_migration();
    340     state
    341         .gossip
    342         .broadcast(vec![GossipEnvelope::ChainBootstrapRequest])
    343         .await?;
    344     Ok(())
    345 }
    346 
    347 pub(super) async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> {
    348     let trimmed = addr.trim();
    349     let parsed = if trimmed.is_empty() {
    350         None
    351     } else {
    352         Some(
    353             trimmed
    354                 .parse::<SocketAddr>()
    355                 .with_context(|| format!("invalid P2P announce address {trimmed}"))?,
    356         )
    357     };
    358 
    359     let mut config = state.ui_config.lock().await;
    360     let mut next_config = config.clone();
    361     next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string());
    362     config_store::save(&state.config_path, &next_config)?;
    363     *config = next_config;
    364     drop(config);
    365     state.gossip.set_p2p_announce_addr(parsed).await;
    366     Ok(())
    367 }
    368 
    369 pub(super) async fn set_p2p_accept_inbound(
    370     state: &HttpState,
    371     enabled: bool,
    372     bind_port: Option<u16>,
    373 ) -> Result<()> {
    374     let bind_port = bind_port.unwrap_or(config_store::DEFAULT_P2P_BIND_PORT);
    375     if bind_port == 0 {
    376         bail!("P2P bind port must be between 1 and 65535");
    377     }
    378     let previous = state.gossip.accepts_inbound().await;
    379     if enabled && previous {
    380         state.gossip.set_accept_inbound(true).await?;
    381     }
    382 
    383     let mut config = state.ui_config.lock().await;
    384     let mut next_config = config.clone();
    385     next_config.p2p_accept_inbound = enabled;
    386     next_config.p2p_bind_port = bind_port;
    387     if !enabled {
    388         next_config.wallet_endpoint_enabled = false;
    389     }
    390     if let Err(error) = config_store::save(&state.config_path, &next_config) {
    391         let _ = state.gossip.set_accept_inbound(previous).await;
    392         return Err(error);
    393     }
    394     *config = next_config;
    395     drop(config);
    396 
    397     if !enabled {
    398         state.gossip.set_accept_inbound(false).await?;
    399     }
    400 
    401     Ok(())
    402 }
    403 
    404 pub(super) async fn set_stratum_settings(
    405     state: &HttpState,
    406     enabled: bool,
    407     bind_port: Option<u16>,
    408 ) -> Result<()> {
    409     let bind_port = bind_port.unwrap_or(config_store::DEFAULT_STRATUM_BIND_PORT);
    410     if bind_port == 0 {
    411         bail!("Stratum bind port must be between 1 and 65535");
    412     }
    413 
    414     let mut config = state.ui_config.lock().await;
    415     let mut next_config = config.clone();
    416     next_config.stratum_enabled = enabled;
    417     next_config.stratum_bind_port = bind_port;
    418     config_store::save(&state.config_path, &next_config)?;
    419     *config = next_config;
    420     Ok(())
    421 }
    422 
    423 pub(super) async fn set_wallet_endpoint_settings(
    424     state: &HttpState,
    425     enabled: bool,
    426     bind_port: Option<u16>,
    427 ) -> Result<()> {
    428     let bind_port = bind_port.unwrap_or(config_store::DEFAULT_WALLET_ENDPOINT_BIND_PORT);
    429     if bind_port == 0 {
    430         bail!("Wallet endpoint bind port must be between 1 and 65535");
    431     }
    432     if enabled && bind_port == state.management_port {
    433         bail!("Wallet endpoint port must differ from the management UI port");
    434     }
    435 
    436     let mut config = state.ui_config.lock().await;
    437     let mut next_config = config.clone();
    438     validate_wallet_endpoint_public_node(&next_config, enabled)?;
    439     if enabled && next_config.p2p_accept_inbound && bind_port == next_config.p2p_bind_port {
    440         bail!("Wallet endpoint port must differ from the P2P listener port");
    441     }
    442     if enabled && next_config.stratum_enabled && bind_port == next_config.stratum_bind_port {
    443         bail!("Wallet endpoint port must differ from the Stratum listener port");
    444     }
    445     next_config.wallet_endpoint_enabled = enabled;
    446     next_config.wallet_endpoint_bind_port = bind_port;
    447     config_store::save(&state.config_path, &next_config)?;
    448     *config = next_config;
    449     Ok(())
    450 }
    451 
    452 fn validate_wallet_endpoint_public_node(
    453     config: &config_store::UiConfig,
    454     enabled: bool,
    455 ) -> Result<()> {
    456     if enabled && !config.p2p_accept_inbound {
    457         bail!("Enable Public node before enabling the wallet endpoint");
    458     }
    459     Ok(())
    460 }
    461 
    462 async fn clear_ui_data(store: &SqliteUiDataStore) -> Result<()> {
    463     let store = store.clone();
    464     tokio::task::spawn_blocking(move || store.clear_all())
    465         .await
    466         .context("UI data cleanup worker failed")??;
    467     Ok(())
    468 }
    469 
    470 async fn clear_chain(store: &SqliteChainStore) -> Result<()> {
    471     let store = store.clone();
    472     tokio::task::spawn_blocking(move || store.clear_chain())
    473         .await
    474         .context("chain reset worker failed")??;
    475     Ok(())
    476 }
    477 
    478 pub(super) async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
    479     let peer = validate_peer_address(peer)?;
    480     let addresses = {
    481         let mut peers = state.peers.lock().await;
    482         peers.add_peer(peer);
    483         peers.addresses()
    484     };
    485     let mut config = state.ui_config.lock().await;
    486     config.peers = addresses;
    487     config_store::save(&state.config_path, &config)
    488 }
    489 
    490 pub(super) async fn remove_peer(state: &HttpState, peer: String) -> Result<()> {
    491     let peer = validate_peer_address(peer)?;
    492     let addresses = {
    493         let mut peers = state.peers.lock().await;
    494         if !peers.remove_peer(&peer) {
    495             bail!("peer is not configured as an outbound peer");
    496         }
    497         peers.addresses()
    498     };
    499     let mut config = state.ui_config.lock().await;
    500     config.peers = addresses;
    501     config_store::save(&state.config_path, &config)
    502 }
    503 
    504 pub(super) async fn upsert_address_book_entry(
    505     state: &HttpState,
    506     address: String,
    507     name: String,
    508     old_address: Option<String>,
    509 ) -> Result<()> {
    510     let address = validate_address_book_address(state, address).await?;
    511     let name = validate_address_book_name(name)?;
    512     let old_address = match old_address {
    513         Some(old_address) => {
    514             Some(validate_existing_address_book_address(state, old_address).await?)
    515         }
    516         None => None,
    517     };
    518     let mut config = state.ui_config.lock().await;
    519     if let Some(old_address) = old_address.as_deref() {
    520         if old_address != address {
    521             if config.address_book.contains_key(&address) {
    522                 bail!("address is already saved");
    523             }
    524             config.address_book.remove(old_address);
    525         }
    526     } else if config.address_book.contains_key(&address) {
    527         bail!("address is already saved");
    528     }
    529     config.address_book.insert(address, name);
    530     config_store::save(&state.config_path, &config)
    531 }
    532 
    533 pub(super) async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> {
    534     let address = validate_existing_address_book_address(state, address).await?;
    535     let mut config = state.ui_config.lock().await;
    536     config.address_book.remove(&address);
    537     config_store::save(&state.config_path, &config)
    538 }
    539 
    540 fn validate_peer_address(peer: String) -> Result<String> {
    541     let peer = peer.trim().to_string();
    542     if peer.is_empty() {
    543         bail!("peer address is required");
    544     }
    545     Ok(peer)
    546 }
    547 
    548 async fn validate_address_book_address(state: &HttpState, address: String) -> Result<String> {
    549     let node = state.node.lock().await;
    550     normalize_address_book_address(&node, address)
    551 }
    552 
    553 fn normalize_address_book_address(node: &NodeCore, address: String) -> Result<String> {
    554     let address = address.trim().to_string();
    555     if address.is_empty() {
    556         bail!("address is required");
    557     }
    558     node.decode_user_address(&address)
    559         .context("invalid address book address")?;
    560     Ok(address.to_ascii_lowercase())
    561 }
    562 
    563 async fn validate_existing_address_book_address(
    564     state: &HttpState,
    565     address: String,
    566 ) -> Result<String> {
    567     let address = address.trim().to_string();
    568     if address.is_empty() {
    569         bail!("address is required");
    570     }
    571     if state
    572         .node
    573         .lock()
    574         .await
    575         .decode_user_address(&address)
    576         .is_err()
    577     {
    578         crate::domain::validate_address(&address, "legacy address book")
    579             .context("invalid existing address book address")?;
    580     }
    581     Ok(address.to_ascii_lowercase())
    582 }
    583 
    584 fn validate_address_book_name(name: String) -> Result<String> {
    585     let name = name.trim().to_string();
    586     if name.is_empty() {
    587         bail!("name is required");
    588     }
    589     Ok(name)
    590 }
    591 
    592 #[cfg(test)]
    593 mod tests {
    594     use std::{
    595         collections::BTreeMap,
    596         net::{IpAddr, Ipv4Addr, SocketAddr},
    597         sync::Arc,
    598     };
    599 
    600     use tempfile::tempdir;
    601     use tokio::sync::Mutex;
    602 
    603     use crate::{
    604         adapters::{
    605             chain_store::SqliteChainStore, config_store, config_store::UiConfig,
    606             p2p::GossipNetwork, ui_data_store::SqliteUiDataStore, wallet_store,
    607         },
    608         app::{NodeCore, PeerBook, StratumStatus},
    609         domain::{AddressNetwork, GenesisBurn, Ledger, MICRO_IUNA, Wallet, encode_address},
    610     };
    611 
    612     use super::super::{AuthSession, HttpState, state::AuthBackoff};
    613     use super::{
    614         CHAIN_RESET_CONFIRMATION, normalize_address_book_address, reset_local_chain,
    615         validate_wallet_endpoint_public_node,
    616     };
    617 
    618     fn socket() -> SocketAddr {
    619         SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9444)
    620     }
    621 
    622     #[test]
    623     fn wallet_endpoint_requires_a_public_node() {
    624         let mut config = UiConfig::default();
    625         let error = validate_wallet_endpoint_public_node(&config, true).unwrap_err();
    626         assert!(error.to_string().contains("Public node"));
    627 
    628         config.p2p_accept_inbound = true;
    629         validate_wallet_endpoint_public_node(&config, true).unwrap();
    630         validate_wallet_endpoint_public_node(&UiConfig::default(), false).unwrap();
    631     }
    632 
    633     #[test]
    634     fn address_book_accepts_legacy_and_hybrid_addresses_for_the_active_network() {
    635         let wallet = Wallet::from_seed("hybrid-address-book-wallet");
    636         let ledger = Ledger::new(BTreeMap::new(), 1);
    637         let node = NodeCore::from_ledger(wallet.clone(), ledger, 0);
    638         let legacy = encode_address(wallet.address(), AddressNetwork::Mainnet).unwrap();
    639         let hybrid = wallet.hybrid_address(AddressNetwork::Mainnet);
    640 
    641         assert_eq!(
    642             normalize_address_book_address(&node, legacy.to_uppercase()).unwrap(),
    643             legacy
    644         );
    645         assert_eq!(
    646             normalize_address_book_address(&node, hybrid.to_uppercase()).unwrap(),
    647             hybrid
    648         );
    649         assert!(
    650             normalize_address_book_address(&node, wallet.hybrid_address(AddressNetwork::Testnet))
    651                 .is_err()
    652         );
    653     }
    654 
    655     #[tokio::test]
    656     async fn local_chain_reset_clears_chain_and_ui_without_touching_wallet_or_config() {
    657         let dir = tempdir().unwrap();
    658         let wallet_path = dir.path().join("wallet.json");
    659         let config_path = dir.path().join("config.json");
    660         let chain_store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
    661         let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui.sqlite3")).unwrap();
    662         let wallet = wallet_store::replace_with_imported_seed_phrase(
    663             &wallet_path,
    664             "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
    665         )
    666         .unwrap();
    667         let config = UiConfig {
    668             setup_complete: true,
    669             peers: vec!["127.0.0.1:9445".to_string()],
    670             ..UiConfig::default()
    671         };
    672         config_store::save(&config_path, &config).unwrap();
    673         let wallet_before = std::fs::read(&wallet_path).unwrap();
    674         let config_before = std::fs::read(&config_path).unwrap();
    675 
    676         let mut allocations = BTreeMap::new();
    677         allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA);
    678         let ledger = Ledger::new_with_genesis_burns(
    679             allocations,
    680             vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)],
    681             1,
    682         )
    683         .unwrap();
    684         let snapshot = ledger.snapshot();
    685         chain_store.save(&snapshot).unwrap();
    686         ui_data_store.project_snapshot(&snapshot, true).unwrap();
    687 
    688         let mut node_core = NodeCore::from_ledger(wallet, ledger, 0);
    689         node_core.require_network_migration("iuna-devnet-v5");
    690         let node = Arc::new(Mutex::new(node_core));
    691         let peers = Arc::new(Mutex::new(PeerBook::default()));
    692         let gossip = GossipNetwork::start(node.clone(), peers.clone(), socket(), None, false)
    693             .await
    694             .unwrap();
    695         let state = HttpState {
    696             node,
    697             peers,
    698             gossip,
    699             ui_config: Arc::new(Mutex::new(config)),
    700             config_path: config_path.clone(),
    701             chain_store: chain_store.clone(),
    702             ui_data_store: ui_data_store.clone(),
    703             wallet_path: wallet_path.clone(),
    704             stratum: StratumStatus {
    705                 enabled: false,
    706                 listen_addr: None,
    707             },
    708             auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())),
    709             auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())),
    710             setup_capability: Arc::new(Mutex::new(Some("test-setup-capability".into()))),
    711             management_port: 9444,
    712             wallet_endpoint_addr: None,
    713         };
    714 
    715         reset_local_chain(&state, CHAIN_RESET_CONFIRMATION)
    716             .await
    717             .unwrap();
    718 
    719         assert!(!state.node.lock().await.has_real_chain());
    720         assert!(state.node.lock().await.network_migration_from().is_none());
    721         assert!(chain_store.load().unwrap().is_none());
    722         assert!(ui_data_store.load_metrics().unwrap().is_empty());
    723         assert_eq!(std::fs::read(&wallet_path).unwrap(), wallet_before);
    724         assert_eq!(std::fs::read(&config_path).unwrap(), config_before);
    725     }
    726 }