187 lines
6.0 KiB
Rust
187 lines
6.0 KiB
Rust
|
|
//! 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<String>,
|
||
|
|
pub blockchain_info: Option<serde_json::Value>,
|
||
|
|
pub network_info: Option<serde_json::Value>,
|
||
|
|
pub index_info: Option<serde_json::Value>,
|
||
|
|
pub zmq_notifications: Option<serde_json::Value>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<RwLock<BitcoinNodeStatus>> = OnceLock::new();
|
||
|
|
|
||
|
|
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
|
||
|
|
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<BitcoinNodeStatus> {
|
||
|
|
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<serde_json::Value> {
|
||
|
|
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")
|
||
|
|
}
|