//! Cached Bitcoin node status for browser UIs. //! //! The bitcoin-ui should not poll Bitcoin RPC directly for display state. //! During container restarts, reindexing, and IBD, direct browser RPC polling //! turns short RPC gaps into visible UI failures. This module owns the RPC //! polling loop, caches the last successful snapshot, and serves stale-but-known //! state while the node is reconnecting. use anyhow::{Context, Result}; use serde::Serialize; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::RwLock; use tracing::{debug, warn}; const CACHE_REFRESH_SECS: u64 = 5; #[derive(Debug, Clone, Serialize)] pub struct BitcoinNodeStatus { pub ok: bool, pub stale: bool, pub updated_at_ms: u64, pub error: Option, pub blockchain_info: Option, pub network_info: Option, pub index_info: Option, pub zmq_notifications: Option, } impl Default for BitcoinNodeStatus { fn default() -> Self { Self { ok: false, stale: false, updated_at_ms: 0, error: Some("Connecting to Bitcoin node...".to_string()), blockchain_info: None, network_info: None, index_info: None, zmq_notifications: None, } } } static STATUS_CACHE: OnceLock> = OnceLock::new(); fn cache() -> &'static RwLock { STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default())) } fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } fn transient_error(err_msg: &str) -> bool { let lower = err_msg.to_lowercase(); lower.contains("connect") || lower.contains("reset") || lower.contains("refused") || lower.contains("timed out") || lower.contains("timeout") || lower.contains("broken pipe") || lower.contains("eof") || lower.contains("500 internal server error") } pub fn spawn_status_cache() { tokio::spawn(async { loop { let fresh = fetch_bitcoin_status().await; let mut cached = cache().write().await; match fresh { Ok(mut status) => { status.ok = true; status.stale = false; status.error = None; *cached = status; } Err(e) => { let err_msg = e.to_string(); if transient_error(&err_msg) { debug!("Bitcoin status: transient RPC failure: {}", err_msg); } else { warn!("Bitcoin status: RPC failure: {}", err_msg); } if cached.blockchain_info.is_some() { cached.ok = false; cached.stale = true; cached.error = Some(format!( "Bitcoin node is reconnecting; showing last known state: {}", err_msg )); } else { *cached = BitcoinNodeStatus { ok: false, stale: false, updated_at_ms: now_ms(), error: Some(format!("Connecting to Bitcoin node: {}", err_msg)), ..BitcoinNodeStatus::default() }; } } } drop(cached); tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await; } }); } pub async fn get_bitcoin_status() -> BitcoinNodeStatus { cache().read().await.clone() } async fn fetch_bitcoin_status() -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(8)) .build() .context("build Bitcoin status HTTP client")?; let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])) .await .context("getblockchaininfo")?; let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])) .await .context("getnetworkinfo") .ok(); let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])) .await .context("getindexinfo") .ok(); let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])) .await .context("getzmqnotifications") .ok(); Ok(BitcoinNodeStatus { ok: true, stale: false, updated_at_ms: now_ms(), error: None, blockchain_info: Some(blockchain_info), network_info, index_info, zmq_notifications, }) } async fn bitcoin_rpc_call( client: &reqwest::Client, method: &str, params: serde_json::Value, ) -> Result { let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; let body = serde_json::json!({ "jsonrpc": "1.0", "id": "bitcoin-status", "method": method, "params": params, }); let resp = client .post(crate::constants::BITCOIN_RPC_URL) .basic_auth(rpc_user, Some(rpc_pass)) .header("Content-Type", "application/json") .json(&body) .send() .await .context("Bitcoin RPC request failed")?; let status = resp.status(); let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?; if !status.is_success() { anyhow::bail!("Bitcoin RPC returned {}: {}", status, json); } if let Some(error) = json.get("error").filter(|e| !e.is_null()) { anyhow::bail!("Bitcoin RPC {} error: {}", method, error); } json.get("result") .cloned() .context("missing Bitcoin RPC result") }