diff options
| -rw-r--r-- | src/features/gifts.js | 8 | ||||
| -rw-r--r-- | src/features/withdraw-links.js | 195 | ||||
| -rw-r--r-- | src/i18n.js | 29 | ||||
| -rw-r--r-- | tools/lnurl-withdraw-links-test.js | 166 |
4 files changed, 396 insertions, 2 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js index f23ecc8..c6ccbd9 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -8,6 +8,7 @@ import { decryptWithCode, npubOf } from '../nostr.js'; import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { withdrawLinks } from './withdraw-links.js'; import { fmtBtc, timeAgo, shortAddr } from '../format.js'; import { getEventHash, verifyEvent } from 'nostr-tools/pure'; import { sha256 } from '@noble/hashes/sha256'; @@ -26,6 +27,7 @@ export function giftsFeature(ctx) { // every call degrades to null in an on-chain-only build. const hook = ctx.hook || (() => null); const arkGiftOf = (code) => hook('arkGiftDecode', code); + const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send // ================================================================ GIFT / CLAIM // Read a gift link, returning the code (and scrubbing it from the URL so the @@ -1085,9 +1087,11 @@ export function giftsFeature(ctx) { return { blob, claimCode, url: `${location.origin}/lg/${blob}` }; }, receiveTakeover() { return claimCelebration(); }, - sendView() { return ui.giftMode ? giftView() : null; }, + sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; }, sendFormExtras() { - return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')); + return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' }, + h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')), + wl.entry()); }, balanceLines() { const locked = wallet.giftLockedValue(); diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js new file mode 100644 index 0000000..a63d180 --- /dev/null +++ b/src/features/withdraw-links.js @@ -0,0 +1,195 @@ +// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that +// whoever holds it can pull sats from — a voucher, a faucet, a printed card — +// even while this wallet is asleep. "Do we have lnurl withdraw links +// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07). +// +// The money is paid up front, from Spending over Lightning, to the coinos +// registrar (coinosv3-server names/withdraw.js), which pays each claim out of +// it; until claimed it sits there, and the creator can take back whatever is +// left at any time ("Take back"), which closes the link. Links belong to the +// wallet's own nostr key, so every device with the seed sees them. +// +// It lives on the Send page as a sibling of the gift link: ui.giftMode === +// 'lnurl' (giftMode is already a navigation field with its resets). +import { nip98Header } from '../nip98.js'; +import { encodeLnurl } from '../lnurl.js'; +import { qrSvg } from '../qr.js'; +import { getNetwork } from '../api.js'; +import { t } from '../i18n.js'; + +const REGISTRAR = 'https://names.coinos.io'; +const NOTE_MAX = 120; +const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet'); +const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()); + +export function withdrawLinks(ctx) { + const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + const base = () => ctx.withdrawRegistrar || REGISTRAR; + // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null } + const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' }); + + const signer = { signEvent: (e) => wallet.nostrSign(e) }; + async function call(method, path, payload) { + const url = base() + path; + const body = payload ? JSON.stringify(payload) : ''; + const auth = await nip98Header(signer, url, method, body); + const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } + + async function load() { + const s = st(); + try { + const j = await call('GET', '/withdraw/mine'); + s.list = (j.links || []).filter((l) => l.net === net()); + s.listError = ''; + } catch (e) { s.listError = e.message; s.list ||= []; } + render(); + } + + function open() { + ui.wl = null; + ui.giftMode = 'lnurl'; + ui.sendError = ''; + st(); + render(); + load(); + } + + const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase(); + const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel(); + + async function create() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.floor(Number(s.uses) || 1); + s.error = ''; + if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; } + if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; } + if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; } + if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; } + const total = sat * uses; + if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; } + s.busy = true; render(); + try { + const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() }); + await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat }); + let link = null; + for (let i = 0; i < 10 && !link; i++) { + try { link = await call('POST', '/withdraw/funded', { id: inv.id }); } + catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); } + } + if (!link) throw new Error(t('wlNotConfirmed')); + s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)]; + s.made = link.id; s.open = link.id; + s.amount = ''; s.uses = '1'; s.note = ''; + } catch (e) { + s.error = e.message || String(e); + } finally { s.busy = false; render(); } + } + + async function takeBack(l) { + const s = st(); + s.taking = l.id; s.error = ''; render(); + let lnId = null; + try { + if (l.remainingSat > 0) { + const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo')); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + lnId = inv.id; + // the wallet has to claim the held payment while the registrar pays it + hook('arkLnWatch', inv.id, () => {}); + const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice }); + Object.assign(l, r); + toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) })); + } else { + Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id })); + } + } catch (e) { + if (lnId) hook('arkLnCancel', lnId); + s.error = e.message || String(e); + } finally { s.taking = null; render(); load(); } + } + + function linkCard(l) { + const s = st(); + const lnurl = lnurlOf(l); + let svg = null; + try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {} + const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses; + return h('div', { class: 'card col', style: 'align-items:center;gap:10px' }, + h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')), + live && svg ? h('div', { html: svg }) : null, + l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null, + h('div', { class: 'small muted', style: 'text-align:center' }, + l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }), + ' · ', t('wlLeft', { amount: amt(l.remainingSat) })), + 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, + s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error), + live || (l.status === 'open' && l.remainingSat > 0) + ? (s.taking === l.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) }))) + : null, + h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back'))); + } + + function formCard() { + const s = st(); + const sat = parseAmount(s.amount, getUnit()) || 0; + const uses = Math.max(1, Math.floor(Number(s.uses) || 1)); + return h('div', { class: 'card col gap6' }, + h('h3', {}, t('wlTitle')), + h('div', { class: 'small muted' }, t('wlDesc')), + h('div', { class: 'input-group' }, + h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001', + placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small grow' }, t('wlUses')), + h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px', + value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })), + h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }), + h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')), + s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null, + s.busy + ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate'))); + } + + function listCard() { + const s = st(); + if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' })); + if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null; + return h('div', { class: 'card col gap6' }, + h('span', { class: 'small muted' }, t('wlYours')), + ...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) }))))); + } + + function view() { + const s = st(); + if (!available()) { + return h('div', { class: 'col', style: 'gap:12px' }, + h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back'))); + } + const l = s.open && (s.list || []).find((x) => x.id === s.open); + if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l)); + return h('div', { class: 'col', style: 'gap:12px' }, + formCard(), + listCard(), + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back'))); + } + + const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle')); + + return { view, entry, open }; +} diff --git a/src/i18n.js b/src/i18n.js index b45638e..96f4548 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1743,6 +1743,35 @@ const STR = { lnWithdrawPaid: 'Received {amount}', lnWithdrawNotPaid: 'They took the invoice but the payment didn’t arrive. Check with them before trying again.', lnWithdrawNoInvoice: 'Couldn’t make an invoice to be paid to.', + wlTitle: 'Withdraw link', + wlReady: 'Your withdraw link is ready', + wlDesc: 'A Lightning link or QR code that anyone can withdraw sats from, like a voucher. It works with most Lightning wallets, even while your phone is off. You pay for it up front; Coinos holds the sats until they’re claimed, and you can take back whatever is left at any time.', + wlAmount: 'Amount per withdrawal', + wlUses: 'How many withdrawals', + wlNote: 'Description (optional)', + wlPayNow: 'Paid now from Spending.', + wlPayNowMany: '{total} paid now from Spending: {n} withdrawals of up to {amount}.', + wlCreate: 'Create withdraw link', + wlCreateFor: 'Create link for {amount}', + wlAmountInvalid: 'Enter an amount.', + wlUsesInvalid: 'Between 1 and 1000 withdrawals.', + wlNoFunds: 'Not enough in Spending for {amount}.', + wlNotConfirmed: 'Paid, but the link isn’t confirmed yet. It will appear under your links in a moment.', + wlTermsOne: 'Up to {amount}, once', + wlTermsMany: 'Up to {amount} each, {n} of {total} withdrawals left', + wlLeft: '{amount} left', + wlCopy: 'Copy LNURL', + wlBearer: 'Anyone with this link or QR code can withdraw from it. Share it only with whoever it’s for.', + wlTakeBack: 'Take back {amount}', + wlTakeBackMemo: 'Withdraw link taken back', + wlTakenBack: '{amount} is back in Spending', + 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.', + wlClosedTag: 'closed', + wlUsedUpTag: 'used up', + wlYours: 'Your withdraw links', + wlMainnetOnly: 'Withdraw links are only available on mainnet.', lnWithdrawWatchOnly: 'This device can’t receive: the wallet is watch-only here.', lnWithdrawMemo: 'Withdrawal from {domain}', lnPayAmount: 'Amount', diff --git a/tools/lnurl-withdraw-links-test.js b/tools/lnurl-withdraw-links-test.js new file mode 100644 index 0000000..b349e17 --- /dev/null +++ b/tools/lnurl-withdraw-links-test.js @@ -0,0 +1,166 @@ +// Prepaid withdraw links (src/features/withdraw-links.js + coinosv3-server +// names/withdraw.js): create one from Send (it pays the registrar up front), +// get an LNURL QR, have it claimed — here by our own LNURL-withdraw redeem +// flow (zaps.js), the same road any Lightning wallet takes — and take back +// what's left. The registrar is the real withdraw service behind a stand-in +// HTTP server (NIP-98 checked) with a fake Lightning node; the Ark side is +// faked through the hooks. Run: bun tools/lnurl-withdraw-links-test.js +import assert from 'node:assert/strict'; +import { verifyEvent } from 'nostr-tools/pure'; +import puppeteer from './puppeteer.js'; +import { withdrawService } from '../../coinosv3-server/names/withdraw.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { withdrawLinks } from './src/features/withdraw-links.js'; +import { zapsFeature } from './src/features/zaps.js'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +${domHelper} +const sk = generateSecretKey(); +const ui = { screen: 'wallet', tab: 'send', giftMode: false, send: { recipients: [{ address: '', amount: '' }] } }; +let lnInvoices = 0; const watched = []; +const wallet = { watchOnly: false, nostrSign: async (e) => finalizeEvent(e, sk), loadFeatureState: (k, d) => d, saveFeatureState: () => {} }; +const api = async (p) => (await fetch(p)).json(); +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), + withdrawRegistrar: location.origin, + copyBtn: (v, label) => h('button', { class: 'btn-sm', onClick: () => { window.copied = v; } }, label), + goBack: (fn) => { fn(); render(); }, + blankSend: () => ({ recipients: [{ address: '', amount: '' }] }), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) && n > 0 ? Math.round(n) : null; }, getUnit: () => 'sats', + amountField: (sat) => String(sat), + hook(name, ...a) { + if (name === 'arkReady' || name === 'canLnPay') return true; + if (name === 'arkSpendableSat') return window.spendable ?? 100000; + if (name === 'arkPayInvoice') { window.paidInvoices = (window.paidInvoices || 0) + 1; return api('/__pay?bolt11=' + a[0]); } + // a coinos wallet's Lightning receive lives on the registrar's own node + if (name === 'arkLnInvoice') { lnInvoices++; return api('/__mkinv?own=1&sat=' + a[0]).then((r) => ({ id: 'ln' + lnInvoices, invoice: r.bolt11, amountSat: a[0] })); } + if (name === 'arkLnWatch') { watched.push(a[0]); return true; } + if (name === 'arkLnUnwatch' || name === 'arkLnCancel') return true; + return null; + }, +}; +const wl = withdrawLinks(ctx); +const zaps = zapsFeature(ctx); +function render() { + const v = (ui.giftMode === 'lnurl' && wl.view()) || zaps.sendView() || h('div', { id: 'closed' }, wl.entry()); + document.querySelector('#app').replaceChildren(v); +} +window.test = { ui, render, pk: getPublicKey(sk), redeem: (s) => zaps.matchSendText(s, false), watched }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['wl-test-entry'], target: 'browser', + plugins: [{ name: 'wl-test', setup(build) { + build.onResolve({ filter: /^wl-test-entry$/ }, () => ({ path: 'entry', namespace: 'wl-test' })); + build.onLoad({ filter: /.*/, namespace: 'wl-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; + +// ---- the stand-in registrar: the real service, a fake node ---- +const OUR = '02' + 'aa'.repeat(32), THEIRS = '03' + 'bb'.repeat(32); +const invoices = {}, decoded = {}, outbound = {}; +let seq = 0, circular = 0; +const mk = (sat, payee) => { 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 === '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); +} }; +const state = {}; +let svc; // made once the server has its port: the service's URLs are built on it +const nip98 = async (req, url, body) => { + const evt = JSON.parse(atob((req.headers.get('authorization') || '').replace(/^Nostr /, ''))); + assert(verifyEvent(evt) && evt.kind === 27235 && new URL(evt.tags.find((t) => t[0] === 'u')[1]).pathname === url.pathname); + return evt.pubkey; +}; +const server = Bun.serve({ port: 0, async fetch(req) { + const u = new URL(req.url); + const j = (b, s = 200) => Response.json(b, { status: s }); + if (u.pathname === '/__pay') { const d = decoded[u.searchParams.get('bolt11')]; invoices[d.payment_hash].status = 'paid'; return j({ ok: true }); } + if (u.pathname === '/__mkinv') { const { b } = mk(Number(u.searchParams.get('sat')), u.searchParams.get('own') ? OUR : THEIRS); return j({ bolt11: b }); } + const w = u.pathname.match(/^\/w\/([A-Za-z0-9_-]+)(\/cb)?$/); + if (w) return j(w[2] ? await svc.callback(w[1], u.searchParams.get('k1'), u.searchParams.get('pr')) : svc.params(w[1])); + if (u.pathname.startsWith('/withdraw/')) { + const body = req.method === 'POST' ? await req.text() : ''; + const pk = await nip98(req, u, body); + const b = body ? JSON.parse(body) : {}; + try { + if (u.pathname === '/withdraw/mine') return j({ links: svc.mine(pk) }); + if (u.pathname === '/withdraw/new') return j(await svc.create({ pubkey: pk, ...b })); + if (u.pathname === '/withdraw/funded') return j(await svc.confirmFunded(b.id, pk)); + if (u.pathname === '/withdraw/reclaim') return j(await svc.reclaim(b.id, pk, b.invoice)); + } catch (e) { return j({ error: e.message }, e.status || 500); } + } + 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(/\/$/, '') }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const waitText = async (re, ms = 6000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; }; +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().startsWith(x)); if (!b) return false; b.click(); return true; }, label); +const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]); +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#wl-open'); + console.log('[making a link]'); + await page.click('#wl-open'); + check('Send opens the withdraw-link page', await waitText(/A Lightning link or QR code that anyone can withdraw sats from/)); + await page.click('#wl-create'); + check('no amount: asked for one, nothing paid', await waitText(/Enter an amount/) && !(await page.evaluate(() => window.paidInvoices))); + await type('#wl-amount', '500'); await type('#wl-uses', '3'); + check('the button and the line say what gets paid now', /Create link for 1,500 sats/.test(await text()) && /1,500 sats paid now from Spending: 3 withdrawals of up to 500 sats/.test(await text()), await text()); + await page.evaluate(() => { window.spendable = 1000; }); + await page.click('#wl-create'); + check('more than Spending holds: refused before paying', await waitText(/Not enough in Spending for 1,500 sats/) && !(await page.evaluate(() => window.paidInvoices))); + await page.evaluate(() => { window.spendable = 100000; }); + await type('textarea', 'Meetup door prize'); + await page.click('#wl-create'); + check('it pays the registrar once and shows the link', await waitText(/Your withdraw link is ready/) && (await page.evaluate(() => window.paidInvoices)) === 1, await text()); + check('…with a QR, the terms and the note', !!(await page.$('svg')) && /Up to 500 sats each, 3 of 3 withdrawals left/.test(await text()) && /Meetup door prize/.test(await text()), await text()); + const lnurl = await page.evaluate(() => [...document.querySelectorAll('.addr-box')].map((e) => e.textContent.trim())[0]); + check('the link is an LNURL', /^LNURL1[0-9A-Z]+$/.test(lnurl), lnurl); + const link = Object.values(state.withdraws)[0]; + check('the registrar holds it funded', link.status === 'open' && link.totalSat === 1500 && link.pubkey === (await page.evaluate(() => test.pk))); + + console.log('\n[claiming it with a Lightning wallet: ours]'); + await click('Back'); + await page.evaluate(() => { test.ui.giftMode = false; }); + check('the redeem flow takes the LNURL', await page.evaluate((s) => test.redeem(s), lnurl)); + check('…and reads the link\'s terms', await waitText(/will pay you between 1 and 500 sats/) && /Meetup door prize/.test(await text()), await text()); + await click('Withdraw'); + check('the claim is paid out of the link', await waitText(/has your invoice for 500 sats/) && (await (async () => { await pause(100); return link.claims[0]?.status === 'paid'; })()), JSON.stringify(link.claims)); + check('…round the circular route (a coinos wallet\'s own invoice)', circular === 1); + + console.log('\n[taking back the rest]'); + await page.evaluate(() => { test.ui.lnWithdraw = null; test.ui.giftMode = false; test.render(); }); + await page.evaluate(() => document.querySelector('#wl-open').click()); + check('the list shows what is left', await waitText(/Your withdraw links/) && /1,000 sats left/.test(await text()), await text()); + await page.evaluate(() => document.querySelector('.wl-row').click()); + check('the link page shows 2 of 3 left', await waitText(/2 of 3 withdrawals left/)); + await click('Take back 1,000 sats'); + check('take back pays the remainder to the wallet and closes the link', await waitText(/This link is closed/) && link.status === 'closed' && link.spentSat === 1500, JSON.stringify(link)); + 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'); + 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); } |
