expansion of desktop wallet features.

This commit is contained in:
Gulshan Yadav 2026-02-02 14:30:07 +05:30
parent 81347ab15d
commit c32622f34f
46 changed files with 11879 additions and 4 deletions

View 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"]

View 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

View file

@ -68,6 +68,9 @@ pub enum Error {
#[error("Contract error: {0}")] #[error("Contract error: {0}")]
ContractError(String), ContractError(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Internal(String), Internal(String),
} }

View file

@ -15,6 +15,8 @@ mod keychain;
mod node; mod node;
mod rpc_client; mod rpc_client;
mod wallet; mod wallet;
mod wallet_manager;
mod watch_only;
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
@ -114,10 +116,18 @@ pub fn run() {
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.setup(|app| { .setup(|app| {
// Initialize wallet state // Initialize wallet state (legacy, for backwards compatibility)
let wallet_state = wallet::WalletState::new(); let wallet_state = wallet::WalletState::new();
app.manage(wallet_state); app.manage(wallet_state);
// Initialize wallet manager (multi-wallet support)
let wallet_manager = wallet_manager::WalletManager::new();
app.manage(wallet_manager);
// Initialize watch-only address manager
let watch_only_manager = watch_only::WatchOnlyManager::new();
app.manage(watch_only_manager);
// Initialize node manager with app handle for events // Initialize node manager with app handle for events
let node_manager = std::sync::Arc::new( let node_manager = std::sync::Arc::new(
node::NodeManager::with_app_handle(app.handle().clone()) node::NodeManager::with_app_handle(app.handle().clone())
@ -171,13 +181,32 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
// Wallet management // Wallet management (legacy single-wallet)
commands::create_wallet, commands::create_wallet,
commands::import_wallet, commands::import_wallet,
commands::unlock_wallet, commands::unlock_wallet,
commands::lock_wallet, commands::lock_wallet,
commands::get_wallet_info, commands::get_wallet_info,
commands::export_mnemonic, commands::export_mnemonic,
// Multi-wallet management
commands::wallets_list,
commands::wallets_create,
commands::wallets_import,
commands::wallets_switch,
commands::wallets_rename,
commands::wallets_delete,
commands::wallets_get_active,
commands::wallets_unlock_active,
commands::wallets_lock_active,
commands::wallets_migrate_legacy,
// Watch-only addresses
commands::watch_only_list,
commands::watch_only_add,
commands::watch_only_update,
commands::watch_only_remove,
commands::watch_only_get,
commands::watch_only_refresh_balance,
commands::watch_only_get_tags,
// Addresses & UTXOs // Addresses & UTXOs
commands::get_addresses, commands::get_addresses,
commands::generate_address, commands::generate_address,
@ -188,6 +217,44 @@ pub fn run() {
commands::sign_transaction, commands::sign_transaction,
commands::broadcast_transaction, commands::broadcast_transaction,
commands::get_transaction_history, commands::get_transaction_history,
// Batch transactions
commands::create_batch_transaction,
// Fee market analytics
commands::fee_get_mempool_stats,
commands::fee_get_recommendations,
commands::fee_get_analytics,
commands::fee_get_history,
commands::fee_calculate,
// Time-locked vaults
commands::vault_list,
commands::vault_get_summary,
commands::vault_create,
commands::vault_get,
commands::vault_withdraw,
commands::vault_delete,
commands::vault_time_remaining,
// Social recovery
commands::recovery_get_config,
commands::recovery_setup,
commands::recovery_add_guardian,
commands::recovery_remove_guardian,
commands::recovery_list_guardians,
commands::recovery_update_threshold,
commands::recovery_initiate,
commands::recovery_approve,
commands::recovery_get_request,
commands::recovery_list_requests,
commands::recovery_cancel,
commands::recovery_disable,
// Decoy wallets
commands::decoy_is_enabled,
commands::decoy_setup,
commands::decoy_create,
commands::decoy_list,
commands::decoy_update_balance,
commands::decoy_delete,
commands::decoy_check_duress,
commands::decoy_disable,
// Network (legacy) // Network (legacy)
commands::connect_node, commands::connect_node,
commands::disconnect_node, commands::disconnect_node,
@ -338,6 +405,58 @@ pub fn run() {
commands::zk_deposit, commands::zk_deposit,
commands::zk_withdraw, commands::zk_withdraw,
commands::zk_transfer, commands::zk_transfer,
// Transaction Mixer (Phase 8)
commands::mixer_get_denominations,
commands::mixer_get_pool_status,
commands::mixer_create_request,
commands::mixer_get_request,
commands::mixer_list_requests,
commands::mixer_cancel_request,
// Limit Orders (Phase 9)
commands::limit_order_create,
commands::limit_order_get,
commands::limit_order_list,
commands::limit_order_cancel,
commands::limit_order_get_orderbook,
// Yield Aggregator (Phase 10)
commands::yield_get_opportunities,
commands::yield_deposit,
commands::yield_withdraw,
commands::yield_list_positions,
commands::yield_compound,
// Portfolio Analytics (Phase 11)
commands::portfolio_get_summary,
commands::portfolio_get_holdings,
commands::portfolio_get_tax_report,
commands::portfolio_export_tax_report,
commands::portfolio_get_history,
// Price Alerts (Phase 12)
commands::alert_create,
commands::alert_list,
commands::alert_delete,
commands::alert_toggle,
// CLI Mode (Phase 13)
commands::cli_execute,
commands::cli_get_history,
// RPC Profiles (Phase 14)
commands::rpc_profile_create,
commands::rpc_profile_list,
commands::rpc_profile_set_active,
commands::rpc_profile_delete,
commands::rpc_profile_test,
// Transaction Builder (Phase 15)
commands::tx_builder_create,
commands::tx_builder_sign,
commands::tx_builder_broadcast,
commands::tx_builder_decode,
// Plugin System (Phase 16)
commands::plugin_list_available,
commands::plugin_list_installed,
commands::plugin_install,
commands::plugin_uninstall,
commands::plugin_toggle,
commands::plugin_get_settings,
commands::plugin_set_settings,
// Updates // Updates
check_update, check_update,
install_update, install_update,

View 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)
}

View 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)
}

View file

@ -59,6 +59,21 @@ import MultisigDashboard from './pages/Multisig/MultisigDashboard';
import HardwareWalletPage from './pages/Hardware/HardwareWalletPage'; import HardwareWalletPage from './pages/Hardware/HardwareWalletPage';
import QRScannerPage from './pages/QRScanner/QRScannerPage'; import QRScannerPage from './pages/QRScanner/QRScannerPage';
import BackupPage from './pages/Backup/BackupPage'; import BackupPage from './pages/Backup/BackupPage';
import WatchOnlyDashboard from './pages/WatchOnly/WatchOnlyDashboard';
import BatchSendDashboard from './pages/BatchSend/BatchSendDashboard';
import FeeAnalyticsDashboard from './pages/FeeAnalytics/FeeAnalyticsDashboard';
import VaultsDashboard from './pages/Vaults/VaultsDashboard';
import RecoveryDashboard from './pages/Recovery/RecoveryDashboard';
import DecoyDashboard from './pages/Decoy/DecoyDashboard';
import MixerDashboard from './pages/Mixer/MixerDashboard';
import LimitOrdersDashboard from './pages/LimitOrders/LimitOrdersDashboard';
import YieldDashboard from './pages/Yield/YieldDashboard';
import PortfolioDashboard from './pages/Portfolio/PortfolioDashboard';
import AlertsDashboard from './pages/Alerts/AlertsDashboard';
import CliDashboard from './pages/CLI/CliDashboard';
import RpcProfilesDashboard from './pages/RpcProfiles/RpcProfilesDashboard';
import TxBuilderDashboard from './pages/TxBuilder/TxBuilderDashboard';
import PluginsDashboard from './pages/Plugins/PluginsDashboard';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isUnlocked } = useWalletStore(); const { isUnlocked } = useWalletStore();
@ -336,6 +351,126 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/watch-only"
element={
<ProtectedRoute>
<WatchOnlyDashboard />
</ProtectedRoute>
}
/>
<Route
path="/batch-send"
element={
<ProtectedRoute>
<BatchSendDashboard />
</ProtectedRoute>
}
/>
<Route
path="/fee-analytics"
element={
<ProtectedRoute>
<FeeAnalyticsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/vaults"
element={
<ProtectedRoute>
<VaultsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/recovery"
element={
<ProtectedRoute>
<RecoveryDashboard />
</ProtectedRoute>
}
/>
<Route
path="/decoy"
element={
<ProtectedRoute>
<DecoyDashboard />
</ProtectedRoute>
}
/>
<Route
path="/mixer"
element={
<ProtectedRoute>
<MixerDashboard />
</ProtectedRoute>
}
/>
<Route
path="/limit-orders"
element={
<ProtectedRoute>
<LimitOrdersDashboard />
</ProtectedRoute>
}
/>
<Route
path="/yield"
element={
<ProtectedRoute>
<YieldDashboard />
</ProtectedRoute>
}
/>
<Route
path="/portfolio"
element={
<ProtectedRoute>
<PortfolioDashboard />
</ProtectedRoute>
}
/>
<Route
path="/alerts"
element={
<ProtectedRoute>
<AlertsDashboard />
</ProtectedRoute>
}
/>
<Route
path="/cli"
element={
<ProtectedRoute>
<CliDashboard />
</ProtectedRoute>
}
/>
<Route
path="/rpc-profiles"
element={
<ProtectedRoute>
<RpcProfilesDashboard />
</ProtectedRoute>
}
/>
<Route
path="/tx-builder"
element={
<ProtectedRoute>
<TxBuilderDashboard />
</ProtectedRoute>
}
/>
<Route
path="/plugins"
element={
<ProtectedRoute>
<PluginsDashboard />
</ProtectedRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
</main> </main>

View 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>
);
}

View 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&apos;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>
);
}

View file

@ -30,15 +30,35 @@ import {
GitBranch, GitBranch,
Vote, Vote,
Layers, Layers,
Eye,
ListPlus,
Activity,
Vault,
ShieldCheck,
// Phase 7-16 icons
UserX,
Shuffle,
ArrowUpDown,
TrendingUp,
PieChart,
Bell,
Terminal,
Wrench,
Puzzle,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react';
import { useWalletStore } from '../store/wallet'; import { useWalletStore } from '../store/wallet';
import { useNodeStore } from '../store/node'; import { useNodeStore } from '../store/node';
import { useMiningStore, formatHashrate } from '../store/mining'; import { useMiningStore, formatHashrate } from '../store/mining';
import { NotificationsBell } from './NotificationsPanel'; import { NotificationsBell } from './NotificationsPanel';
import { WalletSelector } from './WalletSelector';
import { CreateWalletModal } from './CreateWalletModal';
import { ImportWalletModal } from './ImportWalletModal';
const navItems = [ const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/send', label: 'Send', icon: Send }, { to: '/send', label: 'Send', icon: Send },
{ to: '/batch-send', label: 'Batch Send', icon: ListPlus },
{ to: '/receive', label: 'Receive', icon: Download }, { to: '/receive', label: 'Receive', icon: Download },
{ to: '/history', label: 'History', icon: History }, { to: '/history', label: 'History', icon: History },
]; ];
@ -75,6 +95,20 @@ const governanceNavItems = [
]; ];
const toolsNavItems = [ const toolsNavItems = [
{ to: '/watch-only', label: 'Watch-Only', icon: Eye },
{ to: '/fee-analytics', label: 'Fee Analytics', icon: Activity },
{ to: '/vaults', label: 'Time Vaults', icon: Vault },
{ to: '/recovery', label: 'Recovery', icon: ShieldCheck },
{ to: '/decoy', label: 'Decoy Wallets', icon: UserX },
{ to: '/mixer', label: 'Mixer', icon: Shuffle },
{ to: '/limit-orders', label: 'Limit Orders', icon: ArrowUpDown },
{ to: '/yield', label: 'Yield', icon: TrendingUp },
{ to: '/portfolio', label: 'Portfolio', icon: PieChart },
{ to: '/alerts', label: 'Alerts', icon: Bell },
{ to: '/cli', label: 'CLI', icon: Terminal },
{ to: '/rpc-profiles', label: 'RPC Profiles', icon: Server },
{ to: '/tx-builder', label: 'Tx Builder', icon: Wrench },
{ to: '/plugins', label: 'Plugins', icon: Puzzle },
{ to: '/dapps', label: 'DApps', icon: Globe }, { to: '/dapps', label: 'DApps', icon: Globe },
{ to: '/addressbook', label: 'Address Book', icon: Users }, { to: '/addressbook', label: 'Address Book', icon: Users },
{ to: '/multisig', label: 'Multi-sig', icon: Shield }, { to: '/multisig', label: 'Multi-sig', icon: Shield },
@ -89,6 +123,10 @@ export default function Layout() {
const nodeStatus = useNodeStore((state) => state.status); const nodeStatus = useNodeStore((state) => state.status);
const miningStatus = useMiningStore((state) => state.status); const miningStatus = useMiningStore((state) => state.status);
// Modal state for multi-wallet management
const [showCreateModal, setShowCreateModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const handleLock = async () => { const handleLock = async () => {
await lockWallet(); await lockWallet();
}; };
@ -137,6 +175,14 @@ export default function Layout() {
<div className="flex h-full"> <div className="flex h-full">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col"> <aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
{/* Wallet Selector */}
<div className="p-3 border-b border-gray-800">
<WalletSelector
onCreateWallet={() => setShowCreateModal(true)}
onImportWallet={() => setShowImportModal(true)}
/>
</div>
{/* Balance display */} {/* Balance display */}
<div className="p-4 border-b border-gray-800"> <div className="p-4 border-b border-gray-800">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p> <p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Balance</p>
@ -209,6 +255,14 @@ export default function Layout() {
<main className="flex-1 overflow-auto p-6"> <main className="flex-1 overflow-auto p-6">
<Outlet /> <Outlet />
</main> </main>
{/* Multi-wallet Modals */}
{showCreateModal && (
<CreateWalletModal onClose={() => setShowCreateModal(false)} />
)}
{showImportModal && (
<ImportWalletModal onClose={() => setShowImportModal(false)} />
)}
</div> </div>
); );
} }

View 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>
);
}

View file

@ -30,3 +30,8 @@ export {
CountUp, CountUp,
TypeWriter, TypeWriter,
} from './Animations'; } from './Animations';
// Multi-Wallet Components
export { WalletSelector } from './WalletSelector';
export { CreateWalletModal } from './CreateWalletModal';
export { ImportWalletModal } from './ImportWalletModal';

View 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>
);
}

View 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&#10;synor1abc...,10.5,Payment 1&#10;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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View 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"
>
&times;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }),
}));

View 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;
}

View 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 }),
}));

View 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;
}
},
}));

View 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`;
}

View file

@ -2,6 +2,18 @@
export { useWalletStore } from './wallet'; export { useWalletStore } from './wallet';
export type { WalletAddress, Balance, NetworkStatus } from './wallet'; export type { WalletAddress, Balance, NetworkStatus } from './wallet';
// Multi-wallet management
export { useWalletManagerStore } from './walletManager';
export type { WalletSummary, ActiveWalletInfo } from './walletManager';
// Watch-only addresses
export {
useWatchOnlyStore,
useFilteredWatchOnlyAddresses,
formatWatchOnlyBalance,
} from './watchOnly';
export type { WatchOnlyAddress } from './watchOnly';
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node'; export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node'; export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';

View 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 }),
}));

View 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`;
};

View 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 }),
}));

View 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)}%`;
};

View 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';
}
}

View 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 }),
}));

View 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`;
};

View 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 },
];

View 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
}),
}
)
);

View 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`;
}

View 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`;
};

View file

@ -2,6 +2,8 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// Support custom port via environment variable (default 1420 for Tauri, 19420 for Docker)
const port = parseInt(process.env.VITE_DEV_SERVER_PORT || '1420', 10);
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@ -12,14 +14,14 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
server: { server: {
port: 1420, port,
strictPort: true, strictPort: true,
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {
protocol: 'ws', protocol: 'ws',
host, host,
port: 1421, port: port + 1,
} }
: undefined, : undefined,
watch: { watch: {