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:
Gulshan Yadav 2026-01-10 04:38:09 +05:30
parent 1606776394
commit 6b5a232a5e
45 changed files with 5023 additions and 4 deletions

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

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

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

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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

View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

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

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

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

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

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

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

View 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": []
}
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

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

View file

@ -9,6 +9,7 @@ import Address from './pages/Address';
import DAG from './pages/DAG';
import Network from './pages/Network';
import Search from './pages/Search';
import GasEstimator from './pages/GasEstimator';
export default function App() {
return (
@ -22,6 +23,7 @@ export default function App() {
<Route path="/address/:address" element={<Address />} />
<Route path="/dag" element={<DAG />} />
<Route path="/network" element={<Network />} />
<Route path="/gas" element={<GasEstimator />} />
<Route path="/search" element={<Search />} />
<Route path="*" element={<NotFound />} />
</Routes>

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
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 ThemeToggle from './ThemeToggle';
import SearchAutocomplete from './SearchAutocomplete';
@ -13,6 +13,7 @@ export default function Header() {
{ to: '/blocks', label: 'Blocks', icon: Box },
{ to: '/mempool', label: 'Mempool', icon: Clock },
{ to: '/dag', label: 'DAG', icon: Layers },
{ to: '/gas', label: 'Gas', icon: Fuel },
{ to: '/network', label: 'Network', icon: Server },
];

View file

@ -104,3 +104,32 @@ export function useDag(depth = 10): UseQueryResult<DagVisualization> {
export function useTips(): UseQueryResult<string[]> {
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]
);
}

View file

@ -15,6 +15,9 @@ import type {
SearchResult,
HealthStatus,
ApiError,
GasEstimateRequest,
GasEstimateResponse,
GasCosts,
} from './types';
import {
mockStats,
@ -28,18 +31,46 @@ import {
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') {
return true;
}
// Development only: allow env var override
return import.meta.env.VITE_USE_MOCK === 'true';
};
class ApiClient {
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;
if (typeof window !== 'undefined') {
localStorage.setItem('useMockApi', String(enable));
@ -231,6 +262,40 @@ class ApiClient {
}
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();

View file

@ -143,3 +143,49 @@ export interface HealthStatus {
healthy: 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;
};
}

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

View file

@ -203,6 +203,98 @@ services:
profiles:
- 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
# =============================================================================
@ -222,3 +314,6 @@ volumes:
seed2-data:
seed3-data:
postgres-data:
prometheus-data:
grafana-data:
alertmanager-data:

View 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
View 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%)"

View 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": ""
}

View 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

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