synor/apps/desktop-wallet/src-tauri/src/watch_only.rs
2026-02-02 14:30:07 +05:30

283 lines
7.6 KiB
Rust

//! Watch-only address management
//!
//! Allows monitoring addresses without holding private keys.
//! Useful for:
//! - Monitoring cold storage addresses
//! - Tracking other wallets
//! - Observing addresses before import
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
/// Watch-only addresses file name
const WATCH_ONLY_FILE: &str = "watch_only.json";
/// A watch-only address entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchOnlyAddress {
/// Bech32 encoded address
pub address: String,
/// User-defined label
pub label: String,
/// Network (mainnet/testnet)
pub network: String,
/// When this address was added
pub added_at: i64,
/// Optional notes
pub notes: Option<String>,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
/// Last known balance (cached)
#[serde(default)]
pub cached_balance: Option<u64>,
/// When balance was last updated
#[serde(default)]
pub balance_updated_at: Option<i64>,
}
/// Persisted watch-only addresses
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WatchOnlyData {
/// Map of address -> entry
pub addresses: HashMap<String, WatchOnlyAddress>,
}
/// Watch-only address manager
pub struct WatchOnlyManager {
/// Data directory
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
/// Watch-only addresses
pub data: Arc<RwLock<WatchOnlyData>>,
}
impl WatchOnlyManager {
/// Create a new manager
pub fn new() -> Self {
Self {
data_dir: Arc::new(RwLock::new(None)),
data: Arc::new(RwLock::new(WatchOnlyData::default())),
}
}
/// Set the data directory
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
tokio::fs::create_dir_all(&path).await
.map_err(|e| Error::Io(e))?;
let mut data_dir = self.data_dir.write().await;
*data_dir = Some(path);
// Load existing data
drop(data_dir);
self.load().await?;
Ok(())
}
/// Get the file path
async fn file_path(&self) -> Result<PathBuf> {
let data_dir = self.data_dir.read().await;
data_dir
.as_ref()
.map(|p| p.join(WATCH_ONLY_FILE))
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
}
/// Load watch-only addresses from disk
pub async fn load(&self) -> Result<()> {
let path = self.file_path().await?;
if !path.exists() {
return Ok(());
}
let json = tokio::fs::read_to_string(&path).await
.map_err(|e| Error::Io(e))?;
let loaded: WatchOnlyData = serde_json::from_str(&json)
.map_err(|e| Error::Serialization(e.to_string()))?;
let mut data = self.data.write().await;
*data = loaded;
Ok(())
}
/// Save watch-only addresses to disk
pub async fn save(&self) -> Result<()> {
let path = self.file_path().await?;
let data = self.data.read().await;
let json = serde_json::to_string_pretty(&*data)
.map_err(|e| Error::Serialization(e.to_string()))?;
tokio::fs::write(&path, json).await
.map_err(|e| Error::Io(e))?;
Ok(())
}
/// Add a watch-only address
pub async fn add_address(
&self,
address: String,
label: String,
network: String,
notes: Option<String>,
tags: Vec<String>,
) -> Result<WatchOnlyAddress> {
// Validate address format (basic check)
if !address.starts_with("synor1") && !address.starts_with("tsynor1") {
return Err(Error::Validation("Invalid address format".to_string()));
}
// Check for duplicates
{
let data = self.data.read().await;
if data.addresses.contains_key(&address) {
return Err(Error::Validation("Address already exists".to_string()));
}
}
let entry = WatchOnlyAddress {
address: address.clone(),
label,
network,
added_at: current_timestamp(),
notes,
tags,
cached_balance: None,
balance_updated_at: None,
};
{
let mut data = self.data.write().await;
data.addresses.insert(address.clone(), entry.clone());
}
self.save().await?;
Ok(entry)
}
/// Update a watch-only address
pub async fn update_address(
&self,
address: &str,
label: Option<String>,
notes: Option<String>,
tags: Option<Vec<String>>,
) -> Result<WatchOnlyAddress> {
let mut data = self.data.write().await;
let entry = data.addresses.get_mut(address)
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
if let Some(l) = label {
entry.label = l;
}
if let Some(n) = notes {
entry.notes = Some(n);
}
if let Some(t) = tags {
entry.tags = t;
}
let updated = entry.clone();
drop(data);
self.save().await?;
Ok(updated)
}
/// Remove a watch-only address
pub async fn remove_address(&self, address: &str) -> Result<()> {
let mut data = self.data.write().await;
if data.addresses.remove(address).is_none() {
return Err(Error::NotFound("Watch-only address not found".to_string()));
}
drop(data);
self.save().await?;
Ok(())
}
/// List all watch-only addresses
pub async fn list_addresses(&self) -> Vec<WatchOnlyAddress> {
let data = self.data.read().await;
let mut addresses: Vec<WatchOnlyAddress> = data.addresses.values().cloned().collect();
addresses.sort_by(|a, b| b.added_at.cmp(&a.added_at));
addresses
}
/// Get a specific watch-only address
pub async fn get_address(&self, address: &str) -> Option<WatchOnlyAddress> {
let data = self.data.read().await;
data.addresses.get(address).cloned()
}
/// Update cached balance for an address
pub async fn update_balance(&self, address: &str, balance: u64) -> Result<()> {
let mut data = self.data.write().await;
let entry = data.addresses.get_mut(address)
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
entry.cached_balance = Some(balance);
entry.balance_updated_at = Some(current_timestamp());
drop(data);
self.save().await?;
Ok(())
}
/// Get addresses by tag
pub async fn get_addresses_by_tag(&self, tag: &str) -> Vec<WatchOnlyAddress> {
let data = self.data.read().await;
data.addresses.values()
.filter(|a| a.tags.contains(&tag.to_string()))
.cloned()
.collect()
}
/// Get all unique tags
pub async fn get_all_tags(&self) -> Vec<String> {
let data = self.data.read().await;
let mut tags: Vec<String> = data.addresses.values()
.flat_map(|a| a.tags.clone())
.collect();
tags.sort();
tags.dedup();
tags
}
/// Get total count
pub async fn count(&self) -> usize {
let data = self.data.read().await;
data.addresses.len()
}
}
impl Default for WatchOnlyManager {
fn default() -> Self {
Self::new()
}
}
/// Get current timestamp
fn current_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}