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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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); }
|