From ad281e832ed9e36b777f03ed54e799df7d70ffc9 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 31 Jul 2026 08:04:33 -0700 Subject: Fix send-form UX around lightning pays; harden ln state machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 `a@b.co` before `.com`, yanking the form away): the send form offers an explicit "Zap " 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 --- src/app.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) (limited to 'src/app.js') 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: '', @@ -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' }, -- cgit v1.2.3