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)
|
# OS Keychain integration (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||||
keyring = "3"
|
keyring = "3"
|
||||||
|
|
||||||
# Local crates from the monorepo (optional - for direct integration with core)
|
# HTTP client for RPC calls
|
||||||
synor-crypto = { path = "../../../crates/synor-crypto", optional = true }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
synor-types = { path = "../../../crates/synor-types", optional = true }
|
|
||||||
synor-rpc = { path = "../../../crates/synor-rpc", optional = true }
|
# 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]
|
[features]
|
||||||
default = ["custom-protocol"]
|
default = ["custom-protocol"]
|
||||||
custom-protocol = ["tauri/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]
|
[profile.release]
|
||||||
lto = true
|
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}")]
|
#[error("Keychain error: {0}")]
|
||||||
Keychain(String),
|
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}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ mod commands;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod error;
|
mod error;
|
||||||
mod keychain;
|
mod keychain;
|
||||||
|
mod node;
|
||||||
|
mod rpc_client;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
|
||||||
use tauri::{
|
use tauri::{
|
||||||
|
|
@ -116,6 +118,27 @@ pub fn run() {
|
||||||
let wallet_state = wallet::WalletState::new();
|
let wallet_state = wallet::WalletState::new();
|
||||||
app.manage(wallet_state);
|
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
|
// Build and set up system tray
|
||||||
let menu = build_tray_menu(app.handle())?;
|
let menu = build_tray_menu(app.handle())?;
|
||||||
let _tray = TrayIconBuilder::new()
|
let _tray = TrayIconBuilder::new()
|
||||||
|
|
@ -165,10 +188,31 @@ pub fn run() {
|
||||||
commands::sign_transaction,
|
commands::sign_transaction,
|
||||||
commands::broadcast_transaction,
|
commands::broadcast_transaction,
|
||||||
commands::get_transaction_history,
|
commands::get_transaction_history,
|
||||||
// Network
|
// Network (legacy)
|
||||||
commands::connect_node,
|
commands::connect_node,
|
||||||
commands::disconnect_node,
|
commands::disconnect_node,
|
||||||
commands::get_network_status,
|
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
|
// Updates
|
||||||
check_update,
|
check_update,
|
||||||
install_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
|
// Hooks
|
||||||
import { useTrayEvents } from './hooks/useTrayEvents';
|
import { useTrayEvents } from './hooks/useTrayEvents';
|
||||||
|
import { useNodeEvents } from './hooks/useNodeEvents';
|
||||||
|
import { useMiningEvents } from './hooks/useMiningEvents';
|
||||||
|
|
||||||
// Pages
|
// Pages
|
||||||
import Welcome from './pages/Welcome';
|
import Welcome from './pages/Welcome';
|
||||||
|
|
@ -19,6 +21,8 @@ import Send from './pages/Send';
|
||||||
import Receive from './pages/Receive';
|
import Receive from './pages/Receive';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import NodeDashboard from './pages/Node/NodeDashboard';
|
||||||
|
import MiningDashboard from './pages/Mining/MiningDashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isInitialized, isUnlocked } = useWalletStore();
|
const { isInitialized, isUnlocked } = useWalletStore();
|
||||||
|
|
@ -26,6 +30,10 @@ function App() {
|
||||||
// Listen for system tray events
|
// Listen for system tray events
|
||||||
useTrayEvents();
|
useTrayEvents();
|
||||||
|
|
||||||
|
// Setup node and mining event listeners
|
||||||
|
useNodeEvents();
|
||||||
|
useMiningEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-gray-950">
|
<div className="h-screen flex flex-col bg-gray-950">
|
||||||
{/* Update notification banner */}
|
{/* Update notification banner */}
|
||||||
|
|
@ -80,6 +88,18 @@ function App() {
|
||||||
isUnlocked ? <History /> : <Navigate to="/unlock" replace />
|
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
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,30 @@ import {
|
||||||
Lock,
|
Lock,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
|
Server,
|
||||||
|
Pickaxe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
|
import { useNodeStore } from '../store/node';
|
||||||
|
import { useMiningStore, formatHashrate } from '../store/mining';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ to: '/send', label: 'Send', icon: Send },
|
{ to: '/send', label: 'Send', icon: Send },
|
||||||
{ to: '/receive', label: 'Receive', icon: Download },
|
{ to: '/receive', label: 'Receive', icon: Download },
|
||||||
{ to: '/history', label: 'History', icon: History },
|
{ 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 },
|
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
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 () => {
|
const handleLock = async () => {
|
||||||
await lockWallet();
|
await lockWallet();
|
||||||
|
|
@ -63,27 +74,71 @@ export default function Layout() {
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-gray-800 space-y-2">
|
<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">
|
<div className="flex items-center gap-2 px-4 py-2 text-sm">
|
||||||
{networkStatus.connected ? (
|
{nodeStatus.isConnected ? (
|
||||||
<>
|
<>
|
||||||
<Wifi size={16} className="text-green-400" />
|
<Wifi size={16} className="text-green-400" />
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
{networkStatus.network || 'Connected'}
|
{nodeStatus.network || 'Connected'}
|
||||||
|
{nodeStatus.isSyncing && ' (Syncing...)'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<WifiOff size={16} className="text-red-400" />
|
<WifiOff size={16} className="text-red-400" />
|
||||||
<span className="text-gray-400">Disconnected</span>
|
<span className="text-gray-400">Not Connected</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Block height */}
|
||||||
|
{nodeStatus.isConnected && nodeStatus.blockHeight > 0 && (
|
||||||
|
<div className="px-4 text-xs text-gray-500">
|
||||||
|
Block #{nodeStatus.blockHeight.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Lock button */}
|
{/* Lock button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleLock}
|
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