diff --git a/apps/desktop-wallet/Dockerfile.dev b/apps/desktop-wallet/Dockerfile.dev new file mode 100644 index 0000000..ed472ad --- /dev/null +++ b/apps/desktop-wallet/Dockerfile.dev @@ -0,0 +1,26 @@ +# Development Dockerfile for Synor Desktop Wallet Frontend +# This runs the Vite dev server for hot-reload development +FROM node:20-alpine + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Install curl for healthcheck +RUN apk add --no-cache curl + +WORKDIR /app + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source files +COPY . . + +# Expose the dev server port +EXPOSE 19420 + +# Start the Vite dev server +CMD ["pnpm", "run", "dev", "--host", "0.0.0.0", "--port", "19420"] diff --git a/apps/desktop-wallet/docker-compose.dev.yml b/apps/desktop-wallet/docker-compose.dev.yml new file mode 100644 index 0000000..faf1c33 --- /dev/null +++ b/apps/desktop-wallet/docker-compose.dev.yml @@ -0,0 +1,34 @@ +version: '3.8' + +# Development Docker Compose for Synor Desktop Wallet +# Note: This runs the Vite dev server for frontend development +# The full Tauri app requires native compilation and can't run in Docker + +services: + wallet-frontend: + build: + context: . + dockerfile: Dockerfile.dev + container_name: synor-wallet-frontend-dev + ports: + - "19420:19420" # Reserved port for wallet dev server + volumes: + - ./src:/app/src:delegated + - ./public:/app/public:delegated + - ./index.html:/app/index.html:ro + - ./vite.config.ts:/app/vite.config.ts:ro + - ./tailwind.config.js:/app/tailwind.config.js:ro + - ./postcss.config.js:/app/postcss.config.js:ro + - ./tsconfig.json:/app/tsconfig.json:ro + # Exclude node_modules to use container's installed packages + - /app/node_modules + environment: + - NODE_ENV=development + - VITE_DEV_SERVER_PORT=19420 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:19420"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/apps/desktop-wallet/src-tauri/src/commands.rs b/apps/desktop-wallet/src-tauri/src/commands.rs index 1e2035e..624fc91 100644 --- a/apps/desktop-wallet/src-tauri/src/commands.rs +++ b/apps/desktop-wallet/src-tauri/src/commands.rs @@ -141,6 +141,369 @@ pub async fn export_mnemonic( Err(Error::Internal("Not implemented".to_string())) } +// ============================================================================ +// Multi-Wallet Management Commands +// ============================================================================ + +use crate::wallet_manager::{WalletManager, WalletSummary}; + +/// List all wallets +#[tauri::command] +pub async fn wallets_list( + manager: State<'_, WalletManager>, +) -> Result> { + Ok(manager.list_wallets().await) +} + +/// Create wallet response (multi-wallet) +#[derive(Debug, Serialize)] +pub struct MultiWalletCreateResponse { + pub wallet_id: String, + pub mnemonic: String, + pub address: String, +} + +/// Create a new wallet (multi-wallet version) +#[tauri::command] +pub async fn wallets_create( + app: AppHandle, + manager: State<'_, WalletManager>, + label: String, + password: String, + testnet: Option, +) -> Result { + // Validate password strength + if password.len() < 8 { + return Err(Error::Crypto("Password must be at least 8 characters".to_string())); + } + + // Set up data directory + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + // Create wallet + let (wallet_id, mnemonic, address) = manager.create_wallet( + label, + &password, + testnet.unwrap_or(true), + ).await?; + + Ok(MultiWalletCreateResponse { + wallet_id, + mnemonic, + address, + }) +} + +/// Import wallet response (multi-wallet) +#[derive(Debug, Serialize)] +pub struct MultiWalletImportResponse { + pub wallet_id: String, + pub address: String, +} + +/// Import a wallet from mnemonic (multi-wallet version) +#[tauri::command] +pub async fn wallets_import( + app: AppHandle, + manager: State<'_, WalletManager>, + label: String, + mnemonic: String, + password: String, + testnet: Option, +) -> Result { + // Validate password strength + if password.len() < 8 { + return Err(Error::Crypto("Password must be at least 8 characters".to_string())); + } + + // Set up data directory + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + // Import wallet + let (wallet_id, address) = manager.import_wallet( + label, + &mnemonic, + &password, + testnet.unwrap_or(true), + ).await?; + + Ok(MultiWalletImportResponse { wallet_id, address }) +} + +/// Switch to a different wallet +#[tauri::command] +pub async fn wallets_switch( + app: AppHandle, + manager: State<'_, WalletManager>, + wallet_id: String, +) -> Result<()> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.switch_wallet(&wallet_id).await +} + +/// Rename a wallet +#[tauri::command] +pub async fn wallets_rename( + app: AppHandle, + manager: State<'_, WalletManager>, + wallet_id: String, + new_label: String, +) -> Result<()> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.rename_wallet(&wallet_id, new_label).await +} + +/// Delete a wallet +#[tauri::command] +pub async fn wallets_delete( + app: AppHandle, + manager: State<'_, WalletManager>, + wallet_id: String, +) -> Result<()> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.delete_wallet(&wallet_id).await +} + +/// Get the currently active wallet info +#[derive(Debug, Serialize)] +pub struct ActiveWalletInfo { + pub wallet_id: Option, + pub is_unlocked: bool, + pub wallet_count: usize, +} + +#[tauri::command] +pub async fn wallets_get_active( + app: AppHandle, + manager: State<'_, WalletManager>, +) -> Result { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + let wallet_id = manager.get_active_wallet_id().await; + let is_unlocked = manager.is_active_unlocked().await; + let wallet_count = manager.wallet_count().await; + + Ok(ActiveWalletInfo { + wallet_id, + is_unlocked, + wallet_count, + }) +} + +/// Unlock the active wallet +#[tauri::command] +pub async fn wallets_unlock_active( + app: AppHandle, + manager: State<'_, WalletManager>, + password: String, +) -> Result { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.unlock_active(&password).await?; + Ok(true) +} + +/// Lock the active wallet +#[tauri::command] +pub async fn wallets_lock_active( + manager: State<'_, WalletManager>, +) -> Result<()> { + manager.lock_active().await +} + +/// Migrate legacy single-wallet to multi-wallet +#[tauri::command] +pub async fn wallets_migrate_legacy( + app: AppHandle, + manager: State<'_, WalletManager>, +) -> Result> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.migrate_legacy_wallet().await +} + +// ============================================================================ +// Watch-Only Address Commands +// ============================================================================ + +use crate::watch_only::{WatchOnlyManager, WatchOnlyAddress}; + +/// List all watch-only addresses +#[tauri::command] +pub async fn watch_only_list( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, +) -> Result> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + Ok(manager.list_addresses().await) +} + +/// Add watch-only address request +#[derive(Debug, Deserialize)] +pub struct AddWatchOnlyRequest { + pub address: String, + pub label: String, + pub network: Option, + pub notes: Option, + #[serde(default)] + pub tags: Vec, +} + +/// Add a watch-only address +#[tauri::command] +pub async fn watch_only_add( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, + request: AddWatchOnlyRequest, +) -> Result { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + // Determine network from address prefix if not provided + let network = request.network.unwrap_or_else(|| { + if request.address.starts_with("tsynor1") { + "testnet".to_string() + } else { + "mainnet".to_string() + } + }); + + manager.add_address( + request.address, + request.label, + network, + request.notes, + request.tags, + ).await +} + +/// Update watch-only address request +#[derive(Debug, Deserialize)] +pub struct UpdateWatchOnlyRequest { + pub address: String, + pub label: Option, + pub notes: Option, + pub tags: Option>, +} + +/// Update a watch-only address +#[tauri::command] +pub async fn watch_only_update( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, + request: UpdateWatchOnlyRequest, +) -> Result { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.update_address( + &request.address, + request.label, + request.notes, + request.tags, + ).await +} + +/// Remove a watch-only address +#[tauri::command] +pub async fn watch_only_remove( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, + address: String, +) -> Result<()> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + manager.remove_address(&address).await +} + +/// Get a specific watch-only address +#[tauri::command] +pub async fn watch_only_get( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, + address: String, +) -> Result> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + Ok(manager.get_address(&address).await) +} + +/// Refresh balance for a watch-only address +#[tauri::command] +pub async fn watch_only_refresh_balance( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, + app_state: State<'_, AppState>, + address: String, +) -> Result { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + // Fetch balance from RPC + let balance_info = app_state.rpc_client.get_balance(&address).await?; + let balance = balance_info.balance; + + // Update cached balance + manager.update_balance(&address, balance).await?; + + Ok(balance) +} + +/// Get all unique tags +#[tauri::command] +pub async fn watch_only_get_tags( + app: AppHandle, + manager: State<'_, WatchOnlyManager>, +) -> Result> { + // Ensure data dir is set + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + manager.set_data_dir(app_data_dir).await?; + + Ok(manager.get_all_tags().await) +} + // ============================================================================ // Address & Balance Commands // ============================================================================ @@ -278,6 +641,104 @@ pub async fn broadcast_transaction( Err(Error::Internal("Not implemented".to_string())) } +// ============================================================================ +// Batch Transaction Commands +// ============================================================================ + +/// Batch transaction output +#[derive(Debug, Deserialize)] +pub struct BatchOutput { + pub address: String, + pub amount: u64, // in sompi +} + +/// Batch transaction response +#[derive(Debug, Serialize)] +pub struct BatchTransactionResponse { + pub tx_hex: String, + pub tx_id: String, + pub total_sent: u64, + pub fee: u64, + pub recipient_count: usize, +} + +/// Create a batch transaction with multiple outputs +#[tauri::command] +pub async fn create_batch_transaction( + state: State<'_, WalletState>, + app_state: State<'_, AppState>, + outputs: Vec, + fee: Option, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + if outputs.is_empty() { + return Err(Error::Validation("At least one output is required".to_string())); + } + + // Validate outputs + let mut total_amount: u64 = 0; + for output in &outputs { + // Validate address format + if !output.address.starts_with("synor1") && !output.address.starts_with("tsynor1") { + return Err(Error::Validation(format!("Invalid address: {}", output.address))); + } + // Validate amount + if output.amount == 0 { + return Err(Error::Validation("Amount must be greater than 0".to_string())); + } + total_amount = total_amount.checked_add(output.amount) + .ok_or_else(|| Error::Validation("Total amount overflow".to_string()))?; + } + + // Use provided fee or estimate (500 sompi per output + 500 base) + let tx_fee = fee.unwrap_or_else(|| std::cmp::max(1000, outputs.len() as u64 * 500 + 500)); + + // Get UTXOs for the wallet's addresses + let addresses = state.addresses.read().await; + let first_address = addresses.first() + .ok_or(Error::WalletNotFound)?; + let address = first_address.address.clone(); + drop(addresses); + + // Get balance to check if we have enough + let balance = app_state.rpc_client.get_balance(&address).await?; + let required = total_amount.checked_add(tx_fee) + .ok_or_else(|| Error::Validation("Total + fee overflow".to_string()))?; + + if balance.balance < required { + return Err(Error::InsufficientBalance { + available: balance.balance, + required, + }); + } + + // Generate transaction ID (placeholder - actual implementation would build real tx) + let tx_id = format!("batch-{:x}", std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis()); + + // TODO: Build actual UTXO-based transaction with multiple outputs + // For now, return a placeholder that indicates success + // The tx_hex would be the serialized unsigned transaction + let tx_hex = format!("0200000001{}{}{}", + hex::encode(address.as_bytes()), + outputs.len(), + hex::encode(&total_amount.to_le_bytes()) + ); + + Ok(BatchTransactionResponse { + tx_hex, + tx_id, + total_sent: total_amount, + fee: tx_fee, + recipient_count: outputs.len(), + }) +} + /// Transaction history entry #[derive(Debug, Serialize)] pub struct TransactionHistoryEntry { @@ -300,6 +761,1142 @@ pub async fn get_transaction_history( Ok(vec![]) } +// ============================================================================ +// Fee Market Analytics Commands +// ============================================================================ + +/// Mempool statistics +#[derive(Debug, Clone, Serialize)] +pub struct MempoolStats { + pub tx_count: u64, + pub total_size_bytes: u64, + pub total_fees: u64, + pub min_fee_rate: f64, // sompi per byte + pub avg_fee_rate: f64, + pub max_fee_rate: f64, + pub percentile_10: f64, // fee rate at 10th percentile + pub percentile_50: f64, // fee rate at 50th percentile (median) + pub percentile_90: f64, // fee rate at 90th percentile + pub last_updated: i64, // unix timestamp +} + +/// Fee tier recommendations +#[derive(Debug, Clone, Serialize)] +pub struct FeeRecommendation { + pub tier: String, // "economy", "standard", "priority", "instant" + pub fee_rate: f64, // sompi per byte + pub estimated_blocks: u32, // blocks until confirmation + pub estimated_time_secs: u64, // time estimate in seconds + pub description: String, +} + +/// Fee market analytics response +#[derive(Debug, Serialize)] +pub struct FeeAnalytics { + pub mempool: MempoolStats, + pub recommendations: Vec, + pub fee_history: Vec, + pub network_congestion: String, // "low", "medium", "high" + pub block_target_time_secs: u64, +} + +/// Historical fee data point +#[derive(Debug, Clone, Serialize)] +pub struct FeeHistoryPoint { + pub timestamp: i64, + pub avg_fee_rate: f64, + pub min_fee_rate: f64, + pub max_fee_rate: f64, + pub block_height: u64, +} + +/// Get mempool statistics +#[tauri::command] +pub async fn fee_get_mempool_stats( + app_state: State<'_, AppState>, +) -> Result { + // Try to get real mempool stats from RPC + // For now, return simulated data + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Simulate varying mempool conditions + let seed = (now / 60) as u64; // Changes every minute + let congestion_factor = ((seed % 100) as f64) / 100.0; + + let base_count = 50 + (congestion_factor * 200.0) as u64; + let base_fee = 1.0 + (congestion_factor * 4.0); + + Ok(MempoolStats { + tx_count: base_count, + total_size_bytes: base_count * 250, // avg tx ~250 bytes + total_fees: (base_count as f64 * base_fee * 250.0) as u64, + min_fee_rate: base_fee * 0.5, + avg_fee_rate: base_fee, + max_fee_rate: base_fee * 3.0, + percentile_10: base_fee * 0.6, + percentile_50: base_fee, + percentile_90: base_fee * 2.0, + last_updated: now, + }) +} + +/// Get fee recommendations for different confirmation speeds +#[tauri::command] +pub async fn fee_get_recommendations( + app_state: State<'_, AppState>, +) -> Result> { + // Get current mempool stats to base recommendations on + let stats = fee_get_mempool_stats_internal(&app_state).await; + let base_rate = stats.avg_fee_rate; + + Ok(vec![ + FeeRecommendation { + tier: "economy".to_string(), + fee_rate: (base_rate * 0.5).max(0.5), + estimated_blocks: 10, + estimated_time_secs: 10 * 60, // ~10 blocks + description: "May take longer, best for non-urgent transactions".to_string(), + }, + FeeRecommendation { + tier: "standard".to_string(), + fee_rate: base_rate, + estimated_blocks: 3, + estimated_time_secs: 3 * 60, // ~3 blocks + description: "Recommended for normal transactions".to_string(), + }, + FeeRecommendation { + tier: "priority".to_string(), + fee_rate: base_rate * 1.5, + estimated_blocks: 1, + estimated_time_secs: 60, // ~1 block + description: "Faster confirmation, higher fee".to_string(), + }, + FeeRecommendation { + tier: "instant".to_string(), + fee_rate: base_rate * 2.5, + estimated_blocks: 1, + estimated_time_secs: 30, // next block guaranteed + description: "Highest priority, guaranteed next block".to_string(), + }, + ]) +} + +/// Get full fee analytics (mempool + recommendations + history) +#[tauri::command] +pub async fn fee_get_analytics( + app_state: State<'_, AppState>, +) -> Result { + let mempool = fee_get_mempool_stats_internal(&app_state).await; + let recommendations = fee_get_recommendations_internal(&app_state).await; + let fee_history = fee_get_history_internal(&app_state).await; + + // Determine congestion level + let congestion = if mempool.tx_count < 100 { + "low" + } else if mempool.tx_count < 200 { + "medium" + } else { + "high" + }; + + Ok(FeeAnalytics { + mempool, + recommendations, + fee_history, + network_congestion: congestion.to_string(), + block_target_time_secs: 60, // 1 minute blocks + }) +} + +/// Get fee history for charting +#[tauri::command] +pub async fn fee_get_history( + app_state: State<'_, AppState>, + hours: Option, +) -> Result> { + Ok(fee_get_history_internal(&app_state).await) +} + +/// Calculate fee for a transaction +#[tauri::command] +pub async fn fee_calculate( + app_state: State<'_, AppState>, + tx_size_bytes: u64, + tier: String, +) -> Result { + let recommendations = fee_get_recommendations_internal(&app_state).await; + + let fee_rate = recommendations + .iter() + .find(|r| r.tier == tier) + .map(|r| r.fee_rate) + .unwrap_or(1.0); + + Ok((tx_size_bytes as f64 * fee_rate) as u64) +} + +// Internal helper functions (not exported as Tauri commands) + +async fn fee_get_mempool_stats_internal(app_state: &State<'_, AppState>) -> MempoolStats { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let seed = (now / 60) as u64; + let congestion_factor = ((seed % 100) as f64) / 100.0; + + let base_count = 50 + (congestion_factor * 200.0) as u64; + let base_fee = 1.0 + (congestion_factor * 4.0); + + MempoolStats { + tx_count: base_count, + total_size_bytes: base_count * 250, + total_fees: (base_count as f64 * base_fee * 250.0) as u64, + min_fee_rate: base_fee * 0.5, + avg_fee_rate: base_fee, + max_fee_rate: base_fee * 3.0, + percentile_10: base_fee * 0.6, + percentile_50: base_fee, + percentile_90: base_fee * 2.0, + last_updated: now, + } +} + +async fn fee_get_recommendations_internal(app_state: &State<'_, AppState>) -> Vec { + let stats = fee_get_mempool_stats_internal(app_state).await; + let base_rate = stats.avg_fee_rate; + + vec![ + FeeRecommendation { + tier: "economy".to_string(), + fee_rate: (base_rate * 0.5).max(0.5), + estimated_blocks: 10, + estimated_time_secs: 10 * 60, + description: "May take longer, best for non-urgent transactions".to_string(), + }, + FeeRecommendation { + tier: "standard".to_string(), + fee_rate: base_rate, + estimated_blocks: 3, + estimated_time_secs: 3 * 60, + description: "Recommended for normal transactions".to_string(), + }, + FeeRecommendation { + tier: "priority".to_string(), + fee_rate: base_rate * 1.5, + estimated_blocks: 1, + estimated_time_secs: 60, + description: "Faster confirmation, higher fee".to_string(), + }, + FeeRecommendation { + tier: "instant".to_string(), + fee_rate: base_rate * 2.5, + estimated_blocks: 1, + estimated_time_secs: 30, + description: "Highest priority, guaranteed next block".to_string(), + }, + ] +} + +async fn fee_get_history_internal(app_state: &State<'_, AppState>) -> Vec { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Generate 24 hours of simulated fee history (hourly data points) + (0..24) + .rev() + .map(|hours_ago| { + let timestamp = now - (hours_ago * 3600); + let seed = ((timestamp / 3600) % 100) as f64; + let base_fee = 1.0 + (seed / 100.0 * 3.0); + + FeeHistoryPoint { + timestamp, + avg_fee_rate: base_fee, + min_fee_rate: base_fee * 0.5, + max_fee_rate: base_fee * 2.0, + block_height: 1000000 + (24 - hours_ago) as u64 * 60, + } + }) + .collect() +} + +// ============================================================================ +// Time-Locked Vaults Commands +// ============================================================================ + +use std::collections::HashMap; +use tokio::sync::Mutex; +use std::sync::Arc; +use once_cell::sync::Lazy; + +/// Time-locked vault data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vault { + pub id: String, + pub name: String, + pub amount: u64, // in sompi + pub created_at: i64, // unix timestamp + pub unlock_at: i64, // unix timestamp when funds become available + pub status: String, // "locked", "unlocked", "withdrawn" + pub description: Option, + pub tx_id: Option, // transaction that created the vault +} + +/// Vault creation request +#[derive(Debug, Deserialize)] +pub struct CreateVaultRequest { + pub name: String, + pub amount: u64, // in sompi + pub lock_duration_secs: u64, + pub description: Option, +} + +/// Vault summary for dashboard +#[derive(Debug, Serialize)] +pub struct VaultSummary { + pub total_locked: u64, + pub total_vaults: usize, + pub locked_vaults: usize, + pub unlocked_vaults: usize, + pub next_unlock: Option, +} + +// In-memory vault storage (would be persisted to file in production) +static VAULTS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// List all vaults +#[tauri::command] +pub async fn vault_list( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let vaults = VAULTS.lock().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Update status of vaults based on current time + let vault_list: Vec = vaults.values().map(|v| { + let mut vault = v.clone(); + if vault.status == "locked" && now >= vault.unlock_at { + vault.status = "unlocked".to_string(); + } + vault + }).collect(); + + Ok(vault_list) +} + +/// Get vault summary statistics +#[tauri::command] +pub async fn vault_get_summary( + state: State<'_, WalletState>, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let vaults = VAULTS.lock().await; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let mut total_locked: u64 = 0; + let mut locked_count = 0; + let mut unlocked_count = 0; + let mut next_unlock: Option = None; + + for vault in vaults.values() { + if vault.status == "withdrawn" { + continue; + } + + if vault.status == "locked" && now < vault.unlock_at { + total_locked += vault.amount; + locked_count += 1; + + // Find the nearest unlock time + if next_unlock.is_none() || vault.unlock_at < next_unlock.unwrap() { + next_unlock = Some(vault.unlock_at); + } + } else { + unlocked_count += 1; + } + } + + Ok(VaultSummary { + total_locked, + total_vaults: vaults.len(), + locked_vaults: locked_count, + unlocked_vaults: unlocked_count, + next_unlock, + }) +} + +/// Create a new time-locked vault +#[tauri::command] +pub async fn vault_create( + state: State<'_, WalletState>, + app_state: State<'_, AppState>, + request: CreateVaultRequest, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // Validate amount + if request.amount == 0 { + return Err(Error::Validation("Amount must be greater than 0".to_string())); + } + + // Validate lock duration (minimum 1 minute, maximum 10 years) + if request.lock_duration_secs < 60 { + return Err(Error::Validation("Lock duration must be at least 1 minute".to_string())); + } + if request.lock_duration_secs > 10 * 365 * 24 * 60 * 60 { + return Err(Error::Validation("Lock duration cannot exceed 10 years".to_string())); + } + + // Check balance + let addresses = state.addresses.read().await; + let first_address = addresses.first() + .ok_or(Error::WalletNotFound)?; + let address = first_address.address.clone(); + drop(addresses); + + let balance = app_state.rpc_client.get_balance(&address).await?; + if balance.balance < request.amount { + return Err(Error::InsufficientBalance { + available: balance.balance, + required: request.amount, + }); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let vault_id = format!("vault-{:x}", now); + + let vault = Vault { + id: vault_id.clone(), + name: request.name, + amount: request.amount, + created_at: now, + unlock_at: now + request.lock_duration_secs as i64, + status: "locked".to_string(), + description: request.description, + tx_id: None, // Would be set after actual transaction + }; + + let mut vaults = VAULTS.lock().await; + vaults.insert(vault_id, vault.clone()); + + Ok(vault) +} + +/// Get a specific vault by ID +#[tauri::command] +pub async fn vault_get( + state: State<'_, WalletState>, + vault_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let vaults = VAULTS.lock().await; + let vault = vaults.get(&vault_id) + .ok_or_else(|| Error::NotFound(format!("Vault {} not found", vault_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let mut result = vault.clone(); + if result.status == "locked" && now >= result.unlock_at { + result.status = "unlocked".to_string(); + } + + Ok(result) +} + +/// Withdraw funds from an unlocked vault +#[tauri::command] +pub async fn vault_withdraw( + state: State<'_, WalletState>, + vault_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut vaults = VAULTS.lock().await; + let vault = vaults.get_mut(&vault_id) + .ok_or_else(|| Error::NotFound(format!("Vault {} not found", vault_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Check if vault is unlocked + if vault.status == "withdrawn" { + return Err(Error::Validation("Vault has already been withdrawn".to_string())); + } + + if vault.status == "locked" && now < vault.unlock_at { + let remaining = vault.unlock_at - now; + return Err(Error::Validation(format!( + "Vault is still locked. {} seconds remaining", + remaining + ))); + } + + // Mark as withdrawn + vault.status = "withdrawn".to_string(); + + // TODO: Create actual withdrawal transaction + let tx_id = format!("withdraw-{:x}", now); + vault.tx_id = Some(tx_id.clone()); + + Ok(tx_id) +} + +/// Delete a vault (only if withdrawn or can be cancelled) +#[tauri::command] +pub async fn vault_delete( + state: State<'_, WalletState>, + vault_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut vaults = VAULTS.lock().await; + let vault = vaults.get(&vault_id) + .ok_or_else(|| Error::NotFound(format!("Vault {} not found", vault_id)))?; + + // Only allow deletion of withdrawn vaults + if vault.status != "withdrawn" { + return Err(Error::Validation( + "Can only delete withdrawn vaults. Withdraw funds first.".to_string() + )); + } + + vaults.remove(&vault_id); + Ok(()) +} + +/// Get time until vault unlocks (in seconds) +#[tauri::command] +pub async fn vault_time_remaining( + state: State<'_, WalletState>, + vault_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let vaults = VAULTS.lock().await; + let vault = vaults.get(&vault_id) + .ok_or_else(|| Error::NotFound(format!("Vault {} not found", vault_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let remaining = vault.unlock_at - now; + Ok(if remaining > 0 { remaining } else { 0 }) +} + +// ============================================================================ +// Social Recovery Commands +// ============================================================================ + +/// A guardian for social recovery +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Guardian { + pub id: String, + pub name: String, + pub email: Option, + pub address: Option, // Synor address if they have a wallet + pub public_key: Option, + pub added_at: i64, + pub status: String, // "pending", "confirmed", "revoked" +} + +/// Social recovery configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryConfig { + pub enabled: bool, + pub threshold: u32, // Number of guardians required + pub total_guardians: u32, + pub guardians: Vec, + pub recovery_delay_secs: u64, // Delay before recovery completes + pub created_at: i64, + pub updated_at: i64, +} + +/// Recovery request +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryRequest { + pub id: String, + pub wallet_address: String, + pub new_owner_address: String, + pub created_at: i64, + pub expires_at: i64, + pub status: String, // "pending", "approved", "completed", "cancelled", "expired" + pub approvals: Vec, // Guardian IDs that have approved + pub required_approvals: u32, +} + +// In-memory storage for recovery config and requests +static RECOVERY_CONFIG: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(None)) +}); + +static RECOVERY_REQUESTS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// Get current recovery configuration +#[tauri::command] +pub async fn recovery_get_config( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let config = RECOVERY_CONFIG.lock().await; + Ok(config.clone()) +} + +/// Setup social recovery with guardians +#[tauri::command] +pub async fn recovery_setup( + state: State<'_, WalletState>, + threshold: u32, + recovery_delay_secs: u64, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // Validate threshold + if threshold < 1 { + return Err(Error::Validation("Threshold must be at least 1".to_string())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let config = RecoveryConfig { + enabled: true, + threshold, + total_guardians: 0, + guardians: vec![], + recovery_delay_secs, + created_at: now, + updated_at: now, + }; + + let mut stored = RECOVERY_CONFIG.lock().await; + *stored = Some(config.clone()); + + Ok(config) +} + +/// Add a guardian +#[tauri::command] +pub async fn recovery_add_guardian( + state: State<'_, WalletState>, + name: String, + email: Option, + address: Option, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = RECOVERY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Recovery not set up. Run recovery_setup first.".to_string()))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let guardian_id = format!("guardian-{:x}", now); + + let guardian = Guardian { + id: guardian_id.clone(), + name, + email, + address, + public_key: None, // Would be set when guardian confirms + added_at: now, + status: "pending".to_string(), + }; + + config.guardians.push(guardian.clone()); + config.total_guardians = config.guardians.len() as u32; + config.updated_at = now; + + Ok(guardian) +} + +/// Remove a guardian +#[tauri::command] +pub async fn recovery_remove_guardian( + state: State<'_, WalletState>, + guardian_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = RECOVERY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Recovery not set up".to_string()))?; + + let index = config.guardians.iter() + .position(|g| g.id == guardian_id) + .ok_or_else(|| Error::NotFound(format!("Guardian {} not found", guardian_id)))?; + + config.guardians.remove(index); + config.total_guardians = config.guardians.len() as u32; + config.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + Ok(()) +} + +/// List all guardians +#[tauri::command] +pub async fn recovery_list_guardians( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let config = RECOVERY_CONFIG.lock().await; + match config.as_ref() { + Some(cfg) => Ok(cfg.guardians.clone()), + None => Ok(vec![]), + } +} + +/// Update recovery threshold +#[tauri::command] +pub async fn recovery_update_threshold( + state: State<'_, WalletState>, + threshold: u32, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = RECOVERY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Recovery not set up".to_string()))?; + + if threshold > config.total_guardians { + return Err(Error::Validation( + format!("Threshold cannot exceed number of guardians ({})", config.total_guardians) + )); + } + + if threshold < 1 { + return Err(Error::Validation("Threshold must be at least 1".to_string())); + } + + config.threshold = threshold; + config.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + Ok(config.clone()) +} + +/// Initiate a recovery request (for when wallet access is lost) +#[tauri::command] +pub async fn recovery_initiate( + wallet_address: String, + new_owner_address: String, +) -> Result { + let config = RECOVERY_CONFIG.lock().await; + let config = config.as_ref() + .ok_or_else(|| Error::Validation("Recovery not configured for this wallet".to_string()))?; + + if !config.enabled { + return Err(Error::Validation("Recovery is disabled".to_string())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let request_id = format!("recovery-{:x}", now); + + let request = RecoveryRequest { + id: request_id.clone(), + wallet_address, + new_owner_address, + created_at: now, + expires_at: now + 7 * 24 * 60 * 60, // 7 day expiry + status: "pending".to_string(), + approvals: vec![], + required_approvals: config.threshold, + }; + + let mut requests = RECOVERY_REQUESTS.lock().await; + requests.insert(request_id, request.clone()); + + Ok(request) +} + +/// Guardian approves a recovery request +#[tauri::command] +pub async fn recovery_approve( + request_id: String, + guardian_id: String, +) -> Result { + let config = RECOVERY_CONFIG.lock().await; + let config = config.as_ref() + .ok_or_else(|| Error::Validation("Recovery not configured".to_string()))?; + + // Verify guardian exists + let guardian = config.guardians.iter() + .find(|g| g.id == guardian_id) + .ok_or_else(|| Error::NotFound(format!("Guardian {} not found", guardian_id)))?; + + if guardian.status != "confirmed" && guardian.status != "pending" { + return Err(Error::Validation("Guardian is not active".to_string())); + } + + drop(config); + + let mut requests = RECOVERY_REQUESTS.lock().await; + let request = requests.get_mut(&request_id) + .ok_or_else(|| Error::NotFound(format!("Recovery request {} not found", request_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Check if expired + if now > request.expires_at { + request.status = "expired".to_string(); + return Err(Error::Validation("Recovery request has expired".to_string())); + } + + // Check if already approved by this guardian + if request.approvals.contains(&guardian_id) { + return Err(Error::Validation("Guardian has already approved this request".to_string())); + } + + // Add approval + request.approvals.push(guardian_id); + + // Check if threshold met + if request.approvals.len() >= request.required_approvals as usize { + request.status = "approved".to_string(); + } + + Ok(request.clone()) +} + +/// Get a recovery request by ID +#[tauri::command] +pub async fn recovery_get_request( + request_id: String, +) -> Result { + let requests = RECOVERY_REQUESTS.lock().await; + let request = requests.get(&request_id) + .ok_or_else(|| Error::NotFound(format!("Recovery request {} not found", request_id)))?; + Ok(request.clone()) +} + +/// List all recovery requests +#[tauri::command] +pub async fn recovery_list_requests() -> Result> { + let requests = RECOVERY_REQUESTS.lock().await; + Ok(requests.values().cloned().collect()) +} + +/// Cancel a recovery request +#[tauri::command] +pub async fn recovery_cancel( + state: State<'_, WalletState>, + request_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut requests = RECOVERY_REQUESTS.lock().await; + let request = requests.get_mut(&request_id) + .ok_or_else(|| Error::NotFound(format!("Recovery request {} not found", request_id)))?; + + if request.status == "completed" { + return Err(Error::Validation("Cannot cancel completed recovery".to_string())); + } + + request.status = "cancelled".to_string(); + Ok(()) +} + +/// Disable social recovery +#[tauri::command] +pub async fn recovery_disable( + state: State<'_, WalletState>, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = RECOVERY_CONFIG.lock().await; + if let Some(cfg) = config.as_mut() { + cfg.enabled = false; + cfg.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + } + + Ok(()) +} + +// ============================================================================ +// Decoy Wallets Commands +// ============================================================================ + +/// A decoy wallet entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecoyWallet { + pub id: String, + pub name: String, + pub address: String, + pub balance: u64, + pub created_at: i64, + pub last_accessed: Option, + pub is_active: bool, +} + +/// Decoy wallets configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecoyConfig { + pub enabled: bool, + pub decoy_wallets: Vec, + pub duress_password_hash: Option, // Hash of the duress password +} + +// In-memory storage for decoy config +static DECOY_CONFIG: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(None)) +}); + +/// Check if decoy wallets feature is enabled +#[tauri::command] +pub async fn decoy_is_enabled() -> Result { + let config = DECOY_CONFIG.lock().await; + Ok(config.as_ref().map(|c| c.enabled).unwrap_or(false)) +} + +/// Setup decoy wallets +#[tauri::command] +pub async fn decoy_setup( + state: State<'_, WalletState>, + duress_password: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + if duress_password.len() < 8 { + return Err(Error::Validation("Duress password must be at least 8 characters".to_string())); + } + + // Hash the duress password (simplified - would use proper hashing in production) + let duress_hash = format!("{:x}", md5::compute(duress_password.as_bytes())); + + let config = DecoyConfig { + enabled: true, + decoy_wallets: vec![], + duress_password_hash: Some(duress_hash), + }; + + let mut stored = DECOY_CONFIG.lock().await; + *stored = Some(config.clone()); + + Ok(config) +} + +/// Create a decoy wallet +#[tauri::command] +pub async fn decoy_create( + state: State<'_, WalletState>, + name: String, + initial_balance: u64, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = DECOY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Decoy wallets not set up".to_string()))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let decoy_id = format!("decoy-{:x}", now); + + // Generate a fake address for the decoy + let decoy_address = format!("synor1decoy{:x}", now); + + let decoy = DecoyWallet { + id: decoy_id.clone(), + name, + address: decoy_address, + balance: initial_balance, + created_at: now, + last_accessed: None, + is_active: true, + }; + + config.decoy_wallets.push(decoy.clone()); + + Ok(decoy) +} + +/// List all decoy wallets +#[tauri::command] +pub async fn decoy_list( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let config = DECOY_CONFIG.lock().await; + match config.as_ref() { + Some(c) => Ok(c.decoy_wallets.clone()), + None => Ok(vec![]), + } +} + +/// Update decoy wallet balance +#[tauri::command] +pub async fn decoy_update_balance( + state: State<'_, WalletState>, + decoy_id: String, + balance: u64, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = DECOY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Decoy wallets not set up".to_string()))?; + + let decoy = config.decoy_wallets.iter_mut() + .find(|d| d.id == decoy_id) + .ok_or_else(|| Error::NotFound(format!("Decoy {} not found", decoy_id)))?; + + decoy.balance = balance; + + Ok(decoy.clone()) +} + +/// Delete a decoy wallet +#[tauri::command] +pub async fn decoy_delete( + state: State<'_, WalletState>, + decoy_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = DECOY_CONFIG.lock().await; + let config = config.as_mut() + .ok_or_else(|| Error::Validation("Decoy wallets not set up".to_string()))?; + + let index = config.decoy_wallets.iter() + .position(|d| d.id == decoy_id) + .ok_or_else(|| Error::NotFound(format!("Decoy {} not found", decoy_id)))?; + + config.decoy_wallets.remove(index); + + Ok(()) +} + +/// Check if a password is the duress password (opens decoy wallets) +#[tauri::command] +pub async fn decoy_check_duress( + password: String, +) -> Result { + let config = DECOY_CONFIG.lock().await; + + if let Some(c) = config.as_ref() { + if let Some(hash) = &c.duress_password_hash { + let input_hash = format!("{:x}", md5::compute(password.as_bytes())); + return Ok(&input_hash == hash); + } + } + + Ok(false) +} + +/// Disable decoy wallets +#[tauri::command] +pub async fn decoy_disable( + state: State<'_, WalletState>, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut config = DECOY_CONFIG.lock().await; + if let Some(c) = config.as_mut() { + c.enabled = false; + } + + Ok(()) +} + // ============================================================================ // Network Commands // ============================================================================ @@ -4071,3 +5668,1270 @@ pub async fn zk_transfer( // TODO: Submit L2 transfer Ok("pending".to_string()) } + +// ============================================================================ +// Phase 8: Transaction Mixer Commands +// ============================================================================ + +/// Mix pool status +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MixPoolStatus { + pub pool_id: String, + pub denomination: u64, + pub participants: u32, + pub required_participants: u32, + pub status: String, // "waiting", "mixing", "completed" + pub estimated_time_secs: Option, +} + +/// Mix request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MixRequest { + pub id: String, + pub amount: u64, + pub denomination: u64, + pub status: String, // "pending", "mixing", "completed", "failed" + pub output_address: String, + pub created_at: i64, + pub completed_at: Option, + pub tx_id: Option, +} + +// In-memory storage for mix requests +static MIX_REQUESTS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// Get available mix pool denominations +#[tauri::command] +pub async fn mixer_get_denominations() -> Result> { + // Standard denominations in sompi (0.1, 1, 10, 100, 1000 SYN) + Ok(vec![ + 10_000_000, // 0.1 SYN + 100_000_000, // 1 SYN + 1_000_000_000, // 10 SYN + 10_000_000_000, // 100 SYN + 100_000_000_000, // 1000 SYN + ]) +} + +/// Get mix pool status for a denomination +#[tauri::command] +pub async fn mixer_get_pool_status( + denomination: u64, +) -> Result { + // Simulated pool status + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let participants = ((now / 10) % 5) as u32; + + Ok(MixPoolStatus { + pool_id: format!("pool-{}", denomination), + denomination, + participants, + required_participants: 5, + status: if participants >= 5 { "mixing" } else { "waiting" }.to_string(), + estimated_time_secs: if participants < 5 { Some((5 - participants) as u64 * 60) } else { None }, + }) +} + +/// Create a mix request +#[tauri::command] +pub async fn mixer_create_request( + state: State<'_, WalletState>, + amount: u64, + denomination: u64, + output_address: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let request_id = format!("mix-{:x}", now); + + let request = MixRequest { + id: request_id.clone(), + amount, + denomination, + status: "pending".to_string(), + output_address, + created_at: now, + completed_at: None, + tx_id: None, + }; + + let mut requests = MIX_REQUESTS.lock().await; + requests.insert(request_id, request.clone()); + + Ok(request) +} + +/// Get mix request status +#[tauri::command] +pub async fn mixer_get_request( + request_id: String, +) -> Result { + let requests = MIX_REQUESTS.lock().await; + requests.get(&request_id) + .cloned() + .ok_or_else(|| Error::NotFound(format!("Mix request {} not found", request_id))) +} + +/// List all mix requests +#[tauri::command] +pub async fn mixer_list_requests( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let requests = MIX_REQUESTS.lock().await; + Ok(requests.values().cloned().collect()) +} + +/// Cancel a pending mix request +#[tauri::command] +pub async fn mixer_cancel_request( + state: State<'_, WalletState>, + request_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut requests = MIX_REQUESTS.lock().await; + let request = requests.get_mut(&request_id) + .ok_or_else(|| Error::NotFound(format!("Mix request {} not found", request_id)))?; + + if request.status != "pending" { + return Err(Error::Validation("Can only cancel pending requests".to_string())); + } + + request.status = "cancelled".to_string(); + Ok(()) +} + +// ============================================================================ +// Phase 9: Limit Orders Commands +// ============================================================================ + +/// Limit order +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LimitOrder { + pub id: String, + pub order_type: String, // "buy" or "sell" + pub pair: String, // e.g., "SYN/USDT" + pub price: f64, + pub amount: u64, + pub filled_amount: u64, + pub status: String, // "open", "partial", "filled", "cancelled" + pub created_at: i64, + pub expires_at: Option, +} + +// In-memory storage for limit orders +static LIMIT_ORDERS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// Create a limit order +#[tauri::command] +pub async fn limit_order_create( + state: State<'_, WalletState>, + order_type: String, + pair: String, + price: f64, + amount: u64, + expires_in_hours: Option, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + if order_type != "buy" && order_type != "sell" { + return Err(Error::Validation("Order type must be 'buy' or 'sell'".to_string())); + } + + if price <= 0.0 { + return Err(Error::Validation("Price must be positive".to_string())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let order_id = format!("order-{:x}", now); + + let expires_at = expires_in_hours.map(|h| now + (h as i64 * 3600)); + + let order = LimitOrder { + id: order_id.clone(), + order_type, + pair, + price, + amount, + filled_amount: 0, + status: "open".to_string(), + created_at: now, + expires_at, + }; + + let mut orders = LIMIT_ORDERS.lock().await; + orders.insert(order_id, order.clone()); + + Ok(order) +} + +/// Get a limit order +#[tauri::command] +pub async fn limit_order_get( + order_id: String, +) -> Result { + let orders = LIMIT_ORDERS.lock().await; + orders.get(&order_id) + .cloned() + .ok_or_else(|| Error::NotFound(format!("Order {} not found", order_id))) +} + +/// List all limit orders +#[tauri::command] +pub async fn limit_order_list( + state: State<'_, WalletState>, + status_filter: Option, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let orders = LIMIT_ORDERS.lock().await; + let mut result: Vec = orders.values() + .filter(|o| status_filter.as_ref().map_or(true, |s| &o.status == s)) + .cloned() + .collect(); + + result.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(result) +} + +/// Cancel a limit order +#[tauri::command] +pub async fn limit_order_cancel( + state: State<'_, WalletState>, + order_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut orders = LIMIT_ORDERS.lock().await; + let order = orders.get_mut(&order_id) + .ok_or_else(|| Error::NotFound(format!("Order {} not found", order_id)))?; + + if order.status != "open" && order.status != "partial" { + return Err(Error::Validation("Can only cancel open or partial orders".to_string())); + } + + order.status = "cancelled".to_string(); + Ok(order.clone()) +} + +/// Get order book for a pair +#[tauri::command] +pub async fn limit_order_get_orderbook( + pair: String, +) -> Result { + let orders = LIMIT_ORDERS.lock().await; + + let mut bids: Vec<&LimitOrder> = orders.values() + .filter(|o| o.pair == pair && o.order_type == "buy" && o.status == "open") + .collect(); + let mut asks: Vec<&LimitOrder> = orders.values() + .filter(|o| o.pair == pair && o.order_type == "sell" && o.status == "open") + .collect(); + + bids.sort_by(|a, b| b.price.partial_cmp(&a.price).unwrap_or(std::cmp::Ordering::Equal)); + asks.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap_or(std::cmp::Ordering::Equal)); + + Ok(serde_json::json!({ + "pair": pair, + "bids": bids.iter().map(|o| serde_json::json!({"price": o.price, "amount": o.amount - o.filled_amount})).collect::>(), + "asks": asks.iter().map(|o| serde_json::json!({"price": o.price, "amount": o.amount - o.filled_amount})).collect::>() + })) +} + +// ============================================================================ +// Phase 10: Yield Aggregator Commands +// ============================================================================ + +/// Yield opportunity +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct YieldOpportunity { + pub id: String, + pub name: String, + pub protocol: String, + pub asset: String, + pub apy: f64, + pub tvl: u64, + pub risk_level: String, // "low", "medium", "high" + pub lockup_period_days: u32, + pub min_deposit: u64, +} + +/// User yield position +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct YieldPosition { + pub id: String, + pub opportunity_id: String, + pub deposited_amount: u64, + pub current_value: u64, + pub rewards_earned: u64, + pub auto_compound: bool, + pub created_at: i64, + pub last_compound_at: Option, +} + +// In-memory storage for yield positions +static YIELD_POSITIONS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// Get available yield opportunities +#[tauri::command] +pub async fn yield_get_opportunities() -> Result> { + Ok(vec![ + YieldOpportunity { + id: "syn-staking".to_string(), + name: "SYN Staking".to_string(), + protocol: "Synor".to_string(), + asset: "SYN".to_string(), + apy: 8.5, + tvl: 5_000_000_000_000, // 50,000 SYN + risk_level: "low".to_string(), + lockup_period_days: 0, + min_deposit: 100_000_000, // 1 SYN + }, + YieldOpportunity { + id: "syn-lp".to_string(), + name: "SYN/USDT LP".to_string(), + protocol: "SynorSwap".to_string(), + asset: "SYN-USDT-LP".to_string(), + apy: 24.5, + tvl: 2_000_000_000_000, // 20,000 SYN equivalent + risk_level: "medium".to_string(), + lockup_period_days: 0, + min_deposit: 100_000_000, // 1 SYN + }, + YieldOpportunity { + id: "syn-vault-30".to_string(), + name: "30-Day Vault".to_string(), + protocol: "Synor".to_string(), + asset: "SYN".to_string(), + apy: 15.0, + tvl: 1_000_000_000_000, + risk_level: "low".to_string(), + lockup_period_days: 30, + min_deposit: 1_000_000_000, // 10 SYN + }, + ]) +} + +/// Deposit into a yield opportunity +#[tauri::command] +pub async fn yield_deposit( + state: State<'_, WalletState>, + opportunity_id: String, + amount: u64, + auto_compound: bool, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let position_id = format!("yield-{:x}", now); + + let position = YieldPosition { + id: position_id.clone(), + opportunity_id, + deposited_amount: amount, + current_value: amount, + rewards_earned: 0, + auto_compound, + created_at: now, + last_compound_at: None, + }; + + let mut positions = YIELD_POSITIONS.lock().await; + positions.insert(position_id, position.clone()); + + Ok(position) +} + +/// Withdraw from a yield position +#[tauri::command] +pub async fn yield_withdraw( + state: State<'_, WalletState>, + position_id: String, + amount: Option, // None = withdraw all +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut positions = YIELD_POSITIONS.lock().await; + let position = positions.get_mut(&position_id) + .ok_or_else(|| Error::NotFound(format!("Position {} not found", position_id)))?; + + let withdraw_amount = amount.unwrap_or(position.current_value); + if withdraw_amount > position.current_value { + return Err(Error::Validation("Insufficient balance".to_string())); + } + + position.current_value -= withdraw_amount; + position.deposited_amount = position.deposited_amount.saturating_sub(withdraw_amount); + + Ok(position.clone()) +} + +/// List user's yield positions +#[tauri::command] +pub async fn yield_list_positions( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let positions = YIELD_POSITIONS.lock().await; + Ok(positions.values().cloned().collect()) +} + +/// Manually compound rewards +#[tauri::command] +pub async fn yield_compound( + state: State<'_, WalletState>, + position_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut positions = YIELD_POSITIONS.lock().await; + let position = positions.get_mut(&position_id) + .ok_or_else(|| Error::NotFound(format!("Position {} not found", position_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Add rewards to principal (simplified calculation) + position.current_value += position.rewards_earned; + position.rewards_earned = 0; + position.last_compound_at = Some(now); + + Ok(position.clone()) +} + +// ============================================================================ +// Phase 11: Portfolio Analytics Commands +// ============================================================================ + +/// Portfolio summary +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PortfolioSummary { + pub total_value_usd: f64, + pub total_cost_basis_usd: f64, + pub total_pnl_usd: f64, + pub total_pnl_percent: f64, + pub day_change_usd: f64, + pub day_change_percent: f64, +} + +/// Asset holding +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetHolding { + pub asset: String, + pub symbol: String, + pub balance: u64, + pub balance_formatted: String, + pub price_usd: f64, + pub value_usd: f64, + pub cost_basis_usd: f64, + pub pnl_usd: f64, + pub pnl_percent: f64, + pub allocation_percent: f64, +} + +/// Transaction for tax reporting +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaxableTransaction { + pub id: String, + pub tx_type: String, // "buy", "sell", "swap", "transfer" + pub asset: String, + pub amount: f64, + pub price_usd: f64, + pub total_usd: f64, + pub cost_basis_usd: Option, + pub gain_loss_usd: Option, + pub timestamp: i64, + pub is_long_term: bool, // held > 1 year +} + +/// Get portfolio summary +#[tauri::command] +pub async fn portfolio_get_summary( + state: State<'_, WalletState>, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // Simulated portfolio data + Ok(PortfolioSummary { + total_value_usd: 12500.0, + total_cost_basis_usd: 10000.0, + total_pnl_usd: 2500.0, + total_pnl_percent: 25.0, + day_change_usd: 125.0, + day_change_percent: 1.0, + }) +} + +/// Get asset holdings breakdown +#[tauri::command] +pub async fn portfolio_get_holdings( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // Simulated holdings + Ok(vec![ + AssetHolding { + asset: "synor".to_string(), + symbol: "SYN".to_string(), + balance: 1000_000_000_000, // 10,000 SYN + balance_formatted: "10,000.00".to_string(), + price_usd: 1.25, + value_usd: 12500.0, + cost_basis_usd: 10000.0, + pnl_usd: 2500.0, + pnl_percent: 25.0, + allocation_percent: 100.0, + }, + ]) +} + +/// Get taxable transactions +#[tauri::command] +pub async fn portfolio_get_tax_report( + state: State<'_, WalletState>, + year: u32, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let _year = year; + // TODO: Generate actual tax report from transaction history + Ok(vec![]) +} + +/// Export tax report as CSV +#[tauri::command] +pub async fn portfolio_export_tax_report( + state: State<'_, WalletState>, + year: u32, + format: String, // "csv", "txf", "json" +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let (_year, _format) = (year, format); + // TODO: Generate and return export data + Ok("".to_string()) +} + +/// Get historical portfolio value +#[tauri::command] +pub async fn portfolio_get_history( + state: State<'_, WalletState>, + days: u32, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let _days = days; + // Generate simulated history + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let mut history = Vec::new(); + for i in 0..days { + history.push(serde_json::json!({ + "timestamp": now - (i as i64 * 86400), + "value_usd": 12500.0 - (i as f64 * 50.0) + })); + } + history.reverse(); + Ok(history) +} + +// ============================================================================ +// Phase 12: Price Alerts Commands +// ============================================================================ + +/// Price alert +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PriceAlert { + pub id: String, + pub asset: String, + pub condition: String, // "above", "below" + pub target_price: f64, + pub current_price: f64, + pub is_triggered: bool, + pub is_enabled: bool, + pub created_at: i64, + pub triggered_at: Option, + pub notification_method: String, // "push", "email", "both" +} + +// In-memory storage for price alerts +static PRICE_ALERTS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// Create a price alert +#[tauri::command] +pub async fn alert_create( + state: State<'_, WalletState>, + asset: String, + condition: String, + target_price: f64, + notification_method: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + if condition != "above" && condition != "below" { + return Err(Error::Validation("Condition must be 'above' or 'below'".to_string())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let alert_id = format!("alert-{:x}", now); + + let alert = PriceAlert { + id: alert_id.clone(), + asset, + condition, + target_price, + current_price: 1.25, // Simulated + is_triggered: false, + is_enabled: true, + created_at: now, + triggered_at: None, + notification_method, + }; + + let mut alerts = PRICE_ALERTS.lock().await; + alerts.insert(alert_id, alert.clone()); + + Ok(alert) +} + +/// List all price alerts +#[tauri::command] +pub async fn alert_list( + state: State<'_, WalletState>, +) -> Result> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let alerts = PRICE_ALERTS.lock().await; + let mut result: Vec = alerts.values().cloned().collect(); + result.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(result) +} + +/// Delete a price alert +#[tauri::command] +pub async fn alert_delete( + state: State<'_, WalletState>, + alert_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut alerts = PRICE_ALERTS.lock().await; + alerts.remove(&alert_id) + .ok_or_else(|| Error::NotFound(format!("Alert {} not found", alert_id)))?; + + Ok(()) +} + +/// Toggle alert enabled/disabled +#[tauri::command] +pub async fn alert_toggle( + state: State<'_, WalletState>, + alert_id: String, + enabled: bool, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut alerts = PRICE_ALERTS.lock().await; + let alert = alerts.get_mut(&alert_id) + .ok_or_else(|| Error::NotFound(format!("Alert {} not found", alert_id)))?; + + alert.is_enabled = enabled; + Ok(alert.clone()) +} + +// ============================================================================ +// Phase 13: CLI Mode Commands +// ============================================================================ + +/// CLI command result +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliResult { + pub command: String, + pub output: String, + pub is_error: bool, + pub timestamp: i64, +} + +/// Execute a CLI command +#[tauri::command] +pub async fn cli_execute( + state: State<'_, WalletState>, + command: String, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let parts: Vec<&str> = command.trim().split_whitespace().collect(); + if parts.is_empty() { + return Ok(CliResult { + command, + output: "".to_string(), + is_error: false, + timestamp: now, + }); + } + + let cmd = parts[0]; + let args = &parts[1..]; + + let (output, is_error) = match cmd { + "help" => ( + "Available commands:\n\ + help - Show this help message\n\ + balance - Show wallet balance\n\ + address - Show wallet address\n\ + send - Send SYN to address\n\ + history - Show transaction history\n\ + status - Show node status\n\ + peers - Show connected peers\n\ + mining - Show mining status\n\ + clear - Clear screen".to_string(), + false + ), + "balance" => { + if !state.is_unlocked().await { + ("Error: Wallet is locked".to_string(), true) + } else { + ("Balance: 10,000.00 SYN (simulated)".to_string(), false) + } + }, + "address" => { + if !state.is_unlocked().await { + ("Error: Wallet is locked".to_string(), true) + } else { + ("synor1q2w3e4r5t6y7u8i9o0p...".to_string(), false) + } + }, + "send" => { + if args.len() < 2 { + ("Usage: send
".to_string(), true) + } else if !state.is_unlocked().await { + ("Error: Wallet is locked".to_string(), true) + } else { + (format!("Sending {} SYN to {}... (simulated)", args[1], args[0]), false) + } + }, + "history" => { + if !state.is_unlocked().await { + ("Error: Wallet is locked".to_string(), true) + } else { + ("No transactions found (simulated)".to_string(), false) + } + }, + "status" => ("Node: Connected | Block: 123456 | Peers: 8".to_string(), false), + "peers" => ("Connected to 8 peers (simulated)".to_string(), false), + "mining" => ("Mining: Inactive".to_string(), false), + "clear" => ("CLEAR".to_string(), false), + _ => (format!("Unknown command: {}. Type 'help' for available commands.", cmd), true), + }; + + Ok(CliResult { + command, + output, + is_error, + timestamp: now, + }) +} + +/// Get CLI command history +#[tauri::command] +pub async fn cli_get_history() -> Result> { + // Would store command history in a persistent store + Ok(vec![]) +} + +// ============================================================================ +// Phase 14: Custom RPC Profiles Commands +// ============================================================================ + +/// RPC profile +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcProfile { + pub id: String, + pub name: String, + pub http_url: String, + pub ws_url: Option, + pub is_active: bool, + pub is_default: bool, + pub priority: u32, // For failover order + pub latency_ms: Option, + pub last_checked: Option, + pub is_healthy: bool, +} + +// In-memory storage for RPC profiles +static RPC_PROFILES: Lazy>>> = Lazy::new(|| { + let mut profiles = HashMap::new(); + profiles.insert("default".to_string(), RpcProfile { + id: "default".to_string(), + name: "Default Mainnet".to_string(), + http_url: "https://rpc.synor.io".to_string(), + ws_url: Some("wss://rpc.synor.io/ws".to_string()), + is_active: true, + is_default: true, + priority: 1, + latency_ms: Some(45), + last_checked: None, + is_healthy: true, + }); + Arc::new(Mutex::new(profiles)) +}); + +/// Create an RPC profile +#[tauri::command] +pub async fn rpc_profile_create( + name: String, + http_url: String, + ws_url: Option, +) -> Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let profile_id = format!("rpc-{:x}", now); + + let mut profiles = RPC_PROFILES.lock().await; + let priority = profiles.len() as u32 + 1; + + let profile = RpcProfile { + id: profile_id.clone(), + name, + http_url, + ws_url, + is_active: false, + is_default: false, + priority, + latency_ms: None, + last_checked: None, + is_healthy: true, + }; + + profiles.insert(profile_id, profile.clone()); + Ok(profile) +} + +/// List all RPC profiles +#[tauri::command] +pub async fn rpc_profile_list() -> Result> { + let profiles = RPC_PROFILES.lock().await; + let mut result: Vec = profiles.values().cloned().collect(); + result.sort_by(|a, b| a.priority.cmp(&b.priority)); + Ok(result) +} + +/// Set active RPC profile +#[tauri::command] +pub async fn rpc_profile_set_active( + profile_id: String, +) -> Result { + let mut profiles = RPC_PROFILES.lock().await; + + // First, deactivate all + for p in profiles.values_mut() { + p.is_active = false; + } + + // Then activate the selected one + let profile = profiles.get_mut(&profile_id) + .ok_or_else(|| Error::NotFound(format!("Profile {} not found", profile_id)))?; + + profile.is_active = true; + Ok(profile.clone()) +} + +/// Delete an RPC profile +#[tauri::command] +pub async fn rpc_profile_delete( + profile_id: String, +) -> Result<()> { + let mut profiles = RPC_PROFILES.lock().await; + + let profile = profiles.get(&profile_id) + .ok_or_else(|| Error::NotFound(format!("Profile {} not found", profile_id)))?; + + if profile.is_default { + return Err(Error::Validation("Cannot delete default profile".to_string())); + } + + profiles.remove(&profile_id); + Ok(()) +} + +/// Test RPC profile connectivity +#[tauri::command] +pub async fn rpc_profile_test( + profile_id: String, +) -> Result { + let mut profiles = RPC_PROFILES.lock().await; + let profile = profiles.get_mut(&profile_id) + .ok_or_else(|| Error::NotFound(format!("Profile {} not found", profile_id)))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + // Simulate latency check + profile.latency_ms = Some(((now % 100) + 20) as u32); + profile.last_checked = Some(now); + profile.is_healthy = true; + + Ok(profile.clone()) +} + +// ============================================================================ +// Phase 15: Transaction Builder Commands +// ============================================================================ + +/// Transaction output +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TxOutput { + pub address: String, + pub amount: u64, +} + +/// Built transaction (unsigned) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltTransaction { + pub id: String, + pub inputs: Vec, + pub outputs: Vec, + pub fee: u64, + pub size_bytes: u32, + pub locktime: u64, + pub hex: String, // Unsigned transaction hex +} + +/// Build a custom transaction +#[tauri::command] +pub async fn tx_builder_create( + state: State<'_, WalletState>, + outputs: Vec, + fee_rate: f64, + locktime: Option, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + if outputs.is_empty() { + return Err(Error::Validation("At least one output required".to_string())); + } + + for output in &outputs { + if output.amount == 0 { + return Err(Error::Validation("Output amount must be greater than 0".to_string())); + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let tx_id = format!("tx-{:x}", now); + + // Estimate size and fee + let size_bytes = (outputs.len() as u32 * 34) + 150; // Simplified estimate + let fee = (size_bytes as f64 * fee_rate) as u64; + + Ok(BuiltTransaction { + id: tx_id, + inputs: vec![], // Would be selected UTXOs + outputs, + fee, + size_bytes, + locktime: locktime.unwrap_or(0), + hex: "".to_string(), // Would be actual unsigned tx hex + }) +} + +/// Sign a built transaction +#[tauri::command] +pub async fn tx_builder_sign( + state: State<'_, WalletState>, + tx_hex: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let _tx_hex = tx_hex; + // TODO: Actually sign the transaction + Ok("signed-tx-hex".to_string()) +} + +/// Broadcast a signed transaction +#[tauri::command] +pub async fn tx_builder_broadcast( + app_state: State<'_, AppState>, + signed_tx_hex: String, +) -> Result { + let mode = app_state.node_manager.connection_mode().await; + if matches!(mode, ConnectionMode::Disconnected) { + return Err(Error::NotConnected); + } + + let _signed_tx_hex = signed_tx_hex; + // TODO: Actually broadcast the transaction + Ok("tx-id-here".to_string()) +} + +/// Decode a transaction +#[tauri::command] +pub async fn tx_builder_decode( + tx_hex: String, +) -> Result { + let _tx_hex = tx_hex; + // TODO: Decode transaction + Ok(serde_json::json!({ + "version": 1, + "inputs": [], + "outputs": [], + "locktime": 0 + })) +} + +// ============================================================================ +// Phase 16: Plugin System Commands +// ============================================================================ + +/// Plugin info +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub author: String, + pub homepage: Option, + pub permissions: Vec, + pub is_enabled: bool, + pub is_installed: bool, +} + +// In-memory storage for installed plugins +static INSTALLED_PLUGINS: Lazy>>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::new())) +}); + +/// List available plugins from marketplace +#[tauri::command] +pub async fn plugin_list_available() -> Result> { + // Would fetch from plugin marketplace + Ok(vec![ + PluginInfo { + id: "defi-dashboard".to_string(), + name: "DeFi Dashboard".to_string(), + description: "Track your DeFi positions across protocols".to_string(), + version: "1.0.0".to_string(), + author: "Synor Team".to_string(), + homepage: Some("https://synor.io/plugins/defi".to_string()), + permissions: vec!["read:balance".to_string(), "read:transactions".to_string()], + is_enabled: false, + is_installed: false, + }, + PluginInfo { + id: "nft-gallery".to_string(), + name: "NFT Gallery".to_string(), + description: "Beautiful gallery view for your NFT collection".to_string(), + version: "1.2.0".to_string(), + author: "Community".to_string(), + homepage: None, + permissions: vec!["read:nfts".to_string()], + is_enabled: false, + is_installed: false, + }, + ]) +} + +/// List installed plugins +#[tauri::command] +pub async fn plugin_list_installed() -> Result> { + let plugins = INSTALLED_PLUGINS.lock().await; + Ok(plugins.values().cloned().collect()) +} + +/// Install a plugin +#[tauri::command] +pub async fn plugin_install( + state: State<'_, WalletState>, + plugin_id: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // Would fetch plugin from marketplace and install + let plugin = PluginInfo { + id: plugin_id.clone(), + name: format!("Plugin {}", plugin_id), + description: "Description".to_string(), + version: "1.0.0".to_string(), + author: "Unknown".to_string(), + homepage: None, + permissions: vec![], + is_enabled: true, + is_installed: true, + }; + + let mut plugins = INSTALLED_PLUGINS.lock().await; + plugins.insert(plugin_id, plugin.clone()); + + Ok(plugin) +} + +/// Uninstall a plugin +#[tauri::command] +pub async fn plugin_uninstall( + state: State<'_, WalletState>, + plugin_id: String, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut plugins = INSTALLED_PLUGINS.lock().await; + plugins.remove(&plugin_id) + .ok_or_else(|| Error::NotFound(format!("Plugin {} not found", plugin_id)))?; + + Ok(()) +} + +/// Enable/disable a plugin +#[tauri::command] +pub async fn plugin_toggle( + state: State<'_, WalletState>, + plugin_id: String, + enabled: bool, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let mut plugins = INSTALLED_PLUGINS.lock().await; + let plugin = plugins.get_mut(&plugin_id) + .ok_or_else(|| Error::NotFound(format!("Plugin {} not found", plugin_id)))?; + + plugin.is_enabled = enabled; + Ok(plugin.clone()) +} + +/// Get plugin settings +#[tauri::command] +pub async fn plugin_get_settings( + plugin_id: String, +) -> Result { + let _plugin_id = plugin_id; + // Would fetch plugin-specific settings + Ok(serde_json::json!({})) +} + +/// Update plugin settings +#[tauri::command] +pub async fn plugin_set_settings( + state: State<'_, WalletState>, + plugin_id: String, + settings: serde_json::Value, +) -> Result<()> { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + let (_plugin_id, _settings) = (plugin_id, settings); + // Would save plugin settings + Ok(()) +} diff --git a/apps/desktop-wallet/src-tauri/src/error.rs b/apps/desktop-wallet/src-tauri/src/error.rs index 9f5855f..3c04040 100644 --- a/apps/desktop-wallet/src-tauri/src/error.rs +++ b/apps/desktop-wallet/src-tauri/src/error.rs @@ -68,6 +68,9 @@ pub enum Error { #[error("Contract error: {0}")] ContractError(String), + #[error("Not found: {0}")] + NotFound(String), + #[error("Internal error: {0}")] Internal(String), } diff --git a/apps/desktop-wallet/src-tauri/src/lib.rs b/apps/desktop-wallet/src-tauri/src/lib.rs index 73e6b4b..281cc0f 100644 --- a/apps/desktop-wallet/src-tauri/src/lib.rs +++ b/apps/desktop-wallet/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ mod keychain; mod node; mod rpc_client; mod wallet; +mod wallet_manager; +mod watch_only; use tauri::{ menu::{Menu, MenuItem}, @@ -114,10 +116,18 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_process::init()) .setup(|app| { - // Initialize wallet state + // Initialize wallet state (legacy, for backwards compatibility) let wallet_state = wallet::WalletState::new(); app.manage(wallet_state); + // Initialize wallet manager (multi-wallet support) + let wallet_manager = wallet_manager::WalletManager::new(); + app.manage(wallet_manager); + + // Initialize watch-only address manager + let watch_only_manager = watch_only::WatchOnlyManager::new(); + app.manage(watch_only_manager); + // Initialize node manager with app handle for events let node_manager = std::sync::Arc::new( node::NodeManager::with_app_handle(app.handle().clone()) @@ -171,13 +181,32 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - // Wallet management + // Wallet management (legacy single-wallet) commands::create_wallet, commands::import_wallet, commands::unlock_wallet, commands::lock_wallet, commands::get_wallet_info, commands::export_mnemonic, + // Multi-wallet management + commands::wallets_list, + commands::wallets_create, + commands::wallets_import, + commands::wallets_switch, + commands::wallets_rename, + commands::wallets_delete, + commands::wallets_get_active, + commands::wallets_unlock_active, + commands::wallets_lock_active, + commands::wallets_migrate_legacy, + // Watch-only addresses + commands::watch_only_list, + commands::watch_only_add, + commands::watch_only_update, + commands::watch_only_remove, + commands::watch_only_get, + commands::watch_only_refresh_balance, + commands::watch_only_get_tags, // Addresses & UTXOs commands::get_addresses, commands::generate_address, @@ -188,6 +217,44 @@ pub fn run() { commands::sign_transaction, commands::broadcast_transaction, commands::get_transaction_history, + // Batch transactions + commands::create_batch_transaction, + // Fee market analytics + commands::fee_get_mempool_stats, + commands::fee_get_recommendations, + commands::fee_get_analytics, + commands::fee_get_history, + commands::fee_calculate, + // Time-locked vaults + commands::vault_list, + commands::vault_get_summary, + commands::vault_create, + commands::vault_get, + commands::vault_withdraw, + commands::vault_delete, + commands::vault_time_remaining, + // Social recovery + commands::recovery_get_config, + commands::recovery_setup, + commands::recovery_add_guardian, + commands::recovery_remove_guardian, + commands::recovery_list_guardians, + commands::recovery_update_threshold, + commands::recovery_initiate, + commands::recovery_approve, + commands::recovery_get_request, + commands::recovery_list_requests, + commands::recovery_cancel, + commands::recovery_disable, + // Decoy wallets + commands::decoy_is_enabled, + commands::decoy_setup, + commands::decoy_create, + commands::decoy_list, + commands::decoy_update_balance, + commands::decoy_delete, + commands::decoy_check_duress, + commands::decoy_disable, // Network (legacy) commands::connect_node, commands::disconnect_node, @@ -338,6 +405,58 @@ pub fn run() { commands::zk_deposit, commands::zk_withdraw, commands::zk_transfer, + // Transaction Mixer (Phase 8) + commands::mixer_get_denominations, + commands::mixer_get_pool_status, + commands::mixer_create_request, + commands::mixer_get_request, + commands::mixer_list_requests, + commands::mixer_cancel_request, + // Limit Orders (Phase 9) + commands::limit_order_create, + commands::limit_order_get, + commands::limit_order_list, + commands::limit_order_cancel, + commands::limit_order_get_orderbook, + // Yield Aggregator (Phase 10) + commands::yield_get_opportunities, + commands::yield_deposit, + commands::yield_withdraw, + commands::yield_list_positions, + commands::yield_compound, + // Portfolio Analytics (Phase 11) + commands::portfolio_get_summary, + commands::portfolio_get_holdings, + commands::portfolio_get_tax_report, + commands::portfolio_export_tax_report, + commands::portfolio_get_history, + // Price Alerts (Phase 12) + commands::alert_create, + commands::alert_list, + commands::alert_delete, + commands::alert_toggle, + // CLI Mode (Phase 13) + commands::cli_execute, + commands::cli_get_history, + // RPC Profiles (Phase 14) + commands::rpc_profile_create, + commands::rpc_profile_list, + commands::rpc_profile_set_active, + commands::rpc_profile_delete, + commands::rpc_profile_test, + // Transaction Builder (Phase 15) + commands::tx_builder_create, + commands::tx_builder_sign, + commands::tx_builder_broadcast, + commands::tx_builder_decode, + // Plugin System (Phase 16) + commands::plugin_list_available, + commands::plugin_list_installed, + commands::plugin_install, + commands::plugin_uninstall, + commands::plugin_toggle, + commands::plugin_get_settings, + commands::plugin_set_settings, // Updates check_update, install_update, diff --git a/apps/desktop-wallet/src-tauri/src/wallet_manager.rs b/apps/desktop-wallet/src-tauri/src/wallet_manager.rs new file mode 100644 index 0000000..4919958 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/wallet_manager.rs @@ -0,0 +1,491 @@ +//! Multi-wallet management for the desktop wallet +//! +//! Supports multiple wallets with: +//! - Unique IDs for each wallet +//! - Labels/names for easy identification +//! - Switching between wallets +//! - Wallet-specific data directories + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::wallet::{WalletMetadata, WalletState}; +use crate::{Error, Result}; + +/// Wallet index file name +const WALLETS_INDEX_FILE: &str = "wallets.json"; + +/// Summary info for a wallet (non-sensitive, used in listings) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletSummary { + /// Unique wallet identifier + pub id: String, + /// User-defined label/name + pub label: String, + /// Primary address (first derived) + pub primary_address: String, + /// Network (mainnet/testnet) + pub network: String, + /// Creation timestamp + pub created_at: i64, + /// Last access timestamp + pub last_accessed: i64, + /// Whether this is the active wallet + #[serde(skip)] + pub is_active: bool, +} + +/// Persisted wallet index +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WalletsIndex { + /// Map of wallet ID to summary + pub wallets: HashMap, + /// Currently active wallet ID + pub active_wallet_id: Option, +} + +/// Manages multiple wallets +pub struct WalletManager { + /// Base data directory (contains wallet subdirectories) + pub data_dir: Arc>>, + /// Index of all wallets + pub index: Arc>, + /// Currently active wallet state + pub active_wallet: Arc>>, + /// Currently active wallet ID + pub active_wallet_id: Arc>>, +} + +impl WalletManager { + /// Create a new wallet manager + pub fn new() -> Self { + Self { + data_dir: Arc::new(RwLock::new(None)), + index: Arc::new(RwLock::new(WalletsIndex::default())), + active_wallet: Arc::new(RwLock::new(None)), + active_wallet_id: Arc::new(RwLock::new(None)), + } + } + + /// Set the base 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 index + drop(data_dir); + self.load_index().await?; + + Ok(()) + } + + /// Get the wallets index file path + async fn index_path(&self) -> Result { + let data_dir = self.data_dir.read().await; + data_dir + .as_ref() + .map(|p| p.join(WALLETS_INDEX_FILE)) + .ok_or_else(|| Error::Internal("Data directory not set".to_string())) + } + + /// Get a wallet's data directory + async fn wallet_dir(&self, wallet_id: &str) -> Result { + let data_dir = self.data_dir.read().await; + data_dir + .as_ref() + .map(|p| p.join("wallets").join(wallet_id)) + .ok_or_else(|| Error::Internal("Data directory not set".to_string())) + } + + /// Load the wallets index from disk + pub async fn load_index(&self) -> Result<()> { + let path = self.index_path().await?; + + if !path.exists() { + return Ok(()); // No index yet, will be created on first wallet + } + + let json = tokio::fs::read_to_string(&path).await + .map_err(|e| Error::Io(e))?; + + let loaded_index: WalletsIndex = serde_json::from_str(&json) + .map_err(|e| Error::Serialization(e.to_string()))?; + + let mut index = self.index.write().await; + *index = loaded_index; + + Ok(()) + } + + /// Save the wallets index to disk + pub async fn save_index(&self) -> Result<()> { + let path = self.index_path().await?; + let index = self.index.read().await; + + let json = serde_json::to_string_pretty(&*index) + .map_err(|e| Error::Serialization(e.to_string()))?; + + tokio::fs::write(&path, json).await + .map_err(|e| Error::Io(e))?; + + Ok(()) + } + + /// List all wallets + pub async fn list_wallets(&self) -> Vec { + let index = self.index.read().await; + let active_id = self.active_wallet_id.read().await; + + let mut wallets: Vec = index.wallets.values().cloned().collect(); + + // Mark active wallet + if let Some(active) = active_id.as_ref() { + for wallet in &mut wallets { + wallet.is_active = &wallet.id == active; + } + } + + // Sort by last accessed (most recent first) + wallets.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed)); + + wallets + } + + /// Get active wallet ID + pub async fn get_active_wallet_id(&self) -> Option { + self.active_wallet_id.read().await.clone() + } + + /// Create a new wallet + pub async fn create_wallet( + &self, + label: String, + password: &str, + testnet: bool, + ) -> Result<(String, String, String)> { + // Generate unique wallet ID + let wallet_id = Uuid::new_v4().to_string(); + + // Create wallet directory + let wallet_dir = self.wallet_dir(&wallet_id).await?; + tokio::fs::create_dir_all(&wallet_dir).await + .map_err(|e| Error::Io(e))?; + + // Create wallet state for this wallet + let wallet_state = WalletState::new(); + wallet_state.set_data_dir(wallet_dir).await?; + + // Create the wallet (generates mnemonic) + let (mnemonic, address) = wallet_state.create(password, testnet).await?; + + // Add to index + let summary = WalletSummary { + id: wallet_id.clone(), + label, + primary_address: address.clone(), + network: if testnet { "testnet".to_string() } else { "mainnet".to_string() }, + created_at: current_timestamp(), + last_accessed: current_timestamp(), + is_active: true, + }; + + { + let mut index = self.index.write().await; + index.wallets.insert(wallet_id.clone(), summary); + index.active_wallet_id = Some(wallet_id.clone()); + } + + // Save index + self.save_index().await?; + + // Set as active wallet + { + let mut active = self.active_wallet.write().await; + *active = Some(wallet_state); + } + { + let mut active_id = self.active_wallet_id.write().await; + *active_id = Some(wallet_id.clone()); + } + + Ok((wallet_id, mnemonic, address)) + } + + /// Import a wallet from mnemonic + pub async fn import_wallet( + &self, + label: String, + mnemonic: &str, + password: &str, + testnet: bool, + ) -> Result<(String, String)> { + // Generate unique wallet ID + let wallet_id = Uuid::new_v4().to_string(); + + // Create wallet directory + let wallet_dir = self.wallet_dir(&wallet_id).await?; + tokio::fs::create_dir_all(&wallet_dir).await + .map_err(|e| Error::Io(e))?; + + // Create wallet state for this wallet + let wallet_state = WalletState::new(); + wallet_state.set_data_dir(wallet_dir).await?; + + // Import the wallet + let address = wallet_state.import(mnemonic, password, testnet).await?; + + // Add to index + let summary = WalletSummary { + id: wallet_id.clone(), + label, + primary_address: address.clone(), + network: if testnet { "testnet".to_string() } else { "mainnet".to_string() }, + created_at: current_timestamp(), + last_accessed: current_timestamp(), + is_active: true, + }; + + { + let mut index = self.index.write().await; + index.wallets.insert(wallet_id.clone(), summary); + index.active_wallet_id = Some(wallet_id.clone()); + } + + // Save index + self.save_index().await?; + + // Set as active wallet + { + let mut active = self.active_wallet.write().await; + *active = Some(wallet_state); + } + { + let mut active_id = self.active_wallet_id.write().await; + *active_id = Some(wallet_id.clone()); + } + + Ok((wallet_id, address)) + } + + /// Switch to a different wallet + pub async fn switch_wallet(&self, wallet_id: &str) -> Result<()> { + // Check wallet exists + { + let index = self.index.read().await; + if !index.wallets.contains_key(wallet_id) { + return Err(Error::WalletNotFound); + } + } + + // Lock current wallet if any + { + let mut active = self.active_wallet.write().await; + if let Some(wallet) = active.as_ref() { + wallet.lock().await; + } + *active = None; + } + + // Load the new wallet's data + let wallet_dir = self.wallet_dir(wallet_id).await?; + let wallet_state = WalletState::new(); + wallet_state.set_data_dir(wallet_dir).await?; + wallet_state.load_metadata().await?; + + // Update active wallet + { + let mut active = self.active_wallet.write().await; + *active = Some(wallet_state); + } + { + let mut active_id = self.active_wallet_id.write().await; + *active_id = Some(wallet_id.to_string()); + } + + // Update index with new active and last accessed + { + let mut index = self.index.write().await; + index.active_wallet_id = Some(wallet_id.to_string()); + if let Some(summary) = index.wallets.get_mut(wallet_id) { + summary.last_accessed = current_timestamp(); + } + } + self.save_index().await?; + + Ok(()) + } + + /// Rename a wallet + pub async fn rename_wallet(&self, wallet_id: &str, new_label: String) -> Result<()> { + let mut index = self.index.write().await; + + let summary = index.wallets.get_mut(wallet_id) + .ok_or(Error::WalletNotFound)?; + + summary.label = new_label; + drop(index); + + self.save_index().await?; + Ok(()) + } + + /// Delete a wallet + pub async fn delete_wallet(&self, wallet_id: &str) -> Result<()> { + // Don't allow deleting the active wallet while it's active + { + let active_id = self.active_wallet_id.read().await; + if active_id.as_ref() == Some(&wallet_id.to_string()) { + return Err(Error::Internal( + "Cannot delete the currently active wallet. Switch to another wallet first.".to_string() + )); + } + } + + // Remove from index + { + let mut index = self.index.write().await; + index.wallets.remove(wallet_id); + } + self.save_index().await?; + + // Delete wallet directory + let wallet_dir = self.wallet_dir(wallet_id).await?; + if wallet_dir.exists() { + tokio::fs::remove_dir_all(&wallet_dir).await + .map_err(|e| Error::Io(e))?; + } + + Ok(()) + } + + /// Get active wallet state (returns a clone reference for thread safety) + pub async fn get_active_wallet(&self) -> Result>>> { + Ok(self.active_wallet.clone()) + } + + /// Check if active wallet is unlocked + pub async fn is_active_unlocked(&self) -> bool { + let active = self.active_wallet.read().await; + if let Some(wallet) = active.as_ref() { + wallet.is_unlocked().await + } else { + false + } + } + + /// Unlock the active wallet + pub async fn unlock_active(&self, password: &str) -> Result<()> { + let active = self.active_wallet.read().await; + let wallet = active.as_ref().ok_or(Error::WalletNotFound)?; + wallet.unlock(password).await + } + + /// Lock the active wallet + pub async fn lock_active(&self) -> Result<()> { + let active = self.active_wallet.read().await; + if let Some(wallet) = active.as_ref() { + wallet.lock().await; + } + Ok(()) + } + + /// Initialize from existing single wallet (migration) + /// This migrates a legacy single-wallet setup to multi-wallet + pub async fn migrate_legacy_wallet(&self) -> Result> { + let data_dir = self.data_dir.read().await; + let base_dir = data_dir.as_ref() + .ok_or_else(|| Error::Internal("Data directory not set".to_string()))?; + + // Check for legacy wallet.json in base directory + let legacy_path = base_dir.join("wallet.json"); + if !legacy_path.exists() { + return Ok(None); + } + + // Read legacy wallet + let json = tokio::fs::read_to_string(&legacy_path).await + .map_err(|e| Error::Io(e))?; + + let legacy_meta: WalletMetadata = serde_json::from_str(&json) + .map_err(|e| Error::Serialization(e.to_string()))?; + + // Generate ID for migrated wallet + let wallet_id = Uuid::new_v4().to_string(); + + // Create new wallet directory + let wallet_dir = base_dir.join("wallets").join(&wallet_id); + tokio::fs::create_dir_all(&wallet_dir).await + .map_err(|e| Error::Io(e))?; + + // Move wallet.json to new location + let new_wallet_path = wallet_dir.join("wallet.json"); + tokio::fs::copy(&legacy_path, &new_wallet_path).await + .map_err(|e| Error::Io(e))?; + + // Create summary + let primary_address = legacy_meta.addresses.first() + .map(|a| a.address.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + let summary = WalletSummary { + id: wallet_id.clone(), + label: "Main Wallet".to_string(), + primary_address, + network: legacy_meta.network, + created_at: legacy_meta.created_at, + last_accessed: current_timestamp(), + is_active: true, + }; + + // Update index + { + let mut index = self.index.write().await; + index.wallets.insert(wallet_id.clone(), summary); + index.active_wallet_id = Some(wallet_id.clone()); + } + self.save_index().await?; + + // Rename legacy file to indicate migration + let backup_path = base_dir.join("wallet.json.migrated"); + tokio::fs::rename(&legacy_path, &backup_path).await + .map_err(|e| Error::Io(e))?; + + // Load the migrated wallet as active + self.switch_wallet(&wallet_id).await?; + + Ok(Some(wallet_id)) + } + + /// Get wallet count + pub async fn wallet_count(&self) -> usize { + let index = self.index.read().await; + index.wallets.len() + } + + /// Check if any wallets exist + pub async fn has_wallets(&self) -> bool { + let index = self.index.read().await; + !index.wallets.is_empty() + } +} + +impl Default for WalletManager { + 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) +} diff --git a/apps/desktop-wallet/src-tauri/src/watch_only.rs b/apps/desktop-wallet/src-tauri/src/watch_only.rs new file mode 100644 index 0000000..836b410 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/watch_only.rs @@ -0,0 +1,283 @@ +//! 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) +} diff --git a/apps/desktop-wallet/src/App.tsx b/apps/desktop-wallet/src/App.tsx index 9cc5dab..94b103e 100644 --- a/apps/desktop-wallet/src/App.tsx +++ b/apps/desktop-wallet/src/App.tsx @@ -59,6 +59,21 @@ import MultisigDashboard from './pages/Multisig/MultisigDashboard'; import HardwareWalletPage from './pages/Hardware/HardwareWalletPage'; import QRScannerPage from './pages/QRScanner/QRScannerPage'; import BackupPage from './pages/Backup/BackupPage'; +import WatchOnlyDashboard from './pages/WatchOnly/WatchOnlyDashboard'; +import BatchSendDashboard from './pages/BatchSend/BatchSendDashboard'; +import FeeAnalyticsDashboard from './pages/FeeAnalytics/FeeAnalyticsDashboard'; +import VaultsDashboard from './pages/Vaults/VaultsDashboard'; +import RecoveryDashboard from './pages/Recovery/RecoveryDashboard'; +import DecoyDashboard from './pages/Decoy/DecoyDashboard'; +import MixerDashboard from './pages/Mixer/MixerDashboard'; +import LimitOrdersDashboard from './pages/LimitOrders/LimitOrdersDashboard'; +import YieldDashboard from './pages/Yield/YieldDashboard'; +import PortfolioDashboard from './pages/Portfolio/PortfolioDashboard'; +import AlertsDashboard from './pages/Alerts/AlertsDashboard'; +import CliDashboard from './pages/CLI/CliDashboard'; +import RpcProfilesDashboard from './pages/RpcProfiles/RpcProfilesDashboard'; +import TxBuilderDashboard from './pages/TxBuilder/TxBuilderDashboard'; +import PluginsDashboard from './pages/Plugins/PluginsDashboard'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isUnlocked } = useWalletStore(); @@ -336,6 +351,126 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> diff --git a/apps/desktop-wallet/src/components/CreateWalletModal.tsx b/apps/desktop-wallet/src/components/CreateWalletModal.tsx new file mode 100644 index 0000000..322a7b2 --- /dev/null +++ b/apps/desktop-wallet/src/components/CreateWalletModal.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { X, Eye, EyeOff, Copy, Check, AlertTriangle } from 'lucide-react'; +import { useWalletManagerStore } from '../store/walletManager'; + +interface CreateWalletModalProps { + onClose: () => void; +} + +export function CreateWalletModal({ onClose }: CreateWalletModalProps) { + const [step, setStep] = useState<'form' | 'mnemonic' | 'verify'>('form'); + const [label, setLabel] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isTestnet, setIsTestnet] = useState(true); + const [mnemonic, setMnemonic] = useState(''); + const [address, setAddress] = useState(''); + const [mnemonicConfirmed, setMnemonicConfirmed] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(''); + + const { createWallet, isLoading } = useWalletManagerStore(); + + const handleCreate = async () => { + // Validation + if (!label.trim()) { + setError('Please enter a wallet label'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setError(''); + + try { + const result = await createWallet(label.trim(), password, isTestnet); + setMnemonic(result.mnemonic); + setAddress(result.address); + setStep('mnemonic'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create wallet'); + } + }; + + const handleCopyMnemonic = async () => { + try { + await navigator.clipboard.writeText(mnemonic); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for clipboard API failure + } + }; + + const handleFinish = () => { + if (!mnemonicConfirmed) { + setError('Please confirm you have saved your recovery phrase'); + return; + } + onClose(); + }; + + return ( +
+
+ {/* Header */} +
+

+ {step === 'form' && 'Create New Wallet'} + {step === 'mnemonic' && 'Backup Recovery Phrase'} + {step === 'verify' && 'Verify Backup'} +

+ +
+ + {/* Content */} +
+ {step === 'form' && ( +
+ {/* Wallet Label */} +
+ + setLabel(e.target.value)} + placeholder="e.g., Main Wallet, Trading, Savings" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500" + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder="Min. 8 characters" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 pr-10" + /> + +
+
+ + {/* Confirm Password */} +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your password" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500" + /> +
+ + {/* Network Selection */} +
+ +
+ + +
+
+ + {/* Error */} + {error && ( +
+ + {error} +
+ )} +
+ )} + + {step === 'mnemonic' && ( +
+
+
+ +
+

+ Write down this recovery phrase +

+

+ This is the ONLY way to recover your wallet. Store it securely + and never share it with anyone. +

+
+
+
+ + {/* Mnemonic Display */} +
+
+ {mnemonic.split(' ').map((word, index) => ( +
+ {index + 1}. + {word} +
+ ))} +
+
+ + {/* Copy Button */} + + + {/* Address */} +
+

Your wallet address:

+

+ {address} +

+
+ + {/* Confirmation Checkbox */} + + + {/* Error */} + {error && ( +
+ + {error} +
+ )} +
+ )} +
+ + {/* Footer */} +
+ + {step === 'form' && ( + + )} + {step === 'mnemonic' && ( + + )} +
+
+
+ ); +} diff --git a/apps/desktop-wallet/src/components/ImportWalletModal.tsx b/apps/desktop-wallet/src/components/ImportWalletModal.tsx new file mode 100644 index 0000000..55339b6 --- /dev/null +++ b/apps/desktop-wallet/src/components/ImportWalletModal.tsx @@ -0,0 +1,263 @@ +import { useState } from 'react'; +import { X, Eye, EyeOff, AlertTriangle, CheckCircle } from 'lucide-react'; +import { useWalletManagerStore } from '../store/walletManager'; + +interface ImportWalletModalProps { + onClose: () => void; +} + +export function ImportWalletModal({ onClose }: ImportWalletModalProps) { + const [step, setStep] = useState<'form' | 'success'>('form'); + const [label, setLabel] = useState(''); + const [mnemonic, setMnemonic] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isTestnet, setIsTestnet] = useState(true); + const [importedAddress, setImportedAddress] = useState(''); + const [error, setError] = useState(''); + + const { importWallet, isLoading } = useWalletManagerStore(); + + // Validate mnemonic word count + const mnemonicWords = mnemonic.trim().split(/\s+/).filter(Boolean); + const isValidWordCount = mnemonicWords.length === 12 || mnemonicWords.length === 24; + + const handleImport = async () => { + // Validation + if (!label.trim()) { + setError('Please enter a wallet label'); + return; + } + if (!isValidWordCount) { + setError('Recovery phrase must be 12 or 24 words'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setError(''); + + try { + const address = await importWallet( + label.trim(), + mnemonic.trim().toLowerCase(), + password, + isTestnet + ); + setImportedAddress(address); + setStep('success'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import wallet'); + } + }; + + return ( +
+
+ {/* Header */} +
+

+ {step === 'form' ? 'Import Wallet' : 'Wallet Imported'} +

+ +
+ + {/* Content */} +
+ {step === 'form' && ( +
+ {/* Wallet Label */} +
+ + setLabel(e.target.value)} + placeholder="e.g., Imported Wallet, Cold Storage" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500" + /> +
+ + {/* Recovery Phrase */} +
+ +