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
This commit is contained in:
parent
1606776394
commit
6b5a232a5e
45 changed files with 5023 additions and 4 deletions
104
apps/desktop-wallet/Dockerfile
Normal file
104
apps/desktop-wallet/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
13
apps/desktop-wallet/index.html
Normal file
13
apps/desktop-wallet/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/synor.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Synor Wallet</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-950 text-white antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
apps/desktop-wallet/package.json
Normal file
41
apps/desktop-wallet/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/desktop-wallet/postcss.config.js
Normal file
6
apps/desktop-wallet/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
52
apps/desktop-wallet/src-tauri/Cargo.toml
Normal file
52
apps/desktop-wallet/src-tauri/Cargo.toml
Normal file
|
|
@ -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
|
||||||
3
apps/desktop-wallet/src-tauri/build.rs
Normal file
3
apps/desktop-wallet/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
375
apps/desktop-wallet/src-tauri/src/commands.rs
Normal file
375
apps/desktop-wallet/src-tauri/src/commands.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
//! Tauri commands for the desktop wallet
|
||||||
|
//!
|
||||||
|
//! All commands are async and return Result<T, Error> 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<CreateWalletResponse> {
|
||||||
|
// 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<String> {
|
||||||
|
// 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<bool> {
|
||||||
|
// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get wallet information
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_wallet_info(state: State<'_, WalletState>) -> Result<WalletInfo> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<WalletAddress>> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<WalletAddress> {
|
||||||
|
// 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<BalanceResponse> {
|
||||||
|
// 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<Vec<UtxoResponse>> {
|
||||||
|
// 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<u64>,
|
||||||
|
pub use_dilithium: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsigned transaction response
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UnsignedTransaction {
|
||||||
|
pub tx_hex: String,
|
||||||
|
pub fee: u64,
|
||||||
|
pub inputs: Vec<UtxoResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an unsigned transaction
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_transaction(
|
||||||
|
state: State<'_, WalletState>,
|
||||||
|
request: CreateTransactionRequest,
|
||||||
|
) -> Result<UnsignedTransaction> {
|
||||||
|
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<SignedTransaction> {
|
||||||
|
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<BroadcastResponse> {
|
||||||
|
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<u64>,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub confirmations: u64,
|
||||||
|
pub counterparty: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transaction history
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_transaction_history(
|
||||||
|
state: State<'_, WalletState>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<TransactionHistoryEntry>> {
|
||||||
|
// 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<String>,
|
||||||
|
) -> Result<NetworkConnection> {
|
||||||
|
// 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<String>,
|
||||||
|
pub block_height: Option<u64>,
|
||||||
|
pub peer_count: Option<u32>,
|
||||||
|
pub synced: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get network status
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_network_status(state: State<'_, WalletState>) -> Result<NetworkStatus> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
270
apps/desktop-wallet/src-tauri/src/crypto.rs
Normal file
270
apps/desktop-wallet/src-tauri/src/crypto.rs
Normal file
|
|
@ -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<String> {
|
||||||
|
// 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<SeedData> {
|
||||||
|
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<EncryptedWallet> {
|
||||||
|
// 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<SeedData> {
|
||||||
|
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<Sha512>;
|
||||||
|
|
||||||
|
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<bech32-encoded-pubkey-hash>
|
||||||
|
pub fn pubkey_to_address(pubkey: &[u8; 32], testnet: bool) -> Result<String> {
|
||||||
|
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<u8> = 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/desktop-wallet/src-tauri/src/error.rs
Normal file
55
apps/desktop-wallet/src-tauri/src/error.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
//! Error types for the desktop wallet
|
||||||
|
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
|
/// Result type for wallet operations
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// 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<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
apps/desktop-wallet/src-tauri/src/lib.rs
Normal file
67
apps/desktop-wallet/src-tauri/src/lib.rs
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
6
apps/desktop-wallet/src-tauri/src/main.rs
Normal file
6
apps/desktop-wallet/src-tauri/src/main.rs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
403
apps/desktop-wallet/src-tauri/src/wallet.rs
Normal file
403
apps/desktop-wallet/src-tauri/src/wallet.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<WalletAddress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlockedWallet {
|
||||||
|
/// Create from seed
|
||||||
|
pub fn new(seed: SeedData, mnemonic: Option<String>) -> 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<RwLock<Option<PathBuf>>>,
|
||||||
|
/// Network connection
|
||||||
|
pub connection: Arc<RwLock<Option<NetworkConnection>>>,
|
||||||
|
/// Unlocked wallet (None if locked)
|
||||||
|
pub unlocked: Arc<RwLock<Option<UnlockedWallet>>>,
|
||||||
|
/// Derived addresses
|
||||||
|
pub addresses: Arc<RwLock<Vec<WalletAddress>>>,
|
||||||
|
/// Wallet metadata (loaded from file)
|
||||||
|
pub metadata: Arc<RwLock<Option<WalletMetadata>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
// 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<String>, is_change: bool) -> Result<WalletAddress> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
84
apps/desktop-wallet/src-tauri/tauri.conf.json
Normal file
84
apps/desktop-wallet/src-tauri/tauri.conf.json
Normal file
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
apps/desktop-wallet/src/App.tsx
Normal file
86
apps/desktop-wallet/src/App.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-950">
|
||||||
|
{/* Custom title bar for frameless window (optional) */}
|
||||||
|
<TitleBar />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<Routes>
|
||||||
|
{/* Onboarding routes */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
!isInitialized ? (
|
||||||
|
<Welcome />
|
||||||
|
) : !isUnlocked ? (
|
||||||
|
<Navigate to="/unlock" replace />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/dashboard" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/create" element={<CreateWallet />} />
|
||||||
|
<Route path="/import" element={<ImportWallet />} />
|
||||||
|
<Route path="/unlock" element={<Unlock />} />
|
||||||
|
|
||||||
|
{/* Protected routes (require unlocked wallet) */}
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <Dashboard /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/send"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <Send /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/receive"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <Receive /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/history"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <Settings /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
104
apps/desktop-wallet/src/components/Layout.tsx
Normal file
104
apps/desktop-wallet/src/components/Layout.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
|
{/* Balance display */}
|
||||||
|
<div className="p-6 border-b border-gray-800">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||||
|
Balance
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{balance?.balanceHuman || '0 SYN'}
|
||||||
|
</p>
|
||||||
|
{balance?.pending ? (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
+ {(balance.pending / 100_000_000).toFixed(8)} SYN pending
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-800 space-y-2">
|
||||||
|
{/* Network status */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||||
|
{networkStatus.connected ? (
|
||||||
|
<>
|
||||||
|
<Wifi size={16} className="text-green-400" />
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{networkStatus.network || 'Connected'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff size={16} className="text-red-400" />
|
||||||
|
<span className="text-gray-400">Disconnected</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLock}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Lock size={16} />
|
||||||
|
Lock Wallet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/desktop-wallet/src/components/TitleBar.tsx
Normal file
53
apps/desktop-wallet/src/components/TitleBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="h-8 flex items-center justify-between bg-gray-900 border-b border-gray-800 select-none"
|
||||||
|
>
|
||||||
|
{/* App title / drag region */}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="flex-1 h-full flex items-center px-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded bg-gradient-to-br from-synor-400 to-synor-600" />
|
||||||
|
<span className="text-xs font-medium text-gray-400">Synor Wallet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Window controls (Windows/Linux style) */}
|
||||||
|
<div className="flex items-center h-full">
|
||||||
|
<button
|
||||||
|
onClick={() => appWindow.minimize()}
|
||||||
|
className="h-full px-4 hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Minimize"
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => appWindow.toggleMaximize()}
|
||||||
|
className="h-full px-4 hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Maximize"
|
||||||
|
>
|
||||||
|
<Square size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => appWindow.close()}
|
||||||
|
className="h-full px-4 hover:bg-red-600 text-gray-400 hover:text-white transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/desktop-wallet/src/index.css
Normal file
74
apps/desktop-wallet/src/index.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/desktop-wallet/src/main.tsx
Normal file
13
apps/desktop-wallet/src/main.tsx
Normal file
|
|
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
349
apps/desktop-wallet/src/pages/CreateWallet.tsx
Normal file
349
apps/desktop-wallet/src/pages/CreateWallet.tsx
Normal file
|
|
@ -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<Step>('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<number | null>(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 (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
step === 'password'
|
||||||
|
? navigate('/')
|
||||||
|
: setStep(step === 'verify' ? 'mnemonic' : 'password')
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 text-gray-400 hover:text-white mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{step === 'password' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-6">
|
||||||
|
Create Password
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">
|
||||||
|
This password will encrypt your wallet. You'll need it every time
|
||||||
|
you open the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter password (min 8 characters)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreateWallet}
|
||||||
|
disabled={loading || !password || !confirmPassword}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Create Wallet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'mnemonic' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Recovery Phrase
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Write down these 24 words in order. This is the ONLY way to
|
||||||
|
recover your wallet if you lose access.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Security countdown timer */}
|
||||||
|
{!mnemonicHidden && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-400 mb-4">
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>
|
||||||
|
Auto-hiding in {countdown}s for security
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mnemonicHidden ? (
|
||||||
|
/* Mnemonic has been auto-cleared */
|
||||||
|
<div className="bg-gray-950 rounded-lg p-6 mb-4 text-center">
|
||||||
|
<AlertTriangle className="text-amber-500 mx-auto mb-3" size={32} />
|
||||||
|
<p className="text-gray-300 mb-2">
|
||||||
|
Recovery phrase hidden for security
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
If you haven't saved it, go back and create a new wallet.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Start Over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Show mnemonic words */
|
||||||
|
<div className="bg-gray-950 rounded-lg p-4 mb-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{words.map((word, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 text-sm py-1"
|
||||||
|
>
|
||||||
|
<span className="text-gray-500 w-6">{i + 1}.</span>
|
||||||
|
<span className="text-white font-mono">{word}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mnemonicHidden && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyMnemonic}
|
||||||
|
className="btn btn-secondary w-full mb-4"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check size={18} className="text-green-400" />
|
||||||
|
Copied! (auto-clears in {CLIPBOARD_CLEAR_TIMEOUT}s)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={18} />
|
||||||
|
Copy to Clipboard
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-yellow-900/30 border border-yellow-700 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<AlertTriangle className="text-yellow-500 shrink-0" size={20} />
|
||||||
|
<p className="text-sm text-yellow-200">
|
||||||
|
Never share your recovery phrase. Anyone with these words can
|
||||||
|
steal your funds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('verify')}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
I've Written It Down
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'verify' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">
|
||||||
|
Verify Recovery Phrase
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">
|
||||||
|
Enter word #{verifyIndex + 1} from your recovery phrase to verify
|
||||||
|
you've saved it correctly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Word #{verifyIndex + 1}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={verifyWord}
|
||||||
|
onChange={(e) => setVerifyWord(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder={`Enter word #${verifyIndex + 1}`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2 mt-4">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={!verifyWord}
|
||||||
|
className="btn btn-primary w-full mt-6"
|
||||||
|
>
|
||||||
|
Verify & Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/desktop-wallet/src/pages/Dashboard.tsx
Normal file
142
apps/desktop-wallet/src/pages/Dashboard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => refreshBalance()}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="Refresh balance"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance card */}
|
||||||
|
<div className="card bg-gradient-to-br from-synor-600 to-synor-800 border-0">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-synor-200 text-sm mb-1">Total Balance</p>
|
||||||
|
<p className="text-4xl font-bold text-white mb-2">
|
||||||
|
{balance?.balanceHuman || '0 SYN'}
|
||||||
|
</p>
|
||||||
|
{balance?.pending ? (
|
||||||
|
<p className="text-synor-200 text-sm">
|
||||||
|
+{(balance.pending / 100_000_000).toFixed(8)} SYN pending
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white/10 flex items-center justify-center">
|
||||||
|
<Wallet className="text-white" size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Link
|
||||||
|
to="/send"
|
||||||
|
className="card hover:border-synor-500 hover:bg-gray-800/50 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-synor-600/20 text-synor-400 flex items-center justify-center">
|
||||||
|
<Send size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Send</h3>
|
||||||
|
<p className="text-sm text-gray-400">Transfer SYN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/receive"
|
||||||
|
className="card hover:border-synor-500 hover:bg-gray-800/50 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-green-600/20 text-green-400 flex items-center justify-center">
|
||||||
|
<Download size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Receive</h3>
|
||||||
|
<p className="text-sm text-gray-400">Get your address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network status */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Activity size={18} />
|
||||||
|
Network Status
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Status</p>
|
||||||
|
<p className="text-white">
|
||||||
|
{networkStatus.connected ? (
|
||||||
|
<span className="text-green-400">Connected</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400">Disconnected</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Network</p>
|
||||||
|
<p className="text-white capitalize">
|
||||||
|
{networkStatus.network || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Block Height</p>
|
||||||
|
<p className="text-white">
|
||||||
|
{networkStatus.blockHeight?.toLocaleString() || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">Peers</p>
|
||||||
|
<p className="text-white">{networkStatus.peerCount ?? '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary address */}
|
||||||
|
{primaryAddress && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp size={18} />
|
||||||
|
Primary Address
|
||||||
|
</h3>
|
||||||
|
<p className="font-mono text-sm text-gray-300 break-all bg-gray-950 rounded-lg p-3">
|
||||||
|
{primaryAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
apps/desktop-wallet/src/pages/History.tsx
Normal file
161
apps/desktop-wallet/src/pages/History.tsx
Normal file
|
|
@ -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<Transaction[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const history = await invoke<Transaction[]>('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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Transaction History</h1>
|
||||||
|
<button onClick={fetchHistory} className="btn btn-ghost" disabled={loading}>
|
||||||
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{loading && transactions.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<RefreshCw size={24} className="animate-spin mx-auto text-gray-500 mb-2" />
|
||||||
|
<p className="text-gray-500">Loading transactions...</p>
|
||||||
|
</div>
|
||||||
|
) : transactions.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">No transactions yet.</p>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">
|
||||||
|
Transactions will appear here once you send or receive SYN.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<div
|
||||||
|
key={tx.txid}
|
||||||
|
className="py-4 first:pt-0 last:pb-0 hover:bg-gray-800/50 -mx-6 px-6 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
tx.direction === 'received'
|
||||||
|
? 'bg-green-600/20 text-green-400'
|
||||||
|
: 'bg-synor-600/20 text-synor-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tx.direction === 'received' ? (
|
||||||
|
<ArrowDownLeft size={20} />
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight size={20} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-white capitalize">
|
||||||
|
{tx.direction}
|
||||||
|
</span>
|
||||||
|
{tx.confirmations < 10 && (
|
||||||
|
<span className="text-xs bg-yellow-600/20 text-yellow-400 px-1.5 py-0.5 rounded">
|
||||||
|
{tx.confirmations} conf
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatDate(tx.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`font-mono font-medium ${
|
||||||
|
tx.direction === 'received'
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatAmount(tx.amount, tx.direction)}
|
||||||
|
</p>
|
||||||
|
{tx.fee && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Fee: {(tx.fee / 100_000_000).toFixed(8)} SYN
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* External link */}
|
||||||
|
<a
|
||||||
|
href={`https://explorer.synor.io/tx/${tx.txid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 text-gray-500 hover:text-white transition-colors"
|
||||||
|
title="View in explorer"
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TXID */}
|
||||||
|
<p className="font-mono text-xs text-gray-600 mt-2 truncate">
|
||||||
|
{tx.txid}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/desktop-wallet/src/pages/ImportWallet.tsx
Normal file
144
apps/desktop-wallet/src/pages/ImportWallet.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="flex items-center gap-2 text-gray-400 hover:text-white mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-6">
|
||||||
|
Import Wallet
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Recovery Phrase
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={mnemonic}
|
||||||
|
onChange={(e) => setMnemonic(e.target.value)}
|
||||||
|
className="input min-h-[120px] resize-none font-mono text-sm"
|
||||||
|
placeholder="Enter your 24-word recovery phrase, separated by spaces"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Create a password (min 8 characters)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || !mnemonic || !password || !confirmPassword}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loading ? 'Importing...' : 'Import Wallet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/desktop-wallet/src/pages/Receive.tsx
Normal file
130
apps/desktop-wallet/src/pages/Receive.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
|
import { Copy, Check, Plus, QrCode } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
export default function Receive() {
|
||||||
|
const { addresses, refreshAddresses } = useWalletStore();
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async (address: string) => {
|
||||||
|
await writeText(address);
|
||||||
|
setCopied(address);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateAddress = async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
await invoke('generate_address', { label: null });
|
||||||
|
await refreshAddresses();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate address:', error);
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryAddress = addresses[0]?.address;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Receive SYN</h1>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateAddress}
|
||||||
|
disabled={generating}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
New Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary address with QR placeholder */}
|
||||||
|
{primaryAddress && (
|
||||||
|
<div className="card mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<QrCode size={18} className="text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-400">Primary Address</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code placeholder */}
|
||||||
|
<div className="w-48 h-48 mx-auto mb-4 bg-white rounded-lg flex items-center justify-center">
|
||||||
|
<div className="text-gray-400 text-center p-4">
|
||||||
|
<QrCode size={64} className="mx-auto mb-2 text-gray-300" />
|
||||||
|
<span className="text-xs">QR Code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address with copy */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-gray-950 rounded-lg p-3">
|
||||||
|
<p className="font-mono text-sm text-gray-300 break-all">
|
||||||
|
{primaryAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(primaryAddress)}
|
||||||
|
className="btn btn-secondary shrink-0"
|
||||||
|
>
|
||||||
|
{copied === primaryAddress ? (
|
||||||
|
<Check size={18} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All addresses */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-semibold text-white mb-4">All Addresses</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{addresses.map((addr, i) => (
|
||||||
|
<div
|
||||||
|
key={addr.address}
|
||||||
|
className="flex items-center gap-2 p-3 bg-gray-950 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs text-gray-500">#{addr.index}</span>
|
||||||
|
{addr.label && (
|
||||||
|
<span className="text-xs text-synor-400">{addr.label}</span>
|
||||||
|
)}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="text-xs bg-synor-600 text-white px-1.5 py-0.5 rounded">
|
||||||
|
Primary
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-xs text-gray-400 truncate">
|
||||||
|
{addr.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(addr.address)}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded transition-colors text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{copied === addr.address ? (
|
||||||
|
<Check size={16} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{addresses.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No addresses yet. Generate one to receive funds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
apps/desktop-wallet/src/pages/Send.tsx
Normal file
216
apps/desktop-wallet/src/pages/Send.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Send as SendIcon, AlertTriangle, Check } from 'lucide-react';
|
||||||
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a decimal string to sompi (smallest unit) without floating point errors
|
||||||
|
* 1 SYN = 100_000_000 sompi (8 decimal places)
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "1.5" -> 150000000
|
||||||
|
* "0.00000001" -> 1
|
||||||
|
* "123.45678901" -> 12345678901 (truncates extra decimals)
|
||||||
|
*/
|
||||||
|
function parseSynToSompi(amountStr: string): bigint | null {
|
||||||
|
const DECIMALS = 8;
|
||||||
|
const MULTIPLIER = BigInt(10 ** DECIMALS);
|
||||||
|
|
||||||
|
// Clean the input
|
||||||
|
const cleaned = amountStr.trim();
|
||||||
|
if (!cleaned || cleaned === '.') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format (positive decimal number)
|
||||||
|
if (!/^\d*\.?\d*$/.test(cleaned)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into integer and decimal parts
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
const integerPart = parts[0] || '0';
|
||||||
|
let decimalPart = parts[1] || '';
|
||||||
|
|
||||||
|
// Truncate or pad decimal part to exactly 8 digits
|
||||||
|
if (decimalPart.length > DECIMALS) {
|
||||||
|
decimalPart = decimalPart.slice(0, DECIMALS);
|
||||||
|
} else {
|
||||||
|
decimalPart = decimalPart.padEnd(DECIMALS, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine and convert to BigInt
|
||||||
|
const combined = integerPart + decimalPart;
|
||||||
|
const result = BigInt(combined);
|
||||||
|
|
||||||
|
return result > 0n ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Send() {
|
||||||
|
const { balance } = useWalletStore();
|
||||||
|
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [useDilithium, setUseDilithium] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert amount to sompi using precise string parsing (no floating point)
|
||||||
|
const amountSompiBigInt = parseSynToSompi(amount);
|
||||||
|
|
||||||
|
if (amountSompiBigInt === null) {
|
||||||
|
throw new Error('Invalid amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to number for the API (safe for values under 2^53)
|
||||||
|
const amountSompi = Number(amountSompiBigInt);
|
||||||
|
if (!Number.isSafeInteger(amountSompi)) {
|
||||||
|
throw new Error('Amount too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transaction
|
||||||
|
const unsigned = await invoke<{ tx_hex: string; fee: number }>(
|
||||||
|
'create_transaction',
|
||||||
|
{
|
||||||
|
request: {
|
||||||
|
to,
|
||||||
|
amount: amountSompi,
|
||||||
|
use_dilithium: useDilithium,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign transaction
|
||||||
|
const signed = await invoke<{ tx_hex: string; txid: string }>(
|
||||||
|
'sign_transaction',
|
||||||
|
{ tx_hex: unsigned.tx_hex }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast transaction
|
||||||
|
const result = await invoke<{ txid: string; accepted: boolean }>(
|
||||||
|
'broadcast_transaction',
|
||||||
|
{ tx_hex: signed.tx_hex }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.accepted) {
|
||||||
|
setSuccess(`Transaction sent! TXID: ${result.txid}`);
|
||||||
|
setTo('');
|
||||||
|
setAmount('');
|
||||||
|
} else {
|
||||||
|
throw new Error('Transaction rejected by network');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send transaction');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxAmount = balance ? balance.balance / 100_000_000 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-6">Send SYN</h1>
|
||||||
|
|
||||||
|
<div className="card space-y-6">
|
||||||
|
{/* Recipient */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Recipient Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="input font-mono text-sm"
|
||||||
|
placeholder="synor1..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Amount</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="input pr-20"
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.00000001"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setAmount(maxAmount.toString())}
|
||||||
|
className="text-xs text-synor-400 hover:text-synor-300"
|
||||||
|
>
|
||||||
|
MAX
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-500">SYN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Available: {maxAmount.toFixed(8)} SYN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post-quantum signature option */}
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-gray-950 rounded-lg">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="dilithium"
|
||||||
|
checked={useDilithium}
|
||||||
|
onChange={(e) => setUseDilithium(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="dilithium" className="flex-1">
|
||||||
|
<p className="text-white text-sm font-medium">
|
||||||
|
Use Dilithium3 Signature
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
Post-quantum secure, but higher fees (~5x larger signature)
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error/Success messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 text-green-400 text-sm">
|
||||||
|
<Check size={16} />
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={loading || !to || !amount}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
'Sending...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SendIcon size={18} />
|
||||||
|
Send Transaction
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
apps/desktop-wallet/src/pages/Settings.tsx
Normal file
256
apps/desktop-wallet/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Shield,
|
||||||
|
Key,
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { networkStatus, connectNode } = useWalletStore();
|
||||||
|
|
||||||
|
const [rpcUrl, setRpcUrl] = useState(
|
||||||
|
'http://localhost:17110'
|
||||||
|
);
|
||||||
|
const [wsUrl, setWsUrl] = useState('ws://localhost:17111');
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [showMnemonic, setShowMnemonic] = useState(false);
|
||||||
|
const [mnemonicPassword, setMnemonicPassword] = useState('');
|
||||||
|
const [mnemonic, setMnemonic] = useState('');
|
||||||
|
const [exportError, setExportError] = useState('');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await connectNode(rpcUrl, wsUrl || undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportMnemonic = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
setExportError('');
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('export_mnemonic', {
|
||||||
|
password: mnemonicPassword,
|
||||||
|
});
|
||||||
|
setMnemonic(result);
|
||||||
|
setShowMnemonic(true);
|
||||||
|
} catch (err) {
|
||||||
|
setExportError(
|
||||||
|
err instanceof Error ? err.message : 'Failed to export mnemonic'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
setMnemonicPassword('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||||
|
|
||||||
|
{/* Network Settings */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Server size={20} className="text-gray-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Network</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
RPC Endpoint
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rpcUrl}
|
||||||
|
onChange={(e) => setRpcUrl(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="http://localhost:17110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
WebSocket Endpoint (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={wsUrl}
|
||||||
|
onChange={(e) => setWsUrl(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="ws://localhost:17111"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={connecting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Connect'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
networkStatus.connected ? 'bg-green-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
{networkStatus.connected
|
||||||
|
? `Connected to ${networkStatus.network}`
|
||||||
|
: 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Networks */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="font-medium text-white mb-3">Quick Connect</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRpcUrl('http://localhost:17110');
|
||||||
|
setWsUrl('ws://localhost:17111');
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost text-sm"
|
||||||
|
>
|
||||||
|
Local Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRpcUrl('https://testnet-rpc.synor.io');
|
||||||
|
setWsUrl('wss://testnet-ws.synor.io');
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost text-sm"
|
||||||
|
>
|
||||||
|
Public Testnet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRpcUrl('https://rpc.synor.io');
|
||||||
|
setWsUrl('wss://ws.synor.io');
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost text-sm"
|
||||||
|
>
|
||||||
|
Mainnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Settings */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Shield size={20} className="text-gray-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Security</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Export Mnemonic */}
|
||||||
|
<div className="p-4 bg-gray-950 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Key size={16} className="text-yellow-500" />
|
||||||
|
<h3 className="font-medium text-white">Recovery Phrase</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMnemonic ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-gray-900 rounded-lg p-3 border border-yellow-700/50">
|
||||||
|
<p className="font-mono text-sm text-yellow-200 break-all">
|
||||||
|
{mnemonic}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMnemonic(false);
|
||||||
|
setMnemonic('');
|
||||||
|
}}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Hide Recovery Phrase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Enter your password to reveal your recovery phrase.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={mnemonicPassword}
|
||||||
|
onChange={(e) => setMnemonicPassword(e.target.value)}
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleExportMnemonic}
|
||||||
|
disabled={exporting || !mnemonicPassword}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{exporting ? 'Loading...' : 'Reveal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{exportError && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{exportError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="flex gap-3 p-4 bg-yellow-900/20 border border-yellow-700/50 rounded-lg">
|
||||||
|
<AlertTriangle className="text-yellow-500 shrink-0" size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-yellow-200 font-medium">
|
||||||
|
Keep your recovery phrase safe
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-200/70 mt-1">
|
||||||
|
Anyone with access to your recovery phrase can steal your funds.
|
||||||
|
Never share it with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version info */}
|
||||||
|
<div className="text-center text-sm text-gray-600">
|
||||||
|
Synor Wallet v0.1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/desktop-wallet/src/pages/Unlock.tsx
Normal file
83
apps/desktop-wallet/src/pages/Unlock.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { AlertTriangle, Lock } from 'lucide-react';
|
||||||
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
|
||||||
|
export default function Unlock() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { unlockWallet } = useWalletStore();
|
||||||
|
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleUnlock = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await unlockWallet(password);
|
||||||
|
if (success) {
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
setError('Invalid password');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to unlock wallet');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-synor-400 to-synor-600 flex items-center justify-center">
|
||||||
|
<Lock className="text-white" size={32} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-xl font-semibold text-white text-center mb-6">
|
||||||
|
Unlock Wallet
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleUnlock} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loading ? 'Unlocking...' : 'Unlock'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/desktop-wallet/src/pages/Welcome.tsx
Normal file
72
apps/desktop-wallet/src/pages/Welcome.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Plus, Download, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Welcome() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-synor-400 to-synor-600 flex items-center justify-center mb-8">
|
||||||
|
<span className="text-white text-4xl font-bold">S</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
|
Welcome to Synor Wallet
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mb-12 text-center max-w-md">
|
||||||
|
A secure desktop wallet for the Synor blockchain with post-quantum
|
||||||
|
cryptography support.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-2xl">
|
||||||
|
<Link
|
||||||
|
to="/create"
|
||||||
|
className="card hover:border-synor-500 hover:bg-gray-800/50 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-synor-600/20 text-synor-400 flex items-center justify-center group-hover:bg-synor-600 group-hover:text-white transition-colors">
|
||||||
|
<Plus size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
Create New Wallet
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Generate a new wallet with a secure 24-word recovery phrase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/import"
|
||||||
|
className="card hover:border-synor-500 hover:bg-gray-800/50 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-synor-600/20 text-synor-400 flex items-center justify-center group-hover:bg-synor-600 group-hover:text-white transition-colors">
|
||||||
|
<Download size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
Import Existing Wallet
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Restore a wallet using your 24-word recovery phrase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security note */}
|
||||||
|
<div className="mt-12 flex items-center gap-3 text-gray-500 text-sm">
|
||||||
|
<Shield size={16} className="text-green-500" />
|
||||||
|
<span>
|
||||||
|
Your keys are encrypted and stored locally. We never have access to
|
||||||
|
your funds.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/desktop-wallet/src/store/wallet.ts
Normal file
175
apps/desktop-wallet/src/store/wallet.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitized error logging - prevents sensitive data from being logged
|
||||||
|
* Only logs generic error types in production, full errors in development
|
||||||
|
*/
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
// In production, only log the context and error type, not the full message
|
||||||
|
// which might contain sensitive information like addresses or amounts
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
const errorType = error instanceof Error ? error.name : 'Unknown';
|
||||||
|
console.error(`[Wallet] ${context}: ${errorType}`);
|
||||||
|
} else {
|
||||||
|
// In development, log full error for debugging (but still sanitize)
|
||||||
|
const safeError = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
// Remove any potential sensitive patterns (addresses, keys, etc.)
|
||||||
|
const sanitized = safeError
|
||||||
|
.replace(/synor1[a-z0-9]{38,}/gi, '[ADDRESS]')
|
||||||
|
.replace(/tsynor1[a-z0-9]{38,}/gi, '[ADDRESS]')
|
||||||
|
.replace(/[a-f0-9]{64}/gi, '[HASH]')
|
||||||
|
.replace(/\b[a-z]{3,}\s+[a-z]{3,}\s+[a-z]{3,}/gi, '[POSSIBLE_MNEMONIC]');
|
||||||
|
console.error(`[Wallet] ${context}:`, sanitized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletAddress {
|
||||||
|
address: string;
|
||||||
|
index: number;
|
||||||
|
isChange: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Balance {
|
||||||
|
balance: number;
|
||||||
|
balanceHuman: string;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkStatus {
|
||||||
|
connected: boolean;
|
||||||
|
network?: string;
|
||||||
|
blockHeight?: number;
|
||||||
|
peerCount?: number;
|
||||||
|
synced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WalletState {
|
||||||
|
// State
|
||||||
|
isInitialized: boolean;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
addresses: WalletAddress[];
|
||||||
|
balance: Balance | null;
|
||||||
|
networkStatus: NetworkStatus;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setInitialized: (initialized: boolean) => void;
|
||||||
|
setUnlocked: (unlocked: boolean) => void;
|
||||||
|
setAddresses: (addresses: WalletAddress[]) => void;
|
||||||
|
setBalance: (balance: Balance | null) => void;
|
||||||
|
setNetworkStatus: (status: NetworkStatus) => void;
|
||||||
|
|
||||||
|
// Async actions (invoke Tauri commands)
|
||||||
|
createWallet: (password: string) => Promise<{ mnemonic: string; address: string }>;
|
||||||
|
importWallet: (mnemonic: string, password: string) => Promise<string>;
|
||||||
|
unlockWallet: (password: string) => Promise<boolean>;
|
||||||
|
lockWallet: () => Promise<void>;
|
||||||
|
refreshBalance: () => Promise<void>;
|
||||||
|
refreshAddresses: () => Promise<void>;
|
||||||
|
connectNode: (rpcUrl: string, wsUrl?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWalletStore = create<WalletState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
isInitialized: false,
|
||||||
|
isUnlocked: false,
|
||||||
|
addresses: [],
|
||||||
|
balance: null,
|
||||||
|
networkStatus: { connected: false },
|
||||||
|
|
||||||
|
// Sync setters
|
||||||
|
setInitialized: (initialized) => set({ isInitialized: initialized }),
|
||||||
|
setUnlocked: (unlocked) => set({ isUnlocked: unlocked }),
|
||||||
|
setAddresses: (addresses) => set({ addresses }),
|
||||||
|
setBalance: (balance) => set({ balance }),
|
||||||
|
setNetworkStatus: (status) => set({ networkStatus: status }),
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
createWallet: async (password: string) => {
|
||||||
|
const result = await invoke<{ mnemonic: string; address: string }>(
|
||||||
|
'create_wallet',
|
||||||
|
{ password }
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
isInitialized: true,
|
||||||
|
isUnlocked: true,
|
||||||
|
addresses: [{ address: result.address, index: 0, isChange: false }],
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
importWallet: async (mnemonic: string, password: string) => {
|
||||||
|
const address = await invoke<string>('import_wallet', {
|
||||||
|
request: { mnemonic, password },
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
isInitialized: true,
|
||||||
|
isUnlocked: true,
|
||||||
|
addresses: [{ address, index: 0, isChange: false }],
|
||||||
|
});
|
||||||
|
return address;
|
||||||
|
},
|
||||||
|
|
||||||
|
unlockWallet: async (password: string) => {
|
||||||
|
const success = await invoke<boolean>('unlock_wallet', { password });
|
||||||
|
if (success) {
|
||||||
|
set({ isUnlocked: true });
|
||||||
|
// Refresh data after unlock
|
||||||
|
get().refreshBalance();
|
||||||
|
get().refreshAddresses();
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
},
|
||||||
|
|
||||||
|
lockWallet: async () => {
|
||||||
|
await invoke('lock_wallet');
|
||||||
|
set({
|
||||||
|
isUnlocked: false,
|
||||||
|
balance: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshBalance: async () => {
|
||||||
|
try {
|
||||||
|
const balance = await invoke<Balance>('get_balance');
|
||||||
|
set({ balance });
|
||||||
|
} catch (error) {
|
||||||
|
logError('refreshBalance', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshAddresses: async () => {
|
||||||
|
try {
|
||||||
|
const addresses = await invoke<WalletAddress[]>('get_addresses');
|
||||||
|
set({ addresses });
|
||||||
|
} catch (error) {
|
||||||
|
logError('refreshAddresses', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
connectNode: async (rpcUrl: string, wsUrl?: string) => {
|
||||||
|
try {
|
||||||
|
const connection = await invoke<NetworkStatus>('connect_node', {
|
||||||
|
rpcUrl,
|
||||||
|
wsUrl,
|
||||||
|
});
|
||||||
|
set({ networkStatus: { ...connection, connected: true } });
|
||||||
|
} catch (error) {
|
||||||
|
logError('connectNode', error);
|
||||||
|
set({ networkStatus: { connected: false } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-wallet-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
isInitialized: state.isInitialized,
|
||||||
|
// Don't persist sensitive state like isUnlocked
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
29
apps/desktop-wallet/tailwind.config.js
Normal file
29
apps/desktop-wallet/tailwind.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
synor: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
950: '#082f49',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'monospace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
31
apps/desktop-wallet/tsconfig.json
Normal file
31
apps/desktop-wallet/tsconfig.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
apps/desktop-wallet/tsconfig.node.json
Normal file
10
apps/desktop-wallet/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
41
apps/desktop-wallet/vite.config.ts
Normal file
41
apps/desktop-wallet/vite.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
// prevent vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: 'ws',
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// Watch the src-tauri folder for Rust changes
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
|
|
||||||
|
build: {
|
||||||
|
// Tauri uses Chromium on Windows and WebKit on macOS and Linux
|
||||||
|
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
||||||
|
// don't minify for debug builds
|
||||||
|
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
|
||||||
|
// produce sourcemaps for debug builds
|
||||||
|
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@ import Address from './pages/Address';
|
||||||
import DAG from './pages/DAG';
|
import DAG from './pages/DAG';
|
||||||
import Network from './pages/Network';
|
import Network from './pages/Network';
|
||||||
import Search from './pages/Search';
|
import Search from './pages/Search';
|
||||||
|
import GasEstimator from './pages/GasEstimator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -22,6 +23,7 @@ export default function App() {
|
||||||
<Route path="/address/:address" element={<Address />} />
|
<Route path="/address/:address" element={<Address />} />
|
||||||
<Route path="/dag" element={<DAG />} />
|
<Route path="/dag" element={<DAG />} />
|
||||||
<Route path="/network" element={<Network />} />
|
<Route path="/network" element={<Network />} />
|
||||||
|
<Route path="/gas" element={<GasEstimator />} />
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/search" element={<Search />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Menu, X, Box, Activity, Layers, Clock, Server } from 'lucide-react';
|
import { Menu, X, Box, Activity, Layers, Clock, Server, Fuel } from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
import SearchAutocomplete from './SearchAutocomplete';
|
import SearchAutocomplete from './SearchAutocomplete';
|
||||||
|
|
@ -13,6 +13,7 @@ export default function Header() {
|
||||||
{ to: '/blocks', label: 'Blocks', icon: Box },
|
{ to: '/blocks', label: 'Blocks', icon: Box },
|
||||||
{ to: '/mempool', label: 'Mempool', icon: Clock },
|
{ to: '/mempool', label: 'Mempool', icon: Clock },
|
||||||
{ to: '/dag', label: 'DAG', icon: Layers },
|
{ to: '/dag', label: 'DAG', icon: Layers },
|
||||||
|
{ to: '/gas', label: 'Gas', icon: Fuel },
|
||||||
{ to: '/network', label: 'Network', icon: Server },
|
{ to: '/network', label: 'Network', icon: Server },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,32 @@ export function useDag(depth = 10): UseQueryResult<DagVisualization> {
|
||||||
export function useTips(): UseQueryResult<string[]> {
|
export function useTips(): UseQueryResult<string[]> {
|
||||||
return useQuery(() => api.getTips(), []);
|
return useQuery(() => api.getTips(), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gas estimation hooks
|
||||||
|
import type { GasCosts, GasEstimateRequest, GasEstimateResponse } from '../lib/types';
|
||||||
|
|
||||||
|
export function useGasCosts(): UseQueryResult<GasCosts> {
|
||||||
|
return useQuery(() => api.getGasCosts(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGasEstimateOptions {
|
||||||
|
/** Enable or disable the query */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGasEstimate(
|
||||||
|
request: GasEstimateRequest | null,
|
||||||
|
options: UseGasEstimateOptions = {}
|
||||||
|
): UseQueryResult<GasEstimateResponse> {
|
||||||
|
const { enabled = true } = options;
|
||||||
|
|
||||||
|
return useQuery(
|
||||||
|
async () => {
|
||||||
|
if (!request || !enabled) {
|
||||||
|
throw new Error('Gas estimation disabled');
|
||||||
|
}
|
||||||
|
return api.estimateGas(request);
|
||||||
|
},
|
||||||
|
[request?.to, request?.from, request?.data, request?.value, enabled]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import type {
|
||||||
SearchResult,
|
SearchResult,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
GasEstimateRequest,
|
||||||
|
GasEstimateResponse,
|
||||||
|
GasCosts,
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
mockStats,
|
mockStats,
|
||||||
|
|
@ -28,18 +31,46 @@ import {
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
// Check if mock mode is enabled via env var or localStorage
|
/**
|
||||||
const isMockMode = () => {
|
* Check if we're in a production build
|
||||||
|
* VITE_MODE is set by Vite during build
|
||||||
|
*/
|
||||||
|
const isProduction = import.meta.env.PROD;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if mock mode is enabled.
|
||||||
|
* SECURITY: Mock mode is NEVER allowed in production builds.
|
||||||
|
* This prevents users from enabling fake data via localStorage.
|
||||||
|
*/
|
||||||
|
const isMockMode = (): boolean => {
|
||||||
|
// Production builds NEVER use mock mode
|
||||||
|
if (isProduction) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development only: allow localStorage override for testing
|
||||||
if (typeof window !== 'undefined' && localStorage.getItem('useMockApi') === 'true') {
|
if (typeof window !== 'undefined' && localStorage.getItem('useMockApi') === 'true') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development only: allow env var override
|
||||||
return import.meta.env.VITE_USE_MOCK === 'true';
|
return import.meta.env.VITE_USE_MOCK === 'true';
|
||||||
};
|
};
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private useMock = isMockMode();
|
private useMock = isMockMode();
|
||||||
|
|
||||||
enableMock(enable: boolean) {
|
/**
|
||||||
|
* Enable or disable mock mode (development only).
|
||||||
|
* SECURITY: This method is disabled in production builds.
|
||||||
|
*/
|
||||||
|
enableMock(enable: boolean): void {
|
||||||
|
// SECURITY: Never allow mock mode in production
|
||||||
|
if (isProduction) {
|
||||||
|
console.warn('[API] Mock mode cannot be enabled in production builds');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.useMock = enable;
|
this.useMock = enable;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem('useMockApi', String(enable));
|
localStorage.setItem('useMockApi', String(enable));
|
||||||
|
|
@ -231,6 +262,40 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
return this.fetch<SearchResult>(`/search?q=${encodeURIComponent(query)}`);
|
return this.fetch<SearchResult>(`/search?q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gas Estimation
|
||||||
|
async estimateGas(request: GasEstimateRequest): Promise<GasEstimateResponse> {
|
||||||
|
if (this.useMock) {
|
||||||
|
// Mock gas estimation
|
||||||
|
const gasUsed = 21000 + (request.data ? request.data.length * 16 : 0);
|
||||||
|
const gasLimitRecommended = Math.ceil(gasUsed * 1.2);
|
||||||
|
const estimatedFee = gasLimitRecommended * 1; // 1 sompi per gas
|
||||||
|
return {
|
||||||
|
gasUsed,
|
||||||
|
gasLimitRecommended,
|
||||||
|
estimatedFee,
|
||||||
|
estimatedFeeHuman: `${(estimatedFee / 100_000_000).toFixed(8)} SYN`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.fetch<GasEstimateResponse>('/estimate-gas', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGasCosts(): Promise<GasCosts> {
|
||||||
|
if (this.useMock) {
|
||||||
|
return {
|
||||||
|
transfer: 21000,
|
||||||
|
contractDeployBase: 32000,
|
||||||
|
contractCallBase: 21000,
|
||||||
|
storage: { sstore: 20000, sload: 2100 },
|
||||||
|
memory: { perWord: 3 },
|
||||||
|
signature: { ed25519: 3000, dilithium: 15000 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.fetch<GasCosts>('/gas-costs');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -143,3 +143,49 @@ export interface HealthStatus {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
rpcConnected: boolean;
|
rpcConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gas Estimation Types
|
||||||
|
export interface GasEstimateRequest {
|
||||||
|
/** Contract address to call */
|
||||||
|
to: string;
|
||||||
|
/** Sender address (optional) */
|
||||||
|
from?: string;
|
||||||
|
/** Call data (hex encoded) */
|
||||||
|
data?: string;
|
||||||
|
/** Value to send in sompi */
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GasEstimateResponse {
|
||||||
|
/** Estimated gas usage */
|
||||||
|
gasUsed: number;
|
||||||
|
/** Recommended gas limit (with 20% buffer) */
|
||||||
|
gasLimitRecommended: number;
|
||||||
|
/** Estimated fee in sompi */
|
||||||
|
estimatedFee: number;
|
||||||
|
/** Human-readable fee */
|
||||||
|
estimatedFeeHuman: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GasCosts {
|
||||||
|
/** Transfer between addresses */
|
||||||
|
transfer: number;
|
||||||
|
/** Contract deployment base cost */
|
||||||
|
contractDeployBase: number;
|
||||||
|
/** Contract call base cost */
|
||||||
|
contractCallBase: number;
|
||||||
|
/** Storage operations */
|
||||||
|
storage: {
|
||||||
|
sstore: number;
|
||||||
|
sload: number;
|
||||||
|
};
|
||||||
|
/** Memory operations */
|
||||||
|
memory: {
|
||||||
|
perWord: number;
|
||||||
|
};
|
||||||
|
/** Signature verification */
|
||||||
|
signature: {
|
||||||
|
ed25519: number;
|
||||||
|
dilithium: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
388
apps/explorer-web/src/pages/GasEstimator.tsx
Normal file
388
apps/explorer-web/src/pages/GasEstimator.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
/**
|
||||||
|
* Gas Estimator Tool - Interactive gas estimation for transactions and contracts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Fuel,
|
||||||
|
Calculator,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
Info,
|
||||||
|
ArrowRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useGasCosts, useGasEstimate } from '../hooks/useApi';
|
||||||
|
import type { GasEstimateRequest } from '../lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Synor address format
|
||||||
|
* Addresses should be bech32 encoded with 'synor1' or 'tsynor1' prefix
|
||||||
|
*/
|
||||||
|
function validateSynorAddress(address: string): { valid: boolean; error?: string } {
|
||||||
|
if (!address) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check prefix
|
||||||
|
const hasValidPrefix = address.startsWith('synor1') || address.startsWith('tsynor1');
|
||||||
|
if (!hasValidPrefix) {
|
||||||
|
return { valid: false, error: 'Address must start with "synor1" or "tsynor1"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length (bech32 addresses are typically 42-62 chars for synor)
|
||||||
|
const minLength = address.startsWith('tsynor1') ? 40 : 39;
|
||||||
|
const maxLength = 90;
|
||||||
|
if (address.length < minLength || address.length > maxLength) {
|
||||||
|
return { valid: false, error: `Invalid address length (expected ${minLength}-${maxLength} chars)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for valid bech32 characters (lowercase alphanumeric except 1, b, i, o)
|
||||||
|
const bech32Chars = /^(synor1|tsynor1)[ac-hj-np-z02-9]+$/;
|
||||||
|
if (!bech32Chars.test(address)) {
|
||||||
|
return { valid: false, error: 'Address contains invalid characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hex data
|
||||||
|
*/
|
||||||
|
function validateHexData(data: string): { valid: boolean; error?: string } {
|
||||||
|
if (!data) {
|
||||||
|
return { valid: true }; // Empty is valid (optional field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.startsWith('0x')) {
|
||||||
|
return { valid: false, error: 'Hex data must start with "0x"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexPattern = /^0x[a-fA-F0-9]*$/;
|
||||||
|
if (!hexPattern.test(data)) {
|
||||||
|
return { valid: false, error: 'Invalid hex characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GasEstimator() {
|
||||||
|
const { data: gasCosts, isLoading: costsLoading } = useGasCosts();
|
||||||
|
|
||||||
|
// Form state for custom estimation
|
||||||
|
const [toAddress, setToAddress] = useState('');
|
||||||
|
const [fromAddress, setFromAddress] = useState('');
|
||||||
|
const [callData, setCallData] = useState('');
|
||||||
|
const [value, setValue] = useState('0');
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const toValidation = useMemo(() => validateSynorAddress(toAddress), [toAddress]);
|
||||||
|
const fromValidation = useMemo(() =>
|
||||||
|
fromAddress ? validateSynorAddress(fromAddress) : { valid: true },
|
||||||
|
[fromAddress]
|
||||||
|
);
|
||||||
|
const dataValidation = useMemo(() => validateHexData(callData), [callData]);
|
||||||
|
|
||||||
|
// Only build request when all validations pass
|
||||||
|
const isFormValid = toValidation.valid && fromValidation.valid && dataValidation.valid;
|
||||||
|
|
||||||
|
// Build request only when we have a valid to address
|
||||||
|
const estimateRequest: GasEstimateRequest | null = isFormValid && toAddress
|
||||||
|
? {
|
||||||
|
to: toAddress,
|
||||||
|
from: fromAddress || undefined,
|
||||||
|
data: callData || undefined,
|
||||||
|
value: value ? parseInt(value, 10) : undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: gasEstimate,
|
||||||
|
isLoading: estimating,
|
||||||
|
error: estimateError,
|
||||||
|
} = useGasEstimate(estimateRequest, { enabled: !!toAddress });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -top-10 left-0 w-[300px] h-[150px] bg-amber-500/10 rounded-full blur-[80px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-500/20 border border-amber-500/30">
|
||||||
|
<Fuel size={28} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
||||||
|
Gas Estimator
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
Estimate transaction fees and gas usage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gas Cost Reference */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header flex items-center gap-2">
|
||||||
|
<Info size={18} className="text-synor-400" />
|
||||||
|
<h2 className="font-semibold">Gas Cost Reference</h2>
|
||||||
|
</div>
|
||||||
|
{costsLoading ? (
|
||||||
|
<div className="p-4 text-gray-400">Loading gas costs...</div>
|
||||||
|
) : gasCosts ? (
|
||||||
|
<div className="grid md:grid-cols-3 gap-4 p-4">
|
||||||
|
{/* Basic Operations */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
Basic Operations
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<GasCostRow label="Transfer" value={gasCosts.transfer} />
|
||||||
|
<GasCostRow label="Contract Call (base)" value={gasCosts.contractCallBase} />
|
||||||
|
<GasCostRow label="Contract Deploy (base)" value={gasCosts.contractDeployBase} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||||
|
<Clock size={14} />
|
||||||
|
Storage Operations
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<GasCostRow label="SSTORE (write)" value={gasCosts.storage.sstore} />
|
||||||
|
<GasCostRow label="SLOAD (read)" value={gasCosts.storage.sload} />
|
||||||
|
<GasCostRow label="Memory (per word)" value={gasCosts.memory.perWord} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cryptography */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400 flex items-center gap-2">
|
||||||
|
<Shield size={14} />
|
||||||
|
Signature Verification
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<GasCostRow label="Ed25519" value={gasCosts.signature.ed25519} />
|
||||||
|
<GasCostRow
|
||||||
|
label="Dilithium3 (PQC)"
|
||||||
|
value={gasCosts.signature.dilithium}
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-gray-400">Failed to load gas costs</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Gas Estimation Form */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header flex items-center gap-2">
|
||||||
|
<Calculator size={18} className="text-synor-400" />
|
||||||
|
<h2 className="font-semibold">Estimate Gas</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* To Address */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
To Address <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={toAddress}
|
||||||
|
onChange={(e) => setToAddress(e.target.value.toLowerCase())}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className={`w-full px-3 py-2 rounded-lg bg-gray-800/50 border focus:outline-none text-sm font-mono ${
|
||||||
|
toAddress && !toValidation.valid
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{toAddress && toValidation.error && (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-red-400">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
{toValidation.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From Address */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
From Address (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fromAddress}
|
||||||
|
onChange={(e) => setFromAddress(e.target.value.toLowerCase())}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className={`w-full px-3 py-2 rounded-lg bg-gray-800/50 border focus:outline-none text-sm font-mono ${
|
||||||
|
fromAddress && !fromValidation.valid
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{fromAddress && fromValidation.error && (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-red-400">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
{fromValidation.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Data */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Call Data (hex, optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={callData}
|
||||||
|
onChange={(e) => setCallData(e.target.value)}
|
||||||
|
placeholder="0x..."
|
||||||
|
className={`w-full px-3 py-2 rounded-lg bg-gray-800/50 border focus:outline-none text-sm font-mono ${
|
||||||
|
callData && !dataValidation.valid
|
||||||
|
? 'border-red-500 focus:border-red-500'
|
||||||
|
: 'border-gray-700 focus:border-synor-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{callData && dataValidation.error && (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-red-400">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
{dataValidation.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Value (sompi)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-gray-800/50 border border-gray-700 focus:border-synor-500 focus:outline-none text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{toAddress && (
|
||||||
|
<div className="mt-6 p-4 rounded-lg bg-gray-800/30 border border-gray-700/50">
|
||||||
|
<h3 className="font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Zap size={16} className="text-amber-400" />
|
||||||
|
Estimation Results
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{estimating ? (
|
||||||
|
<div className="text-gray-400">Estimating gas...</div>
|
||||||
|
) : estimateError ? (
|
||||||
|
<div className="text-red-400 text-sm">
|
||||||
|
{estimateError.message || 'Failed to estimate gas'}
|
||||||
|
</div>
|
||||||
|
) : gasEstimate ? (
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<ResultCard
|
||||||
|
label="Gas Used"
|
||||||
|
value={gasEstimate.gasUsed.toLocaleString()}
|
||||||
|
sublabel="actual gas"
|
||||||
|
/>
|
||||||
|
<ResultCard
|
||||||
|
label="Recommended Limit"
|
||||||
|
value={gasEstimate.gasLimitRecommended.toLocaleString()}
|
||||||
|
sublabel="+20% buffer"
|
||||||
|
/>
|
||||||
|
<ResultCard
|
||||||
|
label="Estimated Fee"
|
||||||
|
value={gasEstimate.estimatedFee.toLocaleString()}
|
||||||
|
sublabel="sompi"
|
||||||
|
/>
|
||||||
|
<ResultCard
|
||||||
|
label="Fee (SYN)"
|
||||||
|
value={gasEstimate.estimatedFeeHuman}
|
||||||
|
sublabel="human readable"
|
||||||
|
highlight
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-400">Enter an address to estimate gas</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-synor-500/10 border border-synor-500/30 rounded-xl p-4">
|
||||||
|
<h3 className="font-medium text-synor-400 mb-2">About Gas in Synor</h3>
|
||||||
|
<ul className="text-sm text-gray-400 space-y-1">
|
||||||
|
<li>• Gas measures computational effort required for operations</li>
|
||||||
|
<li>• Base fee is 1 sompi per gas unit (subject to network conditions)</li>
|
||||||
|
<li>• Dilithium3 signatures use ~5x more gas than Ed25519 due to larger size</li>
|
||||||
|
<li>• Contract calls include base cost + execution cost + storage changes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GasCostRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<span className="text-gray-400">{label}</span>
|
||||||
|
<span className={`font-mono ${highlight ? 'text-amber-400' : 'text-gray-200'}`}>
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sublabel,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sublabel: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-lg ${
|
||||||
|
highlight
|
||||||
|
? 'bg-amber-500/10 border border-amber-500/30'
|
||||||
|
: 'bg-gray-800/50 border border-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div
|
||||||
|
className={`text-lg font-bold font-mono ${
|
||||||
|
highlight ? 'text-amber-400' : 'text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{sublabel}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -203,6 +203,98 @@ services:
|
||||||
profiles:
|
profiles:
|
||||||
- security
|
- security
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Prometheus - Metrics Collection
|
||||||
|
# ==========================================================================
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.48.0
|
||||||
|
container_name: synor-prometheus
|
||||||
|
hostname: prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=30d'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
profiles:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Grafana - Visualization & Dashboards
|
||||||
|
# ==========================================================================
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.2.2
|
||||||
|
container_name: synor-grafana
|
||||||
|
hostname: grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3000" # Using 3001 to avoid conflict with explorer-api
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=synor123
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
profiles:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Alertmanager - Alert Routing & Notifications
|
||||||
|
# ==========================================================================
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.26.0
|
||||||
|
container_name: synor-alertmanager
|
||||||
|
hostname: alertmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||||
|
- '--storage.path=/alertmanager'
|
||||||
|
ports:
|
||||||
|
- "9093:9093"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||||
|
- alertmanager-data:/alertmanager
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
profiles:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Node Exporter - System Metrics (optional but useful)
|
||||||
|
# Note: Limited functionality on macOS Docker Desktop
|
||||||
|
# ==========================================================================
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.7.0
|
||||||
|
container_name: synor-node-exporter
|
||||||
|
hostname: node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
# Note: Root filesystem mount doesn't work on macOS Docker Desktop
|
||||||
|
# volumes:
|
||||||
|
# - /:/host:ro,rslave
|
||||||
|
# command:
|
||||||
|
# - '--path.rootfs=/host'
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
profiles:
|
||||||
|
- monitoring
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Networks
|
# Networks
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -222,3 +314,6 @@ volumes:
|
||||||
seed2-data:
|
seed2-data:
|
||||||
seed3-data:
|
seed3-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
|
alertmanager-data:
|
||||||
|
|
|
||||||
91
monitoring/alertmanager.yml
Normal file
91
monitoring/alertmanager.yml
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Alertmanager Configuration for Synor Testnet
|
||||||
|
# Routes alerts to appropriate channels based on severity
|
||||||
|
|
||||||
|
global:
|
||||||
|
# Default timeout for resolving alerts
|
||||||
|
resolve_timeout: 5m
|
||||||
|
|
||||||
|
# Alert routing configuration
|
||||||
|
route:
|
||||||
|
# Group alerts by alertname and instance
|
||||||
|
group_by: ['alertname', 'instance']
|
||||||
|
|
||||||
|
# Wait before sending initial notification
|
||||||
|
group_wait: 30s
|
||||||
|
|
||||||
|
# Wait between sending notifications for new alerts in same group
|
||||||
|
group_interval: 5m
|
||||||
|
|
||||||
|
# Wait before resending notification for same alert
|
||||||
|
repeat_interval: 4h
|
||||||
|
|
||||||
|
# Default receiver
|
||||||
|
receiver: 'default-receiver'
|
||||||
|
|
||||||
|
# Child routes for specific severities
|
||||||
|
routes:
|
||||||
|
# Critical alerts - immediate notification
|
||||||
|
- match:
|
||||||
|
severity: critical
|
||||||
|
receiver: 'critical-receiver'
|
||||||
|
group_wait: 10s
|
||||||
|
repeat_interval: 1h
|
||||||
|
continue: true
|
||||||
|
|
||||||
|
# Warning alerts - batched notification
|
||||||
|
- match:
|
||||||
|
severity: warning
|
||||||
|
receiver: 'warning-receiver'
|
||||||
|
group_wait: 1m
|
||||||
|
repeat_interval: 6h
|
||||||
|
|
||||||
|
# Receivers define where alerts go
|
||||||
|
receivers:
|
||||||
|
- name: 'default-receiver'
|
||||||
|
# Default: log to stdout (visible in docker logs)
|
||||||
|
webhook_configs: []
|
||||||
|
|
||||||
|
- name: 'critical-receiver'
|
||||||
|
# Critical alerts - configure your preferred channel
|
||||||
|
# Example: Discord webhook (uncomment and add your URL)
|
||||||
|
# webhook_configs:
|
||||||
|
# - url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN'
|
||||||
|
# send_resolved: true
|
||||||
|
# http_config:
|
||||||
|
# follow_redirects: true
|
||||||
|
|
||||||
|
# Example: Slack webhook (uncomment and add your URL)
|
||||||
|
# slack_configs:
|
||||||
|
# - api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
|
||||||
|
# channel: '#synor-alerts'
|
||||||
|
# title: '{{ .Status | toUpper }}: {{ .CommonAnnotations.summary }}'
|
||||||
|
# text: '{{ .CommonAnnotations.description }}'
|
||||||
|
# send_resolved: true
|
||||||
|
webhook_configs: []
|
||||||
|
|
||||||
|
- name: 'warning-receiver'
|
||||||
|
# Warning alerts - lower priority channel
|
||||||
|
webhook_configs: []
|
||||||
|
|
||||||
|
# Inhibition rules - suppress lower severity when higher fires
|
||||||
|
inhibit_rules:
|
||||||
|
# If SynorNodeDown fires, suppress other alerts for same instance
|
||||||
|
- source_match:
|
||||||
|
alertname: 'SynorNodeDown'
|
||||||
|
target_match_re:
|
||||||
|
alertname: 'Synor.*'
|
||||||
|
equal: ['instance']
|
||||||
|
|
||||||
|
# If NetworkPartition fires, suppress LowPeerCount
|
||||||
|
- source_match:
|
||||||
|
alertname: 'SynorNetworkPartition'
|
||||||
|
target_match:
|
||||||
|
alertname: 'SynorLowPeerCount'
|
||||||
|
equal: ['instance']
|
||||||
|
|
||||||
|
# Critical suppresses warning for same alert type
|
||||||
|
- source_match:
|
||||||
|
severity: 'critical'
|
||||||
|
target_match:
|
||||||
|
severity: 'warning'
|
||||||
|
equal: ['alertname', 'instance']
|
||||||
172
monitoring/alerts.yml
Normal file
172
monitoring/alerts.yml
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Synor Testnet Alert Rules
|
||||||
|
# For 30-day stability validation
|
||||||
|
|
||||||
|
groups:
|
||||||
|
# ==========================================================================
|
||||||
|
# Node Health Alerts
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_node_health
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# Node Down Alert
|
||||||
|
- alert: SynorNodeDown
|
||||||
|
expr: up{job="synor-nodes"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Synor node {{ $labels.instance }} is down"
|
||||||
|
description: "Node {{ $labels.instance }} has been unreachable for more than 2 minutes."
|
||||||
|
|
||||||
|
# Node Restarted
|
||||||
|
- alert: SynorNodeRestarted
|
||||||
|
expr: changes(process_start_time_seconds{job="synor-nodes"}[5m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Synor node {{ $labels.instance }} restarted"
|
||||||
|
description: "Node has restarted in the last 5 minutes."
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Consensus Alerts
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_consensus
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
# No new blocks for 10 minutes (at 10 BPS, this is critical)
|
||||||
|
- alert: SynorNoNewBlocks
|
||||||
|
expr: increase(synor_block_count_total[10m]) == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "No new blocks produced on {{ $labels.instance }}"
|
||||||
|
description: "No blocks have been produced in the last 10 minutes. Consensus may be stalled."
|
||||||
|
|
||||||
|
# Block rate too low (< 5 BPS when target is 10)
|
||||||
|
- alert: SynorLowBlockRate
|
||||||
|
expr: rate(synor_block_count_total[5m]) < 5
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Low block rate on {{ $labels.instance }}"
|
||||||
|
description: "Block rate is {{ $value | humanize }}/s (target: 10/s)"
|
||||||
|
|
||||||
|
# DAA Score not increasing
|
||||||
|
- alert: SynorDaaScoreStalled
|
||||||
|
expr: increase(synor_daa_score[5m]) == 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "DAA score stalled on {{ $labels.instance }}"
|
||||||
|
description: "DAA score has not increased in 5 minutes."
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Network Alerts
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_network
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
# Low peer count
|
||||||
|
- alert: SynorLowPeerCount
|
||||||
|
expr: synor_peer_count < 2
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Low peer count on {{ $labels.instance }}"
|
||||||
|
description: "Node has only {{ $value }} peers (minimum recommended: 3)"
|
||||||
|
|
||||||
|
# Network partition (node isolated)
|
||||||
|
- alert: SynorNetworkPartition
|
||||||
|
expr: synor_peer_count == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Node {{ $labels.instance }} is isolated"
|
||||||
|
description: "Node has 0 peers - possible network partition."
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Mempool Alerts
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_mempool
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
# Mempool growing too large
|
||||||
|
- alert: SynorMempoolOverflow
|
||||||
|
expr: synor_mempool_size > 10000
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Mempool overflow on {{ $labels.instance }}"
|
||||||
|
description: "Mempool has {{ $value }} transactions (threshold: 10000)"
|
||||||
|
|
||||||
|
# Mempool not draining
|
||||||
|
- alert: SynorMempoolStale
|
||||||
|
expr: synor_mempool_size > 100 and increase(synor_mempool_txs_removed[10m]) == 0
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Mempool not draining on {{ $labels.instance }}"
|
||||||
|
description: "Mempool has {{ $value }} transactions but none are being processed."
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Resource Alerts
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_resources
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# High CPU usage
|
||||||
|
- alert: SynorHighCpuUsage
|
||||||
|
expr: rate(process_cpu_seconds_total{job="synor-nodes"}[5m]) > 0.9
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High CPU usage on {{ $labels.instance }}"
|
||||||
|
description: "CPU usage is {{ $value | humanizePercentage }}"
|
||||||
|
|
||||||
|
# High memory usage
|
||||||
|
- alert: SynorHighMemoryUsage
|
||||||
|
expr: process_resident_memory_bytes{job="synor-nodes"} > 4e9
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "High memory usage on {{ $labels.instance }}"
|
||||||
|
description: "Memory usage is {{ $value | humanize1024 }}"
|
||||||
|
|
||||||
|
# Disk space low (host)
|
||||||
|
- alert: SynorLowDiskSpace
|
||||||
|
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.1
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Low disk space on host"
|
||||||
|
description: "Only {{ $value | humanizePercentage }} disk space remaining"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Uptime Tracking (for 99.9% SLA)
|
||||||
|
# ==========================================================================
|
||||||
|
- name: synor_uptime
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
# Record uptime for SLA calculation
|
||||||
|
- record: synor:uptime_ratio:30d
|
||||||
|
expr: avg_over_time(up{job="synor-nodes"}[30d])
|
||||||
|
|
||||||
|
# Alert if below 99.9% uptime target
|
||||||
|
- alert: SynorUptimeBelowSLA
|
||||||
|
expr: synor:uptime_ratio:30d < 0.999
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Uptime below SLA target"
|
||||||
|
description: "30-day uptime is {{ $value | humanizePercentage }} (target: 99.9%)"
|
||||||
345
monitoring/grafana/dashboards/synor-testnet.json
Normal file
345
monitoring/grafana/dashboards/synor-testnet.json
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Synor Testnet - 30-Day Stability Monitoring",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"liveNow": false,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||||
|
"id": 100,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Overview",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"mappings": [
|
||||||
|
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||||
|
],
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
|
||||||
|
"id": 1,
|
||||||
|
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"pluginVersion": "10.2.0",
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "up{job=\"synor-nodes\"}", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "Node Status",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"decimals": 2,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 5 }, { "color": "green", "value": 8 }] },
|
||||||
|
"unit": "blocks/s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 },
|
||||||
|
"id": 2,
|
||||||
|
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(synor_block_count_total[5m])", "legendFormat": "Block Rate", "refId": "A" }],
|
||||||
|
"title": "Block Rate",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 },
|
||||||
|
"id": 3,
|
||||||
|
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_daa_score", "legendFormat": "DAA Score", "refId": "A" }],
|
||||||
|
"title": "DAA Score",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 2 }, { "color": "green", "value": 3 }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 },
|
||||||
|
"id": 4,
|
||||||
|
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_peer_count", "legendFormat": "Peers", "refId": "A" }],
|
||||||
|
"title": "Peer Count",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5000 }, { "color": "red", "value": 10000 }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 },
|
||||||
|
"id": 5,
|
||||||
|
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_mempool_size", "legendFormat": "Mempool", "refId": "A" }],
|
||||||
|
"title": "Mempool Size",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"decimals": 3,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.99 }, { "color": "green", "value": 0.999 }] },
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 },
|
||||||
|
"id": 6,
|
||||||
|
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor:uptime_ratio:30d", "legendFormat": "30d Uptime", "refId": "A" }],
|
||||||
|
"title": "30-Day Uptime",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
|
||||||
|
"id": 101,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Consensus",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line" } },
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 5 }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
|
||||||
|
"id": 10,
|
||||||
|
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(synor_block_count_total[1m])", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "Block Production Rate",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
|
||||||
|
"id": 11,
|
||||||
|
"options": { "legend": { "calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_daa_score", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "DAA Score Over Time",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
|
||||||
|
"id": 102,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Network",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line" } },
|
||||||
|
"mappings": [],
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 2 }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
|
||||||
|
"id": 20,
|
||||||
|
"options": { "legend": { "calcs": ["mean", "min"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_peer_count", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "Peer Connections",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line" } },
|
||||||
|
"mappings": [],
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 10000 }] },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
|
||||||
|
"id": 21,
|
||||||
|
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor_mempool_size", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "Mempool Size",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 },
|
||||||
|
"id": 103,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Resources",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line" } },
|
||||||
|
"mappings": [],
|
||||||
|
"max": 1,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.9 }] },
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||||
|
"id": 30,
|
||||||
|
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "rate(process_cpu_seconds_total{job=\"synor-nodes\"}[5m])", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "CPU Usage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line" } },
|
||||||
|
"mappings": [],
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 4000000000 }] },
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
|
||||||
|
"id": 31,
|
||||||
|
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "process_resident_memory_bytes{job=\"synor-nodes\"}", "legendFormat": "{{instance}}", "refId": "A" }],
|
||||||
|
"title": "Memory Usage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 },
|
||||||
|
"id": 104,
|
||||||
|
"panels": [],
|
||||||
|
"title": "30-Day SLA Tracking",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line+area" } },
|
||||||
|
"decimals": 4,
|
||||||
|
"mappings": [],
|
||||||
|
"max": 1,
|
||||||
|
"min": 0.99,
|
||||||
|
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 0.999 }, { "color": "green", "value": 0.9999 }] },
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 33 },
|
||||||
|
"id": 40,
|
||||||
|
"options": { "legend": { "calcs": ["lastNotNull", "min"], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||||
|
"targets": [
|
||||||
|
{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "synor:uptime_ratio:30d", "legendFormat": "30-Day Uptime", "refId": "A" },
|
||||||
|
{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "0.999", "legendFormat": "99.9% SLA Target", "refId": "B" }
|
||||||
|
],
|
||||||
|
"title": "Uptime vs SLA Target (99.9%)",
|
||||||
|
"type": "timeseries"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"tags": ["synor", "testnet", "blockchain"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": { "selected": false, "text": "Prometheus", "value": "Prometheus" },
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"multi": false,
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"options": [],
|
||||||
|
"query": "prometheus",
|
||||||
|
"refresh": 1,
|
||||||
|
"regex": "",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
|
||||||
|
"time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"]
|
||||||
|
},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Synor Testnet Dashboard",
|
||||||
|
"uid": "synor-testnet",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
16
monitoring/grafana/provisioning/dashboards/dashboards.yml
Normal file
16
monitoring/grafana/provisioning/dashboards/dashboards.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Grafana Dashboard Provisioning
|
||||||
|
# Auto-loads dashboards from /var/lib/grafana/dashboards
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'Synor Dashboards'
|
||||||
|
orgId: 1
|
||||||
|
folder: 'Synor'
|
||||||
|
folderUid: 'synor'
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
19
monitoring/grafana/provisioning/datasources/prometheus.yml
Normal file
19
monitoring/grafana/provisioning/datasources/prometheus.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Grafana Datasource Provisioning
|
||||||
|
# Auto-configures Prometheus as default datasource
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
httpMethod: POST
|
||||||
|
manageAlerts: true
|
||||||
|
prometheusType: Prometheus
|
||||||
|
prometheusVersion: "2.50.0"
|
||||||
|
# 30-day retention for testnet stability tracking
|
||||||
|
timeInterval: "15s"
|
||||||
102
monitoring/prometheus.yml
Normal file
102
monitoring/prometheus.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Prometheus Configuration for Synor Testnet Monitoring
|
||||||
|
# 30-day retention configured in docker-compose
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
cluster: 'synor-testnet'
|
||||||
|
environment: 'testnet'
|
||||||
|
|
||||||
|
# Alertmanager configuration
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets:
|
||||||
|
- alertmanager:9093
|
||||||
|
|
||||||
|
# Load alert rules
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/alerts.yml
|
||||||
|
|
||||||
|
# Scrape configurations
|
||||||
|
scrape_configs:
|
||||||
|
# ==========================================================================
|
||||||
|
# Prometheus Self-Monitoring
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
labels:
|
||||||
|
service: 'prometheus'
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Synor Seed Nodes
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'synor-nodes'
|
||||||
|
scrape_interval: 10s
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'seed1:17110' # Seed 1 RPC (metrics endpoint assumed at /metrics)
|
||||||
|
- 'seed2:17110' # Seed 2
|
||||||
|
- 'seed3:17110' # Seed 3
|
||||||
|
labels:
|
||||||
|
network: 'testnet'
|
||||||
|
metrics_path: /metrics
|
||||||
|
# If no /metrics endpoint, use blackbox exporter pattern below
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Synor Node Health Checks (via HTTP probe)
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'synor-health'
|
||||||
|
scrape_interval: 30s
|
||||||
|
metrics_path: /probe
|
||||||
|
params:
|
||||||
|
module: [http_2xx]
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'http://seed1:17110/health'
|
||||||
|
- 'http://seed2:17110/health'
|
||||||
|
- 'http://seed3:17110/health'
|
||||||
|
labels:
|
||||||
|
network: 'testnet'
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__address__]
|
||||||
|
target_label: __param_target
|
||||||
|
- source_labels: [__param_target]
|
||||||
|
target_label: instance
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: blackbox-exporter:9115 # If using blackbox exporter
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# RPC Endpoint Monitoring (custom metrics via script)
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'synor-rpc-stats'
|
||||||
|
scrape_interval: 30s
|
||||||
|
static_configs:
|
||||||
|
- targets:
|
||||||
|
- 'seed1:17110'
|
||||||
|
- 'seed2:17110'
|
||||||
|
- 'seed3:17110'
|
||||||
|
metrics_path: /api/v1/stats
|
||||||
|
# Note: This assumes the /stats endpoint returns Prometheus-compatible format
|
||||||
|
# If not, we'll use a custom exporter
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Node Exporter (System Metrics)
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'node-exporter'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
labels:
|
||||||
|
service: 'node-exporter'
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Explorer API
|
||||||
|
# ==========================================================================
|
||||||
|
- job_name: 'explorer-api'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['explorer-api:3000']
|
||||||
|
labels:
|
||||||
|
service: 'explorer'
|
||||||
|
metrics_path: /health
|
||||||
Loading…
Add table
Reference in a new issue