iuna

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

actions.rs (16119B)


      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 tokio::sync::Mutex;
     11 
     12 use super::types::{
     13     ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ChainResetForm,
     14     ConfigForm, FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm,
     15     PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, TransferForm,
     16     WalletSetupResponse,
     17 };
     18 use super::{
     19     HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee,
     20     estimate_transfer_fee, fee_estimate_json, required_fee_per_byte_burn, transfer,
     21     validate_address, wallet_setup_json,
     22 };
     23 use crate::{
     24     adapters::{
     25         chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore,
     26     },
     27     app::GossipEnvelope,
     28     domain::Amount,
     29 };
     30 
     31 const CHAIN_RESET_CONFIRMATION: &str = "RESET";
     32 
     33 pub(super) async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> {
     34     let peer = form.peer.trim();
     35     if !peer.is_empty() {
     36         add_peer(state, peer.to_string()).await?;
     37     }
     38     if form.setup_complete && super::setup_requires_peer(state).await {
     39         let has_peer = !state.peers.lock().await.addresses().is_empty();
     40         if !has_peer {
     41             bail!("add a bootstrap peer before completing setup");
     42         }
     43     }
     44     let mut config = state.ui_config.lock().await;
     45     config.setup_complete = form.setup_complete;
     46     config_store::save(&state.config_path, &config)
     47 }
     48 
     49 pub(super) async fn api_wallet_generate_form(
     50     State(state): State<HttpState>,
     51     headers: HeaderMap,
     52 ) -> Json<WalletSetupResponse> {
     53     wallet_setup_json(super::replace_setup_wallet_with_generated_seed(&state, &headers).await)
     54 }
     55 
     56 pub(super) async fn api_wallet_import_form(
     57     State(state): State<HttpState>,
     58     headers: HeaderMap,
     59     Form(form): Form<SeedPhraseForm>,
     60 ) -> Json<WalletSetupResponse> {
     61     wallet_setup_json(super::import_setup_wallet_seed(&state, &headers, &form.seed_phrase).await)
     62 }
     63 
     64 pub(super) async fn api_transfer_fee_estimate_form(
     65     State(state): State<HttpState>,
     66     Form(form): Form<TransferForm>,
     67 ) -> Json<FeeEstimateResponse> {
     68     fee_estimate_json(estimate_transfer_fee(&state, form).await)
     69 }
     70 
     71 pub(super) async fn api_burn_fee_estimate_form(
     72     State(state): State<HttpState>,
     73     Form(form): Form<BurnSettingsForm>,
     74 ) -> Json<FeeEstimateResponse> {
     75     fee_estimate_json(estimate_burn_fee(&state, form).await)
     76 }
     77 
     78 pub(super) async fn api_mine_fee_estimate_form(
     79     State(state): State<HttpState>,
     80     Form(_form): Form<std::collections::BTreeMap<String, String>>,
     81 ) -> Json<FeeEstimateResponse> {
     82     fee_estimate_json(estimate_mine_fee(&state).await)
     83 }
     84 
     85 pub(super) async fn api_burn_per_block_form(
     86     State(state): State<HttpState>,
     87     Form(form): Form<BurnSettingsForm>,
     88 ) -> Json<ActionResponse> {
     89     let enabled = form.enabled.unwrap_or(form.amount > 0);
     90     let result = match required_fee_per_byte_burn(&form) {
     91         Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
     92         Err(error) => Err(error),
     93     };
     94     action_json(result)
     95 }
     96 
     97 pub(super) async fn api_pow_mining_form(
     98     State(state): State<HttpState>,
     99     Form(form): Form<PowMiningForm>,
    100 ) -> Json<ActionResponse> {
    101     action_json(set_pow_mining(&state, form.enabled, form.workers).await)
    102 }
    103 
    104 pub(super) async fn api_metrics_settings_form(
    105     State(state): State<HttpState>,
    106     Form(form): Form<MetricsSettingsForm>,
    107 ) -> Json<ActionResponse> {
    108     action_json(set_keep_track_of_metrics(&state, form.enabled).await)
    109 }
    110 
    111 pub(super) async fn api_recovery_vdf_settings_form(
    112     State(state): State<HttpState>,
    113     Form(form): Form<RecoveryVdfSettingsForm>,
    114 ) -> Json<ActionResponse> {
    115     action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await)
    116 }
    117 
    118 pub(super) async fn api_chain_reset_form(
    119     State(state): State<HttpState>,
    120     Form(form): Form<ChainResetForm>,
    121 ) -> Json<ActionResponse> {
    122     action_json(reset_local_chain(&state, &form.confirm).await)
    123 }
    124 
    125 pub(super) async fn api_p2p_announce_form(
    126     State(state): State<HttpState>,
    127     Form(form): Form<P2pAnnounceForm>,
    128 ) -> Json<ActionResponse> {
    129     action_json(set_p2p_announce_addr(&state, form.addr).await)
    130 }
    131 
    132 pub(super) async fn api_p2p_inbound_form(
    133     State(state): State<HttpState>,
    134     Form(form): Form<P2pInboundForm>,
    135 ) -> Json<ActionResponse> {
    136     action_json(set_p2p_accept_inbound(&state, form.enabled, form.bind_port).await)
    137 }
    138 
    139 pub(super) async fn burn_per_block_form(
    140     State(state): State<HttpState>,
    141     Form(form): Form<BurnSettingsForm>,
    142 ) -> Response {
    143     let enabled = form.enabled.unwrap_or(form.amount > 0);
    144     let result = match required_fee_per_byte_burn(&form) {
    145         Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await,
    146         Err(error) => Err(error),
    147     };
    148     match result {
    149         Ok(_) => Redirect::to("/").into_response(),
    150         Err(error) => api_error(error).into_response(),
    151     }
    152 }
    153 
    154 pub(super) async fn api_transfer_form(
    155     State(state): State<HttpState>,
    156     Form(form): Form<TransferForm>,
    157 ) -> Json<ActionResponse> {
    158     let result = transfer(&state, form).await;
    159     action_json(result)
    160 }
    161 
    162 pub(super) async fn transfer_form(
    163     State(state): State<HttpState>,
    164     Form(form): Form<TransferForm>,
    165 ) -> Response {
    166     match transfer(&state, form).await {
    167         Ok(_) => Redirect::to("/").into_response(),
    168         Err(error) => api_error(error).into_response(),
    169     }
    170 }
    171 
    172 pub(super) async fn api_peer_form(
    173     State(state): State<HttpState>,
    174     Form(form): Form<PeerForm>,
    175 ) -> Json<ActionResponse> {
    176     let result = add_peer(&state, form.peer).await;
    177     action_json(result)
    178 }
    179 
    180 pub(super) async fn api_peer_delete_form(
    181     State(state): State<HttpState>,
    182     Form(form): Form<PeerForm>,
    183 ) -> Json<ActionResponse> {
    184     let result = remove_peer(&state, form.peer).await;
    185     action_json(result)
    186 }
    187 
    188 pub(super) async fn api_address_book_form(
    189     State(state): State<HttpState>,
    190     Form(form): Form<AddressBookForm>,
    191 ) -> Json<ActionResponse> {
    192     action_json(upsert_address_book_entry(&state, form.address, form.name, form.old_address).await)
    193 }
    194 
    195 pub(super) async fn api_address_book_delete_form(
    196     State(state): State<HttpState>,
    197     Form(form): Form<AddressBookDeleteForm>,
    198 ) -> Json<ActionResponse> {
    199     action_json(remove_address_book_entry(&state, form.address).await)
    200 }
    201 
    202 pub(super) async fn peer_form(
    203     State(state): State<HttpState>,
    204     Form(form): Form<PeerForm>,
    205 ) -> Response {
    206     match add_peer(&state, form.peer).await {
    207         Ok(()) => Redirect::to("/").into_response(),
    208         Err(error) => api_error(error).into_response(),
    209     }
    210 }
    211 
    212 pub(super) async fn set_burn_settings(
    213     state: &HttpState,
    214     enabled: bool,
    215     amount: Amount,
    216     fee: Amount,
    217 ) -> Result<()> {
    218     if enabled && amount == 0 {
    219         bail!("IUNA per block must be greater than zero when finalization burns are on");
    220     }
    221     let result = {
    222         let mut node = state.node.lock().await;
    223         let result = node.set_automatic_burn_settings(enabled, amount, fee);
    224         let outbox = node.drain_outbox();
    225         (result, outbox)
    226     };
    227 
    228     match result.0 {
    229         Ok(_) => {
    230             persist_burn_settings_config(
    231                 &state.ui_config,
    232                 &state.config_path,
    233                 enabled,
    234                 amount,
    235                 fee,
    236             )
    237             .await?;
    238             state.gossip.broadcast(result.1).await
    239         }
    240         Err(error) => Err(error),
    241     }
    242 }
    243 
    244 pub(super) async fn persist_burn_settings_config(
    245     ui_config: &Arc<Mutex<UiConfig>>,
    246     config_path: &Path,
    247     enabled: bool,
    248     amount: Amount,
    249     fee: Amount,
    250 ) -> Result<()> {
    251     let mut config = ui_config.lock().await;
    252     config.mining_enabled = enabled;
    253     config.burn_per_block = amount;
    254     config.burn_fee = fee;
    255     config_store::save(config_path, &config)
    256 }
    257 
    258 pub(super) async fn set_pow_mining(
    259     state: &HttpState,
    260     enabled: bool,
    261     workers: Option<u8>,
    262 ) -> Result<()> {
    263     let workers = match workers {
    264         Some(workers) => workers,
    265         None => state.ui_config.lock().await.pow_mining_workers,
    266     };
    267     {
    268         let mut node = state.node.lock().await;
    269         node.set_pow_mining_workers(workers);
    270         node.set_pow_mining_enabled(enabled);
    271     }
    272     persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, workers).await
    273 }
    274 
    275 pub(super) async fn persist_pow_mining_config(
    276     ui_config: &Arc<Mutex<UiConfig>>,
    277     config_path: &Path,
    278     enabled: bool,
    279     workers: u8,
    280 ) -> Result<()> {
    281     let mut config = ui_config.lock().await;
    282     config.pow_mining_enabled = enabled;
    283     config.pow_mining_workers = config_store::clamp_pow_mining_workers(workers);
    284     config_store::save(config_path, &config)
    285 }
    286 
    287 pub(super) async fn set_recovery_vdf_top_rank_percent(
    288     state: &HttpState,
    289     percent: u8,
    290 ) -> Result<()> {
    291     let percent = percent.min(100);
    292     {
    293         let mut node = state.node.lock().await;
    294         node.set_recovery_vdf_top_rank_percent(percent);
    295     }
    296     let mut config = state.ui_config.lock().await;
    297     config.recovery_vdf_top_rank_percent = percent;
    298     config_store::save(&state.config_path, &config)
    299 }
    300 
    301 pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> {
    302     if enabled {
    303         let snapshot = {
    304             let node = state.node.lock().await;
    305             node.has_real_chain().then(|| node.chain_snapshot())
    306         };
    307         if let Some(snapshot) = snapshot {
    308             replace_metrics_for_snapshot(&state.ui_data_store, snapshot).await?;
    309         } else {
    310             clear_metrics(&state.ui_data_store).await?;
    311         }
    312     } else {
    313         clear_metrics(&state.ui_data_store).await?;
    314     }
    315 
    316     let mut config = state.ui_config.lock().await;
    317     config.keep_track_of_metrics = enabled;
    318     config_store::save(&state.config_path, &config)
    319 }
    320 
    321 pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> Result<()> {
    322     if confirmation.trim() != CHAIN_RESET_CONFIRMATION {
    323         bail!("type RESET to confirm deleting the local chain");
    324     }
    325 
    326     {
    327         let mut node = state.node.lock().await;
    328         node.reset_chain_to_setup_placeholder();
    329     }
    330     {
    331         let mut cache = state.ui_cache.lock().await;
    332         *cache = super::UiChainCache::default();
    333     }
    334     clear_chain(&state.chain_store).await?;
    335     clear_ui_data(&state.ui_data_store).await?;
    336     state
    337         .gossip
    338         .broadcast(vec![GossipEnvelope::ChainSnapshotRequest])
    339         .await?;
    340     Ok(())
    341 }
    342 
    343 pub(super) async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> {
    344     let trimmed = addr.trim();
    345     let parsed = if trimmed.is_empty() {
    346         None
    347     } else {
    348         Some(
    349             trimmed
    350                 .parse::<SocketAddr>()
    351                 .with_context(|| format!("invalid P2P announce address {trimmed}"))?,
    352         )
    353     };
    354 
    355     let mut config = state.ui_config.lock().await;
    356     let mut next_config = config.clone();
    357     next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string());
    358     config_store::save(&state.config_path, &next_config)?;
    359     *config = next_config;
    360     drop(config);
    361     state.gossip.set_p2p_announce_addr(parsed).await;
    362     Ok(())
    363 }
    364 
    365 pub(super) async fn set_p2p_accept_inbound(
    366     state: &HttpState,
    367     enabled: bool,
    368     bind_port: Option<u16>,
    369 ) -> Result<()> {
    370     let bind_port = bind_port.unwrap_or(config_store::DEFAULT_P2P_BIND_PORT);
    371     if bind_port == 0 {
    372         bail!("P2P bind port must be between 1 and 65535");
    373     }
    374     let previous = state.gossip.accepts_inbound().await;
    375     if enabled && previous {
    376         state.gossip.set_accept_inbound(true).await?;
    377     }
    378 
    379     let mut config = state.ui_config.lock().await;
    380     let mut next_config = config.clone();
    381     next_config.p2p_accept_inbound = enabled;
    382     next_config.p2p_bind_port = bind_port;
    383     if let Err(error) = config_store::save(&state.config_path, &next_config) {
    384         let _ = state.gossip.set_accept_inbound(previous).await;
    385         return Err(error);
    386     }
    387     *config = next_config;
    388     drop(config);
    389 
    390     if !enabled {
    391         state.gossip.set_accept_inbound(false).await?;
    392     }
    393 
    394     Ok(())
    395 }
    396 
    397 async fn replace_metrics_for_snapshot(
    398     store: &SqliteUiDataStore,
    399     snapshot: crate::domain::ChainSnapshot,
    400 ) -> Result<()> {
    401     let store = store.clone();
    402     tokio::task::spawn_blocking(move || store.replace_metrics_for_snapshot(&snapshot))
    403         .await
    404         .context("metrics worker failed")??;
    405     Ok(())
    406 }
    407 
    408 async fn clear_metrics(store: &SqliteUiDataStore) -> Result<()> {
    409     let store = store.clone();
    410     tokio::task::spawn_blocking(move || store.clear_metrics())
    411         .await
    412         .context("metrics cleanup worker failed")??;
    413     Ok(())
    414 }
    415 
    416 async fn clear_ui_data(store: &SqliteUiDataStore) -> Result<()> {
    417     let store = store.clone();
    418     tokio::task::spawn_blocking(move || store.clear_all())
    419         .await
    420         .context("UI data cleanup worker failed")??;
    421     Ok(())
    422 }
    423 
    424 async fn clear_chain(store: &SqliteChainStore) -> Result<()> {
    425     let store = store.clone();
    426     tokio::task::spawn_blocking(move || store.clear_chain())
    427         .await
    428         .context("chain reset worker failed")??;
    429     Ok(())
    430 }
    431 
    432 pub(super) async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
    433     let peer = validate_peer_address(peer)?;
    434     let addresses = {
    435         let mut peers = state.peers.lock().await;
    436         peers.add_peer(peer);
    437         peers.addresses()
    438     };
    439     let mut config = state.ui_config.lock().await;
    440     config.peers = addresses;
    441     config_store::save(&state.config_path, &config)
    442 }
    443 
    444 pub(super) async fn remove_peer(state: &HttpState, peer: String) -> Result<()> {
    445     let peer = validate_peer_address(peer)?;
    446     let addresses = {
    447         let mut peers = state.peers.lock().await;
    448         if !peers.remove_peer(&peer) {
    449             bail!("peer is not configured as an outbound peer");
    450         }
    451         peers.addresses()
    452     };
    453     let mut config = state.ui_config.lock().await;
    454     config.peers = addresses;
    455     config_store::save(&state.config_path, &config)
    456 }
    457 
    458 pub(super) async fn upsert_address_book_entry(
    459     state: &HttpState,
    460     address: String,
    461     name: String,
    462     old_address: Option<String>,
    463 ) -> Result<()> {
    464     let address = validate_address_book_address(address)?;
    465     let name = validate_address_book_name(name)?;
    466     let old_address = old_address.map(validate_address_book_address).transpose()?;
    467     let mut config = state.ui_config.lock().await;
    468     if let Some(old_address) = old_address.as_deref() {
    469         if old_address != address {
    470             if config.address_book.contains_key(&address) {
    471                 bail!("address is already saved");
    472             }
    473             config.address_book.remove(old_address);
    474         }
    475     } else if config.address_book.contains_key(&address) {
    476         bail!("address is already saved");
    477     }
    478     config.address_book.insert(address, name);
    479     config_store::save(&state.config_path, &config)
    480 }
    481 
    482 pub(super) async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> {
    483     let address = validate_address_book_address(address)?;
    484     let mut config = state.ui_config.lock().await;
    485     config.address_book.remove(&address);
    486     config_store::save(&state.config_path, &config)
    487 }
    488 
    489 fn validate_peer_address(peer: String) -> Result<String> {
    490     let peer = peer.trim().to_string();
    491     if peer.is_empty() {
    492         bail!("peer address is required");
    493     }
    494     Ok(peer)
    495 }
    496 
    497 fn validate_address_book_address(address: String) -> Result<String> {
    498     let address = address.trim().to_string();
    499     if address.is_empty() {
    500         bail!("address is required");
    501     }
    502     validate_address(&address, "address book")?;
    503     Ok(address.to_ascii_lowercase())
    504 }
    505 
    506 fn validate_address_book_name(name: String) -> Result<String> {
    507     let name = name.trim().to_string();
    508     if name.is_empty() {
    509         bail!("name is required");
    510     }
    511     Ok(name)
    512 }