desktop wallet enhancements
This commit is contained in:
parent
01ff14c0a9
commit
5cd6fdcb35
16 changed files with 4990 additions and 10 deletions
1919
apps/desktop-wallet/pnpm-lock.yaml
generated
Normal file
1919
apps/desktop-wallet/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
480
apps/desktop-wallet/src-tauri/src/node.rs
Normal file
480
apps/desktop-wallet/src-tauri/src/node.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
563
apps/desktop-wallet/src-tauri/src/rpc_client.rs
Normal file
563
apps/desktop-wallet/src-tauri/src/rpc_client.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
57
apps/desktop-wallet/src/hooks/useMiningEvents.ts
Normal file
57
apps/desktop-wallet/src/hooks/useMiningEvents.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
59
apps/desktop-wallet/src/hooks/useNodeEvents.ts
Normal file
59
apps/desktop-wallet/src/hooks/useNodeEvents.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
389
apps/desktop-wallet/src/pages/Mining/MiningDashboard.tsx
Normal file
389
apps/desktop-wallet/src/pages/Mining/MiningDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
343
apps/desktop-wallet/src/pages/Node/NodeDashboard.tsx
Normal file
343
apps/desktop-wallet/src/pages/Node/NodeDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/desktop-wallet/src/store/index.ts
Normal file
15
apps/desktop-wallet/src/store/index.ts
Normal 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';
|
||||
310
apps/desktop-wallet/src/store/mining.ts
Normal file
310
apps/desktop-wallet/src/store/mining.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
289
apps/desktop-wallet/src/store/node.ts
Normal file
289
apps/desktop-wallet/src/store/node.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue