From cff8d3d84856adcb4cb481075fc5d33a926ac323 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 2 May 2026 09:52:34 +0000 Subject: [PATCH 1/6] Add chain_info in P2Pool Status --- Cargo.lock | 252 +++++++++++++++++- Cargo.toml | 3 + config/config.toml | 5 + dummy.toml | 49 ++++ src/app.rs | 37 +++ src/components/mod.rs | 1 + src/components/p2pool_client.rs | 206 ++++++++++++++ src/components/p2pool_status_view.rs | 67 ++++- src/config.rs | 29 ++ src/lib.rs | 1 + src/main.rs | 1 + ...i__tests__p2pool_status_screen_render.snap | 24 +- src/ui.rs | 21 +- 13 files changed, 667 insertions(+), 29 deletions(-) create mode 100644 config/config.toml create mode 100644 dummy.toml create mode 100644 src/components/p2pool_client.rs create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index a15efaa..e767838 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -264,6 +274,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -555,7 +574,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -638,7 +657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -744,6 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -763,6 +783,12 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-sink" version = "0.3.32" @@ -782,7 +808,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -804,8 +833,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -815,9 +846,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -962,6 +995,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -976,6 +1015,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -998,6 +1038,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1353,6 +1394,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -1417,6 +1464,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1463,7 +1535,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1636,9 +1708,12 @@ dependencies = [ "crossterm", "directories", "insta", + "mockito", "p2poolv2_config", "ratatui", + "reqwest", "serde", + "serde_json", "serial_test", "tempfile", "toml 0.8.23", @@ -1722,7 +1797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1786,6 +1861,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1805,6 +1889,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1832,7 +1971,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1841,6 +2000,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -1984,7 +2152,9 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1999,6 +2169,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -2006,6 +2178,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -2013,6 +2186,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -2074,6 +2248,12 @@ dependencies = [ "ordered-multimap 0.7.3", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2093,7 +2273,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2103,6 +2283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2115,6 +2296,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -2178,7 +2360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", - "rand", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -2540,7 +2722,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2695,6 +2877,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -3207,6 +3404,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3653,6 +3869,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 7b690d0..d686749 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ unicode-width = "0.2" p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" } bitcoin = "0.32.5" toml_edit = "0.22" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } +mockito = "1.7.2" +serde_json = "1.0.149" [dev-dependencies] insta = "1.44.3" diff --git a/config/config.toml b/config/config.toml new file mode 100644 index 0000000..8d74355 --- /dev/null +++ b/config/config.toml @@ -0,0 +1,5 @@ +[api] +host = "127.0.0.1" +port = 46884 +auth_user = "p2pool" +auth_pass = "p2pool" diff --git a/dummy.toml b/dummy.toml new file mode 100644 index 0000000..bd5642b --- /dev/null +++ b/dummy.toml @@ -0,0 +1,49 @@ + +[network] +listen_address = "/ip4/127.0.0.1/tcp/6884" +dial_peers = [] +max_pending_incoming = 10 +max_pending_outgoing = 10 +max_established_incoming = 50 +max_established_outgoing = 50 +max_established_per_peer = 1 +max_workbase_per_second = 10 +max_userworkbase_per_second = 10 +max_miningshare_per_second = 100 +max_inventory_per_second = 100 +max_transaction_per_second = 100 +max_requests_per_second = 100 +dial_timeout_secs = 30 + +[store] +path = "./store.db" +background_task_frequency_hours = 24 +pplns_ttl_days = 7 + +[stratum] +hostname = "pool.example.com" +port = 3333 +start_difficulty = 10000 +minimum_difficulty = 100 +solo_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +zmqpubhashblock = "tcp://127.0.0.1:28332" +network = "signet" +version_mask = "1fffe000" +difficulty_multiplier = 1.0 +pool_signature = "P2Poolv2" + +[bitcoinrpc] +url = "http://127.0.0.1:38332" +username = "p2pool" +password = "p2pool" + +[logging] +file = "./logs/p2pool.log" +console = true +level = "info" +stats_dir = "./logs/stats" + +[api] +hostname = "127.0.0.1" +port = 46884 diff --git a/src/app.rs b/src/app.rs index 8452e30..d315ab6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; +use crate::components::p2pool_client::{ChainInfo, P2PoolClient}; use crate::components::p2pool_config_view::P2PoolConfigView; use crate::components::settings_view::SettingsView; use crate::settings::Settings; @@ -31,6 +32,11 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; +/// Tab labels for the P2Pool Status view +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info"]; + +pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; + #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum CurrentScreen { Home, @@ -96,12 +102,16 @@ pub struct App { pub bitcoin_data: Vec, pub bitcoin_status_tab: usize, pub settings: Settings, + pub p2pool_client: P2PoolClient, /// Cached value of the `HOME` environment variable, used for path display. /// Populated once at startup to avoid repeated syscalls during rendering. pub home_dir: String, /// Cached result of `settings::config_dir()`, used to display the default /// settings storage path without repeated env-var lookups during rendering. pub config_dir: PathBuf, + pub p2pool_status_tab: usize, + pub chain_info: Option, + pub p2pool_chain_info_error: Option, } impl App { @@ -121,8 +131,32 @@ impl App { bitcoin_data: Vec::new(), bitcoin_status_tab: 0, settings: Settings::default(), + p2pool_client: P2PoolClient::new(), home_dir: std::env::var("HOME").unwrap_or_default(), config_dir: crate::settings::config_dir().unwrap_or_default(), + p2pool_status_tab: 0, + chain_info: None, + p2pool_chain_info_error: None, + } + } + + #[must_use] + pub fn new_with_client(client: P2PoolClient) -> App { + let mut app = App::new(); + app.p2pool_client = client; + app + } + + pub fn refresh_chain_info(&mut self) { + match self.p2pool_client.fetch_chain_info() { + Ok(info) => { + self.chain_info = Some(info); + self.p2pool_chain_info_error = None; + } + Err(e) => { + self.chain_info = None; + self.p2pool_chain_info_error = Some(e.to_string()); + } } } @@ -142,6 +176,9 @@ impl App { } if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; + if self.current_screen == CurrentScreen::P2PoolStatus { + self.refresh_chain_info(); + } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 65ecad5..1f94aa6 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -8,6 +8,7 @@ pub mod file_explorer; pub mod home_view; pub mod ln_config_view; pub mod ln_status_view; +pub mod p2pool_client; pub mod p2pool_config_view; pub mod p2pool_status_view; pub mod settings_view; diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs new file mode 100644 index 0000000..8f9c898 --- /dev/null +++ b/src/components/p2pool_client.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::config::load_api_config; +use reqwest::blocking::Client; +use serde::Deserialize; +use std::time::Duration; + +const REQUEST_TIMEOUT_SECONDS: u64 = 10; + +#[derive(Debug, Clone)] +pub struct P2PoolClient { + client: Client, + base_url: String, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ChainInfo { + pub genesis_blockhash: Option, + pub chain_tip_height: Option, + pub total_work: String, + pub chain_tip_blockhash: Option, +} + +fn build_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) + .build() + .expect("Failed to build reqwest client") +} + +impl P2PoolClient { + pub fn new() -> Self { + let (base_url, auth_credentials) = load_api_config() + .map(|cfg| { + let credentials = cfg.auth_user.zip(cfg.auth_pass); + (cfg.base_url, credentials) + }) + .unwrap_or_else(|_| ("http://localhost:8332".to_string(), None)); + + Self { + client: build_client(), + base_url, + auth_credentials, + } + } + + pub fn with_base_url(base_url: impl Into) -> Self { + Self { + client: build_client(), + base_url: base_url.into(), + auth_credentials: None, + } + } + + pub fn with_client(client: Client, base_url: impl Into) -> Self { + Self { + client, + base_url: base_url.into(), + auth_credentials: None, + } + } + + pub fn with_auth(mut self, user: String, pass: String) -> Self { + self.auth_credentials = Some((user, pass)); + self + } + + pub fn fetch_chain_info(&self) -> Result { + let url = format!("{}/chain_info", self.base_url); + let mut request = self.client.get(url); + + if let Some((user, pass)) = &self.auth_credentials { + request = request.basic_auth(user, Some(pass)); + } + + let response = request.send()?.error_for_status()?; + let data = response.json::()?; + + Ok(data) + } +} + +impl Default for P2PoolClient { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mockito::Server; + use serde_json::json; + + #[test] + fn test_fetch_chain_info_success() { + let mut server = Server::new(); + + let mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "genesis_blockhash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "chain_tip_height": 850_000u64, + "total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_tip_blockhash": "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_chain_info().unwrap(); + + assert_eq!(result.chain_tip_height, Some(850_000)); + assert_eq!( + result.total_work, + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ); + assert_eq!( + result.genesis_blockhash.unwrap(), + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" + ); + assert_eq!( + result.chain_tip_blockhash.unwrap(), + "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054" + ); + mock.assert(); + } + + #[test] + fn test_fetch_chain_info_sends_basic_auth() { + let mut server = Server::new(); + + let mock = server + .mock("GET", "/chain_info") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "total_work": "abc" }).to_string()) + .create(); + + let client = + P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); + + client.fetch_chain_info().unwrap(); + mock.assert(); + } + + #[test] + fn test_fetch_chain_info_errors_on_http_500() { + let mut server = Server::new(); + + server.mock("GET", "/chain_info").with_status(500).create(); + + let client = P2PoolClient::with_base_url(server.url()); + assert!(client.fetch_chain_info().is_err()); + } + + #[test] + fn test_fetch_chain_info_returns_error_on_missing_required_field() { + let mut server = Server::new(); + + server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "chain_tip_height": 100 }).to_string()) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + assert!(client.fetch_chain_info().is_err()); + } + + #[test] + fn test_with_client_can_be_injected_for_isolated_tests() { + let mut server = Server::new(); + + let mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "genesis_blockhash": null, + "chain_tip_height": 1, + "total_work": "abc", + "chain_tip_blockhash": null + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_client(build_client(), server.url()); + let result = client.fetch_chain_info().unwrap(); + + assert_eq!(result.chain_tip_height, Some(1)); + assert_eq!(result.total_work, "abc"); + mock.assert(); + } +} diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index e4fab69..5b3ef43 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -2,10 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::app::App; +use crate::app::{App, P2POOL_STATUS_TABS}; use ratatui::{ prelude::*, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Paragraph, Tabs, Wrap}, }; #[derive(Debug, Clone)] @@ -18,13 +18,62 @@ impl P2PoolStatusView { } // P2Pool Status - pub fn render(f: &mut Frame, _app: &mut App, area: Rect) { - let p = Paragraph::new("P2Pool Status").block( - Block::default() - .borders(Borders::ALL) - .title(" P2Pool Status "), - ); - f.render_widget(p, area); + pub fn render(f: &mut Frame, app: &App, area: Rect) { + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // Tabs bar + Constraint::Min(0), // Content area + ]) + .split(area); + + let tabs = Tabs::new(P2POOL_STATUS_TABS.to_vec()) + .block(Block::default().borders(Borders::ALL).title(" Info ")) + .select(app.p2pool_status_tab) + .highlight_style(Style::default().bg(Color::Gray).fg(Color::Black)); + + f.render_widget(tabs, outer[0]); + + match app.p2pool_status_tab { + 0 => Self::render_chain_info(f, app, outer[1]), + _ => {} + } + } + + fn render_chain_info(f: &mut Frame, app: &App, area: Rect) { + let text = if let Some(info) = &app.chain_info { + vec![ + Line::from(format!( + "Genesis Blockhash : {}", + info.genesis_blockhash.as_deref().unwrap_or("-") + )), + Line::from(format!( + "Chain Tip Height : {}", + info.chain_tip_height.unwrap_or(0) + )), + Line::from(format!( + "Chain Tip Blockhash : {}", + info.chain_tip_blockhash.as_deref().unwrap_or("-") + )), + Line::from(format!("Total Work : {}", info.total_work)), + ] + } else if let Some(err) = &app.p2pool_chain_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch chain info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading 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); } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..606604b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use anyhow::Result; +use config::{Config, File}; + +pub struct ApiConfig { + pub base_url: String, + pub auth_user: Option, + pub auth_pass: Option, +} + +pub fn load_api_config() -> Result { + let settings = Config::builder() + .add_source(File::with_name("config/config")) + .build()?; + + let host: String = settings.get("api.host")?; + let port: u16 = settings.get("api.port")?; + let auth_user: Option = settings.get("api.auth_user").ok(); + let auth_pass: Option = settings.get("api.auth_pass").ok(); + + Ok(ApiConfig { + base_url: format!("http://{}:{}", host, port), + auth_user, + auth_pass, + }) +} diff --git a/src/lib.rs b/src/lib.rs index 82d6883..2ec9158 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod bitcoin_config; pub mod components; +pub mod config; pub mod p2poolv2_config; pub mod settings; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 0771b47..30b59b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,7 @@ fn main() -> Result<()> { let mut app = App::new(); app.settings = load_settings(); bootstrap_from_settings(&mut app); + app.refresh_chain_info(); let res = run_app(&mut terminal, &mut app); // Restore Terminal diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 3c98693..4ff5c3e 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -1,23 +1,23 @@ --- source: src/ui.rs -assertion_line: 190 +assertion_line: 219 expression: terminal.backend() --- TestBackend { buffer: Buffer { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ - "┌ PDM ──────────────────┐┌ P2Pool Status ──────────────────────────────────────┐", - "│Home ││P2Pool Status │", + "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", + "│Home ││ Chain Info │", "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│Settings ││ │", - "│ ││ │", + "│Bitcoin Status │└─────────────────────────────────────────────────────┘", + "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", + "│P2Pool Status ││Genesis Blockhash : - │", + "│LN Config ││Chain Tip Height : 1 │", + "│LN Status ││Chain Tip Blockhash : - │", + "│Shares Market ││Total Work : │", + "│Settings ││fffffffffffffffffffffffffffffffffffffffffffffffffffff│", + "│ ││fff │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -34,6 +34,8 @@ TestBackend { ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, + x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 5, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, diff --git a/src/ui.rs b/src/ui.rs index 322542c..e9fbede 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -112,6 +112,8 @@ pub fn ui(f: &mut Frame, app: &mut App) { mod tests { use super::*; use crate::app::App; + use crate::components::p2pool_client::P2PoolClient; + use mockito::Server; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -192,8 +194,25 @@ mod tests { #[test] fn test_p2pool_status_screen_render() { + let mut server = Server::new(); + let _mock = server + .mock("GET", "/chain_info") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "genesis_blockhash": null, + "chain_tip_height": 1, + "total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "chain_tip_blockhash": null + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let mut app = App::new_with_client(client); let mut terminal = make_terminal(); - let mut app = App::new(); app.sidebar_index = 4; app.toggle_menu(); terminal.draw(|f| ui(f, &mut app)).unwrap(); From af1afd902678cad555f4944c194fd356175d676d Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sat, 2 May 2026 09:59:51 +0000 Subject: [PATCH 2/6] remove unintended file dummy.toml --- dummy.toml | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 dummy.toml diff --git a/dummy.toml b/dummy.toml deleted file mode 100644 index bd5642b..0000000 --- a/dummy.toml +++ /dev/null @@ -1,49 +0,0 @@ - -[network] -listen_address = "/ip4/127.0.0.1/tcp/6884" -dial_peers = [] -max_pending_incoming = 10 -max_pending_outgoing = 10 -max_established_incoming = 50 -max_established_outgoing = 50 -max_established_per_peer = 1 -max_workbase_per_second = 10 -max_userworkbase_per_second = 10 -max_miningshare_per_second = 100 -max_inventory_per_second = 100 -max_transaction_per_second = 100 -max_requests_per_second = 100 -dial_timeout_secs = 30 - -[store] -path = "./store.db" -background_task_frequency_hours = 24 -pplns_ttl_days = 7 - -[stratum] -hostname = "pool.example.com" -port = 3333 -start_difficulty = 10000 -minimum_difficulty = 100 -solo_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" -bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" -zmqpubhashblock = "tcp://127.0.0.1:28332" -network = "signet" -version_mask = "1fffe000" -difficulty_multiplier = 1.0 -pool_signature = "P2Poolv2" - -[bitcoinrpc] -url = "http://127.0.0.1:38332" -username = "p2pool" -password = "p2pool" - -[logging] -file = "./logs/p2pool.log" -console = true -level = "info" -stats_dir = "./logs/stats" - -[api] -hostname = "127.0.0.1" -port = 46884 From 07b09f9e2d67d063af8981c40f4281c1b441ec0c Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Mon, 4 May 2026 09:02:43 +0000 Subject: [PATCH 3/6] feat(p2pool): add async chain_info fetching --- Cargo.lock | 1 + Cargo.toml | 5 +- src/app.rs | 38 +++++++++---- src/components/p2pool_client.rs | 50 ++++++++--------- src/config.rs | 9 ++-- src/main.rs | 5 +- ...pdm__ui__tests__bitcoin_screen_render.snap | 1 - ..._ui__tests__bitcoin_screen_render.snap.new | 52 ------------------ .../pdm__ui__tests__p2pool_screen_render.snap | 1 - ...__ui__tests__p2pool_screen_render.snap.new | 54 ------------------- ...i__tests__p2pool_status_screen_render.snap | 15 +++--- 11 files changed, 74 insertions(+), 157 deletions(-) delete mode 100644 src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new delete mode 100644 src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new diff --git a/Cargo.lock b/Cargo.lock index e767838..9c3d324 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1716,6 +1716,7 @@ dependencies = [ "serde_json", "serial_test", "tempfile", + "tokio", "toml 0.8.23", "toml_edit", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index d686749..d680fc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,11 @@ p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2p bitcoin = "0.32.5" toml_edit = "0.22" reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } -mockito = "1.7.2" -serde_json = "1.0.149" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } [dev-dependencies] insta = "1.44.3" serial_test = "3" tempfile = "3" +mockito = "1.7.2" +serde_json = "1.0.149" diff --git a/src/app.rs b/src/app.rs index d315ab6..e7d7550 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::components::settings_view::SettingsView; use crate::settings::Settings; use p2poolv2_config::Config as P2PoolConfig; use std::path::PathBuf; +use tokio::sync::mpsc; /// Sidebar items labels pub const SIDEBAR_ITEMS: &[(&str, CurrentScreen)] = &[ @@ -112,11 +113,15 @@ pub struct App { pub p2pool_status_tab: usize, pub chain_info: Option, pub p2pool_chain_info_error: Option, + // async channel to receive chain info updates from the background task that fetches it when the P2Pool Status screen is opened + pub chain_info_tx: mpsc::UnboundedSender>, + pub chain_info_rx: mpsc::UnboundedReceiver>, } impl App { #[must_use] pub fn new() -> App { + let (tx, rx) = mpsc::unbounded_channel(); App { current_screen: CurrentScreen::Home, sidebar_index: 0, @@ -137,6 +142,8 @@ impl App { p2pool_status_tab: 0, chain_info: None, p2pool_chain_info_error: None, + chain_info_tx: tx, + chain_info_rx: rx, } } @@ -147,15 +154,18 @@ impl App { app } - pub fn refresh_chain_info(&mut self) { - match self.p2pool_client.fetch_chain_info() { - Ok(info) => { - self.chain_info = Some(info); - self.p2pool_chain_info_error = None; - } - Err(e) => { - self.chain_info = None; - self.p2pool_chain_info_error = Some(e.to_string()); + /// Non-blocking result handler + pub fn poll_chain_info(&mut self) { + while let Ok(result) = self.chain_info_rx.try_recv() { + match result { + Ok(info) => { + self.chain_info = Some(info); + self.p2pool_chain_info_error = None; + } + Err(e) => { + self.chain_info = None; + self.p2pool_chain_info_error = Some(e.to_string()); + } } } } @@ -177,7 +187,15 @@ impl App { if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; if self.current_screen == CurrentScreen::P2PoolStatus { - self.refresh_chain_info(); + let client = self.p2pool_client.clone(); + let tx = self.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.map_err(anyhow::Error::from)); + }); + } } } } diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index 8f9c898..af89d13 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::config::load_api_config; -use reqwest::blocking::Client; +use reqwest::Client; use serde::Deserialize; use std::time::Duration; @@ -38,7 +38,7 @@ impl P2PoolClient { let credentials = cfg.auth_user.zip(cfg.auth_pass); (cfg.base_url, credentials) }) - .unwrap_or_else(|_| ("http://localhost:8332".to_string(), None)); + .unwrap_or_else(|_| ("http://localhost:46884".to_string(), None)); Self { client: build_client(), @@ -68,7 +68,7 @@ impl P2PoolClient { self } - pub fn fetch_chain_info(&self) -> Result { + pub async fn fetch_chain_info(&self) -> Result { let url = format!("{}/chain_info", self.base_url); let mut request = self.client.get(url); @@ -76,8 +76,8 @@ impl P2PoolClient { request = request.basic_auth(user, Some(pass)); } - let response = request.send()?.error_for_status()?; - let data = response.json::()?; + let response = request.send().await?.error_for_status()?; + let data = response.json::().await?; Ok(data) } @@ -95,9 +95,9 @@ mod tests { use mockito::Server; use serde_json::json; - #[test] - fn test_fetch_chain_info_success() { - let mut server = Server::new(); + #[tokio::test] + async fn test_fetch_chain_info_success() { + let mut server = Server::new_async().await; let mock = server .mock("GET", "/chain_info") @@ -115,7 +115,7 @@ mod tests { .create(); let client = P2PoolClient::with_base_url(server.url()); - let result = client.fetch_chain_info().unwrap(); + let result = client.fetch_chain_info().await.unwrap(); assert_eq!(result.chain_tip_height, Some(850_000)); assert_eq!( @@ -133,9 +133,9 @@ mod tests { mock.assert(); } - #[test] - fn test_fetch_chain_info_sends_basic_auth() { - let mut server = Server::new(); + #[tokio::test] + async fn test_fetch_chain_info_sends_basic_auth() { + let mut server = Server::new_async().await; let mock = server .mock("GET", "/chain_info") @@ -148,23 +148,23 @@ mod tests { let client = P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); - client.fetch_chain_info().unwrap(); + client.fetch_chain_info().await.unwrap(); mock.assert(); } - #[test] - fn test_fetch_chain_info_errors_on_http_500() { - let mut server = Server::new(); + #[tokio::test] + async fn test_fetch_chain_info_errors_on_http_500() { + let mut server = Server::new_async().await; server.mock("GET", "/chain_info").with_status(500).create(); let client = P2PoolClient::with_base_url(server.url()); - assert!(client.fetch_chain_info().is_err()); + assert!(client.fetch_chain_info().await.is_err()); } - #[test] - fn test_fetch_chain_info_returns_error_on_missing_required_field() { - let mut server = Server::new(); + #[tokio::test] + async fn test_fetch_chain_info_returns_error_on_missing_required_field() { + let mut server = Server::new_async().await; server .mock("GET", "/chain_info") @@ -174,12 +174,12 @@ mod tests { .create(); let client = P2PoolClient::with_base_url(server.url()); - assert!(client.fetch_chain_info().is_err()); + assert!(client.fetch_chain_info().await.is_err()); } - #[test] - fn test_with_client_can_be_injected_for_isolated_tests() { - let mut server = Server::new(); + #[tokio::test] + async fn test_with_client_can_be_injected_for_isolated_tests() { + let mut server = Server::new_async().await; let mock = server .mock("GET", "/chain_info") @@ -197,7 +197,7 @@ mod tests { .create(); let client = P2PoolClient::with_client(build_client(), server.url()); - let result = client.fetch_chain_info().unwrap(); + let result = client.fetch_chain_info().await.unwrap(); assert_eq!(result.chain_tip_height, Some(1)); assert_eq!(result.total_work, "abc"); diff --git a/src/config.rs b/src/config.rs index 606604b..fd56aa1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,11 +13,14 @@ pub struct ApiConfig { pub fn load_api_config() -> Result { let settings = Config::builder() - .add_source(File::with_name("config/config")) + .add_source(File::with_name("config/config").required(false)) + .add_source( + File::with_name(concat!(env!("CARGO_MANIFEST_DIR"), "/config/config")).required(false), + ) .build()?; - let host: String = settings.get("api.host")?; - let port: u16 = settings.get("api.port")?; + let host: String = settings.get("api.host").unwrap_or("127.0.0.1".into()); + let port: u16 = settings.get("api.port").unwrap_or(9332); let auth_user: Option = settings.get("api.auth_user").ok(); let auth_pass: Option = settings.get("api.auth_pass").ok(); diff --git a/src/main.rs b/src/main.rs index 30b59b4..76b9060 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,8 @@ use crossterm::{ use ratatui::{Terminal, backend::Backend, backend::CrosstermBackend}; use std::io; -fn main() -> Result<()> { +#[tokio::main] +async fn main() -> Result<()> { // Setup Terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -36,7 +37,6 @@ fn main() -> Result<()> { let mut app = App::new(); app.settings = load_settings(); bootstrap_from_settings(&mut app); - app.refresh_chain_info(); let res = run_app(&mut terminal, &mut app); // Restore Terminal @@ -70,6 +70,7 @@ where ::Error: Send + Sync + 'static, { loop { + app.poll_chain_info(); terminal.draw(|f| ui::ui(f, app))?; if let Event::Key(key) = event::read()? { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap index ea4c542..4463830 100644 --- a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 409 expression: terminal.backend() --- TestBackend { diff --git a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new b/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new deleted file mode 100644 index ea4c542..0000000 --- a/src/snapshots/pdm__ui__tests__bitcoin_screen_render.snap.new +++ /dev/null @@ -1,52 +0,0 @@ ---- -source: src/ui.rs -assertion_line: 409 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 20 }, - content: [ - "┌ PDM ──────────────────┐┌ Bitcoin Config ─────────────────────────────────────┐", - "│Home ││Press [Enter] to select a bitcoin.conf file │", - "│Bitcoin Config ││ │", - "│Bitcoin Status ││ │", - "│P2Pool Config ││ │", - "│P2Pool Status ││ │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 1, y: 2, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - x: 24, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 19, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} diff --git a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap index cea0888..2facef3 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 423 expression: terminal.backend() --- TestBackend { diff --git a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new b/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new deleted file mode 100644 index cea0888..0000000 --- a/src/snapshots/pdm__ui__tests__p2pool_screen_render.snap.new +++ /dev/null @@ -1,54 +0,0 @@ ---- -source: src/ui.rs -assertion_line: 423 -expression: terminal.backend() ---- -TestBackend { - buffer: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 20 }, - content: [ - "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ System │ Logs │ Peers │", - "│Bitcoin Config ││ │", - "│Bitcoin Status │└─────────────────────────────────────────────────────┘", - "│P2Pool Config │┌─────────────────────────────────────────────────────┐", - "│P2Pool Status ││Chain Info │", - "│LN Config ││ │", - "│LN Status ││ │", - "│Shares Market ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "│ ││ │", - "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 27, y: 1, fg: Black, bg: Gray, underline: Reset, modifier: NONE, - 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: 0, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 4, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 23, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 27, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 40, y: 19, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 43, y: 19, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 50, y: 19, fg: Reset, bg: Black, underline: Reset, modifier: NONE, - ] - }, - scrollback: Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 0 } - }, - cursor: false, - pos: ( - 0, - 0, - ), -} diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 4ff5c3e..77cbcf1 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -1,6 +1,5 @@ --- source: src/ui.rs -assertion_line: 219 expression: terminal.backend() --- TestBackend { @@ -12,12 +11,12 @@ TestBackend { "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", - "│P2Pool Status ││Genesis Blockhash : - │", - "│LN Config ││Chain Tip Height : 1 │", - "│LN Status ││Chain Tip Blockhash : - │", - "│Shares Market ││Total Work : │", - "│Settings ││fffffffffffffffffffffffffffffffffffffffffffffffffffff│", - "│ ││fff │", + "│P2Pool Status ││Loading chain info... │", + "│LN Config ││ │", + "│LN Status ││ │", + "│Shares Market ││ │", + "│Settings ││ │", + "│ ││ │", "│ ││ │", "│ ││ │", "│ ││ │", @@ -38,6 +37,8 @@ TestBackend { x: 37, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 1, y: 5, fg: Black, bg: Gray, underline: Reset, modifier: NONE, x: 24, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 26, y: 5, fg: DarkGray, bg: Reset, underline: Reset, modifier: NONE, + x: 47, y: 5, 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, From 233d5f94442db013fb9f57d34fc83a319f73e823 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Wed, 6 May 2026 14:23:57 +0000 Subject: [PATCH 4/6] peer info on P2Pool Status --- src/app.rs | 52 ++++++++--- src/components/p2pool_client.rs | 87 +++++++++++++++++++ src/components/p2pool_status_view.rs | 45 ++++++++++ src/components/status_bar.rs | 2 +- src/main.rs | 20 ++++- ...i__tests__p2pool_status_screen_render.snap | 12 +-- 6 files changed, 200 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index e7d7550..3a6a6de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; -use crate::components::p2pool_client::{ChainInfo, P2PoolClient}; +use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo}; use crate::components::p2pool_config_view::P2PoolConfigView; use crate::components::settings_view::SettingsView; use crate::settings::Settings; @@ -34,7 +34,7 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; /// Tab labels for the P2Pool Status view -pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info"]; +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Peers Info"]; pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; @@ -113,15 +113,21 @@ pub struct App { pub p2pool_status_tab: usize, pub chain_info: Option, pub p2pool_chain_info_error: Option, - // async channel to receive chain info updates from the background task that fetches it when the P2Pool Status screen is opened + pub peer_info: Option>, + pub p2pool_peer_info_error: Option, + // async channel to receive chain info updates from the background task that + // fetches it when the P2Pool Status screen is opened. pub chain_info_tx: mpsc::UnboundedSender>, pub chain_info_rx: mpsc::UnboundedReceiver>, + pub peer_info_tx: mpsc::UnboundedSender>>, + pub peer_info_rx: mpsc::UnboundedReceiver>>, } impl App { #[must_use] pub fn new() -> App { - let (tx, rx) = mpsc::unbounded_channel(); + let (chain_info_tx, chain_info_rx) = mpsc::unbounded_channel(); + let (peer_info_tx, peer_info_rx) = mpsc::unbounded_channel(); App { current_screen: CurrentScreen::Home, sidebar_index: 0, @@ -142,8 +148,12 @@ impl App { p2pool_status_tab: 0, chain_info: None, p2pool_chain_info_error: None, - chain_info_tx: tx, - chain_info_rx: rx, + peer_info: None, + p2pool_peer_info_error: None, + chain_info_tx, + chain_info_rx, + peer_info_tx, + peer_info_rx, } } @@ -170,6 +180,21 @@ impl App { } } + pub fn poll_peer_info(&mut self) { + while let Ok(result) = self.peer_info_rx.try_recv() { + match result { + Ok(info) => { + self.peer_info = Some(info); + self.p2pool_peer_info_error = None; + } + Err(e) => { + self.peer_info = None; + self.p2pool_peer_info_error = Some(e.to_string()); + } + } + } + } + // Logic to switch between sidebar items pub fn toggle_menu(&mut self) { if self.current_screen == CurrentScreen::BitcoinConfig { @@ -187,13 +212,20 @@ impl App { if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; if self.current_screen == CurrentScreen::P2PoolStatus { - let client = self.p2pool_client.clone(); - let tx = self.chain_info_tx.clone(); + let chain_client = self.p2pool_client.clone(); + let chain_tx = self.chain_info_tx.clone(); + let peer_client = self.p2pool_client.clone(); + let peer_tx = self.peer_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.map_err(anyhow::Error::from)); + let res = chain_client.fetch_chain_info().await; + let _ = chain_tx.send(res.map_err(anyhow::Error::from)); + }); + + handle.spawn(async move { + let res = peer_client.fetch_peer_info().await; + let _ = peer_tx.send(res.map_err(anyhow::Error::from)); }); } } diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index af89d13..85fe225 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -24,6 +24,12 @@ pub struct ChainInfo { pub chain_tip_blockhash: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct PeerInfo { + pub peer_id: String, + pub status: Option, +} + fn build_client() -> Client { Client::builder() .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) @@ -81,6 +87,20 @@ impl P2PoolClient { Ok(data) } + + pub async fn fetch_peer_info(&self) -> Result, reqwest::Error> { + let url = format!("{}/peers", self.base_url); + let mut request = self.client.get(url); + + if let Some((user, pass)) = &self.auth_credentials { + request = request.basic_auth(user, Some(pass)); + } + + let response = request.send().await?.error_for_status()?; + let data = response.json::>().await?; + + Ok(data) + } } impl Default for P2PoolClient { @@ -152,6 +172,73 @@ mod tests { mock.assert(); } + #[tokio::test] + async fn test_fetch_peer_info_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!([ + { + "peer_id": "12D3KooWPeerOne", + "status": "Connected" + } + ]) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status.as_deref(), Some("Connected")); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_accepts_missing_status() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([{ "peer_id": "12D3KooWPeerOne" }]).to_string()) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status, None); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_sends_basic_auth() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([]).to_string()) + .create(); + + let client = + P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); + + client.fetch_peer_info().await.unwrap(); + mock.assert(); + } + #[tokio::test] async fn test_fetch_chain_info_errors_on_http_500() { let mut server = Server::new_async().await; diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index 5b3ef43..c7c0107 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -36,6 +36,7 @@ impl P2PoolStatusView { match app.p2pool_status_tab { 0 => Self::render_chain_info(f, app, outer[1]), + 1 => Self::render_peer_info(f, app, outer[1]), _ => {} } } @@ -75,6 +76,50 @@ impl P2PoolStatusView { f.render_widget(paragraph, area); } + + fn render_peer_info(f: &mut Frame, app: &App, area: Rect) { + let text = if let Some(peers) = &app.peer_info { + if peers.is_empty() { + vec![Line::from(Span::styled( + "No connected peers", + Style::default().fg(Color::DarkGray), + ))] + } else { + let mut lines = Vec::with_capacity(peers.len() + 2); + lines.push(Line::from(format!( + "Connected Peers : {}", + peers.len() + ))); + lines.push(Line::from("")); + + for peer in peers { + lines.push(Line::from(format!( + "{} ({})", + peer.peer_id, + peer.status.as_deref().unwrap_or("Connected") + ))); + } + + lines + } + } else if let Some(err) = &app.p2pool_peer_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch peer info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading peer info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Peers Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } } impl Default for P2PoolStatusView { diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index 268346d..775d6f6 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -108,7 +108,7 @@ impl StatusBar { spans.extend(hint("Esc", "Back")); } } - CurrentScreen::BitcoinStatus => { + CurrentScreen::BitcoinStatus | CurrentScreen::P2PoolStatus => { spans.extend(hint("↑↓", "Navigate sidebar")); spans.extend(hint("←→", "Switch tab")); spans.extend(hint("q", "Quit")); diff --git a/src/main.rs b/src/main.rs index 76b9060..59e6675 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,8 @@ use p2poolv2_config::Config as P2PoolConfig; use pdm::app::{ - App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_SIDEBAR_INDEX, + App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_P2POOL_STATUS_TAB, + MAX_SIDEBAR_INDEX, }; use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, @@ -71,6 +72,7 @@ where { loop { app.poll_chain_info(); + app.poll_peer_info(); terminal.draw(|f| ui::ui(f, app))?; if let Event::Key(key) = event::read()? { @@ -112,6 +114,22 @@ where k => sidebar_nav(k, app), }, + CurrentScreen::P2PoolStatus => match key.code { + KeyCode::Left => { + if app.p2pool_status_tab > 0 { + app.p2pool_status_tab -= 1; + } + AppAction::None + } + KeyCode::Right => { + if app.p2pool_status_tab < MAX_P2POOL_STATUS_TAB { + app.p2pool_status_tab += 1; + } + AppAction::None + } + k => sidebar_nav(k, app), + }, + CurrentScreen::BitcoinConfig => { if app.bitcoin_conf_path.is_some() { if app.bitcoin_config_view.sidebar_focused { diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 77cbcf1..9614b70 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -7,7 +7,7 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │", + "│Home ││ Chain Info │ Peers Info │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", @@ -29,7 +29,7 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", + " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, @@ -42,10 +42,10 @@ TestBackend { 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, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 27, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 40, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 43, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 50, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { From 94b66f713c5ba5777c2727be9cf9ec714cba0a45 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Tue, 12 May 2026 21:22:16 +0000 Subject: [PATCH 5/6] Initial commit for share_info --- Cargo.lock | 111 +++++- Cargo.toml | 5 + src/app.rs | 120 ++++++- src/components/mod.rs | 1 + src/components/p2pool_client.rs | 249 +++++++++++++- src/components/p2pool_status_view.rs | 304 ++++++++++++++++- src/components/p2pool_websocket.rs | 317 ++++++++++++++++++ src/main.rs | 8 +- ...i__tests__p2pool_status_screen_render.snap | 3 +- 9 files changed, 1093 insertions(+), 25 deletions(-) create mode 100644 src/components/p2pool_websocket.rs diff --git a/Cargo.lock b/Cargo.lock index 9c3d324..f2f8133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -509,6 +515,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deltae" version = "0.3.2" @@ -789,6 +801,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -809,6 +832,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1038,7 +1062,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -1703,10 +1727,12 @@ name = "pdm" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "bitcoin", "config 0.15.19", "crossterm", "directories", + "futures-util", "insta", "mockito", "p2poolv2_config", @@ -1717,9 +1743,11 @@ dependencies = [ "serial_test", "tempfile", "tokio", + "tokio-tungstenite", "toml 0.8.23", "toml_edit", "unicode-width", + "url", ] [[package]] @@ -1972,6 +2000,8 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1981,10 +2011,20 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2000,6 +2040,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "rand_core" @@ -2187,7 +2230,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2515,6 +2558,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2941,6 +2995,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3147,6 +3217,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3218,6 +3308,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3415,6 +3511,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index d680fc5..f8a5ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,11 @@ bitcoin = "0.32.5" toml_edit = "0.22" reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +base64 = "0.22.1" +futures-util = "0.3" +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +url = "2" +serde_json = "1.0.135" [dev-dependencies] insta = "1.44.3" diff --git a/src/app.rs b/src/app.rs index 3a6a6de..824b447 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,8 +5,11 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; -use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo}; +use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo, SharesResponse}; use crate::components::p2pool_config_view::P2PoolConfigView; +use crate::components::p2pool_websocket::{ + LiveP2PoolEvent, LivePeerEvent, LiveShare, P2PoolWebSocketClient, +}; use crate::components::settings_view::SettingsView; use crate::settings::Settings; use p2poolv2_config::Config as P2PoolConfig; @@ -34,7 +37,7 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; /// Tab labels for the P2Pool Status view -pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Peers Info"]; +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Shares", "Peers Info"]; pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; @@ -104,6 +107,7 @@ pub struct App { pub bitcoin_status_tab: usize, pub settings: Settings, pub p2pool_client: P2PoolClient, + pub p2pool_websocket_client: P2PoolWebSocketClient, /// Cached value of the `HOME` environment variable, used for path display. /// Populated once at startup to avoid repeated syscalls during rendering. pub home_dir: String, @@ -113,12 +117,22 @@ pub struct App { pub p2pool_status_tab: usize, pub chain_info: Option, pub p2pool_chain_info_error: Option, + pub share_info: Option, + pub p2pool_share_info_error: Option, pub peer_info: Option>, pub p2pool_peer_info_error: Option, + pub live_shares: Vec, + pub live_peer_events: Vec, + pub p2pool_live_error: Option, + pub p2pool_live_stream_started: bool, + pub p2pool_live_tx: mpsc::UnboundedSender>, + pub p2pool_live_rx: mpsc::UnboundedReceiver>, // async channel to receive chain info updates from the background task that // fetches it when the P2Pool Status screen is opened. pub chain_info_tx: mpsc::UnboundedSender>, pub chain_info_rx: mpsc::UnboundedReceiver>, + pub share_info_tx: mpsc::UnboundedSender>, + pub share_info_rx: mpsc::UnboundedReceiver>, pub peer_info_tx: mpsc::UnboundedSender>>, pub peer_info_rx: mpsc::UnboundedReceiver>>, } @@ -128,6 +142,8 @@ impl App { pub fn new() -> App { let (chain_info_tx, 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(); App { current_screen: CurrentScreen::Home, sidebar_index: 0, @@ -143,15 +159,26 @@ impl App { bitcoin_status_tab: 0, settings: Settings::default(), p2pool_client: P2PoolClient::new(), + p2pool_websocket_client: P2PoolWebSocketClient::new(), home_dir: std::env::var("HOME").unwrap_or_default(), config_dir: crate::settings::config_dir().unwrap_or_default(), p2pool_status_tab: 0, chain_info: None, p2pool_chain_info_error: None, + share_info: None, + p2pool_share_info_error: None, peer_info: None, p2pool_peer_info_error: None, + live_shares: Vec::new(), + live_peer_events: Vec::new(), + p2pool_live_error: None, + p2pool_live_stream_started: false, + p2pool_live_tx, + p2pool_live_rx, chain_info_tx, chain_info_rx, + share_info_tx, + share_info_rx, peer_info_tx, peer_info_rx, } @@ -160,6 +187,7 @@ impl App { #[must_use] pub fn new_with_client(client: P2PoolClient) -> App { let mut app = App::new(); + app.p2pool_websocket_client = client.websocket_client(); app.p2pool_client = client; app } @@ -195,6 +223,72 @@ impl App { } } + pub fn poll_share_info(&mut self) { + while let Ok(result) = self.share_info_rx.try_recv() { + match result { + Ok(info) => { + self.share_info = Some(info); + self.p2pool_share_info_error = None; + } + Err(e) => { + self.share_info = None; + self.p2pool_share_info_error = Some(e.to_string()); + } + } + } + } + + pub fn poll_live_p2pool_events(&mut self) { + while let Ok(result) = self.p2pool_live_rx.try_recv() { + match result { + Ok(LiveP2PoolEvent::Share(share)) => { + Self::push_limited(&mut self.live_shares, share, 50); + self.p2pool_live_error = None; + } + Ok(LiveP2PoolEvent::Peer(peer_event)) => { + self.apply_live_peer_event(&peer_event); + Self::push_limited(&mut self.live_peer_events, peer_event, 50); + self.p2pool_live_error = None; + } + Err(e) => { + self.p2pool_live_error = Some(e.to_string()); + self.p2pool_live_stream_started = false; + } + } + } + } + + pub fn poll_live_shares(&mut self) { + self.poll_live_p2pool_events(); + } + + fn push_limited(items: &mut Vec, item: T, max_len: usize) { + items.push(item); + if items.len() > max_len { + let extra = items.len() - max_len; + items.drain(0..extra); + } + } + + fn apply_live_peer_event(&mut self, event: &LivePeerEvent) { + if event.status.eq_ignore_ascii_case("disconnected") { + if let Some(peers) = &mut self.peer_info { + peers.retain(|peer| peer.peer_id != event.peer_id); + } + return; + } + + let peers = self.peer_info.get_or_insert_with(Vec::new); + if let Some(peer) = peers.iter_mut().find(|peer| peer.peer_id == event.peer_id) { + peer.status = Some(event.status.clone()); + } else { + peers.push(PeerInfo { + peer_id: event.peer_id.clone(), + status: Some(event.status.clone()), + }); + } + } + // Logic to switch between sidebar items pub fn toggle_menu(&mut self) { if self.current_screen == CurrentScreen::BitcoinConfig { @@ -214,8 +308,13 @@ impl App { if self.current_screen == CurrentScreen::P2PoolStatus { let chain_client = self.p2pool_client.clone(); let chain_tx = self.chain_info_tx.clone(); + let share_client = self.p2pool_client.clone(); + let share_tx = self.share_info_tx.clone(); let peer_client = self.p2pool_client.clone(); let peer_tx = self.peer_info_tx.clone(); + let websocket_client = self.p2pool_websocket_client.clone(); + let live_tx = self.p2pool_live_tx.clone(); + let start_live_stream = !self.p2pool_live_stream_started; if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { @@ -223,10 +322,27 @@ impl App { let _ = chain_tx.send(res.map_err(anyhow::Error::from)); }); + handle.spawn(async move { + let res = share_client.fetch_recent_shares(10).await; + let _ = share_tx.send(res.map_err(anyhow::Error::from)); + }); + handle.spawn(async move { let res = peer_client.fetch_peer_info().await; let _ = peer_tx.send(res.map_err(anyhow::Error::from)); }); + + if start_live_stream { + self.p2pool_live_stream_started = true; + handle.spawn(async move { + if let Err(error) = websocket_client + .subscribe_live_events(live_tx.clone()) + .await + { + let _ = live_tx.send(Err(error)); + } + }); + } } } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 1f94aa6..874a783 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -11,6 +11,7 @@ pub mod ln_status_view; pub mod p2pool_client; pub mod p2pool_config_view; pub mod p2pool_status_view; +pub mod p2pool_websocket; pub mod settings_view; pub mod shares_market_view; pub mod status_bar; diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index 85fe225..af47e35 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -2,17 +2,22 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::components::p2pool_websocket::P2PoolWebSocketClient; use crate::config::load_api_config; use reqwest::Client; use serde::Deserialize; +use serde::Deserializer; +use serde::de::DeserializeOwned; use std::time::Duration; const REQUEST_TIMEOUT_SECONDS: u64 = 10; +const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; #[derive(Debug, Clone)] pub struct P2PoolClient { client: Client, base_url: String, + fallback_base_url: Option, auth_credentials: Option<(String, String)>, } @@ -30,6 +35,36 @@ pub struct PeerInfo { pub status: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct ShareInfo { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + #[serde(deserialize_with = "deserialize_string_or_number")] + pub bits: String, + #[serde(default)] + pub uncles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UncleInfo { + pub blockhash: String, + pub prev_blockhash: String, + pub miner_address: String, + pub timestamp: u64, + pub height: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SharesResponse { + pub from_height: u64, + pub to_height: u64, + #[serde(default)] + pub shares: Vec, +} + fn build_client() -> Client { Client::builder() .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) @@ -44,11 +79,15 @@ impl P2PoolClient { let credentials = cfg.auth_user.zip(cfg.auth_pass); (cfg.base_url, credentials) }) - .unwrap_or_else(|_| ("http://localhost:46884".to_string(), None)); + .unwrap_or_else(|_| (TESTNET4_FALLBACK_BASE_URL.to_string(), None)); + + let fallback_base_url = (base_url != TESTNET4_FALLBACK_BASE_URL) + .then(|| TESTNET4_FALLBACK_BASE_URL.to_string()); Self { client: build_client(), base_url, + fallback_base_url, auth_credentials, } } @@ -57,6 +96,7 @@ impl P2PoolClient { Self { client: build_client(), base_url: base_url.into(), + fallback_base_url: None, auth_credentials: None, } } @@ -65,6 +105,7 @@ impl P2PoolClient { Self { client, base_url: base_url.into(), + fallback_base_url: None, auth_credentials: None, } } @@ -74,32 +115,90 @@ impl P2PoolClient { self } - pub async fn fetch_chain_info(&self) -> Result { - let url = format!("{}/chain_info", self.base_url); - let mut request = self.client.get(url); + pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { + self.fallback_base_url = Some(fallback_base_url.into()); + self + } + pub fn websocket_client(&self) -> P2PoolWebSocketClient { + let mut client = P2PoolWebSocketClient::with_base_url(self.base_url.clone()); if let Some((user, pass)) = &self.auth_credentials { - request = request.basic_auth(user, Some(pass)); + client = client.with_auth(user.clone(), pass.clone()); } + if let Some(fallback_base_url) = &self.fallback_base_url { + client = client.with_fallback_base_url(fallback_base_url.clone()); + } + client + } - let response = request.send().await?.error_for_status()?; - let data = response.json::().await?; - - Ok(data) + pub async fn fetch_chain_info(&self) -> Result { + self.fetch_json_with_fallback("/chain_info", &[]).await } pub async fn fetch_peer_info(&self) -> Result, reqwest::Error> { - let url = format!("{}/peers", self.base_url); + self.fetch_json_with_fallback("/peers", &[]).await + } + + pub async fn fetch_recent_shares(&self, num: u16) -> Result { + self.fetch_json_with_fallback("/shares", &[("num", num.min(100))]) + .await + } + + async fn fetch_json_with_fallback( + &self, + path: &str, + query: &[(&str, u16)], + ) -> Result + where + T: DeserializeOwned, + { + match self + .fetch_json_from_base_url(&self.base_url, path, query, true) + .await + { + Ok(data) => Ok(data), + Err(error) => { + if self.should_try_fallback(&error) { + if let Some(fallback_base_url) = &self.fallback_base_url { + return self + .fetch_json_from_base_url(fallback_base_url, path, query, false) + .await; + } + } + Err(error) + } + } + } + + async fn fetch_json_from_base_url( + &self, + base_url: &str, + path: &str, + query: &[(&str, u16)], + use_auth: bool, + ) -> Result + where + T: DeserializeOwned, + { + let url = format!("{}{}", base_url.trim_end_matches('/'), path); let mut request = self.client.get(url); - if let Some((user, pass)) = &self.auth_credentials { - request = request.basic_auth(user, Some(pass)); + if !query.is_empty() { + request = request.query(query); + } + + if use_auth { + if let Some((user, pass)) = &self.auth_credentials { + request = request.basic_auth(user, Some(pass)); + } } let response = request.send().await?.error_for_status()?; - let data = response.json::>().await?; + response.json::().await + } - Ok(data) + fn should_try_fallback(&self, error: &reqwest::Error) -> bool { + self.fallback_base_url.is_some() && (error.is_connect() || error.is_timeout()) } } @@ -109,12 +208,48 @@ impl Default for P2PoolClient { } } +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(u64), + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(value) => Ok(value), + StringOrNumber::Number(value) => Ok(value.to_string()), + } +} + #[cfg(test)] mod tests { use super::*; - use mockito::Server; + use mockito::{Matcher, Server}; use serde_json::json; + #[test] + fn explicit_base_url_does_not_enable_network_fallback() { + let client = P2PoolClient::with_base_url("http://127.0.0.1:46884"); + + assert_eq!(client.base_url, "http://127.0.0.1:46884"); + assert_eq!(client.fallback_base_url, None); + } + + #[test] + fn fallback_base_url_can_be_configured() { + let client = P2PoolClient::with_base_url("http://127.0.0.1:46884") + .with_fallback_base_url("https://testnet4.p2poolv2.org"); + + assert_eq!( + client.fallback_base_url.as_deref(), + Some("https://testnet4.p2poolv2.org") + ); + } + #[tokio::test] async fn test_fetch_chain_info_success() { let mut server = Server::new_async().await; @@ -239,6 +374,90 @@ mod tests { mock.assert(); } + #[tokio::test] + async fn test_fetch_recent_shares_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/shares") + .match_query(Matcher::UrlEncoded("num".into(), "2".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "from_height": 41, + "to_height": 42, + "shares": [ + { + "blockhash": "0000share", + "prev_blockhash": "ffffprev", + "height": 42, + "miner_address": "miner-address", + "timestamp": 1700000000u64, + "bits": "1d00ffff", + "uncles": [ + { + "blockhash": "0000uncle", + "prev_blockhash": "ffffuncleprev", + "miner_address": "uncle-miner", + "timestamp": 1699999999u64, + "height": 41 + } + ] + } + ] + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_recent_shares(2).await.unwrap(); + + assert_eq!(result.from_height, 41); + assert_eq!(result.to_height, 42); + assert_eq!(result.shares.len(), 1); + assert_eq!(result.shares[0].height, 42); + assert_eq!(result.shares[0].uncles.len(), 1); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_recent_shares_accepts_numeric_bits() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/shares") + .match_query(Matcher::UrlEncoded("num".into(), "1".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "from_height": 42, + "to_height": 42, + "shares": [ + { + "blockhash": "0000share", + "prev_blockhash": "ffffprev", + "height": 42, + "miner_address": "miner-address", + "timestamp": 1700000000u64, + "bits": 454130449, + "uncles": [] + } + ] + }) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_recent_shares(1).await.unwrap(); + + assert_eq!(result.shares[0].bits, "454130449"); + mock.assert(); + } + #[tokio::test] async fn test_fetch_chain_info_errors_on_http_500() { let mut server = Server::new_async().await; diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index c7c0107..2479dd0 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -5,12 +5,23 @@ use crate::app::{App, P2POOL_STATUS_TABS}; use ratatui::{ prelude::*, - widgets::{Block, Borders, Paragraph, Tabs, Wrap}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs, Wrap}, }; +use std::collections::HashSet; #[derive(Debug, Clone)] pub struct P2PoolStatusView; +#[derive(Debug)] +struct ShareTableEntry { + height: u64, + blockhash: String, + miner: String, + bits: String, + timestamp: u64, + uncles: usize, +} + impl P2PoolStatusView { #[must_use] pub fn new() -> Self { @@ -36,7 +47,8 @@ impl P2PoolStatusView { match app.p2pool_status_tab { 0 => Self::render_chain_info(f, app, outer[1]), - 1 => Self::render_peer_info(f, app, outer[1]), + 1 => Self::render_share_info(f, app, outer[1]), + 2 => Self::render_peer_info(f, app, outer[1]), _ => {} } } @@ -77,8 +89,46 @@ impl P2PoolStatusView { f.render_widget(paragraph, area); } + fn render_share_info(f: &mut Frame, app: &App, area: Rect) { + let mut rows = Self::share_rows(app); + if rows.is_empty() { + rows.push(Self::message_row(Self::share_empty_message(app))); + } + + let header = Row::new([ + "Height", + "Blockhash", + "Miner", + "Difficulty", + "Time", + "Uncles", + ]) + .style( + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1); + let widths = [ + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Min(12), + Constraint::Length(6), + ]; + + let table = Table::new(rows, widths) + .block(Block::default().borders(Borders::ALL).title(" Shares ")) + .header(header) + .column_spacing(1) + .style(Style::default().fg(Color::White)); + + f.render_widget(table, area); + } + fn render_peer_info(f: &mut Frame, app: &App, area: Rect) { - let text = if let Some(peers) = &app.peer_info { + let mut text = if let Some(peers) = &app.peer_info { if peers.is_empty() { vec![Line::from(Span::styled( "No connected peers", @@ -114,12 +164,260 @@ impl P2PoolStatusView { ))] }; + if let Some(err) = &app.p2pool_live_error { + text.push(Line::from("")); + text.push(Line::from(Span::styled( + format!("Live stream error: {err}"), + Style::default().fg(Color::Red), + ))); + } + + if !app.live_peer_events.is_empty() { + text.push(Line::from("")); + text.push(Line::from(Span::styled( + "Live Peer Events", + Style::default().add_modifier(Modifier::BOLD), + ))); + for event in app.live_peer_events.iter().rev().take(8) { + text.push(Line::from(format!( + "{}: {}", + event.status, + Self::short_value(&event.peer_id, 42) + ))); + } + } + let paragraph = Paragraph::new(text) .block(Block::default().borders(Borders::ALL).title(" Peers Info ")) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } + + fn short_value(value: &str, max_len: usize) -> String { + if value.len() <= max_len { + return value.to_string(); + } + + if max_len <= 3 { + return value.chars().take(max_len).collect(); + } + + let head_len = (max_len - 3) / 2; + let tail_len = max_len - 3 - head_len; + let head: String = value.chars().take(head_len).collect(); + let tail: String = value + .chars() + .rev() + .take(tail_len) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}...{tail}") + } + + fn share_rows(app: &App) -> Vec> { + let mut entries = Vec::new(); + 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 let Some(info) = &app.share_info { + for share in info.shares.iter().rev() { + 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(), + }); + } + } + } + + entries.sort_by(|left, right| { + right + .height + .cmp(&left.height) + .then_with(|| right.timestamp.cmp(&left.timestamp)) + }); + + entries + .into_iter() + .take(50) + .map(|entry| { + Self::share_row( + entry.height, + &entry.blockhash, + &entry.miner, + &entry.bits, + entry.timestamp, + entry.uncles, + ) + }) + .collect() + } + + fn share_row( + height: u64, + blockhash: &str, + miner: &str, + bits: &str, + timestamp: u64, + uncles: usize, + ) -> Row<'static> { + Row::new(vec![ + Cell::from(height.to_string()), + Self::chip(Self::short_value(blockhash, 10)), + Self::chip(Self::short_value(miner, 10)), + Cell::from(Self::format_difficulty(bits)), + Cell::from(Self::format_timestamp(timestamp)), + Cell::from(uncles.to_string()), + ]) + .height(1) + } + + fn message_row(message: String) -> Row<'static> { + Row::new(vec![ + Cell::from(Span::styled(message, Style::default().fg(Color::DarkGray))), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]) + } + + fn share_empty_message(app: &App) -> String { + if let Some(err) = &app.p2pool_share_info_error { + return format!("Recent shares unavailable: {}", Self::short_value(err, 64)); + } + + if let Some(err) = &app.p2pool_live_error { + return format!("Live shares unavailable: {}", Self::short_value(err, 64)); + } + + "Waiting for share data...".to_string() + } + + fn chip(value: String) -> Cell<'static> { + Cell::from(Span::styled( + value, + Style::default().fg(Color::Gray).bg(Color::Black), + )) + } + + fn format_difficulty(bits: &str) -> String { + let Some(bits) = Self::parse_bits(bits) else { + return Self::short_value(bits, 10); + }; + + let exponent = (bits >> 24) as i32; + let mantissa = bits & 0x00ff_ffff; + if mantissa == 0 { + return "-".to_string(); + } + + let difficulty = (0x00ff_ff_u32 as f64 / mantissa as f64) * 256_f64.powi(0x1d - exponent); + if !difficulty.is_finite() || difficulty <= 0.0 { + return "-".to_string(); + } + + if difficulty >= 100.0 { + return Self::format_integer_with_commas(difficulty.round() as u64); + } + + if difficulty >= 1.0 { + return format!("{difficulty:.2}"); + } + + format!("{difficulty:.4}") + } + + fn parse_bits(bits: &str) -> Option { + let value = bits.trim(); + if value.is_empty() { + return None; + } + + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + return u32::from_str_radix(hex, 16).ok(); + } + + if value + .chars() + .any(|c| c.is_ascii_hexdigit() && c.is_ascii_alphabetic()) + { + return u32::from_str_radix(value, 16).ok(); + } + + value.parse::().ok() + } + + fn format_integer_with_commas(value: u64) -> String { + let digits = value.to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(digit); + } + formatted.chars().rev().collect() + } + + fn format_timestamp(timestamp: u64) -> String { + let timestamp = if timestamp > 10_000_000_000 { + timestamp / 1_000 + } else { + timestamp + }; + let days = (timestamp / 86_400) as i64; + let seconds = timestamp % 86_400; + let hour = seconds / 3_600; + let minute = (seconds % 3_600) / 60; + let second = seconds % 60; + let (year, month, day) = Self::civil_from_days(days); + let suffix = if hour < 12 { "AM" } else { "PM" }; + let hour = match hour % 12 { + 0 => 12, + value => value, + }; + + format!("{month}/{day}/{year}, {hour}:{minute:02}:{second:02} {suffix}") + } + + fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_param = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_param + 2) / 5 + 1; + let month = month_param + if month_param < 10 { 3 } else { -9 }; + let year = year + if month <= 2 { 1 } else { 0 }; + + (year as i32, month as u32, day as u32) + } } impl Default for P2PoolStatusView { diff --git a/src/components/p2pool_websocket.rs b/src/components/p2pool_websocket.rs new file mode 100644 index 0000000..9481ab8 --- /dev/null +++ b/src/components/p2pool_websocket.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2024 PDM Authors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::config::load_api_config; +use anyhow::{Context, Result}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Deserializer}; +use tokio::sync::mpsc; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use url::Url; + +const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; + +#[derive(Debug, Clone)] +pub struct P2PoolWebSocketClient { + base_url: String, + fallback_base_url: Option, + auth_credentials: Option<(String, String)>, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ShareEventData { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + #[serde(deserialize_with = "deserialize_string_or_number")] + pub bits: String, + #[serde(default)] + pub uncles: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PeerEventData { + pub peer_id: String, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "topic", content = "data")] +pub enum WebSocketEvent { + #[serde(rename = "Share")] + Share(ShareEventData), + #[serde(rename = "Peer")] + Peer(PeerEventData), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LiveShare { + pub blockhash: String, + pub prev_blockhash: String, + pub height: u64, + pub miner_address: String, + pub timestamp: u64, + pub bits: String, + pub uncles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LivePeerEvent { + pub peer_id: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LiveP2PoolEvent { + Share(LiveShare), + Peer(LivePeerEvent), +} + +impl P2PoolWebSocketClient { + pub fn new() -> Self { + let (base_url, auth_credentials) = load_api_config() + .map(|cfg| { + let credentials = cfg.auth_user.zip(cfg.auth_pass); + (cfg.base_url, credentials) + }) + .unwrap_or_else(|_| (TESTNET4_FALLBACK_BASE_URL.to_string(), None)); + + let fallback_base_url = (base_url != TESTNET4_FALLBACK_BASE_URL) + .then(|| TESTNET4_FALLBACK_BASE_URL.to_string()); + + Self { + base_url, + fallback_base_url, + auth_credentials, + } + } + + pub fn with_base_url(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + fallback_base_url: None, + auth_credentials: None, + } + } + + pub fn with_auth(mut self, user: String, pass: String) -> Self { + self.auth_credentials = Some((user, pass)); + self + } + + pub fn with_fallback_base_url(mut self, fallback_base_url: impl Into) -> Self { + self.fallback_base_url = Some(fallback_base_url.into()); + self + } + + fn ws_url(&self, path: &str) -> Result { + self.ws_url_from_base_url(&self.base_url, path) + } + + fn ws_url_from_base_url(&self, base_url: &str, path: &str) -> Result { + let mut url = Url::parse(base_url) + .with_context(|| format!("Failed to parse base URL: {base_url}"))?; + + match url.scheme() { + "http" => url.set_scheme("ws").unwrap(), + "https" => url.set_scheme("wss").unwrap(), + _ => {} + } + + url.set_path(path); + Ok(url) + } + + fn ws_url_with_auth(&self, path: &str) -> Result { + let mut url = self.ws_url(path)?; + if let Some((user, pass)) = &self.auth_credentials { + let token = STANDARD.encode(format!("{}:{}", user, pass)); + url.query_pairs_mut().append_pair("token", &token); + } + Ok(url) + } + + pub async fn subscribe_live_events( + &self, + tx: mpsc::UnboundedSender>, + ) -> anyhow::Result<()> { + let url = self.ws_url_with_auth("/ws")?; + match self.subscribe_live_events_at(url, tx.clone()).await { + Ok(()) => Ok(()), + Err(primary_error) => { + if let Some(fallback_base_url) = &self.fallback_base_url { + let fallback_url = self.ws_url_from_base_url(fallback_base_url, "/ws")?; + if self + .subscribe_live_events_at(fallback_url, tx) + .await + .is_ok() + { + return Ok(()); + } + } + Err(primary_error) + } + } + } + + async fn subscribe_live_events_at( + &self, + url: Url, + tx: mpsc::UnboundedSender>, + ) -> anyhow::Result<()> { + let (stream, _) = connect_async(url.as_str()).await?; + let (mut write, mut read) = stream.split(); + + for topic in ["shares", "peers"] { + let subscribe_message = serde_json::json!({ + "action": "subscribe", + "topic": topic, + }) + .to_string(); + write.send(Message::Text(subscribe_message)).await?; + } + + while let Some(message_result) = read.next().await { + let message = message_result?; + if let Message::Text(text) = message { + match serde_json::from_str::(&text) { + Ok(WebSocketEvent::Share(data)) => { + let live_share = LiveShare { + blockhash: data.blockhash, + prev_blockhash: data.prev_blockhash, + height: data.height, + miner_address: data.miner_address, + timestamp: data.timestamp, + bits: data.bits, + uncles: data.uncles, + }; + let _ = tx.send(Ok(LiveP2PoolEvent::Share(live_share))); + } + Ok(WebSocketEvent::Peer(data)) => { + let live_peer = LivePeerEvent { + peer_id: data.peer_id, + status: data.status, + }; + let _ = tx.send(Ok(LiveP2PoolEvent::Peer(live_peer))); + } + Err(error) => { + let _ = tx.send(Err(anyhow::Error::new(error))); + } + } + } + } + + Ok(()) + } +} + +impl Default for P2PoolWebSocketClient { + fn default() -> Self { + Self::new() + } +} + +fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNumber { + String(String), + Number(u64), + } + + match StringOrNumber::deserialize(deserializer)? { + StringOrNumber::String(value) => Ok(value), + StringOrNumber::Number(value) => Ok(value.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ws_url_converts_http_to_ws_and_encodes_auth_token() { + let client = P2PoolWebSocketClient::with_base_url("http://127.0.0.1:46884") + .with_auth("user".into(), "password".into()); + + let url = client.ws_url_with_auth("/ws").unwrap(); + + assert_eq!( + url.as_str(), + "ws://127.0.0.1:46884/ws?token=dXNlcjpwYXNzd29yZA%3D%3D" + ); + } + + #[test] + fn ws_url_converts_https_fallback_to_wss() { + let client = P2PoolWebSocketClient::with_base_url("https://testnet4.p2poolv2.org"); + + let url = client.ws_url("/ws").unwrap(); + + assert_eq!(url.as_str(), "wss://testnet4.p2poolv2.org/ws"); + } + + #[test] + fn websocket_event_accepts_share_messages() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Share", + "data": { + "blockhash": "0000", + "prev_blockhash": "ffff", + "height": 42, + "miner_address": "miner", + "timestamp": 1700000000, + "bits": "1d00ffff", + "uncles": ["aaaa"] + } + })) + .unwrap(); + + assert!(matches!(event, WebSocketEvent::Share(_))); + } + + #[test] + fn websocket_event_accepts_numeric_bits() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Share", + "data": { + "blockhash": "0000", + "prev_blockhash": "ffff", + "height": 42, + "miner_address": "miner", + "timestamp": 1700000000, + "bits": 454130449, + "uncles": [] + } + })) + .unwrap(); + + let WebSocketEvent::Share(data) = event else { + panic!("expected share event"); + }; + assert_eq!(data.bits, "454130449"); + } + + #[test] + fn websocket_event_accepts_peer_messages() { + let event: WebSocketEvent = serde_json::from_value(serde_json::json!({ + "topic": "Peer", + "data": { + "peer_id": "12D3KooWPeerOne", + "status": "Connected" + } + })) + .unwrap(); + + assert!(matches!(event, WebSocketEvent::Peer(_))); + } +} diff --git a/src/main.rs b/src/main.rs index 59e6675..0714090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, backend::Backend, backend::CrosstermBackend}; -use std::io; +use std::{io, time::Duration}; #[tokio::main] async fn main() -> Result<()> { @@ -72,9 +72,15 @@ where { loop { app.poll_chain_info(); + app.poll_share_info(); app.poll_peer_info(); + app.poll_live_p2pool_events(); terminal.draw(|f| ui::ui(f, app))?; + if !event::poll(Duration::from_millis(250))? { + continue; + } + if let Event::Key(key) = event::read()? { if key.kind != KeyEventKind::Press { continue; diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 9614b70..3f096dd 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 219 expression: terminal.backend() --- TestBackend { @@ -7,7 +8,7 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │ Peers Info │", + "│Home ││ Chain Info │ Shares │ Peers Info │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", From aa0e7eee307b9092de06d6d0c76a80691dc1ff18 Mon Sep 17 00:00:00 2001 From: Raunak Kumar Date: Sun, 24 May 2026 01:08:08 +0000 Subject: [PATCH 6/6] move p2pool client urls to config and localize tests --- config/config.toml | 2 ++ src/components/p2pool_client.rs | 56 ++++++++++++++++++------------ src/components/p2pool_websocket.rs | 34 +++++++++--------- src/config.rs | 32 +++++++++++++++-- 4 files changed, 81 insertions(+), 43 deletions(-) diff --git a/config/config.toml b/config/config.toml index 8d74355..6ec999a 100644 --- a/config/config.toml +++ b/config/config.toml @@ -1,4 +1,6 @@ [api] +base_url = "http://127.0.0.1:46884" +# fallback_base_url = "http://127.0.0.1:46885" host = "127.0.0.1" port = 46884 auth_user = "p2pool" diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index af47e35..d063760 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use crate::components::p2pool_websocket::P2PoolWebSocketClient; -use crate::config::load_api_config; +use crate::config::{ApiConfig, load_api_config}; use reqwest::Client; use serde::Deserialize; use serde::Deserializer; @@ -11,7 +11,6 @@ use serde::de::DeserializeOwned; use std::time::Duration; const REQUEST_TIMEOUT_SECONDS: u64 = 10; -const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; #[derive(Debug, Clone)] pub struct P2PoolClient { @@ -74,21 +73,22 @@ fn build_client() -> Client { impl P2PoolClient { pub fn new() -> Self { - let (base_url, auth_credentials) = load_api_config() - .map(|cfg| { - let credentials = cfg.auth_user.zip(cfg.auth_pass); - (cfg.base_url, credentials) - }) - .unwrap_or_else(|_| (TESTNET4_FALLBACK_BASE_URL.to_string(), None)); + Self::from_config(load_api_config().unwrap_or_default()) + } - let fallback_base_url = (base_url != TESTNET4_FALLBACK_BASE_URL) - .then(|| TESTNET4_FALLBACK_BASE_URL.to_string()); + fn from_config(config: ApiConfig) -> Self { + let client = P2PoolClient::with_base_url(&config.base_url); - Self { - client: build_client(), - base_url, - fallback_base_url, - auth_credentials, + let client = if let Some(fallback) = &config.fallback_base_url { + client.with_fallback_base_url(fallback) + } else { + client + }; + + if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + client.with_auth(user, pass) + } else { + client } } @@ -231,23 +231,33 @@ mod tests { use mockito::{Matcher, Server}; use serde_json::json; + const PRIMARY_BASE_URL: &str = "http://127.0.0.1:46884"; + const FALLBACK_BASE_URL: &str = "http://127.0.0.1:46885"; + + fn api_config(fallback_base_url: Option<&str>) -> ApiConfig { + ApiConfig { + base_url: PRIMARY_BASE_URL.to_string(), + fallback_base_url: fallback_base_url.map(str::to_string), + auth_user: None, + auth_pass: None, + } + } + #[test] fn explicit_base_url_does_not_enable_network_fallback() { - let client = P2PoolClient::with_base_url("http://127.0.0.1:46884"); + let config = api_config(None); + let client = P2PoolClient::from_config(config); - assert_eq!(client.base_url, "http://127.0.0.1:46884"); + assert_eq!(client.base_url, PRIMARY_BASE_URL); assert_eq!(client.fallback_base_url, None); } #[test] fn fallback_base_url_can_be_configured() { - let client = P2PoolClient::with_base_url("http://127.0.0.1:46884") - .with_fallback_base_url("https://testnet4.p2poolv2.org"); + let config = api_config(Some(FALLBACK_BASE_URL)); + let client = P2PoolClient::from_config(config); - assert_eq!( - client.fallback_base_url.as_deref(), - Some("https://testnet4.p2poolv2.org") - ); + assert_eq!(client.fallback_base_url.as_deref(), Some(FALLBACK_BASE_URL)); } #[tokio::test] diff --git a/src/components/p2pool_websocket.rs b/src/components/p2pool_websocket.rs index 9481ab8..fa3f967 100644 --- a/src/components/p2pool_websocket.rs +++ b/src/components/p2pool_websocket.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::config::load_api_config; +use crate::config::{ApiConfig, load_api_config}; use anyhow::{Context, Result}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; @@ -13,8 +13,6 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use url::Url; -const TESTNET4_FALLBACK_BASE_URL: &str = "https://testnet4.p2poolv2.org"; - #[derive(Debug, Clone)] pub struct P2PoolWebSocketClient { base_url: String, @@ -75,20 +73,22 @@ pub enum LiveP2PoolEvent { impl P2PoolWebSocketClient { pub fn new() -> Self { - let (base_url, auth_credentials) = load_api_config() - .map(|cfg| { - let credentials = cfg.auth_user.zip(cfg.auth_pass); - (cfg.base_url, credentials) - }) - .unwrap_or_else(|_| (TESTNET4_FALLBACK_BASE_URL.to_string(), None)); + Self::from_config(load_api_config().unwrap_or_default()) + } - let fallback_base_url = (base_url != TESTNET4_FALLBACK_BASE_URL) - .then(|| TESTNET4_FALLBACK_BASE_URL.to_string()); + fn from_config(config: ApiConfig) -> Self { + let client = P2PoolWebSocketClient::with_base_url(&config.base_url); - Self { - base_url, - fallback_base_url, - auth_credentials, + let client = if let Some(fallback) = &config.fallback_base_url { + client.with_fallback_base_url(fallback) + } else { + client + }; + + if let Some((user, pass)) = config.auth_user.zip(config.auth_pass) { + client.with_auth(user, pass) + } else { + client } } @@ -253,11 +253,11 @@ mod tests { #[test] fn ws_url_converts_https_fallback_to_wss() { - let client = P2PoolWebSocketClient::with_base_url("https://testnet4.p2poolv2.org"); + let client = P2PoolWebSocketClient::with_base_url("https://127.0.0.1:46884"); let url = client.ws_url("/ws").unwrap(); - assert_eq!(url.as_str(), "wss://testnet4.p2poolv2.org/ws"); + assert_eq!(url.as_str(), "wss://127.0.0.1:46884/ws"); } #[test] diff --git a/src/config.rs b/src/config.rs index fd56aa1..a70f086 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,12 +5,28 @@ use anyhow::Result; use config::{Config, File}; +const DEFAULT_API_HOST: &str = "127.0.0.1"; +const DEFAULT_API_PORT: u16 = 9332; + +#[derive(Debug, Clone)] pub struct ApiConfig { pub base_url: String, + pub fallback_base_url: Option, pub auth_user: Option, pub auth_pass: Option, } +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: format!("http://{}:{}", DEFAULT_API_HOST, DEFAULT_API_PORT), + fallback_base_url: None, + auth_user: None, + auth_pass: None, + } + } +} + pub fn load_api_config() -> Result { let settings = Config::builder() .add_source(File::with_name("config/config").required(false)) @@ -19,13 +35,23 @@ pub fn load_api_config() -> Result { ) .build()?; - let host: String = settings.get("api.host").unwrap_or("127.0.0.1".into()); - let port: u16 = settings.get("api.port").unwrap_or(9332); + let host: String = settings + .get("api.host") + .unwrap_or_else(|_| DEFAULT_API_HOST.to_string()); + let port: u16 = settings.get("api.port").unwrap_or(DEFAULT_API_PORT); + let base_url: String = settings + .get("api.base_url") + .unwrap_or_else(|_| format!("http://{}:{}", host, port)); + let fallback_base_url: Option = settings + .get("api.fallback_base_url") + .ok() + .filter(|url: &String| !url.trim().is_empty()); let auth_user: Option = settings.get("api.auth_user").ok(); let auth_pass: Option = settings.get("api.auth_pass").ok(); Ok(ApiConfig { - base_url: format!("http://{}:{}", host, port), + base_url, + fallback_base_url, auth_user, auth_pass, })