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}")]
|
||||
ContractError(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ mod keychain;
|
|||
mod node;
|
||||
mod rpc_client;
|
||||
mod wallet;
|
||||
mod wallet_manager;
|
||||
mod watch_only;
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
|
|
@ -114,10 +116,18 @@ pub fn run() {
|
|||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.setup(|app| {
|
||||
// Initialize wallet state
|
||||
// Initialize wallet state (legacy, for backwards compatibility)
|
||||
let wallet_state = wallet::WalletState::new();
|
||||
app.manage(wallet_state);
|
||||
|
||||
// Initialize wallet manager (multi-wallet support)
|
||||
let wallet_manager = wallet_manager::WalletManager::new();
|
||||
app.manage(wallet_manager);
|
||||
|
||||
// Initialize watch-only address manager
|
||||
let watch_only_manager = watch_only::WatchOnlyManager::new();
|
||||
app.manage(watch_only_manager);
|
||||
|
||||
// Initialize node manager with app handle for events
|
||||
let node_manager = std::sync::Arc::new(
|
||||
node::NodeManager::with_app_handle(app.handle().clone())
|
||||
|
|
@ -171,13 +181,32 @@ pub fn run() {
|
|||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Wallet management
|
||||
// Wallet management (legacy single-wallet)
|
||||
commands::create_wallet,
|
||||
commands::import_wallet,
|
||||
commands::unlock_wallet,
|
||||
commands::lock_wallet,
|
||||
commands::get_wallet_info,
|
||||
commands::export_mnemonic,
|
||||
// Multi-wallet management
|
||||
commands::wallets_list,
|
||||
commands::wallets_create,
|
||||
commands::wallets_import,
|
||||
commands::wallets_switch,
|
||||
commands::wallets_rename,
|
||||
commands::wallets_delete,
|
||||
commands::wallets_get_active,
|
||||
commands::wallets_unlock_active,
|
||||
commands::wallets_lock_active,
|
||||
commands::wallets_migrate_legacy,
|
||||
// Watch-only addresses
|
||||
commands::watch_only_list,
|
||||
commands::watch_only_add,
|
||||
commands::watch_only_update,
|
||||
commands::watch_only_remove,
|
||||
commands::watch_only_get,
|
||||
commands::watch_only_refresh_balance,
|
||||
commands::watch_only_get_tags,
|
||||
// Addresses & UTXOs
|
||||
commands::get_addresses,
|
||||
commands::generate_address,
|
||||
|
|
@ -188,6 +217,44 @@ pub fn run() {
|
|||
commands::sign_transaction,
|
||||
commands::broadcast_transaction,
|
||||
commands::get_transaction_history,
|
||||
// Batch transactions
|
||||
commands::create_batch_transaction,
|
||||
// Fee market analytics
|
||||
commands::fee_get_mempool_stats,
|
||||
commands::fee_get_recommendations,
|
||||
commands::fee_get_analytics,
|
||||
commands::fee_get_history,
|
||||
commands::fee_calculate,
|
||||
// Time-locked vaults
|
||||
commands::vault_list,
|
||||
commands::vault_get_summary,
|
||||
commands::vault_create,
|
||||
commands::vault_get,
|
||||
commands::vault_withdraw,
|
||||
commands::vault_delete,
|
||||
commands::vault_time_remaining,
|
||||
// Social recovery
|
||||
commands::recovery_get_config,
|
||||
commands::recovery_setup,
|
||||
commands::recovery_add_guardian,
|
||||
commands::recovery_remove_guardian,
|
||||
commands::recovery_list_guardians,
|
||||
commands::recovery_update_threshold,
|
||||
commands::recovery_initiate,
|
||||
commands::recovery_approve,
|
||||
commands::recovery_get_request,
|
||||
commands::recovery_list_requests,
|
||||
commands::recovery_cancel,
|
||||
commands::recovery_disable,
|
||||
// Decoy wallets
|
||||
commands::decoy_is_enabled,
|
||||
commands::decoy_setup,
|
||||
commands::decoy_create,
|
||||
commands::decoy_list,
|
||||
commands::decoy_update_balance,
|
||||
commands::decoy_delete,
|
||||
commands::decoy_check_duress,
|
||||
commands::decoy_disable,
|
||||
// Network (legacy)
|
||||
commands::connect_node,
|
||||
commands::disconnect_node,
|
||||
|
|
@ -338,6 +405,58 @@ pub fn run() {
|
|||
commands::zk_deposit,
|
||||
commands::zk_withdraw,
|
||||
commands::zk_transfer,
|
||||
// Transaction Mixer (Phase 8)
|
||||
commands::mixer_get_denominations,
|
||||
commands::mixer_get_pool_status,
|
||||
commands::mixer_create_request,
|
||||
commands::mixer_get_request,
|
||||
commands::mixer_list_requests,
|
||||
commands::mixer_cancel_request,
|
||||
// Limit Orders (Phase 9)
|
||||
commands::limit_order_create,
|
||||
commands::limit_order_get,
|
||||
commands::limit_order_list,
|
||||
commands::limit_order_cancel,
|
||||
commands::limit_order_get_orderbook,
|
||||
// Yield Aggregator (Phase 10)
|
||||
commands::yield_get_opportunities,
|
||||
commands::yield_deposit,
|
||||
commands::yield_withdraw,
|
||||
commands::yield_list_positions,
|
||||
commands::yield_compound,
|
||||
// Portfolio Analytics (Phase 11)
|
||||
commands::portfolio_get_summary,
|
||||
commands::portfolio_get_holdings,
|
||||
commands::portfolio_get_tax_report,
|
||||
commands::portfolio_export_tax_report,
|
||||
commands::portfolio_get_history,
|
||||
// Price Alerts (Phase 12)
|
||||
commands::alert_create,
|
||||
commands::alert_list,
|
||||
commands::alert_delete,
|
||||
commands::alert_toggle,
|
||||
// CLI Mode (Phase 13)
|
||||
commands::cli_execute,
|
||||
commands::cli_get_history,
|
||||
// RPC Profiles (Phase 14)
|
||||
commands::rpc_profile_create,
|
||||
commands::rpc_profile_list,
|
||||
commands::rpc_profile_set_active,
|
||||
commands::rpc_profile_delete,
|
||||
commands::rpc_profile_test,
|
||||
// Transaction Builder (Phase 15)
|
||||
commands::tx_builder_create,
|
||||
commands::tx_builder_sign,
|
||||
commands::tx_builder_broadcast,
|
||||
commands::tx_builder_decode,
|
||||
// Plugin System (Phase 16)
|
||||
commands::plugin_list_available,
|
||||
commands::plugin_list_installed,
|
||||
commands::plugin_install,
|
||||
commands::plugin_uninstall,
|
||||
commands::plugin_toggle,
|
||||
commands::plugin_get_settings,
|
||||
commands::plugin_set_settings,
|
||||
// Updates
|
||||
check_update,
|
||||
install_update,
|
||||
|
|
|
|||
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 QRScannerPage from './pages/QRScanner/QRScannerPage';
|
||||
import BackupPage from './pages/Backup/BackupPage';
|
||||
import WatchOnlyDashboard from './pages/WatchOnly/WatchOnlyDashboard';
|
||||
import BatchSendDashboard from './pages/BatchSend/BatchSendDashboard';
|
||||
import FeeAnalyticsDashboard from './pages/FeeAnalytics/FeeAnalyticsDashboard';
|
||||
import VaultsDashboard from './pages/Vaults/VaultsDashboard';
|
||||
import RecoveryDashboard from './pages/Recovery/RecoveryDashboard';
|
||||
import DecoyDashboard from './pages/Decoy/DecoyDashboard';
|
||||
import MixerDashboard from './pages/Mixer/MixerDashboard';
|
||||
import LimitOrdersDashboard from './pages/LimitOrders/LimitOrdersDashboard';
|
||||
import YieldDashboard from './pages/Yield/YieldDashboard';
|
||||
import PortfolioDashboard from './pages/Portfolio/PortfolioDashboard';
|
||||
import AlertsDashboard from './pages/Alerts/AlertsDashboard';
|
||||
import CliDashboard from './pages/CLI/CliDashboard';
|
||||
import RpcProfilesDashboard from './pages/RpcProfiles/RpcProfilesDashboard';
|
||||
import TxBuilderDashboard from './pages/TxBuilder/TxBuilderDashboard';
|
||||
import PluginsDashboard from './pages/Plugins/PluginsDashboard';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isUnlocked } = useWalletStore();
|
||||
|
|
@ -336,6 +351,126 @@ function App() {
|
|||
</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>
|
||||
</Routes>
|
||||
</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,
|
||||
Vote,
|
||||
Layers,
|
||||
Eye,
|
||||
ListPlus,
|
||||
Activity,
|
||||
Vault,
|
||||
ShieldCheck,
|
||||
// Phase 7-16 icons
|
||||
UserX,
|
||||
Shuffle,
|
||||
ArrowUpDown,
|
||||
TrendingUp,
|
||||
PieChart,
|
||||
Bell,
|
||||
Terminal,
|
||||
Wrench,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
import { useNodeStore } from '../store/node';
|
||||
import { useMiningStore, formatHashrate } from '../store/mining';
|
||||
import { NotificationsBell } from './NotificationsPanel';
|
||||
import { WalletSelector } from './WalletSelector';
|
||||
import { CreateWalletModal } from './CreateWalletModal';
|
||||
import { ImportWalletModal } from './ImportWalletModal';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/send', label: 'Send', icon: Send },
|
||||
{ to: '/batch-send', label: 'Batch Send', icon: ListPlus },
|
||||
{ to: '/receive', label: 'Receive', icon: Download },
|
||||
{ to: '/history', label: 'History', icon: History },
|
||||
];
|
||||
|
|
@ -75,6 +95,20 @@ const governanceNavItems = [
|
|||
];
|
||||
|
||||
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: '/addressbook', label: 'Address Book', icon: Users },
|
||||
{ to: '/multisig', label: 'Multi-sig', icon: Shield },
|
||||
|
|
@ -89,6 +123,10 @@ export default function Layout() {
|
|||
const nodeStatus = useNodeStore((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 () => {
|
||||
await lockWallet();
|
||||
};
|
||||
|
|
@ -137,6 +175,14 @@ export default function Layout() {
|
|||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<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 */}
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<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">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Multi-wallet Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateWalletModal onClose={() => setShowCreateModal(false)} />
|
||||
)}
|
||||
{showImportModal && (
|
||||
<ImportWalletModal onClose={() => setShowImportModal(false)} />
|
||||
)}
|
||||
</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,
|
||||
TypeWriter,
|
||||
} 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 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 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';
|
||||
|
||||
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/
|
||||
export default defineConfig({
|
||||
|
|
@ -12,14 +14,14 @@ export default defineConfig({
|
|||
clearScreen: false,
|
||||
|
||||
server: {
|
||||
port: 1420,
|
||||
port,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
port: port + 1,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue