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 }