From ae85dc0afba70f40b333cf03c64abbbf4c8dfea0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 5 Sep 2026 19:38:10 -0700 Subject: Point of sale: a coinos-pos terminal works against a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrar: the owner mints a bearer POS token from Settings (NIP-98 POST /pos/token, DELETE revokes; /admin/pos/token for operators). With it the terminal gets /pos/me, rings up sales at /pos/invoice (fiat via coinos.io rates, or sats), reads /pos/payments, and holds a websocket at /pos/ws that gets a payment message when the sale settles. Sales are always minted on our node so the settle loop sees them and the ordinary forward path delivers the sats to the name's ark address. The name's lnurlp pins the newest unpaid sale under five minutes old — coinos.io's /p/ rule — so an NFC tap or a scan pays exactly that invoice. Wallet: a "Point of sale" section on the names card creates, copies and revokes tokens. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y --- names/server.js | 211 +++++++++++++++++++++++++++++++++++++++++++++++++- src/features/names.js | 25 ++++++ src/i18n.js | 6 ++ 3 files changed, 240 insertions(+), 2 deletions(-) diff --git a/names/server.js b/names/server.js index 4b606a1..6588108 100644 --- a/names/server.js +++ b/names/server.js @@ -22,7 +22,7 @@ import * as nip44 from 'nostr-tools/nip44'; import { wrapManyEvents } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { npubEncode, decode as nip19Decode } from 'nostr-tools/nip19'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { HDKey } from '@scure/bip32'; import { mnemonicToSeedSync } from '@scure/bip39'; import { lnBackend } from '../bridge/ln.js'; @@ -359,6 +359,68 @@ async function forward(name, sat, hash) { throw new Error(fwd && dest ? 'insufficient float and the wallet is not listening' : 'no delivery route'); } +// --------------------------------------------------------------------------- +// Point of sale — a coinos POS terminal (coinos-pos, an ESP32 keypad) works +// against a name with a bearer token its owner mints from Settings. The +// terminal can ring up sales (mint invoices for the name) and learn when they +// settle; it can never spend. POS invoices are always minted HERE, never via +// CLINK, so the settle loop sees them and the merchant's phone can be off; +// delivery is the ordinary forward path (float arkoor), so the sats reach the +// name's ark address seconds after settlement. +// --------------------------------------------------------------------------- +const POS_INVOICE_TTL = 5 * 60_000; // an LNURL tap serves the newest unpaid sale this young +let ratesCache = { at: 0, rates: null }; +async function fiatRates() { + if (ratesCache.rates && Date.now() - ratesCache.at < 60_000) return ratesCache.rates; + const r = await fetch('https://coinos.io/api/rates', { signal: AbortSignal.timeout(5000) }); + if (!r.ok) throw new Error('rates unavailable'); + ratesCache = { at: Date.now(), rates: await r.json() }; + return ratesCache.rates; +} +const posTokens = () => (state.posTokens ||= {}); // token -> { key, currency, created } +const posInvoices = () => (state.posInvoices ||= {}); // payment_hash -> { key, sat, fiat, currency, rate, bolt11, created, paid?, received? } +function posAuth(req, url) { + const h = req.headers.get('authorization') || ''; + const tok = (h.match(/^Bearer\s+(.+)$/i) || [])[1] || url.searchParams.get('token') || ''; + const rec = tok && posTokens()[tok]; + if (!rec || !state.names[rec.key]) return null; // a released name takes its tokens with it + return { token: tok, ...rec }; +} +function posMintToken(key, currency) { + const token = randomBytes(32).toString('hex'); + posTokens()[token] = { key, currency: /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD', created: Date.now() }; + persist(); + log(`pos token minted for ${key}`); + return token; +} +// the newest unpaid sale the terminal rang up in the last five minutes, if any +function posPendingInvoice(key) { + let best = null; + for (const [hash, v] of Object.entries(posInvoices())) { + if (v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL) continue; + if (!best || v.created > best.created) best = { hash, ...v }; + } + return best; +} +function sweepPos() { + const cutoff = Date.now() - 7 * 86400_000; + for (const [h, v] of Object.entries(posInvoices())) if (v.created < cutoff) delete posInvoices()[h]; +} +const posSockets = new Set(); +const posPaymentMsg = (hash, v) => JSON.stringify({ type: 'payment', data: { iid: hash, amount: v.received ?? v.sat, tip: 0 } }); +function posSettled(hash, sat) { + const v = posInvoices()[hash]; + if (!v) return; + v.paid = Date.now(); + v.received = sat; + persist(); + for (const ws of posSockets) { + if (ws.data.key !== v.key || (ws.data.hash && ws.data.hash !== hash)) continue; + try { ws.send(posPaymentMsg(hash, v)); } catch {} + } + log(`pos ${v.key}: ${sat} sat paid`); +} + // NIP-57: a zap we invoiced is only "a zap" once we publish the receipt, // signed by the key our LNURL response advertises. async function publishZapReceipt(pending, inv) { @@ -453,6 +515,7 @@ async function settleLoop() { } const sat = Math.floor((inv.amount_received_msat?.msat ?? inv.amount_received_msat ?? 0) / 1000); if (!sat) continue; + if (pending?.pos) posSettled(inv.payment_hash, sat); // money has arrived for this name — push-notify their devices (best effort) if (CFG.push && CFG.push.url && state.names[name]) { fetch(`${CFG.push.url}/notify`, { @@ -803,11 +866,112 @@ const json = (body, status = 200) => { Bun.serve({ port: CFG.port || 8798, - async fetch(req) { + async fetch(req, server) { const url = new URL(req.url); if (req.method === 'OPTIONS') return json({}); const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; + // ---- point of sale (helpers above settleLoop) ---- + if (url.pathname === '/pos/ws') { + const a = posAuth(req, url); + if (!a) return json({ error: 'bad token' }, 401); + if (server.upgrade(req, { data: { key: a.key, hash: null } })) return undefined; + return json({ error: 'websocket expected' }, 400); + } + // The name's owner mints (POST) or revokes all (DELETE) terminal tokens. + if (url.pathname === '/pos/token' && (req.method === 'POST' || req.method === 'DELETE')) { + if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); + const bodyText = await req.text(); + const a = await checkNip98(req, url, bodyText); + if (a.error) return json({ error: a.error }, 401); + let body = {}; + try { body = JSON.parse(bodyText || '{}'); } catch { return json({ error: 'bad body' }, 400); } + const name = String(body.name || '').toLowerCase(); + const domain = String(body.domain || DOMAIN).toLowerCase(); + const key = `${name}@${domain}`; + const rec = state.names[key]; + if (!rec) return json({ error: 'unknown name' }, 404); + if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) return json({ error: 'not your name' }, 403); + if (req.method === 'DELETE') { + let n = 0; + for (const [t, v] of Object.entries(posTokens())) if (v.key === key) { delete posTokens()[t]; n++; } + persist(); + log(`pos tokens revoked for ${key} (${n})`); + return json({ ok: true, revoked: n }); + } + const token = posMintToken(key, body.currency); + return json({ token, name, domain, currency: posTokens()[token].currency }); + } + // Operator bootstrap: mint a terminal token for any name. + if (url.pathname === '/admin/pos/token' && req.method === 'POST') { + if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) return json({ error: 'unauthorized' }, 401); + const body = await req.json().catch(() => ({})); + const key = `${String(body.name || '').toLowerCase()}@${String(body.domain || DOMAIN).toLowerCase()}`; + if (!state.names[key]) return json({ error: 'unknown name' }, 404); + return json({ token: posMintToken(key, body.currency), key }); + } + // Everything else under /pos/ is the terminal itself, by bearer token. + if (url.pathname.startsWith('/pos/')) { + const a = posAuth(req, url); + if (!a) return json({ error: 'unauthorized' }, 401); + const [name] = a.key.split('@'); + if (url.pathname === '/pos/me' && req.method === 'GET') { + return json({ username: name, address: a.key, currency: a.currency }); + } + // Ring up a sale: {invoice:{fiat}} (the terminal's shape) or {amount} in sats. + if (url.pathname === '/pos/invoice' && req.method === 'POST') { + if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429); + if (!ln) return json({ error: 'invoicing is closed right now' }, 503); + const body = await req.json().catch(() => ({})); + const inv = body.invoice || body; + const currency = /^[A-Z]{3}$/.test(String(inv.currency || '')) ? inv.currency : a.currency; + let rates = null; + try { rates = await fiatRates(); } catch {} + const rate = rates ? rates[currency] : null; + let sat = Math.floor(Number(inv.amount) || 0); + let fiat = Number(inv.fiat) || null; + if (!sat) { + if (!(fiat > 0)) return json({ error: 'fiat or amount required' }, 400); + if (!rate) return json({ error: `no rate for ${currency}` }, 503); + sat = Math.round((fiat / rate) * 1e8); + } else if (rate && fiat == null) { + fiat = Math.round((sat / 1e8) * rate * 100) / 100; + } + if (sat < 1) return json({ error: 'amount too small' }, 400); + try { + const r = await ln.call('invoice', { + amount_msat: sat * 1000, + label: `pos-${a.key}-${Date.now()}`, + description: lnurlMeta(a.key), + deschashonly: true, // LUD-06: the tap's lnurlp metadata hashes to this + expiry: 900, + }); + const created = Date.now(); + state.invoices ||= {}; + state.invoices[r.payment_hash] = { key: a.key, bolt11: r.bolt11, ts: created, pos: true }; + posInvoices()[r.payment_hash] = { key: a.key, sat, fiat, currency, rate, bolt11: r.bolt11, created }; + sweepPos(); + persist(); + log(`pos ${a.key}: sale ${sat} sat${fiat ? ` (${fiat} ${currency})` : ''}`); + return json({ id: r.payment_hash, hash: r.bolt11, amount: sat, fiat, currency, rate, created }); + } catch (e) { + log('pos invoice failed: ' + e.message); + return json({ error: 'could not create an invoice' }, 500); + } + } + // Paid sales, newest first, in the shape the terminal's history browser reads. + if (url.pathname === '/pos/payments' && req.method === 'GET') { + const limit = Math.min(50, parseInt(url.searchParams.get('limit') || '10', 10) || 10); + const payments = Object.entries(posInvoices()) + .filter(([, v]) => v.key === a.key && v.paid) + .sort((x, y) => y[1].paid - x[1].paid) + .slice(0, limit) + .map(([hash, v]) => ({ id: hash, amount: v.received ?? v.sat, tip: 0, created: v.paid, currency: v.currency, rate: v.rate })); + return json({ payments, count: payments.length }); + } + return json({ error: 'not found' }, 404); + } + if (url.pathname === '/health') { return json({ ok: true, domain: DOMAIN, names: Object.keys(state.names).length, @@ -1136,6 +1300,19 @@ Bun.serve({ const rec = state.names[`${name}@${domain}`]; if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404); const address = `${name}@${domain}`; + // A terminal that just rang up a sale: the tap pays exactly that + // invoice (coinos.io's /p/ rule — newest unpaid sale under five + // minutes old). Otherwise the payer picks the amount. + const pos = posPendingInvoice(address); + if (pos) return json({ + tag: 'payRequest', + callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}&pos=${pos.hash}`, + minSendable: pos.sat * 1000, + maxSendable: pos.sat * 1000, + metadata: lnurlMeta(address), + commentAllowed: 0, + allowsNostr: false, + }); return json({ tag: 'payRequest', callback: `${PUBLIC_BASE}/lnurlp/${name}/cb?domain=${encodeURIComponent(domain)}`, @@ -1156,6 +1333,15 @@ Bun.serve({ const rec = state.names[key]; if (!rec) return json({ status: 'ERROR', reason: 'unknown address' }, 404); const msat = parseInt(url.searchParams.get('amount') || '0', 10); + // a POS sale: hand back the invoice the terminal minted, nothing else + const posHash = url.searchParams.get('pos'); + if (posHash) { + const v = posInvoices()[posHash]; + if (!v || v.key !== key || v.paid || Date.now() - v.created > POS_INVOICE_TTL) + return json({ status: 'ERROR', reason: 'that sale has expired — tap again' }, 410); + if (msat !== v.sat * 1000) return json({ status: 'ERROR', reason: 'amount must match the sale' }, 400); + return json({ pr: v.bolt11, routes: [] }); + } if (!msat || msat < 1000) return json({ status: 'ERROR', reason: 'amount too small' }, 400); const sat = Math.floor(msat / 1000); const zap = url.searchParams.get('nostr') || null; @@ -1513,6 +1699,27 @@ Bun.serve({ return json({ error: 'not found' }, 404); }, + // POS terminals: one socket per device. `subscribe` names the sale it is + // waiting on; a `payment` message arrives when that sale settles. + // Heartbeats and anything else are ignored. + websocket: { + open(ws) { + posSockets.add(ws); + ws.send(JSON.stringify({ type: 'connected', data: ws.data.key })); + }, + message(ws, raw) { + let m; + try { m = JSON.parse(String(raw)); } catch { return; } + if (m?.type !== 'subscribe') return; + const id = String(m.data?.id || m.data || ''); + const v = posInvoices()[id]; + if (!v || v.key !== ws.data.key) return; + ws.data.hash = id; + // a sale that settled between the mint and this subscribe is still a sale + if (v.paid) { try { ws.send(posPaymentMsg(id, v)); } catch {} } + }, + close(ws) { posSockets.delete(ws); }, + }, }); settleLoop().catch((e) => log('settle loop died:', e.message)); diff --git a/src/features/names.js b/src/features/names.js index bd4ab4d..dc3426e 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -436,6 +436,10 @@ export function namesFeature(ctx) { h('div', { class: 'addr-box break', style: 'font-size:10px' }, code), copyBtn(code, t('namesZapCodeCopy'))) : null; })(), + h('details', { class: 'small faint' }, + h('summary', {}, t('namesPos')), + h('p', { style: 'margin:4px 0' }, t('namesPosHow')), + posSection(st)), h('details', { class: 'small faint' }, h('summary', {}, t('namesOwnNode')), h('p', { style: 'margin:4px 0' }, t('namesOwnNodeMenuHow')), @@ -452,6 +456,27 @@ export function namesFeature(ctx) { claimForm(false)); } + // A coinos POS terminal (coinos-pos) takes payments to this name with a + // bearer token the registrar mints for the owner: the terminal can ring up + // sales and see them settle, never spend. Shown once, never stored — the + // token goes straight into the terminal's config at coinos.io/pos. + function posSection(st) { + const tok = ui.posToken; + const body = () => ({ name: st.name, domain: st.domain || DOMAIN() }); + return h('div', { class: 'col', style: 'gap:6px' }, + tok ? h('div', { class: 'addr-box break', style: 'font-size:10px' }, tok) : null, + h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, + h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { ui.posToken = (await post('/pos/token', 'POST', body())).token; render(); } + catch (e) { toast(e.message); } + } }, t('namesPosCreate')), + tok ? copyBtn(tok, t('namesPosCopy')) : null, + h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { await post('/pos/token', 'DELETE', body()); ui.posToken = null; toast(t('namesPosRevoked')); render(); } + catch (e) { toast(e.message); } + } }, t('namesPosRevoke')))); + } + // The Receive tab's default pane: your name, big and scannable. function namePane(seg) { const st = load(); diff --git a/src/i18n.js b/src/i18n.js index 1885d88..1b74c00 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -732,6 +732,12 @@ const STR = { receiveNameTab: 'Spending', namesCustom: 'Change username', namesCustomHow: 'Pick a new name for your payment address. Your old address stops working.', + namesPos: 'Point of sale', + namesPosHow: 'A coinos POS terminal takes payments to this address. Create a token and enter it as the API token when you flash the terminal at coinos.io/pos. The terminal can ring up sales and see them paid; it can never spend.', + namesPosCreate: 'Create POS token', + namesPosCopy: 'Copy token', + namesPosRevoke: 'Revoke tokens', + namesPosRevoked: 'POS tokens revoked', namesDesc: 'Claim your payment address — like you@coinos.io. People can pay you just by typing it, no codes to copy or QR to scan.', namesPlaceholder: 'username', namesResolving: 'Looking up {name}…', -- cgit v1.2.3