diff options
| author | Adam Soltys <[email protected]> | 2026-10-16 13:05:15 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-16 13:05:15 -0700 |
| commit | 73bffd93caefa53af3a5d28e70059a3cfbe68939 (patch) | |
| tree | 1f27599810674431205bb53e62416684261c2783 /names/server.js | |
| parent | 294a49598e43e3e6f9e34f0246bbea41837359f9 (diff) | |
| download | coinosv3-73bffd93caefa53af3a5d28e70059a3cfbe68939.tar.gz coinosv3-73bffd93caefa53af3a5d28e70059a3cfbe68939.zip | |
The server-side services moved out: names, nwcpush, blossom and bridge now live in coinosv3-server. The coinos-server agent on desk extracted them (2026-10-16, after the names.json truncation of 10-15: the registrar is on SQLite there, the drift that had only ever existed on cs is committed there, and names/nwcpush/blossom on cs run from a clone of that repo, deployed by git push through its own hook). Removed here with the tests that went with them (registry-ownership-test, deepl-translate.test); the README points at the new repository and AGENTS.md says how service changes deploy now (git push from ~/coinosv3-server, never scp). feed/ and vmedia/ stay for the moment — they are in coinosv3-server too but still deploy from ~/public-feed and ~/vmedia the old way — with public-feed-cache-test. sync-blob-test spawns blossom from the sibling coinosv3-server checkout (COINOSV3_SERVER overrides) and says so when it isn't there; it passes.HEADmaster
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'names/server.js')
| -rw-r--r-- | names/server.js | 1840 |
1 files changed, 0 insertions, 1840 deletions
diff --git a/names/server.js b/names/server.js deleted file mode 100644 index 77d758c..0000000 --- a/names/server.js +++ /dev/null @@ -1,1840 +0,0 @@ -// BIP-353 name registrar for halwallet.app — gives every hal user a -// ₿[email protected] payment address. -// -// Auth on every write is NIP-98 (HTTP Auth, kind 27235). -// -// A name is a DNSSEC-signed TXT record at -// <name>.user._bitcoin-payment.halwallet.app -// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address, -// extensible later with lno= etc). This service is the write path: it -// validates a claim signed by the wallet's nostr key, enforces first-come -// ownership by that key, and mirrors the record into Cloudflare DNS. -// -// WHAT THIS SERVICE LEARNS: name ↔ nostr pubkey ↔ payment URI. All of it is -// public by construction (DNS is public). It cannot spend anything. -// -// Deliberately dumb: one JSON state file, no accounts, no email. - -import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { verifyEvent, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; -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, randomBytes } from 'node:crypto'; -import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; -import { lnBackend } from '../bridge/ln.js'; -import { ArkManager } from '../src/ark/manager.js'; -import { fetchPreview } from './preview.js'; -import { translate, translateDeepl, languages } from './translate.js'; -import { decodeAddress } from '../src/ark/proto.js'; -import { decodeNoffer } from '../src/noffer.js'; -import { decodeBolt11 } from '../src/ark/lightning.js'; -import { decodeOffer } from '../src/bolt12.js'; -import { registrationIdentity } from './ownership.js'; - -const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG - || join(import.meta.dir, 'config.json'), 'utf8')); -// { port, domains: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile, -// ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers, -// forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet -// that forwards settled offer payments to the name's ark address } - -const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims -const PUBLIC_BASE = CFG.publicBase || 'https://names.coinos.io'; -const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } }; -const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json'); -const TTL = 300; // record TTL: BIP-353 wallets cache by this - -const log = (...a) => console.log(new Date().toISOString(), ...a); - -// names that must never be claimable -const RESERVED = new Set([ - 'admin', 'administrator', 'root', 'hal', 'halwallet', 'www', 'mail', 'help', - 'support', 'info', 'pay', 'payments', 'wallet', 'staging', 'names', 'api', - 'user', 'users', 'coinos', 'nostr', 'ark', 'test', 'dev', 'security', 'abuse', -]); -const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/; -// What a NEW name may be: letters and digits, 2–24 — coinos v2's rule -// (/^[\p{L}\p{N}]{2,24}$/u there), held to ASCII because a name is a DNS -// label here. NAME_RE stays the wider test for names that already exist -// (a dozen claimed before the rule: bitcoin-education, barefoot.strider…), -// v2 migrations carrying their own name, and npub-shaped defaults. -const NEW_NAME_RE = /^[a-z0-9]{2,24}$/; -const NEW_NAME_ERR = 'names can only have letters and numbers (2–24)'; - -let state = (() => { - try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return { names: {} }; } -})(); -function persist() { - mkdirSync(dirname(STATE), { recursive: true }); - const tmp = STATE + '.tmp'; - writeFileSync(tmp, JSON.stringify(state)); - renameSync(tmp, STATE); -} - -// --------------------------------------------------------------------------- -// Cloudflare DNS -// --------------------------------------------------------------------------- - -const CF = 'https://api.cloudflare.com/client/v4'; -const cfHeaders = { - 'X-Auth-Email': CFG.cfEmail, 'X-Auth-Key': CFG.cfKey, 'content-type': 'application/json', -}; -const recordName = (name, domain) => `${name}.user._bitcoin-payment.${domain}`; - -async function cfWrite(name, domain, uri, existingId) { - // TXT character-strings cap at 255 bytes; BIP-353 readers concatenate the - // chunks in order, so a long ark+lno URI just spans several quoted strings. - const zoneId = DOMAINS[domain]?.zoneId; - if (!zoneId) throw new Error('unknown domain'); - const chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' '); - const body = JSON.stringify({ - type: 'TXT', name: recordName(name, domain), content: chunks, ttl: TTL, - }); - const url = existingId - ? `${CF}/zones/${zoneId}/dns_records/${existingId}` - : `${CF}/zones/${zoneId}/dns_records`; - const r = await fetch(url, { method: existingId ? 'PUT' : 'POST', headers: cfHeaders, body }); - const j = await r.json(); - if (!j.success) throw new Error('dns write failed: ' + JSON.stringify(j.errors).slice(0, 200)); - return j.result.id; -} -async function cfDelete(domain, recordId) { - const zoneId = DOMAINS[domain]?.zoneId; - if (!zoneId) throw new Error('unknown domain'); - const r = await fetch(`${CF}/zones/${zoneId}/dns_records/${recordId}`, { - method: 'DELETE', headers: cfHeaders, - }); - const j = await r.json(); - if (!j.success) throw new Error('dns delete failed'); -} - -// --------------------------------------------------------------------------- -// bolt12 offers + the settle forwarder -// --------------------------------------------------------------------------- -// Each name gets a static BOLT 12 offer on the ASP's CLN, published in the -// DNS record as lno=. Offer payments land on the ASP node, so a forwarder -// pushes each settled payment on to the name's ark address from a small -// float wallet (an ordinary arkoor send — free). Custody window: the seconds -// between LN settle and the ark send. The float must be topped up by the -// operator; LN income accrues on the node as the offset. - -// --------------------------------------------------------------------------- -// LNURL-pay: the same address, payable by wallets that have never heard of -// DNS payment instructions (which is most of them). -// -// Two ways to produce the invoice, preferring the one that touches no funds: -// 1. ASK THE RECIPIENT. If their wallet published a CLINK offer key we send -// it a kind-21001 request over nostr; their wallet (or its service -// worker) mints an invoice against their own balance and we just hand it -// back. Nothing passes through us. -// 2. MINT HERE. Otherwise our node issues the invoice and the settle -// forwarder pushes the money on to their address, exactly like the -// BOLT 12 offer path — custodial for the seconds in between. -const OFFER_KIND = 21001; -const LNURL_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol']; -const pool = new SimplePool(); - -// Our own nostr identity: used to ask recipients for invoices, and to sign -// NIP-57 zap receipts for payments we mint ourselves. -if (!state.serviceSk) { state.serviceSk = Buffer.from(generateSecretKey()).toString('hex'); persist(); } -const SERVICE_SK = Uint8Array.from(Buffer.from(state.serviceSk, 'hex')); -const SERVICE_PK = getPublicKey(SERVICE_SK); - -// --------------------------------------------------------------------------- -// Welcome DM — every first-time registrant gets a NIP-17 DM from Adam. -// config: welcome: { sk: '<hex privkey>', message: '…' }. The registration is -// the one moment we reliably know a new user's npub, so it doubles as the -// "new user" signal. One per pubkey, ever (state.welcomed). -// --------------------------------------------------------------------------- - -const WELCOME = CFG.welcome && /^[0-9a-f]{64}$/.test(CFG.welcome.sk || '') ? { - sk: Uint8Array.from(Buffer.from(CFG.welcome.sk, 'hex')), - message: CFG.welcome.message || 'Welcome to coinos!', -} : null; - -async function inboxRelaysOf(pubkey) { - try { - const evs = await pool.querySync(LNURL_RELAYS, { kinds: [10002, 10050], authors: [pubkey] }, { maxWait: 4000 }); - const newest = (kind) => evs.filter((e) => e.kind === kind).sort((a, b) => b.created_at - a.created_at)[0]; - const dm = newest(10050); - if (dm) { const r = dm.tags.filter((t) => t[0] === 'relay' && t[1]).map((t) => t[1]); if (r.length) return r.slice(0, 4); } - const rl = newest(10002); - if (rl) return rl.tags.filter((t) => t[0] === 'r' && (!t[2] || t[2] === 'read')).map((t) => t[1]).slice(0, 4); - } catch {} - return []; -} - -async function sendWelcome(pubkey, linkedKeys = []) { - if (!WELCOME) return; - state.welcomed ||= {}; - // One welcome per PERSON, not per key: a Google sign-up claims twice — - // the wallet key takes the placeholder name, then the real npub takes - // its own with the wallet key as manager — and greeting each pubkey put - // two identical DMs in the same inbox. - const keys = [pubkey, ...linkedKeys].filter((k) => /^[0-9a-f]{64}$/.test(k || '')); - const seen = keys.some((k) => state.welcomed[k]); - for (const k of keys) state.welcomed[k] ||= Date.now(); - persist(); - if (seen) return; - try { - // wrapManyEvents returns [sender-copy, recipient] — publish ONLY the - // recipient's wrap. A sender copy per registrant flooded Adam's own - // clients with an endless stream of his own welcome message. - const [, toPeer] = wrapManyEvents(WELCOME.sk, [{ publicKey: pubkey }], WELCOME.message); - const inbox = await inboxRelaysOf(pubkey); - const relays = [...new Set([...inbox, ...LNURL_RELAYS])]; - // Count acknowledgements, not attempts: a socket that died under the - // pool (the home uplink drops every connection at once) swallows a - // publish without a word, and four welcomes on 2026-10-07 went nowhere - // while the log said "sent". Nobody heard it → drop those sockets and - // try again on fresh ones, then say what really happened. - let acked = await publishAcked(relays, toPeer); - for (let i = 1; !acked.length && i <= 3; i++) { - try { pool.close(relays); } catch {} - await new Promise((r) => setTimeout(r, 5000 * i)); - acked = await publishAcked(relays, toPeer); - } - if (acked.length) log(`welcome DM sent to ${pubkey.slice(0, 12)} (${acked.join(', ')})`); - else { - for (const k of keys) delete state.welcomed[k]; persist(); // undelivered: the next claim may try again - log(`welcome DM to ${pubkey.slice(0, 12)} FAILED: no relay accepted it`); - } - } catch (e) { - log('welcome DM failed: ' + e.message); - } -} - -// Publish and return the relays that answered OK within the time limit. -async function publishAcked(relays, ev, ms = 10000) { - const timeout = new Promise((_, j) => setTimeout(() => j(new Error('timeout')), ms)); - const res = await Promise.allSettled(pool.publish(relays, ev).map((p) => Promise.race([p, timeout]))); - return relays.filter((_, i) => res[i].status === 'fulfilled').map((r) => r.replace(/^wss:\/\//, '')); -} - -const lnurlMeta = (address) => JSON.stringify([ - ['text/plain', `Paying ${address}`], - ['text/identifier', address], -]); - -// Ask the recipient's own CLINK service for an invoice (kind 21001). -// dest: { pubkey, relays, offerId } — a hal wallet listening on our relays, -// or an external service (Lightning.Pub beside the user's own node) on the -// relay its noffer names. -function requestInvoiceFromWallet(dest, { amountSat, description, zap }) { - return new Promise((resolve) => { - let done = false; - const finish = (v) => { if (!done) { done = true; try { sub.close(); } catch {} resolve(v); } }; - const key = nip44.getConversationKey(SERVICE_SK, dest.pubkey); - const payload = { offer: dest.offerId, amount_sats: amountSat }; - if (description) payload.description = description.slice(0, 100); - if (zap) payload.zap = zap; - const req = finalizeEvent({ - kind: OFFER_KIND, created_at: Math.floor(Date.now() / 1000), - tags: [['p', dest.pubkey], ['clink_version', '1']], - content: nip44.encrypt(JSON.stringify(payload), key), - }, SERVICE_SK); - const sub = pool.subscribeMany(dest.relays, - { kinds: [OFFER_KIND], '#e': [req.id], since: Math.floor(Date.now() / 1000) - 5 }, - { - onevent: (ev) => { - try { - const body = JSON.parse(nip44.decrypt(ev.content, key)); - if (body.bolt11) return finish(body.bolt11); - } catch {} - }, - }); - Promise.allSettled(pool.publish(dest.relays, req)).catch(() => {}); - setTimeout(() => finish(null), 6000); // services that aren't listening - }); -} - -// Where CLINK invoice requests for this name go. An external noffer the -// owner registered wins over the hal wallet's own offer key — pointing the -// name at their node is an explicit choice. -const clinkDest = (rec) => (rec?.noffer - ? { pubkey: rec.noffer.pubkey, relays: [...new Set([rec.noffer.relay, ...LNURL_RELAYS])], offerId: rec.noffer.offerId || '' } - : rec?.offerPk ? { pubkey: rec.offerPk, relays: LNURL_RELAYS, offerId: 'zap_default' } : null); - -// An invoice a CLINK service hands back is only usable if it demands exactly -// the sats owed on the right network — paying (or relaying) one on trust -// would let a hostile service name any amount. -// An invoice a payer can actually pay: the right amount, the right network, -// and not about to expire — an Ark server answers a payment hash it already -// holds with THAT invoice, and a wallet whose receive counter slipped once -// handed back a days-old one; the payer saw "invoice expired". -function usableInvoice(pr, sat, network = null) { - try { - const dec = decodeBolt11(pr); - if (dec.amountMsat !== BigInt(sat) * 1000n) return false; - if (network && dec.network !== network) return false; - if (dec.expiresAt && dec.expiresAt < Date.now() + 60_000) return false; - return true; - } catch { return false; } -} - -const ln = CFG.ln ? lnBackend(CFG.ln) : null; -// Optional mutinynet CLN — lets staging sell (play-money) hats. Nothing else -// uses it: names, offers and forwards remain mainnet affairs. -const lnMut = CFG.lnMut ? lnBackend(CFG.lnMut) : null; - -// -- routing-fee quotes (the /lnquote endpoint) ------------------------------ - -// Ark locks are sat-granular and the locked surplus IS xpay's maxfee, which -// binds hard — a 9 msat route under a 0 budget fails outright (verified live: -// "Could not find route without excessive cost", then the +1-sat retry paid). -// So sub-sat fees round UP: the honest minimum a client can bring is 1 sat. -// Making those truly free needs the ASP to bring the msats itself (captaind -// patch: floor max_routing_fee at 1 sat), not a rounder-down here. -const quoteSat = (msat) => Math.ceil(msat / 1000); - -const _nodeIds = new Map(); -async function ourNodeId(lnq = ln) { - if (!_nodeIds.has(lnq)) _nodeIds.set(lnq, (await lnq.call('getinfo')).id); - return _nodeIds.get(lnq); -} - -// Ask askrene (the router xpay itself uses) what delivering amountMsat to -// dest would cost from here. The maxfee passed in is only the SEARCH cap — -// generous on purpose, since the user sees and approves the quoted fee -// before anything is locked. -async function routeFeeMsat(our, dest, amountMsat, finalCltv, lnq = ln) { - const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05)); - const r = await lnq.call('getroutes', { - source: our, destination: dest, amount_msat: amountMsat, - // localchans FIRST: it re-adds our channels with their advertised fees, - // so sourcefree must come after it to zero our own hops (verified live — - // the other order quotes 1 sat on a direct-peer payment). - layers: ['auto.localchans', 'auto.sourcefree'], - maxfee_msat: cap, final_cltv: finalCltv, - }); - if (!r.routes || !r.routes.length) throw new Error('no route found'); - const sent = r.routes.reduce((s, rt) => s + Number(rt.path[0].amount_msat), 0); - const delivered = r.routes.reduce((s, rt) => s + Number(rt.amount_msat), 0); - return Math.max(0, sent - delivered); -} -let fwd = null; // the float ark wallet -if (CFG.forwarder?.mnemonic) { - const account = HDKey.fromMasterSeed(mnemonicToSeedSync(CFG.forwarder.mnemonic)).derive("m/86'/0'/9'"); - fwd = await new ArkManager({ - account, - storage: { - load: () => state.fwdArk || null, - save: (s) => { state.fwdArk = s; persist(); }, - }, - arkUrl: CFG.forwarder.ark, esploraUrl: CFG.forwarder.esplora, network: CFG.forwarder.network || 'mainnet', - }).init(); - // Read the mailbox before reporting: a top-up sent while we were down (or - // between syncs) lives there, and balance() only counts what state has seen. - await fwd.sync().catch(() => {}); - log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`); -} - -async function makeOffer(address) { // "name@domain" - if (!ln) return null; - const o = await ln.call('offer', { amount: 'any', description: address }); - return { offerId: o.offer_id, bolt12: o.bolt12 }; -} - -const arkParamOf = (uri) => { - const m = String(uri).match(/[?&]ark=([^&]+)/); - return m ? decodeURIComponent(m[1]) : null; -}; - -// A float arkoor delivery is indistinguishable from any other ark receive on -// the wallet's side (the mailbox protocol carries no memo), so remember each -// LN settlement we forwarded that way: the wallet asks /lnreceipts and -// matches by amount + delivery time to put the ⚡ on the right history rows. -// Bounded per name; entries age out on the invoice-retention horizon. Only -// float deliveries are recorded — a viaClink delivery runs the wallet's own -// LN-receive flow, which tags itself. -function recordLnReceipt(name, sat, hash) { - const all = state.lnReceipts ||= {}; - const cutoff = Date.now() - 7 * 86400_000; - all[name] = [...(all[name] || []).filter((r) => r.ts > cutoff), - { sat, ts: Date.now(), ...(hash ? { hash } : {}) }].slice(-50); -} - -// Deliver a settled payment to the name's owner. Two ways, and we try the -// one that works while they're asleep first: -// 1. an arkoor send from the float — instant, free, needs no cooperation -// 2. ask their wallet for an invoice and pay it over Lightning — needs no -// float at all, but only works while something of theirs is listening -// Failing both, it queues and retries; nothing is ever dropped silently. -async function forward(name, sat, hash) { - const rec = state.names[name]; - const dest = rec && arkParamOf(rec.uri); - const clink = clinkDest(rec); - if (!dest && !clink) { log(`forward: nowhere to send ${sat} sat for ${name}`); return; } - - const viaFloat = async () => { - if (!dest || !fwd || fwd.balance().spendableSat < sat) return false; - await fwd.send(dest, sat); - recordLnReceipt(name, sat, hash); - persist(); - log(`forwarded ${sat} sat to ${name}`); - return true; - }; - const viaClink = async () => { - if (!clink || !ln) return false; - const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: `${name} payment` }); - if (!pr) return false; - if (!usableInvoice(pr, sat, 'mainnet')) { - log(`forward: ${name}'s service returned an unusable invoice — refusing to pay it`); - return false; - } - await ln.pay(pr, { maxfeeSat: Math.max(2, Math.ceil(sat * 0.005)) }); - log(`delivered ${sat} sat to ${name} over lightning (no float needed)`); - return true; - }; - // A name pointed at its owner's own node delivers there first; the float - // is the fallback. Everyone else gets the instant free arkoor send first. - for (const step of rec?.noffer ? [viaClink, viaFloat] : [viaFloat, viaClink]) { - if (await step()) return; - } - 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) { - try { - const zapReq = JSON.parse(pending.zap); - const tags = [ - ...zapReq.tags.filter((t) => t[0] === 'p' || t[0] === 'e' || t[0] === 'a'), - ['P', zapReq.pubkey], - ['bolt11', pending.bolt11], - ['description', pending.zap], - ]; - if (inv.payment_preimage) tags.push(['preimage', inv.payment_preimage]); - const receipt = finalizeEvent({ kind: 9735, created_at: Math.floor(Date.now() / 1000), tags, content: '' }, SERVICE_SK); - const relayTag = zapReq.tags.find((t) => t[0] === 'relays'); - const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...LNURL_RELAYS])].slice(0, 8); - await Promise.allSettled(pool.publish(relays, receipt)); - log(`published a zap receipt for ${pending.key}`); - } catch (e) { log('zap receipt failed: ' + e.message); } -} - -// --------------------------------------------------------------------------- -// Hats — cosmetic supporter hats, shown above the buyer's avatar in coinos. -// Ownership is a paid CLN invoice; the registry here is what clients trust -// (a kind-0 field would be self-asserted). Money stays on the node: it IS -// the support. The crown is Adam's and is not for sale. -// --------------------------------------------------------------------------- - -const HAT_ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7'; -const HAT_PRICES = { - beanie: 21, trucker: 2100, cowboy: 21000, fedora: 21000, - bowler: 210000, top: 210000, wizard: 2100000, -}; - -// Mutinynet runs a parallel hat economy (invoices minted on the mutinynet -// CLN, grants in their own registry) so play-money purchases can exercise -// the whole flow on staging without ever buying a hat mainnet users see. -const hatNetOf = (v) => (v === 'mutinynet' ? 'mutinynet' : 'mainnet'); -const hatStore = (net) => (net === 'mutinynet' ? (state.hatsMut ||= {}) : (state.hats ||= {})); -const hatLn = (net) => (net === 'mutinynet' ? lnMut : ln); - -function hatRec(pk, net = 'mainnet') { - const r = hatStore(net)[pk]; - const owned = r ? [...(r.owned || [])] : []; - if (pk === HAT_ADMIN_PK && !owned.includes('crown')) owned.push('crown'); - const equipped = r ? (r.equipped || null) : (pk === HAT_ADMIN_PK ? 'crown' : null); - return { owned, equipped }; -} - -function grantHat(pk, hat, net = 'mainnet', { equip = true } = {}) { - const r = hatStore(net)[pk] ||= { owned: [], equipped: null }; - if (!r.owned.includes(hat)) r.owned.push(hat); - // a fresh purchase goes straight on the head; a GIFT only crowns a bare - // one — knocking off a hat someone paid for is no way to say thanks - if (equip || !r.equipped) r.equipped = hat; -} - -async function settleLoop() { - if (!ln || !fwd) return; - state.lastPayIndex = state.lastPayIndex || 0; - for (;;) { - let inv; - try { - inv = await ln.call('waitanyinvoice', { lastpay_index: state.lastPayIndex, timeout: 120 }); - } catch (e) { - if (!/timed out|Timed out/i.test(e.message)) await new Promise((r) => setTimeout(r, 5000)); - continue; - } - state.lastPayIndex = inv.pay_index || state.lastPayIndex; - persist(); - // hat purchases settle here too — grant and keep the sats (that's the point) - const hatPending = state.hatInvoices && state.hatInvoices[inv.payment_hash]; - if (hatPending) { - grantHat(hatPending.pubkey, hatPending.hat, hatNetOf(hatPending.net)); - delete state.hatInvoices[inv.payment_hash]; - persist(); - log(`hat sold: ${hatPending.hat} (${hatNetOf(hatPending.net)}) to ${hatPending.pubkey.slice(0, 12)}`); - continue; - } - const offerId = inv.local_offer_id; - let name = offerId && Object.keys(state.names).find((n) => state.names[n].offerId === offerId); - // custom offers (a memo'd bolt12 the user minted for e.g. a mining pool) - if (!name && offerId && state.offers && state.offers[offerId]) name = state.offers[offerId].key; - // LNURL invoices we minted: recorded against their payment hash - const pending = state.invoices && state.invoices[inv.payment_hash]; - if (!name && pending) name = pending.key; - if (!name) { - // Usually genuinely not ours (the ASP's and boltz's invoices settle on - // this node too) — but log it, so a lost mapping is an audit-grep away - // instead of an archaeology project. - log(`settle without a name: ${inv.payment_hash} ${inv.amount_received_msat || ''}`); - continue; - } - 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`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - // this registrar's own network, so the notifier delivers only to the - // payee's devices on it (a seed makes the same pubkey on both nets) - body: JSON.stringify({ token: CFG.push.token, pubkey: state.names[name].pubkey, amountSat: sat, net: CFG.forwarder?.network || 'mainnet' }), - }).catch(() => {}); - } - // ...and POST to their webhook, if they registered one - if (state.names[name] && state.names[name].webhook) { - queueWebhook(name, { - type: 'payment', - address: name, - amount: sat, - hash: inv.payment_hash, - ...(inv.payment_preimage ? { preimage: inv.payment_preimage } : {}), - ...(inv.description ? { memo: String(inv.description).slice(0, 640) } : {}), - ...(inv.bolt11 ? { bolt11: inv.bolt11 } : {}), - ts: Date.now(), - }); - } - if (pending) { - if (pending.zap) publishZapReceipt(pending, inv).catch(() => {}); - delete state.invoices[inv.payment_hash]; - persist(); - } - try { - await forward(name, sat, inv.payment_hash); - } catch (e) { - log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); - state.pending = state.pending || []; - state.pending.push({ name, sat, hash: inv.payment_hash, ts: Date.now() }); - persist(); - } - } -} -// -- float self-top-up ------------------------------------------------------- -// The float drains toward recipients while LN settlements pile up on the CLN -// — money that can't reach the float by a normal payment, because the top-up -// invoice lives on the very node holding it (a self-payment the hold plugin -// never sees). The refill that works is a circular route: out one channel to -// a peer, straight back in on another, arriving as a real HTLC the ASP -// intercepts. Costs the peer's forwarding fee (well under 1%). -// chunkSat caps one attempt: an ASP receive is funded from the vtxo pool, -// and asking for more than its fresh buckets hold fails whole ("vtxo pool is -// empty") where two smaller asks would land. Ticks accumulate to the target. -const TOPUP = { lowWaterSat: 100_000, targetSat: 500_000, chunkSat: 200_000, ...(CFG.floatTopup || {}) }; -let topupCoolUntil = 0; -async function autoTopupFloat() { - if (!fwd || !ln || Date.now() < topupCoolUntil) return; - const floatSat = fwd.balance().spendableSat; - const queuedSat = (state.pending || []).reduce((n, p) => n + p.sat, 0); - if (floatSat >= TOPUP.lowWaterSat && floatSat >= queuedSat) return; - topupCoolUntil = Date.now() + 10 * 60_000; // one attempt, then cool off - const info = await ln.call('getinfo'); - const chans = ((await ln.call('listpeerchannels')).channels || []) - .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected); - // the widest same-peer pair: out on one channel, back on the other - let pick = null; - for (const out of chans) { - for (const back of chans) { - if (out === back || out.peer_id !== back.peer_id) continue; - const cap = Math.min( - Math.floor((out.spendable_msat ?? 0) / 1000) - 10_000, - Math.floor((back.receivable_msat ?? 0) / 1000) - 10_000); - if (!pick || cap > pick.cap) pick = { out, back, cap }; - } - } - if (!pick || pick.cap < 20_000) { log('float top-up: no circular route capacity'); return; } - const amountSat = Math.min(Math.max(TOPUP.targetSat, queuedSat + TOPUP.lowWaterSat) - floatSat, pick.cap, TOPUP.chunkSat); - const a = await fwd.createLnInvoice(amountSat, 'float auto top-up'); - const dec = await ln.call('decode', { string: a.invoice }); - const gossip = await ln.call('listchannels', { short_channel_id: pick.back.short_channel_id }); - const ret = (gossip.channels || []).find((c) => c.source === pick.back.peer_id); - if (!ret) throw new Error('no gossip for the return channel'); - const amtMsat = amountSat * 1000; - const feeMsat = Number(ret.base_fee_millisatoshi) + Math.ceil((amtMsat * ret.fee_per_millionth) / 1e6); - const finalDelay = dec.min_final_cltv_expiry || 18; - await ln.call('sendpay', { - route: [ - { id: pick.out.peer_id, channel: pick.out.short_channel_id, amount_msat: amtMsat + feeMsat, delay: finalDelay + ret.delay, style: 'tlv' }, - { id: info.id, channel: pick.back.short_channel_id, amount_msat: amtMsat, delay: finalDelay, style: 'tlv' }, - ], - payment_hash: a.paymentHash, bolt11: a.invoice, payment_secret: dec.payment_secret, - }); - // the HTLC sits held until the float wallet claims — drive it through - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 2000)); - await fwd.sync().catch(() => {}); - if (fwd.balance().spendableSat > floatSat) break; - } - const after = fwd.balance().spendableSat; - if (after > floatSat) { - log(`float self-top-up: +${after - floatSat} sat via circular route (fee ${Math.ceil(feeMsat / 1000)} sat) — float now ${after}`); - for (const p of state.pending || []) p.next = 0; // the queue can go again now - persist(); - topupCoolUntil = 0; - } else { - log('float self-top-up did not settle — cooling off'); - } -} - -// -- float coin upkeep ------------------------------------------------------- -// The float's coins arrive mostly from the ASP's vtxo pool, whose short -// lifetimes (~3 days) expire long before the app's monthly renewal cadence — -// and an expired coin forwarded onward strands money the recipient's wallet -// refuses (a 100k forward died this way on 2026-08-24). Nothing here runs the -// app's upkeep layer, so renew explicitly: refresh any coin inside a day of -// its expiry into a fresh round output. -let floatRefreshAt = 0; -const REFRESH_MARGIN_BLOCKS = 144; -async function floatUpkeep() { - if (!fwd || Date.now() - floatRefreshAt < 30 * 60_000) return; - const spendables = (fwd.state?.vtxos || []).filter((v) => v.state === 'spendable'); - // never start a round while another action is still in flight - if (!spendables.length || (fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; - const tip = await fwd.chain.tipHeight(); - const expiring = spendables.filter((v) => - v.expiryHeight && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330); - if (!expiring.length) return; - floatRefreshAt = Date.now(); - const sat = expiring.reduce((n, v) => n + v.amountSat, 0); - log(`float upkeep: refreshing ${expiring.length} coins (${sat} sat) nearing expiry`); - await fwd.refresh(expiring.map((v) => v.id)); - log(`float upkeep: refresh round complete — float now ${fwd.balance().spendableSat} sat`); -} - -// retry queued forwards (e.g. after the float is topped up). Each failure -// backs off exponentially (1m → 1h cap): a stuck forward must not fire an -// offer request — and push-wake the recipient's devices — every minute -// forever. -setInterval(async () => { - if (!fwd) return; - // always sync: it reads ark mail AND drives pending actions (a board - // waiting out its confirmations, an ln-recv mid-claim, a queued top-up) - await fwd.sync().catch(() => {}); - await autoTopupFloat().catch((e) => log('float self-top-up failed: ' + e.message)); - await floatUpkeep().catch((e) => log('float upkeep failed: ' + e.message)); - const q = state.pending || []; - if (!q.length) return; - const still = []; - for (const p of q) { - if (p.next && Date.now() < p.next) { still.push(p); continue; } - try { await forward(p.name, p.sat, p.hash); } - catch (e) { - log(`forward retry failed for ${p.name} (${p.sat} sat, try ${(p.tries || 0) + 1}): ${e.message}`); - p.tries = (p.tries || 0) + 1; - p.next = Date.now() + Math.min(3_600_000, 60_000 * 2 ** Math.min(p.tries - 1, 6)); - still.push(p); - } - } - state.pending = still; - persist(); -}, 60_000); - -// --------------------------------------------------------------------------- -// claims -// --------------------------------------------------------------------------- - -// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method -// and body hash, so a captured header is useless anywhere else. -async function checkNip98(req, url, bodyText) { - const h = req.headers.get('authorization') || ''; - const m = h.match(/^Nostr\s+(.+)$/i); - if (!m) return { error: 'missing Nostr authorization' }; - let evt; - try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; } - if (evt.kind !== 27235) return { error: 'auth must be kind 27235' }; - if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' }; - if (!verifyEvent(evt)) return { error: 'bad signature' }; - const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1]; - if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' }; - let got; - try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; } - if (got.pathname !== url.pathname) return { error: 'url mismatch' }; - if (bodyText) { - const want = createHash('sha256').update(bodyText).digest('hex'); - if (tag('payload') !== want) return { error: 'payload hash mismatch' }; - } - return { pubkey: evt.pubkey }; -} - -// npub-prefix names are the free default everyone gets, so they must not be -// squattable: a name that looks like one may only be claimed by that very -// identity — or by a key that identity nominated as its manager (the wallet -// key, which is always available, even offline and in the service worker). -const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null); - -// coinos.io names that belong to existing coinos users are reserved for -// them until the migration gives those users a way to claim their own. -async function takenByCoinosUser(domain, name, claimantPubkey = null) { - if (domain !== 'coinos.io') return false; - try { - const r = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`); - if (r.status !== 200) return false; - const u = await r.json().catch(() => null); - if (!u) return false; - // An existing coinos.io account owns its name. The `migrated` flag once - // gated this and proved meaningless (poppy got squatted through it) — - // identity is the proof: only a claim SIGNED by the old account's own - // nostr key may take the name over. - return !(u.npub && claimantPubkey && u.npub === npubEncode(claimantPubkey)); - } catch { return true; } // can't verify → refuse rather than squat -} - -const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480 - && !/[\s"\\]/.test(u); - -// --------------------------------------------------------------------------- -// webhooks — POST-on-payment for a name (the coinos.io-style merchant notify) -// --------------------------------------------------------------------------- -// A name's owner registers a URL (and optional shared secret); every Lightning -// settlement for that name then POSTs a small JSON body there. Fires on the -// SETTLEMENT — the money has arrived on our node for that name — independent -// of how the onward ark delivery goes. -// -// The URL is attacker-supplied and we fetch it from inside our own network, -// so it is vetted hard: https only, no credentials, and every address the -// host resolves to must be public unicast (loopback, RFC1918, CGNAT/tailscale -// 100.64/10, link-local, multicast and their v6 kin all refused) — checked at -// registration AND before every delivery, since DNS can change between them. - -const privV4 = (ip) => { - const p = ip.split('.').map(Number); - if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true; - return p[0] === 0 || p[0] === 10 || p[0] === 127 - || (p[0] === 100 && p[1] >= 64 && p[1] <= 127) - || (p[0] === 169 && p[1] === 254) - || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) - || (p[0] === 192 && p[1] === 168) || (p[0] === 192 && p[1] === 0) - || (p[0] === 198 && (p[1] === 18 || p[1] === 19)) - || p[0] >= 224; -}; -const privAddr = (addr) => { - const a = addr.toLowerCase(); - if (a.includes(':')) { - const v4 = a.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (v4) return privV4(v4[1]); - return a === '::1' || a === '::' || /^f[cd]/.test(a) || /^fe[89ab]/.test(a) || /^ff/.test(a); - } - return privV4(a); -}; -async function vetWebhookUrl(raw) { - let u; - try { u = new URL(String(raw)); } catch { return 'invalid url'; } - if (u.protocol !== 'https:') return 'url must be https'; - if (u.username || u.password) return 'no credentials in the url'; - if (u.hostname.length > 253 || /^(localhost|.*\.(local|internal|lan|home|arpa))$/i.test(u.hostname)) return 'private host'; - try { - const { lookup } = await import('node:dns/promises'); - const addrs = await lookup(u.hostname, { all: true }); - if (!addrs.length) return 'host does not resolve'; - for (const a of addrs) if (privAddr(a.address)) return 'host resolves to a private address'; - } catch { return 'host does not resolve'; } - return null; -} - -// Delivery: immediate attempt, then 30s / 5min / 30min retries, then dropped. -// The queue is persisted so a restart finishes what it owed. -const WEBHOOK_BACKOFF = [0, 30_000, 300_000, 1_800_000]; -function queueWebhook(key, payload) { - const rec = state.names[key]; - if (!rec || !rec.webhook || !rec.webhook.url) return; - state.webhookQueue = state.webhookQueue || []; - state.webhookQueue.push({ key, payload, tries: 0, nextAt: Date.now() }); - if (state.webhookQueue.length > 500) state.webhookQueue.splice(0, state.webhookQueue.length - 500); - persist(); - drainWebhooks(); -} -let webhookDraining = false; -async function drainWebhooks() { - if (webhookDraining) return; - webhookDraining = true; - try { - for (;;) { - const q = state.webhookQueue || []; - const now = Date.now(); - const item = q.find((x) => x.nextAt <= now); - if (!item) break; - const rec = state.names[item.key]; - const hook = rec && rec.webhook; - if (!hook || !hook.url) { q.splice(q.indexOf(item), 1); persist(); continue; } - let outcome; - const bad = await vetWebhookUrl(hook.url); - if (bad) outcome = 'refused: ' + bad; - else { - try { - const ctl = new AbortController(); - const t = setTimeout(() => ctl.abort(), 10_000); - const res = await fetch(hook.url, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ ...item.payload, ...(hook.secret ? { secret: hook.secret } : {}) }), - redirect: 'error', - signal: ctl.signal, - }); - clearTimeout(t); - outcome = res.ok ? 'ok' : `http ${res.status}`; - } catch (e) { outcome = 'error: ' + (e.message || 'fetch failed').slice(0, 80); } - } - rec.webhook.last = { ts: Date.now(), result: outcome, hash: item.payload.hash }; - if (outcome === 'ok' || outcome.startsWith('refused')) { - q.splice(q.indexOf(item), 1); - log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: ${outcome}`); - } else { - item.tries += 1; - if (item.tries >= WEBHOOK_BACKOFF.length) { - q.splice(q.indexOf(item), 1); - log(`webhook ${item.key} ${item.payload.hash?.slice(0, 12)}: gave up after ${item.tries} tries (${outcome})`); - } else { - item.nextAt = Date.now() + WEBHOOK_BACKOFF[item.tries]; - log(`webhook ${item.key}: ${outcome} — retry ${item.tries}/${WEBHOOK_BACKOFF.length - 1}`); - } - } - persist(); - } - } finally { webhookDraining = false; } -} -setInterval(() => { drainWebhooks().catch(() => {}); }, 30_000); - -// crude per-IP limiter -const rate = new Map(); -const previews = new Map(); // url -> { meta, until } -// rateExempt: IPs (e.g. the operator's own, while testing sign-ups) the -// per-minute limits never apply to -const RATE_EXEMPT = new Set(CFG.rateExempt || []); -function rateOk(ip, limit = 10) { - if (RATE_EXEMPT.has(ip)) return true; - const now = Date.now(); - const arr = (rate.get(ip) || []).filter((t) => t > now - 60_000); - if (arr.length >= limit) return false; - arr.push(now); - rate.set(ip, arr); - if (rate.size > 1000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); } - return true; -} - -// --------------------------------------------------------------------------- -// HTTP -// --------------------------------------------------------------------------- - -const json = (body, status = 200) => { - if (status >= 400) log(`→ ${status} ${JSON.stringify(body).slice(0, 120)}`); - return new Response(JSON.stringify(body), { - status, - headers: { - 'content-type': 'application/json', - 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'content-type,authorization,x-deepl-key', - 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', - }, - }); -}; - -Bun.serve({ - port: CFG.port || 8798, - 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); - // Currency is the merchant's, not the token's: a new token in CAD moves - // the terminals already paired to this name to CAD as well. - const { currency } = posTokens()[token]; - for (const v of Object.values(posTokens())) if (v.key === key) v.currency = currency; - persist(); - return json({ token, name, domain, 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); - } - - // A link in a post, as the card its page describes for itself (Open - // Graph). Cached a day either way: a page with nothing to show is asked - // again only after an hour. Public data; the CDN may cache it too. - if (url.pathname === '/preview' && req.method === 'GET') { - if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429); - const target = (url.searchParams.get('url') || '').trim(); - if (!/^https?:\/\/\S{4,2000}$/.test(target)) return json({ error: 'bad url' }, 400); - const now = Date.now(); - let hit = previews.get(target); - if (!hit || now > hit.until) { - const meta = await fetchPreview(target).catch(() => null); - hit = { meta, until: now + (meta ? 24 : 1) * 3600_000 }; - previews.set(target, hit); - if (previews.size > 5000) previews.delete(previews.keys().next().value); - } - return new Response(JSON.stringify(hit.meta || {}), { - headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', - 'cache-control': 'public, max-age=' + (hit.meta ? 86400 : 3600) }, - }); - } - - // A post in another language, in the reader's (LibreTranslate behind a - // cache — see translate.js). POST { text, target, source? } → - // { text, source }; GET /translate/languages lists what it can do. - if (url.pathname === '/translate/languages' && req.method === 'GET') { - try { return new Response(JSON.stringify(await languages()), { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=3600' } }); } - catch { return json({ error: 'translation unavailable' }, 503); } - } - if (url.pathname === '/translate' && req.method === 'POST') { - if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429); - let body = null; - try { body = await req.json(); } catch {} - if (!body || typeof body.text !== 'string' || typeof body.target !== 'string') return json({ error: 'bad request' }, 400); - // a reader's own DeepL key rides in a header: passed on, never kept or logged - const dk = (req.headers.get('x-deepl-key') || '').trim(); - try { return json(dk ? await translateDeepl(body.text, body.target, dk) : await translate(body.text, body.target, body.source)); } - catch (e) { return json({ error: e.message || 'translation failed' }, e.status || 503); } - } - - if (url.pathname === '/health') { - return json({ - ok: true, domain: DOMAIN, names: Object.keys(state.names).length, - offers: !!ln, floatSat: fwd ? fwd.balance().spendableSat : null, - floatAddress: fwd ? fwd.address() : null, - pendingForwards: (state.pending || []).length, - }); - } - - // Routing-fee quote for an outgoing lightning payment. The wallet locks - // amount + this fee into its HTLC and the ASP hands the whole surplus to - // CLN as the routing budget, so the number returned here is exactly what - // the user pays — zero for invoices minted on our own node (every - // coinos-to-coinos payment) and for direct peers. Informational only: - // a wrong quote fails the payment (refunded in full) or overpays a route, - // it can never spend more than the user chose to lock. - if (url.pathname === '/lnquote' && req.method === 'GET') { - // network=mutinynet quotes from the staging ASP's CLN (the same node - // lnMut points at) so staging wallets get the quoted-route-fee model - // mainnet has, instead of a flat schedule - const lnq = (url.searchParams.get('network') || '') === 'mutinynet' ? lnMut : ln; - if (!lnq) return json({ error: 'no lightning backend' }, 503); - if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429); - try { - const invoice = (url.searchParams.get('invoice') || '').trim().toLowerCase(); - if (!invoice || invoice.length > 4000) return json({ error: 'invoice required' }, 400); - const dec = await lnq.call('decode', { string: invoice }); - if (dec.valid === false) return json({ error: 'invalid invoice' }, 400); - const dest = dec.payee || dec.invoice_node_id; - const amountMsat = Math.floor(Number(url.searchParams.get('amount_msat')) || 0) - || Number(dec.amount_msat || dec.invoice_amount_msat || 0); - if (!dest) return json({ error: 'no destination in invoice' }, 400); - if (!amountMsat || amountMsat < 0) return json({ error: 'amount required' }, 400); - const our = await ourNodeId(lnq); - if (dest === our) return json({ feeMsat: 0, feeSat: 0, direct: true }); - const finalCltv = dec.min_final_cltv_expiry || 18; - try { - const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv, lnq); - return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false }); - } catch (e) { - // A bolt12 invoice with blinded paths: its node id is a blinded - // key no router knows. Quote = route to a path's public entry - // node + the blinded segment's advertised payinfo fees. - const paths = dec.invoice_paths || []; - let best = null; - for (const p of paths) { - const pay = p.payinfo || {}; - const entry = p.first_node_id; - if (!entry) continue; - const f = (pay.fee_base_msat || 0) + Math.ceil(amountMsat * (pay.fee_proportional_millionths || 0) / 1_000_000); - try { - const toEntry = entry === our ? 0 : await routeFeeMsat(our, entry, amountMsat + f, finalCltv, lnq); - if (best == null || toEntry + f < best) best = toEntry + f; - } catch {} - } - if (best != null) return json({ feeMsat: best, feeSat: quoteSat(best), direct: false, blinded: true }); - // Private bolt11 recipient: not in gossip, reachable only via the - // invoice's route hints. Quote = route to the hint's public entry - // node + the hint hops' advertised fees. - const hint = (dec.routes || [])[0]; - if (!hint || !hint.length) throw e; - let hintFeeMsat = 0; - let carried = amountMsat; - for (const hop of [...hint].reverse()) { - const f = (hop.fee_base_msat || 0) + Math.ceil(carried * (hop.fee_proportional_millionths || 0) / 1_000_000); - hintFeeMsat += f; carried += f; - } - const entry = hint[0].pubkey; - const toEntry = entry === our ? 0 - : await routeFeeMsat(our, entry, amountMsat + hintFeeMsat, finalCltv, lnq); - const feeMsat = toEntry + hintFeeMsat; - return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false, hinted: true }); - } - } catch (e) { - return json({ error: e.message || 'no route found' }, 404); - } - } - - // Recent LN settlements forwarded to the caller's name(s) as float - // arkoors — the wallet matches them against its receive movements to - // mark which ones actually arrived over Lightning. NIP-98 authed and - // owner-only: this is a feed of incoming amounts. - if (url.pathname === '/lnreceipts' && req.method === 'GET') { - if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429); - const a = await checkNip98(req, url, ''); - if (a.error) return json({ error: a.error }, 401); - const receipts = []; - for (const [key, rec] of Object.entries(state.names)) { - if (rec.pubkey !== a.pubkey && rec.manager !== a.pubkey) continue; - for (const r of (state.lnReceipts || {})[key] || []) { - receipts.push({ name: key, sat: r.sat, ts: r.ts, hash: r.hash || null }); - } - } - receipts.sort((x, y) => y.ts - x.ts); - return json({ receipts: receipts.slice(0, 100) }); - } - - // Prefix search over registered names — powers recipient search in the - // wallet's DMs. Public data (every name is a public DNS record). - if (url.pathname === '/search' && req.method === 'GET') { - if (!rateOk(ip, 60)) return json({ error: 'rate limited' }, 429); - const q = (url.searchParams.get('q') || '').toLowerCase().trim(); - if (q.length < 2 || q.length > 30) return json({ results: [] }); - const results = Object.entries(state.names) - .filter(([key]) => key.split('@')[0].startsWith(q)) - .sort(([a], [b]) => a.length - b.length || (a < b ? -1 : 1)) - .slice(0, 10) - .map(([key, r]) => ({ address: key, name: key.split('@')[0], pubkey: r.pubkey })); - return json({ results }); - } - - // A user's own BOLT 12 offers with a custom memo — what a mining pool or - // payroll wants to send to. Settled payments ride the same forwarder as - // the name's default offer, so they land in the user's Spending balance. - if (url.pathname === '/offer' && (req.method === 'POST' || req.method === 'GET')) { - if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); - const bodyText = req.method === 'POST' ? await req.text() : ''; - const a = await checkNip98(req, url, bodyText); - if (a.error) return json({ error: a.error }, 401); - const key = Object.keys(state.names) - .find((k) => state.names[k].pubkey === a.pubkey || state.names[k].manager === a.pubkey); - if (!key) return json({ error: 'claim a name first' }, 400); - state.offers = state.offers || {}; - if (req.method === 'GET') { - return json({ offers: Object.entries(state.offers) - .filter(([, o]) => o.key === key) - .map(([offerId, o]) => ({ offerId, memo: o.memo, bolt12: o.bolt12, created: o.created })) }); - } - if (!ln) return json({ error: 'offers unavailable' }, 503); - let body; - try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } - const memo = String(body.memo || '').slice(0, 120).trim(); - const mine = Object.entries(state.offers).filter(([, o]) => o.key === key); - const dup = mine.find(([, o]) => o.memo === memo); - if (dup) return json({ ok: true, offerId: dup[0], memo, bolt12: dup[1].bolt12, existing: true }); - if (mine.length >= 10) return json({ error: 'too many offers' }, 400); - let o; - try { - // `issuer` keeps two users' identical memos from collapsing onto one - // CLN offer (it dedupes by the offer's contents). - o = await ln.call('offer', { amount: 'any', description: memo || key, issuer: key }); - } catch (e) { - return json({ error: e.message }, 500); - } - state.offers[o.offer_id] = { key, memo, bolt12: o.bolt12, created: Date.now() }; - persist(); - log(`offer for ${key}: ${memo ? JSON.stringify(memo) : '(no memo)'}`); - return json({ ok: true, offerId: o.offer_id, memo, bolt12: o.bolt12 }); - } - - // --- NIP-05 -------------------------------------------------------- - // Every claimed name doubles as a nostr identifier: name@domain verifies - // against the owner's key. Public data, CORS comes with json(). - if ((url.pathname === '/.well-known/nostr.json' || url.pathname === '/nostr.json') && req.method === 'GET') { - const name = (url.searchParams.get('name') || '').toLowerCase(); - const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase(); - if (!NAME_RE.test(name)) return json({ names: {} }); - const rec = state.names[`${name}@${domain}`]; - return json(rec ? { names: { [name]: rec.pubkey } } : { names: {} }); - } - - // Availability / lookup. Public data (it's DNS). - const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/); - if (m && req.method === 'GET') { - const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase(); - const rec = state.names[`${m[1]}@${domain}`]; - // granted: the record's holder is where the legacy coinos.io account's - // own session sent the name (/migrate-grant) — coinos.io's v3 bridge - // forwards that account's incoming payments here only on such proof - if (rec) { - const g = domain === 'coinos.io' && (state.migrations || {})[`${m[1]}@coinos.io`]; - const keys = [rec.pubkey, rec.manager].filter(Boolean); - const granted = !!g && (keys.includes(g.pubkey) || (!!g.manager && keys.includes(g.manager))); - return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null, granted }); - } - const invalid = !NEW_NAME_RE.test(m[1]) && !npubPrefixOwner(m[1]); - const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || invalid || await takenByCoinosUser(domain, m[1]); - return json({ name: m[1], domain, taken: reserved, reserved, ...(invalid ? { invalid: true } : {}) }); - } - - // Which name(s) does a wallet key own? Lets an imported seed find its - // username again without the user retyping it. Public data (DNS is public). - const pm = url.pathname.match(/^\/pubkey\/([0-9a-f]{64})$/); - if (pm && req.method === 'GET') { - // optional domain filter, so a staging claim can't shadow a mainnet - // name (or vice versa) when a wallet asks who it is - const domFilter = (url.searchParams.get('domain') || '').toLowerCase(); - const mine = Object.entries(state.names) - .filter(([k, r]) => r.pubkey === pm[1] && (!domFilter || k.endsWith('@' + domFilter))) - // a custom name outranks the free npub placeholder regardless of - // which record is fresher — a placeholder re-claimed on a new device - // must not shadow the name the user actually chose - .sort((a, b) => { - const ph = ([k]) => (/^npub1/.test(k.split('@')[0]) ? 1 : 0); - return ph(a) - ph(b) || (b[1].updated || 0) - (a[1].updated || 0); - }); - if (!mine.length) return json({}); - const [key, r] = mine[0]; - const [name, domain] = key.split('@'); - return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null, lno: r.lno || null }); - } - - // --- hats ------------------------------------------------------------ - - // Batched display lookup: which hat is each of these heads wearing? - // Public data (a hat is worn in public by construction). - if (url.pathname === '/hats' && req.method === 'GET') { - const net = hatNetOf(url.searchParams.get('net')); - const pks = (url.searchParams.get('pks') || '') - .split(',').filter((p) => /^[0-9a-f]{64}$/.test(p)).slice(0, 100); - const hats = {}; - for (const pk of pks) hats[pk] = hatRec(pk, net).equipped; - return json({ hats }); - } - - // The shop view: everything one pubkey owns, plus the price list (the - // client shows what the server would actually charge). - const hm = url.pathname.match(/^\/hats\/([0-9a-f]{64})$/); - if (hm && req.method === 'GET') { - return json({ ...hatRec(hm[1], hatNetOf(url.searchParams.get('net'))), prices: HAT_PRICES }); - } - - // Operator gift: drop a hat into someone's rack (x-admin-token). Takes - // hex or npub; giftable ids only — the crown stays singular. The gift - // sits in the rack rather than force-equipping over a paid hat. - if (url.pathname === '/hats/grant' && req.method === 'POST') { - if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { - return json({ error: 'unauthorized' }, 401); - } - let b; - try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); } - let pk = String(b.pubkey || ''); - if (/^npub1/.test(pk)) { - try { pk = nip19Decode(pk).data; } catch { return json({ error: 'bad npub' }, 400); } - } - if (!/^[0-9a-f]{64}$/.test(pk)) return json({ error: 'bad pubkey' }, 400); - const hat = String(b.hat || ''); - // giftable but never sold: the beta hard hat, and the knight's helm - if (!(hat in HAT_PRICES) && !['construction', 'knight'].includes(hat)) return json({ error: 'no such hat' }, 400); - const net = hatNetOf(b.net); - // the operator may say the gift goes straight on the head (a hat made - // for this one person); by default it only crowns a bare one - grantHat(pk, hat, net, { equip: b.equip === true }); - persist(); - log(`hat gifted: ${hat} (${net}) to ${pk.slice(0, 12)}${b.equip === true ? ', worn' : ''}`); - return json(hatRec(pk, net)); - } - - if (url.pathname === '/hats/invoice' && req.method === 'POST') { - 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 hat = String(body.hat || ''); - const net = hatNetOf(body.net); - const sat = HAT_PRICES[hat]; - if (!sat) return json({ error: 'no such hat' }, 400); - if (hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you already own that hat' }, 400); - const node = hatLn(net); - if (!node) return json({ error: 'the hat stand is closed right now' }, 503); - try { - const inv = await node.call('invoice', { - amount_msat: sat * 1000, - label: `hat-${hat}-${a.pubkey.slice(0, 8)}-${Date.now()}`, - description: `coinos hat: ${hat}`, - expiry: 900, - }); - state.hatInvoices ||= {}; - state.hatInvoices[inv.payment_hash] = { pubkey: a.pubkey, hat, sat, net, ts: Date.now() }; - for (const [h2, v] of Object.entries(state.hatInvoices)) { - if (Date.now() - v.ts > 7 * 86400_000) delete state.hatInvoices[h2]; - } - persist(); - log(`hat invoice: ${hat} (${sat} sat, ${net}) for ${a.pubkey.slice(0, 12)}`); - return json({ invoice: inv.bolt11, paymentHash: inv.payment_hash, sat }); - } catch (e) { - log('hat invoice failed: ' + e.message); - return json({ error: 'could not create an invoice' }, 500); - } - } - - // The buyer pings us right after paying, so the hat appears without - // waiting on the settle loop. Idempotent with it: whoever runs first - // grants, the other finds it done. - if (url.pathname === '/hats/claim' && req.method === 'POST') { - 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 hash = String(body.paymentHash || ''); - const hat = String(body.hat || ''); - const net = hatNetOf(body.net); - const pending = (state.hatInvoices || {})[hash]; - if (!pending) { - // the settle loop may have beaten us to it - if (hat && hatRec(a.pubkey, net).owned.includes(hat)) return json(hatRec(a.pubkey, net)); - return json({ error: 'unknown invoice' }, 404); - } - if (pending.pubkey !== a.pubkey) return json({ error: 'not your invoice' }, 403); - const node = hatLn(hatNetOf(pending.net)); - if (!node) return json({ error: 'the hat stand is closed right now' }, 503); - try { - const r = await node.call('listinvoices', { payment_hash: hash }); - if ((r.invoices || [])[0]?.status !== 'paid') return json({ error: 'invoice not paid yet' }, 402); - } catch (e) { - return json({ error: 'could not check the invoice' }, 500); - } - grantHat(pending.pubkey, pending.hat, hatNetOf(pending.net)); - delete state.hatInvoices[hash]; - persist(); - log(`hat sold (claim): ${pending.hat} (${hatNetOf(pending.net)}) to ${pending.pubkey.slice(0, 12)}`); - return json(hatRec(a.pubkey, hatNetOf(pending.net))); - } - - if (url.pathname === '/hats/equip' && req.method === 'POST') { - 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 hat = body.hat ? String(body.hat) : null; - const net = hatNetOf(body.net); - if (hat && !hatRec(a.pubkey, net).owned.includes(hat)) return json({ error: 'you do not own that hat' }, 403); - const r = hatStore(net)[a.pubkey] ||= { owned: [], equipped: null }; - r.equipped = hat; - persist(); - return json(hatRec(a.pubkey, net)); - } - - // --- LNURL-pay (LUD-06/16, NIP-57 zaps) ------------------------------ - const lm = url.pathname.match(/^\/(?:\.well-known\/lnurlp|lnurlp)\/([a-z0-9._-]{1,30})$/i); - if (lm && req.method === 'GET') { - const name = lm[1].toLowerCase(); - const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase(); - 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/<name> 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)}`, - minSendable: 1000, - maxSendable: 500000000, - metadata: lnurlMeta(address), - commentAllowed: 200, - allowsNostr: true, - nostrPubkey: SERVICE_PK, - }); - } - - const cb = url.pathname.match(/^\/lnurlp\/([a-z0-9._-]{1,30})\/cb$/i); - if (cb && req.method === 'GET') { - const name = cb[1].toLowerCase(); - const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase(); - const key = `${name}@${domain}`; - 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; - const comment = (url.searchParams.get('comment') || '').slice(0, 200); - - // 1. the recipient's own wallet or node service, if it is listening - const clink = clinkDest(rec); - if (clink) { - // staging wallets mint signet invoices — only mainnet is checked - const net = domain === 'staging.coinos.io' ? null : 'mainnet'; - // asked twice: a wallet that handed back a stale invoice (its Ark - // server re-serving an old payment hash) usually gets it right on - // the next index - for (let attempt = 0; attempt < 2; attempt++) { - const pr = await requestInvoiceFromWallet(clink, { amountSat: sat, description: comment, zap }); - if (!pr) break; - if (usableInvoice(pr, sat, net)) { - let hash = '', exp = ''; - try { const d = decodeBolt11(pr); hash = String(d.paymentHash || '').slice(0, 12); exp = d.expiresAt ? new Date(d.expiresAt).toISOString().slice(0, 16) : ''; } catch {} - log(`lnurl ${key}: recipient minted ${sat} sat (hash ${hash}, expires ${exp})`); - return json({ pr, routes: [] }); - } - let why = 'unusable'; - try { const d = decodeBolt11(pr); if (d.expiresAt && d.expiresAt < Date.now() + 60_000) why = 'expired ' + new Date(d.expiresAt).toISOString().slice(0, 16); } catch {} - log(`lnurl ${key}: recipient returned an ${why} invoice${attempt ? ' again — minting instead' : ' — asking again'}`); - } - } - // Staging names are mutinynet wallets: only their own wallet can mint a - // right-network invoice. Minting on our mainnet node would take real - // sats for play money and strand them (the forwarder can't deliver to - // a testnet ark address). - if (domain === 'staging.coinos.io') return json({ status: 'ERROR', reason: 'recipient is offline' }, 503); - // 2. our node, forwarded on settle - if (!ln || !fwd) return json({ status: 'ERROR', reason: 'recipient is offline' }, 503); - try { - const description = zap || lnurlMeta(key); - const inv = await ln.call('invoice', { - amount_msat: msat, - label: `lnurl-${key}-${Date.now()}`, - description, - deschashonly: true, // LUD-06: the invoice carries sha256(metadata) - expiry: 900, - }); - state.invoices = state.invoices || {}; - state.invoices[inv.payment_hash] = { key, zap, bolt11: inv.bolt11, ts: Date.now() }; - // forget invoices nobody paid - // Keep mappings as long as the invoice could possibly settle: a 1h - // sweep once erased a mapping while the settle loop was catching up - // after downtime, stranding a paid 1000 sats unattributed (found - // again only via the description_hash). CLN invoices default to a - // 7-day expiry — match it. - for (const [h, v] of Object.entries(state.invoices)) { - if (Date.now() - v.ts > 7 * 86400_000) delete state.invoices[h]; - } - persist(); - log(`lnurl ${key}: issued ${sat} sat (forwarding on settle)`); - return json({ pr: inv.bolt11, routes: [] }); - } catch (e) { - log('lnurl invoice failed: ' + e.message); - return json({ status: 'ERROR', reason: 'could not create an invoice' }, 500); - } - } - - // Top up the float over Lightning: it mints an invoice against its own - // ark balance, which anyone (in practice, the operator's node) can pay. - // Guarded by a token from the config file — this endpoint is public. - // Chain relay for wallets whose networks can't reach any public esplora - // (one tester's ISP blackholes mempool.space AND blockstream — every - // chain-touching wallet feature died for it). A narrow passthrough to - // mempool.space: only the four paths the ark manager speaks, the tip - // briefly cached so polling wallets cost one upstream call per few - // seconds, not one each. - if (url.pathname.startsWith('/esplora/')) { - const sub = url.pathname.slice('/esplora'.length); - const okPath = sub === '/blocks/tip/height' - || /^\/tx\/[0-9a-f]{64}\/(hex|status)$/.test(sub) - || (sub === '/tx' && req.method === 'POST'); - if (!okPath) return json({ error: 'not proxied' }, 404); - if (!rateOk(ip, 120)) return json({ error: 'rate limited' }, 429); - try { - if (sub === '/blocks/tip/height' && globalThis._tipCache && Date.now() - globalThis._tipCache.t < 5000) { - return new Response(globalThis._tipCache.v, { headers: { 'access-control-allow-origin': '*' } }); - } - const r = await fetch('https://mempool.space/api' + sub, - req.method === 'POST' ? { method: 'POST', body: await req.text() } : undefined); - const body = await r.text(); - if (sub === '/blocks/tip/height' && r.ok) globalThis._tipCache = { v: body, t: Date.now() }; - return new Response(body, { - status: r.status, - headers: { - 'access-control-allow-origin': '*', - 'content-type': r.headers.get('content-type') || 'text/plain', - }, - }); - } catch { - return json({ error: 'upstream unreachable' }, 502); - } - } - - // Legacy migration hand-off. coinos.io's migrate action (running with - // the OLD account's authenticated session — that session IS the proof of - // ownership) tells us which v3 identity may claim the released name. - // Without this the claim guard has nothing to go on: identity-only proof - // (the old account's npub signing the claim) never matches a v3 wallet's - // key, so every migrated claim 409'd forever. The grant names either the - // destination address (we look up its record) or a pubkey directly (the - // operator-rescue form). - if (url.pathname === '/migrate-grant' && req.method === 'POST') { - const tok = (req.headers.get('authorization') || '').replace(/^Bearer\s+/i, ''); - if (!CFG.migrateToken || tok !== CFG.migrateToken) return json({ error: 'unauthorized' }, 401); - let b; - try { b = await req.json(); } catch { return json({ error: 'bad body' }, 400); } - const name = String(b.name || '').toLowerCase(); - if (!NAME_RE.test(name)) return json({ error: 'bad name' }, 400); - let pubkey = /^[0-9a-f]{64}$/.test(String(b.pubkey || '')) ? b.pubkey : null; - let manager = null; - if (!pubkey && b.to) { - const [tn, td] = String(b.to).toLowerCase().split('@'); - const rec = state.names[`${tn}@${td}`]; - if (!rec) return json({ error: 'destination is not registered here' }, 404); - pubkey = rec.pubkey; - manager = rec.manager || null; - } - if (!pubkey) return json({ error: 'no destination' }, 400); - (state.migrations ||= {})[`${name}@coinos.io`] = { pubkey, manager, ts: Date.now() }; - persist(); - log(`migrate-grant: ${name}@coinos.io -> ${pubkey.slice(0, 8)}`); - return json({ ok: true }); - } - - if (url.pathname === '/admin/topup' && req.method === 'POST') { - if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { - return json({ error: 'unauthorized' }, 401); - } - if (!fwd) return json({ error: 'no forwarder wallet' }, 503); - const body = await req.json().catch(() => ({})); - const sat = Math.floor(body.sat || 0); - if (!sat || sat < 1000) return json({ error: 'amount too small' }, 400); - try { - const a = await fwd.createLnInvoice(sat, 'names float top-up'); - log(`float top-up invoice for ${sat} sat`); - return json({ invoice: a.invoice, paymentHash: a.paymentHash }); - } catch (e) { - return json({ error: e.message }, 500); - } - } - - // Board on-chain sats into the float: returns a P2TR funding address. - // The funding tx must pay EXACTLY the requested amount to it AT VOUT 0 - // (the board outpoint is fixed to index 0), then /admin/board/complete - // with the txid; confirmations are waited out by the sync loop. - if (url.pathname === '/admin/board' && req.method === 'POST') { - if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { - return json({ error: 'unauthorized' }, 401); - } - if (!fwd) return json({ error: 'no forwarder wallet' }, 503); - const body = await req.json().catch(() => ({})); - const sat = Math.floor(body.sat || 0); - if (!sat || sat < 10_000) return json({ error: 'amount too small' }, 400); - try { - const b = await fwd.startBoard(sat); - log(`float board started: ${sat} sat -> ${b.fundingAddress} (fee ${b.feeSat} sat)`); - return json(b); - } catch (e) { - return json({ error: e.message }, 500); - } - } - if (url.pathname === '/admin/board/complete' && req.method === 'POST') { - if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { - return json({ error: 'unauthorized' }, 401); - } - if (!fwd) return json({ error: 'no forwarder wallet' }, 503); - const body = await req.json().catch(() => ({})); - if (!body.actionId || !/^[0-9a-f]{64}$/.test(body.txid || '')) { - return json({ error: 'actionId and txid required' }, 400); - } - try { - const a = await fwd.completeBoard(body.actionId, body.txid); - return json({ ok: true, step: a.step, confs: a.confs ?? 0, needConfs: a.needConfs ?? null }); - } catch (e) { - return json({ error: e.message }, 500); - } - } - - // Drive an in-flight lightning receive forward (top-ups settle async). - if (url.pathname === '/admin/drive' && req.method === 'POST') { - if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) { - return json({ error: 'unauthorized' }, 401); - } - if (!fwd) return json({ error: 'no forwarder wallet' }, 503); - for (const a of fwd.pendingActions()) { await fwd.driveLn(a.id).catch(() => {}); } - await fwd.sync().catch(() => {}); - return json({ ok: true, floatSat: fwd.balance().spendableSat }); - } - - if (url.pathname === '/register' && req.method === 'POST') { - 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 claim; - try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } - const auth = { pubkey: a.pubkey }; - const name = String(claim.name || '').toLowerCase(); - const domain = String(claim.domain || 'halwallet.app').toLowerCase(); - if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400); - if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400); - // A migration grant (the legacy account's authenticated session told - // us where the name is going) trumps BOTH the reserved list and the - // legacy reservation: a reserved word that is also a real coinos.io - // account (the house account `coinos` itself) can only ever arrive - // here through a migration, and the identity check below can never - // pass for a v3 wallet key. Reserved without a grant stays reserved. - const grant = (state.migrations || {})[`${name}@coinos.io`]; - const granted = domain === 'coinos.io' && grant - && (grant.pubkey === auth.pubkey || (grant.manager && grant.manager === auth.pubkey)); - if (RESERVED.has(name) && !granted) return json({ error: 'name is reserved' }, 400); - if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400); - - const key = `${name}@${domain}`; - const existing = state.names[key]; - // owner OR the manager key the owner nominated may update a record - if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) { - return json({ error: 'name is taken' }, 409); - } - if (!existing && !granted && !npubPrefixOwner(name) && !NEW_NAME_RE.test(name)) return json({ error: NEW_NAME_ERR }, 400); - if (!existing && !granted && await takenByCoinosUser(domain, name, auth.pubkey)) return json({ error: 'name is taken' }, 409); - // an npub-shaped name belongs to that identity alone - if (npubPrefixOwner(name)) { - const managerOk = existing && existing.manager === auth.pubkey; - if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) { - return json({ error: 'that name belongs to another Nostr identity' }, 403); - } - } - if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400); - // Delivery noffer: absent = keep, '' = clear, a code = point Lightning - // delivery at the owner's own CLINK service (their node's invoice - // minter). Decoded here so a typo can't become a delivery black hole. - let noffer = existing?.noffer; - if (claim.noffer !== undefined) { - if (!claim.noffer) noffer = undefined; - else { - try { - const d = decodeNoffer(String(claim.noffer)); - if (!/^wss?:\/\/./.test(d.relay || '')) throw new Error('offer code names no relay'); - noffer = { pubkey: d.pubkey, relay: d.relay, offerId: d.offerId || '', raw: String(claim.noffer) }; - } catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); } - } - } - // Own BOLT 12 offer: same absent/''/set semantics. When set, the DNS - // record carries THEIR lno — senders' fetchinvoice goes straight to - // their node and no money ever touches this service. - let lno = existing?.lno; - if (claim.lno !== undefined) { - if (!claim.lno) lno = undefined; - else { - try { - const d = decodeOffer(String(claim.lno).trim()); - if (domain !== 'staging.coinos.io' && d.network && d.network !== 'mainnet') { - throw new Error(`offer is for ${d.network}`); - } - lno = String(claim.lno).trim().toLowerCase(); - } catch (e) { return json({ error: 'bad offer: ' + e.message }, 400); } - } - } - // A corrupt ark address recorded here is a payment black hole: forwards - // fail forever while the sats sit on the node (goyslop's 190k sat sat - // behind an invalid checksum). Refuse it at the door. - const arkDest = arkParamOf(claim.uri); - if (arkDest) { - try { decodeAddress(arkDest); } - catch { return json({ error: 'the ark address in the uri does not decode' }, 400); } - } - - // The record's lno=: the owner's own offer when they set one, else a - // static offer on our node with the settle forwarder behind it. - let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null; - if (lno) { - // their node answers fetchinvoice directly now — retire our stand-in - if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {}); - offer = null; - } else if (!offer) { - try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); } - } - const pubLno = lno || offer?.bolt12; - const published = pubLno ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${pubLno}` : claim.uri; - - const recordId = await cfWrite(name, domain, published, existing?.recordId); - state.names[key] = { - ...registrationIdentity({ existing, signer: auth.pubkey, manager: claim.manager, grant, granted }), - uri: published, recordId, updated: Date.now(), domain, - // the wallet's CLINK offer key, so LNURL can ask it for invoices - offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk, - // the owner's own CLINK service / BOLT 12 offer, when they set one - ...(noffer ? { noffer } : {}), - ...(lno ? { lno } : {}), - // a registered webhook survives record updates — it's cleared only - // through its own endpoint - ...(existing?.webhook ? { webhook: existing.webhook } : {}), - offerId: offer?.offerId, bolt12: offer?.bolt12, - }; - persist(); - log(`${existing ? 'updated' : 'registered'} ${key} for ${auth.pubkey.slice(0, 12)}`); - if (!existing) sendWelcome(state.names[key].pubkey, [state.names[key].manager]).catch(() => {}); - return json({ ok: true, name, address: key, record: recordName(name, domain) }); - } - - // Webhook management: the name's owner (or nominated manager) points a - // URL at their own system and every settled payment for the name POSTs - // there. GET shows the current config plus the last delivery's outcome; - // an empty/absent url on POST (or DELETE) clears it. - if (url.pathname === '/webhook' && (req.method === 'POST' || req.method === 'DELETE' || req.method === 'GET')) { - if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); - const bodyText = req.method === 'GET' ? '' : await req.text(); - const a = await checkNip98(req, url, bodyText); - if (a.error) return json({ error: a.error }, 401); - let body = {}; - if (bodyText) { try { body = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } } - const name = String((req.method === 'GET' ? url.searchParams.get('name') : body.name) || '').toLowerCase(); - const domain = String((req.method === 'GET' ? url.searchParams.get('domain') : body.domain) || 'coinos.io').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 === 'GET') { - return json({ ok: true, name, domain, webhook: rec.webhook - ? { url: rec.webhook.url, secret: rec.webhook.secret || null, set: rec.webhook.set, last: rec.webhook.last || null } - : null }); - } - if (req.method === 'DELETE' || !body.url) { - delete rec.webhook; - persist(); - log(`webhook cleared for ${key}`); - return json({ ok: true, webhook: null }); - } - const rawUrl = String(body.url).slice(0, 512); - const bad = await vetWebhookUrl(rawUrl); - if (bad) return json({ error: bad }, 400); - const secret = body.secret != null ? String(body.secret).slice(0, 128) : undefined; - rec.webhook = { url: rawUrl, ...(secret ? { secret } : {}), set: Date.now() }; - persist(); - log(`webhook set for ${key} → ${new URL(rawUrl).hostname}`); - return json({ ok: true, webhook: { url: rawUrl, secret: secret || null } }); - } - - if (url.pathname === '/register' && 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 claim; - try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); } - const auth = { pubkey: a.pubkey }; - const name = String(claim.name || '').toLowerCase(); - const domain = String(claim.domain || 'halwallet.app').toLowerCase(); - const key = `${name}@${domain}`; - const existing = state.names[key]; - if (!existing) return json({ error: 'unknown name' }, 404); - if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403); - await cfDelete(domain, existing.recordId).catch(() => {}); - if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {}); - delete state.names[key]; - persist(); - log(`released ${key}`); - return json({ ok: true }); - } - - 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)); -log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s), offers ${ln ? 'on' : 'off'}, forwarder ${fwd ? 'on' : 'off'}`); |
