Contractless-GUI-Wallet/src/wallet_app/commands_network_cache.rs

633 lines
22 KiB
Rust
Raw Normal View History

#[tauri::command]
2026-07-01 16:01:30 +00:00
async fn current_block_height(
state: State<'_, WalletState>,
broadcast_node: String,
) -> Result<u32, String> {
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<NetworkInfoView, String> {
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<u32, String> {
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<String, String> {
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)
2026-07-01 16:01:30 +00:00
.await
.map_err(|err| format!("Unable to write local transaction cache: {err}"))
}
#[tauri::command]
async fn read_local_transaction_index(state: State<'_, WalletState>) -> Result<String, String> {
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)
2026-07-01 16:01:30 +00:00
.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<String, String> {
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)
2026-07-01 16:01:30 +00:00
.await
.map_err(|err| format!("Unable to write local transaction record: {err}"))
}
fn validate_nft_cache_key(nft_name: &str, series: u32) -> Result<String, String> {
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<Vec<u8>>,
image_mime: Option<String>,
) -> 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<NftCacheRecordView, String> {
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<Option<NftCacheRecordView>, 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<NftCacheRecordView, 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);
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)
2026-07-01 16:01:30 +00:00
.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)
2026-07-01 16:01:30 +00:00
.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)
2026-07-01 16:01:30 +00:00
.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<String>,
) -> Result<(), String> {
let cache_dir = active_wallet_nft_dir(&state)?;
let keep: std::collections::HashSet<String> = 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())
2026-07-01 16:01:30 +00:00
.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<String, String> {
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)
2026-07-01 16:01:30 +00:00
.await
.map_err(|err| format!("Unable to write address book: {err}"))
}
#[tauri::command]
async fn read_storage_labels(state: State<'_, WalletState>) -> Result<String, String> {
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}"))
}
2026-07-01 16:01:30 +00:00
#[tauri::command]
async fn lookup_address_vanity(
state: State<'_, WalletState>,
broadcast_node: String,
address: String,
) -> Result<Option<String>, 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<VanityAvailabilityView, String> {
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<Option<String>, 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)
2026-07-01 16:01:30 +00:00
.await
.map_err(|err| format!("Unable to save history export: {err}"))?;
Ok(Some(path.to_string_lossy().into_owned()))
}