feat(wallet): show Lightning, Cashu and Fedimint activity in the transactions modal

- new lnd.lightning-history RPC: settled invoices (incoming) + succeeded
  payments (outgoing) normalized to the wallet-transaction shape; on-chain
  history stays in lnd.gettransactions
- Home merges on-chain + lightning + ecash history into one sorted list;
  every entry now carries kind (onchain/lightning/cashu/fedimint)
- modal renders a rail badge for non-onchain rows instead of a bogus
  confirmation count, and only on-chain rows link into mempool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-13 14:10:45 +01:00
co-authored by Claude Fable 5
parent 533c0713c7
commit f600eaf145
5 changed files with 152 additions and 8 deletions
@@ -131,6 +131,7 @@ impl RpcHandler {
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.lightning-history" => self.handle_lnd_lightning_history().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await,
"lnd.export-channel-backup" => self.handle_lnd_export_channel_backup().await,
"lnd.init-wallet-from-seed" => self.handle_lnd_init_wallet_from_seed(params).await,
@@ -206,4 +206,118 @@ impl RpcHandler {
"incoming_pending_count": incoming_pending,
}))
}
/// Unified Lightning history: settled invoices (incoming) + succeeded
/// payments (outgoing), normalized to the wallet-transaction shape the
/// UI already renders. On-chain history stays in lnd.gettransactions.
pub(in crate::api::rpc) async fn handle_lnd_lightning_history(
&self,
) -> Result<serde_json::Value> {
use base64::Engine;
fn field_i64(v: &serde_json::Value, key: &str) -> i64 {
v.get(key)
.and_then(|f| f.as_str())
.and_then(|s| s.parse().ok())
.or_else(|| v.get(key).and_then(|f| f.as_i64()))
.unwrap_or(0)
}
let (client, macaroon_hex) = self.lnd_client().await?;
let mut transactions: Vec<serde_json::Value> = Vec::new();
// Outgoing: succeeded payments only (include_incomplete=false)
let payments_resp = client
.get(format!(
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=false&max_payments=100&reversed=true"
))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
if payments_resp.status().is_success() {
let body: serde_json::Value = payments_resp
.json()
.await
.context("Failed to parse payments response")?;
for p in body
.get("payments")
.and_then(|v| v.as_array())
.unwrap_or(&vec![])
{
let amount = field_i64(p, "value_sat");
if amount == 0 {
continue;
}
transactions.push(serde_json::json!({
"tx_hash": p.get("payment_hash").and_then(|v| v.as_str()).unwrap_or(""),
"amount_sats": amount,
"direction": "outgoing",
"num_confirmations": 1,
"time_stamp": field_i64(p, "creation_date"),
"total_fees": field_i64(p, "fee_sat"),
"dest_addresses": [],
"label": "",
"block_height": 0,
"kind": "lightning",
}));
}
}
// Incoming: settled invoices only
let invoices_resp = client
.get(format!(
"{LND_REST_BASE_URL}/v1/invoices?num_max_invoices=100&reversed=true"
))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
if invoices_resp.status().is_success() {
let body: serde_json::Value = invoices_resp
.json()
.await
.context("Failed to parse invoices response")?;
for inv in body
.get("invoices")
.and_then(|v| v.as_array())
.unwrap_or(&vec![])
{
let settled = inv.get("state").and_then(|v| v.as_str()) == Some("SETTLED")
|| inv.get("settled").and_then(|v| v.as_bool()) == Some(true);
if !settled {
continue;
}
// r_hash arrives base64 from REST; the UI shows hex
let r_hash_hex = inv
.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| {
base64::engine::general_purpose::STANDARD.decode(b64).ok()
})
.map(hex::encode)
.unwrap_or_default();
transactions.push(serde_json::json!({
"tx_hash": r_hash_hex,
"amount_sats": field_i64(inv, "amt_paid_sat"),
"direction": "incoming",
"num_confirmations": 1,
"time_stamp": field_i64(inv, "settle_date"),
"total_fees": 0,
"dest_addresses": [],
"label": inv.get("memo").and_then(|v| v.as_str()).unwrap_or(""),
"block_height": 0,
"kind": "lightning",
}));
}
}
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)
});
Ok(serde_json::json!({ "transactions": transactions }))
}
}