diff options
Diffstat (limited to 'names')
| -rw-r--r-- | names/server.js | 125 |
1 files changed, 119 insertions, 6 deletions
diff --git a/names/server.js b/names/server.js index e9e5cc0..d416d80 100644 --- a/names/server.js +++ b/names/server.js @@ -16,10 +16,17 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { verifyEvent } from 'nostr-tools/pure'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { lnBackend } from '../bridge/ln.js'; +import { ArkManager } from '../src/ark/manager.js'; const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); -// { port, domain, zoneId, cfEmail, cfKey, stateFile } +// { port, domain, zoneId, 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 || 'halwallet.app'; const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json'); @@ -58,8 +65,11 @@ const cfHeaders = { const recordName = (name) => `${name}.user._bitcoin-payment.${DOMAIN}`; async function cfWrite(name, 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 chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' '); const body = JSON.stringify({ - type: 'TXT', name: recordName(name), content: `"${uri}"`, ttl: TTL, + type: 'TXT', name: recordName(name), content: chunks, ttl: TTL, }); const url = existingId ? `${CF}/zones/${CFG.zoneId}/dns_records/${existingId}` @@ -78,6 +88,92 @@ async function cfDelete(recordId) { } // --------------------------------------------------------------------------- +// 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. + +const ln = CFG.ln ? lnBackend(CFG.ln) : null; +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(); + log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`); +} + +async function makeOffer(name) { + if (!ln) return null; + const o = await ln.call('offer', { amount: 'any', description: `${name}@${DOMAIN}` }); + return { offerId: o.offer_id, bolt12: o.bolt12 }; +} + +const arkParamOf = (uri) => { + const m = String(uri).match(/[?&]ark=([^&]+)/); + return m ? decodeURIComponent(m[1]) : null; +}; + +// Forward one settled offer payment; queue it on failure (empty float, etc). +async function forward(name, sat) { + const rec = state.names[name]; + const dest = rec && arkParamOf(rec.uri); + if (!dest) { log(`forward: no ark destination for ${name}, dropping ${sat} sat`); return; } + if (!fwd) throw new Error('no forwarder wallet'); + await fwd.send(dest, sat); + log(`forwarded ${sat} sat to ${name}`); +} + +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(); + const offerId = inv.local_offer_id; + const name = offerId && Object.keys(state.names).find((n) => state.names[n].offerId === offerId); + if (!name) continue; // not one of ours (e.g. the ASP's own invoices) + const sat = Math.floor((inv.amount_received_msat?.msat ?? inv.amount_received_msat ?? 0) / 1000); + if (!sat) continue; + try { + await forward(name, sat); + } catch (e) { + log(`forward failed for ${name} (${sat} sat): ${e.message} — queued`); + state.pending = state.pending || []; + state.pending.push({ name, sat, ts: Date.now() }); + persist(); + } + } +} +// retry queued forwards (e.g. after the float is topped up) +setInterval(async () => { + const q = state.pending || []; + if (!q.length || !fwd) return; + const still = []; + for (const p of q) { + try { await forward(p.name, p.sat); } catch { still.push(p); } + } + state.pending = still; + persist(); +}, 60_000); + +// --------------------------------------------------------------------------- // claims // --------------------------------------------------------------------------- @@ -127,7 +223,12 @@ Bun.serve({ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; if (url.pathname === '/health') { - return json({ ok: true, domain: DOMAIN, names: Object.keys(state.names).length }); + 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, + }); } // Availability / lookup. Public data (it's DNS). @@ -154,9 +255,19 @@ Bun.serve({ const existing = state.names[name]; if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409); + if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400); + + // Every name also gets a static Lightning offer on our node; payments + // to it are forwarded to the ark destination in the claim. + let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null; + if (!offer) { try { offer = await makeOffer(name); } catch (e) { log('offer creation failed: ' + e.message); } } + const published = offer ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${offer.bolt12}` : claim.uri; - const recordId = await cfWrite(name, claim.uri, existing?.recordId); - state.names[name] = { pubkey: auth.pubkey, uri: claim.uri, recordId, updated: Date.now() }; + const recordId = await cfWrite(name, published, existing?.recordId); + state.names[name] = { + pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), + offerId: offer?.offerId, bolt12: offer?.bolt12, + }; persist(); log(`${existing ? 'updated' : 'registered'} ${name} for ${auth.pubkey.slice(0, 12)}`); return json({ ok: true, name, address: `${name}@${DOMAIN}`, record: recordName(name) }); @@ -176,6 +287,7 @@ Bun.serve({ if (!existing) return json({ error: 'unknown name' }, 404); if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403); await cfDelete(existing.recordId).catch(() => {}); + if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {}); delete state.names[name]; persist(); log(`released ${name}`); @@ -186,4 +298,5 @@ Bun.serve({ }, }); -log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s)`); +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'}`); |
