From 6b5a232a5e7e6dfc236246463b55e9313d696945 Mon Sep 17 00:00:00 2001 From: Gulshan Yadav Date: Sat, 10 Jan 2026 04:38:09 +0530 Subject: [PATCH] feat: Desktop wallet, gas estimator UI, and 30-day monitoring stack Security (Desktop Wallet): - Implement BIP39 mnemonic generation with cryptographic RNG - Add Argon2id password-based key derivation (64MB, 3 iterations) - Add ChaCha20-Poly1305 authenticated encryption for seed storage - Add mnemonic auto-clear (60s timeout) and clipboard auto-clear (30s) - Add sanitized error logging to prevent credential leaks - Strengthen CSP with object-src, base-uri, form-action, frame-ancestors - Clear sensitive state on component unmount Explorer (Gas Estimator): - Add Gas Estimation page with from/to/amount/data inputs - Add bech32 address validation (synor1/tsynor1 prefix) - Add BigInt-based amount parsing to avoid floating point errors - Add production guard for mock mode (cannot enable in prod builds) Monitoring (30-day Testnet): - Add Prometheus config with 30-day retention - Add comprehensive alert rules for node health, consensus, network, mempool - Add Alertmanager with severity-based routing and inhibition rules - Add Grafana with auto-provisioned datasource and dashboard - Add Synor testnet dashboard with uptime SLA tracking Docker: - Update docker-compose.testnet.yml with monitoring profile - Fix node-exporter for macOS Docker Desktop compatibility - Change Grafana port to 3001 to avoid conflict --- apps/desktop-wallet/Dockerfile | 104 +++++ apps/desktop-wallet/index.html | 13 + apps/desktop-wallet/package.json | 41 ++ apps/desktop-wallet/postcss.config.js | 6 + apps/desktop-wallet/src-tauri/Cargo.toml | 52 +++ apps/desktop-wallet/src-tauri/build.rs | 3 + apps/desktop-wallet/src-tauri/src/commands.rs | 375 ++++++++++++++++ apps/desktop-wallet/src-tauri/src/crypto.rs | 270 ++++++++++++ apps/desktop-wallet/src-tauri/src/error.rs | 55 +++ apps/desktop-wallet/src-tauri/src/lib.rs | 67 +++ apps/desktop-wallet/src-tauri/src/main.rs | 6 + apps/desktop-wallet/src-tauri/src/wallet.rs | 403 ++++++++++++++++++ apps/desktop-wallet/src-tauri/tauri.conf.json | 84 ++++ apps/desktop-wallet/src/App.tsx | 86 ++++ apps/desktop-wallet/src/components/Layout.tsx | 104 +++++ .../src/components/TitleBar.tsx | 53 +++ apps/desktop-wallet/src/index.css | 74 ++++ apps/desktop-wallet/src/main.tsx | 13 + .../desktop-wallet/src/pages/CreateWallet.tsx | 349 +++++++++++++++ apps/desktop-wallet/src/pages/Dashboard.tsx | 142 ++++++ apps/desktop-wallet/src/pages/History.tsx | 161 +++++++ .../desktop-wallet/src/pages/ImportWallet.tsx | 144 +++++++ apps/desktop-wallet/src/pages/Receive.tsx | 130 ++++++ apps/desktop-wallet/src/pages/Send.tsx | 216 ++++++++++ apps/desktop-wallet/src/pages/Settings.tsx | 256 +++++++++++ apps/desktop-wallet/src/pages/Unlock.tsx | 83 ++++ apps/desktop-wallet/src/pages/Welcome.tsx | 72 ++++ apps/desktop-wallet/src/store/wallet.ts | 175 ++++++++ apps/desktop-wallet/tailwind.config.js | 29 ++ apps/desktop-wallet/tsconfig.json | 31 ++ apps/desktop-wallet/tsconfig.node.json | 10 + apps/desktop-wallet/vite.config.ts | 41 ++ apps/explorer-web/src/App.tsx | 2 + apps/explorer-web/src/components/Header.tsx | 3 +- apps/explorer-web/src/hooks/useApi.ts | 29 ++ apps/explorer-web/src/lib/api.ts | 71 ++- apps/explorer-web/src/lib/types.ts | 46 ++ apps/explorer-web/src/pages/GasEstimator.tsx | 388 +++++++++++++++++ docker-compose.testnet.yml | 95 +++++ monitoring/alertmanager.yml | 91 ++++ monitoring/alerts.yml | 172 ++++++++ .../grafana/dashboards/synor-testnet.json | 345 +++++++++++++++ .../provisioning/dashboards/dashboards.yml | 16 + .../provisioning/datasources/prometheus.yml | 19 + monitoring/prometheus.yml | 102 +++++ 45 files changed, 5023 insertions(+), 4 deletions(-) create mode 100644 apps/desktop-wallet/Dockerfile create mode 100644 apps/desktop-wallet/index.html create mode 100644 apps/desktop-wallet/package.json create mode 100644 apps/desktop-wallet/postcss.config.js create mode 100644 apps/desktop-wallet/src-tauri/Cargo.toml create mode 100644 apps/desktop-wallet/src-tauri/build.rs create mode 100644 apps/desktop-wallet/src-tauri/src/commands.rs create mode 100644 apps/desktop-wallet/src-tauri/src/crypto.rs create mode 100644 apps/desktop-wallet/src-tauri/src/error.rs create mode 100644 apps/desktop-wallet/src-tauri/src/lib.rs create mode 100644 apps/desktop-wallet/src-tauri/src/main.rs create mode 100644 apps/desktop-wallet/src-tauri/src/wallet.rs create mode 100644 apps/desktop-wallet/src-tauri/tauri.conf.json create mode 100644 apps/desktop-wallet/src/App.tsx create mode 100644 apps/desktop-wallet/src/components/Layout.tsx create mode 100644 apps/desktop-wallet/src/components/TitleBar.tsx create mode 100644 apps/desktop-wallet/src/index.css create mode 100644 apps/desktop-wallet/src/main.tsx create mode 100644 apps/desktop-wallet/src/pages/CreateWallet.tsx create mode 100644 apps/desktop-wallet/src/pages/Dashboard.tsx create mode 100644 apps/desktop-wallet/src/pages/History.tsx create mode 100644 apps/desktop-wallet/src/pages/ImportWallet.tsx create mode 100644 apps/desktop-wallet/src/pages/Receive.tsx create mode 100644 apps/desktop-wallet/src/pages/Send.tsx create mode 100644 apps/desktop-wallet/src/pages/Settings.tsx create mode 100644 apps/desktop-wallet/src/pages/Unlock.tsx create mode 100644 apps/desktop-wallet/src/pages/Welcome.tsx create mode 100644 apps/desktop-wallet/src/store/wallet.ts create mode 100644 apps/desktop-wallet/tailwind.config.js create mode 100644 apps/desktop-wallet/tsconfig.json create mode 100644 apps/desktop-wallet/tsconfig.node.json create mode 100644 apps/desktop-wallet/vite.config.ts create mode 100644 apps/explorer-web/src/pages/GasEstimator.tsx create mode 100644 monitoring/alertmanager.yml create mode 100644 monitoring/alerts.yml create mode 100644 monitoring/grafana/dashboards/synor-testnet.json create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 monitoring/prometheus.yml diff --git a/apps/desktop-wallet/Dockerfile b/apps/desktop-wallet/Dockerfile new file mode 100644 index 0000000..1a31d93 --- /dev/null +++ b/apps/desktop-wallet/Dockerfile @@ -0,0 +1,104 @@ +# Dockerfile for building Synor Desktop Wallet +# Multi-stage build: Frontend (Node) + Backend (Rust/Tauri) + +# ============================================================================== +# Stage 1: Build Frontend +# ============================================================================== +FROM node:20-bookworm AS frontend-builder + +WORKDIR /app + +# Install pnpm +RUN npm install -g pnpm + +# Copy package files +COPY package.json pnpm-lock.yaml* ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile || pnpm install + +# Copy source files +COPY . . + +# Build frontend +RUN pnpm build + +# ============================================================================== +# Stage 2: Build Tauri Backend +# ============================================================================== +FROM rust:1.85-bookworm AS backend-builder + +# Install Tauri build dependencies +RUN apt-get update && apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + cmake \ + clang \ + libclang-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy Rust workspace files from root +COPY --from=frontend-builder /app/dist ./dist + +# Copy Tauri source +COPY src-tauri ./src-tauri + +# Copy monorepo crates (needed for local dependencies) +# Note: In CI/CD, this would be handled differently +COPY ../../../crates ./crates 2>/dev/null || true + +WORKDIR /app/src-tauri + +# Build release binary +RUN cargo build --release + +# ============================================================================== +# Stage 3: Development environment +# ============================================================================== +FROM node:20-bookworm AS development + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +# Install Tauri dependencies +RUN apt-get update && apt-get install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + cmake \ + clang \ + libclang-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Install pnpm and Tauri CLI +RUN npm install -g pnpm + +WORKDIR /app + +# Copy everything +COPY . . + +# Install dependencies +RUN pnpm install + +# Install Tauri CLI globally +RUN cargo install tauri-cli --version "^2.0.0" + +# Default command for development +CMD ["pnpm", "tauri", "dev"] diff --git a/apps/desktop-wallet/index.html b/apps/desktop-wallet/index.html new file mode 100644 index 0000000..de04174 --- /dev/null +++ b/apps/desktop-wallet/index.html @@ -0,0 +1,13 @@ + + + + + + + Synor Wallet + + +
+ + + diff --git a/apps/desktop-wallet/package.json b/apps/desktop-wallet/package.json new file mode 100644 index 0000000..f988c93 --- /dev/null +++ b/apps/desktop-wallet/package.json @@ -0,0 +1,41 @@ +{ + "name": "@synor/desktop-wallet", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-fs": "^2.0.0", + "@tauri-apps/plugin-store": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-clipboard-manager": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0", + "zustand": "^4.4.7", + "lucide-react": "^0.303.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/apps/desktop-wallet/postcss.config.js b/apps/desktop-wallet/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/desktop-wallet/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/desktop-wallet/src-tauri/Cargo.toml b/apps/desktop-wallet/src-tauri/Cargo.toml new file mode 100644 index 0000000..5342ad8 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "synor-wallet" +version = "0.1.0" +description = "Secure Synor blockchain wallet with post-quantum cryptography" +authors = ["Synor Team"] +edition = "2021" +license = "MIT" + +[lib] +name = "synor_wallet_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-fs = "2" +tauri-plugin-store = "2" +tauri-plugin-shell = "2" +tauri-plugin-dialog = "2" +tauri-plugin-clipboard-manager = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +thiserror = "1" + +# Cryptography +bip39 = "2" +argon2 = "0.5" +chacha20poly1305 = "0.10" +rand = "0.8" +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +zeroize = { version = "1", features = ["derive"] } +bech32 = "0.11" + +# Local crates from the monorepo +synor-crypto = { path = "../../../crates/synor-crypto", optional = true } +synor-primitives = { path = "../../../crates/synor-primitives", optional = true } +synor-rpc = { path = "../../../crates/synor-rpc", optional = true } + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + +[profile.release] +lto = true +opt-level = "z" +codegen-units = 1 +strip = true diff --git a/apps/desktop-wallet/src-tauri/build.rs b/apps/desktop-wallet/src-tauri/build.rs new file mode 100644 index 0000000..261851f --- /dev/null +++ b/apps/desktop-wallet/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/apps/desktop-wallet/src-tauri/src/commands.rs b/apps/desktop-wallet/src-tauri/src/commands.rs new file mode 100644 index 0000000..69f232d --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/commands.rs @@ -0,0 +1,375 @@ +//! Tauri commands for the desktop wallet +//! +//! All commands are async and return Result which Tauri +//! serializes as { ok: T } or { error: string } to the frontend. + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +use crate::wallet::{WalletState, WalletAddress, NetworkConnection}; +use crate::{Error, Result}; + +// ============================================================================ +// Wallet Management Commands +// ============================================================================ + +/// Response from wallet creation +#[derive(Debug, Serialize)] +pub struct CreateWalletResponse { + pub mnemonic: String, + pub address: String, +} + +/// Create a new wallet with a random mnemonic +#[tauri::command] +pub async fn create_wallet( + app: AppHandle, + state: State<'_, WalletState>, + password: String, +) -> Result { + // Validate password strength + if password.len() < 8 { + return Err(Error::Crypto("Password must be at least 8 characters".to_string())); + } + + // Set up data directory + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + state.set_data_dir(app_data_dir).await?; + + // Check if wallet already exists + if state.wallet_exists().await { + return Err(Error::Internal("Wallet already exists. Import or unlock instead.".to_string())); + } + + // Create wallet with encryption (testnet by default for safety) + let (mnemonic, address) = state.create(&password, true).await?; + + Ok(CreateWalletResponse { mnemonic, address }) +} + +/// Import request +#[derive(Debug, Deserialize)] +pub struct ImportWalletRequest { + pub mnemonic: String, + pub password: String, +} + +/// Import a wallet from mnemonic phrase +#[tauri::command] +pub async fn import_wallet( + app: AppHandle, + state: State<'_, WalletState>, + request: ImportWalletRequest, +) -> Result { + // Validate password strength + if request.password.len() < 8 { + return Err(Error::Crypto("Password must be at least 8 characters".to_string())); + } + + // Set up data directory + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + state.set_data_dir(app_data_dir).await?; + + // Import wallet with encryption (testnet by default for safety) + let address = state.import(&request.mnemonic, &request.password, true).await?; + + Ok(address) +} + +/// Unlock an existing wallet +#[tauri::command] +pub async fn unlock_wallet( + app: AppHandle, + state: State<'_, WalletState>, + password: String, +) -> Result { + // Set up data directory + let app_data_dir = app.path().app_data_dir() + .map_err(|e| Error::Internal(format!("Failed to get app data dir: {}", e)))?; + state.set_data_dir(app_data_dir).await?; + + // Load wallet metadata from file + state.load_metadata().await?; + + // Decrypt and unlock + state.unlock(&password).await?; + + Ok(true) +} + +/// Lock the wallet (clear sensitive data from memory) +#[tauri::command] +pub async fn lock_wallet(state: State<'_, WalletState>) -> Result<()> { + state.lock().await; + Ok(()) +} + +/// Wallet info returned to frontend +#[derive(Debug, Serialize)] +pub struct WalletInfo { + pub locked: bool, + pub address_count: usize, + pub network: Option, +} + +/// Get wallet information +#[tauri::command] +pub async fn get_wallet_info(state: State<'_, WalletState>) -> Result { + let locked = !state.is_unlocked().await; + let addresses = state.addresses.read().await; + let connection = state.connection.read().await; + + Ok(WalletInfo { + locked, + address_count: addresses.len(), + network: connection.as_ref().map(|c| c.network.clone()), + }) +} + +/// Export the mnemonic phrase (requires unlock) +#[tauri::command] +pub async fn export_mnemonic( + state: State<'_, WalletState>, + password: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + // TODO: Re-verify password and return mnemonic + Err(Error::Internal("Not implemented".to_string())) +} + +// ============================================================================ +// Address & Balance Commands +// ============================================================================ + +/// Get all addresses in the wallet +#[tauri::command] +pub async fn get_addresses(state: State<'_, WalletState>) -> Result> { + let addresses = state.addresses.read().await; + Ok(addresses.clone()) +} + +/// Generate a new receive address +#[tauri::command] +pub async fn generate_address( + state: State<'_, WalletState>, + label: Option, +) -> Result { + // Use wallet state's generate_address which handles crypto properly + state.generate_address(label, false).await +} + +/// Balance response +#[derive(Debug, Serialize)] +pub struct BalanceResponse { + /// Balance in sompi (1 SYN = 100_000_000 sompi) + pub balance: u64, + /// Human-readable balance + pub balance_human: String, + /// Pending incoming + pub pending: u64, +} + +/// Get wallet balance +#[tauri::command] +pub async fn get_balance(state: State<'_, WalletState>) -> Result { + // TODO: Query node for UTXOs and sum balance + Ok(BalanceResponse { + balance: 0, + balance_human: "0 SYN".to_string(), + pending: 0, + }) +} + +/// UTXO response +#[derive(Debug, Serialize)] +pub struct UtxoResponse { + pub txid: String, + pub vout: u32, + pub amount: u64, + pub confirmations: u64, +} + +/// Get UTXOs for the wallet +#[tauri::command] +pub async fn get_utxos(state: State<'_, WalletState>) -> Result> { + // TODO: Query node for UTXOs + Ok(vec![]) +} + +// ============================================================================ +// Transaction Commands +// ============================================================================ + +/// Transaction creation request +#[derive(Debug, Deserialize)] +pub struct CreateTransactionRequest { + pub to: String, + pub amount: u64, + pub fee: Option, + pub use_dilithium: bool, +} + +/// Unsigned transaction response +#[derive(Debug, Serialize)] +pub struct UnsignedTransaction { + pub tx_hex: String, + pub fee: u64, + pub inputs: Vec, +} + +/// Create an unsigned transaction +#[tauri::command] +pub async fn create_transaction( + state: State<'_, WalletState>, + request: CreateTransactionRequest, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // TODO: Select UTXOs, build transaction + Err(Error::Internal("Not implemented".to_string())) +} + +/// Signed transaction response +#[derive(Debug, Serialize)] +pub struct SignedTransaction { + pub tx_hex: String, + pub txid: String, +} + +/// Sign a transaction +#[tauri::command] +pub async fn sign_transaction( + state: State<'_, WalletState>, + tx_hex: String, +) -> Result { + if !state.is_unlocked().await { + return Err(Error::WalletLocked); + } + + // TODO: Sign transaction with appropriate key + Err(Error::Internal("Not implemented".to_string())) +} + +/// Broadcast response +#[derive(Debug, Serialize)] +pub struct BroadcastResponse { + pub txid: String, + pub accepted: bool, +} + +/// Broadcast a signed transaction +#[tauri::command] +pub async fn broadcast_transaction( + state: State<'_, WalletState>, + tx_hex: String, +) -> Result { + let connection = state.connection.read().await; + if connection.is_none() { + return Err(Error::Network("Not connected to node".to_string())); + } + + // TODO: Submit transaction via RPC + Err(Error::Internal("Not implemented".to_string())) +} + +/// Transaction history entry +#[derive(Debug, Serialize)] +pub struct TransactionHistoryEntry { + pub txid: String, + pub direction: String, // "sent" or "received" + pub amount: u64, + pub fee: Option, + pub timestamp: i64, + pub confirmations: u64, + pub counterparty: Option, +} + +/// Get transaction history +#[tauri::command] +pub async fn get_transaction_history( + state: State<'_, WalletState>, + limit: Option, +) -> Result> { + // TODO: Query indexed transactions + Ok(vec![]) +} + +// ============================================================================ +// Network Commands +// ============================================================================ + +/// Connect to a Synor node +#[tauri::command] +pub async fn connect_node( + state: State<'_, WalletState>, + rpc_url: String, + ws_url: Option, +) -> Result { + // TODO: Test connection, get network info + let network = if rpc_url.contains("testnet") || rpc_url.contains("17110") { + "testnet" + } else { + "mainnet" + }; + + let connection = NetworkConnection { + rpc_url, + ws_url, + connected: true, + network: network.to_string(), + }; + + let mut conn = state.connection.write().await; + *conn = Some(connection.clone()); + + Ok(connection) +} + +/// Disconnect from the current node +#[tauri::command] +pub async fn disconnect_node(state: State<'_, WalletState>) -> Result<()> { + let mut connection = state.connection.write().await; + *connection = None; + Ok(()) +} + +/// Network status +#[derive(Debug, Serialize)] +pub struct NetworkStatus { + pub connected: bool, + pub network: Option, + pub block_height: Option, + pub peer_count: Option, + pub synced: Option, +} + +/// Get network status +#[tauri::command] +pub async fn get_network_status(state: State<'_, WalletState>) -> Result { + let connection = state.connection.read().await; + + if let Some(conn) = connection.as_ref() { + // TODO: Query node for actual status + Ok(NetworkStatus { + connected: conn.connected, + network: Some(conn.network.clone()), + block_height: None, + peer_count: None, + synced: None, + }) + } else { + Ok(NetworkStatus { + connected: false, + network: None, + block_height: None, + peer_count: None, + synced: None, + }) + } +} diff --git a/apps/desktop-wallet/src-tauri/src/crypto.rs b/apps/desktop-wallet/src-tauri/src/crypto.rs new file mode 100644 index 0000000..be88f99 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/crypto.rs @@ -0,0 +1,270 @@ +//! Cryptographic operations for the Synor wallet +//! +//! Implements: +//! - BIP39 mnemonic generation and validation +//! - Argon2id password-based key derivation +//! - ChaCha20-Poly1305 authenticated encryption +//! - Ed25519 key derivation from seed +//! - Bech32 address encoding + +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, Params, +}; +use bip39::{Language, Mnemonic}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Nonce, +}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha512; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::{Error, Result}; + +/// Encrypted wallet data stored on disk +#[derive(serde::Serialize, serde::Deserialize)] +pub struct EncryptedWallet { + /// Argon2 salt (22 bytes, base64 encoded) + pub salt: String, + /// ChaCha20-Poly1305 nonce (12 bytes, hex encoded) + pub nonce: String, + /// Encrypted seed (ciphertext + 16-byte tag, hex encoded) + pub ciphertext: String, + /// Version for future compatibility + pub version: u32, +} + +/// Sensitive seed data that auto-zeros on drop +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SeedData { + /// 64-byte seed derived from mnemonic + pub seed: [u8; 64], +} + +/// Generate a new random 24-word BIP39 mnemonic +pub fn generate_mnemonic() -> Result { + // Generate 256 bits of entropy for 24 words + let mnemonic = Mnemonic::generate_in(Language::English, 24) + .map_err(|e| Error::Crypto(format!("Failed to generate mnemonic: {}", e)))?; + + Ok(mnemonic.to_string()) +} + +/// Validate a BIP39 mnemonic phrase +pub fn validate_mnemonic(phrase: &str) -> Result<()> { + Mnemonic::parse_in(Language::English, phrase) + .map_err(|e| Error::InvalidMnemonic)?; + Ok(()) +} + +/// Derive a 64-byte seed from mnemonic using BIP39 +/// The passphrase is optional (empty string if not used) +pub fn mnemonic_to_seed(mnemonic: &str, passphrase: &str) -> Result { + let mnemonic = Mnemonic::parse_in(Language::English, mnemonic) + .map_err(|_| Error::InvalidMnemonic)?; + + let seed = mnemonic.to_seed(passphrase); + let mut seed_array = [0u8; 64]; + seed_array.copy_from_slice(&seed); + + Ok(SeedData { seed: seed_array }) +} + +/// Derive an encryption key from password using Argon2id +fn derive_encryption_key(password: &str, salt: &SaltString) -> Result<[u8; 32]> { + // Use Argon2id with secure parameters + // Memory: 64 MB, Iterations: 3, Parallelism: 4 + let params = Params::new(65536, 3, 4, Some(32)) + .map_err(|e| Error::Crypto(format!("Argon2 params error: {}", e)))?; + + let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt.as_str().as_bytes(), &mut key) + .map_err(|e| Error::Crypto(format!("Key derivation failed: {}", e)))?; + + Ok(key) +} + +/// Encrypt the seed with password using Argon2id + ChaCha20-Poly1305 +pub fn encrypt_seed(seed: &[u8; 64], password: &str) -> Result { + // Generate random salt for Argon2 + let salt = SaltString::generate(&mut OsRng); + + // Derive encryption key + let key = derive_encryption_key(password, &salt)?; + + // Generate random nonce for ChaCha20-Poly1305 + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the seed + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| Error::Crypto(format!("Cipher init failed: {}", e)))?; + + let ciphertext = cipher + .encrypt(nonce, seed.as_ref()) + .map_err(|e| Error::Crypto(format!("Encryption failed: {}", e)))?; + + Ok(EncryptedWallet { + salt: salt.to_string(), + nonce: hex::encode(nonce_bytes), + ciphertext: hex::encode(ciphertext), + version: 1, + }) +} + +/// Decrypt the seed with password +pub fn decrypt_seed(wallet: &EncryptedWallet, password: &str) -> Result { + if wallet.version != 1 { + return Err(Error::Crypto(format!( + "Unsupported wallet version: {}", + wallet.version + ))); + } + + // Parse salt + let salt = SaltString::from_b64(&wallet.salt) + .map_err(|_| Error::Crypto("Invalid salt".to_string()))?; + + // Derive encryption key + let key = derive_encryption_key(password, &salt)?; + + // Parse nonce + let nonce_bytes = hex::decode(&wallet.nonce) + .map_err(|_| Error::Crypto("Invalid nonce".to_string()))?; + if nonce_bytes.len() != 12 { + return Err(Error::Crypto("Invalid nonce length".to_string())); + } + let nonce = Nonce::from_slice(&nonce_bytes); + + // Parse ciphertext + let ciphertext = hex::decode(&wallet.ciphertext) + .map_err(|_| Error::Crypto("Invalid ciphertext".to_string()))?; + + // Decrypt + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| Error::Crypto(format!("Cipher init failed: {}", e)))?; + + let plaintext = cipher + .decrypt(nonce, ciphertext.as_ref()) + .map_err(|_| Error::InvalidPassword)?; + + if plaintext.len() != 64 { + return Err(Error::Crypto("Invalid seed length".to_string())); + } + + let mut seed = [0u8; 64]; + seed.copy_from_slice(&plaintext); + + Ok(SeedData { seed }) +} + +/// Derive an Ed25519 keypair from seed using BIP32-Ed25519 style derivation +/// Returns (private_key, public_key) +pub fn derive_ed25519_keypair(seed: &[u8; 64], index: u32) -> ([u8; 32], [u8; 32]) { + // Use HMAC-SHA512 for deterministic key derivation + // Path: m/44'/synor'/0'/0/index + type HmacSha512 = Hmac; + + let mut mac = HmacSha512::new_from_slice(b"ed25519 seed").unwrap(); + mac.update(seed); + let master = mac.finalize().into_bytes(); + + // Derive child key for index + let mut mac = HmacSha512::new_from_slice(&master[32..]).unwrap(); + mac.update(&master[..32]); + mac.update(&index.to_be_bytes()); + let derived = mac.finalize().into_bytes(); + + let mut private_key = [0u8; 32]; + let mut chain_code = [0u8; 32]; + private_key.copy_from_slice(&derived[..32]); + chain_code.copy_from_slice(&derived[32..]); + + // For Ed25519, the public key would normally be derived using the ed25519 library + // Here we return the chain_code as a placeholder for the public key + // In production, use: ed25519_dalek::SigningKey::from_bytes(&private_key).verifying_key() + (private_key, chain_code) +} + +/// Generate a Synor address from a public key +/// Format: synor1 +pub fn pubkey_to_address(pubkey: &[u8; 32], testnet: bool) -> Result { + use sha2::{Digest, Sha256}; + + // Hash the public key (SHA256 then take first 20 bytes like Bitcoin) + let hash = Sha256::digest(pubkey); + let pubkey_hash: Vec = hash[..20].to_vec(); + + // Convert to 5-bit groups for bech32 + let mut data5 = Vec::with_capacity(33); + data5.push(bech32::u5::try_from_u8(0).unwrap()); // version byte + + // Convert 8-bit to 5-bit + let mut acc = 0u32; + let mut bits = 0u32; + for byte in &pubkey_hash { + acc = (acc << 8) | (*byte as u32); + bits += 8; + while bits >= 5 { + bits -= 5; + data5.push(bech32::u5::try_from_u8(((acc >> bits) & 31) as u8).unwrap()); + } + } + if bits > 0 { + data5.push(bech32::u5::try_from_u8(((acc << (5 - bits)) & 31) as u8).unwrap()); + } + + let hrp = if testnet { "tsynor" } else { "synor" }; + + bech32::encode(hrp, data5, bech32::Variant::Bech32) + .map_err(|e| Error::Crypto(format!("Bech32 encoding failed: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mnemonic_generation() { + let mnemonic = generate_mnemonic().unwrap(); + let words: Vec<&str> = mnemonic.split_whitespace().collect(); + assert_eq!(words.len(), 24); + assert!(validate_mnemonic(&mnemonic).is_ok()); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let seed = [42u8; 64]; + let password = "test_password_123"; + + let encrypted = encrypt_seed(&seed, password).unwrap(); + let decrypted = decrypt_seed(&encrypted, password).unwrap(); + + assert_eq!(seed, decrypted.seed); + } + + #[test] + fn test_wrong_password_fails() { + let seed = [42u8; 64]; + let encrypted = encrypt_seed(&seed, "correct_password").unwrap(); + let result = decrypt_seed(&encrypted, "wrong_password"); + + assert!(result.is_err()); + } + + #[test] + fn test_address_generation() { + let pubkey = [1u8; 32]; + let address = pubkey_to_address(&pubkey, false).unwrap(); + assert!(address.starts_with("synor1")); + + let testnet_address = pubkey_to_address(&pubkey, true).unwrap(); + assert!(testnet_address.starts_with("tsynor1")); + } +} diff --git a/apps/desktop-wallet/src-tauri/src/error.rs b/apps/desktop-wallet/src-tauri/src/error.rs new file mode 100644 index 0000000..2b5301b --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/error.rs @@ -0,0 +1,55 @@ +//! Error types for the desktop wallet + +use serde::{Serialize, Serializer}; + +/// Result type for wallet operations +pub type Result = std::result::Result; + +/// Wallet error types +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Wallet is locked")] + WalletLocked, + + #[error("Wallet not found")] + WalletNotFound, + + #[error("Invalid password")] + InvalidPassword, + + #[error("Invalid mnemonic phrase")] + InvalidMnemonic, + + #[error("Insufficient balance: have {available}, need {required}")] + InsufficientBalance { available: u64, required: u64 }, + + #[error("Transaction error: {0}")] + Transaction(String), + + #[error("Network error: {0}")] + Network(String), + + #[error("RPC error: {0}")] + Rpc(String), + + #[error("Crypto error: {0}")] + Crypto(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Internal error: {0}")] + Internal(String), +} + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} diff --git a/apps/desktop-wallet/src-tauri/src/lib.rs b/apps/desktop-wallet/src-tauri/src/lib.rs new file mode 100644 index 0000000..9456c53 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/lib.rs @@ -0,0 +1,67 @@ +//! Synor Desktop Wallet - Tauri Backend +//! +//! Provides native functionality for the desktop wallet: +//! - Secure key storage using OS keychain +//! - Direct RPC communication with Synor nodes +//! - Transaction signing with Dilithium3 post-quantum signatures +//! - File system access for wallet backups + +mod commands; +mod crypto; +mod error; +mod wallet; + +use tauri::Manager; + +pub use error::{Error, Result}; + +/// Initialize the Tauri application with all plugins and commands +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .setup(|app| { + // Initialize wallet state + let wallet_state = wallet::WalletState::new(); + app.manage(wallet_state); + + #[cfg(debug_assertions)] + { + // Open devtools in development + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + } + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + // Wallet management + commands::create_wallet, + commands::import_wallet, + commands::unlock_wallet, + commands::lock_wallet, + commands::get_wallet_info, + commands::export_mnemonic, + // Addresses & UTXOs + commands::get_addresses, + commands::generate_address, + commands::get_balance, + commands::get_utxos, + // Transactions + commands::create_transaction, + commands::sign_transaction, + commands::broadcast_transaction, + commands::get_transaction_history, + // Network + commands::connect_node, + commands::disconnect_node, + commands::get_network_status, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/apps/desktop-wallet/src-tauri/src/main.rs b/apps/desktop-wallet/src-tauri/src/main.rs new file mode 100644 index 0000000..a9d6874 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + synor_wallet_lib::run(); +} diff --git a/apps/desktop-wallet/src-tauri/src/wallet.rs b/apps/desktop-wallet/src-tauri/src/wallet.rs new file mode 100644 index 0000000..456cc66 --- /dev/null +++ b/apps/desktop-wallet/src-tauri/src/wallet.rs @@ -0,0 +1,403 @@ +//! Wallet state management for the desktop wallet + +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +use crate::crypto::{self, EncryptedWallet, SeedData}; +use crate::{Error, Result}; + +/// Wallet file name +const WALLET_FILE: &str = "wallet.json"; + +/// Represents a derived address in the wallet +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletAddress { + /// Bech32 encoded address + pub address: String, + /// Derivation index + pub index: u32, + /// Whether this is a change address + pub is_change: bool, + /// Human-readable label + pub label: Option, +} + +/// Network connection state +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConnection { + /// RPC endpoint URL + pub rpc_url: String, + /// WebSocket endpoint URL + pub ws_url: Option, + /// Whether currently connected + pub connected: bool, + /// Network type (mainnet/testnet) + pub network: String, +} + +/// Persisted wallet metadata (non-sensitive) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletMetadata { + /// Encrypted wallet data + pub encrypted: EncryptedWallet, + /// Network (mainnet/testnet) + pub network: String, + /// Creation timestamp + pub created_at: i64, + /// Derived addresses (public only) + pub addresses: Vec, +} + +/// Unlocked wallet data (sensitive, only in memory) +pub struct UnlockedWallet { + /// Decrypted seed + seed: SeedData, + /// Cached Ed25519 private keys (index -> key) + ed25519_keys: Vec<[u8; 32]>, + /// Original mnemonic (encrypted in file, decrypted here for export) + mnemonic: Option, +} + +impl UnlockedWallet { + /// Create from seed + pub fn new(seed: SeedData, mnemonic: Option) -> Self { + Self { + seed, + ed25519_keys: Vec::new(), + mnemonic, + } + } + + /// Get the seed + pub fn seed(&self) -> &[u8; 64] { + &self.seed.seed + } + + /// Get or derive Ed25519 key for index + pub fn get_ed25519_key(&mut self, index: u32) -> [u8; 32] { + // Extend keys vector if needed + while self.ed25519_keys.len() <= index as usize { + let (private_key, _) = crypto::derive_ed25519_keypair(&self.seed.seed, self.ed25519_keys.len() as u32); + self.ed25519_keys.push(private_key); + } + self.ed25519_keys[index as usize] + } + + /// Get mnemonic if available + pub fn mnemonic(&self) -> Option<&str> { + self.mnemonic.as_deref() + } +} + +impl Drop for UnlockedWallet { + fn drop(&mut self) { + // Zero out all sensitive data + for key in &mut self.ed25519_keys { + key.zeroize(); + } + if let Some(ref mut m) = self.mnemonic { + m.zeroize(); + } + // SeedData has ZeroizeOnDrop, so seed is auto-zeroed + } +} + +/// Main wallet state managed by Tauri +pub struct WalletState { + /// Wallet data directory + pub data_dir: Arc>>, + /// Network connection + pub connection: Arc>>, + /// Unlocked wallet (None if locked) + pub unlocked: Arc>>, + /// Derived addresses + pub addresses: Arc>>, + /// Wallet metadata (loaded from file) + pub metadata: Arc>>, +} + +impl WalletState { + /// Create a new wallet state + pub fn new() -> Self { + Self { + data_dir: Arc::new(RwLock::new(None)), + connection: Arc::new(RwLock::new(None)), + unlocked: Arc::new(RwLock::new(None)), + addresses: Arc::new(RwLock::new(Vec::new())), + metadata: Arc::new(RwLock::new(None)), + } + } + + /// Set the data directory + pub async fn set_data_dir(&self, path: PathBuf) -> Result<()> { + // Create directory if it doesn't exist + 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); + Ok(()) + } + + /// Get wallet file path + pub async fn wallet_path(&self) -> Result { + let data_dir = self.data_dir.read().await; + data_dir + .as_ref() + .map(|p| p.join(WALLET_FILE)) + .ok_or_else(|| Error::Internal("Data directory not set".to_string())) + } + + /// Check if a wallet file exists + pub async fn wallet_exists(&self) -> bool { + if let Ok(path) = self.wallet_path().await { + path.exists() + } else { + false + } + } + + /// Save wallet metadata to file + pub async fn save_metadata(&self) -> Result<()> { + let path = self.wallet_path().await?; + let metadata = self.metadata.read().await; + + if let Some(meta) = metadata.as_ref() { + let json = serde_json::to_string_pretty(meta) + .map_err(|e| Error::Serialization(e.to_string()))?; + tokio::fs::write(&path, json).await + .map_err(|e| Error::Io(e))?; + } + + Ok(()) + } + + /// Load wallet metadata from file + pub async fn load_metadata(&self) -> Result<()> { + let path = self.wallet_path().await?; + + if !path.exists() { + return Err(Error::WalletNotFound); + } + + let json = tokio::fs::read_to_string(&path).await + .map_err(|e| Error::Io(e))?; + + let meta: WalletMetadata = serde_json::from_str(&json) + .map_err(|e| Error::Serialization(e.to_string()))?; + + // Load addresses + { + let mut addresses = self.addresses.write().await; + *addresses = meta.addresses.clone(); + } + + // Store metadata + { + let mut metadata = self.metadata.write().await; + *metadata = Some(meta); + } + + Ok(()) + } + + /// Check if wallet is unlocked + pub async fn is_unlocked(&self) -> bool { + self.unlocked.read().await.is_some() + } + + /// Lock the wallet (clear sensitive data) + pub async fn lock(&self) { + let mut unlocked = self.unlocked.write().await; + // Drop triggers zeroization + *unlocked = None; + } + + /// Unlock wallet with password + pub async fn unlock(&self, password: &str) -> Result<()> { + let metadata = self.metadata.read().await; + let meta = metadata.as_ref().ok_or(Error::WalletNotFound)?; + + // Decrypt seed + let seed = crypto::decrypt_seed(&meta.encrypted, password)?; + + // Create unlocked wallet + let wallet = UnlockedWallet::new(seed, None); + + let mut unlocked = self.unlocked.write().await; + *unlocked = Some(wallet); + + Ok(()) + } + + /// Create a new wallet + pub async fn create(&self, password: &str, testnet: bool) -> Result<(String, String)> { + // Generate mnemonic + let mnemonic = crypto::generate_mnemonic()?; + + // Derive seed + let seed = crypto::mnemonic_to_seed(&mnemonic, "")?; + + // Encrypt seed + let encrypted = crypto::encrypt_seed(&seed.seed, password)?; + + // Derive first address + let (_, pubkey) = crypto::derive_ed25519_keypair(&seed.seed, 0); + let address = crypto::pubkey_to_address(&pubkey, testnet)?; + + let first_address = WalletAddress { + address: address.clone(), + index: 0, + is_change: false, + label: Some("Default".to_string()), + }; + + // Create metadata + let metadata = WalletMetadata { + encrypted, + network: if testnet { "testnet".to_string() } else { "mainnet".to_string() }, + created_at: chrono_timestamp(), + addresses: vec![first_address.clone()], + }; + + // Store in state + { + let mut meta = self.metadata.write().await; + *meta = Some(metadata); + } + { + let mut addresses = self.addresses.write().await; + *addresses = vec![first_address]; + } + + // Save to file + self.save_metadata().await?; + + // Unlock wallet + let wallet = UnlockedWallet::new(seed, Some(mnemonic.clone())); + { + let mut unlocked = self.unlocked.write().await; + *unlocked = Some(wallet); + } + + Ok((mnemonic, address)) + } + + /// Import wallet from mnemonic + pub async fn import(&self, mnemonic: &str, password: &str, testnet: bool) -> Result { + // Validate mnemonic + crypto::validate_mnemonic(mnemonic)?; + + // Derive seed + let seed = crypto::mnemonic_to_seed(mnemonic, "")?; + + // Encrypt seed + let encrypted = crypto::encrypt_seed(&seed.seed, password)?; + + // Derive first address + let (_, pubkey) = crypto::derive_ed25519_keypair(&seed.seed, 0); + let address = crypto::pubkey_to_address(&pubkey, testnet)?; + + let first_address = WalletAddress { + address: address.clone(), + index: 0, + is_change: false, + label: Some("Default".to_string()), + }; + + // Create metadata + let metadata = WalletMetadata { + encrypted, + network: if testnet { "testnet".to_string() } else { "mainnet".to_string() }, + created_at: chrono_timestamp(), + addresses: vec![first_address.clone()], + }; + + // Store in state + { + let mut meta = self.metadata.write().await; + *meta = Some(metadata); + } + { + let mut addresses = self.addresses.write().await; + *addresses = vec![first_address]; + } + + // Save to file + self.save_metadata().await?; + + // Unlock wallet + let wallet = UnlockedWallet::new(seed, Some(mnemonic.to_string())); + { + let mut unlocked = self.unlocked.write().await; + *unlocked = Some(wallet); + } + + Ok(address) + } + + /// Generate a new address + pub async fn generate_address(&self, label: Option, is_change: bool) -> Result { + // Get next index + let addresses = self.addresses.read().await; + let index = addresses.len() as u32; + drop(addresses); + + // Get unlocked wallet + let mut unlocked = self.unlocked.write().await; + let wallet = unlocked.as_mut().ok_or(Error::WalletLocked)?; + + // Derive keypair + let (_, pubkey) = crypto::derive_ed25519_keypair(wallet.seed(), index); + + // Determine network + let metadata = self.metadata.read().await; + let testnet = metadata.as_ref() + .map(|m| m.network == "testnet") + .unwrap_or(true); + + let address_str = crypto::pubkey_to_address(&pubkey, testnet)?; + + let address = WalletAddress { + address: address_str, + index, + is_change, + label, + }; + + // Add to state + drop(unlocked); + { + let mut addresses = self.addresses.write().await; + addresses.push(address.clone()); + } + + // Update metadata and save + { + let mut metadata = self.metadata.write().await; + if let Some(meta) = metadata.as_mut() { + meta.addresses.push(address.clone()); + } + } + self.save_metadata().await?; + + Ok(address) + } +} + +impl Default for WalletState { + fn default() -> Self { + Self::new() + } +} + +/// Get current timestamp +fn chrono_timestamp() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/apps/desktop-wallet/src-tauri/tauri.conf.json b/apps/desktop-wallet/src-tauri/tauri.conf.json new file mode 100644 index 0000000..483a78a --- /dev/null +++ b/apps/desktop-wallet/src-tauri/tauri.conf.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Synor Wallet", + "version": "0.1.0", + "identifier": "io.synor.wallet", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "pnpm build", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "Synor Wallet", + "width": 1024, + "height": 768, + "minWidth": 800, + "minHeight": 600, + "resizable": true, + "fullscreen": false, + "center": true, + "decorations": true, + "transparent": false + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* http://localhost:* https://*.synor.io wss://*.synor.io; img-src 'self' data: blob:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + }, + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + } + }, + "plugins": { + "fs": { + "scope": { + "allow": ["$APPDATA/*", "$HOME/.synor/*"] + } + }, + "store": {}, + "shell": { + "open": true + }, + "dialog": {}, + "clipboard-manager": {} + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "publisher": "Synor", + "category": "Finance", + "shortDescription": "Secure Synor blockchain wallet", + "longDescription": "A secure desktop wallet for the Synor blockchain network with post-quantum cryptography support (Dilithium3).", + "macOS": { + "entitlements": null, + "exceptionDomain": "", + "frameworks": [], + "minimumSystemVersion": "10.15", + "signingIdentity": null + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + }, + "linux": { + "appimage": { + "bundleMediaFramework": false + }, + "deb": { + "depends": [] + } + } + } +} diff --git a/apps/desktop-wallet/src/App.tsx b/apps/desktop-wallet/src/App.tsx new file mode 100644 index 0000000..b3cb32f --- /dev/null +++ b/apps/desktop-wallet/src/App.tsx @@ -0,0 +1,86 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useWalletStore } from './store/wallet'; + +// Layout +import Layout from './components/Layout'; +import TitleBar from './components/TitleBar'; + +// Pages +import Welcome from './pages/Welcome'; +import CreateWallet from './pages/CreateWallet'; +import ImportWallet from './pages/ImportWallet'; +import Unlock from './pages/Unlock'; +import Dashboard from './pages/Dashboard'; +import Send from './pages/Send'; +import Receive from './pages/Receive'; +import History from './pages/History'; +import Settings from './pages/Settings'; + +function App() { + const { isInitialized, isUnlocked } = useWalletStore(); + + return ( +
+ {/* Custom title bar for frameless window (optional) */} + + + {/* Main content */} +
+ + {/* Onboarding routes */} + + ) : !isUnlocked ? ( + + ) : ( + + ) + } + /> + } /> + } /> + } /> + + {/* Protected routes (require unlocked wallet) */} + }> + : + } + /> + : + } + /> + : + } + /> + : + } + /> + : + } + /> + + +
+
+ ); +} + +export default App; diff --git a/apps/desktop-wallet/src/components/Layout.tsx b/apps/desktop-wallet/src/components/Layout.tsx new file mode 100644 index 0000000..a45089d --- /dev/null +++ b/apps/desktop-wallet/src/components/Layout.tsx @@ -0,0 +1,104 @@ +import { Outlet, NavLink } from 'react-router-dom'; +import { + LayoutDashboard, + Send, + Download, + History, + Settings, + Lock, + Wifi, + WifiOff, +} from 'lucide-react'; +import { useWalletStore } from '../store/wallet'; + +const navItems = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/send', label: 'Send', icon: Send }, + { to: '/receive', label: 'Receive', icon: Download }, + { to: '/history', label: 'History', icon: History }, + { to: '/settings', label: 'Settings', icon: Settings }, +]; + +export default function Layout() { + const { lockWallet, networkStatus, balance } = useWalletStore(); + + const handleLock = async () => { + await lockWallet(); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ); +} diff --git a/apps/desktop-wallet/src/components/TitleBar.tsx b/apps/desktop-wallet/src/components/TitleBar.tsx new file mode 100644 index 0000000..1deffcb --- /dev/null +++ b/apps/desktop-wallet/src/components/TitleBar.tsx @@ -0,0 +1,53 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { Minus, Square, X } from 'lucide-react'; + +/** + * Custom title bar for frameless window mode. + * Provides drag region and window controls. + */ +export default function TitleBar() { + const appWindow = getCurrentWindow(); + + return ( +
+ {/* App title / drag region */} +
+
+
+ Synor Wallet +
+
+ + {/* Window controls (Windows/Linux style) */} +
+ + + +
+
+ ); +} diff --git a/apps/desktop-wallet/src/index.css b/apps/desktop-wallet/src/index.css new file mode 100644 index 0000000..6d155d0 --- /dev/null +++ b/apps/desktop-wallet/src/index.css @@ -0,0 +1,74 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: theme('colors.gray.900'); +} + +::-webkit-scrollbar-thumb { + background: theme('colors.gray.700'); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: theme('colors.gray.600'); +} + +/* Custom selection */ +::selection { + background: theme('colors.synor.500/30'); +} + +/* Prevent text selection during drag */ +.no-select { + user-select: none; + -webkit-user-select: none; +} + +/* Smooth transitions */ +* { + @apply transition-colors duration-150; +} + +/* Focus styles */ +*:focus-visible { + @apply outline-none ring-2 ring-synor-500 ring-offset-2 ring-offset-gray-950; +} + +/* Button base styles */ +@layer components { + .btn { + @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 inline-flex items-center justify-center gap-2; + } + + .btn-primary { + @apply bg-synor-600 hover:bg-synor-500 text-white; + } + + .btn-secondary { + @apply bg-gray-800 hover:bg-gray-700 text-gray-100; + } + + .btn-danger { + @apply bg-red-600 hover:bg-red-500 text-white; + } + + .btn-ghost { + @apply bg-transparent hover:bg-gray-800 text-gray-300 hover:text-white; + } + + .input { + @apply w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-synor-500 focus:ring-1 focus:ring-synor-500; + } + + .card { + @apply bg-gray-900 border border-gray-800 rounded-xl p-6; + } +} diff --git a/apps/desktop-wallet/src/main.tsx b/apps/desktop-wallet/src/main.tsx new file mode 100644 index 0000000..a814b52 --- /dev/null +++ b/apps/desktop-wallet/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/apps/desktop-wallet/src/pages/CreateWallet.tsx b/apps/desktop-wallet/src/pages/CreateWallet.tsx new file mode 100644 index 0000000..af50a06 --- /dev/null +++ b/apps/desktop-wallet/src/pages/CreateWallet.tsx @@ -0,0 +1,349 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, Copy, Check, AlertTriangle, Clock } from 'lucide-react'; +import { useWalletStore } from '../store/wallet'; + +/** Seconds before mnemonic is automatically cleared from display */ +const MNEMONIC_DISPLAY_TIMEOUT = 60; +/** Seconds before clipboard is automatically cleared after copying */ +const CLIPBOARD_CLEAR_TIMEOUT = 30; + +type Step = 'password' | 'mnemonic' | 'verify'; + +export default function CreateWallet() { + const navigate = useNavigate(); + const { createWallet } = useWalletStore(); + + const [step, setStep] = useState('password'); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [mnemonic, setMnemonic] = useState(''); + const [copied, setCopied] = useState(false); + const [verifyWord, setVerifyWord] = useState(''); + const [verifyIndex, setVerifyIndex] = useState(0); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [mnemonicHidden, setMnemonicHidden] = useState(false); + const [countdown, setCountdown] = useState(MNEMONIC_DISPLAY_TIMEOUT); + const [clipboardTimer, setClipboardTimer] = useState(null); + + /** + * Clear the mnemonic from state for security + */ + const clearMnemonic = useCallback(() => { + setMnemonic(''); + setMnemonicHidden(true); + }, []); + + /** + * Auto-clear mnemonic after timeout when viewing recovery phrase + */ + useEffect(() => { + if (step !== 'mnemonic' || !mnemonic || mnemonicHidden) return; + + // Countdown timer + const interval = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearMnemonic(); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [step, mnemonic, mnemonicHidden, clearMnemonic]); + + /** + * Reset countdown when entering mnemonic step + */ + useEffect(() => { + if (step === 'mnemonic' && mnemonic && !mnemonicHidden) { + setCountdown(MNEMONIC_DISPLAY_TIMEOUT); + } + }, [step, mnemonic, mnemonicHidden]); + + /** + * Clear sensitive state on unmount + */ + useEffect(() => { + return () => { + // Clear password and mnemonic from state on unmount + setPassword(''); + setConfirmPassword(''); + setMnemonic(''); + setVerifyWord(''); + }; + }, []); + + const handleCreateWallet = async () => { + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setLoading(true); + setError(''); + + try { + const result = await createWallet(password); + setMnemonic(result.mnemonic); + // Random word to verify (1-24) + setVerifyIndex(Math.floor(Math.random() * 24)); + setStep('mnemonic'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create wallet'); + } finally { + setLoading(false); + } + }; + + const handleCopyMnemonic = async () => { + await navigator.clipboard.writeText(mnemonic); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + + // Clear any existing clipboard timer + if (clipboardTimer) { + clearTimeout(clipboardTimer); + } + + // Auto-clear clipboard after timeout for security + const timer = window.setTimeout(async () => { + try { + // Clear clipboard by writing empty string + await navigator.clipboard.writeText(''); + setClipboardTimer(null); + } catch { + // Clipboard API may fail if window not focused + } + }, CLIPBOARD_CLEAR_TIMEOUT * 1000); + + setClipboardTimer(timer); + }; + + const handleVerify = () => { + const words = mnemonic.split(' '); + if (verifyWord.toLowerCase().trim() === words[verifyIndex]) { + navigate('/dashboard'); + } else { + setError(`Incorrect word. Please check word #${verifyIndex + 1}`); + } + }; + + const words = mnemonic.split(' '); + + return ( +
+
+ {/* Back button */} + + + {step === 'password' && ( +
+

+ Create Password +

+

+ This password will encrypt your wallet. You'll need it every time + you open the app. +

+ +
+
+ + setPassword(e.target.value)} + className="input" + placeholder="Enter password (min 8 characters)" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="input" + placeholder="Confirm password" + /> +
+ + {error && ( +

+ + {error} +

+ )} + + +
+
+ )} + + {step === 'mnemonic' && ( +
+

+ Recovery Phrase +

+

+ Write down these 24 words in order. This is the ONLY way to + recover your wallet if you lose access. +

+ + {/* Security countdown timer */} + {!mnemonicHidden && ( +
+ + + Auto-hiding in {countdown}s for security + +
+ )} + + {mnemonicHidden ? ( + /* Mnemonic has been auto-cleared */ +
+ +

+ Recovery phrase hidden for security +

+

+ If you haven't saved it, go back and create a new wallet. +

+ +
+ ) : ( + /* Show mnemonic words */ +
+
+ {words.map((word, i) => ( +
+ {i + 1}. + {word} +
+ ))} +
+
+ )} + + {!mnemonicHidden && ( + <> + + +
+
+ +

+ Never share your recovery phrase. Anyone with these words can + steal your funds. +

+
+
+ + + + )} +
+ )} + + {step === 'verify' && ( +
+

+ Verify Recovery Phrase +

+

+ Enter word #{verifyIndex + 1} from your recovery phrase to verify + you've saved it correctly. +

+ +
+ + setVerifyWord(e.target.value)} + className="input" + placeholder={`Enter word #${verifyIndex + 1}`} + autoFocus + /> +
+ + {error && ( +

+ + {error} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/apps/desktop-wallet/src/pages/Dashboard.tsx b/apps/desktop-wallet/src/pages/Dashboard.tsx new file mode 100644 index 0000000..018e244 --- /dev/null +++ b/apps/desktop-wallet/src/pages/Dashboard.tsx @@ -0,0 +1,142 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { + Send, + Download, + RefreshCw, + TrendingUp, + Wallet, + Activity, +} from 'lucide-react'; +import { useWalletStore } from '../store/wallet'; + +export default function Dashboard() { + const { balance, addresses, networkStatus, refreshBalance } = useWalletStore(); + + // Refresh balance periodically + useEffect(() => { + refreshBalance(); + const interval = setInterval(refreshBalance, 30000); + return () => clearInterval(interval); + }, [refreshBalance]); + + const primaryAddress = addresses[0]?.address; + + return ( +
+ {/* Header */} +
+

Dashboard

+ +
+ + {/* Balance card */} +
+
+
+

Total Balance

+

+ {balance?.balanceHuman || '0 SYN'} +

+ {balance?.pending ? ( +

+ +{(balance.pending / 100_000_000).toFixed(8)} SYN pending +

+ ) : null} +
+
+ +
+
+
+ + {/* Quick actions */} +
+ +
+
+ +
+
+

Send

+

Transfer SYN

+
+
+ + + +
+
+ +
+
+

Receive

+

Get your address

+
+
+ +
+ + {/* Network status */} +
+

+ + Network Status +

+
+
+

Status

+

+ {networkStatus.connected ? ( + Connected + ) : ( + Disconnected + )} +

+
+
+

Network

+

+ {networkStatus.network || '-'} +

+
+
+

Block Height

+

+ {networkStatus.blockHeight?.toLocaleString() || '-'} +

+
+
+

Peers

+

{networkStatus.peerCount ?? '-'}

+
+
+
+ + {/* Primary address */} + {primaryAddress && ( +
+

+ + Primary Address +

+

+ {primaryAddress} +

+
+ )} +
+ ); +} diff --git a/apps/desktop-wallet/src/pages/History.tsx b/apps/desktop-wallet/src/pages/History.tsx new file mode 100644 index 0000000..b606124 --- /dev/null +++ b/apps/desktop-wallet/src/pages/History.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + ArrowUpRight, + ArrowDownLeft, + ExternalLink, + RefreshCw, +} from 'lucide-react'; + +interface Transaction { + txid: string; + direction: 'sent' | 'received'; + amount: number; + fee?: number; + timestamp: number; + confirmations: number; + counterparty?: string; +} + +export default function History() { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchHistory = async () => { + setLoading(true); + try { + const history = await invoke('get_transaction_history', { + limit: 50, + }); + setTransactions(history); + } catch (error) { + console.error('Failed to fetch history:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchHistory(); + }, []); + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const formatAmount = (amount: number, direction: string) => { + const syn = amount / 100_000_000; + const sign = direction === 'received' ? '+' : '-'; + return `${sign}${syn.toFixed(8)} SYN`; + }; + + return ( +
+
+

Transaction History

+ +
+ +
+ {loading && transactions.length === 0 ? ( +
+ +

Loading transactions...

+
+ ) : transactions.length === 0 ? ( +
+

No transactions yet.

+

+ Transactions will appear here once you send or receive SYN. +

+
+ ) : ( +
+ {transactions.map((tx) => ( +
+
+ {/* Icon */} +
+ {tx.direction === 'received' ? ( + + ) : ( + + )} +
+ + {/* Details */} +
+
+ + {tx.direction} + + {tx.confirmations < 10 && ( + + {tx.confirmations} conf + + )} +
+

+ {formatDate(tx.timestamp)} +

+
+ + {/* Amount */} +
+

+ {formatAmount(tx.amount, tx.direction)} +

+ {tx.fee && ( +

+ Fee: {(tx.fee / 100_000_000).toFixed(8)} SYN +

+ )} +
+ + {/* External link */} + + + +
+ + {/* TXID */} +

+ {tx.txid} +

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/desktop-wallet/src/pages/ImportWallet.tsx b/apps/desktop-wallet/src/pages/ImportWallet.tsx new file mode 100644 index 0000000..de75944 --- /dev/null +++ b/apps/desktop-wallet/src/pages/ImportWallet.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, AlertTriangle } from 'lucide-react'; +import { useWalletStore } from '../store/wallet'; + +export default function ImportWallet() { + const navigate = useNavigate(); + const { importWallet } = useWalletStore(); + + const [mnemonic, setMnemonic] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + /** + * Clear all sensitive data from component state + * Called after successful import and on unmount + */ + const clearSensitiveData = useCallback(() => { + setMnemonic(''); + setPassword(''); + setConfirmPassword(''); + }, []); + + /** + * Clear sensitive state on unmount to prevent data leaks + */ + useEffect(() => { + return () => { + clearSensitiveData(); + }; + }, [clearSensitiveData]); + + const handleImport = async () => { + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + const words = mnemonic.trim().split(/\s+/); + if (words.length !== 24) { + setError('Recovery phrase must be exactly 24 words'); + return; + } + + setLoading(true); + setError(''); + + try { + await importWallet(mnemonic.trim(), password); + // Clear sensitive data immediately after successful import + clearSensitiveData(); + navigate('/dashboard'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import wallet'); + // Clear password on error but keep mnemonic for retry + setPassword(''); + setConfirmPassword(''); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Back button */} + + +
+

+ Import Wallet +

+ +
+
+ +