diff --git a/.claude/plans/reflective-meandering-castle.md b/.claude/plans/reflective-meandering-castle.md index c1305e94..450aff45 100644 --- a/.claude/plans/reflective-meandering-castle.md +++ b/.claude/plans/reflective-meandering-castle.md @@ -45,7 +45,7 @@ Add `tail-logs` action handler: --- -## 3. Bitcoin Deep Data (backend + frontend) +## 3. Bitcoin Deep Data (backend + frontend) [DONE] ### `core/archipelago/src/api/rpc/mod.rs` Add routing: `"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await` diff --git a/core/archipelago/src/api/rpc/bitcoin.rs b/core/archipelago/src/api/rpc/bitcoin.rs new file mode 100644 index 00000000..9eac796c --- /dev/null +++ b/core/archipelago/src/api/rpc/bitcoin.rs @@ -0,0 +1,108 @@ +use super::RpcHandler; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +struct BitcoinInfo { + block_height: u64, + sync_progress: f64, + chain: String, + difficulty: f64, + mempool_size: u64, + mempool_tx_count: u64, + verification_progress: f64, +} + +#[derive(Debug, Deserialize)] +struct BitcoinRpcResponse { + result: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct BlockchainInfo { + chain: Option, + blocks: Option, + difficulty: Option, + #[serde(rename = "verificationprogress")] + verification_progress: Option, +} + +#[derive(Debug, Deserialize)] +struct MempoolInfo { + size: Option, + bytes: Option, +} + +impl RpcHandler { + pub(super) async fn handle_bitcoin_getinfo(&self) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .context("Failed to create HTTP client")?; + + let blockchain_info = self + .bitcoin_rpc_call::(&client, "getblockchaininfo", &[]) + .await + .context("Failed to query getblockchaininfo")?; + + let mempool_info = self + .bitcoin_rpc_call::(&client, "getmempoolinfo", &[]) + .await + .unwrap_or(MempoolInfo { + size: Some(0), + bytes: Some(0), + }); + + let info = BitcoinInfo { + block_height: blockchain_info.blocks.unwrap_or(0), + sync_progress: blockchain_info + .verification_progress + .unwrap_or(0.0), + chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()), + difficulty: blockchain_info.difficulty.unwrap_or(0.0), + mempool_size: mempool_info.bytes.unwrap_or(0), + mempool_tx_count: mempool_info.size.unwrap_or(0), + verification_progress: blockchain_info + .verification_progress + .unwrap_or(0.0), + }; + + Ok(serde_json::to_value(info)?) + } + + async fn bitcoin_rpc_call( + &self, + client: &reqwest::Client, + method: &str, + params: &[serde_json::Value], + ) -> Result { + let body = serde_json::json!({ + "jsonrpc": "1.0", + "id": "archy", + "method": method, + "params": params, + }); + + let resp = client + .post("http://127.0.0.1:8332/") + .basic_auth("archipelago", Some("archipelago123")) + .json(&body) + .send() + .await + .context("Bitcoin RPC connection failed")?; + + let rpc_resp: BitcoinRpcResponse = resp + .json() + .await + .context("Failed to parse Bitcoin RPC response")?; + + if let Some(err) = rpc_resp.error { + anyhow::bail!("Bitcoin RPC error: {}", err); + } + + rpc_resp + .result + .ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result")) + } +} diff --git a/core/archipelago/src/api/rpc/lnd.rs b/core/archipelago/src/api/rpc/lnd.rs new file mode 100644 index 00000000..031b8af3 --- /dev/null +++ b/core/archipelago/src/api/rpc/lnd.rs @@ -0,0 +1,124 @@ +use super::RpcHandler; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +struct LndInfo { + alias: String, + num_active_channels: u32, + num_peers: u32, + synced_to_chain: bool, + block_height: u64, + balance_sats: i64, + channel_balance_sats: i64, + pending_open_balance: i64, +} + +#[derive(Debug, Deserialize)] +struct LndGetInfoResponse { + alias: Option, + num_active_channels: Option, + num_peers: Option, + synced_to_chain: Option, + block_height: Option, +} + +#[derive(Debug, Deserialize)] +struct LndChannelBalanceResponse { + local_balance: Option, + pending_open_local_balance: Option, +} + +#[derive(Debug, Deserialize)] +struct LndBalanceResponse { + total_balance: Option, + #[allow(dead_code)] + confirmed_balance: Option, +} + +#[derive(Debug, Deserialize)] +struct LndAmount { + sat: Option, +} + +impl RpcHandler { + pub(super) async fn handle_lnd_getinfo(&self) -> Result { + let macaroon_path = + "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; + + let macaroon_bytes = tokio::fs::read(macaroon_path) + .await + .context("Failed to read LND admin macaroon — is LND installed?")?; + let macaroon_hex = hex::encode(&macaroon_bytes); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .danger_accept_invalid_certs(true) + .build() + .context("Failed to create HTTP client")?; + + let get_info: LndGetInfoResponse = client + .get("https://127.0.0.1:8080/v1/getinfo") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("LND REST connection failed")? + .json() + .await + .context("Failed to parse LND getinfo response")?; + + let channel_balance: LndChannelBalanceResponse = match client + .get("https://127.0.0.1:8080/v1/balance/channels") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + { + Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse { + local_balance: None, + pending_open_local_balance: None, + }), + Err(_) => LndChannelBalanceResponse { + local_balance: None, + pending_open_local_balance: None, + }, + }; + + let wallet_balance: LndBalanceResponse = match client + .get("https://127.0.0.1:8080/v1/balance/blockchain") + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + { + Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse { + total_balance: None, + confirmed_balance: None, + }), + Err(_) => LndBalanceResponse { + total_balance: None, + confirmed_balance: None, + }, + }; + + let info = LndInfo { + alias: get_info.alias.unwrap_or_default(), + num_active_channels: get_info.num_active_channels.unwrap_or(0), + num_peers: get_info.num_peers.unwrap_or(0), + synced_to_chain: get_info.synced_to_chain.unwrap_or(false), + block_height: get_info.block_height.unwrap_or(0), + balance_sats: wallet_balance + .total_balance + .and_then(|s| s.parse().ok()) + .unwrap_or(0), + channel_balance_sats: channel_balance + .local_balance + .and_then(|a| a.sat.and_then(|s| s.parse().ok())) + .unwrap_or(0), + pending_open_balance: channel_balance + .pending_open_local_balance + .and_then(|a| a.sat.and_then(|s| s.parse().ok())) + .unwrap_or(0), + }; + + Ok(serde_json::to_value(info)?) + } +} diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index ea3367c1..1170b4ae 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -1,5 +1,7 @@ mod auth; +mod bitcoin; mod container; +mod lnd; mod node; mod package; mod peers; @@ -128,6 +130,10 @@ impl RpcHandler { "node.nostr-pubkey" => self.handle_node_nostr_pubkey().await, "node-nostr-verify-revoked" => self.handle_node_nostr_verify_revoked().await, + // Bitcoin & Lightning deep data + "bitcoin.getinfo" => self.handle_bitcoin_getinfo().await, + "lnd.getinfo" => self.handle_lnd_getinfo().await, + _ => { Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method)) }