diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 08:04:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 08:04:33 -0700 |
| commit | ad281e832ed9e36b777f03ed54e799df7d70ffc9 (patch) | |
| tree | 2e7ba6df785e6b8f74266c56a7ca2006cbb9579b /src/features | |
| parent | 72e407bf6d7d09923d15461be82685017a0887be (diff) | |
| download | coinosv3-ad281e832ed9e36b777f03ed54e799df7d70ffc9.tar.gz coinosv3-ad281e832ed9e36b777f03ed54e799df7d70ffc9.zip | |
Fix send-form UX around lightning pays; harden ln state machines
- a rejected cosign (gRPC INVALID_ARGUMENT) returns the input vtxo to
spendable and fails the action instead of leaving it pending forever —
the stuck-pending state that pushed small zaps onto Boltz's 25k minimum
- ark ln-pay now takes over when funds are settling (retrying quote) rather
than falling through to Boltz
- typing a destination clears stale send errors (also cleared on tab
switch); paste path likewise
- typed lightning addresses no longer auto-advance mid-keystroke (the
matcher fired at `[email protected]` before `.com`, yanking the form away): the send
form offers an explicit "Zap <addr>" button; pasted/scanned addresses
still advance immediately
- ln-recv: HTLC vtxos anchored to a not-yet-confirmed pool round park and
retry instead of throwing; the requested HTLC expiry is anchored on first
prepare so retries can't inflate it past the server's grant
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 78 | ||||
| -rw-r--r-- | src/features/zaps.js | 34 |
2 files changed, 70 insertions, 42 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 03041a2..143bec4 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -341,42 +341,59 @@ export function arkFeature(ctx) { const dec = maybeBolt11(invoice); if (!dec || !arkAvailable() || wallet.watchOnly) return false; const s = arkStateNow(); - const canCover = (sats) => - !!s && (s.vtxos || []).some((v) => v.state === 'spendable' && v.amountSat >= sats); - if (!canCover(dec.amountSat || 1)) return false; + const covers = (state, sats) => + !!s && (s.vtxos || []).some((v) => v.state === state && v.amountSat >= sats); + // pending funds count for the take-over decision: an in-flight payment or + // revocation frees them in seconds, and the Boltz fallback can't do small + // amounts at all — better to wait for ark than bounce off Boltz's minimum + if (!covers('spendable', dec.amountSat || 1) && !covers('pending', dec.amountSat || 1)) return false; ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' }; ui.sendError = ''; render(); - connectArk().then(async (mgr) => { - const p = ui.arkLnPay; - if (!p || p.invoice !== invoice) return; - if (p.amountSat) { - // real quote: smallest vtxo that covers amount + its expiry-based fee - const tip = await mgr.chain.tipHeight(); - const candidates = mgr.vtxos().filter((v) => v.state === 'spendable') - .sort((a, b) => a.amountSat - b.amountSat); - let fee = null; - for (const v of candidates) { - const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip); - if (v.amountSat >= p.amountSat + f) { fee = f; break; } - } - if (fee == null) { - // can't cover once fees are in — hand off to the Boltz path - ui.arkLnPay = null; - if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); } - render(); - return; - } - p.feeSat = fee; - } - p.status = 'ready'; - render(); - }).catch((e) => { + quoteArkLnPay(invoice, meta).catch((e) => { if (ui.arkLnPay && ui.arkLnPay.invoice === invoice) { ui.sendError = e.message; render(); } }); return true; } + // Quote (or re-quote) an in-form lightning pay: smallest vtxo that covers + // amount + its expiry-based fee. Funds locked by a settling action retry + // themselves free; genuinely insufficient funds hand off to Boltz. + async function quoteArkLnPay(invoice, meta) { + const mgr = await connectArk(); + const p = ui.arkLnPay; + if (!p || p.invoice !== invoice) return; + if (!p.amountSat) { p.status = 'ready'; render(); return; } + const tip = await mgr.chain.tipHeight(); + const candidates = mgr.vtxos().filter((v) => v.state === 'spendable') + .sort((a, b) => a.amountSat - b.amountSat); + let fee = null; + for (const v of candidates) { + const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip); + if (v.amountSat >= p.amountSat + f) { fee = f; break; } + } + if (fee == null) { + const pendingCover = mgr.vtxos().some((v) => v.state === 'pending' && v.amountSat >= p.amountSat); + if (pendingCover) { + p.status = 'fundsPending'; + render(); + mgr.resumePending().catch(() => {}); // nudge whatever holds them + setTimeout(() => { + if (ui.arkLnPay === p) quoteArkLnPay(invoice, meta).catch(() => {}); + }, 4000); + return; + } + // can't cover once fees are in — hand off to the Boltz path + ui.arkLnPay = null; + if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); } + render(); + return; + } + p.feeSat = fee; + p.status = 'ready'; + render(); + } + async function doArkLnPay() { const p = ui.arkLnPay; const sats = p.amountSat || ctx.parseAmount(p.amount, ctx.getUnit()); @@ -434,6 +451,9 @@ export function arkFeature(ctx) { p.status === 'quote' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('lnQuoting'))) : null, + p.status === 'fundsPending' + ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnFundsPending'))) + : null, p.amountSat == null && p.status !== 'quote' ? h('div', { class: 'input-group' }, h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: p.amount, @@ -449,7 +469,7 @@ export function arkFeature(ctx) { p.status === 'paying' ? h('div', { class: 'card row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkLnPaying'))) - : h('button', { class: 'btn-primary btn-block', disabled: !!ui.busy || p.status === 'quote', onClick: doArkLnPay }, + : h('button', { class: 'btn-primary btn-block', disabled: !!ui.busy || p.status !== 'ready', onClick: doArkLnPay }, ui.busy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), p.status !== 'paying' ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkLnPay = null; ui.sendError = ''; ui.send = blankSend(); render(); } }, t('back')) diff --git a/src/features/zaps.js b/src/features/zaps.js index 36afb12..2667921 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -21,13 +21,6 @@ export function zapsFeature(ctx) { // Lightning payment has to be possible for a zap to make sense. const canPay = () => !wallet.watchOnly && !!hook('canLnPay'); - // A lightning address (name@domain) matches its regex mid-typing — at - // `[email protected]` before the user finishes `.com` — so we debounce those; npub and - // lnurl carry a bech32 checksum and only match when complete, so they fire - // immediately. - let resolveTimer = null; - const clearResolve = () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null; } }; - const shortNpub = (npub) => (npub && npub.length > 16 ? npub.slice(0, 12) + '…' + npub.slice(-6) : npub); // Kick off resolution of a parsed target. `display` is what we show as the @@ -136,18 +129,33 @@ export function zapsFeature(ctx) { return { id: 'zaps', // A lightning address / lnurl (always), or an npub that Ark didn't claim. - matchSendText(text) { - if (!canPay() || !ui.send || ui.send.recipients.length !== 1) { clearResolve(); return false; } + matchSendText(text, typed) { + if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; const target = parseZapTarget(text); - if (!target) { clearResolve(); return false; } + if (!target) return false; // npub with no nostr seam to look up a profile → can't resolve here. if (target.kind === 'npub' && !wallet.nostrProfile) return false; + // A TYPED lightning address matches mid-keystroke (`[email protected]` before the + // user finishes `.com`), so never auto-advance while typing — the send + // form offers a zap button via sendFormNote instead. Pasted/scanned + // text is complete and advances immediately; npub/lnurl carry a bech32 + // checksum and only ever match when complete. + if (typed && target.kind === 'lnaddr') return false; const display = target.kind === 'npub' ? shortNpub(npubOf(target.pk) || text.trim()) : (target.address || text.trim()); - clearResolve(); - if (target.kind === 'lnaddr') resolveTimer = setTimeout(() => { resolveTimer = null; begin(target, display); }, 600); - else begin(target, display); + begin(target, display); return true; }, + // The typed-lightning-address affordance on the send form. + sendFormNote(a) { + if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return null; + const target = parseZapTarget(a); + if (!target || target.kind === 'npub') return null; + const display = target.address || String(a || '').trim(); + return h('button', { + type: 'button', class: 'btn-primary btn-block', + onClick: () => begin(target, display), + }, '⚡ ' + t('lnZapOffer', { addr: display })); + }, // True when an npub can be zapped over Lightning from this build/wallet — // ark's "no Ark address" screen checks this before offering the fallback. canLnZap() { return canPay() && !!wallet.nostrProfile; }, |
