aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 08:04:33 -0700
committerAdam Soltys <[email protected]>2026-07-31 08:04:33 -0700
commitad281e832ed9e36b777f03ed54e799df7d70ffc9 (patch)
tree2e7ba6df785e6b8f74266c56a7ca2006cbb9579b /src
parent72e407bf6d7d09923d15461be82685017a0887be (diff)
downloadcoinosv3-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')
-rw-r--r--src/app.js20
-rw-r--r--src/ark/manager.js40
-rw-r--r--src/features/ark.js78
-rw-r--r--src/features/zaps.js34
-rw-r--r--src/i18n.js2
5 files changed, 119 insertions, 55 deletions
diff --git a/src/app.js b/src/app.js
index b0a5fec..5aa1316 100644
--- a/src/app.js
+++ b/src/app.js
@@ -38,7 +38,9 @@ const featureHook = (name, ...args) => {
return null;
};
const featureAll = (name, ...args) => FEATURES.flatMap((f) => (f[name] ? f[name](...args) : []));
-const featureMatchSend = (text) => FEATURES.some((f) => f.matchSendText && f.matchSendText(text));
+// `typed` distinguishes keystroke input from paste/scan: some matchers (a
+// lightning address) match mid-typing and must not yank the form away.
+const featureMatchSend = (text, typed) => FEATURES.some((f) => f.matchSendText && f.matchSendText(text, typed));
@@ -1973,6 +1975,7 @@ function tabsBar() {
tabs.map(([id, label]) =>
tabBtn(label, ui.tab === id, () => {
ui.tab = id;
+ ui.sendError = ''; // a stale send error never survives a tab change
ui.revealShown = false; // re-mask the recovery phrase whenever tabs change
ui.txDetail = null; // back to the history list when leaving/returning
ui.arkMoveDetail = null;
@@ -2418,15 +2421,18 @@ function recipientRow(s, r, i) {
// A pasted/typed/scanned payload a feature recognizes (e.g. a bolt11) jumps
// straight to that feature's own confirmation flow.
- const tryFeature = (v) => featureMatchSend(v);
+ const tryFeature = (v, typed) => featureMatchSend(v, typed);
const addrInput = h('input', {
type: 'text', class: 'mono-input grow', placeholder: i === 0 ? t('destPlaceholder') : 'bc1q…',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address,
onInput: (e) => {
const v = e.target.value;
r.address = v; syncCheck();
- if (tryFeature(v)) return; // a bolt11 advances to its own confirmation
- if (destReady(v) !== r._ready) render(); // reveal/hide amount + controls as validity flips
+ const hadError = !!ui.sendError;
+ ui.sendError = ''; // editing the destination starts over — drop stale errors
+ if (tryFeature(v, true)) return; // a bolt11 advances to its own confirmation
+ // reveal/hide amount + controls as validity flips (or an error clears)
+ if (hadError || destReady(v) !== r._ready) render();
},
});
const row = h(
@@ -2434,7 +2440,7 @@ function recipientRow(s, r, i) {
{ class: 'col gap6' },
h('div', { class: 'input-group' },
addrInput,
- pasteBtn((text) => { addrInput.value = text; r.address = text; syncCheck(); if (!tryFeature(text) && destReady(text) !== r._ready) render(); }),
+ pasteBtn((text) => { addrInput.value = text; r.address = text; ui.sendError = ''; syncCheck(); if (!tryFeature(text)) render(); }),
i === 0 && canScan() && h('button', {
type: 'button', class: 'btn-sm', title: t('scanQr'), onClick: scanIntoSend,
html: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/></svg>',
@@ -2500,7 +2506,9 @@ function sendForm() {
onClick: () => { s.recipients.push({ address: '', amount: '' }); s.max = false; render(); },
}, t('addRecipient'))
),
- ready && arkDest && (featureHook('sendFormNote', plainAddr) || null),
+ // any feature may annotate the destination (ark hint, lightning-address
+ // zap offer, …) — each hook decides for itself whether the text is its
+ s.recipients.length === 1 ? (featureHook('sendFormNote', plainAddr) || null) : null,
ready && !arkDest && h(
'div',
{ class: 'field' },
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 700050a..d575d64 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -491,9 +491,12 @@ export class ArkManager {
try {
sigs = await cosignWithServer(this.arkUrl, build, { input, outputs, vtxoKeys: keys, serverPubkey: this.serverPub });
} catch (e) {
- if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) {
- // the input is gone (possibly a prior crashed attempt) — don't retry
- inputRec.state = 'spent';
+ const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message);
+ if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) {
+ // "already spent": the input is gone (possibly a prior crashed
+ // attempt). Status 3: the server rejected the cosign outright, so
+ // the input is untouched and returns to spendable. Neither retries.
+ inputRec.state = spent ? 'spent' : 'spendable';
action.step = 'failed';
action.error = e.message;
this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message });
@@ -646,8 +649,12 @@ export class ArkManager {
[resp] = await requestLightningPayHtlcCosign(this.arkUrl,
[cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]);
} catch (e) {
- if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) {
- inputRec.state = 'spent';
+ // INVALID_ARGUMENT (status 3): the server rejected the cosign outright
+ // — nothing was spent, so the input goes back to spendable instead of
+ // rotting in pending. "already spent" means the input really is gone.
+ const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message);
+ if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) {
+ inputRec.state = spent ? 'spent' : 'spendable';
action.step = 'failed';
action.error = e.message;
this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message });
@@ -861,7 +868,14 @@ export class ArkManager {
// accepted | htlcsReady: ask for our HTLC vtxos and validate them
const tip = await this.chain.tipHeight();
const keys = this._key(action.keyIndex);
- const htlcRecvExpiry = tip + action.minCltvDelta;
+ // anchor the requested expiry on the FIRST attempt: the server's grant
+ // is fixed by the inbound HTLC, so recomputing from a later tip would
+ // inflate our own requirement until it exceeds the grant
+ if (!action.htlcRecvExpiry) {
+ action.htlcRecvExpiry = tip + action.minCltvDelta;
+ this._save();
+ }
+ const htlcRecvExpiry = action.htlcRecvExpiry;
let antiDos = null;
if (this.info.lnReceiveAntiDosRequired) {
const proof = this.state.vtxos.filter((v) => v.state === 'spendable')
@@ -887,7 +901,19 @@ export class ArkManager {
if (v.policy.paymentHash !== action.paymentHash) throw new Error('HTLC vtxo payment hash mismatch');
if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch');
if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested');
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] });
+ try {
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] });
+ } catch (e) {
+ // a granted vtxo can anchor to a round the chain hasn't confirmed
+ // yet — park (no preimage revealed, nothing at risk) and retry on
+ // sync; the invoice-expiry reap bounds how long we'll wait
+ if (e instanceof VtxoValidationError) {
+ action.lastError = e.message;
+ this._save();
+ return;
+ }
+ throw e;
+ }
total += v.amountSat;
}
if (total + lnReceiveFee(action.amountSat, this.info.lnReceiveFees) < action.amountSat) {
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; },
diff --git a/src/i18n.js b/src/i18n.js
index 82a397b..9b39503 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -435,6 +435,8 @@ const STR = {
arkLnPayFailed: 'Payment failed',
arkLnRefunded: 'Payment failed — funds returned to your Ark balance.',
arkLnPaidTitle: 'Paid!',
+ arkLnFundsPending: 'Your Ark funds are settling from a previous payment — retrying…',
+ lnZapOffer: 'Zap {addr}',
arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.',
arkLnPaidHistory: 'Paid Lightning invoice',
arkLnReceivedHistory: 'Received over Lightning',