aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 20:46:12 -0700
committerAdam Soltys <[email protected]>2026-08-01 20:46:12 -0700
commited1d996f9dbd6cedc18ec293daba0cfcd745ae2b (patch)
tree6d552796e899314d402dc92f6f80886405e296b5 /names
parent5986cbf5337593eb11fd984d284bd41da7719a87 (diff)
downloadcoinosv3-ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b.tar.gz
coinosv3-ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b.zip
Zappable offer codes and per-name Lightning offers
CLINK offers (noffer): every wallet gets a static offer keypair (synced across devices, first-minted wins) answering kind-21001 invoice requests — from open tabs and from the service worker while closed, which mints against the mirrored state (the record now carries chain-4 and chain-5 key windows) and raises an open-to-receive notification. Zap requests ride along and their kind-9735 receipts publish once the payment is claimed. The code is shown in the payment-address card for pasting into a Nostr profile; the notifier watches and relays kind 21001 alongside NWC. Payment names also gain a Lightning offer: the registrar creates a BOLT 12 offer per name on the node and appends lno= to the DNS record; a settle-forwarder pushes each received payment on to the name's ark destination from a small float wallet (custody window: seconds between Lightning settle and the ark send). Name resolution now runs full client-side DNSSEC validation (src/dnssec.js): binary DoH, root-anchor chain walk, RSA + ECDSA via WebCrypto — the resolver is untrusted transport, verified against live signed zones including the canonical BIP-353 test name. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'names')
-rw-r--r--names/server.js125
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'}`);