From 065d1b96afa32781f1a0805e0b4a02d32bf883ee Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 14 Nov 2026 21:38:58 -0700 Subject: A withdraw link says when a claim couldn't be delivered, and while one is still being paid. "As you can see, I made a transaction for 1,000,000 sats using the Withdraw link method, but they never arrived" (a user via Adam, 2026-11-14). The registrar couldn't route the claim to the wallet that asked, so nothing was sent and the 1M went back on the link, but the link's page said only "1,000,000 sats left", the same as an unclaimed one. Now the latest claim shows on the link's page: "on its way" / "couldn't find a Lightning route yet, still trying for a few minutes" (the registrar now retries for up to ten minutes, coinosv3-server a29af6f), looked at again every 10 s, or "couldn't be delivered … nothing was sent and the sats are still in this link", with the QR and Take back still there; the list row reads "claim failed · N left". lnurl-withdraw-links-test claims a second link with no route: retried, given back, both messages shown, and the same link pays once a route turns up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS --- src/features/withdraw-links.js | 25 ++++++++++++++++++++++++- src/i18n.js | 4 ++++ tools/lnurl-withdraw-links-test.js | 27 ++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js index a63d180..38b2d8c 100644 --- a/src/features/withdraw-links.js +++ b/src/features/withdraw-links.js @@ -114,6 +114,18 @@ export function withdrawLinks(ctx) { } finally { s.taking = null; render(); load(); } } + // What became of the latest claim someone made (reclaims aside): on its + // way, or turned back with the sats still here. "I made a transaction for + // 1,000,000 sats using the Withdraw link method, but they never arrived" + // (a user, 2026-11-14): the claim had failed and the link said only + // "1,000,000 sats left". + function lastClaim(l) { + const c = (l.claims || []).filter((x) => !x.reclaim).at(-1); + if (!c || c.status === 'paid') return null; + if (c.status === 'paying') return { kind: 'paying', c }; + return l.status === 'open' && l.remainingSat >= c.sat ? { kind: 'failed', c } : null; + } + function linkCard(l) { const s = st(); const lnurl = lnurlOf(l); @@ -130,6 +142,15 @@ export function withdrawLinks(ctx) { live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')), live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null, live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null, + (() => { + const lc = lastClaim(l); + if (!lc) return null; + // while it's being paid, look again every 10 s so the answer shows up here + if (lc.kind === 'paying' && !s.poll) s.poll = setTimeout(() => { st().poll = null; if (ui.giftMode === 'lnurl') load(); }, 10_000); + return lc.kind === 'paying' + ? h('div', { class: 'notice info small wl-claim-paying', style: 'width:100%' }, t(lc.c.tries > 1 ? 'wlClaimRetrying' : 'wlClaimPaying', { amount: amt(lc.c.sat) })) + : h('div', { class: 'notice err small wl-claim-failed', style: 'width:100%' }, t('wlClaimFailed', { amount: amt(lc.c.sat) })); + })(), s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), live || (l.status === 'open' && l.remainingSat > 0) ? (s.taking === l.id @@ -171,7 +192,9 @@ export function withdrawLinks(ctx) { ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } }, h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null), h('span', { class: 'small faint' }, - l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) }))))); + l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') + : lastClaim(l)?.kind === 'failed' ? t('wlClaimFailedTag', { amount: amt(l.remainingSat) }) + : t('wlLeft', { amount: amt(l.remainingSat) }))))); } function view() { diff --git a/src/i18n.js b/src/i18n.js index baf2012..f4774f6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1805,6 +1805,10 @@ const STR = { wlTakeBackFailed: 'Couldn’t pay it back just now. The link stays open; try again in a moment.', wlClosed: 'This link is closed.', wlUsedUp: 'This link has been used up.', + wlClaimPaying: 'A claim of {amount} is on its way to the wallet that asked.', + wlClaimRetrying: "A claim of {amount} couldn't find a Lightning route yet. Still trying for a few minutes.", + wlClaimFailed: "A claim of {amount} couldn't be delivered: there was no Lightning route to the wallet that asked. Nothing was sent and the sats are still in this link. Claim it again, maybe for less or from another wallet, or take them back.", + wlClaimFailedTag: 'claim failed · {amount} left', wlClosedTag: 'closed', wlUsedUpTag: 'used up', wlYours: 'Your withdraw links', diff --git a/tools/lnurl-withdraw-links-test.js b/tools/lnurl-withdraw-links-test.js index b349e17..0e0b340 100644 --- a/tools/lnurl-withdraw-links-test.js +++ b/tools/lnurl-withdraw-links-test.js @@ -66,13 +66,13 @@ const html = ` { const hash = (++seq).toString(16).padStart(64, '0'); const b = 'lnbc' + sat + 'n1fake' + seq; decoded[b] = { valid: true, payment_hash: hash, amount_msat: sat * 1000, payee, created_at: Math.floor(Date.now() / 1000), expiry: 3600 }; return { b, hash }; }; const ln = { async call(m, p) { if (m === 'invoice') { const { b, hash } = mk(p.amount_msat / 1000, OUR); invoices[hash] = { status: 'unpaid', bolt11: b }; return { bolt11: b, payment_hash: hash }; } if (m === 'listinvoices') return { invoices: invoices[p.payment_hash] ? [invoices[p.payment_hash]] : [] }; if (m === 'decode') { if (!decoded[p.string]) throw new Error('bad'); return decoded[p.string]; } - if (m === 'pay') { const d = decoded[p.bolt11]; outbound[d.payment_hash] = 'complete'; return { status: 'complete', amount_msat: d.amount_msat, amount_sent_msat: d.amount_msat }; } + if (m === 'pay') { const d = decoded[p.bolt11]; if (routeless) { outbound[d.payment_hash] = 'failed'; throw new Error('Failed: We could not find a usable set of paths'); } outbound[d.payment_hash] = 'complete'; return { status: 'complete', amount_msat: d.amount_msat, amount_sent_msat: d.amount_msat }; } if (m === 'waitsendpay') { outbound[p.payment_hash] = 'complete'; return { status: 'complete' }; } if (m === 'listsendpays') return { payments: outbound[p.payment_hash] ? [{ status: outbound[p.payment_hash] }] : [] }; throw new Error('unexpected ' + m); @@ -105,7 +105,7 @@ const server = Bun.serve({ port: 0, async fetch(req) { return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); svc = withdrawService({ state, persist: () => {}, lnFor: () => ln, ourNodeId: async () => OUR, - circularPick: async () => ({ cap: 1e9 }), payCircular: async () => { circular++; return { feeMsat: 0 }; }, publicBase: server.url.origin.replace(/\/$/, '') }); + circularPick: async () => ({ cap: 1e9 }), payCircular: async () => { circular++; return { feeMsat: 0 }; }, wait: async () => {}, publicBase: server.url.origin.replace(/\/$/, '') }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); @@ -161,6 +161,27 @@ try { check('…and the wallet watched for that payment', (await page.evaluate(() => test.watched.length)) >= 1); check('…the toast says so', (await page.evaluate(() => window.toasts || [])).some((m) => /1,000 sats is back in Spending/.test(m))); check('a closed link refuses claims', svc.params(link.id).status === 'ERROR'); + + console.log('\n[a claim with no route to the wallet that asked]'); + await click('← Back'); + if (!(await page.$('#wl-amount'))) await page.evaluate(() => document.querySelector('#wl-open')?.click()); + await page.waitForSelector('#wl-amount', { timeout: 3000 }).catch(async () => { throw new Error('no form: ' + await text()); }); + await type('#wl-amount', '700'); await type('#wl-uses', '1'); await type('textarea', ''); + await page.click('#wl-create'); + check('a second link is made', await waitText(/Your withdraw link is ready/)); + const link2 = Object.values(state.withdraws).find((x) => x.id !== link.id); + routeless = true; + const theirs = mk(700, THEIRS).b; + check('the claimer is told OK (LUD-03 pays after)', (await svc.callback(link2.id, link2.k1, theirs)).status === 'OK'); + for (let i = 0; i < 50 && link2.claims[0]?.status !== 'failed'; i++) await pause(20); + check('it was tried again before giving up, and the sats went back', link2.claims[0].status === 'failed' && link2.claims[0].tries > 1 && link2.spentSat === 0, JSON.stringify(link2)); + await page.evaluate(() => { test.ui.giftMode = false; test.render(); document.querySelector('#wl-open').click(); }); + check('the list says the claim failed', await waitText(/claim failed · 700 sats left/), await text()); + await page.evaluate(() => [...document.querySelectorAll('.wl-row')].find((b) => /claim failed/.test(b.textContent)).click()); + check('the link page says nothing was sent and the sats are still here', await waitText(/A claim of 700 sats couldn't be delivered.*sats are still in this link/), await text()); + check('…and still offers the QR and take back', !!(await page.$('svg')) && /Take back 700 sats/.test(await text())); + routeless = false; + check('the same link pays a claim once a route is there', (await svc.callback(link2.id, link2.k1, mk(700, THEIRS).b)).status === 'OK' && (await (async () => { await pause(50); return link2.claims[1]?.status === 'paid'; })()), JSON.stringify(link2.claims)); check('no page errors', errors.length === 0, errors.join('; ')); console.log('\n✅ prepaid withdraw links'); } catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); } -- cgit v1.2.3