Dorian 0cf71c4115 fix: zero-amount invoices, identity.verify DID extraction, tor service permissions
- Allow zero-amount Lightning invoices (BOLT11 "any amount") by changing
  validation from amount_sats < 1 to amount_sats < 0
- identity.verify now extracts pubkey directly from did:key format instead
  of requiring the DID to belong to a local identity
- tor.create-service writes config to data_dir/tor-config/ instead of
  /var/lib/archipelago/tor/ (owned by debian-tor, not archipelago user)
- Add E2E test script (scripts/run-e2e-tests.sh) covering 47 RPC endpoints
- Add testing plan with results (loop/testing.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:53:36 +00:00

540 lines
19 KiB
Rust

use super::RpcHandler;
use anyhow::{Context, Result};
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>,
#[allow(dead_code)]
confirmed_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,
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)?)
}
/// Helper: create an authenticated LND REST client
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)"))?;
if amount < 20000 {
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 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()) {
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'"));
}
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)"));
}
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"));
}
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"))?;
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,
}))
}
}
// 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>,
}