aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 12:52:14 -0700
committerAdam Soltys <[email protected]>2026-10-07 12:52:14 -0700
commit6faf6b6fe49b8e51a38e0106c858341faf2145ef (patch)
treedc7fe7ebe86552f166a980cb046563c9b651de97
parent7c76b28b3e52622f8a8f734df7478ec8ba5428cf (diff)
downloadcoinosv3-6faf6b6fe49b8e51a38e0106c858341faf2145ef.tar.gz
coinosv3-6faf6b6fe49b8e51a38e0106c858341faf2145ef.zip
History: a Lightning payment still settling (a zap, mostly) shows at once as a pending Sent row with the recipient's face, and gives way to its completed row
One-tap zaps now record who they went to, so their rows carry a face too. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/features/ark.js32
-rw-r--r--src/features/zaps.js2
-rw-r--r--tools/ark/ln-pending-history-test.js54
3 files changed, 82 insertions, 6 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index d6d6e6d..8026ed9 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1101,6 +1101,7 @@ export function arkFeature(ctx) {
// (and fully refunded) attempt re-tries with one extra sat of routing
// budget before giving up — askrene fees are exact, liquidity isn't.
let tries = 0;
+ if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk);
const attempt = async (routingFeeSat) => {
const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats, routingFeeSat });
const a = mgr.lnAction(id);
@@ -1111,7 +1112,6 @@ export function arkFeature(ctx) {
if (a.step === 'done') {
ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta, autopaid: !!(p._autopaid || p.autopaying) };
ui.send = blankSend(); // the next send starts empty, however this card is left
- if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk);
// the user opted this recipient into auto-pay: remember the chosen
// budget so future fixed charges from them clear without a tap
if (p.setBudget && p.meta && p.meta.budgetKey && ctx.setLnBudget)
@@ -1351,7 +1351,7 @@ export function arkFeature(ctx) {
g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag')));
})(),
(() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
h('div', { style: 'text-align:right' },
// in fiat this is what the payment was worth when it was made
@@ -1360,6 +1360,14 @@ export function arkFeature(ctx) {
}
const exitStartedTs = (a) => Number((a.id || '').split('-')[1]) || Date.now();
+ // A Lightning payment (a zap, mostly) still settling: the money has left
+ // the balance, so history shows it now as the Sent row it will become.
+ const lnPendingMove = (a) => ({
+ id: 'lnpay:' + a.id, type: 'ln-send', status: 'pending', ts: exitStartedTs(a),
+ amountSat: a.amountSat, invoice: a.invoice, paymentHash: a.paymentHash,
+ detail: a.feeSat ? `fee ${a.feeSat} sat` : undefined,
+ inputIds: (a.parts || []).map((p) => p.inputId).concat(a.inputId ? [a.inputId] : []).filter(Boolean),
+ });
// Only link a tx that is KNOWN to be out there: the claim, a confirmed
// hop, or the finished chain. Guessing from lastError made the link
// flicker in time with the retry loop.
@@ -1473,7 +1481,7 @@ export function arkFeature(ctx) {
: ONCHAIN_ARK.has(m.type) ? h('span', { html: BITCOIN_ICON(18) })
: h('span', { html: ARK_ICON(18) }),
h('h3', { style: 'margin:0' }, label),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
m.type === 'refresh'
? h('div', { class: m.feeSat > 0 ? 'amount-neg' : 'small faint', style: 'font-size:20px' },
m.feeSat > 0 ? '-' + fmtAmount(m.feeSat) + ' ' + unitLabel() : t('arkDepthFree'))
@@ -3671,7 +3679,10 @@ export function arkFeature(ctx) {
},
startLnPay(invoice, meta) { return startArkLnPay(invoice, meta); },
// ---- headless seam for the NWC wallet service ----
- arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); },
+ arkPayInvoice(invoice, opts = {}) {
+ if (opts.pk) noteZap('inv:' + invoice, opts.pk); // a one-tap zap's face on its history row
+ return payInvoiceHeadless(invoice, opts);
+ },
// The settled outcome of a payment another device of this wallet
// started (the ASP refuses a second HTLC for the same invoice).
async arkLnOutcome(paymentHash, timeoutMs) {
@@ -3858,6 +3869,11 @@ export function arkFeature(ctx) {
const renewals = (s.actions || [])
.filter((a) => a.type === 'refresh' && a.manual && !['done', 'failed'].includes(a.step))
.map((a) => ({ time: exitStartedTs(a), render: () => arkRefreshActionItem(a) }));
+ // ...and Lightning payments on their way, until their Sent row exists
+ const paid = new Set((s.movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete').map((m) => m.paymentHash));
+ const lnPending = (s.actions || [])
+ .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step) && !paid.has(a.paymentHash))
+ .map((a) => { const m = lnPendingMove(a); return { time: m.ts, render: () => arkHistoryItem(m) }; });
// Same-payment duplicates (the old settle race, or a snapshot adopted
// whole before the merge learned to dedupe) fold at display time too —
// the earliest telling wins, everywhere, however the state got here.
@@ -3908,7 +3924,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
- return exits.concat(renewals, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
+ return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
},
historyDetail() {
if (ui.arkExitDetail) {
@@ -3921,6 +3937,12 @@ export function arkFeature(ctx) {
if (ui.arkReconDetail && typeof ui.arkReconDetail === 'object') return arkReconDetailView(ui.arkReconDetail);
if (!ui.arkMoveDetail) return null;
const s = arkStateNow();
+ if (s && String(ui.arkMoveDetail).startsWith('lnpay:')) {
+ const a = (s.actions || []).find((x) => 'lnpay:' + x.id === ui.arkMoveDetail);
+ const done = a && (s.movements || []).find((x) => x.type === 'ln-send' && x.paymentHash === a.paymentHash);
+ if (done) return arkMoveDetailView(done);
+ if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
+ }
const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
diff --git a/src/features/zaps.js b/src/features/zaps.js
index f0b655a..a23ecdd 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -213,7 +213,7 @@ export function zapsFeature(ctx) {
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 });
- await hook('arkPayInvoice', invoice, { maxAmountSat: sats + 50 });
+ await hook('arkPayInvoice', invoice, { maxAmountSat: sats + 50, pk: t2.pk || null });
if (!target.eventId) toast('⚡ ' + t('zapSentShort', { n: fmtAmount(sats) + ' ' + unitLabel() }));
render();
}
diff --git a/tools/ark/ln-pending-history-test.js b/tools/ark/ln-pending-history-test.js
new file mode 100644
index 0000000..39b59ba
--- /dev/null
+++ b/tools/ark/ln-pending-history-test.js
@@ -0,0 +1,54 @@
+// A Lightning payment still settling (a zap, mostly) shows in history as a
+// pending Sent row with the recipient's face, and gives way to the completed
+// row once the payment lands. Run: bun tools/ark/ln-pending-history-test.js
+import assert from 'node:assert/strict';
+
+const { arkFeature } = await import('../../src/features/ark.js');
+const h = (tag, props, ...children) => ({ tag, props, children: children.flat().filter(x => x != null && x !== false) });
+const text = n => n && typeof n === 'object' ? (n.children || []).map(text).join('') : n == null ? '' : String(n);
+const store = { zapnotes: {} };
+const ui = {};
+let state;
+const feature = arkFeature({ h, ui, render() {}, fmtAmount: String, unitLabel: () => 'sats', unitTag: () => 'sats', goBack() {}, copyBtn: () => null,
+ hook: (name, pk) => name === 'profileChip' ? h('span', {}, 'face:' + pk.slice(0, 4)) : null,
+ wallet: { registerCacheExtension() {}, loadArkState: () => state, loadFeatureState: (k, d) => store[k] ?? d, saveFeatureState: (k, v) => { store[k] = v; } } });
+
+const zapped = 'c'.repeat(64);
+const invoice = 'lnbc210n1zap';
+// what a one-tap zap does before paying: remember whose invoice this is
+store.zapnotes['inv:' + invoice] = zapped;
+const started = Date.now() - 5000;
+const action = { id: `lnpay-${started}`, type: 'ln-pay', step: 'htlc-sent', invoice, paymentHash: 'ab'.repeat(32), amountSat: 21, feeSat: 1, parts: [{ inputId: 'v:0', htlcSat: 22 }] };
+state = { movements: [], actions: [action], vtxos: [] };
+
+let rows = feature._histBuild(state);
+assert.equal(rows.length, 1, 'the in-flight payment has a row');
+assert.equal(rows[0].time, started, 'dated when it was sent');
+let row = text(rows[0].render());
+assert.match(row, /Sent/);
+assert.match(row, /in progress/);
+assert.match(row, /-21/, 'shown as money out');
+assert.match(row, /face:cccc/, 'with who was zapped');
+console.log('✓ An in-flight zap shows as a pending Sent row with the recipient');
+
+// tapping it opens a detail view while it settles…
+ui.arkMoveDetail = 'lnpay:' + action.id;
+assert.match(text(feature.historyDetail()), /in progress/);
+
+// …and once it lands, the completed movement replaces it, row and detail
+action.step = 'done';
+state.movements.push({ id: 'mv1', type: 'ln-send', status: 'complete', amountSat: 21, ts: Date.now(), invoice, paymentHash: action.paymentHash, preimage: 'ef'.repeat(32) });
+rows = feature._histBuild(state);
+assert.equal(rows.length, 1, 'no duplicate once settled');
+row = text(rows[0].render());
+assert.ok(!/in progress/.test(row), 'the settled row is not pending');
+assert.match(row, /face:cccc/);
+const detail = text(feature.historyDetail());
+assert.ok(!/in progress/.test(detail) && /Sent/.test(detail), 'an open detail follows the payment to its completed record');
+console.log('✓ The settled payment replaces the pending row, detail included');
+
+// a failed payment leaves no pending row (its failed movement tells it)
+const failed = { ...action, id: `lnpay-${started + 1}`, step: 'failed', paymentHash: 'cd'.repeat(32) };
+assert.equal(feature._histBuild({ movements: [], actions: [failed], vtxos: [] }).length, 0);
+console.log('✓ A failed payment drops its pending row');
+process.exit(0);