#[tauri::command] async fn current_block_height( state: State<'_, WalletState>, broadcast_node: String, ) -> Result { ensure_gui_settings_file().await?; let socket_address = active_broadcast_socket(&broadcast_node)?; let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; let response = serialized_wallet_rpc( &state, socket_address, String::new(), RPC_BLOCK_HEIGHT as usize, public_key, private_key, "Block height lookup failed", ) .await?; if let Some(error) = parse_error_response(&response) { return Err(error); } if response.len() != 4 { return Err("Block height response had an unexpected byte length.".to_string()); } Ok(u32::from_le_bytes(response[0..4].try_into().map_err( |_| "Failed to parse block height response.".to_string(), )?)) } #[tauri::command] async fn network_info( state: State<'_, WalletState>, broadcast_node: String, ) -> Result { ensure_gui_settings_file().await?; let socket_address = active_broadcast_socket(&broadcast_node)?; let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; let response = serialized_wallet_rpc( &state, socket_address, String::new(), RPC_NETWORK_INFO as usize, public_key, private_key, "Network info lookup failed", ) .await?; parse_network_info_response(response) } #[tauri::command] async fn test_broadcast_node_time( state: State<'_, WalletState>, broadcast_node: String, ) -> Result { ensure_gui_settings_file().await?; let socket_address = active_broadcast_socket(&broadcast_node)?; let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; let response = serialized_wallet_rpc( &state, socket_address, String::new(), RPC_TIME as usize, public_key, private_key, "Node time lookup failed", ) .await?; if let Some(error) = parse_error_response(&response) { return Err(error); } if response.len() != 4 { return Err("Node time response had an unexpected byte length.".to_string()); } Ok(u32::from_le_bytes( response[0..4] .try_into() .map_err(|_| "Failed to parse node time response.".to_string())?, )) } #[tauri::command] async fn read_local_transaction_cache(state: State<'_, WalletState>) -> Result { let cache_path = active_wallet_cache_path(&state)?; match read(&cache_path).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|_| "Local transaction cache is not valid UTF-8.".to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), Err(err) => Err(format!("Unable to read local transaction cache: {err}")), } } #[tauri::command] async fn write_local_transaction_cache( state: State<'_, WalletState>, cache_json: String, ) -> Result<(), String> { let cache_path = active_wallet_cache_path(&state)?; let parsed: JsonValue = serde_json::from_str(&cache_json) .map_err(|err| format!("Transaction cache must be valid JSON: {err}"))?; let normalized = serde_json::to_string_pretty(&parsed) .map_err(|err| format!("Unable to serialize transaction cache: {err}"))?; contractless::tokio::fs::write(&cache_path, normalized) .await .map_err(|err| format!("Unable to write local transaction cache: {err}")) } #[tauri::command] async fn read_local_transaction_index(state: State<'_, WalletState>) -> Result { let history_dir = active_wallet_history_dir(&state)?; let index_path = history_dir.join("index.json"); match read(&index_path).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|_| "Local transaction index is not valid UTF-8.".to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), Err(err) => Err(format!("Unable to read local transaction index: {err}")), } } #[tauri::command] async fn write_local_transaction_index( state: State<'_, WalletState>, index_json: String, ) -> Result<(), String> { let history_dir = active_wallet_history_dir(&state)?; create_dir_all(&history_dir) .await .map_err(|err| format!("Unable to create transaction history directory: {err}"))?; let parsed: JsonValue = serde_json::from_str(&index_json) .map_err(|err| format!("Transaction index must be valid JSON: {err}"))?; let normalized = serde_json::to_string_pretty(&parsed) .map_err(|err| format!("Unable to serialize transaction index: {err}"))?; contractless::tokio::fs::write(history_dir.join("index.json"), normalized) .await .map_err(|err| format!("Unable to write local transaction index: {err}")) } #[tauri::command] async fn read_local_transaction_record( state: State<'_, WalletState>, file_name: String, ) -> Result { let history_dir = active_wallet_history_dir(&state)?; let file_name = validate_transaction_record_file(&file_name)?; match read(history_dir.join(file_name)).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|_| "Local transaction record is not valid UTF-8.".to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("{}".to_string()), Err(err) => Err(format!("Unable to read local transaction record: {err}")), } } #[tauri::command] async fn write_local_transaction_record( state: State<'_, WalletState>, file_name: String, record_json: String, ) -> Result<(), String> { let history_dir = active_wallet_history_dir(&state)?; create_dir_all(&history_dir) .await .map_err(|err| format!("Unable to create transaction history directory: {err}"))?; let file_name = validate_transaction_record_file(&file_name)?; let parsed: JsonValue = serde_json::from_str(&record_json) .map_err(|err| format!("Transaction record must be valid JSON: {err}"))?; let normalized = serde_json::to_string_pretty(&parsed) .map_err(|err| format!("Unable to serialize transaction record: {err}"))?; contractless::tokio::fs::write(history_dir.join(file_name), normalized) .await .map_err(|err| format!("Unable to write local transaction record: {err}")) } fn validate_nft_cache_key(nft_name: &str, series: u32) -> Result { let name = nft_name.trim().to_ascii_lowercase(); if name.is_empty() { return Err("NFT cache name cannot be empty.".to_string()); } if !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') { return Err("NFT cache name contains unsupported characters.".to_string()); } Ok(format!("{name}_{series}")) } fn nft_cache_record_from_parts( detail: JsonValue, metadata: JsonValue, image_bytes: Option>, image_mime: Option, ) -> NftCacheRecordView { let image_data_url = image_bytes.map(|bytes| { let mime = image_mime .filter(|value| value.starts_with("image/")) .unwrap_or_else(|| "image/png".to_string()); format!("data:{mime};base64,{}", STANDARD.encode(bytes)) }); NftCacheRecordView { detail, metadata, image_data_url, } } async fn read_cached_nft_record(record_dir: &Path) -> Result { let record_path = record_dir.join("record.json"); let record_bytes = read(&record_path) .await .map_err(|err| format!("Unable to read NFT cache record: {err}"))?; let record: JsonValue = serde_json::from_slice(&record_bytes) .map_err(|err| format!("NFT cache record is not valid JSON: {err}"))?; let detail = record.get("detail").cloned().unwrap_or(JsonValue::Null); let metadata = record.get("metadata").cloned().unwrap_or(JsonValue::Null); let image_mime = record .get("image_mime") .and_then(|value| value.as_str()) .map(|value| value.to_string()); let image_bytes = match read(record_dir.join("image.bin")).await { Ok(bytes) => Some(bytes), Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, Err(err) => return Err(format!("Unable to read NFT cached image: {err}")), }; Ok(nft_cache_record_from_parts( detail, metadata, image_bytes, image_mime, )) } #[tauri::command] async fn read_nft_cache( state: State<'_, WalletState>, nft_name: String, series: u32, ) -> Result, String> { let cache_dir = active_wallet_nft_dir(&state)?; let key = validate_nft_cache_key(&nft_name, series)?; let record_dir = cache_dir.join(key); match read_cached_nft_record(&record_dir).await { Ok(record) => Ok(Some(record)), Err(err) if err.contains("os error 2") || err.contains("not find") => Ok(None), Err(err) => Err(err), } } #[tauri::command] async fn write_nft_cache( state: State<'_, WalletState>, nft_name: String, series: u32, detail_json: String, metadata_json: String, image_url: String, ) -> Result { let cache_dir = active_wallet_nft_dir(&state)?; let key = validate_nft_cache_key(&nft_name, series)?; let record_dir = cache_dir.join(key); create_dir_all(&record_dir) .await .map_err(|err| format!("Unable to create NFT cache directory: {err}"))?; let detail: JsonValue = serde_json::from_str(&detail_json) .map_err(|err| format!("NFT detail cache must be valid JSON: {err}"))?; let metadata: JsonValue = serde_json::from_str(&metadata_json) .map_err(|err| format!("NFT metadata cache must be valid JSON: {err}"))?; let mut image_mime = None; let mut image_cached = false; let image_url = image_url.trim(); if image_url.starts_with("http://") || image_url.starts_with("https://") || image_url.starts_with("data:image/") { if image_url.starts_with("data:image/") { if let Some((header, encoded)) = image_url.split_once(',') { image_mime = header .strip_prefix("data:") .and_then(|header| header.split_once(';').map(|(mime, _)| mime.to_string())); if let Ok(bytes) = base64::Engine::decode(&STANDARD, encoded) { if bytes.len() <= 10_000_000 { contractless::tokio::fs::write(record_dir.join("image.bin"), bytes) .await .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; image_cached = true; } } } } else { let client = reqwest::Client::builder() .timeout(Duration::from_secs(20)) .build() .map_err(|err| format!("Failed to prepare NFT image request: {err}"))?; let response = client .get(image_url) .send() .await .map_err(|err| format!("Failed to fetch NFT image: {err}"))? .error_for_status() .map_err(|err| format!("NFT image gateway returned an error: {err}"))?; image_mime = response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(|value| value.split(';').next().unwrap_or(value).to_string()); let bytes = response .bytes() .await .map_err(|err| format!("Failed to read NFT image response: {err}"))?; if bytes.len() <= 10_000_000 { contractless::tokio::fs::write(record_dir.join("image.bin"), &bytes) .await .map_err(|err| format!("Unable to write NFT cached image: {err}"))?; image_cached = true; } } } let record = serde_json::json!({ "detail": detail, "metadata": metadata, "image_mime": image_mime, "image_cached": image_cached, }); let normalized = serde_json::to_string_pretty(&record) .map_err(|err| format!("Unable to serialize NFT cache record: {err}"))?; contractless::tokio::fs::write(record_dir.join("record.json"), normalized) .await .map_err(|err| format!("Unable to write NFT cache record: {err}"))?; read_cached_nft_record(&record_dir).await } #[tauri::command] async fn prune_nft_cache( state: State<'_, WalletState>, keep_keys: Vec, ) -> Result<(), String> { let cache_dir = active_wallet_nft_dir(&state)?; let keep: std::collections::HashSet = keep_keys .into_iter() .map(|key| key.trim().to_ascii_lowercase()) .filter(|key| !key.is_empty()) .collect(); let mut entries = match read_dir(&cache_dir).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")), }; while let Ok(Some(entry)) = entries.next_entry().await { let file_type = match entry.file_type().await { Ok(file_type) => file_type, Err(_) => continue, }; if !file_type.is_dir() { continue; } let key = entry.file_name().to_string_lossy().to_ascii_lowercase(); if !keep.contains(&key) { contractless::tokio::fs::remove_dir_all(entry.path()) .await .map_err(|err| format!("Unable to remove stale NFT cache entry: {err}"))?; } } Ok(()) } async fn remove_file_if_exists(path: &Path) -> Result<(), String> { match remove_file(path).await { Ok(_) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(format!("Unable to remove {}: {err}", path.display())), } } async fn remove_dir_if_exists(path: &Path) -> Result<(), String> { match remove_dir_all(path).await { Ok(_) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(format!("Unable to remove {}: {err}", path.display())), } } async fn clear_nft_cache_files(cache_dir: &Path, file_name: &str) -> Result<(), String> { let mut entries = match read_dir(cache_dir).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) => return Err(format!("Unable to read NFT cache directory: {err}")), }; while let Ok(Some(entry)) = entries.next_entry().await { let file_type = match entry.file_type().await { Ok(file_type) => file_type, Err(_) => continue, }; if file_type.is_dir() { remove_file_if_exists(&entry.path().join(file_name)).await?; } } Ok(()) } #[tauri::command] async fn clear_wallet_cache(state: State<'_, WalletState>, cache_kind: String) -> Result<(), String> { match cache_kind.trim().to_ascii_lowercase().as_str() { "history" => { remove_file_if_exists(&active_wallet_cache_path(&state)?).await?; remove_dir_if_exists(&active_wallet_history_dir(&state)?).await } "nft" | "nft_images" | "nft_media" => { clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "image.bin").await } "nft_details" => { clear_nft_cache_files(&active_wallet_nft_dir(&state)?, "record.json").await } "address_book" => remove_file_if_exists(&active_wallet_address_book_path(&state)?).await, _ => Err("Unknown cache type.".to_string()), } } #[tauri::command] async fn read_address_book(state: State<'_, WalletState>) -> Result { let address_book_path = active_wallet_address_book_path(&state)?; match read(&address_book_path).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|_| "Address book is not valid UTF-8.".to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Ok("{\"entries\":{}}".to_string()) } Err(err) => Err(format!("Unable to read address book: {err}")), } } #[tauri::command] async fn write_address_book( state: State<'_, WalletState>, address_book_json: String, ) -> Result<(), String> { let address_book_path = active_wallet_address_book_path(&state)?; let parsed: JsonValue = serde_json::from_str(&address_book_json) .map_err(|err| format!("Address book must be valid JSON: {err}"))?; let normalized = serde_json::to_string_pretty(&parsed) .map_err(|err| format!("Unable to serialize address book: {err}"))?; contractless::tokio::fs::write(&address_book_path, normalized) .await .map_err(|err| format!("Unable to write address book: {err}")) } #[tauri::command] async fn read_storage_labels(state: State<'_, WalletState>) -> Result { let labels_path = active_wallet_storage_labels_path(&state)?; match read(&labels_path).await { Ok(bytes) => String::from_utf8(bytes) .map_err(|_| "Storage labels file is not valid UTF-8.".to_string()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Ok("{\"labels\":{}}".to_string()) } Err(err) => Err(format!("Unable to read storage labels: {err}")), } } #[tauri::command] async fn write_storage_labels( state: State<'_, WalletState>, storage_labels_json: String, ) -> Result<(), String> { let labels_path = active_wallet_storage_labels_path(&state)?; let parsed: JsonValue = serde_json::from_str(&storage_labels_json) .map_err(|err| format!("Storage labels must be valid JSON: {err}"))?; let normalized = serde_json::to_string_pretty(&parsed) .map_err(|err| format!("Unable to serialize storage labels: {err}"))?; contractless::tokio::fs::write(&labels_path, normalized) .await .map_err(|err| format!("Unable to write storage labels: {err}")) } #[tauri::command] async fn lookup_address_vanity( state: State<'_, WalletState>, broadcast_node: String, address: String, ) -> Result, String> { ensure_gui_settings_file().await?; let owner_address = address.trim(); if owner_address.is_empty() { return Err("Address cannot be empty.".to_string()); } let socket_address = active_broadcast_socket(&broadcast_node)?; let (_short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; let response = serialized_wallet_rpc( &state, socket_address, owner_address.to_string(), RPC_VANITY_LOOKUP as usize, public_key, private_key, "Vanity address lookup failed", ) .await?; if let Some(error) = parse_error_response(&response) { return Err(error); } let vanity = String::from_utf8_lossy(&response).trim().to_string(); if vanity.is_empty() { Ok(None) } else { Ok(Some(vanity)) } } #[tauri::command] async fn check_vanity_address_availability( state: State<'_, WalletState>, broadcast_node: String, vanity_name: String, ) -> Result { ensure_gui_settings_file().await?; let socket_address = active_broadcast_socket(&broadcast_node)?; let (short_address, public_key, private_key) = active_wallet_rpc_parts(&state)?; let vanity_address = vanity_address_for_wallet(&vanity_name, &short_address)?; let display_vanity = display_vanity_address(&vanity_address) .ok_or_else(|| "Failed to build vanity address display.".to_string())?; let response = serialized_wallet_rpc( &state, socket_address, vanity_address, RPC_VANITY_OWNER_LOOKUP as usize, public_key, private_key, "Vanity availability lookup failed", ) .await?; if let Some(error) = parse_error_response(&response) { return Err(error); } let owner = String::from_utf8_lossy(&response).trim().to_string(); if owner.is_empty() { Ok(VanityAvailabilityView { vanity_address: display_vanity, available: true, owner: None, }) } else { Ok(VanityAvailabilityView { vanity_address: display_vanity, available: false, owner: Some(owner), }) } } #[tauri::command] async fn save_history_export( default_file_name: String, format: String, contents: String, ) -> Result, String> { let extension = match format.trim().to_ascii_lowercase().as_str() { "json" => "json", "xml" => "xml", "csv" => "csv", _ => return Err("Export format must be json, xml, or csv.".to_string()), }; let mut file_name = default_file_name.trim().to_string(); if file_name.is_empty() { file_name = format!("contractless-history.{extension}"); } if !file_name .to_ascii_lowercase() .ends_with(&format!(".{extension}")) { file_name.push('.'); file_name.push_str(extension); } let selected = FileDialog::new() .set_filename(&file_name) .add_filter( match extension { "json" => "JSON", "xml" => "XML", "csv" => "CSV", _ => "Export", }, &[extension], ) .show_save_single_file() .map_err(|err| format!("Unable to open export save dialog: {err}"))?; let Some(mut path) = selected else { return Ok(None); }; if path.extension().is_none() { path.set_extension(extension); } contractless::tokio::fs::write(&path, contents) .await .map_err(|err| format!("Unable to save history export: {err}"))?; Ok(Some(path.to_string_lossy().into_owned())) }