//! 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, /// Tags for categorization #[serde(default)] pub tags: Vec, /// Last known balance (cached) #[serde(default)] pub cached_balance: Option, /// When balance was last updated #[serde(default)] pub balance_updated_at: Option, } /// Persisted watch-only addresses #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct WatchOnlyData { /// Map of address -> entry pub addresses: HashMap, } /// Watch-only address manager pub struct WatchOnlyManager { /// Data directory pub data_dir: Arc>>, /// Watch-only addresses pub data: Arc>, } 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 { 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, tags: Vec, ) -> Result { // 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, notes: Option, tags: Option>, ) -> 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()))?; 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 { let data = self.data.read().await; let mut addresses: Vec = 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 { 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 { 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 { let data = self.data.read().await; let mut tags: Vec = 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) }