expansion of desktop wallet features.
This commit is contained in:
parent
81347ab15d
commit
c32622f34f
46 changed files with 11879 additions and 4 deletions
26
apps/desktop-wallet/Dockerfile.dev
Normal file
26
apps/desktop-wallet/Dockerfile.dev
Normal file
|
|
@ -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"]
|
||||||
34
apps/desktop-wallet/docker-compose.dev.yml
Normal file
34
apps/desktop-wallet/docker-compose.dev.yml
Normal file
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -68,6 +68,9 @@ pub enum Error {
|
||||||
#[error("Contract error: {0}")]
|
#[error("Contract error: {0}")]
|
||||||
ContractError(String),
|
ContractError(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ mod keychain;
|
||||||
mod node;
|
mod node;
|
||||||
mod rpc_client;
|
mod rpc_client;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
mod wallet_manager;
|
||||||
|
mod watch_only;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem},
|
menu::{Menu, MenuItem},
|
||||||
|
|
@ -114,10 +116,18 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Initialize wallet state
|
// Initialize wallet state (legacy, for backwards compatibility)
|
||||||
let wallet_state = wallet::WalletState::new();
|
let wallet_state = wallet::WalletState::new();
|
||||||
app.manage(wallet_state);
|
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
|
// Initialize node manager with app handle for events
|
||||||
let node_manager = std::sync::Arc::new(
|
let node_manager = std::sync::Arc::new(
|
||||||
node::NodeManager::with_app_handle(app.handle().clone())
|
node::NodeManager::with_app_handle(app.handle().clone())
|
||||||
|
|
@ -171,13 +181,32 @@ pub fn run() {
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// Wallet management
|
// Wallet management (legacy single-wallet)
|
||||||
commands::create_wallet,
|
commands::create_wallet,
|
||||||
commands::import_wallet,
|
commands::import_wallet,
|
||||||
commands::unlock_wallet,
|
commands::unlock_wallet,
|
||||||
commands::lock_wallet,
|
commands::lock_wallet,
|
||||||
commands::get_wallet_info,
|
commands::get_wallet_info,
|
||||||
commands::export_mnemonic,
|
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
|
// Addresses & UTXOs
|
||||||
commands::get_addresses,
|
commands::get_addresses,
|
||||||
commands::generate_address,
|
commands::generate_address,
|
||||||
|
|
@ -188,6 +217,44 @@ pub fn run() {
|
||||||
commands::sign_transaction,
|
commands::sign_transaction,
|
||||||
commands::broadcast_transaction,
|
commands::broadcast_transaction,
|
||||||
commands::get_transaction_history,
|
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)
|
// Network (legacy)
|
||||||
commands::connect_node,
|
commands::connect_node,
|
||||||
commands::disconnect_node,
|
commands::disconnect_node,
|
||||||
|
|
@ -338,6 +405,58 @@ pub fn run() {
|
||||||
commands::zk_deposit,
|
commands::zk_deposit,
|
||||||
commands::zk_withdraw,
|
commands::zk_withdraw,
|
||||||
commands::zk_transfer,
|
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
|
// Updates
|
||||||
check_update,
|
check_update,
|
||||||
install_update,
|
install_update,
|
||||||
|
|
|
||||||
491
apps/desktop-wallet/src-tauri/src/wallet_manager.rs
Normal file
491
apps/desktop-wallet/src-tauri/src/wallet_manager.rs
Normal file
|
|
@ -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<String, WalletSummary>,
|
||||||
|
/// Currently active wallet ID
|
||||||
|
pub active_wallet_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages multiple wallets
|
||||||
|
pub struct WalletManager {
|
||||||
|
/// Base data directory (contains wallet subdirectories)
|
||||||
|
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
/// Index of all wallets
|
||||||
|
pub index: Arc<RwLock<WalletsIndex>>,
|
||||||
|
/// Currently active wallet state
|
||||||
|
pub active_wallet: Arc<RwLock<Option<WalletState>>>,
|
||||||
|
/// Currently active wallet ID
|
||||||
|
pub active_wallet_id: Arc<RwLock<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<WalletSummary> {
|
||||||
|
let index = self.index.read().await;
|
||||||
|
let active_id = self.active_wallet_id.read().await;
|
||||||
|
|
||||||
|
let mut wallets: Vec<WalletSummary> = 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<String> {
|
||||||
|
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<Arc<RwLock<Option<WalletState>>>> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
283
apps/desktop-wallet/src-tauri/src/watch_only.rs
Normal file
283
apps/desktop-wallet/src-tauri/src/watch_only.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
/// Tags for categorization
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Last known balance (cached)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached_balance: Option<u64>,
|
||||||
|
/// When balance was last updated
|
||||||
|
#[serde(default)]
|
||||||
|
pub balance_updated_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted watch-only addresses
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct WatchOnlyData {
|
||||||
|
/// Map of address -> entry
|
||||||
|
pub addresses: HashMap<String, WatchOnlyAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch-only address manager
|
||||||
|
pub struct WatchOnlyManager {
|
||||||
|
/// Data directory
|
||||||
|
pub data_dir: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
/// Watch-only addresses
|
||||||
|
pub data: Arc<RwLock<WatchOnlyData>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchOnlyManager {
|
||||||
|
/// Create a new manager
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
data_dir: Arc::new(RwLock::new(None)),
|
||||||
|
data: Arc::new(RwLock::new(WatchOnlyData::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the data directory
|
||||||
|
pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> {
|
||||||
|
tokio::fs::create_dir_all(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let mut data_dir = self.data_dir.write().await;
|
||||||
|
*data_dir = Some(path);
|
||||||
|
|
||||||
|
// Load existing data
|
||||||
|
drop(data_dir);
|
||||||
|
self.load().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the file path
|
||||||
|
async fn file_path(&self) -> Result<PathBuf> {
|
||||||
|
let data_dir = self.data_dir.read().await;
|
||||||
|
data_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.join(WATCH_ONLY_FILE))
|
||||||
|
.ok_or_else(|| Error::Internal("Data directory not set".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load watch-only addresses from disk
|
||||||
|
pub async fn load(&self) -> Result<()> {
|
||||||
|
let path = self.file_path().await?;
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = tokio::fs::read_to_string(&path).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
let loaded: WatchOnlyData = serde_json::from_str(&json)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
*data = loaded;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save watch-only addresses to disk
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let path = self.file_path().await?;
|
||||||
|
let data = self.data.read().await;
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&*data)
|
||||||
|
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&path, json).await
|
||||||
|
.map_err(|e| Error::Io(e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a watch-only address
|
||||||
|
pub async fn add_address(
|
||||||
|
&self,
|
||||||
|
address: String,
|
||||||
|
label: String,
|
||||||
|
network: String,
|
||||||
|
notes: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<WatchOnlyAddress> {
|
||||||
|
// Validate address format (basic check)
|
||||||
|
if !address.starts_with("synor1") && !address.starts_with("tsynor1") {
|
||||||
|
return Err(Error::Validation("Invalid address format".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
{
|
||||||
|
let data = self.data.read().await;
|
||||||
|
if data.addresses.contains_key(&address) {
|
||||||
|
return Err(Error::Validation("Address already exists".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = WatchOnlyAddress {
|
||||||
|
address: address.clone(),
|
||||||
|
label,
|
||||||
|
network,
|
||||||
|
added_at: current_timestamp(),
|
||||||
|
notes,
|
||||||
|
tags,
|
||||||
|
cached_balance: None,
|
||||||
|
balance_updated_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
data.addresses.insert(address.clone(), entry.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a watch-only address
|
||||||
|
pub async fn update_address(
|
||||||
|
&self,
|
||||||
|
address: &str,
|
||||||
|
label: Option<String>,
|
||||||
|
notes: Option<String>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
) -> Result<WatchOnlyAddress> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
let entry = data.addresses.get_mut(address)
|
||||||
|
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||||
|
|
||||||
|
if let Some(l) = label {
|
||||||
|
entry.label = l;
|
||||||
|
}
|
||||||
|
if let Some(n) = notes {
|
||||||
|
entry.notes = Some(n);
|
||||||
|
}
|
||||||
|
if let Some(t) = tags {
|
||||||
|
entry.tags = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = entry.clone();
|
||||||
|
drop(data);
|
||||||
|
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a watch-only address
|
||||||
|
pub async fn remove_address(&self, address: &str) -> Result<()> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
if data.addresses.remove(address).is_none() {
|
||||||
|
return Err(Error::NotFound("Watch-only address not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(data);
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all watch-only addresses
|
||||||
|
pub async fn list_addresses(&self) -> Vec<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
let mut addresses: Vec<WatchOnlyAddress> = data.addresses.values().cloned().collect();
|
||||||
|
addresses.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific watch-only address
|
||||||
|
pub async fn get_address(&self, address: &str) -> Option<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.get(address).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cached balance for an address
|
||||||
|
pub async fn update_balance(&self, address: &str, balance: u64) -> Result<()> {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
|
||||||
|
let entry = data.addresses.get_mut(address)
|
||||||
|
.ok_or(Error::NotFound("Watch-only address not found".to_string()))?;
|
||||||
|
|
||||||
|
entry.cached_balance = Some(balance);
|
||||||
|
entry.balance_updated_at = Some(current_timestamp());
|
||||||
|
|
||||||
|
drop(data);
|
||||||
|
self.save().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get addresses by tag
|
||||||
|
pub async fn get_addresses_by_tag(&self, tag: &str) -> Vec<WatchOnlyAddress> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.values()
|
||||||
|
.filter(|a| a.tags.contains(&tag.to_string()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all unique tags
|
||||||
|
pub async fn get_all_tags(&self) -> Vec<String> {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
let mut tags: Vec<String> = data.addresses.values()
|
||||||
|
.flat_map(|a| a.tags.clone())
|
||||||
|
.collect();
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total count
|
||||||
|
pub async fn count(&self) -> usize {
|
||||||
|
let data = self.data.read().await;
|
||||||
|
data.addresses.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WatchOnlyManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current timestamp
|
||||||
|
fn current_timestamp() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,21 @@ import MultisigDashboard from './pages/Multisig/MultisigDashboard';
|
||||||
import HardwareWalletPage from './pages/Hardware/HardwareWalletPage';
|
import HardwareWalletPage from './pages/Hardware/HardwareWalletPage';
|
||||||
import QRScannerPage from './pages/QRScanner/QRScannerPage';
|
import QRScannerPage from './pages/QRScanner/QRScannerPage';
|
||||||
import BackupPage from './pages/Backup/BackupPage';
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isUnlocked } = useWalletStore();
|
const { isUnlocked } = useWalletStore();
|
||||||
|
|
@ -336,6 +351,126 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/watch-only"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<WatchOnlyDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/batch-send"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<BatchSendDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fee-analytics"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<FeeAnalyticsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/vaults"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<VaultsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/recovery"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RecoveryDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/decoy"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DecoyDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/mixer"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MixerDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/limit-orders"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LimitOrdersDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/yield"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<YieldDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolio"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfolioDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/alerts"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AlertsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cli"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CliDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/rpc-profiles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<RpcProfilesDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tx-builder"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<TxBuilderDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/plugins"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PluginsDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
295
apps/desktop-wallet/src/components/CreateWalletModal.tsx
Normal file
295
apps/desktop-wallet/src/components/CreateWalletModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{step === 'form' && 'Create New Wallet'}
|
||||||
|
{step === 'mnemonic' && 'Backup Recovery Phrase'}
|
||||||
|
{step === 'verify' && 'Verify Backup'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{step === 'form' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Wallet Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Wallet Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Network
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(true)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(false)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
!isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mainnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'mnemonic' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-500 mt-0.5" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="text-yellow-300 font-medium text-sm">
|
||||||
|
Write down this recovery phrase
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-300/70 text-xs mt-1">
|
||||||
|
This is the ONLY way to recover your wallet. Store it securely
|
||||||
|
and never share it with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mnemonic Display */}
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{mnemonic.split(' ').map((word, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 bg-gray-900/50 rounded px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-gray-600 text-xs w-4">{index + 1}.</span>
|
||||||
|
<span className="text-white text-sm font-mono">{word}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMnemonic}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
<span className="text-green-400">Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={16} />
|
||||||
|
Copy to clipboard
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="text-center text-sm">
|
||||||
|
<p className="text-gray-500">Your wallet address:</p>
|
||||||
|
<p className="text-synor-400 font-mono text-xs mt-1 break-all">
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Checkbox */}
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mnemonicConfirmed}
|
||||||
|
onChange={(e) => setMnemonicConfirmed(e.target.checked)}
|
||||||
|
className="mt-1 w-4 h-4 rounded border-gray-600 bg-gray-800 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
I have securely saved my recovery phrase and understand that
|
||||||
|
losing it means losing access to my funds forever.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{step === 'form' && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Creating...' : 'Create Wallet'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 'mnemonic' && (
|
||||||
|
<button
|
||||||
|
onClick={handleFinish}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
apps/desktop-wallet/src/components/ImportWalletModal.tsx
Normal file
263
apps/desktop-wallet/src/components/ImportWalletModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">
|
||||||
|
{step === 'form' ? 'Import Wallet' : 'Wallet Imported'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{step === 'form' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Wallet Label */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Wallet Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recovery Phrase */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Recovery Phrase
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={mnemonic}
|
||||||
|
onChange={(e) => setMnemonic(e.target.value)}
|
||||||
|
placeholder="Enter your 12 or 24 word recovery phrase..."
|
||||||
|
rows={4}
|
||||||
|
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 resize-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5">
|
||||||
|
Words: {mnemonicWords.length}
|
||||||
|
{mnemonicWords.length > 0 && !isValidWordCount && (
|
||||||
|
<span className="text-yellow-500 ml-2">
|
||||||
|
(needs 12 or 24 words)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isValidWordCount && (
|
||||||
|
<span className="text-green-500 ml-2">
|
||||||
|
<CheckCircle size={12} className="inline" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Create a password for this wallet"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Network
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(true)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsTestnet(false)}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg border transition-colors ${
|
||||||
|
!isTestnet
|
||||||
|
? 'bg-synor-600/20 border-synor-500 text-synor-300'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mainnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Warning */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="text-yellow-500 mt-0.5" size={14} />
|
||||||
|
<p className="text-yellow-300/80 text-xs">
|
||||||
|
Make sure you're entering your recovery phrase on a secure device.
|
||||||
|
Never share your phrase with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm bg-red-500/10 px-3 py-2 rounded-lg">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle size={32} className="text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Wallet Imported!
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Your wallet has been successfully imported and is ready to use.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Wallet Address</p>
|
||||||
|
<p className="text-synor-400 font-mono text-sm break-all">
|
||||||
|
{importedAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
{step === 'form' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isLoading || !isValidWordCount}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Importing...' : 'Import Wallet'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'success' && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,15 +30,35 @@ import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Vote,
|
Vote,
|
||||||
Layers,
|
Layers,
|
||||||
|
Eye,
|
||||||
|
ListPlus,
|
||||||
|
Activity,
|
||||||
|
Vault,
|
||||||
|
ShieldCheck,
|
||||||
|
// Phase 7-16 icons
|
||||||
|
UserX,
|
||||||
|
Shuffle,
|
||||||
|
ArrowUpDown,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
Bell,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
import { useNodeStore } from '../store/node';
|
import { useNodeStore } from '../store/node';
|
||||||
import { useMiningStore, formatHashrate } from '../store/mining';
|
import { useMiningStore, formatHashrate } from '../store/mining';
|
||||||
import { NotificationsBell } from './NotificationsPanel';
|
import { NotificationsBell } from './NotificationsPanel';
|
||||||
|
import { WalletSelector } from './WalletSelector';
|
||||||
|
import { CreateWalletModal } from './CreateWalletModal';
|
||||||
|
import { ImportWalletModal } from './ImportWalletModal';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ to: '/send', label: 'Send', icon: Send },
|
{ to: '/send', label: 'Send', icon: Send },
|
||||||
|
{ to: '/batch-send', label: 'Batch Send', icon: ListPlus },
|
||||||
{ to: '/receive', label: 'Receive', icon: Download },
|
{ to: '/receive', label: 'Receive', icon: Download },
|
||||||
{ to: '/history', label: 'History', icon: History },
|
{ to: '/history', label: 'History', icon: History },
|
||||||
];
|
];
|
||||||
|
|
@ -75,6 +95,20 @@ const governanceNavItems = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const toolsNavItems = [
|
const toolsNavItems = [
|
||||||
|
{ to: '/watch-only', label: 'Watch-Only', icon: Eye },
|
||||||
|
{ to: '/fee-analytics', label: 'Fee Analytics', icon: Activity },
|
||||||
|
{ to: '/vaults', label: 'Time Vaults', icon: Vault },
|
||||||
|
{ to: '/recovery', label: 'Recovery', icon: ShieldCheck },
|
||||||
|
{ to: '/decoy', label: 'Decoy Wallets', icon: UserX },
|
||||||
|
{ to: '/mixer', label: 'Mixer', icon: Shuffle },
|
||||||
|
{ to: '/limit-orders', label: 'Limit Orders', icon: ArrowUpDown },
|
||||||
|
{ to: '/yield', label: 'Yield', icon: TrendingUp },
|
||||||
|
{ to: '/portfolio', label: 'Portfolio', icon: PieChart },
|
||||||
|
{ to: '/alerts', label: 'Alerts', icon: Bell },
|
||||||
|
{ to: '/cli', label: 'CLI', icon: Terminal },
|
||||||
|
{ to: '/rpc-profiles', label: 'RPC Profiles', icon: Server },
|
||||||
|
{ to: '/tx-builder', label: 'Tx Builder', icon: Wrench },
|
||||||
|
{ to: '/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
{ to: '/dapps', label: 'DApps', icon: Globe },
|
{ to: '/dapps', label: 'DApps', icon: Globe },
|
||||||
{ to: '/addressbook', label: 'Address Book', icon: Users },
|
{ to: '/addressbook', label: 'Address Book', icon: Users },
|
||||||
{ to: '/multisig', label: 'Multi-sig', icon: Shield },
|
{ to: '/multisig', label: 'Multi-sig', icon: Shield },
|
||||||
|
|
@ -89,6 +123,10 @@ export default function Layout() {
|
||||||
const nodeStatus = useNodeStore((state) => state.status);
|
const nodeStatus = useNodeStore((state) => state.status);
|
||||||
const miningStatus = useMiningStore((state) => state.status);
|
const miningStatus = useMiningStore((state) => state.status);
|
||||||
|
|
||||||
|
// Modal state for multi-wallet management
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
const handleLock = async () => {
|
const handleLock = async () => {
|
||||||
await lockWallet();
|
await lockWallet();
|
||||||
};
|
};
|
||||||
|
|
@ -137,6 +175,14 @@ export default function Layout() {
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
|
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
|
{/* Wallet Selector */}
|
||||||
|
<div className="p-3 border-b border-gray-800">
|
||||||
|
<WalletSelector
|
||||||
|
onCreateWallet={() => setShowCreateModal(true)}
|
||||||
|
onImportWallet={() => setShowImportModal(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Balance display */}
|
{/* Balance display */}
|
||||||
<div className="p-4 border-b border-gray-800">
|
<div className="p-4 border-b border-gray-800">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
|
||||||
|
|
@ -209,6 +255,14 @@ export default function Layout() {
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<main className="flex-1 overflow-auto p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Multi-wallet Modals */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateWalletModal onClose={() => setShowCreateModal(false)} />
|
||||||
|
)}
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportWalletModal onClose={() => setShowImportModal(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
291
apps/desktop-wallet/src/components/WalletSelector.tsx
Normal file
291
apps/desktop-wallet/src/components/WalletSelector.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Wallet,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Import,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
MoreVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useWalletManagerStore, WalletSummary } from '../store/walletManager';
|
||||||
|
|
||||||
|
interface WalletSelectorProps {
|
||||||
|
onCreateWallet?: () => void;
|
||||||
|
onImportWallet?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletSelector({ onCreateWallet, onImportWallet }: WalletSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState('');
|
||||||
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
wallets,
|
||||||
|
activeWalletId,
|
||||||
|
isLoading,
|
||||||
|
loadWallets,
|
||||||
|
switchWallet,
|
||||||
|
renameWallet,
|
||||||
|
deleteWallet,
|
||||||
|
} = useWalletManagerStore();
|
||||||
|
|
||||||
|
// Load wallets on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadWallets();
|
||||||
|
}, [loadWallets]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setMenuOpenId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeWallet = wallets.find((w) => w.id === activeWalletId);
|
||||||
|
|
||||||
|
const handleSwitch = async (walletId: string) => {
|
||||||
|
if (walletId === activeWalletId) return;
|
||||||
|
try {
|
||||||
|
await switchWallet(walletId);
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (wallet: WalletSummary, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingId(wallet.id);
|
||||||
|
setEditLabel(wallet.label);
|
||||||
|
setMenuOpenId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (walletId: string) => {
|
||||||
|
if (editLabel.trim()) {
|
||||||
|
try {
|
||||||
|
await renameWallet(walletId, editLabel.trim());
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditLabel('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (walletId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpenId(null);
|
||||||
|
|
||||||
|
if (walletId === activeWalletId) {
|
||||||
|
alert('Cannot delete the active wallet. Switch to another wallet first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm('Are you sure you want to delete this wallet? This action cannot be undone.')) {
|
||||||
|
try {
|
||||||
|
await deleteWallet(walletId);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = (walletId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpenId(menuOpenId === walletId ? null : walletId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate address for display
|
||||||
|
const truncateAddress = (address: string) => {
|
||||||
|
if (address.length <= 16) return address;
|
||||||
|
return `${address.slice(0, 8)}...${address.slice(-6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Selector Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-lg bg-gray-800/50 hover:bg-gray-800 transition-colors border border-gray-700/50"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-synor-600/20 flex items-center justify-center">
|
||||||
|
<Wallet size={16} className="text-synor-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{activeWallet?.label || 'No Wallet'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{activeWallet ? truncateAddress(activeWallet.primaryAddress) : 'Select a wallet'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-gray-900 border border-gray-800 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||||
|
{/* Wallet List */}
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{wallets.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
No wallets yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
wallets.map((wallet) => (
|
||||||
|
<div
|
||||||
|
key={wallet.id}
|
||||||
|
className={`relative ${
|
||||||
|
wallet.id === activeWalletId ? 'bg-synor-600/10' : 'hover:bg-gray-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{editingId === wallet.id ? (
|
||||||
|
// Edit mode
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white focus:outline-none focus:border-synor-500"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveEdit(wallet.id);
|
||||||
|
if (e.key === 'Escape') handleCancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(wallet.id)}
|
||||||
|
className="p-1 text-green-400 hover:bg-green-400/10 rounded"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="p-1 text-red-400 hover:bg-red-400/10 rounded"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Normal mode
|
||||||
|
<button
|
||||||
|
onClick={() => handleSwitch(wallet.id)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 text-left"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'bg-synor-600/30'
|
||||||
|
: 'bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Wallet
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'text-synor-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium truncate ${
|
||||||
|
wallet.id === activeWalletId
|
||||||
|
? 'text-synor-300'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wallet.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{truncateAddress(wallet.primaryAddress)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{wallet.id === activeWalletId && (
|
||||||
|
<span className="text-xs text-synor-400 bg-synor-600/20 px-2 py-0.5 rounded">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={(e) => toggleMenu(wallet.id, e)}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
{menuOpenId === wallet.id && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-lg z-10 min-w-[120px]">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleStartEdit(wallet, e)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(wallet.id, e)}
|
||||||
|
disabled={wallet.id === activeWalletId}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onCreateWallet?.();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Create New Wallet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onImportWallet?.();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Import size={14} />
|
||||||
|
Import Wallet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,3 +30,8 @@ export {
|
||||||
CountUp,
|
CountUp,
|
||||||
TypeWriter,
|
TypeWriter,
|
||||||
} from './Animations';
|
} from './Animations';
|
||||||
|
|
||||||
|
// Multi-Wallet Components
|
||||||
|
export { WalletSelector } from './WalletSelector';
|
||||||
|
export { CreateWalletModal } from './CreateWalletModal';
|
||||||
|
export { ImportWalletModal } from './ImportWalletModal';
|
||||||
|
|
|
||||||
66
apps/desktop-wallet/src/pages/Alerts/AlertsDashboard.tsx
Normal file
66
apps/desktop-wallet/src/pages/Alerts/AlertsDashboard.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Bell, Info, AlertTriangle, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AlertsDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Bell className="text-synor-400" />
|
||||||
|
Price Alerts
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Get notified when tokens hit your targets</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<Plus size={18} />
|
||||||
|
New Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Set price targets and receive desktop notifications when your tokens
|
||||||
|
reach those prices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Active Alerts</h3>
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Bell size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No price alerts set</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Alert Types</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-green-400">Above Price</p>
|
||||||
|
<p className="text-xs text-gray-500">Alert when price rises above target</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-red-400">Below Price</p>
|
||||||
|
<p className="text-xs text-gray-500">Alert when price drops below target</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-yellow-400">% Change</p>
|
||||||
|
<p className="text-xs text-gray-500">Alert on percentage movement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Alerts use OS notifications to notify you even when the wallet is minimized
|
||||||
|
to the system tray.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
572
apps/desktop-wallet/src/pages/BatchSend/BatchSendDashboard.tsx
Normal file
572
apps/desktop-wallet/src/pages/BatchSend/BatchSendDashboard.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
AlertTriangle,
|
||||||
|
Send,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
FileSpreadsheet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useBatchSendStore,
|
||||||
|
useValidRecipientCount,
|
||||||
|
useIsBatchReady,
|
||||||
|
BatchRecipient,
|
||||||
|
} from '../../store/batchSend';
|
||||||
|
|
||||||
|
export default function BatchSendDashboard() {
|
||||||
|
const {
|
||||||
|
recipients,
|
||||||
|
summary,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastTxId,
|
||||||
|
addRecipient,
|
||||||
|
removeRecipient,
|
||||||
|
updateRecipient,
|
||||||
|
clearRecipients,
|
||||||
|
importFromCsv,
|
||||||
|
createBatchTransaction,
|
||||||
|
signAndBroadcast,
|
||||||
|
} = useBatchSendStore();
|
||||||
|
|
||||||
|
const validCount = useValidRecipientCount();
|
||||||
|
const isReady = useIsBatchReady();
|
||||||
|
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||||
|
const [txHex, setTxHex] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateTransaction = async () => {
|
||||||
|
try {
|
||||||
|
const hex = await createBatchTransaction();
|
||||||
|
setTxHex(hex);
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSend = async () => {
|
||||||
|
if (!txHex) return;
|
||||||
|
try {
|
||||||
|
await signAndBroadcast(txHex);
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setShowSuccessModal(true);
|
||||||
|
setTxHex(null);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTxId = async () => {
|
||||||
|
if (!lastTxId) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lastTxId);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTemplate = () => {
|
||||||
|
const template = 'address,amount,label\nsynor1...,10.5,Payment 1\nsynor1...,25.0,Payment 2';
|
||||||
|
const blob = new Blob([template], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'batch_template.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Layers className="text-synor-400" />
|
||||||
|
Batch Send
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Send to multiple addresses in a single transaction
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleExportTemplate}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
Import CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearRecipients}
|
||||||
|
disabled={recipients.length === 1 && !recipients[0].address}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => useBatchSendStore.setState({ error: null })}
|
||||||
|
className="text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recipients List */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 p-4 border-b border-gray-800 text-sm text-gray-500">
|
||||||
|
<div className="col-span-1">#</div>
|
||||||
|
<div className="col-span-5">Recipient Address</div>
|
||||||
|
<div className="col-span-2">Amount (SYN)</div>
|
||||||
|
<div className="col-span-3">Label (optional)</div>
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients */}
|
||||||
|
<div className="divide-y divide-gray-800/50">
|
||||||
|
{recipients.map((recipient, index) => (
|
||||||
|
<RecipientRow
|
||||||
|
key={recipient.id}
|
||||||
|
recipient={recipient}
|
||||||
|
index={index}
|
||||||
|
onUpdate={(updates) => updateRecipient(recipient.id, updates)}
|
||||||
|
onRemove={() => removeRecipient(recipient.id)}
|
||||||
|
canRemove={recipients.length > 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
<div className="p-3 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={addRecipient}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors w-full justify-center"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Recipient
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{summary && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Transaction Summary</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Recipients</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.recipientCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Amount</p>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{summary.totalAmountHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Estimated Fee</p>
|
||||||
|
<p className="text-xl font-bold text-yellow-400">{summary.estimatedFeeHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total (incl. fee)</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.totalWithFeeHuman}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateTransaction}
|
||||||
|
disabled={!isReady || isLoading}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
{isLoading ? 'Creating Transaction...' : `Send to ${validCount} Recipients`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||||
|
<h4 className="text-blue-300 font-medium mb-2">About Batch Transactions</h4>
|
||||||
|
<ul className="text-blue-300/70 text-sm space-y-1">
|
||||||
|
<li>• Batch transactions combine multiple sends into a single transaction</li>
|
||||||
|
<li>• This saves on fees compared to sending individually</li>
|
||||||
|
<li>• All recipients receive their funds when the transaction confirms</li>
|
||||||
|
<li>• You can import recipients from a CSV file</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import Modal */}
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportCsvModal
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImport={(csv) => {
|
||||||
|
importFromCsv(csv);
|
||||||
|
setShowImportModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{showConfirmModal && summary && (
|
||||||
|
<ConfirmModal
|
||||||
|
summary={summary}
|
||||||
|
recipients={recipients.filter((r) => r.isValid)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onConfirm={handleConfirmSend}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setTxHex(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Modal */}
|
||||||
|
{showSuccessModal && lastTxId && (
|
||||||
|
<SuccessModal
|
||||||
|
txId={lastTxId}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSuccessModal(false);
|
||||||
|
clearRecipients();
|
||||||
|
}}
|
||||||
|
onCopy={handleCopyTxId}
|
||||||
|
copied={copied}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient row component
|
||||||
|
function RecipientRow({
|
||||||
|
recipient,
|
||||||
|
index,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
canRemove,
|
||||||
|
}: {
|
||||||
|
recipient: BatchRecipient;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (updates: Partial<BatchRecipient>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
canRemove: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`grid grid-cols-12 gap-4 p-4 ${recipient.error ? 'bg-red-500/5' : ''}`}>
|
||||||
|
<div className="col-span-1 flex items-center">
|
||||||
|
<span className="text-gray-500">{index + 1}</span>
|
||||||
|
{recipient.isValid && <Check size={14} className="text-green-400 ml-2" />}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient.address}
|
||||||
|
onChange={(e) => onUpdate({ address: e.target.value })}
|
||||||
|
placeholder="synor1... or tsynor1..."
|
||||||
|
className={`w-full bg-gray-800 border rounded-lg px-3 py-2 text-white placeholder-gray-500 font-mono text-sm focus:outline-none ${
|
||||||
|
recipient.error && recipient.address
|
||||||
|
? 'border-red-500/50'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={recipient.amount || ''}
|
||||||
|
onChange={(e) => onUpdate({ amount: parseFloat(e.target.value) || 0 })}
|
||||||
|
placeholder="0.00"
|
||||||
|
min="0"
|
||||||
|
step="0.00000001"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={recipient.label || ''}
|
||||||
|
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||||
|
placeholder="Label"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={!canRemove}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recipient.error && (
|
||||||
|
<div className="col-span-12 -mt-2">
|
||||||
|
<p className="text-red-400 text-xs">{recipient.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import CSV modal
|
||||||
|
function ImportCsvModal({
|
||||||
|
onClose,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onImport: (csv: string) => void;
|
||||||
|
}) {
|
||||||
|
const [csvContent, setCsvContent] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const content = event.target?.result as string;
|
||||||
|
setCsvContent(content);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-lg shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<FileSpreadsheet size={20} />
|
||||||
|
Import from CSV
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
Upload a CSV file or paste content below. Format: address,amount,label
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.txt"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="w-full py-8 border-2 border-dashed border-gray-700 rounded-lg text-gray-400 hover:border-synor-500 hover:text-synor-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto mb-2" size={24} />
|
||||||
|
Click to upload CSV file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-gray-500 text-sm">or paste content</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={csvContent}
|
||||||
|
onChange={(e) => setCsvContent(e.target.value)}
|
||||||
|
placeholder="address,amount,label synor1abc...,10.5,Payment 1 synor1def...,25.0,Payment 2"
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onImport(csvContent)}
|
||||||
|
disabled={!csvContent.trim()}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm transaction modal
|
||||||
|
function ConfirmModal({
|
||||||
|
summary,
|
||||||
|
recipients,
|
||||||
|
isLoading,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
summary: { totalAmountHuman: string; estimatedFeeHuman: string; totalWithFeeHuman: string };
|
||||||
|
recipients: BatchRecipient[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Confirm Batch Transaction</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Total Amount</span>
|
||||||
|
<span className="text-white font-medium">{summary.totalAmountHuman}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Network Fee</span>
|
||||||
|
<span className="text-yellow-400">{summary.estimatedFeeHuman}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-700 pt-2 flex justify-between">
|
||||||
|
<span className="text-gray-300 font-medium">Total</span>
|
||||||
|
<span className="text-synor-400 font-bold">{summary.totalWithFeeHuman}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients list */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Sending to {recipients.length} recipients:</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{recipients.slice(0, 5).map((r, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-400 font-mono truncate max-w-[200px]">
|
||||||
|
{r.address.slice(0, 12)}...{r.address.slice(-6)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white">{r.amount} SYN</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{recipients.length > 5 && (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
... and {recipients.length - 5} more
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
|
||||||
|
<p className="text-yellow-300 text-sm">
|
||||||
|
Please verify all recipients and amounts. This transaction cannot be reversed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Confirm & Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success modal
|
||||||
|
function SuccessModal({
|
||||||
|
txId,
|
||||||
|
onClose,
|
||||||
|
onCopy,
|
||||||
|
copied,
|
||||||
|
}: {
|
||||||
|
txId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl text-center">
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Check size={32} className="text-green-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">Transaction Sent!</h2>
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Your batch transaction has been broadcast to the network.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Transaction ID</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<code className="text-synor-400 text-sm font-mono break-all">
|
||||||
|
{txId.slice(0, 16)}...{txId.slice(-16)}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className="text-gray-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/desktop-wallet/src/pages/CLI/CliDashboard.tsx
Normal file
97
apps/desktop-wallet/src/pages/CLI/CliDashboard.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Terminal, Info, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function CliDashboard() {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [history, setHistory] = useState<string[]>([
|
||||||
|
'> Welcome to Synor CLI Mode',
|
||||||
|
'> Type "help" for available commands',
|
||||||
|
'>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCommand = () => {
|
||||||
|
if (!input.trim()) return;
|
||||||
|
|
||||||
|
const cmd = input.trim().toLowerCase();
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'help':
|
||||||
|
output = `Available commands:
|
||||||
|
help - Show this help
|
||||||
|
balance - Show wallet balance
|
||||||
|
address - Show wallet address
|
||||||
|
send - Send transaction (coming soon)
|
||||||
|
status - Network status
|
||||||
|
clear - Clear history`;
|
||||||
|
break;
|
||||||
|
case 'balance':
|
||||||
|
output = 'Balance: 0 SYN (feature coming soon)';
|
||||||
|
break;
|
||||||
|
case 'address':
|
||||||
|
output = 'Primary address: synor1... (feature coming soon)';
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
output = 'Network: Testnet\nConnected: No\nBlock Height: -';
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
setHistory(['> Cleared', '>']);
|
||||||
|
setInput('');
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
output = `Unknown command: ${cmd}. Type "help" for available commands.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory([...history, `> ${input}`, output, '>']);
|
||||||
|
setInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Terminal className="text-synor-400" />
|
||||||
|
CLI Mode
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Terminal interface for power users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
||||||
|
<div className="p-4 font-mono text-sm bg-black/50 h-96 overflow-y-auto">
|
||||||
|
{history.map((line, i) => (
|
||||||
|
<div key={i} className={line.startsWith('>') ? 'text-synor-400' : 'text-gray-300'}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 p-3 flex gap-2">
|
||||||
|
<span className="text-synor-400 font-mono">{'>'}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCommand()}
|
||||||
|
placeholder="Enter command..."
|
||||||
|
className="flex-1 bg-transparent text-white font-mono outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCommand}
|
||||||
|
className="p-2 bg-synor-600 rounded-lg hover:bg-synor-700"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
CLI mode provides a terminal interface for advanced users who prefer keyboard-driven
|
||||||
|
interaction. All wallet operations are available through commands.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
apps/desktop-wallet/src/pages/Decoy/DecoyDashboard.tsx
Normal file
266
apps/desktop-wallet/src/pages/Decoy/DecoyDashboard.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
EyeOff,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
ShieldAlert,
|
||||||
|
Info,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDecoyStore, DecoyWallet } from '../../store/decoy';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
function SetupDecoyModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { setup, isLoading } = useDecoyStore();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setup(password);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Setup failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<ShieldAlert className="text-synor-400" />
|
||||||
|
Setup Decoy Wallets
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Create a "duress password" that opens decoy wallets instead of your real wallet.
|
||||||
|
This provides plausible deniability if forced to unlock your wallet.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Duress Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
|
||||||
|
{isLoading ? 'Setting up...' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateDecoyModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { createDecoy, isLoading } = useDecoyStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [balance, setBalance] = useState('0.1');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await createDecoy(name, parseFloat(balance));
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Create Decoy Wallet</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Wallet Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Savings"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Fake Balance (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
value={balance}
|
||||||
|
onChange={(e) => setBalance(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button type="button" onClick={onClose} className="flex-1 px-4 py-2 bg-gray-700 rounded-lg">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={isLoading} className="flex-1 px-4 py-2 bg-synor-600 rounded-lg">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecoyCard({ decoy }: { decoy: DecoyWallet }) {
|
||||||
|
const { deleteDecoy } = useDecoyStore();
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">{decoy.name}</h4>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{decoy.address.slice(0, 20)}...</p>
|
||||||
|
</div>
|
||||||
|
{!showConfirm ? (
|
||||||
|
<button onClick={() => setShowConfirm(true)} className="text-gray-500 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setShowConfirm(false)} className="px-2 py-1 text-xs bg-gray-700 rounded">
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteDecoy(decoy.id)} className="px-2 py-1 text-xs bg-red-600 rounded">
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{decoy.balanceHuman}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecoyDashboard() {
|
||||||
|
const { isEnabled, decoys, isLoading, error, checkEnabled, fetchDecoys } = useDecoyStore();
|
||||||
|
const [showSetup, setShowSetup] = useState(false);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkEnabled();
|
||||||
|
fetchDecoys();
|
||||||
|
}, [checkEnabled, fetchDecoys]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<EyeOff className="text-synor-400" />
|
||||||
|
Decoy Wallets
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Plausible deniability for your crypto</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={fetchDecoys} className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
{isEnabled && (
|
||||||
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Decoy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEnabled ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<ShieldAlert size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">Decoy Wallets Not Enabled</h3>
|
||||||
|
<p className="text-gray-500 mb-4 max-w-md mx-auto">
|
||||||
|
Set up a duress password that opens fake wallets to protect your real funds
|
||||||
|
under coercion.
|
||||||
|
</p>
|
||||||
|
<button onClick={() => setShowSetup(true)} className="px-6 py-2 bg-synor-600 rounded-lg">
|
||||||
|
Enable Decoy Wallets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<ShieldAlert className="text-green-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Decoy Protection Active</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Using your duress password will show decoy wallets instead of real funds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decoys.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<Wallet size={32} className="mx-auto text-gray-600 mb-2" />
|
||||||
|
<p className="text-gray-500">No decoy wallets created yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{decoys.map((d) => (
|
||||||
|
<DecoyCard key={d.id} decoy={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">How Decoy Wallets Work</p>
|
||||||
|
<p>
|
||||||
|
When unlocking with your duress password, decoy wallets are shown instead of your
|
||||||
|
real wallet. The decoys appear legitimate but contain minimal funds, protecting your
|
||||||
|
actual holdings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSetup && <SetupDecoyModal onClose={() => setShowSetup(false)} />}
|
||||||
|
{showCreate && <CreateDecoyModal onClose={() => setShowCreate(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
RefreshCw,
|
||||||
|
Gauge,
|
||||||
|
DollarSign,
|
||||||
|
BarChart3,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFeeAnalyticsStore,
|
||||||
|
useSelectedRecommendation,
|
||||||
|
getCongestionColor,
|
||||||
|
getCongestionBgColor,
|
||||||
|
formatDuration,
|
||||||
|
formatFeeRate,
|
||||||
|
FeeRecommendation,
|
||||||
|
} from '../../store/feeAnalytics';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee tier selection card
|
||||||
|
*/
|
||||||
|
function FeeTierCard({
|
||||||
|
recommendation,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
recommendation: FeeRecommendation;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const tierIcons = {
|
||||||
|
economy: Clock,
|
||||||
|
standard: CheckCircle,
|
||||||
|
priority: TrendingUp,
|
||||||
|
instant: Zap,
|
||||||
|
};
|
||||||
|
const tierColors = {
|
||||||
|
economy: 'text-blue-400 border-blue-500/30',
|
||||||
|
standard: 'text-green-400 border-green-500/30',
|
||||||
|
priority: 'text-yellow-400 border-yellow-500/30',
|
||||||
|
instant: 'text-red-400 border-red-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = tierIcons[recommendation.tier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`
|
||||||
|
p-4 rounded-xl border-2 transition-all text-left w-full
|
||||||
|
${isSelected
|
||||||
|
? `${tierColors[recommendation.tier]} bg-gray-800`
|
||||||
|
: 'border-gray-700 hover:border-gray-600 bg-gray-900'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon size={18} className={tierColors[recommendation.tier].split(' ')[0]} />
|
||||||
|
<span className="font-semibold capitalize">{recommendation.tier}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-xs bg-synor-600 px-2 py-0.5 rounded">Selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-2xl font-bold text-white mb-1">
|
||||||
|
{recommendation.feeRate.toFixed(2)}
|
||||||
|
<span className="text-sm text-gray-400 ml-1">sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-400 mt-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
~{recommendation.estimatedBlocks} blocks
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{formatDuration(recommendation.estimatedTimeSecs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 mt-2">{recommendation.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool visualization bar
|
||||||
|
*/
|
||||||
|
function MempoolVisualization({
|
||||||
|
txCount,
|
||||||
|
percentile10,
|
||||||
|
percentile50,
|
||||||
|
percentile90,
|
||||||
|
}: {
|
||||||
|
txCount: number;
|
||||||
|
percentile10: number;
|
||||||
|
percentile50: number;
|
||||||
|
percentile90: number;
|
||||||
|
}) {
|
||||||
|
const maxTx = 300; // Visualization max
|
||||||
|
const fillPercent = Math.min((txCount / maxTx) * 100, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gray-400">Mempool Transactions</span>
|
||||||
|
<span className="text-lg font-bold">{txCount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mempool fill bar */}
|
||||||
|
<div className="h-6 bg-gray-800 rounded-lg overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-green-500 via-yellow-500 to-red-500 transition-all duration-500"
|
||||||
|
style={{ width: `${fillPercent}%` }}
|
||||||
|
/>
|
||||||
|
{/* Fee distribution markers */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-between px-2">
|
||||||
|
<span className="text-xs text-white/70">Low</span>
|
||||||
|
<span className="text-xs text-white/70">High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fee distribution */}
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">10th %ile</p>
|
||||||
|
<p className="text-sm font-mono">{percentile10.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">Median</p>
|
||||||
|
<p className="text-sm font-mono">{percentile50.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded p-2">
|
||||||
|
<p className="text-xs text-gray-500">90th %ile</p>
|
||||||
|
<p className="text-sm font-mono">{percentile90.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple fee history chart (text-based)
|
||||||
|
*/
|
||||||
|
function FeeHistoryChart() {
|
||||||
|
const { analytics } = useFeeAnalyticsStore();
|
||||||
|
const history = analytics?.feeHistory || [];
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-gray-500 text-center">No fee history available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find min/max for scaling
|
||||||
|
const maxFee = Math.max(...history.map((h) => h.maxFeeRate));
|
||||||
|
const minFee = Math.min(...history.map((h) => h.minFeeRate));
|
||||||
|
const range = maxFee - minFee || 1;
|
||||||
|
|
||||||
|
// Take last 12 hours for display
|
||||||
|
const recentHistory = history.slice(-12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-gray-400">Fee Rate History (24h)</span>
|
||||||
|
<span className="text-xs text-gray-500">sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple bar chart */}
|
||||||
|
<div className="flex items-end gap-1 h-24">
|
||||||
|
{recentHistory.map((point, i) => {
|
||||||
|
const height = ((point.avgFeeRate - minFee) / range) * 100;
|
||||||
|
const date = new Date(point.timestamp * 1000);
|
||||||
|
const hour = date.getHours();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-synor-500 rounded-t transition-all hover:bg-synor-400"
|
||||||
|
style={{ height: `${Math.max(height, 5)}%` }}
|
||||||
|
title={`${point.avgFeeRate.toFixed(2)} sompi/byte at ${date.toLocaleTimeString()}`}
|
||||||
|
/>
|
||||||
|
{i % 3 === 0 && (
|
||||||
|
<span className="text-xs text-gray-600 mt-1">{hour}:00</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mt-2">
|
||||||
|
<span>{minFee.toFixed(1)}</span>
|
||||||
|
<span>{((maxFee + minFee) / 2).toFixed(1)}</span>
|
||||||
|
<span>{maxFee.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee calculator component
|
||||||
|
*/
|
||||||
|
function FeeCalculator() {
|
||||||
|
const { analytics, selectedTier, calculateFee } = useFeeAnalyticsStore();
|
||||||
|
const [txSize, setTxSize] = useState(250); // Default tx size
|
||||||
|
const [calculatedFee, setCalculatedFee] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const selectedRec = useMemo(() => {
|
||||||
|
return analytics?.recommendations.find((r) => r.tier === selectedTier);
|
||||||
|
}, [analytics, selectedTier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRec) {
|
||||||
|
setCalculatedFee(Math.ceil(txSize * selectedRec.feeRate));
|
||||||
|
}
|
||||||
|
}, [txSize, selectedRec]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<DollarSign size={16} />
|
||||||
|
Fee Calculator
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 block mb-1">
|
||||||
|
Transaction Size (bytes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={txSize}
|
||||||
|
onChange={(e) => setTxSize(parseInt(e.target.value) || 0)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Tier:</span>
|
||||||
|
<span className="capitalize font-medium">{selectedTier}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400">Rate:</span>
|
||||||
|
<span className="font-mono">{selectedRec?.feeRate.toFixed(2) || '0'} sompi/byte</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Estimated Fee:</span>
|
||||||
|
<span className="text-xl font-bold text-synor-400">
|
||||||
|
{calculatedFee?.toLocaleString() || '0'} sompi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
= {((calculatedFee || 0) / 100_000_000).toFixed(8)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Fee Analytics Dashboard
|
||||||
|
*/
|
||||||
|
export default function FeeAnalyticsDashboard() {
|
||||||
|
const {
|
||||||
|
analytics,
|
||||||
|
selectedTier,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
autoRefresh,
|
||||||
|
fetchAnalytics,
|
||||||
|
setSelectedTier,
|
||||||
|
setAutoRefresh,
|
||||||
|
} = useFeeAnalyticsStore();
|
||||||
|
|
||||||
|
// Fetch analytics on mount and set up auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnalytics();
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
if (autoRefresh) {
|
||||||
|
interval = setInterval(fetchAnalytics, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoRefresh, fetchAnalytics]);
|
||||||
|
|
||||||
|
if (isLoading && !analytics) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size={32} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Activity className="text-synor-400" />
|
||||||
|
Fee Market Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Monitor network fees and choose optimal transaction costs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="rounded bg-gray-800 border-gray-600"
|
||||||
|
/>
|
||||||
|
Auto-refresh
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={fetchAnalytics}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analytics && (
|
||||||
|
<>
|
||||||
|
{/* Network Status Bar */}
|
||||||
|
<div className={`rounded-xl p-4 border ${getCongestionBgColor(analytics.networkCongestion)}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Gauge className={getCongestionColor(analytics.networkCongestion)} size={24} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Network Congestion</p>
|
||||||
|
<p className={`text-lg font-bold capitalize ${getCongestionColor(analytics.networkCongestion)}`}>
|
||||||
|
{analytics.networkCongestion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Block Time</p>
|
||||||
|
<p className="text-lg font-mono">{analytics.blockTargetTimeSecs}s</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Avg Fee Rate</p>
|
||||||
|
<p className="text-lg font-mono">{analytics.mempool.avgFeeRate.toFixed(2)} sompi/b</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Mempool Size</p>
|
||||||
|
<p className="text-lg font-mono">
|
||||||
|
{(analytics.mempool.totalSizeBytes / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fee Tier Selection */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
|
<Zap className="text-synor-400" size={20} />
|
||||||
|
Select Fee Tier
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{analytics.recommendations.map((rec) => (
|
||||||
|
<FeeTierCard
|
||||||
|
key={rec.tier}
|
||||||
|
recommendation={rec}
|
||||||
|
isSelected={selectedTier === rec.tier}
|
||||||
|
onSelect={() => setSelectedTier(rec.tier)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Mempool Visualization */}
|
||||||
|
<MempoolVisualization
|
||||||
|
txCount={analytics.mempool.txCount}
|
||||||
|
percentile10={analytics.mempool.percentile10}
|
||||||
|
percentile50={analytics.mempool.percentile50}
|
||||||
|
percentile90={analytics.mempool.percentile90}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fee History Chart */}
|
||||||
|
<FeeHistoryChart />
|
||||||
|
|
||||||
|
{/* Fee Calculator */}
|
||||||
|
<FeeCalculator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">About Fee Selection</p>
|
||||||
|
<p>
|
||||||
|
Fee rates are measured in sompi per byte. Higher fees generally result in faster
|
||||||
|
confirmation times, especially during network congestion. The recommendations above
|
||||||
|
are based on current mempool conditions and adjust automatically as network activity
|
||||||
|
changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ArrowUpDown, Info, AlertTriangle, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LimitOrdersDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<ArrowUpDown className="text-synor-400" />
|
||||||
|
Limit Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Set buy/sell orders at specific prices</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<Plus size={18} />
|
||||||
|
New Order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Limit orders let you automate trades - set your target price and the order
|
||||||
|
executes automatically when the market reaches it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-2 text-green-400">Buy Orders</h3>
|
||||||
|
<p className="text-sm text-gray-500">No active buy orders</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-2 text-red-400">Sell Orders</h3>
|
||||||
|
<p className="text-sm text-gray-500">No active sell orders</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Limit orders are executed on-chain through smart contracts, ensuring trustless
|
||||||
|
and decentralized trading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/desktop-wallet/src/pages/Mixer/MixerDashboard.tsx
Normal file
56
apps/desktop-wallet/src/pages/Mixer/MixerDashboard.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Shuffle, Info, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function MixerDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Shuffle className="text-synor-400" />
|
||||||
|
Transaction Mixer
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Enhanced privacy through coin mixing</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Transaction mixing will allow you to break the link between source and destination
|
||||||
|
addresses, enhancing privacy for your transactions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">How Mixing Works</h3>
|
||||||
|
<ol className="space-y-3 text-sm text-gray-400">
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs">1</span>
|
||||||
|
<span>Deposit funds into the mixing pool</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs">2</span>
|
||||||
|
<span>Your funds are combined with others</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs">3</span>
|
||||||
|
<span>After a delay, withdraw to a fresh address</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="w-6 h-6 bg-synor-600 rounded-full flex items-center justify-center text-white text-xs">4</span>
|
||||||
|
<span>Transaction history is broken</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Mixing uses cryptographic techniques to make transactions untraceable while remaining
|
||||||
|
fully on-chain and trustless.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
apps/desktop-wallet/src/pages/Plugins/PluginsDashboard.tsx
Normal file
112
apps/desktop-wallet/src/pages/Plugins/PluginsDashboard.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Puzzle, Info, AlertTriangle, Download, Trash2, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginsDashboard() {
|
||||||
|
const plugins: Plugin[] = [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Puzzle className="text-synor-400" />
|
||||||
|
Plugin System
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Third-party extensions and integrations</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<Download size={18} />
|
||||||
|
Browse Plugins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
The plugin system will allow third-party developers to extend wallet
|
||||||
|
functionality with custom features and integrations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Installed Plugins</h3>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Puzzle size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No plugins installed</p>
|
||||||
|
<p className="text-sm mt-1">Plugin marketplace coming soon</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<div key={plugin.id} className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{plugin.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{plugin.description}</p>
|
||||||
|
<p className="text-xs text-gray-600">v{plugin.version} by {plugin.author}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="p-2 bg-gray-700 rounded-lg">
|
||||||
|
<Settings size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-gray-700 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Plugin Categories</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">DeFi</p>
|
||||||
|
<p className="text-xs text-gray-500">Yield, lending, swaps</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">NFT</p>
|
||||||
|
<p className="text-xs text-gray-500">Galleries, marketplaces</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Analytics</p>
|
||||||
|
<p className="text-xs text-gray-500">Charts, tracking</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Privacy</p>
|
||||||
|
<p className="text-xs text-gray-500">Mixing, stealth</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Gaming</p>
|
||||||
|
<p className="text-xs text-gray-500">Web3 games</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-800 rounded-lg">
|
||||||
|
<p className="font-medium text-synor-400">Social</p>
|
||||||
|
<p className="text-xs text-gray-500">Messaging, identity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Plugins run in a sandboxed environment with limited permissions. Always verify
|
||||||
|
plugin sources before installation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { PieChart, Info, AlertTriangle, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function PortfolioDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<PieChart className="text-synor-400" />
|
||||||
|
Portfolio Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">P&L tracking and tax reports</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<Download size={18} />
|
||||||
|
Export Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Track your profit & loss, view portfolio allocation, and generate tax reports
|
||||||
|
for your crypto holdings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Value</p>
|
||||||
|
<p className="text-2xl font-bold">$0.00</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">24h Change</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-500">0%</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Unrealized P&L</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-500">$0.00</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Realized P&L</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-500">$0.00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Asset Allocation</h3>
|
||||||
|
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||||
|
Chart coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Performance History</h3>
|
||||||
|
<div className="h-48 flex items-center justify-center text-gray-500">
|
||||||
|
Chart coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Portfolio analytics helps you understand your investment performance and simplifies
|
||||||
|
tax reporting with exportable transaction history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
547
apps/desktop-wallet/src/pages/Recovery/RecoveryDashboard.tsx
Normal file
547
apps/desktop-wallet/src/pages/Recovery/RecoveryDashboard.tsx
Normal file
|
|
@ -0,0 +1,547 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
|
UserMinus,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
Mail,
|
||||||
|
Wallet,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useRecoveryStore,
|
||||||
|
Guardian,
|
||||||
|
RecoveryRequest,
|
||||||
|
getGuardianStatusColor,
|
||||||
|
getRequestStatusColor,
|
||||||
|
} from '../../store/recovery';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Recovery Modal
|
||||||
|
*/
|
||||||
|
function SetupRecoveryModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { setupRecovery, isLoading } = useRecoveryStore();
|
||||||
|
const [threshold, setThreshold] = useState(2);
|
||||||
|
const [delaySecs, setDelaySecs] = useState(86400); // 24 hours
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setupRecovery(threshold, delaySecs);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to setup recovery');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="text-synor-400" />
|
||||||
|
Setup Social Recovery
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Required Approvals (Threshold)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Number of guardians required to approve recovery
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Recovery Delay
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={delaySecs}
|
||||||
|
onChange={(e) => setDelaySecs(parseInt(e.target.value))}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value={3600}>1 hour</option>
|
||||||
|
<option value={86400}>24 hours</option>
|
||||||
|
<option value={259200}>3 days</option>
|
||||||
|
<option value={604800}>1 week</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Delay before recovery completes (gives time to cancel if fraudulent)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner size={18} /> : <ShieldCheck size={18} />}
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Guardian Modal
|
||||||
|
*/
|
||||||
|
function AddGuardianModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { addGuardian, isLoading } = useRecoveryStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Guardian name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addGuardian(name.trim(), email.trim() || undefined, address.trim() || undefined);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add guardian');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<UserPlus className="text-synor-400" />
|
||||||
|
Add Guardian
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Mom, Best Friend"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Email (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="guardian@example.com"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
For sending recovery notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Synor Address (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
If they have a Synor wallet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner size={18} /> : <UserPlus size={18} />}
|
||||||
|
Add Guardian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guardian Card
|
||||||
|
*/
|
||||||
|
function GuardianCard({ guardian }: { guardian: Guardian }) {
|
||||||
|
const { removeGuardian, isLoading } = useRecoveryStore();
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
try {
|
||||||
|
await removeGuardian(guardian.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-white">{guardian.name}</h4>
|
||||||
|
<span className={`text-sm ${getGuardianStatusColor(guardian.status)}`}>
|
||||||
|
{guardian.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!showConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<UserMinus size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="px-2 py-1 text-xs bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-2 py-1 text-xs bg-red-600 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guardian.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-2">
|
||||||
|
<Mail size={14} />
|
||||||
|
{guardian.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{guardian.address && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mt-1">
|
||||||
|
<Wallet size={14} />
|
||||||
|
<span className="font-mono text-xs">{guardian.address.slice(0, 20)}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
Added {new Date(guardian.addedAt * 1000).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery Request Card
|
||||||
|
*/
|
||||||
|
function RecoveryRequestCard({ request }: { request: RecoveryRequest }) {
|
||||||
|
const { cancelRecovery, isLoading } = useRecoveryStore();
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
await cancelRecovery(request.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = (request.approvals.length / request.requiredApprovals) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span className={`text-sm font-medium ${getRequestStatusColor(request.status)}`}>
|
||||||
|
{request.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">ID: {request.id}</p>
|
||||||
|
</div>
|
||||||
|
{request.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-3 py-1 text-sm bg-red-600 hover:bg-red-700 rounded"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approval progress */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-400">Approvals</span>
|
||||||
|
<span>
|
||||||
|
{request.approvals.length} / {request.requiredApprovals}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-synor-500"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-gray-500">
|
||||||
|
<p>Expires: {new Date(request.expiresAt * 1000).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Recovery Dashboard
|
||||||
|
*/
|
||||||
|
export default function RecoveryDashboard() {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
requests,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchConfig,
|
||||||
|
fetchRequests,
|
||||||
|
disableRecovery,
|
||||||
|
} = useRecoveryStore();
|
||||||
|
|
||||||
|
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||||
|
const [showAddGuardian, setShowAddGuardian] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRequests();
|
||||||
|
}, [fetchConfig, fetchRequests]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<ShieldCheck className="text-synor-400" />
|
||||||
|
Social Recovery
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Recover your wallet using trusted guardians
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchConfig();
|
||||||
|
fetchRequests();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not Setup State */}
|
||||||
|
{!config && !isLoading && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<ShieldCheck size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-400 mb-2">Social Recovery Not Configured</h3>
|
||||||
|
<p className="text-gray-500 mb-4 max-w-md mx-auto">
|
||||||
|
Set up social recovery to allow trusted friends or family members to help you
|
||||||
|
recover access to your wallet if you lose your keys.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSetupModal(true)}
|
||||||
|
className="px-6 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
Setup Social Recovery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configured State */}
|
||||||
|
{config && (
|
||||||
|
<>
|
||||||
|
{/* Status Card */}
|
||||||
|
<div className={`rounded-xl p-4 border ${config.enabled ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{config.enabled ? (
|
||||||
|
<CheckCircle className="text-green-400" size={24} />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="text-red-400" size={24} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
{config.enabled ? 'Recovery Enabled' : 'Recovery Disabled'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
{config.threshold} of {config.totalGuardians} guardians required
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-400">Recovery Delay</p>
|
||||||
|
<p className="font-mono">
|
||||||
|
{config.recoveryDelaySecs >= 86400
|
||||||
|
? `${Math.floor(config.recoveryDelaySecs / 86400)} days`
|
||||||
|
: `${Math.floor(config.recoveryDelaySecs / 3600)} hours`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{config.enabled && (
|
||||||
|
<button
|
||||||
|
onClick={disableRecovery}
|
||||||
|
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guardians Section */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Users className="text-synor-400" size={20} />
|
||||||
|
Guardians ({config.guardians.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddGuardian(true)}
|
||||||
|
className="px-3 py-1.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UserPlus size={16} />
|
||||||
|
Add Guardian
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.guardians.length === 0 ? (
|
||||||
|
<div className="text-center py-6 text-gray-500">
|
||||||
|
<Users size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No guardians added yet</p>
|
||||||
|
<p className="text-sm">Add trusted contacts to enable recovery</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{config.guardians.map((guardian) => (
|
||||||
|
<GuardianCard key={guardian.id} guardian={guardian} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.guardians.length > 0 && config.guardians.length < config.threshold && (
|
||||||
|
<div className="mt-4 bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-yellow-200">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
You need at least {config.threshold} guardians for recovery to work
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recovery Requests Section */}
|
||||||
|
{requests.length > 0 && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Clock className="text-synor-400" size={20} />
|
||||||
|
Recovery Requests
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{requests.map((request) => (
|
||||||
|
<RecoveryRequestCard key={request.id} request={request} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">How Social Recovery Works</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>Add trusted friends or family as guardians</li>
|
||||||
|
<li>If you lose access, initiate a recovery request</li>
|
||||||
|
<li>Guardians approve the request (threshold required)</li>
|
||||||
|
<li>After the delay period, recovery completes</li>
|
||||||
|
<li>You can cancel fraudulent requests during the delay</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showSetupModal && <SetupRecoveryModal onClose={() => setShowSetupModal(false)} />}
|
||||||
|
{showAddGuardian && <AddGuardianModal onClose={() => setShowAddGuardian(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Server, Plus, Trash2, CheckCircle, RefreshCw, Info, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RpcProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive: boolean;
|
||||||
|
latency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RpcProfilesDashboard() {
|
||||||
|
const [profiles] = useState<RpcProfile[]>([
|
||||||
|
{ id: '1', name: 'Default Mainnet', url: 'https://rpc.synor.io', isActive: true, latency: 45 },
|
||||||
|
{ id: '2', name: 'Testnet', url: 'https://testnet.synor.io', isActive: false, latency: 52 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Server className="text-synor-400" />
|
||||||
|
Custom RPC Profiles
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Multiple endpoints with automatic failover</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-synor-600 rounded-lg flex items-center gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Add Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className={`bg-gray-900 rounded-xl p-4 border ${
|
||||||
|
profile.isActive ? 'border-synor-500' : 'border-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{profile.isActive ? (
|
||||||
|
<CheckCircle className="text-green-400" size={20} />
|
||||||
|
) : (
|
||||||
|
<Wifi className="text-gray-500" size={20} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{profile.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 font-mono">{profile.url}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{profile.latency && (
|
||||||
|
<span className={`text-sm ${profile.latency < 100 ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||||
|
{profile.latency}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button className="p-2 bg-gray-800 rounded-lg hover:bg-gray-700">
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-3">Failover Settings</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Auto-failover on disconnect</span>
|
||||||
|
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Retry failed requests</span>
|
||||||
|
<input type="checkbox" defaultChecked className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-400">Latency-based routing</span>
|
||||||
|
<input type="checkbox" className="rounded bg-gray-800" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Multiple RPC profiles ensure reliability. If one endpoint fails, the wallet
|
||||||
|
automatically switches to the next available endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/desktop-wallet/src/pages/TxBuilder/TxBuilderDashboard.tsx
Normal file
124
apps/desktop-wallet/src/pages/TxBuilder/TxBuilderDashboard.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Wrench, Info, AlertTriangle, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function TxBuilderDashboard() {
|
||||||
|
const [outputs, setOutputs] = useState([{ address: '', amount: '' }]);
|
||||||
|
|
||||||
|
const addOutput = () => {
|
||||||
|
setOutputs([...outputs, { address: '', amount: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOutput = (index: number) => {
|
||||||
|
if (outputs.length > 1) {
|
||||||
|
setOutputs(outputs.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<Wrench className="text-synor-400" />
|
||||||
|
Transaction Builder
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Advanced custom transaction crafting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Advanced Feature</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
Transaction builder is for advanced users. Incorrect transactions may result in
|
||||||
|
lost funds. Use with caution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Outputs</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{outputs.map((output, index) => (
|
||||||
|
<div key={index} className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Address"
|
||||||
|
value={output.address}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOutputs = [...outputs];
|
||||||
|
newOutputs[index].address = e.target.value;
|
||||||
|
setOutputs(newOutputs);
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Amount (SYN)"
|
||||||
|
value={output.amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOutputs = [...outputs];
|
||||||
|
newOutputs[index].amount = e.target.value;
|
||||||
|
setOutputs(newOutputs);
|
||||||
|
}}
|
||||||
|
className="w-40 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeOutput(index)}
|
||||||
|
className="p-2 bg-gray-800 rounded-lg hover:bg-red-600/50 text-gray-400 hover:text-red-400"
|
||||||
|
disabled={outputs.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={addOutput}
|
||||||
|
className="mt-3 px-4 py-2 bg-gray-800 rounded-lg flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Output
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Advanced Options</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Fee Rate (sompi/byte)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue="1"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Locktime</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
defaultValue="0"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button className="flex-1 px-4 py-3 bg-gray-800 rounded-lg">
|
||||||
|
Preview Transaction
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-4 py-3 bg-synor-600 rounded-lg">
|
||||||
|
Create & Sign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
The transaction builder allows you to craft custom transactions with multiple
|
||||||
|
outputs, specific fee rates, and advanced options like timelocks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
516
apps/desktop-wallet/src/pages/Vaults/VaultsDashboard.tsx
Normal file
516
apps/desktop-wallet/src/pages/Vaults/VaultsDashboard.tsx
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Vault as VaultIcon,
|
||||||
|
Plus,
|
||||||
|
Clock,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Download,
|
||||||
|
Timer,
|
||||||
|
Info,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useVaultsStore,
|
||||||
|
Vault,
|
||||||
|
formatTimeRemaining,
|
||||||
|
getVaultStatusColor,
|
||||||
|
LOCK_DURATION_PRESETS,
|
||||||
|
} from '../../store/vaults';
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Vault Modal
|
||||||
|
*/
|
||||||
|
function CreateVaultModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const { createVault, isLoading } = useVaultsStore();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [duration, setDuration] = useState(86400); // 24 hours default
|
||||||
|
const [customDuration, setCustomDuration] = useState('');
|
||||||
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const amountNum = parseFloat(amount);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Vault name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(amountNum) || amountNum <= 0) {
|
||||||
|
setError('Invalid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockDuration = useCustom
|
||||||
|
? parseInt(customDuration) * 3600 // Custom is in hours
|
||||||
|
: duration;
|
||||||
|
|
||||||
|
if (lockDuration < 60) {
|
||||||
|
setError('Lock duration must be at least 1 minute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createVault({
|
||||||
|
name: name.trim(),
|
||||||
|
amount: Math.floor(amountNum * 100_000_000), // Convert SYN to sompi
|
||||||
|
lockDurationSecs: lockDuration,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create vault');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<VaultIcon className="text-synor-400" size={24} />
|
||||||
|
Create Time-Locked Vault
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Vault Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g., Savings Goal, Emergency Fund"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.00000001"
|
||||||
|
min="0"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="0.00000000"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Lock Duration</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-2">
|
||||||
|
{LOCK_DURATION_PRESETS.slice(0, 4).map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(preset.value);
|
||||||
|
setUseCustom(false);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
!useCustom && duration === preset.value
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{LOCK_DURATION_PRESETS.slice(4).map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDuration(preset.value);
|
||||||
|
setUseCustom(false);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
!useCustom && duration === preset.value
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseCustom(true)}
|
||||||
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||||
|
useCustom
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{useCustom && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={customDuration}
|
||||||
|
onChange={(e) => setCustomDuration(e.target.value)}
|
||||||
|
placeholder="Duration in hours"
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-400">hours</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">Description (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What is this vault for?"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-200">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-start gap-2 text-sm text-yellow-200">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<p>
|
||||||
|
<strong>Warning:</strong> Funds locked in a vault cannot be accessed until the lock
|
||||||
|
period expires. Make sure you don't need these funds during the lock period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner size={18} />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock size={18} />
|
||||||
|
Lock Funds
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual Vault Card
|
||||||
|
*/
|
||||||
|
function VaultCard({ vault }: { vault: Vault }) {
|
||||||
|
const { withdrawVault, deleteVault, isLoading } = useVaultsStore();
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(vault.remainingSecs);
|
||||||
|
const [showConfirm, setShowConfirm] = useState<'withdraw' | 'delete' | null>(null);
|
||||||
|
|
||||||
|
// Update countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (vault.status !== 'locked') return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeRemaining((prev) => Math.max(0, prev - 1));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [vault.status]);
|
||||||
|
|
||||||
|
const handleWithdraw = async () => {
|
||||||
|
try {
|
||||||
|
await withdrawVault(vault.id);
|
||||||
|
setShowConfirm(null);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteVault(vault.id);
|
||||||
|
setShowConfirm(null);
|
||||||
|
} catch (err) {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusIcon = vault.status === 'locked' ? Lock : vault.status === 'unlocked' ? Unlock : CheckCircle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">{vault.name}</h3>
|
||||||
|
{vault.description && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{vault.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${getVaultStatusColor(vault.status)}`}>
|
||||||
|
<StatusIcon size={14} />
|
||||||
|
<span className="capitalize">{vault.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{vault.amountHuman}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vault.status === 'locked' && (
|
||||||
|
<>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-synor-500 transition-all duration-1000"
|
||||||
|
style={{ width: `${vault.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400 flex items-center gap-1">
|
||||||
|
<Timer size={14} />
|
||||||
|
Unlocks in:
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-synor-400">
|
||||||
|
{formatTimeRemaining(timeRemaining)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vault.status === 'unlocked' && !showConfirm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm('withdraw')}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
Withdraw Funds
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vault.status === 'withdrawn' && !showConfirm && (
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Withdrawn {vault.txId && `(${vault.txId.slice(0, 12)}...)`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm('delete')}
|
||||||
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showConfirm && (
|
||||||
|
<div className="mt-3 bg-gray-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-gray-300 mb-3">
|
||||||
|
{showConfirm === 'withdraw'
|
||||||
|
? 'Are you sure you want to withdraw funds from this vault?'
|
||||||
|
: 'Are you sure you want to delete this vault record?'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirm(null)}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={showConfirm === 'withdraw' ? handleWithdraw : handleDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`flex-1 px-3 py-1.5 rounded text-sm ${
|
||||||
|
showConfirm === 'withdraw'
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Processing...' : 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-800 flex justify-between text-xs text-gray-500">
|
||||||
|
<span>Created: {new Date(vault.createdAt * 1000).toLocaleDateString()}</span>
|
||||||
|
<span>Unlock: {new Date(vault.unlockAt * 1000).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Vaults Dashboard
|
||||||
|
*/
|
||||||
|
export default function VaultsDashboard() {
|
||||||
|
const { vaults, summary, isLoading, error, fetchVaults, fetchSummary } = useVaultsStore();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch vaults on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
|
||||||
|
// Refresh every minute
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchVaults, fetchSummary]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<VaultIcon className="text-synor-400" />
|
||||||
|
Time-Locked Vaults
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Lock funds for a period to enforce saving goals or vesting schedules
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchVaults();
|
||||||
|
fetchSummary();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Vault
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<AlertCircle className="text-red-400" />
|
||||||
|
<span className="text-red-200">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Locked</p>
|
||||||
|
<p className="text-xl font-bold text-synor-400">{summary.totalLockedHuman}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Active Vaults</p>
|
||||||
|
<p className="text-xl font-bold text-white">{summary.lockedVaults}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Ready to Withdraw</p>
|
||||||
|
<p className="text-xl font-bold text-green-400">{summary.unlockedVaults}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Next Unlock</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{summary.nextUnlock
|
||||||
|
? new Date(summary.nextUnlock * 1000).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vaults List */}
|
||||||
|
{isLoading && vaults.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<LoadingSpinner size={32} />
|
||||||
|
</div>
|
||||||
|
) : vaults.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
||||||
|
<VaultIcon size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-400 mb-2">No Vaults Yet</h3>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
Create a time-locked vault to start saving with enforced holding periods.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Create Your First Vault
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{vaults.map((vault) => (
|
||||||
|
<VaultCard key={vault.id} vault={vault} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<p className="font-medium text-gray-300 mb-1">About Time-Locked Vaults</p>
|
||||||
|
<p>
|
||||||
|
Vaults use time-locked transactions to enforce a holding period. Once funds are
|
||||||
|
deposited, they cannot be withdrawn until the lock period expires. This is useful
|
||||||
|
for savings goals, vesting schedules, or preventing impulsive spending. The lock
|
||||||
|
is enforced at the protocol level, so even you cannot access the funds early.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && <CreateVaultModal onClose={() => setShowCreateModal(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
714
apps/desktop-wallet/src/pages/WatchOnly/WatchOnlyDashboard.tsx
Normal file
714
apps/desktop-wallet/src/pages/WatchOnly/WatchOnlyDashboard.tsx
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Tag,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
AlertTriangle,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useWatchOnlyStore,
|
||||||
|
useFilteredWatchOnlyAddresses,
|
||||||
|
formatWatchOnlyBalance,
|
||||||
|
WatchOnlyAddress,
|
||||||
|
} from '../../store/watchOnly';
|
||||||
|
|
||||||
|
export default function WatchOnlyDashboard() {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
selectedTag,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAddresses,
|
||||||
|
loadTags,
|
||||||
|
addAddress,
|
||||||
|
updateAddress,
|
||||||
|
removeAddress,
|
||||||
|
refreshBalance,
|
||||||
|
refreshAllBalances,
|
||||||
|
setSelectedTag,
|
||||||
|
setError,
|
||||||
|
} = useWatchOnlyStore();
|
||||||
|
|
||||||
|
const addresses = useFilteredWatchOnlyAddresses();
|
||||||
|
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [editingAddress, setEditingAddress] = useState<WatchOnlyAddress | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddresses();
|
||||||
|
loadTags();
|
||||||
|
}, [loadAddresses, loadTags]);
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
const filteredAddresses = addresses.filter(
|
||||||
|
(a) =>
|
||||||
|
a.address.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
a.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
a.notes?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = async (address: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(address);
|
||||||
|
setCopiedAddress(address);
|
||||||
|
setTimeout(() => setCopiedAddress(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (address: string) => {
|
||||||
|
if (confirm('Are you sure you want to remove this watch-only address?')) {
|
||||||
|
try {
|
||||||
|
await removeAddress(address);
|
||||||
|
} catch {
|
||||||
|
// Error handled in store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total balance
|
||||||
|
const totalBalance = addresses.reduce(
|
||||||
|
(sum, a) => sum + (a.cachedBalance || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Eye className="text-synor-400" />
|
||||||
|
Watch-Only Addresses
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Monitor addresses without private keys
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => refreshAllBalances()}
|
||||||
|
disabled={isLoading || addresses.length === 0}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||||
|
Refresh All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-between bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Addresses</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{addresses.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Total Balance</p>
|
||||||
|
<p className="text-2xl font-bold text-synor-400">
|
||||||
|
{formatWatchOnlyBalance(totalBalance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">Tags</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{tags.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search addresses, labels, or notes..."
|
||||||
|
className="w-full bg-gray-900 border border-gray-800 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filter */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTag(null)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||||
|
!selectedTag
|
||||||
|
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||||
|
selectedTag === tag
|
||||||
|
? 'bg-synor-600/20 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Tag size={12} />
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address List */}
|
||||||
|
{filteredAddresses.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-900 rounded-xl border border-gray-800">
|
||||||
|
<Eye className="mx-auto text-gray-600 mb-4\" size={48} />
|
||||||
|
<p className="text-gray-400 mb-2">No watch-only addresses yet</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
Add addresses to monitor their balances without exposing private keys
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add Your First Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAddresses.map((addr) => (
|
||||||
|
<WatchOnlyCard
|
||||||
|
key={addr.address}
|
||||||
|
address={addr}
|
||||||
|
onCopy={() => handleCopy(addr.address)}
|
||||||
|
onEdit={() => setEditingAddress(addr)}
|
||||||
|
onDelete={() => handleDelete(addr.address)}
|
||||||
|
onRefresh={() => refreshBalance(addr.address)}
|
||||||
|
copied={copiedAddress === addr.address}
|
||||||
|
isRefreshing={isLoading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
|
{showAddModal && (
|
||||||
|
<AddWatchOnlyModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onAdd={addAddress}
|
||||||
|
existingTags={tags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingAddress && (
|
||||||
|
<EditWatchOnlyModal
|
||||||
|
address={editingAddress}
|
||||||
|
onClose={() => setEditingAddress(null)}
|
||||||
|
onSave={updateAddress}
|
||||||
|
existingTags={tags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch-only address card component
|
||||||
|
function WatchOnlyCard({
|
||||||
|
address,
|
||||||
|
onCopy,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onRefresh,
|
||||||
|
copied,
|
||||||
|
isRefreshing,
|
||||||
|
}: {
|
||||||
|
address: WatchOnlyAddress;
|
||||||
|
onCopy: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}) {
|
||||||
|
const truncateAddress = (addr: string) => {
|
||||||
|
return `${addr.slice(0, 12)}...${addr.slice(-8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 hover:border-gray-700 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Label and network */}
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-white truncate">{address.label}</h3>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
address.network === 'testnet'
|
||||||
|
? 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
: 'bg-green-500/20 text-green-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{address.network}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<code className="text-gray-400 text-sm font-mono">
|
||||||
|
{truncateAddress(address.address)}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
className="text-gray-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`https://explorer.synor.io/address/${address.address}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-synor-400 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{address.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{address.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-2 py-0.5 bg-gray-800 text-gray-400 rounded text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{address.notes && (
|
||||||
|
<p className="text-gray-500 text-sm truncate">{address.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance and actions */}
|
||||||
|
<div className="flex flex-col items-end gap-2 ml-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{formatWatchOnlyBalance(address.cachedBalance)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Updated: {formatTime(address.balanceUpdatedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh balance"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1.5 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add watch-only address modal
|
||||||
|
function AddWatchOnlyModal({
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
existingTags,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (address: string, label: string, notes?: string, tags?: string[]) => Promise<WatchOnlyAddress>;
|
||||||
|
existingTags: string[];
|
||||||
|
}) {
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!address.trim()) {
|
||||||
|
setError('Address is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Label is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAdd(
|
||||||
|
address.trim(),
|
||||||
|
label.trim(),
|
||||||
|
notes.trim() || undefined,
|
||||||
|
selectedTags.length > 0 ? selectedTags : undefined
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add address');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewTag = () => {
|
||||||
|
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
|
||||||
|
setSelectedTags((prev) => [...prev, newTag.trim()]);
|
||||||
|
setNewTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Add Watch-Only Address</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
placeholder="synor1... or tsynor1..."
|
||||||
|
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 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g., Cold Storage, Exchange"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
rows={2}
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{existingTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder="Add new tag..."
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addNewTag}
|
||||||
|
disabled={!newTag.trim()}
|
||||||
|
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding...' : 'Add Address'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit watch-only address modal
|
||||||
|
function EditWatchOnlyModal({
|
||||||
|
address,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
existingTags,
|
||||||
|
}: {
|
||||||
|
address: WatchOnlyAddress;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (
|
||||||
|
address: string,
|
||||||
|
label?: string,
|
||||||
|
notes?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<WatchOnlyAddress>;
|
||||||
|
existingTags: string[];
|
||||||
|
}) {
|
||||||
|
const [label, setLabel] = useState(address.label);
|
||||||
|
const [notes, setNotes] = useState(address.notes || '');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>(address.tags);
|
||||||
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!label.trim()) {
|
||||||
|
setError('Label is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(
|
||||||
|
address.address,
|
||||||
|
label.trim(),
|
||||||
|
notes.trim(),
|
||||||
|
selectedTags
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update address');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setSelectedTags((prev) =>
|
||||||
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNewTag = () => {
|
||||||
|
if (newTag.trim() && !selectedTags.includes(newTag.trim())) {
|
||||||
|
setSelectedTags((prev) => [...prev, newTag.trim()]);
|
||||||
|
setNewTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allTags = Array.from(new Set([...existingTags, ...address.tags]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 w-full max-w-md shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Edit Watch-Only Address</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Address</label>
|
||||||
|
<code className="block w-full bg-gray-800/50 rounded-lg px-4 py-2.5 text-gray-400 font-mono text-sm break-all">
|
||||||
|
{address.address}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-synor-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">Tags</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-synor-600/30 text-synor-300 border border-synor-500/50'
|
||||||
|
: 'bg-gray-800 text-gray-400 border border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder="Add new tag..."
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addNewTag()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addNewTag}
|
||||||
|
disabled={!newTag.trim()}
|
||||||
|
className="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-400 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 py-2.5 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/desktop-wallet/src/pages/Yield/YieldDashboard.tsx
Normal file
61
apps/desktop-wallet/src/pages/Yield/YieldDashboard.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { TrendingUp, Info, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function YieldDashboard() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-3">
|
||||||
|
<TrendingUp className="text-synor-400" />
|
||||||
|
Yield Aggregator
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">Auto-compound and find the best APY</p>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 bg-gray-800 rounded-lg">
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-xl p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-yellow-200">Coming Soon</p>
|
||||||
|
<p className="text-sm text-yellow-200/70">
|
||||||
|
The yield aggregator automatically finds the best yields across DeFi protocols
|
||||||
|
and compounds your earnings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Deposited</p>
|
||||||
|
<p className="text-2xl font-bold">0 SYN</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Total Earned</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">0 SYN</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800 text-center">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Best APY</p>
|
||||||
|
<p className="text-2xl font-bold text-synor-400">--%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
||||||
|
<h3 className="font-medium mb-4">Available Strategies</h3>
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
No yield strategies available yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
||||||
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Yield aggregation optimizes gas costs and maximizes returns by automatically
|
||||||
|
moving funds to the highest-yielding opportunities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/desktop-wallet/src/store/alerts.ts
Normal file
117
apps/desktop-wallet/src/store/alerts.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface PriceAlert {
|
||||||
|
id: string;
|
||||||
|
asset: string;
|
||||||
|
condition: 'above' | 'below';
|
||||||
|
targetPrice: number;
|
||||||
|
currentPrice: number;
|
||||||
|
isTriggered: boolean;
|
||||||
|
isEnabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
triggeredAt?: number;
|
||||||
|
notificationMethod: 'push' | 'email' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsState {
|
||||||
|
alerts: PriceAlert[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsActions {
|
||||||
|
createAlert: (
|
||||||
|
asset: string,
|
||||||
|
condition: 'above' | 'below',
|
||||||
|
targetPrice: number,
|
||||||
|
notificationMethod: 'push' | 'email' | 'both'
|
||||||
|
) => Promise<PriceAlert>;
|
||||||
|
listAlerts: () => Promise<void>;
|
||||||
|
deleteAlert: (alertId: string) => Promise<void>;
|
||||||
|
toggleAlert: (alertId: string, enabled: boolean) => Promise<PriceAlert>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformAlert = (data: Record<string, unknown>): PriceAlert => ({
|
||||||
|
id: data.id as string,
|
||||||
|
asset: data.asset as string,
|
||||||
|
condition: data.condition as PriceAlert['condition'],
|
||||||
|
targetPrice: data.target_price as number,
|
||||||
|
currentPrice: data.current_price as number,
|
||||||
|
isTriggered: data.is_triggered as boolean,
|
||||||
|
isEnabled: data.is_enabled as boolean,
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
triggeredAt: data.triggered_at as number | undefined,
|
||||||
|
notificationMethod: data.notification_method as PriceAlert['notificationMethod'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAlertsStore = create<AlertsState & AlertsActions>((set) => ({
|
||||||
|
alerts: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createAlert: async (asset, condition, targetPrice, notificationMethod) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('alert_create', {
|
||||||
|
asset,
|
||||||
|
condition,
|
||||||
|
targetPrice,
|
||||||
|
notificationMethod,
|
||||||
|
});
|
||||||
|
const alert = transformAlert(data);
|
||||||
|
set((state) => ({
|
||||||
|
alerts: [alert, ...state.alerts],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return alert;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listAlerts: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('alert_list');
|
||||||
|
const alerts = data.map(transformAlert);
|
||||||
|
set({ alerts, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAlert: async (alertId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('alert_delete', { alertId });
|
||||||
|
set((state) => ({
|
||||||
|
alerts: state.alerts.filter((a) => a.id !== alertId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAlert: async (alertId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('alert_toggle', {
|
||||||
|
alertId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const alert = transformAlert(data);
|
||||||
|
set((state) => ({
|
||||||
|
alerts: state.alerts.map((a) => (a.id === alertId ? alert : a)),
|
||||||
|
}));
|
||||||
|
return alert;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
325
apps/desktop-wallet/src/store/batchSend.ts
Normal file
325
apps/desktop-wallet/src/store/batchSend.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient in a batch transaction
|
||||||
|
*/
|
||||||
|
export interface BatchRecipient {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
amount: number; // in SYN (human readable)
|
||||||
|
amountSompi: number; // in sompi (internal)
|
||||||
|
label?: string;
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch transaction summary
|
||||||
|
*/
|
||||||
|
export interface BatchSummary {
|
||||||
|
totalAmount: number; // in sompi
|
||||||
|
totalAmountHuman: string;
|
||||||
|
recipientCount: number;
|
||||||
|
estimatedFee: number; // in sompi
|
||||||
|
estimatedFeeHuman: string;
|
||||||
|
totalWithFee: number; // in sompi
|
||||||
|
totalWithFeeHuman: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Created batch transaction
|
||||||
|
*/
|
||||||
|
interface BatchTransactionResponse {
|
||||||
|
tx_hex: string;
|
||||||
|
tx_id: string;
|
||||||
|
total_sent: number;
|
||||||
|
fee: number;
|
||||||
|
recipient_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchSendState {
|
||||||
|
// State
|
||||||
|
recipients: BatchRecipient[];
|
||||||
|
summary: BatchSummary | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastTxId: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
addRecipient: () => void;
|
||||||
|
removeRecipient: (id: string) => void;
|
||||||
|
updateRecipient: (id: string, updates: Partial<BatchRecipient>) => void;
|
||||||
|
clearRecipients: () => void;
|
||||||
|
importFromCsv: (csv: string) => void;
|
||||||
|
calculateSummary: () => void;
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
createBatchTransaction: (fee?: number) => Promise<string>;
|
||||||
|
signAndBroadcast: (txHex: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID
|
||||||
|
let idCounter = 0;
|
||||||
|
const generateId = () => `recipient-${++idCounter}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Convert SYN to sompi
|
||||||
|
const synToSompi = (syn: number): number => Math.floor(syn * 100_000_000);
|
||||||
|
|
||||||
|
// Convert sompi to SYN
|
||||||
|
const sompiToSyn = (sompi: number): string => (sompi / 100_000_000).toFixed(8);
|
||||||
|
|
||||||
|
// Validate address format
|
||||||
|
const validateAddress = (address: string): boolean => {
|
||||||
|
return address.startsWith('synor1') || address.startsWith('tsynor1');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBatchSendStore = create<BatchSendState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastTxId: null,
|
||||||
|
|
||||||
|
// Add a new recipient row
|
||||||
|
addRecipient: () => {
|
||||||
|
set((state) => ({
|
||||||
|
recipients: [
|
||||||
|
...state.recipients,
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a recipient
|
||||||
|
removeRecipient: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const newRecipients = state.recipients.filter((r) => r.id !== id);
|
||||||
|
// Keep at least one recipient row
|
||||||
|
if (newRecipients.length === 0) {
|
||||||
|
return {
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { recipients: newRecipients };
|
||||||
|
});
|
||||||
|
get().calculateSummary();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update a recipient
|
||||||
|
updateRecipient: (id, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
recipients: state.recipients.map((r) => {
|
||||||
|
if (r.id !== id) return r;
|
||||||
|
|
||||||
|
const newRecipient = { ...r, ...updates };
|
||||||
|
|
||||||
|
// Validate and update
|
||||||
|
if ('amount' in updates) {
|
||||||
|
newRecipient.amountSompi = synToSompi(updates.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address
|
||||||
|
const addressValid = validateAddress(newRecipient.address);
|
||||||
|
const amountValid = newRecipient.amountSompi > 0;
|
||||||
|
|
||||||
|
newRecipient.isValid = addressValid && amountValid;
|
||||||
|
|
||||||
|
if (!addressValid && newRecipient.address) {
|
||||||
|
newRecipient.error = 'Invalid address format';
|
||||||
|
} else if (!amountValid && newRecipient.amount > 0) {
|
||||||
|
newRecipient.error = 'Amount must be greater than 0';
|
||||||
|
} else {
|
||||||
|
newRecipient.error = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRecipient;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
get().calculateSummary();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all recipients
|
||||||
|
clearRecipients: () => {
|
||||||
|
set({
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
address: '',
|
||||||
|
amount: 0,
|
||||||
|
amountSompi: 0,
|
||||||
|
isValid: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
summary: null,
|
||||||
|
error: null,
|
||||||
|
lastTxId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import recipients from CSV (address,amount format)
|
||||||
|
importFromCsv: (csv) => {
|
||||||
|
const lines = csv.trim().split('\n');
|
||||||
|
const recipients: BatchRecipient[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const [address, amountStr, label] = line.split(',').map((s) => s.trim());
|
||||||
|
const amount = parseFloat(amountStr) || 0;
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
const isValidAddress = validateAddress(address);
|
||||||
|
const isValidAmount = amount > 0;
|
||||||
|
|
||||||
|
recipients.push({
|
||||||
|
id: generateId(),
|
||||||
|
address,
|
||||||
|
amount,
|
||||||
|
amountSompi: synToSompi(amount),
|
||||||
|
label: label || undefined,
|
||||||
|
isValid: isValidAddress && isValidAmount,
|
||||||
|
error: !isValidAddress
|
||||||
|
? 'Invalid address'
|
||||||
|
: !isValidAmount
|
||||||
|
? 'Invalid amount'
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length > 0) {
|
||||||
|
set({ recipients });
|
||||||
|
get().calculateSummary();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Calculate transaction summary
|
||||||
|
calculateSummary: () => {
|
||||||
|
const { recipients } = get();
|
||||||
|
const validRecipients = recipients.filter((r) => r.isValid);
|
||||||
|
|
||||||
|
if (validRecipients.length === 0) {
|
||||||
|
set({ summary: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = validRecipients.reduce((sum, r) => sum + r.amountSompi, 0);
|
||||||
|
|
||||||
|
// Estimate fee (roughly 1000 sompi per recipient as base)
|
||||||
|
// This is a simplification - actual fee depends on tx size
|
||||||
|
const estimatedFee = Math.max(1000, validRecipients.length * 500 + 500);
|
||||||
|
|
||||||
|
set({
|
||||||
|
summary: {
|
||||||
|
totalAmount,
|
||||||
|
totalAmountHuman: `${sompiToSyn(totalAmount)} SYN`,
|
||||||
|
recipientCount: validRecipients.length,
|
||||||
|
estimatedFee,
|
||||||
|
estimatedFeeHuman: `${sompiToSyn(estimatedFee)} SYN`,
|
||||||
|
totalWithFee: totalAmount + estimatedFee,
|
||||||
|
totalWithFeeHuman: `${sompiToSyn(totalAmount + estimatedFee)} SYN`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a batch transaction (unsigned)
|
||||||
|
createBatchTransaction: async (fee) => {
|
||||||
|
const { recipients, summary } = get();
|
||||||
|
const validRecipients = recipients.filter((r) => r.isValid);
|
||||||
|
|
||||||
|
if (validRecipients.length === 0) {
|
||||||
|
throw new Error('No valid recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert to backend format
|
||||||
|
const outputs = validRecipients.map((r) => ({
|
||||||
|
address: r.address,
|
||||||
|
amount: r.amountSompi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await invoke<BatchTransactionResponse>('create_batch_transaction', {
|
||||||
|
outputs,
|
||||||
|
fee: fee || summary?.estimatedFee || 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
set({
|
||||||
|
lastTxId: response.tx_id,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.tx_hex;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create batch transaction';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sign and broadcast the transaction
|
||||||
|
signAndBroadcast: async (txHex) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sign the transaction
|
||||||
|
const signedHex = await invoke<string>('sign_transaction', { txHex });
|
||||||
|
|
||||||
|
// Broadcast it
|
||||||
|
const txId = await invoke<string>('broadcast_transaction', { txHex: signedHex });
|
||||||
|
|
||||||
|
set({
|
||||||
|
lastTxId: txId,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to broadcast transaction';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid recipient count
|
||||||
|
*/
|
||||||
|
export function useValidRecipientCount(): number {
|
||||||
|
return useBatchSendStore((state) =>
|
||||||
|
state.recipients.filter((r) => r.isValid).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if batch is ready to send
|
||||||
|
*/
|
||||||
|
export function useIsBatchReady(): boolean {
|
||||||
|
const { recipients, summary } = useBatchSendStore();
|
||||||
|
const validCount = recipients.filter((r) => r.isValid).length;
|
||||||
|
return validCount > 0 && summary !== null;
|
||||||
|
}
|
||||||
112
apps/desktop-wallet/src/store/cli.ts
Normal file
112
apps/desktop-wallet/src/store/cli.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface CliResult {
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
isError: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliState {
|
||||||
|
history: CliResult[];
|
||||||
|
commandHistory: string[];
|
||||||
|
historyIndex: number;
|
||||||
|
isExecuting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CliActions {
|
||||||
|
execute: (command: string) => Promise<CliResult>;
|
||||||
|
loadHistory: () => Promise<void>;
|
||||||
|
clearOutput: () => void;
|
||||||
|
navigateHistory: (direction: 'up' | 'down') => string | null;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformResult = (data: Record<string, unknown>): CliResult => ({
|
||||||
|
command: data.command as string,
|
||||||
|
output: data.output as string,
|
||||||
|
isError: data.is_error as boolean,
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCliStore = create<CliState & CliActions>((set, get) => ({
|
||||||
|
history: [],
|
||||||
|
commandHistory: [],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
execute: async (command: string) => {
|
||||||
|
try {
|
||||||
|
set({ isExecuting: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('cli_execute', { command });
|
||||||
|
const result = transformResult(data);
|
||||||
|
|
||||||
|
// Handle special "CLEAR" output
|
||||||
|
if (result.output === 'CLEAR') {
|
||||||
|
set({
|
||||||
|
history: [],
|
||||||
|
commandHistory: [...get().commandHistory, command],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
});
|
||||||
|
return { ...result, output: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
history: [...state.history, result],
|
||||||
|
commandHistory: [...state.commandHistory, command],
|
||||||
|
historyIndex: -1,
|
||||||
|
isExecuting: false,
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorResult: CliResult = {
|
||||||
|
command,
|
||||||
|
output: String(error),
|
||||||
|
isError: true,
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
};
|
||||||
|
set((state) => ({
|
||||||
|
history: [...state.history, errorResult],
|
||||||
|
isExecuting: false,
|
||||||
|
error: String(error),
|
||||||
|
}));
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHistory: async () => {
|
||||||
|
try {
|
||||||
|
const commands = await invoke<string[]>('cli_get_history');
|
||||||
|
set({ commandHistory: commands });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearOutput: () => {
|
||||||
|
set({ history: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
navigateHistory: (direction: 'up' | 'down') => {
|
||||||
|
const { commandHistory, historyIndex } = get();
|
||||||
|
if (commandHistory.length === 0) return null;
|
||||||
|
|
||||||
|
let newIndex = historyIndex;
|
||||||
|
if (direction === 'up') {
|
||||||
|
newIndex = historyIndex === -1 ? commandHistory.length - 1 : Math.max(0, historyIndex - 1);
|
||||||
|
} else {
|
||||||
|
newIndex = historyIndex === -1 ? -1 : Math.min(commandHistory.length - 1, historyIndex + 1);
|
||||||
|
if (newIndex === historyIndex) newIndex = -1; // Reset at end
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ historyIndex: newIndex });
|
||||||
|
return newIndex === -1 ? '' : commandHistory[newIndex];
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
148
apps/desktop-wallet/src/store/decoy.ts
Normal file
148
apps/desktop-wallet/src/store/decoy.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A decoy wallet for plausible deniability
|
||||||
|
*/
|
||||||
|
export interface DecoyWallet {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
balance: number;
|
||||||
|
balanceHuman: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessed?: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoyState {
|
||||||
|
// State
|
||||||
|
isEnabled: boolean;
|
||||||
|
decoys: DecoyWallet[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkEnabled: () => Promise<void>;
|
||||||
|
setup: (duressPassword: string) => Promise<void>;
|
||||||
|
createDecoy: (name: string, balance: number) => Promise<DecoyWallet>;
|
||||||
|
fetchDecoys: () => Promise<void>;
|
||||||
|
updateBalance: (decoyId: string, balance: number) => Promise<void>;
|
||||||
|
deleteDecoy: (decoyId: string) => Promise<void>;
|
||||||
|
disable: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sompiToSyn = (sompi: number): string => {
|
||||||
|
return `${(sompi / 100_000_000).toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformDecoy(data: any): DecoyWallet {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
address: data.address,
|
||||||
|
balance: data.balance,
|
||||||
|
balanceHuman: sompiToSyn(data.balance),
|
||||||
|
createdAt: data.created_at,
|
||||||
|
lastAccessed: data.last_accessed || undefined,
|
||||||
|
isActive: data.is_active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDecoyStore = create<DecoyState>()((set, get) => ({
|
||||||
|
isEnabled: false,
|
||||||
|
decoys: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
checkEnabled: async () => {
|
||||||
|
try {
|
||||||
|
const enabled = await invoke<boolean>('decoy_is_enabled');
|
||||||
|
set({ isEnabled: enabled });
|
||||||
|
} catch (error) {
|
||||||
|
set({ isEnabled: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setup: async (duressPassword) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('decoy_setup', { duressPassword });
|
||||||
|
set({ isEnabled: true, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to setup decoy wallets';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createDecoy: async (name, balance) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('decoy_create', {
|
||||||
|
name,
|
||||||
|
initialBalance: Math.floor(balance * 100_000_000),
|
||||||
|
});
|
||||||
|
const decoy = transformDecoy(data);
|
||||||
|
set((state) => ({
|
||||||
|
decoys: [...state.decoys, decoy],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return decoy;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create decoy';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchDecoys: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await invoke<any[]>('decoy_list');
|
||||||
|
const decoys = data.map(transformDecoy);
|
||||||
|
set({ decoys, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch decoys';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBalance: async (decoyId, balance) => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_update_balance', {
|
||||||
|
decoyId,
|
||||||
|
balance: Math.floor(balance * 100_000_000),
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
decoys: state.decoys.map((d) =>
|
||||||
|
d.id === decoyId
|
||||||
|
? { ...d, balance: balance * 100_000_000, balanceHuman: sompiToSyn(balance * 100_000_000) }
|
||||||
|
: d
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDecoy: async (decoyId) => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_delete', { decoyId });
|
||||||
|
set((state) => ({
|
||||||
|
decoys: state.decoys.filter((d) => d.id !== decoyId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disable: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('decoy_disable');
|
||||||
|
set({ isEnabled: false });
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
227
apps/desktop-wallet/src/store/feeAnalytics.ts
Normal file
227
apps/desktop-wallet/src/store/feeAnalytics.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool statistics from the network
|
||||||
|
*/
|
||||||
|
export interface MempoolStats {
|
||||||
|
txCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
totalFees: number;
|
||||||
|
minFeeRate: number; // sompi per byte
|
||||||
|
avgFeeRate: number;
|
||||||
|
maxFeeRate: number;
|
||||||
|
percentile10: number; // fee rate at 10th percentile
|
||||||
|
percentile50: number; // fee rate at 50th percentile (median)
|
||||||
|
percentile90: number; // fee rate at 90th percentile
|
||||||
|
lastUpdated: number; // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee tier recommendation
|
||||||
|
*/
|
||||||
|
export interface FeeRecommendation {
|
||||||
|
tier: 'economy' | 'standard' | 'priority' | 'instant';
|
||||||
|
feeRate: number; // sompi per byte
|
||||||
|
estimatedBlocks: number;
|
||||||
|
estimatedTimeSecs: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historical fee data point for charting
|
||||||
|
*/
|
||||||
|
export interface FeeHistoryPoint {
|
||||||
|
timestamp: number;
|
||||||
|
avgFeeRate: number;
|
||||||
|
minFeeRate: number;
|
||||||
|
maxFeeRate: number;
|
||||||
|
blockHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full fee analytics data
|
||||||
|
*/
|
||||||
|
export interface FeeAnalytics {
|
||||||
|
mempool: MempoolStats;
|
||||||
|
recommendations: FeeRecommendation[];
|
||||||
|
feeHistory: FeeHistoryPoint[];
|
||||||
|
networkCongestion: 'low' | 'medium' | 'high';
|
||||||
|
blockTargetTimeSecs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeeAnalyticsState {
|
||||||
|
// State
|
||||||
|
analytics: FeeAnalytics | null;
|
||||||
|
selectedTier: FeeRecommendation['tier'];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchAnalytics: () => Promise<void>;
|
||||||
|
setSelectedTier: (tier: FeeRecommendation['tier']) => void;
|
||||||
|
setAutoRefresh: (enabled: boolean) => void;
|
||||||
|
calculateFee: (txSizeBytes: number) => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case backend response to camelCase
|
||||||
|
function transformMempoolStats(data: any): MempoolStats {
|
||||||
|
return {
|
||||||
|
txCount: data.tx_count,
|
||||||
|
totalSizeBytes: data.total_size_bytes,
|
||||||
|
totalFees: data.total_fees,
|
||||||
|
minFeeRate: data.min_fee_rate,
|
||||||
|
avgFeeRate: data.avg_fee_rate,
|
||||||
|
maxFeeRate: data.max_fee_rate,
|
||||||
|
percentile10: data.percentile_10,
|
||||||
|
percentile50: data.percentile_50,
|
||||||
|
percentile90: data.percentile_90,
|
||||||
|
lastUpdated: data.last_updated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRecommendation(data: any): FeeRecommendation {
|
||||||
|
return {
|
||||||
|
tier: data.tier as FeeRecommendation['tier'],
|
||||||
|
feeRate: data.fee_rate,
|
||||||
|
estimatedBlocks: data.estimated_blocks,
|
||||||
|
estimatedTimeSecs: data.estimated_time_secs,
|
||||||
|
description: data.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformFeeHistory(data: any): FeeHistoryPoint {
|
||||||
|
return {
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
avgFeeRate: data.avg_fee_rate,
|
||||||
|
minFeeRate: data.min_fee_rate,
|
||||||
|
maxFeeRate: data.max_fee_rate,
|
||||||
|
blockHeight: data.block_height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformAnalytics(data: any): FeeAnalytics {
|
||||||
|
return {
|
||||||
|
mempool: transformMempoolStats(data.mempool),
|
||||||
|
recommendations: data.recommendations.map(transformRecommendation),
|
||||||
|
feeHistory: data.fee_history.map(transformFeeHistory),
|
||||||
|
networkCongestion: data.network_congestion as FeeAnalytics['networkCongestion'],
|
||||||
|
blockTargetTimeSecs: data.block_target_time_secs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFeeAnalyticsStore = create<FeeAnalyticsState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
analytics: null,
|
||||||
|
selectedTier: 'standard',
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
autoRefresh: true,
|
||||||
|
refreshIntervalMs: 30000, // 30 seconds
|
||||||
|
|
||||||
|
// Fetch all analytics data
|
||||||
|
fetchAnalytics: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('fee_get_analytics');
|
||||||
|
const analytics = transformAnalytics(data);
|
||||||
|
set({ analytics, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch fee analytics';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set selected fee tier
|
||||||
|
setSelectedTier: (tier) => {
|
||||||
|
set({ selectedTier: tier });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle auto-refresh
|
||||||
|
setAutoRefresh: (enabled) => {
|
||||||
|
set({ autoRefresh: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Calculate fee for a transaction
|
||||||
|
calculateFee: async (txSizeBytes) => {
|
||||||
|
const { selectedTier } = get();
|
||||||
|
try {
|
||||||
|
const fee = await invoke<number>('fee_calculate', {
|
||||||
|
txSizeBytes,
|
||||||
|
tier: selectedTier,
|
||||||
|
});
|
||||||
|
return fee;
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback calculation
|
||||||
|
const { analytics } = get();
|
||||||
|
const recommendation = analytics?.recommendations.find((r) => r.tier === selectedTier);
|
||||||
|
const feeRate = recommendation?.feeRate || 1.0;
|
||||||
|
return Math.ceil(txSizeBytes * feeRate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the selected recommendation
|
||||||
|
*/
|
||||||
|
export function useSelectedRecommendation(): FeeRecommendation | null {
|
||||||
|
const { analytics, selectedTier } = useFeeAnalyticsStore();
|
||||||
|
return analytics?.recommendations.find((r) => r.tier === selectedTier) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get congestion color class
|
||||||
|
*/
|
||||||
|
export function getCongestionColor(congestion: FeeAnalytics['networkCongestion']): string {
|
||||||
|
switch (congestion) {
|
||||||
|
case 'low':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'high':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get congestion background color
|
||||||
|
*/
|
||||||
|
export function getCongestionBgColor(congestion: FeeAnalytics['networkCongestion']): string {
|
||||||
|
switch (congestion) {
|
||||||
|
case 'low':
|
||||||
|
return 'bg-green-500/20 border-green-500/30';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-500/20 border-yellow-500/30';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-500/20 border-red-500/30';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 border-gray-500/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time duration in human readable format
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `~${seconds}s`;
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
return `~${mins}m`;
|
||||||
|
} else {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
return `~${hours}h`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format fee rate
|
||||||
|
*/
|
||||||
|
export function formatFeeRate(rate: number): string {
|
||||||
|
return `${rate.toFixed(2)} sompi/byte`;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
export { useWalletStore } from './wallet';
|
export { useWalletStore } from './wallet';
|
||||||
export type { WalletAddress, Balance, NetworkStatus } from './wallet';
|
export type { WalletAddress, Balance, NetworkStatus } from './wallet';
|
||||||
|
|
||||||
|
// Multi-wallet management
|
||||||
|
export { useWalletManagerStore } from './walletManager';
|
||||||
|
export type { WalletSummary, ActiveWalletInfo } from './walletManager';
|
||||||
|
|
||||||
|
// Watch-only addresses
|
||||||
|
export {
|
||||||
|
useWatchOnlyStore,
|
||||||
|
useFilteredWatchOnlyAddresses,
|
||||||
|
formatWatchOnlyBalance,
|
||||||
|
} from './watchOnly';
|
||||||
|
export type { WatchOnlyAddress } from './watchOnly';
|
||||||
|
|
||||||
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
|
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
|
||||||
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';
|
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';
|
||||||
|
|
||||||
|
|
|
||||||
132
apps/desktop-wallet/src/store/limitOrders.ts
Normal file
132
apps/desktop-wallet/src/store/limitOrders.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface LimitOrder {
|
||||||
|
id: string;
|
||||||
|
orderType: 'buy' | 'sell';
|
||||||
|
pair: string;
|
||||||
|
price: number;
|
||||||
|
amount: number;
|
||||||
|
filledAmount: number;
|
||||||
|
status: 'open' | 'partial' | 'filled' | 'cancelled';
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBook {
|
||||||
|
pair: string;
|
||||||
|
bids: { price: number; amount: number }[];
|
||||||
|
asks: { price: number; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitOrdersState {
|
||||||
|
orders: LimitOrder[];
|
||||||
|
orderBook: OrderBook | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitOrdersActions {
|
||||||
|
createOrder: (
|
||||||
|
orderType: 'buy' | 'sell',
|
||||||
|
pair: string,
|
||||||
|
price: number,
|
||||||
|
amount: number,
|
||||||
|
expiresInHours?: number
|
||||||
|
) => Promise<LimitOrder>;
|
||||||
|
getOrder: (orderId: string) => Promise<LimitOrder>;
|
||||||
|
listOrders: (statusFilter?: string) => Promise<void>;
|
||||||
|
cancelOrder: (orderId: string) => Promise<LimitOrder>;
|
||||||
|
getOrderBook: (pair: string) => Promise<OrderBook>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformOrder = (data: Record<string, unknown>): LimitOrder => ({
|
||||||
|
id: data.id as string,
|
||||||
|
orderType: data.order_type as LimitOrder['orderType'],
|
||||||
|
pair: data.pair as string,
|
||||||
|
price: data.price as number,
|
||||||
|
amount: data.amount as number,
|
||||||
|
filledAmount: data.filled_amount as number,
|
||||||
|
status: data.status as LimitOrder['status'],
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
expiresAt: data.expires_at as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLimitOrdersStore = create<LimitOrdersState & LimitOrdersActions>((set) => ({
|
||||||
|
orders: [],
|
||||||
|
orderBook: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createOrder: async (orderType, pair, price, amount, expiresInHours) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_create', {
|
||||||
|
orderType,
|
||||||
|
pair,
|
||||||
|
price,
|
||||||
|
amount,
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
const order = transformOrder(data);
|
||||||
|
set((state) => ({
|
||||||
|
orders: [order, ...state.orders],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return order;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrder: async (orderId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_get', { orderId });
|
||||||
|
return transformOrder(data);
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listOrders: async (statusFilter?: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('limit_order_list', { statusFilter });
|
||||||
|
const orders = data.map(transformOrder);
|
||||||
|
set({ orders, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelOrder: async (orderId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('limit_order_cancel', { orderId });
|
||||||
|
const order = transformOrder(data);
|
||||||
|
set((state) => ({
|
||||||
|
orders: state.orders.map((o) => (o.id === orderId ? order : o)),
|
||||||
|
}));
|
||||||
|
return order;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrderBook: async (pair: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<OrderBook>('limit_order_get_orderbook', { pair });
|
||||||
|
set({ orderBook: data });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
157
apps/desktop-wallet/src/store/mixer.ts
Normal file
157
apps/desktop-wallet/src/store/mixer.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
// Types matching backend
|
||||||
|
export interface MixPoolStatus {
|
||||||
|
poolId: string;
|
||||||
|
denomination: number;
|
||||||
|
participants: number;
|
||||||
|
requiredParticipants: number;
|
||||||
|
status: 'waiting' | 'mixing' | 'completed';
|
||||||
|
estimatedTimeSecs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixRequest {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
denomination: number;
|
||||||
|
status: 'pending' | 'mixing' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
outputAddress: string;
|
||||||
|
createdAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
txId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerState {
|
||||||
|
denominations: number[];
|
||||||
|
poolStatus: Record<number, MixPoolStatus>;
|
||||||
|
requests: MixRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixerActions {
|
||||||
|
loadDenominations: () => Promise<void>;
|
||||||
|
getPoolStatus: (denomination: number) => Promise<MixPoolStatus>;
|
||||||
|
createRequest: (amount: number, denomination: number, outputAddress: string) => Promise<MixRequest>;
|
||||||
|
getRequest: (requestId: string) => Promise<MixRequest>;
|
||||||
|
listRequests: () => Promise<void>;
|
||||||
|
cancelRequest: (requestId: string) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformMixRequest = (data: Record<string, unknown>): MixRequest => ({
|
||||||
|
id: data.id as string,
|
||||||
|
amount: data.amount as number,
|
||||||
|
denomination: data.denomination as number,
|
||||||
|
status: data.status as MixRequest['status'],
|
||||||
|
outputAddress: data.output_address as string,
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
completedAt: data.completed_at as number | undefined,
|
||||||
|
txId: data.tx_id as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformPoolStatus = (data: Record<string, unknown>): MixPoolStatus => ({
|
||||||
|
poolId: data.pool_id as string,
|
||||||
|
denomination: data.denomination as number,
|
||||||
|
participants: data.participants as number,
|
||||||
|
requiredParticipants: data.required_participants as number,
|
||||||
|
status: data.status as MixPoolStatus['status'],
|
||||||
|
estimatedTimeSecs: data.estimated_time_secs as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useMixerStore = create<MixerState & MixerActions>((set, get) => ({
|
||||||
|
denominations: [],
|
||||||
|
poolStatus: {},
|
||||||
|
requests: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadDenominations: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const denominations = await invoke<number[]>('mixer_get_denominations');
|
||||||
|
set({ denominations, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPoolStatus: async (denomination: number) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_get_pool_status', { denomination });
|
||||||
|
const status = transformPoolStatus(data);
|
||||||
|
set((state) => ({
|
||||||
|
poolStatus: { ...state.poolStatus, [denomination]: status },
|
||||||
|
}));
|
||||||
|
return status;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createRequest: async (amount: number, denomination: number, outputAddress: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_create_request', {
|
||||||
|
amount,
|
||||||
|
denomination,
|
||||||
|
outputAddress,
|
||||||
|
});
|
||||||
|
const request = transformMixRequest(data);
|
||||||
|
set((state) => ({
|
||||||
|
requests: [request, ...state.requests],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getRequest: async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('mixer_get_request', { requestId });
|
||||||
|
return transformMixRequest(data);
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listRequests: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('mixer_list_requests');
|
||||||
|
const requests = data.map(transformMixRequest);
|
||||||
|
set({ requests, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelRequest: async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('mixer_cancel_request', { requestId });
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? { ...r, status: 'cancelled' as const } : r
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format denomination
|
||||||
|
export const formatDenomination = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn} SYN`;
|
||||||
|
};
|
||||||
148
apps/desktop-wallet/src/store/plugins.ts
Normal file
148
apps/desktop-wallet/src/store/plugins.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface PluginInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
homepage?: string;
|
||||||
|
permissions: string[];
|
||||||
|
isEnabled: boolean;
|
||||||
|
isInstalled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginsState {
|
||||||
|
availablePlugins: PluginInfo[];
|
||||||
|
installedPlugins: PluginInfo[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginsActions {
|
||||||
|
loadAvailable: () => Promise<void>;
|
||||||
|
loadInstalled: () => Promise<void>;
|
||||||
|
install: (pluginId: string) => Promise<PluginInfo>;
|
||||||
|
uninstall: (pluginId: string) => Promise<void>;
|
||||||
|
toggle: (pluginId: string, enabled: boolean) => Promise<PluginInfo>;
|
||||||
|
getSettings: (pluginId: string) => Promise<Record<string, unknown>>;
|
||||||
|
setSettings: (pluginId: string, settings: Record<string, unknown>) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformPlugin = (data: Record<string, unknown>): PluginInfo => ({
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
description: data.description as string,
|
||||||
|
version: data.version as string,
|
||||||
|
author: data.author as string,
|
||||||
|
homepage: data.homepage as string | undefined,
|
||||||
|
permissions: data.permissions as string[],
|
||||||
|
isEnabled: data.is_enabled as boolean,
|
||||||
|
isInstalled: data.is_installed as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePluginsStore = create<PluginsState & PluginsActions>((set) => ({
|
||||||
|
availablePlugins: [],
|
||||||
|
installedPlugins: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadAvailable: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('plugin_list_available');
|
||||||
|
const availablePlugins = data.map(transformPlugin);
|
||||||
|
set({ availablePlugins, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadInstalled: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('plugin_list_installed');
|
||||||
|
const installedPlugins = data.map(transformPlugin);
|
||||||
|
set({ installedPlugins, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
install: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('plugin_install', { pluginId });
|
||||||
|
const plugin = transformPlugin(data);
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: [...state.installedPlugins, plugin],
|
||||||
|
availablePlugins: state.availablePlugins.map((p) =>
|
||||||
|
p.id === pluginId ? { ...p, isInstalled: true } : p
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return plugin;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
uninstall: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('plugin_uninstall', { pluginId });
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: state.installedPlugins.filter((p) => p.id !== pluginId),
|
||||||
|
availablePlugins: state.availablePlugins.map((p) =>
|
||||||
|
p.id === pluginId ? { ...p, isInstalled: false } : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle: async (pluginId: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('plugin_toggle', {
|
||||||
|
pluginId,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const plugin = transformPlugin(data);
|
||||||
|
set((state) => ({
|
||||||
|
installedPlugins: state.installedPlugins.map((p) =>
|
||||||
|
p.id === pluginId ? plugin : p
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return plugin;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSettings: async (pluginId: string) => {
|
||||||
|
try {
|
||||||
|
const settings = await invoke<Record<string, unknown>>('plugin_get_settings', { pluginId });
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSettings: async (pluginId: string, settings: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await invoke('plugin_set_settings', { pluginId, settings });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
180
apps/desktop-wallet/src/store/portfolio.ts
Normal file
180
apps/desktop-wallet/src/store/portfolio.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface PortfolioSummary {
|
||||||
|
totalValueUsd: number;
|
||||||
|
totalCostBasisUsd: number;
|
||||||
|
totalPnlUsd: number;
|
||||||
|
totalPnlPercent: number;
|
||||||
|
dayChangeUsd: number;
|
||||||
|
dayChangePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetHolding {
|
||||||
|
asset: string;
|
||||||
|
symbol: string;
|
||||||
|
balance: number;
|
||||||
|
balanceFormatted: string;
|
||||||
|
priceUsd: number;
|
||||||
|
valueUsd: number;
|
||||||
|
costBasisUsd: number;
|
||||||
|
pnlUsd: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
allocationPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxableTransaction {
|
||||||
|
id: string;
|
||||||
|
txType: 'buy' | 'sell' | 'swap' | 'transfer';
|
||||||
|
asset: string;
|
||||||
|
amount: number;
|
||||||
|
priceUsd: number;
|
||||||
|
totalUsd: number;
|
||||||
|
costBasisUsd?: number;
|
||||||
|
gainLossUsd?: number;
|
||||||
|
timestamp: number;
|
||||||
|
isLongTerm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryPoint {
|
||||||
|
timestamp: number;
|
||||||
|
valueUsd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortfolioState {
|
||||||
|
summary: PortfolioSummary | null;
|
||||||
|
holdings: AssetHolding[];
|
||||||
|
taxReport: TaxableTransaction[];
|
||||||
|
history: HistoryPoint[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortfolioActions {
|
||||||
|
loadSummary: () => Promise<void>;
|
||||||
|
loadHoldings: () => Promise<void>;
|
||||||
|
loadTaxReport: (year: number) => Promise<void>;
|
||||||
|
exportTaxReport: (year: number, format: 'csv' | 'txf' | 'json') => Promise<string>;
|
||||||
|
loadHistory: (days: number) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformSummary = (data: Record<string, unknown>): PortfolioSummary => ({
|
||||||
|
totalValueUsd: data.total_value_usd as number,
|
||||||
|
totalCostBasisUsd: data.total_cost_basis_usd as number,
|
||||||
|
totalPnlUsd: data.total_pnl_usd as number,
|
||||||
|
totalPnlPercent: data.total_pnl_percent as number,
|
||||||
|
dayChangeUsd: data.day_change_usd as number,
|
||||||
|
dayChangePercent: data.day_change_percent as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformHolding = (data: Record<string, unknown>): AssetHolding => ({
|
||||||
|
asset: data.asset as string,
|
||||||
|
symbol: data.symbol as string,
|
||||||
|
balance: data.balance as number,
|
||||||
|
balanceFormatted: data.balance_formatted as string,
|
||||||
|
priceUsd: data.price_usd as number,
|
||||||
|
valueUsd: data.value_usd as number,
|
||||||
|
costBasisUsd: data.cost_basis_usd as number,
|
||||||
|
pnlUsd: data.pnl_usd as number,
|
||||||
|
pnlPercent: data.pnl_percent as number,
|
||||||
|
allocationPercent: data.allocation_percent as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformTaxTx = (data: Record<string, unknown>): TaxableTransaction => ({
|
||||||
|
id: data.id as string,
|
||||||
|
txType: data.tx_type as TaxableTransaction['txType'],
|
||||||
|
asset: data.asset as string,
|
||||||
|
amount: data.amount as number,
|
||||||
|
priceUsd: data.price_usd as number,
|
||||||
|
totalUsd: data.total_usd as number,
|
||||||
|
costBasisUsd: data.cost_basis_usd as number | undefined,
|
||||||
|
gainLossUsd: data.gain_loss_usd as number | undefined,
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
isLongTerm: data.is_long_term as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformHistory = (data: Record<string, unknown>): HistoryPoint => ({
|
||||||
|
timestamp: data.timestamp as number,
|
||||||
|
valueUsd: data.value_usd as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usePortfolioStore = create<PortfolioState & PortfolioActions>((set) => ({
|
||||||
|
summary: null,
|
||||||
|
holdings: [],
|
||||||
|
taxReport: [],
|
||||||
|
history: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadSummary: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('portfolio_get_summary');
|
||||||
|
const summary = transformSummary(data);
|
||||||
|
set({ summary, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHoldings: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_holdings');
|
||||||
|
const holdings = data.map(transformHolding);
|
||||||
|
set({ holdings, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTaxReport: async (year: number) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_tax_report', { year });
|
||||||
|
const taxReport = data.map(transformTaxTx);
|
||||||
|
set({ taxReport, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportTaxReport: async (year: number, format: 'csv' | 'txf' | 'json') => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<string>('portfolio_export_tax_report', { year, format });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadHistory: async (days: number) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('portfolio_get_history', { days });
|
||||||
|
const history = data.map(transformHistory);
|
||||||
|
set({ history, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format currency
|
||||||
|
export const formatUSD = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to format percentage
|
||||||
|
export const formatPercent = (value: number): string => {
|
||||||
|
const sign = value >= 0 ? '+' : '';
|
||||||
|
return `${sign}${value.toFixed(2)}%`;
|
||||||
|
};
|
||||||
351
apps/desktop-wallet/src/store/recovery.ts
Normal file
351
apps/desktop-wallet/src/store/recovery.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guardian for social recovery
|
||||||
|
*/
|
||||||
|
export interface Guardian {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string; // Synor address
|
||||||
|
publicKey?: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: 'pending' | 'confirmed' | 'revoked';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery configuration
|
||||||
|
*/
|
||||||
|
export interface RecoveryConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
threshold: number; // Number of guardians required
|
||||||
|
totalGuardians: number;
|
||||||
|
guardians: Guardian[];
|
||||||
|
recoveryDelaySecs: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery request
|
||||||
|
*/
|
||||||
|
export interface RecoveryRequest {
|
||||||
|
id: string;
|
||||||
|
walletAddress: string;
|
||||||
|
newOwnerAddress: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
status: 'pending' | 'approved' | 'completed' | 'cancelled' | 'expired';
|
||||||
|
approvals: string[]; // Guardian IDs
|
||||||
|
requiredApprovals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryState {
|
||||||
|
// State
|
||||||
|
config: RecoveryConfig | null;
|
||||||
|
requests: RecoveryRequest[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchConfig: () => Promise<void>;
|
||||||
|
setupRecovery: (threshold: number, delaySecs: number) => Promise<void>;
|
||||||
|
addGuardian: (name: string, email?: string, address?: string) => Promise<Guardian>;
|
||||||
|
removeGuardian: (guardianId: string) => Promise<void>;
|
||||||
|
updateThreshold: (threshold: number) => Promise<void>;
|
||||||
|
disableRecovery: () => Promise<void>;
|
||||||
|
// Recovery process
|
||||||
|
initiateRecovery: (walletAddress: string, newOwnerAddress: string) => Promise<RecoveryRequest>;
|
||||||
|
approveRecovery: (requestId: string, guardianId: string) => Promise<RecoveryRequest>;
|
||||||
|
cancelRecovery: (requestId: string) => Promise<void>;
|
||||||
|
fetchRequests: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend guardian to frontend format
|
||||||
|
function transformGuardian(data: any): Guardian {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
email: data.email || undefined,
|
||||||
|
address: data.address || undefined,
|
||||||
|
publicKey: data.public_key || undefined,
|
||||||
|
addedAt: data.added_at,
|
||||||
|
status: data.status as Guardian['status'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend config
|
||||||
|
function transformConfig(data: any): RecoveryConfig {
|
||||||
|
return {
|
||||||
|
enabled: data.enabled,
|
||||||
|
threshold: data.threshold,
|
||||||
|
totalGuardians: data.total_guardians,
|
||||||
|
guardians: data.guardians.map(transformGuardian),
|
||||||
|
recoveryDelaySecs: data.recovery_delay_secs,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform recovery request
|
||||||
|
function transformRequest(data: any): RecoveryRequest {
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
walletAddress: data.wallet_address,
|
||||||
|
newOwnerAddress: data.new_owner_address,
|
||||||
|
createdAt: data.created_at,
|
||||||
|
expiresAt: data.expires_at,
|
||||||
|
status: data.status as RecoveryRequest['status'],
|
||||||
|
approvals: data.approvals,
|
||||||
|
requiredApprovals: data.required_approvals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRecoveryStore = create<RecoveryState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
config: null,
|
||||||
|
requests: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch current configuration
|
||||||
|
fetchConfig: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any | null>('recovery_get_config');
|
||||||
|
const config = data ? transformConfig(data) : null;
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch recovery config';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Setup social recovery
|
||||||
|
setupRecovery: async (threshold, delaySecs) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_setup', {
|
||||||
|
threshold,
|
||||||
|
recoveryDelaySecs: delaySecs,
|
||||||
|
});
|
||||||
|
const config = transformConfig(data);
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to setup recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a guardian
|
||||||
|
addGuardian: async (name, email, address) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_add_guardian', {
|
||||||
|
name,
|
||||||
|
email: email || null,
|
||||||
|
address: address || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guardian = transformGuardian(data);
|
||||||
|
|
||||||
|
// Update local config
|
||||||
|
set((state) => {
|
||||||
|
if (!state.config) return state;
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
guardians: [...state.config.guardians, guardian],
|
||||||
|
totalGuardians: state.config.totalGuardians + 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return guardian;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to add guardian';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a guardian
|
||||||
|
removeGuardian: async (guardianId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_remove_guardian', { guardianId });
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
if (!state.config) return state;
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...state.config,
|
||||||
|
guardians: state.config.guardians.filter((g) => g.id !== guardianId),
|
||||||
|
totalGuardians: state.config.totalGuardians - 1,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to remove guardian';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update threshold
|
||||||
|
updateThreshold: async (threshold) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_update_threshold', { threshold });
|
||||||
|
const config = transformConfig(data);
|
||||||
|
set({ config, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update threshold';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disable recovery
|
||||||
|
disableRecovery: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_disable');
|
||||||
|
set((state) => ({
|
||||||
|
config: state.config ? { ...state.config, enabled: false } : null,
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to disable recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Initiate a recovery request
|
||||||
|
initiateRecovery: async (walletAddress, newOwnerAddress) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_initiate', {
|
||||||
|
walletAddress,
|
||||||
|
newOwnerAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = transformRequest(data);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: [request, ...state.requests],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to initiate recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Guardian approves a recovery
|
||||||
|
approveRecovery: async (requestId, guardianId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('recovery_approve', {
|
||||||
|
requestId,
|
||||||
|
guardianId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedRequest = transformRequest(data);
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? updatedRequest : r
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to approve recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel a recovery request
|
||||||
|
cancelRecovery: async (requestId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('recovery_cancel', { requestId });
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
requests: state.requests.map((r) =>
|
||||||
|
r.id === requestId ? { ...r, status: 'cancelled' as const } : r
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to cancel recovery';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch all recovery requests
|
||||||
|
fetchRequests: async () => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<any[]>('recovery_list_requests');
|
||||||
|
const requests = data.map(transformRequest);
|
||||||
|
set({ requests });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch recovery requests:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for guardian
|
||||||
|
*/
|
||||||
|
export function getGuardianStatusColor(status: Guardian['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'revoked':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for recovery request
|
||||||
|
*/
|
||||||
|
export function getRequestStatusColor(status: RecoveryRequest['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
case 'completed':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'pending':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'cancelled':
|
||||||
|
case 'expired':
|
||||||
|
return 'text-red-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
130
apps/desktop-wallet/src/store/rpcProfiles.ts
Normal file
130
apps/desktop-wallet/src/store/rpcProfiles.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface RpcProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
httpUrl: string;
|
||||||
|
wsUrl?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
priority: number;
|
||||||
|
latencyMs?: number;
|
||||||
|
lastChecked?: number;
|
||||||
|
isHealthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcProfilesState {
|
||||||
|
profiles: RpcProfile[];
|
||||||
|
activeProfile: RpcProfile | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcProfilesActions {
|
||||||
|
createProfile: (name: string, httpUrl: string, wsUrl?: string) => Promise<RpcProfile>;
|
||||||
|
listProfiles: () => Promise<void>;
|
||||||
|
setActive: (profileId: string) => Promise<RpcProfile>;
|
||||||
|
deleteProfile: (profileId: string) => Promise<void>;
|
||||||
|
testProfile: (profileId: string) => Promise<RpcProfile>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformProfile = (data: Record<string, unknown>): RpcProfile => ({
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
httpUrl: data.http_url as string,
|
||||||
|
wsUrl: data.ws_url as string | undefined,
|
||||||
|
isActive: data.is_active as boolean,
|
||||||
|
isDefault: data.is_default as boolean,
|
||||||
|
priority: data.priority as number,
|
||||||
|
latencyMs: data.latency_ms as number | undefined,
|
||||||
|
lastChecked: data.last_checked as number | undefined,
|
||||||
|
isHealthy: data.is_healthy as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useRpcProfilesStore = create<RpcProfilesState & RpcProfilesActions>((set, get) => ({
|
||||||
|
profiles: [],
|
||||||
|
activeProfile: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
createProfile: async (name: string, httpUrl: string, wsUrl?: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_create', {
|
||||||
|
name,
|
||||||
|
httpUrl,
|
||||||
|
wsUrl,
|
||||||
|
});
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: [...state.profiles, profile],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listProfiles: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('rpc_profile_list');
|
||||||
|
const profiles = data.map(transformProfile);
|
||||||
|
const activeProfile = profiles.find((p) => p.isActive) || null;
|
||||||
|
set({ profiles, activeProfile, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_set_active', { profileId });
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((p) => ({
|
||||||
|
...p,
|
||||||
|
isActive: p.id === profileId,
|
||||||
|
})),
|
||||||
|
activeProfile: profile,
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProfile: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('rpc_profile_delete', { profileId });
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.filter((p) => p.id !== profileId),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
testProfile: async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('rpc_profile_test', { profileId });
|
||||||
|
const profile = transformProfile(data);
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((p) => (p.id === profileId ? profile : p)),
|
||||||
|
}));
|
||||||
|
return profile;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
172
apps/desktop-wallet/src/store/txBuilder.ts
Normal file
172
apps/desktop-wallet/src/store/txBuilder.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface TxOutput {
|
||||||
|
address: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltTransaction {
|
||||||
|
id: string;
|
||||||
|
inputs: unknown[];
|
||||||
|
outputs: TxOutput[];
|
||||||
|
fee: number;
|
||||||
|
sizeBytes: number;
|
||||||
|
locktime: number;
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedTransaction {
|
||||||
|
version: number;
|
||||||
|
inputs: unknown[];
|
||||||
|
outputs: unknown[];
|
||||||
|
locktime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TxBuilderState {
|
||||||
|
outputs: TxOutput[];
|
||||||
|
feeRate: number;
|
||||||
|
locktime: number;
|
||||||
|
builtTx: BuiltTransaction | null;
|
||||||
|
signedTxHex: string | null;
|
||||||
|
txId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TxBuilderActions {
|
||||||
|
addOutput: (address: string, amount: number) => void;
|
||||||
|
removeOutput: (index: number) => void;
|
||||||
|
updateOutput: (index: number, address: string, amount: number) => void;
|
||||||
|
setFeeRate: (rate: number) => void;
|
||||||
|
setLocktime: (locktime: number) => void;
|
||||||
|
buildTransaction: () => Promise<BuiltTransaction>;
|
||||||
|
signTransaction: (txHex: string) => Promise<string>;
|
||||||
|
broadcastTransaction: (signedTxHex: string) => Promise<string>;
|
||||||
|
decodeTransaction: (txHex: string) => Promise<DecodedTransaction>;
|
||||||
|
reset: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformBuiltTx = (data: Record<string, unknown>): BuiltTransaction => ({
|
||||||
|
id: data.id as string,
|
||||||
|
inputs: data.inputs as unknown[],
|
||||||
|
outputs: data.outputs as TxOutput[],
|
||||||
|
fee: data.fee as number,
|
||||||
|
sizeBytes: data.size_bytes as number,
|
||||||
|
locktime: data.locktime as number,
|
||||||
|
hex: data.hex as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTxBuilderStore = create<TxBuilderState & TxBuilderActions>((set, get) => ({
|
||||||
|
outputs: [{ address: '', amount: 0 }],
|
||||||
|
feeRate: 1.0,
|
||||||
|
locktime: 0,
|
||||||
|
builtTx: null,
|
||||||
|
signedTxHex: null,
|
||||||
|
txId: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
addOutput: (address: string, amount: number) => {
|
||||||
|
set((state) => ({
|
||||||
|
outputs: [...state.outputs, { address, amount }],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOutput: (index: number) => {
|
||||||
|
set((state) => ({
|
||||||
|
outputs: state.outputs.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateOutput: (index: number, address: string, amount: number) => {
|
||||||
|
set((state) => ({
|
||||||
|
outputs: state.outputs.map((o, i) => (i === index ? { address, amount } : o)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setFeeRate: (rate: number) => {
|
||||||
|
set({ feeRate: rate });
|
||||||
|
},
|
||||||
|
|
||||||
|
setLocktime: (locktime: number) => {
|
||||||
|
set({ locktime });
|
||||||
|
},
|
||||||
|
|
||||||
|
buildTransaction: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const { outputs, feeRate, locktime } = get();
|
||||||
|
const validOutputs = outputs.filter((o) => o.address && o.amount > 0);
|
||||||
|
|
||||||
|
const data = await invoke<Record<string, unknown>>('tx_builder_create', {
|
||||||
|
outputs: validOutputs,
|
||||||
|
feeRate,
|
||||||
|
locktime: locktime > 0 ? locktime : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtTx = transformBuiltTx(data);
|
||||||
|
set({ builtTx, isLoading: false });
|
||||||
|
return builtTx;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signTransaction: async (txHex: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const signedTxHex = await invoke<string>('tx_builder_sign', { txHex });
|
||||||
|
set({ signedTxHex, isLoading: false });
|
||||||
|
return signedTxHex;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastTransaction: async (signedTxHex: string) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const txId = await invoke<string>('tx_builder_broadcast', { signedTxHex });
|
||||||
|
set({ txId, isLoading: false });
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
decodeTransaction: async (txHex: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<DecodedTransaction>('tx_builder_decode', { txHex });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => {
|
||||||
|
set({
|
||||||
|
outputs: [{ address: '', amount: 0 }],
|
||||||
|
feeRate: 1.0,
|
||||||
|
locktime: 0,
|
||||||
|
builtTx: null,
|
||||||
|
signedTxHex: null,
|
||||||
|
txId: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format sompi to SYN
|
||||||
|
export const formatAmount = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
return `${syn.toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
280
apps/desktop-wallet/src/store/vaults.ts
Normal file
280
apps/desktop-wallet/src/store/vaults.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time-locked vault
|
||||||
|
*/
|
||||||
|
export interface Vault {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
amount: number; // in sompi
|
||||||
|
amountHuman: string; // formatted for display
|
||||||
|
createdAt: number; // unix timestamp
|
||||||
|
unlockAt: number; // unix timestamp
|
||||||
|
status: 'locked' | 'unlocked' | 'withdrawn';
|
||||||
|
description?: string;
|
||||||
|
txId?: string;
|
||||||
|
// Computed fields
|
||||||
|
remainingSecs: number;
|
||||||
|
progress: number; // 0-100 percentage complete
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault summary statistics
|
||||||
|
*/
|
||||||
|
export interface VaultSummary {
|
||||||
|
totalLocked: number;
|
||||||
|
totalLockedHuman: string;
|
||||||
|
totalVaults: number;
|
||||||
|
lockedVaults: number;
|
||||||
|
unlockedVaults: number;
|
||||||
|
nextUnlock?: number; // unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create vault request
|
||||||
|
*/
|
||||||
|
interface CreateVaultRequest {
|
||||||
|
name: string;
|
||||||
|
amount: number; // in sompi
|
||||||
|
lockDurationSecs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultsState {
|
||||||
|
// State
|
||||||
|
vaults: Vault[];
|
||||||
|
summary: VaultSummary | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchVaults: () => Promise<void>;
|
||||||
|
fetchSummary: () => Promise<void>;
|
||||||
|
createVault: (request: CreateVaultRequest) => Promise<Vault>;
|
||||||
|
withdrawVault: (vaultId: string) => Promise<string>;
|
||||||
|
deleteVault: (vaultId: string) => Promise<void>;
|
||||||
|
getTimeRemaining: (vaultId: string) => Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert sompi to SYN for display
|
||||||
|
const sompiToSyn = (sompi: number): string => {
|
||||||
|
return `${(sompi / 100_000_000).toFixed(8)} SYN`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform backend vault to frontend format
|
||||||
|
function transformVault(data: any): Vault {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const createdAt = data.created_at;
|
||||||
|
const unlockAt = data.unlock_at;
|
||||||
|
const totalDuration = unlockAt - createdAt;
|
||||||
|
const elapsed = now - createdAt;
|
||||||
|
const remaining = Math.max(0, unlockAt - now);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
amount: data.amount,
|
||||||
|
amountHuman: sompiToSyn(data.amount),
|
||||||
|
createdAt: data.created_at,
|
||||||
|
unlockAt: data.unlock_at,
|
||||||
|
status: data.status as Vault['status'],
|
||||||
|
description: data.description || undefined,
|
||||||
|
txId: data.tx_id || undefined,
|
||||||
|
remainingSecs: remaining,
|
||||||
|
progress: Math.min(100, Math.max(0, (elapsed / totalDuration) * 100)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform backend summary
|
||||||
|
function transformSummary(data: any): VaultSummary {
|
||||||
|
return {
|
||||||
|
totalLocked: data.total_locked,
|
||||||
|
totalLockedHuman: sompiToSyn(data.total_locked),
|
||||||
|
totalVaults: data.total_vaults,
|
||||||
|
lockedVaults: data.locked_vaults,
|
||||||
|
unlockedVaults: data.unlocked_vaults,
|
||||||
|
nextUnlock: data.next_unlock || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useVaultsStore = create<VaultsState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
vaults: [],
|
||||||
|
summary: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch all vaults
|
||||||
|
fetchVaults: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any[]>('vault_list');
|
||||||
|
const vaults = data.map(transformVault);
|
||||||
|
// Sort by unlock time (soonest first for locked, newest first for others)
|
||||||
|
vaults.sort((a, b) => {
|
||||||
|
if (a.status === 'locked' && b.status === 'locked') {
|
||||||
|
return a.unlockAt - b.unlockAt;
|
||||||
|
}
|
||||||
|
if (a.status === 'locked') return -1;
|
||||||
|
if (b.status === 'locked') return 1;
|
||||||
|
return b.createdAt - a.createdAt;
|
||||||
|
});
|
||||||
|
set({ vaults, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch vaults';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch summary statistics
|
||||||
|
fetchSummary: async () => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('vault_get_summary');
|
||||||
|
const summary = transformSummary(data);
|
||||||
|
set({ summary });
|
||||||
|
} catch (error) {
|
||||||
|
// Non-critical, don't set error state
|
||||||
|
console.error('Failed to fetch vault summary:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new vault
|
||||||
|
createVault: async (request) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await invoke<any>('vault_create', {
|
||||||
|
request: {
|
||||||
|
name: request.name,
|
||||||
|
amount: request.amount,
|
||||||
|
lock_duration_secs: request.lockDurationSecs,
|
||||||
|
description: request.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const vault = transformVault(data);
|
||||||
|
|
||||||
|
// Add to list
|
||||||
|
set((state) => ({
|
||||||
|
vaults: [vault, ...state.vaults],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh summary
|
||||||
|
get().fetchSummary();
|
||||||
|
|
||||||
|
return vault;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create vault';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Withdraw from an unlocked vault
|
||||||
|
withdrawVault: async (vaultId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const txId = await invoke<string>('vault_withdraw', { vaultId });
|
||||||
|
|
||||||
|
// Update vault status
|
||||||
|
set((state) => ({
|
||||||
|
vaults: state.vaults.map((v) =>
|
||||||
|
v.id === vaultId ? { ...v, status: 'withdrawn' as const, txId } : v
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh summary
|
||||||
|
get().fetchSummary();
|
||||||
|
|
||||||
|
return txId;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to withdraw from vault';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a withdrawn vault
|
||||||
|
deleteVault: async (vaultId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('vault_delete', { vaultId });
|
||||||
|
|
||||||
|
// Remove from list
|
||||||
|
set((state) => ({
|
||||||
|
vaults: state.vaults.filter((v) => v.id !== vaultId),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Refresh summary
|
||||||
|
get().fetchSummary();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to delete vault';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get time remaining for a vault
|
||||||
|
getTimeRemaining: async (vaultId) => {
|
||||||
|
const remaining = await invoke<number>('vault_time_remaining', { vaultId });
|
||||||
|
return remaining;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format remaining time for display
|
||||||
|
*/
|
||||||
|
export function formatTimeRemaining(seconds: number): string {
|
||||||
|
if (seconds <= 0) return 'Ready to withdraw';
|
||||||
|
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${secs}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color class
|
||||||
|
*/
|
||||||
|
export function getVaultStatusColor(status: Vault['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'locked':
|
||||||
|
return 'text-yellow-400';
|
||||||
|
case 'unlocked':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'withdrawn':
|
||||||
|
return 'text-gray-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset lock durations for quick selection
|
||||||
|
*/
|
||||||
|
export const LOCK_DURATION_PRESETS = [
|
||||||
|
{ label: '1 hour', value: 3600 },
|
||||||
|
{ label: '24 hours', value: 86400 },
|
||||||
|
{ label: '1 week', value: 604800 },
|
||||||
|
{ label: '1 month', value: 2592000 },
|
||||||
|
{ label: '3 months', value: 7776000 },
|
||||||
|
{ label: '6 months', value: 15552000 },
|
||||||
|
{ label: '1 year', value: 31536000 },
|
||||||
|
];
|
||||||
289
apps/desktop-wallet/src/store/walletManager.ts
Normal file
289
apps/desktop-wallet/src/store/walletManager.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary info for a wallet
|
||||||
|
*/
|
||||||
|
export interface WalletSummary {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
primaryAddress: string;
|
||||||
|
network: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastAccessed: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active wallet info
|
||||||
|
*/
|
||||||
|
export interface ActiveWalletInfo {
|
||||||
|
walletId: string | null;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
walletCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create wallet response
|
||||||
|
*/
|
||||||
|
interface CreateWalletResponse {
|
||||||
|
wallet_id: string;
|
||||||
|
mnemonic: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import wallet response
|
||||||
|
*/
|
||||||
|
interface ImportWalletResponse {
|
||||||
|
wallet_id: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletManagerState {
|
||||||
|
// State
|
||||||
|
wallets: WalletSummary[];
|
||||||
|
activeWalletId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions (sync)
|
||||||
|
setWallets: (wallets: WalletSummary[]) => void;
|
||||||
|
setActiveWalletId: (id: string | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// Actions (async - Tauri commands)
|
||||||
|
loadWallets: () => Promise<void>;
|
||||||
|
createWallet: (label: string, password: string, testnet?: boolean) => Promise<{ mnemonic: string; address: string }>;
|
||||||
|
importWallet: (label: string, mnemonic: string, password: string, testnet?: boolean) => Promise<string>;
|
||||||
|
switchWallet: (walletId: string) => Promise<void>;
|
||||||
|
renameWallet: (walletId: string, newLabel: string) => Promise<void>;
|
||||||
|
deleteWallet: (walletId: string) => Promise<void>;
|
||||||
|
getActiveWalletInfo: () => Promise<ActiveWalletInfo>;
|
||||||
|
unlockActiveWallet: (password: string) => Promise<boolean>;
|
||||||
|
lockActiveWallet: () => Promise<void>;
|
||||||
|
migrateLegacyWallet: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform snake_case keys to camelCase
|
||||||
|
*/
|
||||||
|
function transformWalletSummary(raw: Record<string, unknown>): WalletSummary {
|
||||||
|
return {
|
||||||
|
id: raw.id as string,
|
||||||
|
label: raw.label as string,
|
||||||
|
primaryAddress: raw.primary_address as string,
|
||||||
|
network: raw.network as string,
|
||||||
|
createdAt: raw.created_at as number,
|
||||||
|
lastAccessed: raw.last_accessed as number,
|
||||||
|
isActive: raw.is_active as boolean | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWalletManagerStore = create<WalletManagerState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
wallets: [],
|
||||||
|
activeWalletId: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Sync setters
|
||||||
|
setWallets: (wallets) => set({ wallets }),
|
||||||
|
setActiveWalletId: (id) => set({ activeWalletId: id }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
// Load all wallets
|
||||||
|
loadWallets: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const rawWallets = await invoke<Record<string, unknown>[]>('wallets_list');
|
||||||
|
const wallets = rawWallets.map(transformWalletSummary);
|
||||||
|
const activeWallet = wallets.find(w => w.isActive);
|
||||||
|
set({
|
||||||
|
wallets,
|
||||||
|
activeWalletId: activeWallet?.id ?? null,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load wallets';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new wallet
|
||||||
|
createWallet: async (label, password, testnet = true) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await invoke<CreateWalletResponse>('wallets_create', {
|
||||||
|
label,
|
||||||
|
password,
|
||||||
|
testnet,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload wallets list
|
||||||
|
await get().loadWallets();
|
||||||
|
|
||||||
|
return {
|
||||||
|
mnemonic: response.mnemonic,
|
||||||
|
address: response.address,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import a wallet from mnemonic
|
||||||
|
importWallet: async (label, mnemonic, password, testnet = true) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await invoke<ImportWalletResponse>('wallets_import', {
|
||||||
|
label,
|
||||||
|
mnemonic,
|
||||||
|
password,
|
||||||
|
testnet,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload wallets list
|
||||||
|
await get().loadWallets();
|
||||||
|
|
||||||
|
return response.address;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to import wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Switch to a different wallet
|
||||||
|
switchWallet: async (walletId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('wallets_switch', { walletId });
|
||||||
|
set({ activeWalletId: walletId });
|
||||||
|
|
||||||
|
// Reload wallets list to update active status
|
||||||
|
await get().loadWallets();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to switch wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rename a wallet
|
||||||
|
renameWallet: async (walletId, newLabel) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('wallets_rename', { walletId, newLabel });
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => ({
|
||||||
|
wallets: state.wallets.map((w) =>
|
||||||
|
w.id === walletId ? { ...w, label: newLabel } : w
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to rename wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a wallet
|
||||||
|
deleteWallet: async (walletId) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('wallets_delete', { walletId });
|
||||||
|
|
||||||
|
// Remove from local state
|
||||||
|
set((state) => ({
|
||||||
|
wallets: state.wallets.filter((w) => w.id !== walletId),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to delete wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get active wallet info
|
||||||
|
getActiveWalletInfo: async () => {
|
||||||
|
try {
|
||||||
|
const info = await invoke<{
|
||||||
|
wallet_id: string | null;
|
||||||
|
is_unlocked: boolean;
|
||||||
|
wallet_count: number;
|
||||||
|
}>('wallets_get_active');
|
||||||
|
|
||||||
|
return {
|
||||||
|
walletId: info.wallet_id,
|
||||||
|
isUnlocked: info.is_unlocked,
|
||||||
|
walletCount: info.wallet_count,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to get active wallet info';
|
||||||
|
set({ error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unlock active wallet
|
||||||
|
unlockActiveWallet: async (password) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const success = await invoke<boolean>('wallets_unlock_active', { password });
|
||||||
|
set({ isLoading: false });
|
||||||
|
return success;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to unlock wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lock active wallet
|
||||||
|
lockActiveWallet: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('wallets_lock_active');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to lock wallet';
|
||||||
|
set({ error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Migrate legacy wallet
|
||||||
|
migrateLegacyWallet: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const walletId = await invoke<string | null>('wallets_migrate_legacy');
|
||||||
|
if (walletId) {
|
||||||
|
await get().loadWallets();
|
||||||
|
}
|
||||||
|
set({ isLoading: false });
|
||||||
|
return walletId;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to migrate wallet';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-wallet-manager',
|
||||||
|
partialize: (state) => ({
|
||||||
|
activeWalletId: state.activeWalletId,
|
||||||
|
// Don't persist the full wallets list, always load fresh from backend
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
273
apps/desktop-wallet/src/store/watchOnly.ts
Normal file
273
apps/desktop-wallet/src/store/watchOnly.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch-only address entry
|
||||||
|
*/
|
||||||
|
export interface WatchOnlyAddress {
|
||||||
|
address: string;
|
||||||
|
label: string;
|
||||||
|
network: string;
|
||||||
|
addedAt: number;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
cachedBalance: number | null;
|
||||||
|
balanceUpdatedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchOnlyState {
|
||||||
|
// State
|
||||||
|
addresses: WatchOnlyAddress[];
|
||||||
|
tags: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
selectedTag: string | null;
|
||||||
|
|
||||||
|
// Actions (sync)
|
||||||
|
setAddresses: (addresses: WatchOnlyAddress[]) => void;
|
||||||
|
setTags: (tags: string[]) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
setSelectedTag: (tag: string | null) => void;
|
||||||
|
|
||||||
|
// Actions (async - Tauri commands)
|
||||||
|
loadAddresses: () => Promise<void>;
|
||||||
|
loadTags: () => Promise<void>;
|
||||||
|
addAddress: (
|
||||||
|
address: string,
|
||||||
|
label: string,
|
||||||
|
notes?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<WatchOnlyAddress>;
|
||||||
|
updateAddress: (
|
||||||
|
address: string,
|
||||||
|
label?: string,
|
||||||
|
notes?: string,
|
||||||
|
tags?: string[]
|
||||||
|
) => Promise<WatchOnlyAddress>;
|
||||||
|
removeAddress: (address: string) => Promise<void>;
|
||||||
|
refreshBalance: (address: string) => Promise<number>;
|
||||||
|
refreshAllBalances: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform snake_case keys to camelCase
|
||||||
|
*/
|
||||||
|
function transformAddress(raw: Record<string, unknown>): WatchOnlyAddress {
|
||||||
|
return {
|
||||||
|
address: raw.address as string,
|
||||||
|
label: raw.label as string,
|
||||||
|
network: raw.network as string,
|
||||||
|
addedAt: raw.added_at as number,
|
||||||
|
notes: raw.notes as string | null,
|
||||||
|
tags: (raw.tags as string[]) || [],
|
||||||
|
cachedBalance: raw.cached_balance as number | null,
|
||||||
|
balanceUpdatedAt: raw.balance_updated_at as number | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWatchOnlyStore = create<WatchOnlyState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
addresses: [],
|
||||||
|
tags: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
selectedTag: null,
|
||||||
|
|
||||||
|
// Sync setters
|
||||||
|
setAddresses: (addresses) => set({ addresses }),
|
||||||
|
setTags: (tags) => set({ tags }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
setSelectedTag: (tag) => set({ selectedTag: tag }),
|
||||||
|
|
||||||
|
// Load all addresses
|
||||||
|
loadAddresses: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const rawAddresses = await invoke<Record<string, unknown>[]>('watch_only_list');
|
||||||
|
const addresses = rawAddresses.map(transformAddress);
|
||||||
|
set({ addresses, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load watch-only addresses';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load all tags
|
||||||
|
loadTags: async () => {
|
||||||
|
try {
|
||||||
|
const tags = await invoke<string[]>('watch_only_get_tags');
|
||||||
|
set({ tags });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load tags';
|
||||||
|
set({ error: message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a watch-only address
|
||||||
|
addAddress: async (address, label, notes, tags) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const raw = await invoke<Record<string, unknown>>('watch_only_add', {
|
||||||
|
request: {
|
||||||
|
address,
|
||||||
|
label,
|
||||||
|
notes: notes || null,
|
||||||
|
tags: tags || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newAddress = transformAddress(raw);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => ({
|
||||||
|
addresses: [newAddress, ...state.addresses],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reload tags
|
||||||
|
get().loadTags();
|
||||||
|
|
||||||
|
return newAddress;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to add watch-only address';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update a watch-only address
|
||||||
|
updateAddress: async (address, label, notes, tags) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const raw = await invoke<Record<string, unknown>>('watch_only_update', {
|
||||||
|
request: {
|
||||||
|
address,
|
||||||
|
label: label || null,
|
||||||
|
notes: notes !== undefined ? notes : null,
|
||||||
|
tags: tags || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = transformAddress(raw);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => ({
|
||||||
|
addresses: state.addresses.map((a) =>
|
||||||
|
a.address === address ? updated : a
|
||||||
|
),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reload tags
|
||||||
|
get().loadTags();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update watch-only address';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a watch-only address
|
||||||
|
removeAddress: async (address) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
await invoke('watch_only_remove', { address });
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => ({
|
||||||
|
addresses: state.addresses.filter((a) => a.address !== address),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reload tags
|
||||||
|
get().loadTags();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to remove watch-only address';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh balance for a specific address
|
||||||
|
refreshBalance: async (address) => {
|
||||||
|
try {
|
||||||
|
const balance = await invoke<number>('watch_only_refresh_balance', { address });
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set((state) => ({
|
||||||
|
addresses: state.addresses.map((a) =>
|
||||||
|
a.address === address
|
||||||
|
? {
|
||||||
|
...a,
|
||||||
|
cachedBalance: balance,
|
||||||
|
balanceUpdatedAt: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
: a
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return balance;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to refresh balance';
|
||||||
|
set({ error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh all balances
|
||||||
|
refreshAllBalances: async () => {
|
||||||
|
const { addresses } = get();
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const addr of addresses) {
|
||||||
|
try {
|
||||||
|
await get().refreshBalance(addr.address);
|
||||||
|
} catch {
|
||||||
|
// Continue with other addresses even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-watch-only',
|
||||||
|
partialize: (state) => ({
|
||||||
|
selectedTag: state.selectedTag,
|
||||||
|
// Don't persist addresses, always load fresh from backend
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtered addresses by selected tag
|
||||||
|
*/
|
||||||
|
export function useFilteredWatchOnlyAddresses(): WatchOnlyAddress[] {
|
||||||
|
const { addresses, selectedTag } = useWatchOnlyStore();
|
||||||
|
|
||||||
|
if (!selectedTag) {
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses.filter((a) => a.tags.includes(selectedTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format balance for display
|
||||||
|
*/
|
||||||
|
export function formatWatchOnlyBalance(balance: number | null): string {
|
||||||
|
if (balance === null) {
|
||||||
|
return '---';
|
||||||
|
}
|
||||||
|
return `${(balance / 100_000_000).toFixed(8)} SYN`;
|
||||||
|
}
|
||||||
160
apps/desktop-wallet/src/store/yield.ts
Normal file
160
apps/desktop-wallet/src/store/yield.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface YieldOpportunity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
protocol: string;
|
||||||
|
asset: string;
|
||||||
|
apy: number;
|
||||||
|
tvl: number;
|
||||||
|
riskLevel: 'low' | 'medium' | 'high';
|
||||||
|
lockupPeriodDays: number;
|
||||||
|
minDeposit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YieldPosition {
|
||||||
|
id: string;
|
||||||
|
opportunityId: string;
|
||||||
|
depositedAmount: number;
|
||||||
|
currentValue: number;
|
||||||
|
rewardsEarned: number;
|
||||||
|
autoCompound: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
lastCompoundAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YieldState {
|
||||||
|
opportunities: YieldOpportunity[];
|
||||||
|
positions: YieldPosition[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YieldActions {
|
||||||
|
loadOpportunities: () => Promise<void>;
|
||||||
|
deposit: (opportunityId: string, amount: number, autoCompound: boolean) => Promise<YieldPosition>;
|
||||||
|
withdraw: (positionId: string, amount?: number) => Promise<YieldPosition>;
|
||||||
|
listPositions: () => Promise<void>;
|
||||||
|
compound: (positionId: string) => Promise<YieldPosition>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform snake_case to camelCase
|
||||||
|
const transformOpportunity = (data: Record<string, unknown>): YieldOpportunity => ({
|
||||||
|
id: data.id as string,
|
||||||
|
name: data.name as string,
|
||||||
|
protocol: data.protocol as string,
|
||||||
|
asset: data.asset as string,
|
||||||
|
apy: data.apy as number,
|
||||||
|
tvl: data.tvl as number,
|
||||||
|
riskLevel: data.risk_level as YieldOpportunity['riskLevel'],
|
||||||
|
lockupPeriodDays: data.lockup_period_days as number,
|
||||||
|
minDeposit: data.min_deposit as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformPosition = (data: Record<string, unknown>): YieldPosition => ({
|
||||||
|
id: data.id as string,
|
||||||
|
opportunityId: data.opportunity_id as string,
|
||||||
|
depositedAmount: data.deposited_amount as number,
|
||||||
|
currentValue: data.current_value as number,
|
||||||
|
rewardsEarned: data.rewards_earned as number,
|
||||||
|
autoCompound: data.auto_compound as boolean,
|
||||||
|
createdAt: data.created_at as number,
|
||||||
|
lastCompoundAt: data.last_compound_at as number | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useYieldStore = create<YieldState & YieldActions>((set) => ({
|
||||||
|
opportunities: [],
|
||||||
|
positions: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
loadOpportunities: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('yield_get_opportunities');
|
||||||
|
const opportunities = data.map(transformOpportunity);
|
||||||
|
set({ opportunities, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deposit: async (opportunityId: string, amount: number, autoCompound: boolean) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('yield_deposit', {
|
||||||
|
opportunityId,
|
||||||
|
amount,
|
||||||
|
autoCompound,
|
||||||
|
});
|
||||||
|
const position = transformPosition(data);
|
||||||
|
set((state) => ({
|
||||||
|
positions: [position, ...state.positions],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return position;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
withdraw: async (positionId: string, amount?: number) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>>('yield_withdraw', {
|
||||||
|
positionId,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
const position = transformPosition(data);
|
||||||
|
set((state) => ({
|
||||||
|
positions: state.positions.map((p) => (p.id === positionId ? position : p)),
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
return position;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listPositions: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
const data = await invoke<Record<string, unknown>[]>('yield_list_positions');
|
||||||
|
const positions = data.map(transformPosition);
|
||||||
|
set({ positions, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error), isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
compound: async (positionId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<Record<string, unknown>>('yield_compound', { positionId });
|
||||||
|
const position = transformPosition(data);
|
||||||
|
set((state) => ({
|
||||||
|
positions: state.positions.map((p) => (p.id === positionId ? position : p)),
|
||||||
|
}));
|
||||||
|
return position;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: String(error) });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to format APY
|
||||||
|
export const formatAPY = (apy: number): string => `${apy.toFixed(2)}%`;
|
||||||
|
|
||||||
|
// Helper to format TVL
|
||||||
|
export const formatTVL = (sompi: number): string => {
|
||||||
|
const syn = sompi / 100_000_000;
|
||||||
|
if (syn >= 1_000_000) return `${(syn / 1_000_000).toFixed(2)}M SYN`;
|
||||||
|
if (syn >= 1_000) return `${(syn / 1_000).toFixed(2)}K SYN`;
|
||||||
|
return `${syn.toFixed(2)} SYN`;
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,8 @@ import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
// Support custom port via environment variable (default 1420 for Tauri, 19420 for Docker)
|
||||||
|
const port = parseInt(process.env.VITE_DEV_SERVER_PORT || '1420', 10);
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
@ -12,14 +14,14 @@ export default defineConfig({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: 'ws',
|
protocol: 'ws',
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
port: port + 1,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue