diff --git a/src/app.rs b/src/app.rs index 824b447..2d688c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::bitcoin_config::ConfigEntry as BitcoinEntry; +use crate::components::bitcoin_client::{BitcoinChainInfo, BitcoinClient}; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo, SharesResponse}; @@ -105,6 +106,8 @@ pub struct App { pub p2pool_config: Option, pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, + pub bitcoin_chain_info: Option, + pub bitcoin_chain_info_error: Option, pub settings: Settings, pub p2pool_client: P2PoolClient, pub p2pool_websocket_client: P2PoolWebSocketClient, @@ -125,6 +128,8 @@ pub struct App { pub live_peer_events: Vec, pub p2pool_live_error: Option, pub p2pool_live_stream_started: bool, + pub bitcoin_chain_info_tx: mpsc::UnboundedSender>, + pub bitcoin_chain_info_rx: mpsc::UnboundedReceiver>, pub p2pool_live_tx: mpsc::UnboundedSender>, pub p2pool_live_rx: mpsc::UnboundedReceiver>, // async channel to receive chain info updates from the background task that @@ -141,6 +146,7 @@ impl App { #[must_use] pub fn new() -> App { let (chain_info_tx, chain_info_rx) = mpsc::unbounded_channel(); + let (bitcoin_chain_info_tx, bitcoin_chain_info_rx) = mpsc::unbounded_channel(); let (peer_info_tx, peer_info_rx) = mpsc::unbounded_channel(); let (share_info_tx, share_info_rx) = mpsc::unbounded_channel(); let (p2pool_live_tx, p2pool_live_rx) = mpsc::unbounded_channel(); @@ -157,6 +163,8 @@ impl App { p2pool_config: None, bitcoin_data: Vec::new(), bitcoin_status_tab: 0, + bitcoin_chain_info: None, + bitcoin_chain_info_error: None, settings: Settings::default(), p2pool_client: P2PoolClient::new(), p2pool_websocket_client: P2PoolWebSocketClient::new(), @@ -173,6 +181,8 @@ impl App { live_peer_events: Vec::new(), p2pool_live_error: None, p2pool_live_stream_started: false, + bitcoin_chain_info_tx, + bitcoin_chain_info_rx, p2pool_live_tx, p2pool_live_rx, chain_info_tx, @@ -208,6 +218,21 @@ impl App { } } + pub fn poll_bitcoin_chain_info(&mut self) { + while let Ok(result) = self.bitcoin_chain_info_rx.try_recv() { + match result { + Ok(info) => { + self.bitcoin_chain_info = Some(info); + self.bitcoin_chain_info_error = None; + } + Err(e) => { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = Some(e.to_string()); + } + } + } + } + pub fn poll_peer_info(&mut self) { while let Ok(result) = self.peer_info_rx.try_recv() { match result { @@ -305,6 +330,9 @@ impl App { } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; + if self.current_screen == CurrentScreen::BitcoinStatus { + self.fetch_bitcoin_chain_info(); + } if self.current_screen == CurrentScreen::P2PoolStatus { let chain_client = self.p2pool_client.clone(); let chain_tx = self.chain_info_tx.clone(); @@ -347,9 +375,125 @@ impl App { } } } + + fn fetch_bitcoin_chain_info(&mut self) { + self.bitcoin_chain_info = None; + self.bitcoin_chain_info_error = None; + + if self.bitcoin_conf_path.is_none() { + return; + } + + let client = BitcoinClient::from_config_entries(&self.bitcoin_data); + let tx = self.bitcoin_chain_info_tx.clone(); + + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let res = client.fetch_chain_info().await; + let _ = tx.send(res); + }); + } + } } impl Default for App { fn default() -> Self { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_bitcoin_chain_info_updates_state_on_success() { + let mut app = App::new(); + app.bitcoin_chain_info_error = Some("stale".to_string()); + app.bitcoin_chain_info_tx + .send(Ok(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + })) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + let info = app.bitcoin_chain_info.as_ref().unwrap(); + + assert_eq!(info.block_height, 1); + assert_eq!(info.best_block_hash, "abc"); + assert!(app.bitcoin_chain_info_error.is_none()); + } + + #[test] + fn poll_bitcoin_chain_info_updates_state_on_error() { + let mut app = App::new(); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + }); + app.bitcoin_chain_info_tx + .send(Err(anyhow::anyhow!("boom"))) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert_eq!(app.bitcoin_chain_info_error.as_deref(), Some("boom")); + } + + #[test] + fn poll_bitcoin_chain_info_processes_all_queued_results() { + let mut app = App::new(); + app.bitcoin_chain_info_tx + .send(Ok(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + })) + .unwrap(); + app.bitcoin_chain_info_tx + .send(Err(anyhow::anyhow!("second failure"))) + .unwrap(); + + app.poll_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert_eq!( + app.bitcoin_chain_info_error.as_deref(), + Some("second failure") + ); + } + + #[test] + fn fetch_bitcoin_chain_info_clears_state_without_configured_bitcoin_conf() { + let mut app = App::new(); + app.bitcoin_conf_path = None; + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 1, + best_block_hash: "abc".to_string(), + verification_progress: None, + initial_block_download: None, + connection_count: None, + }); + app.bitcoin_chain_info_error = Some("stale".to_string()); + + app.fetch_bitcoin_chain_info(); + + assert!(app.bitcoin_chain_info.is_none()); + assert!(app.bitcoin_chain_info_error.is_none()); + assert!(app.bitcoin_chain_info_rx.try_recv().is_err()); + } +} diff --git a/src/components/bitcoin_client.rs b/src/components/bitcoin_client.rs new file mode 100644 index 0000000..d5e452d --- /dev/null +++ b/src/components/bitcoin_client.rs @@ -0,0 +1,636 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::bitcoin_config::ConfigEntry; +use anyhow::{Context, Result, anyhow, bail}; +use reqwest::Client; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::{path::PathBuf, time::Duration}; + +const REQUEST_TIMEOUT_SECONDS: u64 = 10; + +#[derive(Debug, Clone)] +pub struct BitcoinClient { + client: Client, + url: String, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct BitcoinChainInfo { + pub network: String, + pub block_height: u64, + pub best_block_hash: String, + pub verification_progress: Option, + pub initial_block_download: Option, + pub connection_count: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BitcoinNetwork { + Mainnet, + Testnet, + Testnet4, + Signet, + Regtest, +} + +#[derive(Debug, Deserialize)] +struct BlockchainInfoResponse { + chain: String, + blocks: u64, + bestblockhash: String, + verificationprogress: Option, + initialblockdownload: Option, +} + +#[derive(Debug, Serialize)] +struct RpcRequest<'a> { + jsonrpc: &'static str, + id: &'static str, + method: &'a str, + params: &'static [Value], +} + +#[derive(Debug, Deserialize)] +struct RpcResponse { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct RpcError { + code: i64, + message: String, +} + +impl BitcoinClient { + #[must_use] + pub fn from_config_entries(entries: &[ConfigEntry]) -> Self { + let network = network_from_entries(entries); + let port = entry_value(entries, "rpcport") + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| default_rpc_port(network)); + let host = entry_value(entries, "rpcbind").unwrap_or("127.0.0.1"); + let url = rpc_url(host, port); + let auth_credentials = rpc_auth(entries, network); + + Self { + client: build_client(), + url, + auth_credentials, + } + } + + pub async fn fetch_chain_info(&self) -> Result { + let chain_info: BlockchainInfoResponse = self.rpc_call("getblockchaininfo").await?; + let connection_count = self.rpc_call("getconnectioncount").await.ok(); + + Ok(BitcoinChainInfo { + network: display_network(&chain_info.chain).to_string(), + block_height: chain_info.blocks, + best_block_hash: chain_info.bestblockhash, + verification_progress: chain_info.verificationprogress, + initial_block_download: chain_info.initialblockdownload, + connection_count, + }) + } + + async fn rpc_call(&self, method: &str) -> Result + where + T: DeserializeOwned, + { + let request = RpcRequest { + jsonrpc: "1.0", + id: "pdm", + method, + params: &[], + }; + + let mut builder = self.client.post(&self.url).json(&request); + if let Some((user, pass)) = &self.auth_credentials { + builder = builder.basic_auth(user, Some(pass)); + } + + let response = builder + .send() + .await + .with_context(|| format!("could not connect to Bitcoin Core at {}", self.url))? + .error_for_status() + .context("Bitcoin Core RPC returned an HTTP error")? + .json::>() + .await + .context("Bitcoin Core RPC returned an invalid response")?; + + if let Some(error) = response.error { + bail!("Bitcoin Core RPC error {}: {}", error.code, error.message); + } + + response + .result + .ok_or_else(|| anyhow!("Bitcoin Core RPC response did not include a result")) + } +} + +fn build_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) + .build() + .expect("Failed to build reqwest client") +} + +fn entry_value<'a>(entries: &'a [ConfigEntry], key: &str) -> Option<&'a str> { + entries + .iter() + .find(|entry| entry.enabled && entry.key == key && !entry.value.trim().is_empty()) + .map(|entry| entry.value.trim()) +} + +fn network_from_entries(entries: &[ConfigEntry]) -> BitcoinNetwork { + if bool_entry(entries, "regtest") { + return BitcoinNetwork::Regtest; + } + if bool_entry(entries, "signet") { + return BitcoinNetwork::Signet; + } + if bool_entry(entries, "testnet4") { + return BitcoinNetwork::Testnet4; + } + if bool_entry(entries, "testnet") { + return BitcoinNetwork::Testnet; + } + + match entry_value(entries, "chain") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "test" | "testnet" | "testnet3" => BitcoinNetwork::Testnet, + "testnet4" => BitcoinNetwork::Testnet4, + "signet" => BitcoinNetwork::Signet, + "regtest" => BitcoinNetwork::Regtest, + _ => BitcoinNetwork::Mainnet, + } +} + +fn bool_entry(entries: &[ConfigEntry], key: &str) -> bool { + matches!( + entry_value(entries, key) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +fn default_rpc_port(network: BitcoinNetwork) -> u16 { + match network { + BitcoinNetwork::Mainnet => 8332, + BitcoinNetwork::Testnet => 18332, + BitcoinNetwork::Testnet4 => 48332, + BitcoinNetwork::Signet => 38332, + BitcoinNetwork::Regtest => 18443, + } +} + +fn rpc_url(host: &str, port: u16) -> String { + let host = host.trim().trim_matches('/'); + if host.starts_with("http://") || host.starts_with("https://") { + return host.to_string(); + } + if has_explicit_port(host) { + return format!("http://{host}"); + } + if host.contains(':') && !host.starts_with('[') { + return format!("http://[{host}]:{port}"); + } + format!("http://{host}:{port}") +} + +fn has_explicit_port(host: &str) -> bool { + if let Some(end_bracket) = host.find(']') { + return host[end_bracket + 1..].starts_with(':'); + } + + host.matches(':').count() == 1 + && host + .rsplit_once(':') + .is_some_and(|(_, port)| port.parse::().is_ok()) +} + +fn rpc_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Option<(String, String)> { + if let (Some(user), Some(pass)) = ( + entry_value(entries, "rpcuser"), + entry_value(entries, "rpcpassword"), + ) { + return Some((user.to_string(), pass.to_string())); + } + + read_cookie_auth(entries, network).ok() +} + +fn read_cookie_auth(entries: &[ConfigEntry], network: BitcoinNetwork) -> Result<(String, String)> { + let cookie_path = cookie_path(entries, network); + let content = std::fs::read_to_string(&cookie_path) + .with_context(|| format!("could not read RPC cookie at {}", cookie_path.display()))?; + let (user, pass) = content + .trim() + .split_once(':') + .ok_or_else(|| anyhow!("RPC cookie did not contain username and password"))?; + + Ok((user.to_string(), pass.to_string())) +} + +fn cookie_path(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + if let Some(path) = entry_value(entries, "rpccookiefile") { + let configured = PathBuf::from(path); + if configured.is_absolute() { + return configured; + } + return data_dir(entries, network).join(configured); + } + + data_dir(entries, network).join(".cookie") +} + +fn data_dir(entries: &[ConfigEntry], network: BitcoinNetwork) -> PathBuf { + let base = entry_value(entries, "datadir") + .map(PathBuf::from) + .or_else(default_data_dir) + .unwrap_or_default(); + + match network { + BitcoinNetwork::Mainnet => base, + BitcoinNetwork::Testnet => base.join("testnet3"), + BitcoinNetwork::Testnet4 => base.join("testnet4"), + BitcoinNetwork::Signet => base.join("signet"), + BitcoinNetwork::Regtest => base.join("regtest"), + } +} + +fn default_data_dir() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".bitcoin")) +} + +fn display_network(chain: &str) -> &str { + match chain { + "main" => "mainnet", + "test" | "testnet" | "testnet3" | "testnet4" => "testnet", + "signet" => "signet", + "regtest" => "regtest", + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::{Matcher, Server}; + use serde_json::json; + + fn entry(key: &str, value: &str) -> ConfigEntry { + ConfigEntry { + key: key.to_string(), + value: value.to_string(), + schema: None, + enabled: true, + section: None, + } + } + + #[test] + fn builds_default_mainnet_endpoint() { + let client = BitcoinClient::from_config_entries(&[]); + + assert_eq!(client.url, "http://127.0.0.1:8332"); + } + + #[test] + fn uses_configured_rpc_port_and_auth() { + let entries = vec![ + entry("rpcport", "18443"), + entry("rpcuser", "alice"), + entry("rpcpassword", "secret"), + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + assert_eq!( + client.auth_credentials, + Some(("alice".to_string(), "secret".to_string())) + ); + } + + #[test] + fn detects_network_from_chain_setting() { + let entries = vec![entry("chain", "testnet4")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:48332"); + } + + #[test] + fn preserves_rpcbind_with_explicit_port() { + let entries = vec![entry("rpcbind", "127.0.0.1:18443")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:18443"); + } + + #[tokio::test] + async fn fetch_chain_info_success() { + let mut server = Server::new_async().await; + + let chain_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 850_000u64, + "bestblockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054", + "verificationprogress": 0.9999, + "initialblockdownload": false + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + let connections_mock = server + .mock("POST", "/") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "result": 8u64, "error": null, "id": "pdm" }).to_string()) + .create(); + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.network, "mainnet"); + assert_eq!(result.block_height, 850_000); + assert_eq!( + result.best_block_hash, + "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + ); + assert_eq!(result.verification_progress, Some(0.9999)); + assert_eq!(result.initial_block_download, Some(false)); + assert_eq!(result.connection_count, Some(8)); + chain_mock.assert(); + connections_mock.assert(); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_rpc_error_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": null, + "error": {"code": -8, "message": "invalid parameter"}, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC error -8: invalid parameter" + ); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_http_error_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(500) + .with_body("internal error") + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!(error.to_string(), "Bitcoin Core RPC returned an HTTP error"); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_for_invalid_json_response() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("not-json") + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC returned an invalid response" + ); + } + + #[tokio::test] + async fn fetch_chain_info_returns_error_when_result_is_missing() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "result": null, "error": null, "id": "pdm" }).to_string()) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let error = client.fetch_chain_info().await.unwrap_err(); + + assert_eq!( + error.to_string(), + "Bitcoin Core RPC response did not include a result" + ); + } + + #[tokio::test] + async fn fetch_chain_info_treats_connection_count_failure_as_none() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 111, + "bestblockhash": "abc", + "verificationprogress": 0.5, + "initialblockdownload": true + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + server + .mock("POST", "/") + .match_body(Matcher::Regex("getconnectioncount".to_string())) + .with_status(500) + .with_body("boom") + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: None, + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.block_height, 111); + assert_eq!(result.best_block_hash, "abc"); + assert_eq!(result.verification_progress, Some(0.5)); + assert_eq!(result.initial_block_download, Some(true)); + assert_eq!(result.connection_count, None); + } + + #[tokio::test] + async fn fetch_chain_info_sends_basic_auth_credentials() { + let mut server = Server::new_async().await; + + server + .mock("POST", "/") + .match_header("authorization", "Basic YWxpY2U6c2VjcmV0") + .match_body(Matcher::Regex("getblockchaininfo".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "result": { + "chain": "main", + "blocks": 1, + "bestblockhash": "abc", + "verificationprogress": null, + "initialblockdownload": null + }, + "error": null, + "id": "pdm" + }) + .to_string(), + ) + .create(); + + let client = BitcoinClient { + client: build_client(), + url: server.url(), + auth_credentials: Some(("alice".to_string(), "secret".to_string())), + }; + + let result = client.fetch_chain_info().await.unwrap(); + + assert_eq!(result.block_height, 1); + } + + #[test] + fn ignores_disabled_and_whitespace_only_config_entries() { + let entries = vec![ + entry("rpcport", " "), + ConfigEntry { + key: "rpcport".to_string(), + value: "18443".to_string(), + schema: None, + enabled: false, + section: None, + }, + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://127.0.0.1:8332"); + } + + #[test] + fn falls_back_to_cookie_auth_when_rpc_password_is_missing() { + let temp_dir = std::env::temp_dir().join(format!( + "pdm-bitcoin-client-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let cookie_path = temp_dir.join(".cookie"); + std::fs::create_dir_all(&temp_dir).unwrap(); + std::fs::write(&cookie_path, "alice:secret").unwrap(); + + let entries = vec![ + entry("rpcuser", "alice"), + entry("rpccookiefile", cookie_path.to_string_lossy().as_ref()), + ]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!( + client.auth_credentials, + Some(("alice".to_string(), "secret".to_string())) + ); + + let _ = std::fs::remove_file(cookie_path); + let _ = std::fs::remove_dir(temp_dir); + } + + #[test] + fn formats_ipv6_rpcbind_without_explicit_port() { + let entries = vec![entry("rpcbind", "::1")]; + let client = BitcoinClient::from_config_entries(&entries); + + assert_eq!(client.url, "http://[::1]:8332"); + } +} diff --git a/src/components/bitcoin_status_view.rs b/src/components/bitcoin_status_view.rs index c2b3017..93eddaf 100644 --- a/src/components/bitcoin_status_view.rs +++ b/src/components/bitcoin_status_view.rs @@ -43,13 +43,7 @@ impl BitcoinStatusView { let content_area = outer[1]; match app.bitcoin_status_tab { // Chain Info - 0 => { - let text = "Chain Info"; - let p = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL)) - .wrap(Wrap { trim: true }); - f.render_widget(p, content_area); - } + 0 => Self::render_chain_info(f, app, content_area), // System 1 => { let text = "System"; @@ -77,6 +71,65 @@ impl BitcoinStatusView { _ => {} } } + + fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { + let text = if app.bitcoin_conf_path.is_none() { + vec![Line::from(Span::styled( + "Select a bitcoin.conf file to load Bitcoin Core chain info.", + Style::default().fg(Color::DarkGray), + ))] + } else if let Some(info) = &app.bitcoin_chain_info { + vec![ + Line::from(format!("Network : {}", info.network)), + Line::from(format!("Block Height : {}", info.block_height)), + Line::from(format!("Best Block Hash : {}", info.best_block_hash)), + Line::from(format!( + "Verification Progress : {}", + Self::format_verification_progress(info.verification_progress) + )), + Line::from(format!( + "Initial Block Download : {}", + Self::format_optional_bool(info.initial_block_download) + )), + Line::from(format!( + "Connection Count : {}", + Self::format_optional_u64(info.connection_count) + )), + ] + } else if let Some(err) = &app.bitcoin_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch Bitcoin chain info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading Bitcoin chain info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Chain Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } + + fn format_verification_progress(progress: Option) -> String { + progress.map_or_else(|| "-".to_string(), |value| format!("{:.2}%", value * 100.0)) + } + + fn format_optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "-", + } + } + + fn format_optional_u64(value: Option) -> String { + value.map_or_else(|| "-".to_string(), |value| value.to_string()) + } } impl Default for BitcoinStatusView { @@ -84,3 +137,111 @@ impl Default for BitcoinStatusView { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::components::bitcoin_client::BitcoinChainInfo; + use ratatui::{Terminal, backend::TestBackend, prelude::Rect}; + use std::path::PathBuf; + + fn render_view(app: &App) -> String { + let backend = TestBackend::new(80, 25); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect::new(0, 0, 80, 25); + + terminal + .draw(|f| BitcoinStatusView::render(f, app, area)) + .unwrap(); + + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn renders_prompt_when_no_bitcoin_conf_is_selected() { + let app = App::new(); + + let output = render_view(&app); + + assert!(output.contains("Select a bitcoin.conf file to load Bitcoin Core chain info.")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_loaded_chain_info_with_formatted_values() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "mainnet".to_string(), + block_height: 850_000, + best_block_hash: "abc123".to_string(), + verification_progress: Some(0.9123), + initial_block_download: Some(true), + connection_count: Some(7), + }); + + let output = render_view(&app).replace(" ", " "); + + assert!(output.contains("Network : mainnet")); + assert!(output.contains("Block Height : 850000")); + assert!(output.contains("Best Block Hash : abc123")); + assert!(output.contains("Verification Progress : 91.23%")); + assert!(output.contains("Initial Block Download : yes")); + assert!(output.contains("Connection Count : 7")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_loading_state_when_chain_info_is_pending() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + + let output = render_view(&app); + + assert!(output.contains("Loading Bitcoin chain info...")); + assert!(!output.contains("Select a bitcoin.conf file")); + assert!(!output.contains("Failed to fetch Bitcoin chain info")); + } + + #[test] + fn renders_error_state_when_chain_info_fetch_fails() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info_error = Some("RPC offline".to_string()); + + let output = render_view(&app); + + assert!(output.contains("Failed to fetch Bitcoin chain info: RPC offline")); + assert!(!output.contains("Loading Bitcoin chain info")); + assert!(!output.contains("Network")); + } + + #[test] + fn renders_none_and_false_formatting_for_optional_values() { + let mut app = App::new(); + app.bitcoin_conf_path = Some(PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_chain_info = Some(BitcoinChainInfo { + network: "testnet".to_string(), + block_height: 42, + best_block_hash: "def456".to_string(), + verification_progress: None, + initial_block_download: Some(false), + connection_count: None, + }); + + let output = render_view(&app); + + assert!(output.contains("Verification Progress : -")); + assert!(output.contains("Initial Block Download : no")); + assert!(output.contains("Connection Count : -")); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 874a783..e55d93a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod bitcoin_client; pub mod bitcoin_config_view; pub mod bitcoin_status_view; pub mod file_explorer; diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index 157eb01..db8ab0e 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -223,15 +223,16 @@ impl P2PoolStatusView { let mut seen = HashSet::new(); for share in app.live_shares.iter().rev() { - seen.insert(share.blockhash.clone()); - entries.push(ShareTableEntry { - height: share.height, - blockhash: share.blockhash.clone(), - miner: share.miner_address.clone(), - bits: share.bits.clone(), - timestamp: share.timestamp, - uncles: share.uncles.len(), - }); + if seen.insert(share.blockhash.clone()) { + entries.push(ShareTableEntry { + height: share.height, + blockhash: share.blockhash.clone(), + miner: share.miner_address.clone(), + bits: share.bits.clone(), + timestamp: share.timestamp, + uncles: share.uncles.len(), + }); + } } if let Some(info) = &app.share_info { @@ -756,6 +757,21 @@ mod tests { assert!(output.contains("12D3KooWNoStatus (Connected)")); } + #[test] + fn render_share_info_deduplicates_duplicate_live_shares() { + let mut app = App::new(); + app.p2pool_status_tab = SHARE_TAB; + + app.live_shares = vec![ + live_share(42, "samehash", "duplicated", 1_700_000_000, "1d00ffff", 0), + live_share(42, "samehash", "duplicated", 1_700_000_001, "1d00ffff", 0), + ]; + + let output = render_view(&app); + + assert_eq!(output.matches("duplicated").count(), 1); + } + #[test] fn short_value_preserves_short_values_and_truncates_long_values() { assert_eq!(P2PoolStatusView::short_value("short", 10), "short"); diff --git a/src/main.rs b/src/main.rs index 5b0acca..cd235e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,7 @@ where ::Error: Send + Sync + 'static, { loop { + app.poll_bitcoin_chain_info(); app.poll_chain_info(); app.poll_share_info(); app.poll_peer_info(); diff --git a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap index 1a0eafc..5593cb3 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_status_screen_render.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 149 expression: terminal.backend() --- TestBackend { @@ -10,9 +11,9 @@ TestBackend { "│Home ││ Chain Info │ System │ Logs │ Peers │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", + "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", + "│P2Pool Status ││Select a bitcoin.conf file to load Bitcoin Core chain│", + "│LN Config ││info. │", "│LN Status ││ │", "│Shares Market ││ │", "│Settings ││ │", @@ -37,6 +38,10 @@ TestBackend { x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 3, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 79, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 6, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 31, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE,