From f600eaf1458867ab225f64d5ac3a73562a71b85c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 13 Jul 2026 14:10:45 +0100 Subject: [PATCH] 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 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/lnd/payments.rs | 114 ++++++++++++++++++ neode-ui/src/components/TransactionsModal.vue | 36 +++++- neode-ui/src/views/Home.vue | 7 +- neode-ui/src/views/home/HomeWalletCard.vue | 2 + 5 files changed, 152 insertions(+), 8 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index faf7b637..cb7258cc 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs index 35c2a5a9..e810e10f 100644 --- a/core/archipelago/src/api/rpc/lnd/payments.rs +++ b/core/archipelago/src/api/rpc/lnd/payments.rs @@ -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 { + 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 = 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 })) + } } diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue index 81b93452..cd4c370c 100644 --- a/neode-ui/src/components/TransactionsModal.vue +++ b/neode-ui/src/components/TransactionsModal.vue @@ -7,9 +7,10 @@
+ {{ kindLabel(tx) }} +

{{ tx.tx_hash }}

@@ -56,7 +65,7 @@
{{ formatTxTime(tx.time_stamp) }} - +
@@ -80,6 +89,8 @@ interface WalletTransaction { dest_addresses: string[] label: string block_height: number + // Which rail the transaction happened on; absent = onchain (older backends) + kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' } defineProps<{ @@ -95,8 +106,21 @@ function close() { emit('close') } -function openInMempool(txHash: string) { - router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) +// Only on-chain transactions exist in mempool; Lightning/ecash rows don't link +function isOnchain(tx: WalletTransaction): boolean { + return !tx.kind || tx.kind === 'onchain' +} + +function kindLabel(tx: WalletTransaction): string { + if (tx.kind === 'lightning') return '⚡ Lightning' + if (tx.kind === 'cashu') return 'Cashu' + if (tx.kind === 'fedimint') return 'Fedimint' + return '' +} + +function openInMempool(tx: WalletTransaction) { + if (!isOnchain(tx)) return + router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${tx.tx_hash}` } }) } function formatTxTime(timestamp: number): string { diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 245988bb..56840b8f 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -545,6 +545,7 @@ function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction { dest_addresses: [], label: tx.description, block_height: 0, + kind: tx.kind, } } @@ -560,10 +561,12 @@ async function loadWeb5Status() { // here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never // appeared in the Transactions modal even though the balance included it. let lndTxs: WalletTransaction[] = [] - try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = res.transactions || [] } catch { /* keep last-known transactions */ } + try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ } + let lightningTxs: WalletTransaction[] = [] + try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ } let ecashTxs: WalletTransaction[] = [] try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ } - walletTransactions.value = [...lndTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp) + walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp) } // System stats diff --git a/neode-ui/src/views/home/HomeWalletCard.vue b/neode-ui/src/views/home/HomeWalletCard.vue index a66e21d8..94c5fcbe 100644 --- a/neode-ui/src/views/home/HomeWalletCard.vue +++ b/neode-ui/src/views/home/HomeWalletCard.vue @@ -172,6 +172,8 @@ export interface WalletTransaction { dest_addresses: string[] label: string block_height: number + // Which rail the transaction happened on; absent = onchain (older backends) + kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' } const props = defineProps<{