desktop wallet enhancements

This commit is contained in:
Gulshan Yadav 2026-02-02 04:54:53 +05:30
parent 01ff14c0a9
commit 5cd6fdcb35
16 changed files with 4990 additions and 10 deletions

1919
apps/desktop-wallet/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -42,14 +42,32 @@ bech32 = "0.11"
# OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service)
keyring = "3"
# Local crates from the monorepo (optional - for direct integration with core)
synor-crypto = { path = "../../../crates/synor-crypto", optional = true }
synor-types = { path = "../../../crates/synor-types", optional = true }
synor-rpc = { path = "../../../crates/synor-rpc", optional = true }
# HTTP client for RPC calls
reqwest = { version = "0.12", features = ["json"] }
# WebSocket client for real-time events
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Local crates from the monorepo (required for wallet functionality)
synor-crypto = { path = "../../../crates/synor-crypto" }
synor-types = { path = "../../../crates/synor-types" }
synor-rpc = { path = "../../../crates/synor-rpc" }
# Optional: Embedded node support (enables running a full node inside the wallet)
synord = { path = "../../../apps/synord", optional = true }
synor-mining = { path = "../../../crates/synor-mining", optional = true }
synor-network = { path = "../../../crates/synor-network", optional = true }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# Enable embedded node support - compiles full node into wallet binary
embedded-node = ["dep:synord", "dep:synor-mining", "dep:synor-network"]
[profile.release]
lto = true

View file

@ -373,3 +373,404 @@ pub async fn get_network_status(state: State<'_, WalletState>) -> Result<Network
})
}
}
// ============================================================================
// Node Management Commands
// ============================================================================
use crate::node::{ConnectionMode, NodeManager, NodeStatus, PeerInfo, SyncProgress};
use crate::rpc_client::RpcClient;
use std::path::PathBuf;
use std::sync::Arc;
/// Application state containing node manager and RPC client
pub struct AppState {
pub node_manager: Arc<NodeManager>,
pub rpc_client: Arc<RpcClient>,
}
/// Connect to external RPC node
#[tauri::command]
pub async fn node_connect_external(
state: State<'_, AppState>,
http_url: String,
ws_url: Option<String>,
) -> Result<NodeStatus> {
state
.node_manager
.connect_external(http_url, ws_url)
.await?;
Ok(state.node_manager.status().await)
}
/// Start embedded node (requires embedded-node feature)
#[tauri::command]
pub async fn node_start_embedded(
state: State<'_, AppState>,
network: String,
data_dir: Option<String>,
mining_enabled: bool,
coinbase_address: Option<String>,
mining_threads: usize,
) -> Result<NodeStatus> {
let data_dir = data_dir.map(PathBuf::from);
state
.node_manager
.start_embedded_node(
&network,
data_dir,
mining_enabled,
coinbase_address,
mining_threads,
)
.await?;
Ok(state.node_manager.status().await)
}
/// Stop the current node connection
#[tauri::command]
pub async fn node_stop(state: State<'_, AppState>) -> Result<()> {
state.node_manager.disconnect().await
}
/// Get current node status
#[tauri::command]
pub async fn node_get_status(state: State<'_, AppState>) -> Result<NodeStatus> {
state.node_manager.refresh_status().await
}
/// Get current connection mode
#[tauri::command]
pub async fn node_get_connection_mode(state: State<'_, AppState>) -> Result<ConnectionMode> {
Ok(state.node_manager.connection_mode().await)
}
/// Get connected peers
#[tauri::command]
pub async fn node_get_peers(state: State<'_, AppState>) -> Result<Vec<PeerInfo>> {
state.rpc_client.get_peers().await
}
/// Get sync progress
#[tauri::command]
pub async fn node_get_sync_progress(state: State<'_, AppState>) -> Result<SyncProgress> {
let status = state.node_manager.status().await;
Ok(SyncProgress {
current_height: status.block_height,
target_height: status.block_height, // TODO: Get from peers
progress: status.sync_progress,
eta_seconds: None,
status: if status.is_syncing {
"Syncing...".to_string()
} else {
"Synced".to_string()
},
})
}
// ============================================================================
// Mining Commands
// ============================================================================
/// Mining status
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MiningStatus {
/// Is mining active
pub is_mining: bool,
/// Is mining paused
pub is_paused: bool,
/// Current hashrate (H/s)
pub hashrate: f64,
/// Blocks found in this session
pub blocks_found: u64,
/// Total shares submitted
pub shares_submitted: u64,
/// Number of mining threads
pub threads: usize,
/// Coinbase address for rewards
pub coinbase_address: Option<String>,
}
/// Mining stats (more detailed)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MiningStats {
/// Current hashrate (H/s)
pub hashrate: f64,
/// Average hashrate (H/s)
pub avg_hashrate: f64,
/// Peak hashrate (H/s)
pub peak_hashrate: f64,
/// Blocks found
pub blocks_found: u64,
/// Rejected blocks
pub blocks_rejected: u64,
/// Estimated daily coins
pub estimated_daily_coins: f64,
/// Mining uptime in seconds
pub uptime_seconds: u64,
/// Per-thread hashrates
pub thread_hashrates: Vec<f64>,
}
/// Global mining state
pub struct MiningState {
pub is_mining: std::sync::atomic::AtomicBool,
pub is_paused: std::sync::atomic::AtomicBool,
pub threads: std::sync::atomic::AtomicUsize,
pub coinbase_address: tokio::sync::RwLock<Option<String>>,
pub blocks_found: std::sync::atomic::AtomicU64,
pub hashrate: tokio::sync::RwLock<f64>,
}
impl MiningState {
pub fn new() -> Self {
MiningState {
is_mining: std::sync::atomic::AtomicBool::new(false),
is_paused: std::sync::atomic::AtomicBool::new(false),
threads: std::sync::atomic::AtomicUsize::new(0),
coinbase_address: tokio::sync::RwLock::new(None),
blocks_found: std::sync::atomic::AtomicU64::new(0),
hashrate: tokio::sync::RwLock::new(0.0),
}
}
}
impl Default for MiningState {
fn default() -> Self {
Self::new()
}
}
/// Start mining
#[tauri::command]
pub async fn mining_start(
app_state: State<'_, AppState>,
mining_state: State<'_, MiningState>,
coinbase_address: String,
threads: usize,
) -> Result<MiningStatus> {
use std::sync::atomic::Ordering;
// Verify we're connected to a node
let mode = app_state.node_manager.connection_mode().await;
if matches!(mode, ConnectionMode::Disconnected) {
return Err(Error::NotConnected);
}
// Store mining configuration
*mining_state.coinbase_address.write().await = Some(coinbase_address.clone());
mining_state.threads.store(threads, Ordering::SeqCst);
mining_state.is_mining.store(true, Ordering::SeqCst);
mining_state.is_paused.store(false, Ordering::SeqCst);
// TODO: Actually start mining via embedded node or external RPC
// For embedded node with mining feature:
// if let Some(node) = app_state.node_manager.embedded_node().await {
// node.miner().start().await?;
// }
Ok(MiningStatus {
is_mining: true,
is_paused: false,
hashrate: 0.0,
blocks_found: 0,
shares_submitted: 0,
threads,
coinbase_address: Some(coinbase_address),
})
}
/// Stop mining
#[tauri::command]
pub async fn mining_stop(
mining_state: State<'_, MiningState>,
) -> Result<()> {
use std::sync::atomic::Ordering;
mining_state.is_mining.store(false, Ordering::SeqCst);
mining_state.is_paused.store(false, Ordering::SeqCst);
*mining_state.hashrate.write().await = 0.0;
// TODO: Actually stop mining
Ok(())
}
/// Pause mining
#[tauri::command]
pub async fn mining_pause(
mining_state: State<'_, MiningState>,
) -> Result<()> {
use std::sync::atomic::Ordering;
if !mining_state.is_mining.load(Ordering::SeqCst) {
return Err(Error::MiningError("Mining is not active".to_string()));
}
mining_state.is_paused.store(true, Ordering::SeqCst);
Ok(())
}
/// Resume mining
#[tauri::command]
pub async fn mining_resume(
mining_state: State<'_, MiningState>,
) -> Result<()> {
use std::sync::atomic::Ordering;
if !mining_state.is_mining.load(Ordering::SeqCst) {
return Err(Error::MiningError("Mining is not active".to_string()));
}
mining_state.is_paused.store(false, Ordering::SeqCst);
Ok(())
}
/// Get mining status
#[tauri::command]
pub async fn mining_get_status(
mining_state: State<'_, MiningState>,
) -> Result<MiningStatus> {
use std::sync::atomic::Ordering;
Ok(MiningStatus {
is_mining: mining_state.is_mining.load(Ordering::SeqCst),
is_paused: mining_state.is_paused.load(Ordering::SeqCst),
hashrate: *mining_state.hashrate.read().await,
blocks_found: mining_state.blocks_found.load(Ordering::SeqCst),
shares_submitted: 0,
threads: mining_state.threads.load(Ordering::SeqCst),
coinbase_address: mining_state.coinbase_address.read().await.clone(),
})
}
/// Get detailed mining stats
#[tauri::command]
pub async fn mining_get_stats(
mining_state: State<'_, MiningState>,
) -> Result<MiningStats> {
use std::sync::atomic::Ordering;
let hashrate = *mining_state.hashrate.read().await;
let threads = mining_state.threads.load(Ordering::SeqCst);
Ok(MiningStats {
hashrate,
avg_hashrate: hashrate,
peak_hashrate: hashrate,
blocks_found: mining_state.blocks_found.load(Ordering::SeqCst),
blocks_rejected: 0,
estimated_daily_coins: 0.0, // TODO: Calculate based on network difficulty
uptime_seconds: 0,
thread_hashrates: vec![hashrate / threads.max(1) as f64; threads],
})
}
/// Set mining threads
#[tauri::command]
pub async fn mining_set_threads(
mining_state: State<'_, MiningState>,
threads: usize,
) -> Result<()> {
use std::sync::atomic::Ordering;
if threads == 0 {
return Err(Error::MiningError("Threads must be greater than 0".to_string()));
}
mining_state.threads.store(threads, Ordering::SeqCst);
// TODO: Actually adjust mining threads
Ok(())
}
// ============================================================================
// Enhanced Wallet Commands (using RPC client)
// ============================================================================
/// Get balance using RPC client
#[tauri::command]
pub async fn wallet_get_balance(
wallet_state: State<'_, WalletState>,
app_state: State<'_, AppState>,
) -> Result<BalanceResponse> {
let addresses = wallet_state.addresses.read().await;
if addresses.is_empty() {
return Ok(BalanceResponse {
balance: 0,
balance_human: "0 SYN".to_string(),
pending: 0,
});
}
let mut total_balance: u64 = 0;
for addr in addresses.iter() {
match app_state.rpc_client.get_balance(&addr.address).await {
Ok(balance) => {
total_balance += balance.balance;
}
Err(e) => {
tracing::warn!("Failed to get balance for {}: {}", addr.address, e);
}
}
}
// Convert sompi to SYN (1 SYN = 100_000_000 sompi)
let syn = total_balance as f64 / 100_000_000.0;
let balance_human = format!("{:.8} SYN", syn);
Ok(BalanceResponse {
balance: total_balance,
balance_human,
pending: 0, // TODO: Track pending transactions
})
}
/// Get UTXOs using RPC client
#[tauri::command]
pub async fn wallet_get_utxos(
wallet_state: State<'_, WalletState>,
app_state: State<'_, AppState>,
) -> Result<Vec<crate::rpc_client::Utxo>> {
let addresses = wallet_state.addresses.read().await;
let mut all_utxos = Vec::new();
for addr in addresses.iter() {
match app_state.rpc_client.get_utxos(&addr.address).await {
Ok(utxos) => {
all_utxos.extend(utxos);
}
Err(e) => {
tracing::warn!("Failed to get UTXOs for {}: {}", addr.address, e);
}
}
}
Ok(all_utxos)
}
/// Get network info using RPC client
#[tauri::command]
pub async fn wallet_get_network_info(
app_state: State<'_, AppState>,
) -> Result<crate::rpc_client::NetworkInfo> {
app_state.rpc_client.get_network_info().await
}
/// Get fee estimate using RPC client
#[tauri::command]
pub async fn wallet_get_fee_estimate(
app_state: State<'_, AppState>,
) -> Result<crate::rpc_client::FeeEstimate> {
app_state.rpc_client.get_fee_estimate().await
}

View file

@ -44,6 +44,24 @@ pub enum Error {
#[error("Keychain error: {0}")]
Keychain(String),
#[error("Node error: {0}")]
NodeError(String),
#[error("Node is already running")]
NodeAlreadyRunning,
#[error("Node is not running")]
NodeNotRunning,
#[error("Not connected to any node")]
NotConnected,
#[error("Feature not enabled: {0}")]
FeatureNotEnabled(String),
#[error("Mining error: {0}")]
MiningError(String),
#[error("Internal error: {0}")]
Internal(String),
}

View file

@ -12,6 +12,8 @@ mod commands;
mod crypto;
mod error;
mod keychain;
mod node;
mod rpc_client;
mod wallet;
use tauri::{
@ -116,6 +118,27 @@ pub fn run() {
let wallet_state = wallet::WalletState::new();
app.manage(wallet_state);
// Initialize node manager with app handle for events
let node_manager = std::sync::Arc::new(
node::NodeManager::with_app_handle(app.handle().clone())
);
// Initialize RPC client
let rpc_client = std::sync::Arc::new(
rpc_client::RpcClient::new(node_manager.clone())
);
// Initialize app state (node + RPC)
let app_state = commands::AppState {
node_manager,
rpc_client,
};
app.manage(app_state);
// Initialize mining state
let mining_state = commands::MiningState::new();
app.manage(mining_state);
// Build and set up system tray
let menu = build_tray_menu(app.handle())?;
let _tray = TrayIconBuilder::new()
@ -165,10 +188,31 @@ pub fn run() {
commands::sign_transaction,
commands::broadcast_transaction,
commands::get_transaction_history,
// Network
// Network (legacy)
commands::connect_node,
commands::disconnect_node,
commands::get_network_status,
// Node management (new)
commands::node_connect_external,
commands::node_start_embedded,
commands::node_stop,
commands::node_get_status,
commands::node_get_connection_mode,
commands::node_get_peers,
commands::node_get_sync_progress,
// Mining
commands::mining_start,
commands::mining_stop,
commands::mining_pause,
commands::mining_resume,
commands::mining_get_status,
commands::mining_get_stats,
commands::mining_set_threads,
// Enhanced wallet (using RPC client)
commands::wallet_get_balance,
commands::wallet_get_utxos,
commands::wallet_get_network_info,
commands::wallet_get_fee_estimate,
// Updates
check_update,
install_update,

View file

@ -0,0 +1,480 @@
//! Embedded Node Module
//!
//! Provides optional embedded Synor node functionality for the desktop wallet.
//! When enabled with the `embedded-node` feature, users can run a full node
//! directly inside the wallet application.
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info, warn};
/// Node connection mode - embedded node or external RPC
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ConnectionMode {
/// No connection configured
Disconnected,
/// Connect to an external node via RPC
External {
http_url: String,
ws_url: Option<String>,
},
/// Run embedded node (requires `embedded-node` feature)
#[cfg(feature = "embedded-node")]
Embedded {
network: String,
data_dir: Option<PathBuf>,
},
}
impl Default for ConnectionMode {
fn default() -> Self {
ConnectionMode::Disconnected
}
}
/// Node status information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeStatus {
/// Connection mode
pub mode: ConnectionMode,
/// Whether the node is connected/running
pub is_connected: bool,
/// Current block height
pub block_height: u64,
/// Current blue score (DAG metric)
pub blue_score: u64,
/// Number of connected peers
pub peer_count: usize,
/// Whether the node is syncing
pub is_syncing: bool,
/// Sync progress (0.0 - 1.0)
pub sync_progress: f64,
/// Network name
pub network: String,
/// Chain ID
pub chain_id: u64,
}
impl Default for NodeStatus {
fn default() -> Self {
NodeStatus {
mode: ConnectionMode::Disconnected,
is_connected: false,
block_height: 0,
blue_score: 0,
peer_count: 0,
is_syncing: false,
sync_progress: 0.0,
network: String::new(),
chain_id: 0,
}
}
}
/// Sync progress information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SyncProgress {
/// Current block height
pub current_height: u64,
/// Target block height (highest known)
pub target_height: u64,
/// Progress percentage (0.0 - 100.0)
pub progress: f64,
/// Estimated time remaining in seconds
pub eta_seconds: Option<u64>,
/// Sync status message
pub status: String,
}
/// Peer information
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PeerInfo {
/// Peer ID
pub peer_id: String,
/// Peer address
pub address: String,
/// Connection direction (inbound/outbound)
pub direction: String,
/// Latency in milliseconds
pub latency_ms: Option<u64>,
/// Peer's block height
pub block_height: u64,
/// Connection status
pub status: String,
}
/// Manages node connection state and lifecycle
pub struct NodeManager {
/// Current connection mode
mode: RwLock<ConnectionMode>,
/// Cached status
status: RwLock<NodeStatus>,
/// Embedded node instance (when feature enabled)
#[cfg(feature = "embedded-node")]
embedded_node: RwLock<Option<Arc<synord::SynorNode>>>,
/// Tauri app handle for emitting events
app_handle: Option<tauri::AppHandle>,
}
impl NodeManager {
/// Creates a new node manager
pub fn new() -> Self {
NodeManager {
mode: RwLock::new(ConnectionMode::Disconnected),
status: RwLock::new(NodeStatus::default()),
#[cfg(feature = "embedded-node")]
embedded_node: RwLock::new(None),
app_handle: None,
}
}
/// Creates a new node manager with Tauri app handle for events
pub fn with_app_handle(app_handle: tauri::AppHandle) -> Self {
NodeManager {
mode: RwLock::new(ConnectionMode::Disconnected),
status: RwLock::new(NodeStatus::default()),
#[cfg(feature = "embedded-node")]
embedded_node: RwLock::new(None),
app_handle: Some(app_handle),
}
}
/// Gets the current connection mode
pub async fn connection_mode(&self) -> ConnectionMode {
self.mode.read().await.clone()
}
/// Gets the current node status
pub async fn status(&self) -> NodeStatus {
self.status.read().await.clone()
}
/// Connects to an external node via RPC
pub async fn connect_external(
&self,
http_url: String,
ws_url: Option<String>,
) -> crate::Result<()> {
info!(http_url = %http_url, "Connecting to external node");
// Update mode
*self.mode.write().await = ConnectionMode::External {
http_url: http_url.clone(),
ws_url: ws_url.clone(),
};
// Update status
let mut status = self.status.write().await;
status.mode = ConnectionMode::External {
http_url,
ws_url,
};
status.is_connected = true;
self.emit_status_changed(&status);
Ok(())
}
/// Disconnects from the current node
pub async fn disconnect(&self) -> crate::Result<()> {
let current_mode = self.mode.read().await.clone();
match current_mode {
ConnectionMode::Disconnected => {
// Already disconnected
Ok(())
}
ConnectionMode::External { .. } => {
info!("Disconnecting from external node");
*self.mode.write().await = ConnectionMode::Disconnected;
let mut status = self.status.write().await;
*status = NodeStatus::default();
self.emit_status_changed(&status);
Ok(())
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
self.stop_embedded_node().await
}
}
}
/// Emits a status changed event to the frontend
fn emit_status_changed(&self, status: &NodeStatus) {
if let Some(ref app) = self.app_handle {
use tauri::Emitter;
if let Err(e) = app.emit("node:status-changed", status) {
warn!("Failed to emit node status: {}", e);
}
}
}
/// Emits a sync progress event to the frontend
fn emit_sync_progress(&self, progress: &SyncProgress) {
if let Some(ref app) = self.app_handle {
use tauri::Emitter;
if let Err(e) = app.emit("node:sync-progress", progress) {
warn!("Failed to emit sync progress: {}", e);
}
}
}
}
// ============================================================================
// Embedded Node Support (feature-gated)
// ============================================================================
#[cfg(feature = "embedded-node")]
impl NodeManager {
/// Starts the embedded node
pub async fn start_embedded_node(
&self,
network: &str,
data_dir: Option<PathBuf>,
mining_enabled: bool,
coinbase_address: Option<String>,
mining_threads: usize,
) -> crate::Result<()> {
// Check if already running
if self.embedded_node.read().await.is_some() {
return Err(crate::Error::NodeAlreadyRunning);
}
info!(network = %network, "Starting embedded node");
// Create node configuration
let mut config = synord::NodeConfig::for_network(network)
.map_err(|e| crate::Error::NodeError(e.to_string()))?;
// Override data directory if specified
if let Some(dir) = data_dir.clone() {
config.data_dir = dir;
}
// Configure mining
if mining_enabled {
config.mining.enabled = true;
config.mining.coinbase_address = coinbase_address;
if mining_threads > 0 {
config.mining.threads = mining_threads;
}
}
// Configure RPC to use wallet-specific ports
config.rpc.http_addr = "127.0.0.1:19423".to_string();
config.rpc.ws_addr = "127.0.0.1:19424".to_string();
// Configure P2P to use wallet-specific port
config.p2p.listen_addr = format!("/ip4/0.0.0.0/tcp/{}", match network {
"mainnet" => 19422,
"testnet" => 19522,
"devnet" => 19622,
_ => 19422,
});
// Create and start the node
let node = synord::SynorNode::new(config)
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to create node: {}", e)))?;
node.start()
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to start node: {}", e)))?;
let node = Arc::new(node);
// Store the node
*self.embedded_node.write().await = Some(node.clone());
// Update mode and status
*self.mode.write().await = ConnectionMode::Embedded {
network: network.to_string(),
data_dir,
};
let node_info = node.info().await;
let mut status = self.status.write().await;
status.mode = self.mode.read().await.clone();
status.is_connected = true;
status.network = node_info.network;
status.chain_id = node_info.chain_id;
status.block_height = node_info.block_height;
status.blue_score = node_info.blue_score;
status.peer_count = node_info.peer_count;
status.is_syncing = node_info.is_syncing;
self.emit_status_changed(&status);
info!("Embedded node started successfully");
Ok(())
}
/// Stops the embedded node
pub async fn stop_embedded_node(&self) -> crate::Result<()> {
let node = self.embedded_node.write().await.take();
if let Some(node) = node {
info!("Stopping embedded node");
node.stop()
.await
.map_err(|e| crate::Error::NodeError(format!("Failed to stop node: {}", e)))?;
}
*self.mode.write().await = ConnectionMode::Disconnected;
let mut status = self.status.write().await;
*status = NodeStatus::default();
self.emit_status_changed(&status);
info!("Embedded node stopped");
Ok(())
}
/// Gets the embedded node if running
pub async fn embedded_node(&self) -> Option<Arc<synord::SynorNode>> {
self.embedded_node.read().await.clone()
}
/// Refreshes the node status from the embedded node
pub async fn refresh_status(&self) -> crate::Result<NodeStatus> {
if let Some(node) = self.embedded_node.read().await.as_ref() {
let node_info = node.info().await;
let state = node.state().await;
let mut status = self.status.write().await;
status.block_height = node_info.block_height;
status.blue_score = node_info.blue_score;
status.peer_count = node_info.peer_count;
status.is_syncing = node_info.is_syncing;
// Calculate sync progress if syncing
if node_info.is_syncing {
// In a real implementation, we'd get the target height from peers
status.sync_progress = 0.0; // Placeholder
} else {
status.sync_progress = 100.0;
}
self.emit_status_changed(&status);
Ok(status.clone())
} else {
Ok(self.status.read().await.clone())
}
}
/// Gets connected peers from the embedded node
pub async fn get_peers(&self) -> crate::Result<Vec<PeerInfo>> {
if let Some(node) = self.embedded_node.read().await.as_ref() {
let peer_count = node.network().peer_count().await;
// In a full implementation, we'd get detailed peer info from the network service
// For now, return a placeholder
Ok(vec![])
} else {
Ok(vec![])
}
}
}
// Stub implementations when embedded-node feature is disabled
#[cfg(not(feature = "embedded-node"))]
impl NodeManager {
/// Stub: Embedded node not available
pub async fn start_embedded_node(
&self,
_network: &str,
_data_dir: Option<PathBuf>,
_mining_enabled: bool,
_coinbase_address: Option<String>,
_mining_threads: usize,
) -> crate::Result<()> {
Err(crate::Error::FeatureNotEnabled("embedded-node".to_string()))
}
/// Stub: Embedded node not available
pub async fn stop_embedded_node(&self) -> crate::Result<()> {
Err(crate::Error::FeatureNotEnabled("embedded-node".to_string()))
}
/// Stub: No embedded node
pub async fn refresh_status(&self) -> crate::Result<NodeStatus> {
Ok(self.status.read().await.clone())
}
/// Stub: No peers without embedded node
pub async fn get_peers(&self) -> crate::Result<Vec<PeerInfo>> {
Ok(vec![])
}
}
impl Default for NodeManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection_mode_default() {
let mode = ConnectionMode::default();
assert_eq!(mode, ConnectionMode::Disconnected);
}
#[test]
fn test_node_status_default() {
let status = NodeStatus::default();
assert!(!status.is_connected);
assert_eq!(status.block_height, 0);
assert_eq!(status.peer_count, 0);
}
#[tokio::test]
async fn test_node_manager_creation() {
let manager = NodeManager::new();
let mode = manager.connection_mode().await;
assert_eq!(mode, ConnectionMode::Disconnected);
}
#[tokio::test]
async fn test_connect_external() {
let manager = NodeManager::new();
let result = manager
.connect_external(
"http://localhost:16110".to_string(),
Some("ws://localhost:16111".to_string()),
)
.await;
assert!(result.is_ok());
let mode = manager.connection_mode().await;
match mode {
ConnectionMode::External { http_url, ws_url } => {
assert_eq!(http_url, "http://localhost:16110");
assert_eq!(ws_url, Some("ws://localhost:16111".to_string()));
}
_ => panic!("Expected External mode"),
}
}
#[tokio::test]
async fn test_disconnect() {
let manager = NodeManager::new();
manager
.connect_external("http://localhost:16110".to_string(), None)
.await
.unwrap();
let result = manager.disconnect().await;
assert!(result.is_ok());
let mode = manager.connection_mode().await;
assert_eq!(mode, ConnectionMode::Disconnected);
}
}

View file

@ -0,0 +1,563 @@
//! RPC Client Abstraction
//!
//! Provides a unified interface for communicating with Synor nodes,
//! whether embedded or external. This allows the wallet to seamlessly
//! switch between connection modes.
use std::sync::Arc;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{debug, error, info};
use crate::node::{ConnectionMode, NodeManager, NodeStatus};
/// JSON-RPC 2.0 request
#[derive(Debug, Serialize)]
struct JsonRpcRequest<T> {
jsonrpc: &'static str,
method: String,
params: T,
id: u64,
}
/// JSON-RPC 2.0 response
#[derive(Debug, Deserialize)]
struct JsonRpcResponse<T> {
#[allow(dead_code)]
jsonrpc: String,
result: Option<T>,
error: Option<JsonRpcError>,
#[allow(dead_code)]
id: u64,
}
/// JSON-RPC 2.0 error
#[derive(Debug, Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
#[allow(dead_code)]
data: Option<serde_json::Value>,
}
/// Balance information
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Balance {
/// Address
pub address: String,
/// Balance in sompi (smallest unit)
pub balance: u64,
}
/// UTXO (Unspent Transaction Output)
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Utxo {
/// Transaction ID
pub transaction_id: String,
/// Output index
pub index: u32,
/// Amount in sompi
pub amount: u64,
/// Script public key (hex)
pub script_public_key: String,
/// Block DAA score when created
pub block_daa_score: u64,
/// Whether this is a coinbase output
pub is_coinbase: bool,
}
/// Transaction status
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionStatus {
/// Transaction ID
pub transaction_id: String,
/// Is confirmed
pub is_confirmed: bool,
/// Confirmations count
pub confirmations: u64,
/// Block hash (if confirmed)
pub block_hash: Option<String>,
/// Block time (if confirmed)
pub block_time: Option<u64>,
}
/// Transaction for broadcasting
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawTransaction {
/// Transaction version
pub version: u16,
/// Inputs
pub inputs: Vec<TransactionInput>,
/// Outputs
pub outputs: Vec<TransactionOutput>,
/// Lock time
pub lock_time: u64,
/// Subnetwork ID
pub subnetwork_id: String,
/// Gas
pub gas: u64,
/// Payload (hex)
pub payload: String,
}
/// Transaction input
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionInput {
/// Previous transaction ID
pub previous_transaction_id: String,
/// Previous output index
pub previous_index: u32,
/// Signature script (hex)
pub signature_script: String,
/// Sequence number
pub sequence: u64,
}
/// Transaction output
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransactionOutput {
/// Amount in sompi
pub amount: u64,
/// Script public key (hex)
pub script_public_key: String,
}
/// Network information
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkInfo {
/// Network name
pub network: String,
/// Is synced
pub is_synced: bool,
/// Current block height
pub block_height: u64,
/// Blue score
pub blue_score: u64,
/// Difficulty
pub difficulty: f64,
/// Peer count
pub peer_count: usize,
/// Mempool size
pub mempool_size: u64,
}
/// Fee estimate
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeeEstimate {
/// Priority fee rate (sompi per gram)
pub priority: f64,
/// Normal fee rate
pub normal: f64,
/// Low fee rate
pub low: f64,
}
/// Unified RPC client for communicating with Synor nodes
pub struct RpcClient {
/// Node manager for connection handling
node_manager: Arc<NodeManager>,
/// HTTP client for external RPC
http_client: reqwest::Client,
/// Request ID counter
request_id: RwLock<u64>,
}
impl RpcClient {
/// Creates a new RPC client
pub fn new(node_manager: Arc<NodeManager>) -> Self {
RpcClient {
node_manager,
http_client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client"),
request_id: RwLock::new(0),
}
}
/// Gets the next request ID
async fn next_id(&self) -> u64 {
let mut id = self.request_id.write().await;
*id += 1;
*id
}
/// Gets the current connection mode
pub async fn connection_mode(&self) -> ConnectionMode {
self.node_manager.connection_mode().await
}
/// Gets the current node status
pub async fn node_status(&self) -> NodeStatus {
self.node_manager.status().await
}
/// Makes an RPC call to an external node
async fn call_external<P, R>(&self, http_url: &str, method: &str, params: P) -> crate::Result<R>
where
P: Serialize,
R: DeserializeOwned,
{
let id = self.next_id().await;
let request = JsonRpcRequest {
jsonrpc: "2.0",
method: method.to_string(),
params,
id,
};
debug!(method = %method, id = %id, "Making external RPC call");
let response = self
.http_client
.post(http_url)
.json(&request)
.send()
.await
.map_err(|e| crate::Error::Rpc(format!("HTTP request failed: {}", e)))?;
let status = response.status();
if !status.is_success() {
return Err(crate::Error::Rpc(format!("HTTP error: {}", status)));
}
let rpc_response: JsonRpcResponse<R> = response
.json()
.await
.map_err(|e| crate::Error::Rpc(format!("Failed to parse response: {}", e)))?;
if let Some(error) = rpc_response.error {
return Err(crate::Error::Rpc(format!(
"RPC error {}: {}",
error.code, error.message
)));
}
rpc_response
.result
.ok_or_else(|| crate::Error::Rpc("Empty response".to_string()))
}
// ============================================================================
// Public API Methods
// ============================================================================
/// Gets the balance for an address
pub async fn get_balance(&self, address: &str) -> crate::Result<Balance> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBalanceByAddress", (address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
// Direct call to embedded node
if let Some(node) = self.node_manager.embedded_node().await {
// TODO: Implement direct UTXO query from embedded node
// For now, use the internal RPC
let rpc_addr = format!(
"http://{}",
node.config().rpc.http_addr
);
self.call_external(&rpc_addr, "synor_getBalanceByAddress", (address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets UTXOs for an address
pub async fn get_utxos(&self, address: &str) -> crate::Result<Vec<Utxo>> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getUtxosByAddress", (address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getUtxosByAddress", (address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Broadcasts a transaction to the network
pub async fn broadcast_transaction(&self, tx: &RawTransaction) -> crate::Result<String> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_submitTransaction", (tx,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_submitTransaction", (tx,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets transaction status
pub async fn get_transaction_status(&self, txid: &str) -> crate::Result<TransactionStatus> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getTransaction", (txid,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getTransaction", (txid,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets network information
pub async fn get_network_info(&self) -> crate::Result<NetworkInfo> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getInfo", ())
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
// Build network info from embedded node directly
let info = node.info().await;
Ok(NetworkInfo {
network: info.network,
is_synced: !info.is_syncing,
block_height: info.block_height,
blue_score: info.blue_score,
difficulty: 0.0, // TODO: Get from consensus
peer_count: info.peer_count,
mempool_size: 0, // TODO: Get from mempool
})
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets fee estimate
pub async fn get_fee_estimate(&self) -> crate::Result<FeeEstimate> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_estimateFee", ())
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_estimateFee", ())
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets block by hash
pub async fn get_block(&self, hash: &str) -> crate::Result<serde_json::Value> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBlock", (hash, true))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getBlock", (hash, true))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets current block template for mining
pub async fn get_block_template(&self, coinbase_address: &str) -> crate::Result<serde_json::Value> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_getBlockTemplate", (coinbase_address,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_getBlockTemplate", (coinbase_address,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Submits a mined block
pub async fn submit_block(&self, block: &serde_json::Value) -> crate::Result<bool> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
self.call_external(&http_url, "synor_submitBlock", (block,))
.await
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
if let Some(node) = self.node_manager.embedded_node().await {
let rpc_addr = format!("http://{}", node.config().rpc.http_addr);
self.call_external(&rpc_addr, "synor_submitBlock", (block,))
.await
} else {
Err(crate::Error::NodeNotRunning)
}
}
}
}
/// Gets connected peers
pub async fn get_peers(&self) -> crate::Result<Vec<crate::node::PeerInfo>> {
let mode = self.node_manager.connection_mode().await;
match mode {
ConnectionMode::Disconnected => Err(crate::Error::NotConnected),
ConnectionMode::External { http_url, .. } => {
// External: call RPC
let peers: Vec<serde_json::Value> = self
.call_external(&http_url, "net_getPeerInfo", ())
.await?;
// Convert to our PeerInfo type
let peer_infos = peers
.into_iter()
.map(|p| crate::node::PeerInfo {
peer_id: p["id"].as_str().unwrap_or("").to_string(),
address: p["address"].as_str().unwrap_or("").to_string(),
direction: if p["isOutbound"].as_bool().unwrap_or(false) {
"outbound"
} else {
"inbound"
}
.to_string(),
latency_ms: p["lastPingDuration"].as_u64(),
block_height: p["syncedHeaders"].as_u64().unwrap_or(0),
status: "connected".to_string(),
})
.collect();
Ok(peer_infos)
}
#[cfg(feature = "embedded-node")]
ConnectionMode::Embedded { .. } => {
self.node_manager.get_peers().await
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_balance_serialization() {
let balance = Balance {
address: "synor:qztest123".to_string(),
balance: 1_000_000_000,
};
let json = serde_json::to_string(&balance).unwrap();
assert!(json.contains("synor:qztest123"));
assert!(json.contains("1000000000"));
}
#[test]
fn test_utxo_serialization() {
let utxo = Utxo {
transaction_id: "abc123".to_string(),
index: 0,
amount: 5_000_000_000,
script_public_key: "76a914...88ac".to_string(),
block_daa_score: 100,
is_coinbase: false,
};
let json = serde_json::to_string(&utxo).unwrap();
assert!(json.contains("transactionId"));
assert!(json.contains("blockDaaScore"));
}
#[test]
fn test_network_info_serialization() {
let info = NetworkInfo {
network: "testnet".to_string(),
is_synced: true,
block_height: 10000,
blue_score: 5000,
difficulty: 12345.67,
peer_count: 25,
mempool_size: 50,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("isSynced"));
assert!(json.contains("blockHeight"));
}
}

View file

@ -8,6 +8,8 @@ import UpdateBanner from './components/UpdateBanner';
// Hooks
import { useTrayEvents } from './hooks/useTrayEvents';
import { useNodeEvents } from './hooks/useNodeEvents';
import { useMiningEvents } from './hooks/useMiningEvents';
// Pages
import Welcome from './pages/Welcome';
@ -19,6 +21,8 @@ import Send from './pages/Send';
import Receive from './pages/Receive';
import History from './pages/History';
import Settings from './pages/Settings';
import NodeDashboard from './pages/Node/NodeDashboard';
import MiningDashboard from './pages/Mining/MiningDashboard';
function App() {
const { isInitialized, isUnlocked } = useWalletStore();
@ -26,6 +30,10 @@ function App() {
// Listen for system tray events
useTrayEvents();
// Setup node and mining event listeners
useNodeEvents();
useMiningEvents();
return (
<div className="h-screen flex flex-col bg-gray-950">
{/* Update notification banner */}
@ -80,6 +88,18 @@ function App() {
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
}
/>
<Route
path="/node"
element={
isUnlocked ? <NodeDashboard /> : <Navigate to="/unlock" replace />
}
/>
<Route
path="/mining"
element={
isUnlocked ? <MiningDashboard /> : <Navigate to="/unlock" replace />
}
/>
<Route
path="/settings"
element={

View file

@ -8,19 +8,30 @@ import {
Lock,
Wifi,
WifiOff,
Server,
Pickaxe,
} from 'lucide-react';
import { useWalletStore } from '../store/wallet';
import { useNodeStore } from '../store/node';
import { useMiningStore, formatHashrate } from '../store/mining';
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 },
];
const advancedNavItems = [
{ to: '/node', label: 'Node', icon: Server },
{ to: '/mining', label: 'Mining', icon: Pickaxe },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export default function Layout() {
const { lockWallet, networkStatus, balance } = useWalletStore();
const { lockWallet, balance } = useWalletStore();
const nodeStatus = useNodeStore((state) => state.status);
const miningStatus = useMiningStore((state) => state.status);
const handleLock = async () => {
await lockWallet();
@ -63,27 +74,71 @@ export default function Layout() {
{label}
</NavLink>
))}
{/* Separator */}
<div className="pt-4 pb-2">
<p className="px-4 text-xs text-gray-600 uppercase tracking-wider">
Advanced
</p>
</div>
{/* Advanced nav items with status indicators */}
{advancedNavItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center justify-between px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-synor-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`
}
>
<div className="flex items-center gap-3">
<Icon size={20} />
{label}
</div>
{/* Status indicators */}
{to === '/node' && nodeStatus.isConnected && (
<span className="w-2 h-2 rounded-full bg-green-400" />
)}
{to === '/mining' && miningStatus.isMining && (
<span className="text-xs text-synor-400">
{formatHashrate(miningStatus.hashrate)}
</span>
)}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="p-4 border-t border-gray-800 space-y-2">
{/* Network status */}
{/* Node status */}
<div className="flex items-center gap-2 px-4 py-2 text-sm">
{networkStatus.connected ? (
{nodeStatus.isConnected ? (
<>
<Wifi size={16} className="text-green-400" />
<span className="text-gray-400">
{networkStatus.network || 'Connected'}
{nodeStatus.network || 'Connected'}
{nodeStatus.isSyncing && ' (Syncing...)'}
</span>
</>
) : (
<>
<WifiOff size={16} className="text-red-400" />
<span className="text-gray-400">Disconnected</span>
<span className="text-gray-400">Not Connected</span>
</>
)}
</div>
{/* Block height */}
{nodeStatus.isConnected && nodeStatus.blockHeight > 0 && (
<div className="px-4 text-xs text-gray-500">
Block #{nodeStatus.blockHeight.toLocaleString()}
</div>
)}
{/* Lock button */}
<button
onClick={handleLock}

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useMiningStore } from '../store/mining';
/**
* Hook to setup and cleanup mining event listeners
* Should be called once at the app level
*/
export function useMiningEvents() {
const setupEventListeners = useMiningStore(
(state) => state.setupEventListeners
);
const cleanupEventListeners = useMiningStore(
(state) => state.cleanupEventListeners
);
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
}
/**
* Hook to auto-start mining if enabled in settings
*/
export function useAutoStartMining() {
const autoStartMining = useMiningStore((state) => state.autoStartMining);
const defaultThreads = useMiningStore((state) => state.defaultThreads);
const defaultCoinbaseAddress = useMiningStore(
(state) => state.defaultCoinbaseAddress
);
const status = useMiningStore((state) => state.status);
const startMining = useMiningStore((state) => state.startMining);
useEffect(() => {
// Only auto-start if setting is enabled and not already mining
if (!autoStartMining || status.isMining || !defaultCoinbaseAddress) return;
const autoStart = async () => {
try {
await startMining(defaultCoinbaseAddress, defaultThreads);
} catch (error) {
// Silently fail auto-start
console.debug('Auto-start mining failed:', error);
}
};
// Delay auto-start to allow node to connect first
const timeout = setTimeout(autoStart, 3000);
return () => clearTimeout(timeout);
}, [
autoStartMining,
defaultThreads,
defaultCoinbaseAddress,
status.isMining,
startMining,
]);
}

View file

@ -0,0 +1,59 @@
import { useEffect } from 'react';
import { useNodeStore } from '../store/node';
/**
* Hook to setup and cleanup node event listeners
* Should be called once at the app level
*/
export function useNodeEvents() {
const setupEventListeners = useNodeStore((state) => state.setupEventListeners);
const cleanupEventListeners = useNodeStore(
(state) => state.cleanupEventListeners
);
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
}
/**
* Hook to auto-connect to the last used node on startup
*/
export function useAutoConnect() {
const preferredMode = useNodeStore((state) => state.preferredMode);
const lastExternalUrl = useNodeStore((state) => state.lastExternalUrl);
const lastNetwork = useNodeStore((state) => state.lastNetwork);
const status = useNodeStore((state) => state.status);
const connectExternal = useNodeStore((state) => state.connectExternal);
const startEmbeddedNode = useNodeStore((state) => state.startEmbeddedNode);
useEffect(() => {
// Only auto-connect if not already connected
if (status.isConnected) return;
const autoConnect = async () => {
try {
if (preferredMode === 'external' && lastExternalUrl) {
await connectExternal(lastExternalUrl);
} else if (preferredMode === 'embedded' && lastNetwork) {
await startEmbeddedNode({ network: lastNetwork });
}
} catch (error) {
// Silently fail auto-connect - user can manually connect
console.debug('Auto-connect failed:', error);
}
};
// Delay auto-connect to allow app to fully initialize
const timeout = setTimeout(autoConnect, 1000);
return () => clearTimeout(timeout);
}, [
preferredMode,
lastExternalUrl,
lastNetwork,
status.isConnected,
connectExternal,
startEmbeddedNode,
]);
}

View file

@ -0,0 +1,389 @@
import { useEffect, useState } from 'react';
import {
Pickaxe,
Play,
Pause,
Square,
Cpu,
Blocks,
TrendingUp,
Clock,
Zap,
} from 'lucide-react';
import { useMiningStore, formatHashrate } from '../../store/mining';
import { useNodeStore } from '../../store/node';
import { useWalletStore } from '../../store/wallet';
export default function MiningDashboard() {
const {
status,
stats,
recentBlocks,
hashrateHistory,
defaultThreads,
defaultCoinbaseAddress,
startMining,
stopMining,
pauseMining,
resumeMining,
setThreads,
refreshStatus,
refreshStats,
setupEventListeners,
cleanupEventListeners,
} = useMiningStore();
const nodeStatus = useNodeStore((state) => state.status);
const addresses = useWalletStore((state) => state.addresses);
const [threads, setThreadsLocal] = useState(defaultThreads);
const [coinbaseAddress, setCoinbaseAddress] = useState(
defaultCoinbaseAddress || addresses[0]?.address || ''
);
const [isStarting, setIsStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Max threads available
const maxThreads = navigator.hardwareConcurrency || 8;
// Setup event listeners on mount
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
// Refresh stats periodically when mining
useEffect(() => {
if (!status.isMining) return;
const interval = setInterval(() => {
refreshStatus();
refreshStats();
}, 1000);
return () => clearInterval(interval);
}, [status.isMining, refreshStatus, refreshStats]);
// Update coinbase address when addresses change
useEffect(() => {
if (!coinbaseAddress && addresses.length > 0) {
setCoinbaseAddress(addresses[0].address);
}
}, [addresses, coinbaseAddress]);
const handleStart = async () => {
if (!nodeStatus.isConnected) {
setError('Please connect to a node first');
return;
}
if (!coinbaseAddress) {
setError('Please select a coinbase address for rewards');
return;
}
setIsStarting(true);
setError(null);
try {
await startMining(coinbaseAddress, threads);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start mining');
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
try {
await stopMining();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop mining');
}
};
const handlePauseResume = async () => {
try {
if (status.isPaused) {
await resumeMining();
} else {
await pauseMining();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Action failed');
}
};
const handleThreadsChange = async (newThreads: number) => {
setThreadsLocal(newThreads);
if (status.isMining) {
try {
await setThreads(newThreads);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update threads');
}
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Pickaxe size={28} />
Mining
</h1>
<p className="text-gray-400 mt-1">
Mine SYN coins using your computer's CPU
</p>
</div>
{/* Control buttons */}
<div className="flex items-center gap-2">
{status.isMining ? (
<>
<button
onClick={handlePauseResume}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
status.isPaused
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
}`}
>
{status.isPaused ? <Play size={18} /> : <Pause size={18} />}
{status.isPaused ? 'Resume' : 'Pause'}
</button>
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<Square size={18} />
Stop
</button>
</>
) : (
<button
onClick={handleStart}
disabled={isStarting || !nodeStatus.isConnected}
className="flex items-center gap-2 px-6 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStarting ? (
<Cpu size={18} className="animate-spin" />
) : (
<Play size={18} />
)}
Start Mining
</button>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400">
{error}
</div>
)}
{/* Warning if not connected */}
{!nodeStatus.isConnected && (
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
Please connect to a node before starting mining
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Zap size={20} className={status.isMining && !status.isPaused ? 'animate-pulse' : ''} />}
label="Hashrate"
value={formatHashrate(status.hashrate)}
highlight={status.isMining && !status.isPaused}
/>
<StatCard
icon={<Blocks size={20} />}
label="Blocks Found"
value={status.blocksFound.toString()}
/>
<StatCard
icon={<Cpu size={20} />}
label="Threads"
value={`${status.threads || threads}/${maxThreads}`}
/>
<StatCard
icon={<Clock size={20} />}
label="Status"
value={
status.isMining
? status.isPaused
? 'Paused'
: 'Mining'
: 'Idle'
}
highlight={status.isMining && !status.isPaused}
/>
</div>
{/* Configuration (when not mining) */}
{!status.isMining && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4">
Mining Configuration
</h3>
<div className="space-y-4">
{/* Coinbase address */}
<div>
<label className="block text-sm text-gray-400 mb-2">
Reward Address
</label>
<select
value={coinbaseAddress}
onChange={(e) => setCoinbaseAddress(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="">Select address...</option>
{addresses.map((addr) => (
<option key={addr.address} value={addr.address}>
{addr.label || `Address ${addr.index}`} - {addr.address.slice(0, 20)}...
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Mining rewards will be sent to this address
</p>
</div>
{/* Thread slider */}
<div>
<label className="block text-sm text-gray-400 mb-2">
Mining Threads: {threads}
</label>
<input
type="range"
min={1}
max={maxThreads}
value={threads}
onChange={(e) => handleThreadsChange(parseInt(e.target.value))}
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-synor-500"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (Low Power)</span>
<span>{maxThreads} (Max Performance)</span>
</div>
</div>
</div>
</div>
)}
{/* Hashrate Chart (simplified) */}
{status.isMining && hashrateHistory.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp size={20} />
Hashrate History
</h3>
<div className="h-32 flex items-end gap-0.5">
{hashrateHistory.slice(-60).map((point, i) => {
const maxHash = Math.max(...hashrateHistory.map((h) => h.hashrate));
const height = maxHash > 0 ? (point.hashrate / maxHash) * 100 : 0;
return (
<div
key={i}
className="flex-1 bg-synor-500 rounded-t transition-all duration-300"
style={{ height: `${height}%` }}
title={`${formatHashrate(point.hashrate)}`}
/>
);
})}
</div>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>60s ago</span>
<span>Now</span>
</div>
</div>
)}
{/* Recent Blocks */}
{recentBlocks.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Blocks size={20} />
Blocks Found
</h3>
<div className="space-y-2">
{recentBlocks.slice(0, 5).map((block, i) => (
<div
key={i}
className="flex items-center justify-between p-3 rounded-lg bg-gray-800"
>
<div>
<p className="text-sm text-white font-mono">
Block #{block.height.toLocaleString()}
</p>
<p className="text-xs text-gray-500">
{block.hash.slice(0, 24)}...
</p>
</div>
<div className="text-right">
<p className="text-sm text-synor-400 font-medium">
+{(block.reward / 100_000_000).toFixed(2)} SYN
</p>
<p className="text-xs text-gray-500">
{new Date(block.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Mining Tips */}
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
<h3 className="text-sm font-semibold text-gray-400 mb-3">Mining Tips</h3>
<ul className="space-y-2 text-sm text-gray-500">
<li> Use fewer threads to keep your computer responsive</li>
<li> Mining profitability depends on network difficulty</li>
<li> Ensure adequate cooling for sustained mining</li>
<li> For best results, use an embedded node for lower latency</li>
</ul>
</div>
</div>
);
}
// Stat card component
function StatCard({
icon,
label,
value,
highlight = false,
}: {
icon: React.ReactNode;
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div
className={`p-4 rounded-xl border ${
highlight
? 'bg-synor-900/30 border-synor-700'
: 'bg-gray-900 border-gray-800'
}`}
>
<div className="flex items-center gap-2 text-gray-400 mb-2">
{icon}
<span className="text-sm">{label}</span>
</div>
<p
className={`text-xl font-bold ${
highlight ? 'text-synor-400' : 'text-white'
}`}
>
{value}
</p>
</div>
);
}

View file

@ -0,0 +1,343 @@
import { useEffect, useState } from 'react';
import {
Server,
Wifi,
WifiOff,
Users,
Blocks,
RefreshCw,
Power,
PowerOff,
Globe,
HardDrive,
} from 'lucide-react';
import { useNodeStore, ConnectionMode } from '../../store/node';
export default function NodeDashboard() {
const {
status,
syncProgress,
peers,
preferredMode,
lastExternalUrl,
lastNetwork,
connectExternal,
startEmbeddedNode,
disconnect,
refreshStatus,
refreshPeers,
setupEventListeners,
cleanupEventListeners,
} = useNodeStore();
const [isConnecting, setIsConnecting] = useState(false);
const [externalUrl, setExternalUrl] = useState(lastExternalUrl);
const [embeddedNetwork, setEmbeddedNetwork] = useState(lastNetwork);
const [error, setError] = useState<string | null>(null);
// Setup event listeners on mount
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, [setupEventListeners, cleanupEventListeners]);
// Refresh status periodically when connected
useEffect(() => {
if (!status.isConnected) return;
const interval = setInterval(() => {
refreshStatus();
refreshPeers();
}, 5000);
return () => clearInterval(interval);
}, [status.isConnected, refreshStatus, refreshPeers]);
const handleConnectExternal = async () => {
setIsConnecting(true);
setError(null);
try {
await connectExternal(externalUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed');
} finally {
setIsConnecting(false);
}
};
const handleStartEmbedded = async () => {
setIsConnecting(true);
setError(null);
try {
await startEmbeddedNode({ network: embeddedNetwork });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start node');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
setError(null);
try {
await disconnect();
} catch (err) {
setError(err instanceof Error ? err.message : 'Disconnect failed');
}
};
const getModeLabel = (mode: ConnectionMode): string => {
switch (mode.type) {
case 'disconnected':
return 'Disconnected';
case 'external':
return `External: ${mode.http_url}`;
case 'embedded':
return `Embedded: ${mode.network}`;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Server size={28} />
Node
</h1>
<p className="text-gray-400 mt-1">
Manage your connection to the Synor network
</p>
</div>
{status.isConnected && (
<button
onClick={handleDisconnect}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white transition-colors"
>
<PowerOff size={18} />
Disconnect
</button>
)}
</div>
{/* Error display */}
{error && (
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400">
{error}
</div>
)}
{/* Connection Status Card */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-4">
{status.isConnected ? (
<div className="p-3 rounded-full bg-green-500/20">
<Wifi size={24} className="text-green-400" />
</div>
) : (
<div className="p-3 rounded-full bg-gray-700">
<WifiOff size={24} className="text-gray-400" />
</div>
)}
<div>
<h2 className="text-lg font-semibold text-white">
{status.isConnected ? 'Connected' : 'Not Connected'}
</h2>
<p className="text-sm text-gray-400">{getModeLabel(status.mode)}</p>
</div>
</div>
{/* Sync progress bar */}
{status.isConnected && status.isSyncing && syncProgress && (
<div className="mt-4">
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>Syncing...</span>
<span>{syncProgress.progress.toFixed(1)}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-synor-500 transition-all duration-300"
style={{ width: `${syncProgress.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{syncProgress.status}</p>
</div>
)}
</div>
{/* Stats Grid (when connected) */}
{status.isConnected && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={<Blocks size={20} />}
label="Block Height"
value={status.blockHeight.toLocaleString()}
/>
<StatCard
icon={<HardDrive size={20} />}
label="Blue Score"
value={status.blueScore.toLocaleString()}
/>
<StatCard
icon={<Users size={20} />}
label="Peers"
value={status.peerCount.toString()}
/>
<StatCard
icon={<Globe size={20} />}
label="Network"
value={status.network || 'Unknown'}
/>
</div>
)}
{/* Connection Options (when disconnected) */}
{!status.isConnected && (
<div className="grid gap-6 md:grid-cols-2">
{/* External Node */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<Globe size={24} className="text-synor-400" />
<h3 className="text-lg font-semibold text-white">External Node</h3>
</div>
<p className="text-sm text-gray-400 mb-4">
Connect to a remote Synor node via RPC. No local resources required.
</p>
<div className="space-y-3">
<input
type="text"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="http://localhost:16110"
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
/>
<button
onClick={handleConnectExternal}
disabled={isConnecting || !externalUrl}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Power size={18} />
)}
Connect
</button>
</div>
</div>
{/* Embedded Node */}
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-3 mb-4">
<Server size={24} className="text-synor-400" />
<h3 className="text-lg font-semibold text-white">Embedded Node</h3>
</div>
<p className="text-sm text-gray-400 mb-4">
Run a full node inside the wallet. Requires more resources but provides maximum security.
</p>
<div className="space-y-3">
<select
value={embeddedNetwork}
onChange={(e) => setEmbeddedNetwork(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
>
<option value="mainnet">Mainnet</option>
<option value="testnet">Testnet</option>
<option value="devnet">Devnet</option>
</select>
<button
onClick={handleStartEmbedded}
disabled={isConnecting}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-gray-700 hover:bg-gray-600 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<Power size={18} />
)}
Start Node
</button>
<p className="text-xs text-gray-500 text-center">
Requires embedded-node feature enabled
</p>
</div>
</div>
</div>
)}
{/* Peers List (when connected) */}
{status.isConnected && peers.length > 0 && (
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Users size={20} />
Connected Peers
</h3>
<button
onClick={refreshPeers}
className="p-2 rounded-lg hover:bg-gray-800 text-gray-400 hover:text-white transition-colors"
>
<RefreshCw size={18} />
</button>
</div>
<div className="space-y-2">
{peers.slice(0, 10).map((peer) => (
<div
key={peer.peerId}
className="flex items-center justify-between p-3 rounded-lg bg-gray-800"
>
<div className="flex items-center gap-3">
<div
className={`w-2 h-2 rounded-full ${
peer.status === 'connected' ? 'bg-green-400' : 'bg-gray-500'
}`}
/>
<div>
<p className="text-sm text-white font-mono">
{peer.peerId.slice(0, 16)}...
</p>
<p className="text-xs text-gray-500">{peer.address}</p>
</div>
</div>
<div className="text-right">
<p className="text-xs text-gray-400">{peer.direction}</p>
{peer.latencyMs !== undefined && (
<p className="text-xs text-gray-500">{peer.latencyMs}ms</p>
)}
</div>
</div>
))}
</div>
{peers.length > 10 && (
<p className="text-xs text-gray-500 text-center mt-3">
And {peers.length - 10} more peers...
</p>
)}
</div>
)}
</div>
);
}
// Stat card component
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
<div className="flex items-center gap-2 text-gray-400 mb-2">
{icon}
<span className="text-sm">{label}</span>
</div>
<p className="text-xl font-bold text-white">{value}</p>
</div>
);
}

View file

@ -0,0 +1,15 @@
// Store exports
export { useWalletStore } from './wallet';
export type { WalletAddress, Balance, NetworkStatus } from './wallet';
export { useNodeStore, useIsConnected, useBlockHeight, useIsSyncing } from './node';
export type { ConnectionMode, NodeStatus, SyncProgress, PeerInfo } from './node';
export {
useMiningStore,
useIsMiningActive,
useHashrate,
useBlocksFound,
formatHashrate,
} from './mining';
export type { MiningStatus, MiningStats, BlockFoundEvent } from './mining';

View file

@ -0,0 +1,310 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
/**
* Sanitized error logging
*/
function logError(context: string, error: unknown): void {
if (import.meta.env.PROD) {
const errorType = error instanceof Error ? error.name : 'Unknown';
console.error(`[Mining] ${context}: ${errorType}`);
} else {
console.error(`[Mining] ${context}:`, error);
}
}
// ============================================================================
// Types
// ============================================================================
export interface MiningStatus {
isMining: boolean;
isPaused: boolean;
hashrate: number;
blocksFound: number;
sharesSubmitted: number;
threads: number;
coinbaseAddress?: string;
}
export interface MiningStats {
hashrate: number;
avgHashrate: number;
peakHashrate: number;
blocksFound: number;
blocksRejected: number;
estimatedDailyCoins: number;
uptimeSeconds: number;
threadHashrates: number[];
}
export interface BlockFoundEvent {
height: number;
hash: string;
reward: number;
timestamp: number;
}
// ============================================================================
// Store
// ============================================================================
interface MiningState {
// Status
status: MiningStatus;
stats: MiningStats | null;
recentBlocks: BlockFoundEvent[];
hashrateHistory: { timestamp: number; hashrate: number }[];
// Settings (persisted)
defaultThreads: number;
defaultCoinbaseAddress: string;
autoStartMining: boolean;
// Event listener cleanup
_unlisteners: UnlistenFn[];
// Actions
setStatus: (status: MiningStatus) => void;
setStats: (stats: MiningStats | null) => void;
addBlockFound: (block: BlockFoundEvent) => void;
addHashratePoint: (hashrate: number) => void;
setDefaultThreads: (threads: number) => void;
setDefaultCoinbaseAddress: (address: string) => void;
setAutoStartMining: (autoStart: boolean) => void;
// Async actions
startMining: (coinbaseAddress: string, threads: number) => Promise<void>;
stopMining: () => Promise<void>;
pauseMining: () => Promise<void>;
resumeMining: () => Promise<void>;
setThreads: (threads: number) => Promise<void>;
refreshStatus: () => Promise<void>;
refreshStats: () => Promise<void>;
// Event listeners
setupEventListeners: () => Promise<void>;
cleanupEventListeners: () => void;
}
const initialStatus: MiningStatus = {
isMining: false,
isPaused: false,
hashrate: 0,
blocksFound: 0,
sharesSubmitted: 0,
threads: 0,
coinbaseAddress: undefined,
};
export const useMiningStore = create<MiningState>()(
persist(
(set, get) => ({
// Initial state
status: initialStatus,
stats: null,
recentBlocks: [],
hashrateHistory: [],
defaultThreads: navigator.hardwareConcurrency || 4,
defaultCoinbaseAddress: '',
autoStartMining: false,
_unlisteners: [],
// Sync setters
setStatus: (status) => set({ status }),
setStats: (stats) => set({ stats }),
addBlockFound: (block) =>
set((state) => ({
recentBlocks: [block, ...state.recentBlocks].slice(0, 50), // Keep last 50
})),
addHashratePoint: (hashrate) =>
set((state) => ({
hashrateHistory: [
...state.hashrateHistory,
{ timestamp: Date.now(), hashrate },
].slice(-300), // Keep last 5 minutes (assuming 1s intervals)
})),
setDefaultThreads: (threads) => set({ defaultThreads: threads }),
setDefaultCoinbaseAddress: (address) =>
set({ defaultCoinbaseAddress: address }),
setAutoStartMining: (autoStart) => set({ autoStartMining: autoStart }),
// Async actions
startMining: async (coinbaseAddress: string, threads: number) => {
try {
const status = await invoke<MiningStatus>('mining_start', {
coinbaseAddress,
threads,
});
set({
status,
defaultCoinbaseAddress: coinbaseAddress,
defaultThreads: threads,
});
} catch (error) {
logError('startMining', error);
throw error;
}
},
stopMining: async () => {
try {
await invoke('mining_stop');
set({
status: initialStatus,
stats: null,
});
} catch (error) {
logError('stopMining', error);
throw error;
}
},
pauseMining: async () => {
try {
await invoke('mining_pause');
set((state) => ({
status: { ...state.status, isPaused: true },
}));
} catch (error) {
logError('pauseMining', error);
throw error;
}
},
resumeMining: async () => {
try {
await invoke('mining_resume');
set((state) => ({
status: { ...state.status, isPaused: false },
}));
} catch (error) {
logError('resumeMining', error);
throw error;
}
},
setThreads: async (threads: number) => {
try {
await invoke('mining_set_threads', { threads });
set((state) => ({
status: { ...state.status, threads },
defaultThreads: threads,
}));
} catch (error) {
logError('setThreads', error);
throw error;
}
},
refreshStatus: async () => {
try {
const status = await invoke<MiningStatus>('mining_get_status');
set({ status });
} catch (error) {
logError('refreshStatus', error);
}
},
refreshStats: async () => {
try {
const stats = await invoke<MiningStats>('mining_get_stats');
set({ stats });
// Also add to hashrate history
get().addHashratePoint(stats.hashrate);
} catch (error) {
logError('refreshStats', error);
}
},
// Event listeners for real-time updates
setupEventListeners: async () => {
const unlisteners: UnlistenFn[] = [];
// Listen for mining stats updates
const unlistenStats = await listen<MiningStats>(
'mining:stats-update',
(event) => {
set({ stats: event.payload });
get().addHashratePoint(event.payload.hashrate);
}
);
unlisteners.push(unlistenStats);
// Listen for block found events
const unlistenBlock = await listen<BlockFoundEvent>(
'mining:block-found',
(event) => {
get().addBlockFound(event.payload);
// Refresh status to update block count
get().refreshStatus();
}
);
unlisteners.push(unlistenBlock);
set({ _unlisteners: unlisteners });
},
cleanupEventListeners: () => {
const { _unlisteners } = get();
for (const unlisten of _unlisteners) {
unlisten();
}
set({ _unlisteners: [] });
},
}),
{
name: 'synor-mining-storage',
partialize: (state) => ({
defaultThreads: state.defaultThreads,
defaultCoinbaseAddress: state.defaultCoinbaseAddress,
autoStartMining: state.autoStartMining,
}),
}
)
);
// ============================================================================
// Helper Hooks
// ============================================================================
/**
* Returns true if currently mining (and not paused)
*/
export function useIsMiningActive(): boolean {
return useMiningStore((state) => state.status.isMining && !state.status.isPaused);
}
/**
* Returns the current hashrate
*/
export function useHashrate(): number {
return useMiningStore((state) => state.status.hashrate);
}
/**
* Returns total blocks found
*/
export function useBlocksFound(): number {
return useMiningStore((state) => state.status.blocksFound);
}
/**
* Formats hashrate for display
*/
export function formatHashrate(hashrate: number): string {
if (hashrate < 1000) {
return `${hashrate.toFixed(2)} H/s`;
} else if (hashrate < 1_000_000) {
return `${(hashrate / 1000).toFixed(2)} KH/s`;
} else if (hashrate < 1_000_000_000) {
return `${(hashrate / 1_000_000).toFixed(2)} MH/s`;
} else {
return `${(hashrate / 1_000_000_000).toFixed(2)} GH/s`;
}
}

View file

@ -0,0 +1,289 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen, UnlistenFn } from '@tauri-apps/api/event';
/**
* Sanitized error logging
*/
function logError(context: string, error: unknown): void {
if (import.meta.env.PROD) {
const errorType = error instanceof Error ? error.name : 'Unknown';
console.error(`[Node] ${context}: ${errorType}`);
} else {
console.error(`[Node] ${context}:`, error);
}
}
// ============================================================================
// Types
// ============================================================================
export type ConnectionMode =
| { type: 'disconnected' }
| { type: 'external'; http_url: string; ws_url?: string }
| { type: 'embedded'; network: string; data_dir?: string };
export interface NodeStatus {
mode: ConnectionMode;
isConnected: boolean;
blockHeight: number;
blueScore: number;
peerCount: number;
isSyncing: boolean;
syncProgress: number;
network: string;
chainId: number;
}
export interface SyncProgress {
currentHeight: number;
targetHeight: number;
progress: number;
etaSeconds?: number;
status: string;
}
export interface PeerInfo {
peerId: string;
address: string;
direction: 'inbound' | 'outbound';
latencyMs?: number;
blockHeight: number;
status: string;
}
// ============================================================================
// Store
// ============================================================================
interface NodeState {
// Status
status: NodeStatus;
syncProgress: SyncProgress | null;
peers: PeerInfo[];
// Connection preferences (persisted)
preferredMode: 'external' | 'embedded';
lastExternalUrl: string;
lastNetwork: string;
// Event listener cleanup
_unlisteners: UnlistenFn[];
// Actions
setStatus: (status: NodeStatus) => void;
setSyncProgress: (progress: SyncProgress | null) => void;
setPeers: (peers: PeerInfo[]) => void;
setPreferredMode: (mode: 'external' | 'embedded') => void;
// Async actions
connectExternal: (httpUrl: string, wsUrl?: string) => Promise<void>;
startEmbeddedNode: (options: {
network: string;
dataDir?: string;
miningEnabled?: boolean;
coinbaseAddress?: string;
miningThreads?: number;
}) => Promise<void>;
disconnect: () => Promise<void>;
refreshStatus: () => Promise<void>;
refreshPeers: () => Promise<void>;
// Event listeners
setupEventListeners: () => Promise<void>;
cleanupEventListeners: () => void;
}
const initialStatus: NodeStatus = {
mode: { type: 'disconnected' },
isConnected: false,
blockHeight: 0,
blueScore: 0,
peerCount: 0,
isSyncing: false,
syncProgress: 0,
network: '',
chainId: 0,
};
export const useNodeStore = create<NodeState>()(
persist(
(set, get) => ({
// Initial state
status: initialStatus,
syncProgress: null,
peers: [],
preferredMode: 'external',
lastExternalUrl: 'http://localhost:16110',
lastNetwork: 'testnet',
_unlisteners: [],
// Sync setters
setStatus: (status) => set({ status }),
setSyncProgress: (progress) => set({ syncProgress: progress }),
setPeers: (peers) => set({ peers }),
setPreferredMode: (mode) => set({ preferredMode: mode }),
// Async actions
connectExternal: async (httpUrl: string, wsUrl?: string) => {
try {
const status = await invoke<NodeStatus>('node_connect_external', {
httpUrl,
wsUrl,
});
set({
status: { ...status, isConnected: true },
lastExternalUrl: httpUrl,
});
} catch (error) {
logError('connectExternal', error);
throw error;
}
},
startEmbeddedNode: async (options) => {
try {
const status = await invoke<NodeStatus>('node_start_embedded', {
network: options.network,
dataDir: options.dataDir,
miningEnabled: options.miningEnabled ?? false,
coinbaseAddress: options.coinbaseAddress,
miningThreads: options.miningThreads ?? 0,
});
set({
status: { ...status, isConnected: true },
lastNetwork: options.network,
preferredMode: 'embedded',
});
} catch (error) {
logError('startEmbeddedNode', error);
throw error;
}
},
disconnect: async () => {
try {
await invoke('node_stop');
set({
status: initialStatus,
syncProgress: null,
peers: [],
});
} catch (error) {
logError('disconnect', error);
throw error;
}
},
refreshStatus: async () => {
try {
const status = await invoke<NodeStatus>('node_get_status');
set({ status });
} catch (error) {
logError('refreshStatus', error);
}
},
refreshPeers: async () => {
try {
const peers = await invoke<PeerInfo[]>('node_get_peers');
set({ peers });
} catch (error) {
logError('refreshPeers', error);
}
},
// Event listeners for real-time updates
setupEventListeners: async () => {
const unlisteners: UnlistenFn[] = [];
// Listen for status changes
const unlistenStatus = await listen<NodeStatus>(
'node:status-changed',
(event) => {
set({ status: event.payload });
}
);
unlisteners.push(unlistenStatus);
// Listen for sync progress
const unlistenSync = await listen<SyncProgress>(
'node:sync-progress',
(event) => {
set({ syncProgress: event.payload });
}
);
unlisteners.push(unlistenSync);
// Listen for new blocks
const unlistenBlock = await listen<{ height: number; hash: string }>(
'node:new-block',
(event) => {
const current = get().status;
set({
status: {
...current,
blockHeight: event.payload.height,
},
});
}
);
unlisteners.push(unlistenBlock);
// Listen for peer connections
const unlistenPeer = await listen<PeerInfo>(
'node:peer-connected',
() => {
// Refresh peer list when new peer connects
get().refreshPeers();
}
);
unlisteners.push(unlistenPeer);
set({ _unlisteners: unlisteners });
},
cleanupEventListeners: () => {
const { _unlisteners } = get();
for (const unlisten of _unlisteners) {
unlisten();
}
set({ _unlisteners: [] });
},
}),
{
name: 'synor-node-storage',
partialize: (state) => ({
preferredMode: state.preferredMode,
lastExternalUrl: state.lastExternalUrl,
lastNetwork: state.lastNetwork,
}),
}
)
);
// ============================================================================
// Helper Hooks
// ============================================================================
/**
* Returns true if connected to any node (embedded or external)
*/
export function useIsConnected(): boolean {
return useNodeStore((state) => state.status.isConnected);
}
/**
* Returns the current block height
*/
export function useBlockHeight(): number {
return useNodeStore((state) => state.status.blockHeight);
}
/**
* Returns the current sync status
*/
export function useIsSyncing(): boolean {
return useNodeStore((state) => state.status.isSyncing);
}