diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 3282d339da..652e66509f 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -27,7 +27,7 @@ use crate::auth::{ use crate::chain::{Chain, SyncState}; use crate::foreign::Foreign; use crate::foreign_rpc::ForeignRpc; -use crate::owner::Owner; +use crate::owner::{MiningStatsProvider, Owner}; use crate::owner_rpc::OwnerRpc; use crate::pool; use crate::pool::{BlockChain, PoolAdapter}; @@ -63,6 +63,40 @@ pub fn node_apis( api_chan: (mpsc::Sender<()>, mpsc::Receiver<()>), stop_state: Arc, ) -> Result<(), Error> +where + B: BlockChain + 'static, + P: PoolAdapter + 'static, +{ + node_apis_with_mining_stats( + addr, + chain, + tx_pool, + peers, + sync_state, + api_secret, + foreign_api_secret, + tls_config, + api_chan, + stop_state, + None, + ) +} + +/// Same as [`node_apis`] but additionally wires a provider of live stratum +/// mining stats into the Owner API `get_mining_status` method. +pub fn node_apis_with_mining_stats( + addr: &str, + chain: Arc, + tx_pool: Arc>>, + peers: Arc, + sync_state: Arc, + api_secret: Option, + foreign_api_secret: Option, + tls_config: Option, + api_chan: (mpsc::Sender<()>, mpsc::Receiver<()>), + stop_state: Arc, + mining_stats: Option, +) -> Result<(), Error> where B: BlockChain + 'static, P: PoolAdapter + 'static, @@ -85,6 +119,7 @@ where Arc::downgrade(&chain), Arc::downgrade(&peers), Arc::downgrade(&sync_state), + mining_stats, ); router.add_route("/v2/owner", Arc::new(api_handler))?; @@ -142,25 +177,33 @@ pub struct OwnerAPIHandlerV2 { pub chain: Weak, pub peers: Weak, pub sync_state: Weak, + pub mining_stats: Option, } impl OwnerAPIHandlerV2 { /// Create a new owner API handler for GET methods - pub fn new(chain: Weak, peers: Weak, sync_state: Weak) -> Self { + pub fn new( + chain: Weak, + peers: Weak, + sync_state: Weak, + mining_stats: Option, + ) -> Self { OwnerAPIHandlerV2 { chain, peers, sync_state, + mining_stats, } } } impl crate::router::Handler for OwnerAPIHandlerV2 { fn post(&self, req: Request) -> ResponseFuture { - let api = Owner::new( + let api = Owner::with_mining_stats( self.chain.clone(), self.peers.clone(), self.sync_state.clone(), + self.mining_stats.clone(), ); Box::pin(async move { diff --git a/api/src/lib.rs b/api/src/lib.rs index e9f1af4ae2..a6763a7a72 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -47,8 +47,8 @@ pub use crate::auth::{ }; pub use crate::foreign::Foreign; pub use crate::foreign_rpc::ForeignRpc; -pub use crate::handlers::node_apis; -pub use crate::owner::Owner; +pub use crate::handlers::{node_apis, node_apis_with_mining_stats}; +pub use crate::owner::{MiningStatsProvider, Owner}; pub use crate::owner_rpc::OwnerRpc; pub use crate::rest::*; pub use crate::router::*; diff --git a/api/src/owner.rs b/api/src/owner.rs index 2ec02c12cc..bf5f37487f 100644 --- a/api/src/owner.rs +++ b/api/src/owner.rs @@ -22,9 +22,12 @@ use crate::handlers::server_api::StatusHandler; use crate::p2p::types::PeerInfoDisplay; use crate::p2p::{self, PeerData}; use crate::rest::*; -use crate::types::Status; +use crate::types::{MiningStatus, Status}; use std::net::SocketAddr; -use std::sync::Weak; +use std::sync::{Arc, Weak}; + +/// Callback that returns a snapshot of stratum mining stats for the Owner API. +pub type MiningStatsProvider = Arc MiningStatus + Send + Sync>; /// Main interface into all node API functions. /// Node APIs are split into two separate blocks of functionality @@ -37,6 +40,8 @@ pub struct Owner { pub chain: Weak, pub peers: Weak, pub sync_state: Weak, + /// Optional provider of live stratum mining stats (set by the server process). + pub mining_stats: Option, } impl Owner { @@ -58,6 +63,22 @@ impl Owner { chain, peers, sync_state, + mining_stats: None, + } + } + + /// Create a new Owner API instance with a mining stats provider. + pub fn with_mining_stats( + chain: Weak, + peers: Weak, + sync_state: Weak, + mining_stats: Option, + ) -> Self { + Owner { + chain, + peers, + sync_state, + mining_stats, } } @@ -78,6 +99,23 @@ impl Owner { status_handler.get_status() } + /// Returns current stratum mining statistics, mirroring the TUI mining tab. + /// + /// # Returns + /// * Result Containing: + /// * A [`MiningStatus`](types/struct.MiningStatus.html) + /// * or [`Error`](struct.Error.html) if no mining stats provider is configured. + /// + + pub fn get_mining_status(&self) -> Result { + match &self.mining_stats { + Some(provider) => Ok(provider()), + None => Err(Error::Internal( + "no mining stats provider configured".to_string(), + )), + } + } + /// Trigger a validation of the chain state. /// /// # Arguments @@ -201,3 +239,45 @@ impl Owner { peer_handler.unban_peer(addr) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::types::{MiningStatus, WorkerInfo}; + use std::sync::Arc; + + #[test] + fn get_mining_status_without_provider_returns_error() { + let owner = Owner::new(Weak::new(), Weak::new(), Weak::new()); + assert!(owner.get_mining_status().is_err()); + } + + #[test] + fn get_mining_status_with_provider() { + let expected = MiningStatus { + is_enabled: true, + is_running: true, + num_workers: 2, + block_height: 50, + network_difficulty: 10, + edge_bits: 29, + blocks_found: 1, + network_hashrate: 0.5, + minimum_share_difficulty: 1, + worker_stats: vec![WorkerInfo { + id: "w0".into(), + last_seen: 100, + initial_block_height: 40, + pow_difficulty: 1, + num_accepted: 3, + num_rejected: 0, + num_stale: 0, + num_blocks_found: 0, + }], + }; + let expected_clone = expected.clone(); + let provider: MiningStatsProvider = Arc::new(move || expected_clone.clone()); + let owner = Owner::with_mining_stats(Weak::new(), Weak::new(), Weak::new(), Some(provider)); + assert_eq!(owner.get_mining_status().unwrap(), expected); + } +} diff --git a/api/src/owner_rpc.rs b/api/src/owner_rpc.rs index 6884b509e0..4678bc677e 100644 --- a/api/src/owner_rpc.rs +++ b/api/src/owner_rpc.rs @@ -18,7 +18,7 @@ use crate::owner::Owner; use crate::p2p::types::PeerInfoDisplay; use crate::p2p::PeerData; use crate::rest::Error; -use crate::types::Status; +use crate::types::{MiningStatus, Status}; use std::net::SocketAddr; /// Public definition used to generate Node jsonrpc api. @@ -73,6 +73,61 @@ pub trait OwnerRpc: Sync + Send { */ fn get_status(&self) -> Result; + /** + Networked version of [Owner::get_mining_status](struct.Owner.html#method.get_mining_status). + + Returns the same mining / stratum information shown on the TUI mining tab so + headless operators can query connected workers and share stats. + + # Json rpc example + + ``` + # grin_api::doctest_helper_json_rpc_owner_assert_response!( + # r#" + { + "jsonrpc": "2.0", + "method": "get_mining_status", + "params": [], + "id": 1 + } + # "# + # , + # r#" + { + "id": 1, + "jsonrpc": "2.0", + "result": { + "Ok": { + "is_enabled": true, + "is_running": true, + "num_workers": 1, + "block_height": 1000, + "network_difficulty": 42, + "edge_bits": 29, + "blocks_found": 3, + "network_hashrate": 1.5, + "minimum_share_difficulty": 1, + "worker_stats": [ + { + "id": "0", + "last_seen": 1609459200, + "initial_block_height": 990, + "pow_difficulty": 1, + "num_accepted": 10, + "num_rejected": 0, + "num_stale": 0, + "num_blocks_found": 1 + } + ] + } + } + } + # "# + # ); + ``` + */ + fn get_mining_status(&self) -> Result; + /** Networked version of [Owner::validate_chain](struct.Owner.html#method.validate_chain). @@ -364,6 +419,10 @@ impl OwnerRpc for Owner { Owner::get_status(self) } + fn get_mining_status(&self) -> Result { + Owner::get_mining_status(self) + } + fn validate_chain(&self, assume_valid_rangeproofs_kernels: bool) -> Result<(), Error> { Owner::validate_chain(self, assume_valid_rangeproofs_kernels) } diff --git a/api/src/types.rs b/api/src/types.rs index 06918262eb..9093fc74dc 100644 --- a/api/src/types.rs +++ b/api/src/types.rs @@ -104,6 +104,71 @@ impl Status { } } +/// Stratum worker statistics exposed via the Owner mining API. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct WorkerInfo { + /// Unique ID for this worker + pub id: String, + /// Unix timestamp (seconds) of most recent communication with this worker + pub last_seen: u64, + /// Block height the worker started mining at + pub initial_block_height: u64, + /// PoW difficulty this worker is using + pub pow_difficulty: u64, + /// Number of valid shares submitted + pub num_accepted: u64, + /// Number of invalid shares submitted + pub num_rejected: u64, + /// Number of shares submitted too late + pub num_stale: u64, + /// Number of valid blocks found by this worker + pub num_blocks_found: u64, +} + +/// Mining / stratum status available via the Owner API (and `grin client miningstatus`). +/// Mirrors the stats shown on the TUI mining tab so headless operators can query them. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct MiningStatus { + /// Whether the stratum server is enabled in config + pub is_enabled: bool, + /// Whether the stratum server has been started. Set once at startup and + /// not cleared on listener failure or shutdown. + pub is_running: bool, + /// Number of currently connected workers + pub num_workers: usize, + /// Block height currently being mined + pub block_height: u64, + /// Current network difficulty + pub network_difficulty: u64, + /// Edge bits (cuckoo size) for the current network target + pub edge_bits: u16, + /// Total blocks found by all workers + pub blocks_found: u16, + /// Estimated network hashrate for the current edge_bits + pub network_hashrate: f64, + /// Minimum share difficulty requested from miners + pub minimum_share_difficulty: u64, + /// Per-worker status for currently connected workers + pub worker_stats: Vec, +} + +impl Default for MiningStatus { + fn default() -> MiningStatus { + MiningStatus { + is_enabled: false, + is_running: false, + num_workers: 0, + block_height: 0, + network_difficulty: 0, + edge_bits: 32, + blocks_found: 0, + network_hashrate: 0.0, + minimum_share_difficulty: 1, + worker_stats: Vec::new(), + } + } +} + /// TxHashSet #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TxHashSet { @@ -781,4 +846,44 @@ mod test { let serialized = serde_json::to_string(&deserialized).unwrap(); assert_eq!(serialized, hex_commit); } + + #[test] + fn mining_status_default_is_disabled() { + let status = MiningStatus::default(); + assert!(!status.is_enabled); + assert!(!status.is_running); + assert_eq!(status.num_workers, 0); + assert!(status.worker_stats.is_empty()); + } + + #[test] + fn serialize_mining_status_roundtrip() { + let status = MiningStatus { + is_enabled: true, + is_running: true, + num_workers: 1, + block_height: 1000, + network_difficulty: 42, + edge_bits: 29, + blocks_found: 3, + network_hashrate: 1.5, + minimum_share_difficulty: 1, + worker_stats: vec![WorkerInfo { + id: "0".into(), + last_seen: 1609459200, + initial_block_height: 990, + pow_difficulty: 1, + num_accepted: 10, + num_rejected: 0, + num_stale: 0, + num_blocks_found: 1, + }], + }; + let json = serde_json::to_string(&status).unwrap(); + let back: MiningStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(back, status); + assert!(json.contains("\"is_enabled\":true")); + assert!(json.contains("\"num_workers\":1")); + assert!(json.contains("\"num_accepted\":10")); + } } diff --git a/doc/api/api.md b/doc/api/api.md index 8d82ebb751..d1f6227060 100644 --- a/doc/api/api.md +++ b/doc/api/api.md @@ -11,6 +11,27 @@ This API version uses jsonrpc for its requests. It is split up in a foreign API Basic auth passwords can be found in `.api_secret`/`.foreign_api_secret` files respectively. +### Mining status (Owner API) + +Headless operators can query stratum mining statistics (the same data shown on the TUI mining tab) via: + +```json +{ + "jsonrpc": "2.0", + "method": "get_mining_status", + "params": [], + "id": 1 +} +``` + +Or from the CLI: + +``` +grin client miningstatus +``` + +The response includes whether stratum is enabled/running, connected worker count, current block height and network difficulty, blocks found, network hashrate estimate, and per-worker share stats for currently connected workers. + ## Node API v1 **Note:** version 1 of the API will be deprecated in v4.0.0 and subsequently removed in v5.0.0. Users of this API are encouraged to upgrade to API v2. diff --git a/servers/src/common/stats.rs b/servers/src/common/stats.rs index 2a537f4589..6d22b53992 100644 --- a/servers/src/common/stats.rs +++ b/servers/src/common/stats.rs @@ -19,7 +19,7 @@ use chrono::prelude::*; use grin_core::pow::Difficulty; use millisecond::MillisecondFormatter; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::chain::SyncStatus; use crate::core::core::hash::Hash; @@ -27,6 +27,7 @@ use crate::core::ser::ProtocolVersion; use crate::p2p; use crate::p2p::Capabilities; use crate::util::RwLock; +use grin_api::types::{MiningStatus, WorkerInfo}; /// Server state info collection struct, to be passed around into internals /// and populated when required @@ -292,3 +293,100 @@ impl Default for StratumStats { } } } + +impl From<&WorkerStats> for WorkerInfo { + fn from(stats: &WorkerStats) -> WorkerInfo { + let last_seen = stats + .last_seen + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + WorkerInfo { + id: stats.id.clone(), + last_seen, + initial_block_height: stats.initial_block_height, + pow_difficulty: stats.pow_difficulty, + num_accepted: stats.num_accepted, + num_rejected: stats.num_rejected, + num_stale: stats.num_stale, + num_blocks_found: stats.num_blocks_found, + } + } +} + +impl From<&StratumStats> for MiningStatus { + fn from(stats: &StratumStats) -> MiningStatus { + MiningStatus { + is_enabled: stats.is_enabled, + is_running: stats.is_running, + num_workers: stats.num_workers, + block_height: stats.block_height, + network_difficulty: stats.network_difficulty, + edge_bits: stats.edge_bits, + blocks_found: stats.blocks_found, + network_hashrate: stats.network_hashrate, + minimum_share_difficulty: stats.minimum_share_difficulty, + // worker_stats retains every worker that ever connected; only expose + // currently connected ones so the response does not grow unbounded. + worker_stats: stats + .worker_stats + .iter() + .filter(|w| w.is_connected) + .map(WorkerInfo::from) + .collect(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::time::Duration; + + #[test] + fn stratum_stats_to_mining_status() { + let mut worker = WorkerStats::default(); + worker.id = "42".into(); + worker.is_connected = true; + worker.last_seen = UNIX_EPOCH + Duration::from_secs(1_600_000_000); + worker.initial_block_height = 100; + worker.pow_difficulty = 7; + worker.num_accepted = 5; + worker.num_rejected = 1; + worker.num_stale = 2; + worker.num_blocks_found = 1; + + let mut disconnected = WorkerStats::default(); + disconnected.id = "gone".into(); + disconnected.is_connected = false; + + let stats = StratumStats { + is_enabled: true, + is_running: true, + num_workers: 1, + block_height: 123, + network_difficulty: 999, + edge_bits: 29, + blocks_found: 4, + network_hashrate: 12.5, + minimum_share_difficulty: 3, + worker_stats: vec![worker, disconnected], + }; + + let mining = MiningStatus::from(&stats); + assert!(mining.is_enabled); + assert!(mining.is_running); + assert_eq!(mining.num_workers, 1); + assert_eq!(mining.block_height, 123); + assert_eq!(mining.network_difficulty, 999); + assert_eq!(mining.edge_bits, 29); + assert_eq!(mining.blocks_found, 4); + assert!((mining.network_hashrate - 12.5).abs() < f64::EPSILON); + assert_eq!(mining.minimum_share_difficulty, 3); + assert_eq!(mining.worker_stats.len(), 1); + assert_eq!(mining.worker_stats[0].id, "42"); + assert_eq!(mining.worker_stats[0].last_seen, 1_600_000_000); + assert_eq!(mining.worker_stats[0].num_accepted, 5); + assert_eq!(mining.worker_stats[0].num_blocks_found, 1); + } +} diff --git a/servers/src/grin/server.rs b/servers/src/grin/server.rs index f486350345..1d777d95d8 100644 --- a/servers/src/grin/server.rs +++ b/servers/src/grin/server.rs @@ -308,7 +308,15 @@ impl Server { } }; - api::node_apis( + // Shared with stratum (started later) and Owner API `get_mining_status`. + let state_info = ServerStateInfo::default(); + let stratum_stats = state_info.stratum_stats.clone(); + let mining_stats: api::MiningStatsProvider = Arc::new(move || { + let stats = stratum_stats.read(); + api::types::MiningStatus::from(&*stats) + }); + + api::node_apis_with_mining_stats( &config.api_http_addr, shared_chain.clone(), tx_pool.clone(), @@ -319,6 +327,7 @@ impl Server { tls_conf, api_chan, stop_state.clone(), + Some(mining_stats), )?; info!("Starting dandelion monitor: {}", &config.api_http_addr); @@ -336,9 +345,7 @@ impl Server { chain: shared_chain, tx_pool, sync_state, - state_info: ServerStateInfo { - ..Default::default() - }, + state_info, stop_state, lock_file, start_time, diff --git a/src/bin/cmd/client.rs b/src/bin/cmd/client.rs index 04bbdeb32b..fd13eced67 100644 --- a/src/bin/cmd/client.rs +++ b/src/bin/cmd/client.rs @@ -19,7 +19,7 @@ use clap::ArgMatches; use crate::api::client; use crate::api::json_rpc::*; -use crate::api::types::Status; +use crate::api::types::{MiningStatus, Status}; use crate::config::GlobalConfig; use crate::p2p::types::PeerInfoDisplay; use crate::util::file::get_first_line; @@ -139,6 +139,69 @@ impl HTTPNodeClient { e.reset().unwrap(); } + pub fn show_mining_status(&self) { + println!(); + let title = "Grin Mining Status".to_string(); + if term::stdout().is_none() { + println!("Could not open terminal"); + return; + } + let mut t = term::stdout().unwrap(); + let mut e = term::stdout().unwrap(); + t.fg(term::color::MAGENTA).unwrap(); + writeln!(t, "{}", title).unwrap(); + writeln!(t, "--------------------------").unwrap(); + t.reset().unwrap(); + match self.send_json_request::("get_mining_status", &serde_json::Value::Null) + { + Ok(status) => { + writeln!(e, "Mining server enabled: {}", status.is_enabled).unwrap(); + writeln!(e, "Mining server running: {}", status.is_running).unwrap(); + writeln!(e, "Active workers: {}", status.num_workers).unwrap(); + writeln!(e, "Blocks found: {}", status.blocks_found).unwrap(); + if status.num_workers > 0 { + writeln!(e, "Solving block height: {}", status.block_height).unwrap(); + writeln!(e, "Network difficulty: {}", status.network_difficulty).unwrap(); + writeln!( + e, + "Network hashrate (C{}): {:.2}", + status.edge_bits, status.network_hashrate + ) + .unwrap(); + } + writeln!( + e, + "Minimum share difficulty: {}", + status.minimum_share_difficulty + ) + .unwrap(); + if !status.worker_stats.is_empty() { + writeln!(e, "Workers:").unwrap(); + for worker in status.worker_stats { + writeln!( + e, + " id={} accepted={} rejected={} stale={} blocks={} difficulty={}", + worker.id, + worker.num_accepted, + worker.num_rejected, + worker.num_stale, + worker.num_blocks_found, + worker.pow_difficulty + ) + .unwrap(); + } + } + } + Err(_) => writeln!( + e, + "WARNING: Client failed to get mining data. Is your `grin server` offline or broken?" + ) + .unwrap(), + }; + e.reset().unwrap(); + println!() + } + pub fn reset_chain_head(&self, hash: String) { let mut e = term::stdout().unwrap(); let params = json!([hash]); @@ -211,6 +274,9 @@ pub fn client_command(client_args: &ArgMatches<'_>, global_config: GlobalConfig) ("status", Some(_)) => { node_client.show_status(); } + ("miningstatus", Some(_)) => { + node_client.show_mining_status(); + } ("listconnectedpeers", Some(_)) => { node_client.list_connected_peers(); } diff --git a/src/bin/grin.yml b/src/bin/grin.yml index 018983f210..37d836804f 100644 --- a/src/bin/grin.yml +++ b/src/bin/grin.yml @@ -56,6 +56,8 @@ subcommands: subcommands: - status: about: Current status of the Grin chain + - miningstatus: + about: Current stratum mining status and connected workers - listconnectedpeers: about: Print a list of currently connected peers - ban: