aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-06 14:26:53 -0700
committerAdam Soltys <[email protected]>2026-09-06 14:26:53 -0700
commit5119fcab671646454c10563c7b79741728d33a03 (patch)
treed5e68454a7008eb5949b164e276e6a03b2e6c88c /src
parent46e0350a1fb1a549357ce40370bcd9e2520915d4 (diff)
downloadcoinosv3-5119fcab671646454c10563c7b79741728d33a03.tar.gz
coinosv3-5119fcab671646454c10563c7b79741728d33a03.zip
LNURL/merchant payments read as PAYMENTS, not zaps, and skip redundant steps. (1) Terminology: a payment is a "zap" only when it targets a nostr person or note (npub / note-zap with an eventId, captured as z.social at begin); paying a lightning address, LNURL or merchant now says "Pay over Lightning" / "Pay" everywhere — amount screen, review title, success screen — even when the endpoint supports zap receipts. (2) A fixed-amount payment (min==max — a POS tag, paywall, bolt card) has nothing to type, so the amount screen is skipped and the flow goes straight to the quote/review; the two perceived confirmations collapse to one. (3) Per-recipient auto-pay budget: the review offers "Auto-pay <merchant> up to N next time" (ctx.lnBudget/setLnBudget in prefs, synced), and a fixed charge within a set budget pays as soon as the quote lands — no tap. Guarded: only fixed amounts, only within the budget, only when funds cover it; over budget / no funds / quote error all fall back to the normal review, and social zaps are untouched. Verified with a driven-feature harness (fake ctx + local LNURL server): fixed skips to startLnPay with social=false/fixedSat set, a covering budget sets autopay, variable still shows the amount screen titled/buttoned "Pay"
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src')
-rw-r--r--src/app.js15
-rw-r--r--src/features/ark.js36
-rw-r--r--src/features/zaps.js54
-rw-r--r--src/i18n.js1
4 files changed, 96 insertions, 10 deletions
diff --git a/src/app.js b/src/app.js
index a2411a9..802781c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4829,6 +4829,21 @@ const ctx = {
wallet.saveFeatureState('prefs', p);
try { wallet.saveCache(); } catch {}
},
+ // Per-recipient auto-pay budget: a fixed-amount LNURL/address the user
+ // trusts to pay without confirmation, up to this many sats. Keyed by the
+ // recipient's address/lnurl. Synced with the rest of prefs.
+ lnBudget: (key) => {
+ if (!key || !wallet.loadFeatureState) return 0;
+ return (wallet.loadFeatureState('prefs', {}).lnBudgets || {})[key] || 0;
+ },
+ setLnBudget: (key, sat) => {
+ if (!key) return;
+ const p = wallet.loadFeatureState('prefs', {});
+ p.lnBudgets = { ...(p.lnBudgets || {}) };
+ if (sat > 0) p.lnBudgets[key] = Math.floor(sat); else delete p.lnBudgets[key];
+ wallet.saveFeatureState('prefs', p);
+ try { wallet.saveCache(); } catch {}
+ },
brandHeader, activeAccount, setAccounts: (list) => { accounts = list; },
getAccounts: () => accounts,
claimTargets, enterWallet, activateAccount, commitAccount,
diff --git a/src/features/ark.js b/src/features/ark.js
index b65bce2..46ec701 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -854,7 +854,20 @@ export function arkFeature(ctx) {
ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' };
ui.sendError = '';
render();
- quoteArkLnPay(invoice, meta).catch(() => {
+ // An auto-pay recipient (a budget the user set) pays as soon as the quote
+ // lands — no tap. Guarded to the budget and to a single fire; anything
+ // off-script (over budget, no amount, a quote error) falls back to the
+ // normal review with the Pay button.
+ const maybeAutopay = () => {
+ const p2 = ui.arkLnPay;
+ if (!p2 || p2.invoice !== invoice || p2._autopaid) return;
+ if (p2.status !== 'ready' || !p2.meta || !p2.meta.autopay || p2.amountSat == null) return;
+ const budget = p2.meta.budgetKey && ctx.lnBudget ? ctx.lnBudget(p2.meta.budgetKey) : 0;
+ if (!(budget > 0 && p2.amountSat <= budget)) return;
+ p2._autopaid = true;
+ doArkLnPay();
+ };
+ quoteArkLnPay(invoice, meta).then(maybeAutopay).catch(() => {
// The quote is a nicety — a dead esplora or quote service must not
// wedge the form at an eternal spinner (it did: "Getting a quote…"
// above a Failed-to-fetch, Pay unreachable). Price it like the outage
@@ -866,6 +879,7 @@ export function arkFeature(ctx) {
p2.status = 'ready';
ui.sendError = '';
render();
+ maybeAutopay();
});
return true;
}
@@ -943,6 +957,10 @@ export function arkFeature(ctx) {
if (a.step === 'done') {
ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta };
if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk);
+ // the user opted this recipient into auto-pay: remember the budget
+ // so the next fixed charge from them clears without a tap
+ if (p.setBudget && p.meta && p.meta.budgetKey && ctx.setLnBudget)
+ ctx.setLnBudget(p.meta.budgetKey, a.amountSat);
ui.arkLnPay = null;
} else if (ui.arkLnPay) {
if (tries < 2 && lnRetryable(a)) {
@@ -1003,7 +1021,7 @@ export function arkFeature(ctx) {
onClick: () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); },
},
h('div', { class: 'check-badge' }, '⚡'),
- h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('arkLnPaidTitle')),
+ h('h2', { style: 'margin:0' }, zap && zap.social ? t('arkZapSentTitle') : t('arkLnPaidTitle')),
zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null,
h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkLnPaid.amountSat) + u),
h('div', { class: 'small muted' }, t('tapToProceed')));
@@ -1016,7 +1034,7 @@ export function arkFeature(ctx) {
const total = (p.amountSat || 0) + (p.feeSat || 0);
return h('div', { class: 'col', style: 'gap:12px' },
h('div', { class: 'card col', style: 'gap:10px' },
- h('h3', { style: 'margin:0' }, '⚡ ' + (zap ? t('lnZapReviewTitle') : t('lnPayTitle'))),
+ h('h3', { style: 'margin:0' }, '⚡ ' + (zap && zap.social ? t('lnZapReviewTitle') : t('lnPayTitle'))),
h('div', { class: 'small muted' }, t('arkLnPayVia')),
zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null,
zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null,
@@ -1039,6 +1057,18 @@ export function arkFeature(ctx) {
? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)]
: null),
ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null,
+ // Offer to skip this confirmation next time: a fixed-amount merchant
+ // pay with no budget yet can be trusted up to this amount. Ticking it
+ // stores the budget on a successful pay (see settle); after that the
+ // same recipient auto-pays without this screen.
+ (zap && zap.budgetKey && zap.fixedSat && !zap.autopay
+ && p.status === 'ready'
+ && !(ctx.lnBudget && ctx.lnBudget(zap.budgetKey) > 0))
+ ? h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', checked: !!p.setBudget, style: 'flex:0 0 auto;margin:0',
+ onChange: (e) => { p.setBudget = e.target.checked; } }),
+ h('span', { class: 'small muted' }, t('lnAutopayOffer', { name: zap.name || zap.address || '', n: fmtAmount(zap.fixedSat) + ' ' + unitLabel() })))
+ : null,
p.status === 'paying'
? h('div', { class: 'card row gap6', style: 'align-items:center' },
h('span', { class: 'spinner sm' }),
diff --git a/src/features/zaps.js b/src/features/zaps.js
index b1af6a9..fc11f80 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -61,12 +61,24 @@ export function zapsFeature(ctx) {
return true;
}
+ // A payment is a nostr ZAP only when it targets a person or a note (an
+ // npub, or a note-zap carrying an eventId). Paying a lightning address,
+ // an LNURL, or a merchant is a PAYMENT — even if the endpoint happens to
+ // support nostr zaps. Computed at begin() because resolve() rewrites the
+ // target kind (npub → its lnaddr) but keeps the eventId.
+ const isSocial = (target) => target.kind === 'npub' || !!target.eventId;
+
+ // A stable id for a fixed-amount recipient's auto-pay budget: their
+ // lightning address if we have one, else the LNURL bech32, else their pk.
+ const budgetKeyOf = (z) =>
+ z.address || z.target.lnurlBech32 || (z.target.pk ? 'pk:' + z.target.pk : null);
+
// Kick off resolution of a parsed target. `display` is what we show as the
// recipient until a profile name arrives.
function begin(target, display) {
ui.arkZap = null; ui.arkZapped = null; // in case we were handed off from ark
ui.sendError = '';
- const z = (ui.zap = { status: 'resolving', target, name: display, address: target.address || null, amount: '', comment: '' });
+ const z = (ui.zap = { status: 'resolving', target, name: display, address: target.address || null, amount: '', comment: '', social: isSocial(target) });
render();
resolve(z).catch((e) => { if (ui.zap === z) { z.status = 'error'; z.error = e.message; render(); } });
}
@@ -154,6 +166,22 @@ export function zapsFeature(ctx) {
}
z.params = params;
z.status = 'ready';
+ // A fixed-amount payment (not a social zap) has nothing to type, so the
+ // amount screen is a pointless confirm — go straight to the quote. And
+ // if this recipient carries an auto-pay budget that covers it, pay
+ // outright (confirm() passes autopay through to the review, which fetches
+ // the quote and pays without a tap). Over budget, or funds don't cover
+ // → show the screen so the amount/board handoff is still available.
+ if (!z.social && params.fixedSat) {
+ const ours = hook('lnSpendableSat');
+ const covers = ours == null || params.fixedSat <= ours;
+ if (covers) {
+ const key = budgetKeyOf(z);
+ z.autopay = !!(key && ctx.lnBudget && params.fixedSat <= (ctx.lnBudget(key) || 0));
+ confirm();
+ return;
+ }
+ }
render();
}
@@ -240,7 +268,16 @@ export function zapsFeature(ctx) {
});
}
const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment });
- const meta = { name: z.name, address: z.address, pk: z.target.pk || null, comment: canZap ? z.comment : '', isZap: !!zapRequest };
+ const meta = {
+ name: z.name, address: z.address, pk: z.target.pk || null,
+ comment: canZap ? z.comment : '', isZap: !!zapRequest, social: z.social,
+ // a fixed-amount payment can carry (or offer to set) an auto-pay
+ // budget for this recipient — the review honors autopay and, when
+ // offered, remembers the budget on confirm
+ budgetKey: !z.social && p.fixedSat ? budgetKeyOf(z) : null,
+ fixedSat: p.fixedSat || null,
+ autopay: !!z.autopay,
+ };
ui.zap = null;
// Hand the bolt11 to the swaps feature's pay flow (review → confirm).
hook('startLnPay', invoice, meta);
@@ -253,6 +290,9 @@ export function zapsFeature(ctx) {
function zapView() {
const z = ui.zap;
if (!z) return null;
+ // "Zap" only for a social zap (a person / a note); a plain LNURL or
+ // merchant payment is a "Pay".
+ const heading = '⚡ ' + (z.social ? t('zapTitle') : t('lnPayTitle'));
const back = () => { ui.zap = null; ui.sendError = ''; ui.send = blankSend(); render(); };
// Resolution is a background step, not a screen: the send form stays put
// and shows one inline line (resolvingNote below) until we can render
@@ -260,7 +300,7 @@ export function zapsFeature(ctx) {
if (z.status === 'resolving') {
// twin of ark's skeleton: identical shape, so the handoff is invisible
return h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', {}, '⚡ ' + t('zapTitle')),
+ h('h3', {}, heading),
(z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg'))
|| h('div', { class: 'small muted break' }, z.name || z.address || ''),
h('div', { class: 'row gap6', style: 'align-items:center;padding:6px 0' },
@@ -275,7 +315,7 @@ export function zapsFeature(ctx) {
}
if (z.status === 'error') {
return h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', {}, '⚡ ' + t('zapTitle')),
+ h('h3', {}, heading),
(z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || ''),
h('div', { class: 'notice err' }, z.error || t('lnZapFailed')),
h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
@@ -298,7 +338,7 @@ export function zapsFeature(ctx) {
const boardable = !!hook('arkReady') && ours != null;
const needsBoard = boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat));
return h('div', { class: 'card col', style: 'gap:12px' },
- h('h3', {}, '⚡ ' + t('zapTitle')),
+ h('h3', {}, heading),
(z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || z.address || ''),
h('div', { class: 'col gap6' },
h('div', { class: 'input-group' },
@@ -323,13 +363,13 @@ export function zapsFeature(ctx) {
maxlength: p.allowsNostr ? '280' : String(p.commentAllowed || 280),
onInput: (e) => { z.comment = e.target.value; } })
: null,
- h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain'))),
+ z.social ? h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain')) : null),
z.error ? h('div', { class: 'notice err' }, z.error) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost', onClick: back }, t('back')),
needsBoard
? h('button', { class: 'btn-primary grow', onClick: () => hook('arkOfferBoard', typedSats || min) }, t('zapBoardBtn'))
- : h('button', { class: 'btn-primary grow', onClick: confirm, disabled: broke }, t('arkZapBtn'))));
+ : h('button', { class: 'btn-primary grow', onClick: confirm, disabled: broke }, z.social ? t('arkZapBtn') : t('lnPayConfirm'))));
}
return {
diff --git a/src/i18n.js b/src/i18n.js
index 0387550..b5db206 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -911,6 +911,7 @@ const STR = {
lnPayNetworkFee: 'Network fee',
lnPayTotal: 'Total',
lnPayConfirm: 'Pay',
+ lnAutopayOffer: 'Auto-pay {name} up to {n} next time',
lnPaySentTitle: 'Lightning payment sent',
lnPaySentBody: 'The swap provider pays the invoice once the on-chain lockup confirms.',
lnZapTitle: 'Zap over Lightning',