use super::RpcHandler; use anyhow::{Context, Result}; use base64::Engine; use serde::{Deserialize, Serialize}; use tracing::info; #[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, } #[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, }), Err(_) => LndBalanceResponse { total_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)?) } /// Helper: create an authenticated LND REST client pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> { 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(15)) .danger_accept_invalid_certs(true) .build() .context("Failed to create HTTP client")?; Ok((client, macaroon_hex)) } pub(super) async fn handle_lnd_listchannels(&self) -> Result { let (client, macaroon_hex) = self.lnd_client().await?; let channels_resp: LndListChannelsResponse = client .get("https://127.0.0.1:8080/v1/channels") .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await .context("LND REST connection failed")? .json() .await .context("Failed to parse LND channels response")?; let pending_resp: LndPendingChannelsResponse = match client .get("https://127.0.0.1:8080/v1/channels/pending") .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await { Ok(resp) => resp.json().await.unwrap_or_default(), Err(_) => LndPendingChannelsResponse::default(), }; let channels: Vec = channels_resp .channels .unwrap_or_default() .into_iter() .map(|ch| { let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); ChannelInfo { chan_id: ch.chan_id.unwrap_or_default(), remote_pubkey: ch.remote_pubkey.unwrap_or_default(), capacity, local_balance: local, remote_balance: remote, active: ch.active.unwrap_or(false), status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() }, channel_point: ch.channel_point.unwrap_or_default(), } }) .collect(); let mut pending_channels: Vec = Vec::new(); for pch in pending_resp.pending_open_channels.unwrap_or_default() { if let Some(ch) = pch.channel { let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0); pending_channels.push(ChannelInfo { chan_id: String::new(), remote_pubkey: ch.remote_node_pub.unwrap_or_default(), capacity, local_balance: local, remote_balance: remote, active: false, status: "pending_open".into(), channel_point: ch.channel_point.unwrap_or_default(), }); } } let total_local: i64 = channels.iter().map(|c| c.local_balance).sum(); let total_remote: i64 = channels.iter().map(|c| c.remote_balance).sum(); let mut all_channels = channels; all_channels.extend(pending_channels); let result = ChannelListResult { channels: all_channels, total_inbound: total_remote, total_outbound: total_local, }; Ok(serde_json::to_value(result)?) } pub(super) async fn handle_lnd_openchannel(&self, params: Option) -> Result { let params = params.unwrap_or_default(); let pubkey = params.get("pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?; let amount = params.get("amount") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?; // Validate pubkey: must be 66-char hex (compressed secp256k1) if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string")); } if amount < 20000 { return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats")); } if amount > 16_777_215 { return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)")); } info!(peer = pubkey, amount = amount, "Opening Lightning channel"); let (client, macaroon_hex) = self.lnd_client().await?; // First connect to the peer if an address is provided if let Some(addr) = params.get("address").and_then(|v| v.as_str()) { // Validate peer address format (host:port) if addr.len() > 256 || addr.contains('\0') || addr.contains(' ') { return Err(anyhow::anyhow!("Invalid peer address format")); } let connect_body = serde_json::json!({ "addr": { "pubkey": pubkey, "host": addr }, "perm": true }); let _ = client .post("https://127.0.0.1:8080/v1/peers") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&connect_body) .send() .await; } let open_body = serde_json::json!({ "node_pubkey_string": pubkey, "local_funding_amount": amount.to_string(), }); let resp = client .post("https://127.0.0.1:8080/v1/channels") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&open_body) .send() .await .context("Failed to open channel")?; let status = resp.status(); let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to open channel: {}", msg)); } Ok(body) } pub(super) async fn handle_lnd_closechannel(&self, params: Option) -> Result { let params = params.unwrap_or_default(); let channel_point = params.get("channel_point") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?; let parts: Vec<&str> = channel_point.split(':').collect(); if parts.len() != 2 { return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'")); } // Validate txid is 64-char hex and output_index is numeric if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) { return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex")); } if parts[1].parse::().is_err() { return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number")); } let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false); info!(channel_point = channel_point, force = force, "Closing Lightning channel"); let (client, macaroon_hex) = self.lnd_client().await?; let url = format!( "https://127.0.0.1:8080/v1/channels/{}/{}?force={}", parts[0], parts[1], force ); let resp = client .delete(&url) .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await .context("Failed to close channel")?; let status = resp.status(); let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to close channel: {}", msg)); } Ok(serde_json::json!({ "success": true })) } /// Generate a new on-chain Bitcoin address. pub(super) async fn handle_lnd_newaddress(&self) -> Result { let (client, macaroon_hex) = self.lnd_client().await?; let resp = client .get("https://127.0.0.1:8080/v1/newaddress") .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await .context("LND REST connection failed")?; let body: serde_json::Value = resp.json().await .context("Failed to parse newaddress response")?; let address = body.get("address") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok(serde_json::json!({ "address": address })) } /// Send on-chain Bitcoin to an address. pub(super) async fn handle_lnd_sendcoins(&self, params: Option) -> Result { let params = params.unwrap_or_default(); let addr = params.get("addr") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?; let amount = params.get("amount") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?; if amount < 546 { return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)")); } if amount > 21_000_000 * 100_000_000 { return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply")); } // Validate Bitcoin address format (basic: length and allowed chars) if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) { return Err(anyhow::anyhow!("Invalid Bitcoin address format")); } info!(addr = addr, amount = amount, "Sending on-chain Bitcoin"); let (client, macaroon_hex) = self.lnd_client().await?; let send_body = serde_json::json!({ "addr": addr, "amount": amount.to_string(), }); let resp = client .post("https://127.0.0.1:8080/v1/transactions") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&send_body) .send() .await .context("Failed to send on-chain transaction")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse send response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to send: {}", msg)); } let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string(); Ok(serde_json::json!({ "txid": txid })) } /// Create a Lightning invoice. pub(super) async fn handle_lnd_createinvoice(&self, params: Option) -> Result { let params = params.unwrap_or_default(); let amount_sats = params.get("amount_sats") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?; let memo = params.get("memo") .and_then(|v| v.as_str()) .unwrap_or(""); if amount_sats < 0 { return Err(anyhow::anyhow!("Amount must be non-negative")); } if amount_sats > 21_000_000 * 100_000_000 { return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply")); } // Limit memo length to prevent abuse if memo.len() > 639 { return Err(anyhow::anyhow!("Memo too long (max 639 bytes)")); } info!(amount_sats = amount_sats, "Creating Lightning invoice"); let (client, macaroon_hex) = self.lnd_client().await?; let invoice_body = serde_json::json!({ "value": amount_sats.to_string(), "memo": memo, }); let resp = client .post("https://127.0.0.1:8080/v1/invoices") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&invoice_body) .send() .await .context("Failed to create invoice")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse invoice response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to create invoice: {}", msg)); } let payment_request = body.get("payment_request") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok(serde_json::json!({ "payment_request": payment_request, "amount_sats": amount_sats, })) } /// Pay a Lightning invoice. pub(super) async fn handle_lnd_payinvoice(&self, params: Option) -> Result { let params = params.unwrap_or_default(); let payment_request = params.get("payment_request") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?; // Basic validation: Lightning invoices start with lnbc/lntb/lnbcrt if payment_request.len() < 10 || payment_request.len() > 2048 { return Err(anyhow::anyhow!("Invalid payment request length")); } let lower = payment_request.to_lowercase(); if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") { return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)")); } info!("Paying Lightning invoice"); let (client, macaroon_hex) = self.lnd_client().await?; let pay_body = serde_json::json!({ "payment_request": payment_request, }); let resp = client .post("https://127.0.0.1:8080/v1/channels/transactions") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&pay_body) .send() .await .context("Failed to pay invoice")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse payment response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Payment failed: {}", msg)); } let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or(""); if !payment_error.is_empty() { return Err(anyhow::anyhow!("Payment failed: {}", payment_error)); } let amount_sat = body.get("payment_route") .and_then(|r| r.get("total_amt")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) .unwrap_or(0); let payment_hash = body.get("payment_hash") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok(serde_json::json!({ "payment_hash": payment_hash, "amount_sats": amount_sat, })) } /// Create an unsigned PSBT for hardware wallet signing. /// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template. pub(super) async fn handle_lnd_create_psbt(&self, params: Option) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let outputs = params.get("outputs") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?; if outputs.is_empty() { return Err(anyhow::anyhow!("outputs must not be empty")); } // Build the outputs map for LND: { "address": "amount_sats_as_string" } let mut lnd_outputs: serde_json::Map = serde_json::Map::new(); let mut total_amount: i64 = 0; for output in outputs { let addr = output.get("address") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?; // Validate Bitcoin address format if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) { return Err(anyhow::anyhow!("Invalid Bitcoin address format in output")); } let amount = output.get("amount_sats") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?; if amount < 546 { return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)")); } lnd_outputs.insert(addr.to_string(), serde_json::json!(amount)); total_amount += amount; } let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte") .and_then(|v| v.as_u64()) .unwrap_or(10); info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing"); let (client, macaroon_hex) = self.lnd_client().await?; let fund_body = serde_json::json!({ "raw": { "outputs": lnd_outputs, }, "sat_per_vbyte": sat_per_vbyte, "spend_unconfirmed": false, }); let resp = client .post("https://127.0.0.1:8080/v2/wallet/psbt/fund") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&fund_body) .send() .await .context("Failed to create PSBT via LND")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse PSBT response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg)); } let funded_psbt = body.get("funded_psbt") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let change_output_index = body.get("change_output_index") .and_then(|v| v.as_i64()) .unwrap_or(-1); Ok(serde_json::json!({ "psbt_base64": funded_psbt, "change_output_index": change_output_index, "total_amount_sats": total_amount, "fee_rate_sat_per_vbyte": sat_per_vbyte, })) } /// Finalize a signed PSBT and broadcast the transaction. /// Takes a PSBT that has been signed by a hardware wallet. pub(super) async fn handle_lnd_finalize_psbt(&self, params: Option) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let signed_psbt = params.get("signed_psbt_base64") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?; info!("Finalizing signed PSBT from hardware wallet"); let (client, macaroon_hex) = self.lnd_client().await?; let finalize_body = serde_json::json!({ "funded_psbt": signed_psbt, }); let resp = client .post("https://127.0.0.1:8080/v2/wallet/psbt/finalize") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&finalize_body) .send() .await .context("Failed to finalize PSBT via LND")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse finalize response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg)); } let raw_final_tx = body.get("raw_final_tx") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); // Broadcast the finalized transaction let publish_body = serde_json::json!({ "tx_hex": raw_final_tx, }); let pub_resp = client .post("https://127.0.0.1:8080/v2/wallet/tx") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&publish_body) .send() .await .context("Failed to broadcast transaction")?; let pub_status = pub_resp.status(); let pub_body: serde_json::Value = pub_resp.json().await .context("Failed to parse broadcast response")?; if !pub_status.is_success() { let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg)); } Ok(serde_json::json!({ "raw_final_tx": raw_final_tx, "broadcast": true, })) } /// Create a signed raw transaction WITHOUT broadcasting. /// Used for mesh relay: create the TX locally, then relay the hex to an /// internet-connected peer who broadcasts it. pub(super) async fn handle_lnd_create_raw_tx(&self, params: Option) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let addr = params.get("addr") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?; let amount_sats = params.get("amount_sats") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?; if amount_sats < 546 { anyhow::bail!("Amount must be at least 546 sats (dust limit)"); } if amount_sats > 2_100_000_000_000_000 { anyhow::bail!("Amount exceeds 21M BTC"); } let (client, macaroon_hex) = self.lnd_client().await?; // Step 1: Fund a PSBT with the desired output let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5); let fund_body = serde_json::json!({ "raw": { "outputs": { addr: amount_sats } }, "sat_per_vbyte": fee_rate, "spend_unconfirmed": false, }); let resp = client .post("https://127.0.0.1:8080/v2/wallet/psbt/fund") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&fund_body) .send() .await .context("Failed to fund PSBT via LND")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse fund response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to create TX: {}", msg)); } let funded_psbt = body.get("funded_psbt") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?; // Step 2: Finalize (LND auto-signs with hot wallet keys) let finalize_body = serde_json::json!({ "funded_psbt": funded_psbt, }); let resp = client .post("https://127.0.0.1:8080/v2/wallet/psbt/finalize") .header("Grpc-Metadata-macaroon", &macaroon_hex) .json(&finalize_body) .send() .await .context("Failed to finalize PSBT")?; let status = resp.status(); let body: serde_json::Value = resp.json().await .context("Failed to parse finalize response")?; if !status.is_success() { let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to sign TX: {}", msg)); } // raw_final_tx from LND is base64-encoded — decode to hex for Bitcoin RPC let raw_final_tx_b64 = body.get("raw_final_tx") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?; use base64::Engine; let tx_bytes = base64::engine::general_purpose::STANDARD .decode(raw_final_tx_b64) .context("Failed to decode raw_final_tx base64")?; let raw_tx_hex = hex::encode(&tx_bytes); info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)"); Ok(serde_json::json!({ "raw_tx_hex": raw_tx_hex, "amount_sats": amount_sats, "addr": addr, "broadcast": false, })) } /// List on-chain transactions from LND. /// Returns all transactions, with incoming (amount > 0) flagged. pub(super) async fn handle_lnd_gettransactions(&self) -> Result { let (client, macaroon_hex) = self.lnd_client().await?; let resp = client .get("https://127.0.0.1:8080/v1/transactions") .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await .context("LND REST connection failed")?; let status = resp.status(); let body: serde_json::Value = resp .json() .await .context("Failed to parse transactions response")?; if !status.is_success() { let msg = body .get("message") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Failed to list transactions: {}", msg)); } let empty_vec = vec![]; let raw_txs = body .get("transactions") .and_then(|v| v.as_array()) .unwrap_or(&empty_vec); let mut transactions: Vec = Vec::new(); for tx in raw_txs { let amount: i64 = tx .get("amount") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .or_else(|| tx.get("amount").and_then(|v| v.as_i64())) .unwrap_or(0); let num_confirmations: i64 = tx .get("num_confirmations") .and_then(|v| v.as_i64()) .unwrap_or(0); let tx_hash = tx .get("tx_hash") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let time_stamp: i64 = tx .get("time_stamp") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .or_else(|| tx.get("time_stamp").and_then(|v| v.as_i64())) .unwrap_or(0); let total_fees: i64 = tx .get("total_fees") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) .or_else(|| tx.get("total_fees").and_then(|v| v.as_i64())) .unwrap_or(0); let dest_addresses: Vec = tx .get("dest_addresses") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|a| a.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); let label = tx .get("label") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let block_height: i64 = tx .get("block_height") .and_then(|v| v.as_i64()) .unwrap_or(0); let direction = if amount > 0 { "incoming" } else { "outgoing" }; transactions.push(serde_json::json!({ "tx_hash": tx_hash, "amount_sats": amount.abs(), "direction": direction, "num_confirmations": num_confirmations, "time_stamp": time_stamp, "total_fees": total_fees, "dest_addresses": dest_addresses, "label": label, "block_height": block_height, })); } // Sort by timestamp descending (most recent first) transactions.sort_by(|a, b| { let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); let tb = b.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); tb.cmp(&ta) }); let incoming_pending: usize = transactions .iter() .filter(|t| { t.get("direction").and_then(|v| v.as_str()) == Some("incoming") && t.get("num_confirmations").and_then(|v| v.as_i64()) == Some(0) }) .count(); Ok(serde_json::json!({ "transactions": transactions, "incoming_pending_count": incoming_pending, })) } /// Return LND connection info: base64url-encoded TLS cert and admin macaroon /// for building lndconnect:// URIs in the frontend. pub(crate) async fn handle_lnd_connect_info(&self) -> Result { let cert_path = "/var/lib/archipelago/lnd/tls.cert"; let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; // Read and encode TLS cert (PEM → DER → base64url) let cert_pem = tokio::fs::read_to_string(cert_path) .await .context("Failed to read LND TLS certificate")?; let cert_der_b64: String = cert_pem .lines() .filter(|l| !l.starts_with("-----")) .collect(); let cert_der = base64::engine::general_purpose::STANDARD .decode(&cert_der_b64) .context("Failed to decode PEM base64")?; let cert_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cert_der); // Read and encode macaroon (binary → base64url) let macaroon_bytes = tokio::fs::read(macaroon_path) .await .context("Failed to read LND admin macaroon")?; let macaroon_b64url = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&macaroon_bytes); // Read Tor onion address — check system Tor path first, then legacy let tor_onion = { let mut onion = None; for path in &[ "/var/lib/archipelago/tor-hostnames/lnd", "/var/lib/tor/hidden_service_lnd/hostname", "/var/lib/archipelago/tor/hidden_service_lnd/hostname", ] { if let Ok(addr) = tokio::fs::read_to_string(path).await { let addr = addr.trim().to_string(); if addr.ends_with(".onion") { onion = Some(addr); break; } } // Try sudo for system Tor dirs (owned by debian-tor, 0700) if let Ok(output) = tokio::process::Command::new("sudo") .args(["cat", path]) .output() .await { if output.status.success() { let addr = String::from_utf8_lossy(&output.stdout).trim().to_string(); if addr.ends_with(".onion") { onion = Some(addr); break; } } } } onion }; Ok(serde_json::json!({ "cert_base64url": cert_b64url, "macaroon_base64url": macaroon_b64url, "tor_onion": tor_onion, "rest_port": 8080, "grpc_port": 10009, })) } /// lnd.export-channel-backup — Export all channel static backups (SCB). /// Returns base64-encoded multi-channel backup that can restore channels on a new node. pub(super) async fn handle_lnd_export_channel_backup(&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")?; let macaroon_hex = hex::encode(&macaroon_bytes); let cert_path = "/var/lib/archipelago/lnd/tls.cert"; let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .timeout(std::time::Duration::from_secs(10)) .build() .context("Failed to build HTTP client")?; let resp = client .get("https://127.0.0.1:8080/v1/channels/backup") .header("Grpc-Metadata-macaroon", &macaroon_hex) .send() .await .context("Failed to reach LND REST API")?; if !resp.status().is_success() { anyhow::bail!("LND returned {}", resp.status()); } let data: serde_json::Value = resp.json().await.context("Invalid JSON from LND")?; // Extract the multi_chan_backup bytes let backup_b64 = data .get("multi_chan_backup") .and_then(|m| m.get("multi_chan_backup")) .and_then(|b| b.as_str()) .unwrap_or(""); Ok(serde_json::json!({ "backup": backup_b64, "channel_count": data.get("multi_chan_backup") .and_then(|m| m.get("chan_points")) .and_then(|c| c.as_array()) .map(|a| a.len()) .unwrap_or(0), "timestamp": chrono::Utc::now().to_rfc3339(), })) } } // Channel types #[derive(Debug, Serialize)] struct ChannelInfo { chan_id: String, remote_pubkey: String, capacity: i64, local_balance: i64, remote_balance: i64, active: bool, status: String, channel_point: String, } #[derive(Debug, Serialize)] struct ChannelListResult { channels: Vec, total_inbound: i64, total_outbound: i64, } #[derive(Debug, Deserialize)] struct LndListChannelsResponse { channels: Option>, } #[derive(Debug, Deserialize)] struct LndChannel { chan_id: Option, remote_pubkey: Option, capacity: Option, local_balance: Option, remote_balance: Option, active: Option, channel_point: Option, } #[derive(Debug, Deserialize, Default)] struct LndPendingChannelsResponse { pending_open_channels: Option>, } #[derive(Debug, Deserialize)] struct LndPendingOpenChannel { channel: Option, } #[derive(Debug, Deserialize)] struct LndPendingChannel { remote_node_pub: Option, capacity: Option, local_balance: Option, remote_balance: Option, channel_point: Option, }