- R13+R16: Replace .expect() with .context()? in main.rs and identity.rs - R17+R18+R19: Fix unwrap() calls in helpers and js-engine - R20+R21: Remove #[allow(dead_code)] annotations and delete truly dead code - R22-R26: Create constants.rs module, replace 21 hardcoded values across 12 files - R28+R29: LND/DWN timeouts already present — verified - R30-R33: Remove TODO comments, implement marketplace payment check Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1065 lines
39 KiB
Rust
1065 lines
39 KiB
Rust
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<String>,
|
|
num_active_channels: Option<u32>,
|
|
num_peers: Option<u32>,
|
|
synced_to_chain: Option<bool>,
|
|
block_height: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndChannelBalanceResponse {
|
|
local_balance: Option<LndAmount>,
|
|
pending_open_local_balance: Option<LndAmount>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndBalanceResponse {
|
|
total_balance: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndAmount {
|
|
sat: Option<String>,
|
|
}
|
|
|
|
impl RpcHandler {
|
|
pub(super) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<ChannelInfo> = 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<ChannelInfo> = 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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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::<u32>().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<serde_json::Value> {
|
|
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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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::<i64>().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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<String, serde_json::Value> = 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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<serde_json::Value>) -> Result<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<serde_json::Value> = 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<String> = 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<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<ChannelInfo>,
|
|
total_inbound: i64,
|
|
total_outbound: i64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndListChannelsResponse {
|
|
channels: Option<Vec<LndChannel>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndChannel {
|
|
chan_id: Option<String>,
|
|
remote_pubkey: Option<String>,
|
|
capacity: Option<String>,
|
|
local_balance: Option<String>,
|
|
remote_balance: Option<String>,
|
|
active: Option<bool>,
|
|
channel_point: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct LndPendingChannelsResponse {
|
|
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndPendingOpenChannel {
|
|
channel: Option<LndPendingChannel>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LndPendingChannel {
|
|
remote_node_pub: Option<String>,
|
|
capacity: Option<String>,
|
|
local_balance: Option<String>,
|
|
remote_balance: Option<String>,
|
|
channel_point: Option<String>,
|
|
}
|