diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 21:30:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 21:30:33 -0700 |
| commit | 29c84501956226f16d63442bce2132834c065114 (patch) | |
| tree | fe81979ff425cf567fb78199b86136a70624a0a2 | |
| parent | 4fcfbef28ee9f53425f552b69e99422096d8dfcb (diff) | |
| download | coinosv3-29c84501956226f16d63442bce2132834c065114.tar.gz coinosv3-29c84501956226f16d63442bce2132834c065114.zip | |
A Lightning invoice from Receive says to keep coinos open until it's paid — paid while coinos is closed, the money goes back to the sender — and that it lasts an hour (ark.coinos.io's invoices now expire in 1 h, not 48); when the hour is up the QR gives way to "expired" and a New invoice button.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
| -rw-r--r-- | src/features/names.js | 11 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | tools/names-invoice-expiry-test.js | 70 |
3 files changed, 83 insertions, 0 deletions
diff --git a/src/features/names.js b/src/features/names.js index fe5ab0a..45b0b36 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -694,14 +694,25 @@ export function namesFeature(ctx) { try { const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100)); inv.invoice = a.invoice; + inv.expiresAt = a.expiresAt || 0; + // the QR gives way to "expired" when the hour is up + if (inv.expiresAt) setTimeout(() => { if (ui.namesInv === inv) render(); }, Math.max(0, inv.expiresAt - Date.now()) + 500); } catch (e) { inv.error = e.message; } inv.busy = false; render(); } function invoicePane() { const inv = ui.namesInv || (ui.namesInv = {}); + // The server finishes a Lightning receive only while this wallet is + // open to take it: paid with coinos shut, the money goes back to the + // payer. The invoice lasts an hour, and the screen says both. + if (inv.invoice && inv.expiresAt && Date.now() >= inv.expiresAt) + return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'small muted', style: 'text-align:center' }, t('namesInvExpired')), + h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew'))); if (inv.invoice) return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, + h('div', { class: 'notice small names-inv-open', style: 'width:100%;text-align:center' }, t('namesInvKeepOpen')), h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice), h('div', { class: 'row gap6' }, diff --git a/src/i18n.js b/src/i18n.js index fa21732..a06c35c 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1422,6 +1422,8 @@ const STR = { namesInvMemoPh: 'Description (optional)', namesInvCreate: 'Create invoice', namesInvNew: 'New invoice', + namesInvKeepOpen: 'Keep coinos open until this is paid. If it\u2019s paid while coinos is closed, the money goes back to the sender. It expires in an hour.', + namesInvExpired: 'This invoice has expired. Make a new one when you\u2019re ready to be paid.', namesInvBadAmount: 'Enter an amount in sats', namesOfferMemoPh: 'Label (e.g. Ocean payout)', namesOfferCreate: 'Create', diff --git a/tools/names-invoice-expiry-test.js b/tools/names-invoice-expiry-test.js new file mode 100644 index 0000000..511d212 --- /dev/null +++ b/tools/names-invoice-expiry-test.js @@ -0,0 +1,70 @@ +// Receive → More → Lightning invoice: the invoice says to keep coinos open +// until it's paid (the server only finishes a receive while the wallet is +// there to take it) and gives way to "expired" when its hour is up. +// The Ark side is faked. Run: bun tools/names-invoice-expiry-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.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 { namesFeature } from './src/features/names.js'; +${domHelper} +const state = { names: { byDomain: { 'coinos.io': { name: 'alice', domain: 'coinos.io' } } } }; +const ui = { screen: 'wallet', namesMore: 'bolt11' }; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => e, nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' }; +const ctx = { + h, ui, wallet, render, toast: () => {}, copy: () => {}, copyBtn: (x, l) => h('button', { class: 'btn-sm' }, l), brandHeader: () => null, goBack: () => {}, setAccount: () => {}, + hook(name, ...a) { + if (name === 'arkReady') return true; + if (name === 'arkMakeInvoice') return Promise.resolve({ invoice: 'lnbc10u1testinvoice', amountSat: a[0], expiresAt: Date.now() + (window.__life || 3600_000) }); + return null; + }, +}; +const feature = namesFeature(ctx); +function render() { document.querySelector('#app').replaceChildren(feature.receiveModes()[0].render(h('div', { class: 'seg' }))); } +window.test = { render }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['names-inv-entry'], target: 'browser', + plugins: [{ name: 'names-inv', setup(build) { + build.onResolve({ filter: /^names-inv-entry$/ }, () => ({ path: 'entry', namespace: 'names-inv' })); + build.onLoad({ filter: /.*/, namespace: 'names-inv' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const html = `<!doctype html><meta charset="utf-8"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +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 make = async () => { + await page.evaluate(() => { const i = document.querySelector('input[type=number]'); i.value = '1000'; i.dispatchEvent(new Event('input', { bubbles: true })); }); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /create/i.test(b.textContent)).click()); + await pause(100); +}; +try { + await page.goto(server.url.href); + await page.waitForSelector('input[type=number]'); + await make(); + check('the invoice shows', /lnbc10u1testinvoice/.test(await text())); + check('...saying to keep coinos open until it\'s paid, and that it lasts an hour', /Keep coinos open until this is paid/.test(await text()) && /goes back to the sender/.test(await text()) && /expires in an hour/.test(await text())); + await page.evaluate(() => { window.__life = 1500; document.querySelector('.btn-sm:last-child').click(); }); + await pause(50); + await page.evaluate(() => test.render()); + await make(); + check('a fresh one shows its QR', !!(await page.$('svg')) && !/expired/.test(await text())); + await pause(2300); + check('when its time is up it says expired, the QR gone', /This invoice has expired/.test(await text()) && !(await page.$('svg')) && !/lnbc10u1testinvoice/.test(await text())); + check('...with a button for a new one', await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'New invoice'))); + check('no page errors', !errors.length, errors.join(' | ')); + console.log('\n✅ invoice: keep open, expires'); +} finally { + await browser.close(); + server.stop(true); +} |
