1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
|
// LNURL-withdraw: a site's withdraw lnurl pasted into Send is an offer to
// PAY us — the wallet shows their range, makes an invoice for the amount
// chosen, hands it to their callback and watches for the money. The Ark
// side is faked (the invoice hook); the site is a stand-in on the test
// server. Run: bun tools/lnurl-withdraw-test.js
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
import { encodeLnurl } from '../src/lnurl.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 { zapsFeature } from './src/features/zaps.js';
${domHelper}
const ui = { screen: 'wallet', tab: 'send', send: { recipients: [{ address: '', amount: '' }] } };
let watch = null, cancelled = [], invoices = 0;
const wallet = { watchOnly: false, loadFeatureState: (k, d) => d, saveFeatureState: () => {} };
const ctx = {
h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m),
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) ? Math.round(n) : null; }, getUnit: () => 'sats',
amountField: (sat) => String(sat),
hook(name, ...a) {
if (name === 'canLnPay') return true;
if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); }
if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; }
if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; }
if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); }
return null;
},
};
const feature = zapsFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.sendView() || h('div', { id: 'closed' }, 'closed')); }
window.test = { paste: (s) => feature.matchSendText(s, false), settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, ui };
render();
`;
const bundle = await Bun.build({
entrypoints: ['lnw-test-entry'], target: 'browser',
plugins: [{ name: 'lnw-test', setup(build) {
build.onResolve({ filter: /^lnw-test-entry$/ }, () => ({ path: 'entry', namespace: 'lnw-test' }));
build.onLoad({ filter: /.*/, namespace: 'lnw-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>`;
const claims = [];
const server = Bun.serve({ port: 0, fetch: (req) => {
const u = new URL(req.url);
if (u.pathname === '/withdraw') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'deadbeef', minWithdrawable: 100000, maxWithdrawable: 5000000, defaultDescription: 'Faucet drip' });
if (u.pathname === '/withdraw-fixed') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'fixed1', minWithdrawable: 21000, maxWithdrawable: 21000 });
if (u.pathname === '/withdraw-spent') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb-spent', k1: 'spent', minWithdrawable: 1000, maxWithdrawable: 1000 });
if (u.pathname === '/cb') { claims.push({ k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }); return Response.json({ status: 'OK' }); }
if (u.pathname === '/cb-spent') return Response.json({ status: 'ERROR', reason: 'this voucher was already used' });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const lnurl = (path) => encodeLnurl(server.url.origin + path);
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 = 5000) => { 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() === x || n.textContent.trim().endsWith(' ' + 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]);
const host = new URL(server.url.origin).hostname;
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(server.url.href);
await page.waitForSelector('#closed');
console.log('[a withdraw lnurl with a range]');
check('the paste is claimed', await page.evaluate((s) => test.paste(s), lnurl('/withdraw')));
check('their terms show: the site, the range, the description', await waitText(/Withdraw over Lightning/) && new RegExp(host + ' will pay you between 100 and 5,000 sats').test(await text()) && /Faucet drip/.test(await text()), await text());
check('the amount starts at their max', await page.$eval('input[type=number]', (i) => i.value) === '5000');
await type('input[type=number]', '9000'); await click('Withdraw'); await pause(100);
check('over their max: told the range, nothing sent', /Between 100 and 5,000 sats/.test(await text()) && claims.length === 0);
await type('input[type=number]', '1500'); await click('Withdraw');
check('an invoice for the amount is handed to their callback', await waitText(new RegExp(host + ' has your invoice for 1,500 sats')) && claims.length === 1 && claims[0].k1 === 'deadbeef' && claims[0].pr === 'lnbc1500n1testinvoice1', JSON.stringify(claims));
check('…and the wallet watches for the money', (await page.evaluate(() => test.watch)) === 'ln1');
await page.evaluate(() => test.settle(true));
check('paid: Received', await waitText(/Received 1,500 sats/) && !!(await page.$('.check-badge')));
await click('Done'); await pause(50);
check('Done closes it', !!(await page.$('#closed')));
console.log('[a fixed amount]');
await page.evaluate((s) => test.paste(s), lnurl('/withdraw-fixed'));
check('a fixed amount is stated, with no field', await waitText(new RegExp(host + ' will pay you 21 sats')) && !(await page.$('input[type=number]')));
await click('Withdraw');
check('withdrawn at that amount', await waitText(/has your invoice for 21 sats/) && claims[1].pr === 'lnbc21n1testinvoice2', JSON.stringify(claims));
await click('Back'); await pause(50);
check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed')));
console.log('[a site that refuses]');
await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent'));
await waitText(/will pay you 1 sats/); await click('Withdraw');
check("their reason is shown, with Try again", await waitText(/already used/) && /Try again/.test(await text()), await text());
check('no page errors', errors.length === 0, errors.join('; '));
console.log('\n✅ a withdraw lnurl pays us');
} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); }
|