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:
parent
533c0713c7
commit
f600eaf145
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,10 @@
|
||||
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
||||
<div
|
||||
v-for="tx in transactions"
|
||||
:key="tx.tx_hash"
|
||||
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 cursor-pointer transition-colors"
|
||||
@click="openInMempool(tx.tx_hash)"
|
||||
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
||||
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
|
||||
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="openInMempool(tx)"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
@ -38,6 +39,7 @@
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
||||
</span>
|
||||
<span
|
||||
v-if="isOnchain(tx)"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.num_confirmations === 0
|
||||
? 'bg-yellow-500/15 text-yellow-400'
|
||||
@ -47,6 +49,13 @@
|
||||
>
|
||||
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : 'bg-blue-500/15 text-blue-400'"
|
||||
>
|
||||
{{ kindLabel(tx) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
||||
@ -56,7 +65,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
||||
<svg class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user