synor/apps/desktop-wallet/src-tauri/src/wallet.rs
Gulshan Yadav 6b5a232a5e 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
2026-01-10 04:38:09 +05:30

403 lines
12 KiB
Rust

//! 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)
}