aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-16 13:05:15 -0700
committerAdam Soltys <[email protected]>2026-10-16 13:05:15 -0700
commit73bffd93caefa53af3a5d28e70059a3cfbe68939 (patch)
tree1f27599810674431205bb53e62416684261c2783
parent294a49598e43e3e6f9e34f0246bbea41837359f9 (diff)
downloadcoinosv3-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
-rw-r--r--AGENTS.md1
-rw-r--r--README.md21
-rw-r--r--blossom/compose-blossom.yml23
-rw-r--r--blossom/server.js281
-rw-r--r--bridge/.gitignore2
-rw-r--r--bridge/config.example.json12
-rw-r--r--bridge/ln.js116
-rw-r--r--bridge/server.js373
-rw-r--r--names/compose-libretranslate.yml26
-rw-r--r--names/ownership.js10
-rw-r--r--names/preview.js133
-rw-r--r--names/server.js1840
-rw-r--r--names/translate.js123
-rw-r--r--nwcpush/.gitignore2
-rw-r--r--nwcpush/config.example.json8
-rw-r--r--nwcpush/server.js529
-rw-r--r--tools/deepl-translate.test.js38
-rw-r--r--tools/registry-ownership-test.js18
-rw-r--r--tools/sync-blob-test.js10
19 files changed, 23 insertions, 3543 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6519e54..15f1c65 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,3 +4,4 @@
- Deploy only the requested changes; preserve unrelated work in the shared workspace. Use an isolated release worktree when necessary.
- Production deploys through `git push prod HEAD:prod` (`prod` points to `cs:coinosv3.git`). The remote post-receive hook builds and publishes the site at https://v3.coinos.io. Verify the live assets and matching service-worker version after deployment.
- Keep `master` and `prod` identical (user's instruction, 2026-10-01). Build release worktrees from `master` (after `git fetch prod` and checking `prod/prod` is not ahead). After the prod push succeeds, fast-forward `master` to the deployed commit and push it to `origin`: `git push prod HEAD:prod && git push origin HEAD:master`. If the shared checkout has uncommitted work on `master`, move the ref with `git update-ref` and carry the working-tree edits over with a three-way merge per file (`git merge-file`); never stash or reset them away.
+- Server-side services (names registrar, nwcpush, blossom, bridge; feed and vmedia are moving there too) live in the `coinosv3-server` repository (clone at `~/coinosv3-server`, remotes `prod` = `cs:coinosv3-server.git`, `origin` = GitHub). Deploy them with `git push prod HEAD:prod && git push origin HEAD:master` from that clone; its post-receive hook restarts only the services whose files changed. Never scp files into `~/arkswap` or `~/coinosv3-server` on cs (user's instruction, relayed 2026-10-16).
diff --git a/README.md b/README.md
index 4593506..4227479 100644
--- a/README.md
+++ b/README.md
@@ -221,17 +221,24 @@ bun run build # → dist/
save it and open it from disk with no server.
-## Services in this repo
+## Server-side services
-The app is static; these small services run beside it (Bun):
+The app is static. The small services that run beside it (Bun) live in
+their own repository, **[coinosv3-server](https://github.com/coinos/coinosv3-server)**
+(also at https://git.coinos.io/coinosv3-server.git): the payment-name
+registrar (`names.coinos.io`: BIP-353 records, LNURL-pay for `@coinos.io`
+names, link previews, translation), the NWC push notifier (`nwcpush`), the
+Blossom server for encrypted sync envelopes (`blossom.coinos.io`), the
+public feed and server-rendered note pages, video help (`vmedia`) and the
+retired Ark→Lightning bridge. They import the wallet's own modules
+(`src/ark/*`, `src/noffer.js`, …) through a pinned `coinosv3` submodule.
+
+Two of them still have a copy here until their deployments move over:
| Directory | Service |
| --- | --- |
-| `names/` | Payment-name registrar (`names.coinos.io`): BIP-353 TXT records via the DNS provider's API, LNURL-pay for `@coinos.io` names, NIP-98 auth, link previews |
-| `feed/` | Public feed snapshot (`/api/feed`) and server-rendered note pages |
-| `nwcpush/` | Watches relays for NWC requests and push notifications, and wakes devices with Web Push |
-| `blossom/` | `blossom.coinos.io`: a Blossom server that only stores encrypted sync envelopes |
-| `bridge/` | Ark→Lightning swap bridge (retired; the app uses the Ark server's Lightning) |
+| `feed/` | Public feed snapshot (`/api/feed`) and server-rendered note and profile pages |
+| `vmedia/` | Server posters and H.264 copies for videos some browsers play black |
## How state is kept
diff --git a/blossom/compose-blossom.yml b/blossom/compose-blossom.yml
deleted file mode 100644
index 28acbad..0000000
--- a/blossom/compose-blossom.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-# blossom.coinos.io — Blossom blob server for oversized wallet-sync domains.
-# Stores nothing but the wallet's encrypted sync envelopes (see server.js).
-# Lives beside the names registrar: ~/arkswap is a coinosv3 checkout on cs.
-services:
- blossom:
- image: oven/bun:1
- container_name: blossom
- working_dir: /app
- command: bun blossom/server.js
- environment:
- BLOSSOM_DATA: /data
- BLOSSOM_PORT: "8796"
- BLOSSOM_HOST: blossom.coinos.io
- volumes:
- - /home/adam/arkswap:/app
- - /home/adam/arkswap/data-blossom:/data
- ports:
- - 127.0.0.1:8796:8796
- networks: [net]
- restart: unless-stopped
-networks:
- net:
- external: true
diff --git a/blossom/server.js b/blossom/server.js
deleted file mode 100644
index 9d3d631..0000000
--- a/blossom/server.js
+++ /dev/null
@@ -1,281 +0,0 @@
-// blossom.coinos.io — a Blossom (BUD-01/02/06/12) blob server that stores
-// ONE kind of thing: encrypted wallet-sync envelopes. The wallet keeps its
-// cross-device state in nostr replaceable events, but a deep Ark coin set
-// outgrows a relay's event cap; those domains go here as an encrypted blob
-// and the nostr event carries only its hash. Any Blossom server can hold the
-// blob (content-addressed), so users can self-host or pick another provider.
-//
-// Abuse policy — this must never become a file host:
-// * uploads, listing and deletion need a signed kind-24242 event (BUD-11)
-// * a blob is stored only if it starts with the wallet's envelope magic
-// ("CSB1" + 24-byte nonce): everything else is 415, so images, video and
-// arbitrary files are refused at the door
-// * blobs are always served as application/octet-stream, as an attachment,
-// nosniff — nothing here renders in a browser or embeds anywhere
-// * per-blob size cap, per-pubkey quota (bytes and count, oldest evicted),
-// per-IP/pubkey rate limits, a global disk cap, and unused-blob expiry
-//
-// Run: bun blossom/server.js (env: BLOSSOM_DATA, BLOSSOM_PORT, BLOSSOM_HOST,
-// BLOSSOM_MAX_BLOB, BLOSSOM_QUOTA_BYTES, BLOSSOM_QUOTA_COUNT,
-// BLOSSOM_DISK_CAP, BLOSSOM_TTL_DAYS)
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, statSync } from 'node:fs';
-import { join } from 'node:path';
-import { createHash } from 'node:crypto';
-import { verifyEvent } from 'nostr-tools/pure';
-
-const DATA = process.env.BLOSSOM_DATA || join(import.meta.dir, 'data');
-const PORT = Number(process.env.BLOSSOM_PORT || 8796);
-const HOST = process.env.BLOSSOM_HOST || 'blossom.coinos.io';
-const PUBLIC_BASE = process.env.BLOSSOM_PUBLIC_BASE || `https://${HOST}`;
-const MAX_BLOB = Number(process.env.BLOSSOM_MAX_BLOB || 8 * 1024 * 1024); // 8 MB per blob
-const QUOTA_BYTES = Number(process.env.BLOSSOM_QUOTA_BYTES || 32 * 1024 * 1024); // 32 MB per pubkey
-const QUOTA_COUNT = Number(process.env.BLOSSOM_QUOTA_COUNT || 24); // blobs per pubkey
-const DISK_CAP = Number(process.env.BLOSSOM_DISK_CAP || 40 * 1024 * 1024 * 1024); // 40 GB total
-const TTL_DAYS = Number(process.env.BLOSSOM_TTL_DAYS || 365); // unused-blob expiry
-const MAGIC = new TextEncoder().encode('CSB1');
-const ENVELOPE_MIN = MAGIC.length + 24 + 16; // magic + nonce + poly1305 tag
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// ---------------------------------------------------------------------------
-// storage: blobs/<sha256> on disk, a small JSON index for ownership + times
-// ---------------------------------------------------------------------------
-const BLOBS = join(DATA, 'blobs');
-const INDEX = join(DATA, 'index.json');
-mkdirSync(BLOBS, { recursive: true });
-let index = (() => {
- try { return JSON.parse(readFileSync(INDEX, 'utf8')); } catch { return { blobs: {} }; }
-})();
-// index.blobs[sha] = { size, uploaded, touched, owners: { [pubkey]: uploadedAt } }
-let persistTimer = null;
-function persist() {
- clearTimeout(persistTimer);
- persistTimer = setTimeout(() => {
- const tmp = INDEX + '.tmp';
- writeFileSync(tmp, JSON.stringify(index));
- renameSync(tmp, INDEX);
- }, 200);
-}
-const blobPath = (sha) => join(BLOBS, sha);
-const totalBytes = () => Object.values(index.blobs).reduce((n, b) => n + b.size, 0);
-const ownedBy = (pk) => Object.entries(index.blobs).filter(([, b]) => b.owners[pk]).map(([sha, b]) => ({ sha, ...b }));
-
-function descriptor(sha, b) {
- return { url: `${PUBLIC_BASE}/${sha}`, sha256: sha, size: b.size, type: 'application/octet-stream', uploaded: Math.floor(b.uploaded / 1000) };
-}
-function dropBlob(sha) {
- delete index.blobs[sha];
- try { unlinkSync(blobPath(sha)); } catch {}
-}
-// Remove a pubkey's claim; the file goes when nobody claims it.
-function disown(sha, pk) {
- const b = index.blobs[sha];
- if (!b) return;
- delete b.owners[pk];
- if (!Object.keys(b.owners).length) dropBlob(sha);
-}
-// Quota: a pubkey keeps at most QUOTA_COUNT blobs / QUOTA_BYTES — the OLDEST
-// go first, so a client that forgets to delete superseded snapshots keeps
-// working (the newest state is what sync needs).
-function enforceQuota(pk, incomingSize) {
- const mine = ownedBy(pk).sort((a, b) => a.owners[pk] - b.owners[pk]);
- let bytes = mine.reduce((n, b) => n + b.size, 0) + incomingSize;
- let count = mine.length + 1;
- for (const b of mine) {
- if (count <= QUOTA_COUNT && bytes <= QUOTA_BYTES) break;
- disown(b.sha, pk);
- bytes -= b.size; count--;
- }
- return count <= QUOTA_COUNT && bytes <= QUOTA_BYTES;
-}
-// Blobs nobody fetched or re-uploaded in TTL_DAYS are abandoned wallets'
-// snapshots (or a client that moved on) — sweep them.
-function sweep() {
- const cutoff = Date.now() - TTL_DAYS * 86400_000;
- let n = 0;
- for (const [sha, b] of Object.entries(index.blobs)) if ((b.touched || b.uploaded) < cutoff) { dropBlob(sha); n++; }
- if (n) { log('swept', n, 'expired blobs'); persist(); }
-}
-setInterval(sweep, 6 * 3600_000);
-sweep();
-
-// ---------------------------------------------------------------------------
-// auth (BUD-11): kind 24242, t=verb, expiration in the future, x=sha when the
-// endpoint names a blob, optional server tags naming us
-// ---------------------------------------------------------------------------
-function checkAuth(req, verb, sha = null) {
- 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].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
- if (!evt || evt.kind !== 24242) return { error: 'auth must be kind 24242' };
- const now = Math.floor(Date.now() / 1000);
- if (!(evt.created_at <= now + 60)) return { error: 'auth created in the future' };
- const tags = Array.isArray(evt.tags) ? evt.tags : [];
- const tag = (k) => tags.filter((x) => x[0] === k).map((x) => x[1]);
- const exp = Number(tag('expiration')[0]);
- if (!exp || exp <= now) return { error: 'auth expired' };
- if (!tag('t').includes(verb)) return { error: `auth is not for ${verb}` };
- const servers = tag('server');
- if (servers.length && !servers.some((s) => String(s).toLowerCase() === HOST)) return { error: 'auth is for another server' };
- if (sha && !tag('x').includes(sha)) return { error: 'auth does not name this blob' };
- if (!verifyEvent(evt)) return { error: 'bad signature' };
- return { pubkey: evt.pubkey };
-}
-
-// ---------------------------------------------------------------------------
-// rate limits: per IP and per pubkey, sliding minute
-// ---------------------------------------------------------------------------
-const rate = new Map();
-function rateOk(key, limit) {
- const now = Date.now();
- const arr = (rate.get(key) || []).filter((t) => t > now - 60_000);
- if (arr.length >= limit) return false;
- arr.push(now);
- rate.set(key, arr);
- if (rate.size > 5000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); }
- return true;
-}
-
-// ---------------------------------------------------------------------------
-// http
-// ---------------------------------------------------------------------------
-const CORS = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-SHA-256, X-Content-Length, X-Content-Type, *',
- 'Access-Control-Allow-Methods': 'GET, HEAD, PUT, DELETE, OPTIONS',
- 'Access-Control-Expose-Headers': 'X-Reason, Content-Length',
- 'Access-Control-Max-Age': '86400',
-};
-const json = (obj, status = 200, extra = {}) =>
- new Response(JSON.stringify(obj), { status, headers: { 'content-type': 'application/json', ...CORS, ...extra } });
-const fail = (status, reason) => json({ message: reason }, status, { 'X-Reason': reason });
-const SHA_RE = /^\/([0-9a-f]{64})(\.[a-z0-9]+)?$/;
-
-// Envelope check: our client's sealed snapshot, nothing else.
-function envelopeOk(head, size) {
- if (size < ENVELOPE_MIN || size > MAX_BLOB) return false;
- for (let i = 0; i < MAGIC.length; i++) if (head[i] !== MAGIC[i]) return false;
- return true;
-}
-// BUD-06 style policy check shared by HEAD /upload and PUT /upload.
-function uploadPolicy(size, head, pk) {
- if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required');
- if (size > MAX_BLOB) return fail(413, `blob exceeds ${MAX_BLOB} bytes`);
- if (head && !envelopeOk(head, size)) return fail(415, 'only coinos wallet sync envelopes are stored here');
- if (totalBytes() + size > DISK_CAP) return fail(503, 'storage full');
- if (pk && !enforceQuota(pk, size)) return fail(413, 'per-key quota exceeded');
- return null;
-}
-
-Bun.serve({
- port: PORT,
- maxRequestBodySize: MAX_BLOB + 1024,
- async fetch(req) {
- const url = new URL(req.url);
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
-
- if (url.pathname === '/' && req.method === 'GET') {
- return json({ name: HOST, about: 'coinos wallet sync storage (Blossom). Stores encrypted wallet snapshots only.',
- maxBlob: MAX_BLOB, quotaBytes: QUOTA_BYTES, quotaCount: QUOTA_COUNT, blobs: Object.keys(index.blobs).length });
- }
-
- // ---- upload requirements (BUD-06) ----
- if (url.pathname === '/upload' && req.method === 'HEAD') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const sha = (req.headers.get('x-sha-256') || '').toLowerCase();
- const a = checkAuth(req, 'upload', /^[0-9a-f]{64}$/.test(sha) ? sha : null);
- if (a.error) return fail(401, a.error);
- const size = Number(req.headers.get('x-content-length'));
- if (!Number.isFinite(size) || size <= 0) return fail(411, 'X-Content-Length required');
- if (size < ENVELOPE_MIN) return fail(415, 'only coinos wallet sync envelopes are stored here');
- const type = (req.headers.get('x-content-type') || '').split(';')[0].trim();
- if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here');
- // quota is not checked here: PUT evicts the key's oldest blob to make room
- const bad = uploadPolicy(size, null, null);
- if (bad) return bad;
- return new Response(null, { status: 200, headers: CORS });
- }
-
- // ---- upload (BUD-02) ----
- if (url.pathname === '/upload' && req.method === 'PUT') {
- if (!rateOk('ip:' + ip, 60)) return fail(429, 'rate limited');
- const bytes = new Uint8Array(await req.arrayBuffer());
- const sha = createHash('sha256').update(bytes).digest('hex');
- const a = checkAuth(req, 'upload', sha);
- if (a.error) return fail(401, a.error);
- if (!rateOk('pk:' + a.pubkey, 30)) return fail(429, 'rate limited');
- const want = (req.headers.get('x-sha-256') || '').toLowerCase();
- if (want && want !== sha) return fail(409, 'X-SHA-256 does not match the body');
- const type = (req.headers.get('content-type') || '').split(';')[0].trim();
- if (type && type !== 'application/octet-stream') return fail(415, 'only application/octet-stream sync envelopes are stored here');
- const existing = index.blobs[sha];
- const bad = uploadPolicy(bytes.length, bytes.subarray(0, MAGIC.length), existing?.owners[a.pubkey] ? null : a.pubkey);
- if (bad) return bad;
- const now = Date.now();
- if (!existing) {
- writeFileSync(blobPath(sha), bytes);
- index.blobs[sha] = { size: bytes.length, uploaded: now, touched: now, owners: { [a.pubkey]: now } };
- } else {
- existing.touched = now;
- existing.owners[a.pubkey] = existing.owners[a.pubkey] || now;
- }
- persist();
- log('upload', sha.slice(0, 12), bytes.length, 'b', a.pubkey.slice(0, 8), existing ? '(exists)' : '');
- return json(descriptor(sha, index.blobs[sha]), existing ? 200 : 201);
- }
-
- // ---- list (BUD-12): only the owner sees their own blobs ----
- const lm = url.pathname.match(/^\/list\/([0-9a-f]{64})$/);
- if (lm && req.method === 'GET') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const a = checkAuth(req, 'list');
- if (a.error) return fail(401, a.error);
- if (a.pubkey !== lm[1]) return fail(403, 'you can only list your own blobs');
- const since = Number(url.searchParams.get('since')) || 0, until = Number(url.searchParams.get('until')) || Infinity;
- const list = ownedBy(a.pubkey)
- .map((b) => descriptor(b.sha, b))
- .filter((d) => d.uploaded >= since && d.uploaded <= until)
- .sort((x, y) => y.uploaded - x.uploaded);
- return json(list);
- }
-
- // ---- fetch / delete a blob ----
- const bm = url.pathname.match(SHA_RE);
- if (bm) {
- const sha = bm[1];
- const b = index.blobs[sha];
- if (req.method === 'DELETE') {
- if (!rateOk('ip:' + ip, 120)) return fail(429, 'rate limited');
- const a = checkAuth(req, 'delete', sha);
- if (a.error) return fail(401, a.error);
- if (!b) return fail(404, 'no such blob');
- if (!b.owners[a.pubkey]) return fail(403, 'not your blob');
- disown(sha, a.pubkey);
- persist();
- log('delete', sha.slice(0, 12), a.pubkey.slice(0, 8));
- return new Response(null, { status: 204, headers: CORS });
- }
- if (req.method === 'GET' || req.method === 'HEAD') {
- if (!rateOk('ip:' + ip, 300)) return fail(429, 'rate limited');
- if (!b || !existsSync(blobPath(sha))) return fail(404, 'no such blob');
- b.touched = Date.now(); persist();
- const headers = {
- ...CORS,
- 'content-type': 'application/octet-stream',
- 'content-length': String(b.size),
- 'content-disposition': `attachment; filename="${sha}.bin"`,
- 'x-content-type-options': 'nosniff',
- 'cache-control': 'private, max-age=31536000, immutable',
- };
- if (req.method === 'HEAD') return new Response(null, { status: 200, headers });
- return new Response(Bun.file(blobPath(sha)), { status: 200, headers });
- }
- }
- return fail(404, 'not found');
- },
-});
-log(`blossom ${HOST} on :${PORT} — ${Object.keys(index.blobs).length} blobs, ${(totalBytes() / 1e6).toFixed(1)} MB, data ${DATA}`);
diff --git a/bridge/.gitignore b/bridge/.gitignore
deleted file mode 100644
index 45bbf0e..0000000
--- a/bridge/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-config.json
diff --git a/bridge/config.example.json b/bridge/config.example.json
deleted file mode 100644
index fee8ad7..0000000
--- a/bridge/config.example.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "port": 8790,
- "network": "regtest",
- "ark": "http://127.0.0.1:3535",
- "esplora": "http://127.0.0.1:30002",
- "mnemonic": "REPLACE with a 12/24-word mnemonic — this wallet holds bridge float",
- "stateFile": "./data/bridge.json",
- "ln": { "kind": "docker", "container": "clc", "network": "regtest" },
- "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 },
- "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 },
- "tokens": []
-}
diff --git a/bridge/ln.js b/bridge/ln.js
deleted file mode 100644
index cf2eab4..0000000
--- a/bridge/ln.js
+++ /dev/null
@@ -1,116 +0,0 @@
-// Lightning backend for the swap bridge: pay a bolt11 and report the
-// preimage. Deliberately tiny — the bridge only ever needs to *send* over
-// Lightning (an ark->LN swap), because an ASP's LN *receive* is already free.
-//
-// Two adapters, both CLN, both using named parameters:
-// 'socket' — lightning-rpc unix socket (production: mount the socket)
-// 'docker' — `docker exec <container> lightning-cli` (regtest convenience)
-
-import { execFile } from 'node:child_process';
-import { createConnection } from 'node:net';
-
-const run = (cmd, args) => new Promise((resolve, reject) => {
- execFile(cmd, args, { maxBuffer: 8 << 20 }, (err, stdout, stderr) => {
- if (err) return reject(new Error((stderr || err.message).trim().slice(0, 400)));
- resolve(stdout);
- });
-});
-
-// CLN prints progress lines before the JSON body on some commands.
-const parseJson = (s) => {
- const i = s.indexOf('{');
- if (i < 0) throw new Error('no JSON in CLN response: ' + s.slice(0, 200));
- return JSON.parse(s.slice(i));
-};
-
-function dockerAdapter({ container, network }) {
- return {
- async call(method, params = {}) {
- const args = ['exec', container, 'lightning-cli', `--${network}`, method,
- ...Object.entries(params).map(([k, v]) => `${k}=${v}`)];
- return parseJson(await run('docker', args));
- },
- };
-}
-
-function socketAdapter({ path }) {
- let id = 0;
- return {
- call(method, params = {}) {
- return new Promise((resolve, reject) => {
- const sock = createConnection(path);
- let buf = '';
- sock.on('error', reject);
- sock.on('connect', () => sock.write(JSON.stringify({
- jsonrpc: '2.0', id: ++id, method, params,
- }) + '\n'));
- sock.on('data', (d) => {
- buf += d;
- let nl;
- while ((nl = buf.indexOf('\n')) >= 0) {
- const line = buf.slice(0, nl).trim();
- buf = buf.slice(nl + 1);
- if (!line) continue;
- let msg;
- try { msg = JSON.parse(line); } catch { continue; }
- sock.end();
- if (msg.error) return reject(new Error(JSON.stringify(msg.error).slice(0, 400)));
- return resolve(msg.result);
- }
- });
- });
- },
- };
-}
-
-export function lnBackend(cfg) {
- const rpc = cfg.kind === 'docker' ? dockerAdapter(cfg) : socketAdapter(cfg);
-
- return {
- // Raw RPC passthrough — the names registrar drives offers/waitanyinvoice.
- call: (method, params) => rpc.call(method, params),
-
- async info() {
- const r = await rpc.call('getinfo');
- return { id: r.id, alias: r.alias, blockheight: r.blockheight, network: r.network };
- },
-
- // Spendable outbound capacity, so the bridge can refuse a swap it can't
- // route rather than taking an HTLC it will only have to refund.
- async outboundSat() {
- const r = await rpc.call('listpeerchannels');
- return (r.channels || [])
- .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected)
- .reduce((n, c) => n + Math.floor((c.spendable_msat ?? 0) / 1000), 0);
- },
-
- // Pay a bolt11. Resolves { preimage, feeSat }; throws otherwise.
- // maxfee caps routing spend so a swap can't cost more than we charged.
- async pay(bolt11, { maxfeeSat, retryFor = 60 } = {}) {
- const params = { bolt11, retry_for: retryFor };
- if (maxfeeSat != null) params.maxfee = `${Math.max(0, Math.floor(maxfeeSat))}sat`;
- const r = await rpc.call('pay', params);
- if (r.status && r.status !== 'complete') throw new Error(`payment not complete: ${r.status}`);
- if (!r.payment_preimage) throw new Error('payment returned no preimage');
- return {
- preimage: r.payment_preimage,
- feeSat: Math.ceil((((r.amount_sent_msat ?? 0) - (r.amount_msat ?? 0)) || 0) / 1000),
- };
- },
-
- // Has this payment hash already been paid by us? Checked before paying so
- // a crashed-and-resumed bridge never double-pays.
- async paidPreimage(paymentHash) {
- const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null);
- const done = (r?.pays || []).find((p) => p.status === 'complete');
- return done?.preimage || null;
- },
-
- // Is a payment still in flight? A pending pay must not be retried and
- // must not be refunded — its preimage may still arrive.
- async isPending(paymentHash) {
- const r = await rpc.call('listpays', { payment_hash: paymentHash }).catch(() => null);
- return (r?.pays || []).some((p) => p.status === 'pending');
- },
- };
-}
diff --git a/bridge/server.js b/bridge/server.js
deleted file mode 100644
index 6a5c04e..0000000
--- a/bridge/server.js
+++ /dev/null
@@ -1,373 +0,0 @@
-// Trustless Ark -> Lightning swap bridge.
-//
-// A third-party swap provider: users pay bolt11 invoices out of their Ark
-// balance without the ASP being the counterparty and without trusting us with
-// custody. Requires an ASP supporting VtxoPolicy::Htlc (see the upstream
-// proposal in bark's docs/third-party-htlc.md).
-//
-// Why only ark -> LN: an ASP's lightning *receive* is already free (0 base,
-// 0 ppm on mainnet today). The expensive direction is *send*, which has a
-// 20 sat floor — 80% of a 25 sat zap. That's the leg worth undercutting; the
-// receive leg stays on the ASP's native path.
-//
-// The swap:
-// 1. GET /quote?invoice=... -> amount, fee, our claim pubkey, expiry
-// 2. POST /swap {invoice, htlcVtxo}
-// the user has locked funds in an HTLC that only we can claim, and
-// only by revealing the invoice preimage. We validate it, pay the
-// invoice, then claim with the preimage.
-// 3. POST /refund {paymentHash}
-// we couldn't pay: we cosign the HTLC back to the user's refund key.
-// The ASP constrains such a spend to pay only the refunder, so this
-// endpoint cannot be turned into a redirect.
-//
-// What the user risks: nothing. If we vanish between lock and claim, the HTLC
-// expires and they exit through the refund leaf. We cannot take the funds
-// without the preimage, which we only have if we paid their invoice.
-//
-// What WE risk: paying an invoice and then failing to claim. Mitigated by
-// validating before paying, claiming immediately after, and retrying on
-// resume — we hold the preimage, so the claim stays available (worst case
-// through an on-chain exit of the HTLC leaf).
-
-import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { hex } from '@scure/base';
-import { sha256 } from '@noble/hashes/sha256';
-import { HDKey } from '@scure/bip32';
-import { mnemonicToSeedSync } from '@scure/bip39';
-
-import { ArkManager } from '../src/ark/manager.js';
-import { decodeVtxo } from '../src/ark/proto.js';
-import { validateVtxo } from '../src/ark/validate.js';
-import { decodeBolt11 } from '../src/ark/lightning.js';
-import { lnBackend } from './ln.js';
-
-// ---------------------------------------------------------------------------
-// config
-// ---------------------------------------------------------------------------
-
-const CONFIG_PATH = process.env.BRIDGE_CONFIG || join(import.meta.dir, 'config.json');
-const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
-// {
-// "port": 8790, "network": "regtest",
-// "ark": "http://127.0.0.1:3535", "esplora": "http://127.0.0.1:30002",
-// "mnemonic": "...", "stateFile": "./data/bridge.json",
-// "ln": { "kind": "docker", "container": "clc", "network": "regtest" },
-// "fee": { "baseSat": 0, "ppm": 1000, "minSat": 1 },
-// "limits": { "minSat": 1, "maxSat": 1000000, "htlcBlocks": 144 },
-// "tokens": ["..."] // omit or empty to run open
-// }
-
-const FEE = { baseSat: 0, ppm: 1000, minSat: 1, ...(cfg.fee || {}) };
-const LIMITS = { minSat: 1, maxSat: 1_000_000, htlcBlocks: 144, ...(cfg.limits || {}) };
-const TOKENS = new Set(cfg.tokens || []);
-const STATE_PATH = cfg.stateFile || join(import.meta.dir, 'data', 'bridge.json');
-
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// The fee we charge for a swap. Deliberately simple and cheap: our real cost
-// is the routing fee plus an amortized slice of rebalancing.
-const swapFee = (amountSat) =>
- Math.max(FEE.minSat, FEE.baseSat + Math.ceil((amountSat * FEE.ppm) / 1_000_000));
-
-// ---------------------------------------------------------------------------
-// state (swap records, crash-safe via atomic replace)
-// ---------------------------------------------------------------------------
-
-function loadState() {
- try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); } catch { return { swaps: {} }; }
-}
-let state = loadState();
-function saveState() {
- mkdirSync(dirname(STATE_PATH), { recursive: true });
- const tmp = STATE_PATH + '.tmp';
- writeFileSync(tmp, JSON.stringify(state, null, 1));
- renameSync(tmp, STATE_PATH); // atomic
-}
-
-// ---------------------------------------------------------------------------
-// wallets
-// ---------------------------------------------------------------------------
-
-const account = HDKey.fromMasterSeed(mnemonicToSeedSync(cfg.mnemonic)).derive("m/86'/0'/9'");
-const arkStore = {
- load: () => state.ark || null,
- save: (s) => { state.ark = s; saveState(); },
-};
-const ark = await new ArkManager({
- account, storage: arkStore, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: cfg.network,
-}).init();
-const ln = lnBackend(cfg.ln);
-
-// Our HTLC claim key. Fixed index so it survives restarts and users can pin it.
-const CLAIM_INDEX = 0;
-const claimPubkey = hex.encode(ark._key(CLAIM_INDEX).pubkey);
-
-// ---------------------------------------------------------------------------
-// swap engine
-// ---------------------------------------------------------------------------
-
-const swap = (hash) => state.swaps[hash];
-function putSwap(hash, patch) {
- state.swaps[hash] = { ...(state.swaps[hash] || {}), ...patch, updated: Date.now() };
- saveState();
- return state.swaps[hash];
-}
-
-// Validate a user's HTLC lock. Everything here must pass BEFORE we spend a
-// satoshi on Lightning — this is the only thing standing between us and
-// paying an invoice against a worthless VTXO.
-async function validateLock({ htlcVtxoBytes, invoice, dec }) {
- const bytes = hex.decode(htlcVtxoBytes);
- const v = decodeVtxo(bytes);
-
- if (v.policy.type !== 'htlc') throw new Error('vtxo is not an HTLC policy');
- if (v.policy.claimPubkey !== claimPubkey) throw new Error('HTLC is not claimable by this bridge');
- if (v.policy.paymentHash !== dec.paymentHash) throw new Error('HTLC payment hash does not match the invoice');
-
- const amountSat = dec.amountSat;
- const fee = swapFee(amountSat);
- if (v.amountSat < amountSat + fee) {
- throw new Error(`HTLC underfunded: ${v.amountSat} < ${amountSat} + ${fee} fee`);
- }
-
- // Expiry must leave us enough room to pay and claim. Too short and we could
- // pay an invoice and then watch the user refund it out from under us.
- const tip = await ark.chain.tipHeight();
- const left = v.policy.htlcExpiry - tip;
- if (left < Math.floor(LIMITS.htlcBlocks / 2)) {
- throw new Error(`HTLC expires too soon (${left} blocks left)`);
- }
-
- // Full chain validation against the ASP + the chain: this proves the VTXO
- // exists, is properly cosigned and is anchored on-chain.
- await validateVtxo(v, {
- serverPubkey: ark.serverPub, chain: ark.chain, allowPolicies: ['htlc'],
- });
-
- return { vtxo: v, amountSat, fee };
-}
-
-// Drive one swap to a terminal state. Idempotent and safe to re-enter: each
-// step checks reality (has the invoice been paid? is the HTLC already spent?)
-// rather than trusting the stored step alone.
-async function driveSwap(hash) {
- const s = swap(hash);
- if (!s || ['done', 'refunded', 'failed'].includes(s.step)) return s;
-
- // Never pay twice: ask the node what it actually did.
- if (s.step === 'validated' || s.step === 'paying') {
- const already = await ln.paidPreimage(hash);
- if (already) putSwap(hash, { step: 'paid', preimage: already });
- else if (await ln.isPending(hash)) return swap(hash); // in flight; come back later
- }
-
- if (swap(hash).step === 'validated') {
- putSwap(hash, { step: 'paying' });
- try {
- const { preimage, feeSat } = await ln.pay(s.invoice, { maxfeeSat: s.fee });
- if (hex.encode(sha256(hex.decode(preimage))) !== hash) {
- throw new Error('node returned a preimage that does not match the payment hash');
- }
- putSwap(hash, { step: 'paid', preimage, routingFeeSat: feeSat });
- log(`paid ${hash.slice(0, 12)} amount=${s.amountSat} routing=${feeSat} charged=${s.fee}`);
- } catch (e) {
- // The payment failed; the user's money is untouched. Offer the refund.
- log(`pay failed ${hash.slice(0, 12)}: ${e.message}`);
- putSwap(hash, { step: 'refundable', error: e.message });
- return swap(hash);
- }
- }
-
- if (swap(hash).step === 'paid') {
- // We hold the preimage, so this claim can always be made — retry until it
- // lands (and in the worst case it is claimable on-chain via the leaf).
- try {
- await ark.htlcClaim({
- htlcVtxoBytes: swap(hash).htlcVtxo,
- claimKeyIndex: CLAIM_INDEX,
- preimage: swap(hash).preimage,
- });
- putSwap(hash, { step: 'done' });
- log(`claimed ${hash.slice(0, 12)} — swap complete`);
- } catch (e) {
- if (/already spent|not spendable/i.test(e.message)) {
- putSwap(hash, { step: 'done', note: 'htlc already spent' });
- } else {
- putSwap(hash, { claimError: e.message });
- log(`claim failed ${hash.slice(0, 12)} (will retry): ${e.message}`);
- }
- }
- }
-
- return swap(hash);
-}
-
-// Resume anything in flight, then keep nudging.
-async function resumeAll() {
- for (const hash of Object.keys(state.swaps)) {
- await driveSwap(hash).catch((e) => log('resume error', hash.slice(0, 12), e.message));
- }
-}
-
-// ---------------------------------------------------------------------------
-// HTTP API
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type,authorization',
- 'access-control-allow-methods': 'GET,POST,OPTIONS',
- },
-});
-const fail = (msg, status = 400) => json({ error: msg }, status);
-
-function authed(req) {
- if (!TOKENS.size) return true; // open mode
- const h = req.headers.get('authorization') || '';
- return TOKENS.has(h.replace(/^Bearer\s+/i, '').trim());
-}
-
-const server = Bun.serve({
- port: cfg.port || 8790,
- async fetch(req) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
-
- try {
- // Public: what this bridge is and what it charges.
- if (url.pathname === '/info') {
- return json({
- network: cfg.network,
- ark: cfg.ark,
- claimPubkey,
- fee: FEE,
- limits: LIMITS,
- requiresToken: TOKENS.size > 0,
- outboundSat: await ln.outboundSat().catch(() => null),
- });
- }
-
- if (url.pathname === '/quote') {
- const invoice = url.searchParams.get('invoice');
- if (!invoice) return fail('missing invoice');
- let dec;
- try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); }
- if (!dec.amountSat) return fail('invoice has no amount');
- if (dec.amountSat < LIMITS.minSat || dec.amountSat > LIMITS.maxSat) {
- return fail(`amount out of range (${LIMITS.minSat}–${LIMITS.maxSat} sat)`);
- }
- if (Date.now() > dec.expiresAt) return fail('invoice expired');
- const outbound = await ln.outboundSat().catch(() => 0);
- if (outbound < dec.amountSat) return fail('insufficient outbound liquidity', 503);
- const tip = await ark.chain.tipHeight();
- return json({
- amountSat: dec.amountSat,
- feeSat: swapFee(dec.amountSat),
- totalSat: dec.amountSat + swapFee(dec.amountSat),
- paymentHash: dec.paymentHash,
- claimPubkey,
- htlcExpiry: tip + LIMITS.htlcBlocks,
- });
- }
-
- if (url.pathname === '/swap' && req.method === 'POST') {
- if (!authed(req)) return fail('unauthorized', 401);
- const body = await req.json();
- const { invoice, htlcVtxo } = body || {};
- if (!invoice || !htlcVtxo) return fail('missing invoice or htlcVtxo');
-
- let dec;
- try { dec = decodeBolt11(invoice); } catch (e) { return fail('bad invoice: ' + e.message); }
- const hash = dec.paymentHash;
-
- const existing = swap(hash);
- if (existing) {
- // idempotent: same swap resubmitted (client retry / crash recovery)
- return json(publicSwap(hash, await driveSwap(hash)));
- }
- if (!dec.amountSat) return fail('invoice has no amount');
- if (Date.now() > dec.expiresAt) return fail('invoice expired');
-
- let checked;
- try {
- checked = await validateLock({ htlcVtxoBytes: htlcVtxo, invoice, dec });
- } catch (e) {
- return fail('invalid HTLC: ' + e.message);
- }
-
- putSwap(hash, {
- step: 'validated', invoice, htlcVtxo,
- amountSat: checked.amountSat, fee: checked.fee,
- htlcAmountSat: checked.vtxo.amountSat,
- created: Date.now(),
- });
- log(`accepted ${hash.slice(0, 12)} amount=${checked.amountSat} fee=${checked.fee}`);
-
- return json(publicSwap(hash, await driveSwap(hash)));
- }
-
- // Poll a swap (or nudge a stuck one forward).
- if (url.pathname === '/swap' && req.method === 'GET') {
- const hash = url.searchParams.get('paymentHash');
- if (!hash || !swap(hash)) return fail('unknown swap', 404);
- return json(publicSwap(hash, await driveSwap(hash)));
- }
-
- // Give up on a swap we couldn't pay: cosign the HTLC back to the user.
- // Safe to expose — the ASP only cosigns a preimage-less HTLC spend when
- // every output pays the refund pubkey, so we cannot redirect it.
- if (url.pathname === '/refund' && req.method === 'POST') {
- const body = await req.json();
- const hash = body?.paymentHash;
- const s = hash && swap(hash);
- if (!s) return fail('unknown swap', 404);
- if (s.step === 'done') return fail('swap already completed');
- if (s.step === 'paid') return fail('invoice was paid; the swap will complete');
- if (await ln.isPending(hash)) return fail('payment still in flight, try again shortly', 409);
- if (s.step === 'refunded') return json({ paymentHash: hash, step: 'refunded', refundVtxos: s.refundVtxos });
-
- const refundVtxos = await ark.htlcCosignRefund({
- htlcVtxoBytes: s.htlcVtxo, claimKeyIndex: CLAIM_INDEX,
- });
- putSwap(hash, { step: 'refunded', refundVtxos });
- log(`refunded ${hash.slice(0, 12)}`);
- return json({ paymentHash: hash, step: 'refunded', refundVtxos });
- }
-
- return fail('not found', 404);
- } catch (e) {
- log('handler error:', e.stack || e.message);
- return fail('internal error: ' + e.message, 500);
- }
- },
-});
-
-// What we tell the client — never the raw stored record.
-function publicSwap(hash, s = swap(hash)) {
- return {
- paymentHash: hash,
- step: s.step,
- amountSat: s.amountSat,
- feeSat: s.fee,
- preimage: s.preimage || null, // the user's proof of payment
- error: s.error || s.claimError || null,
- refundable: s.step === 'refundable',
- };
-}
-
-log(`bridge listening on :${server.port}`);
-log(` network=${cfg.network} ark=${cfg.ark}`);
-log(` claim pubkey ${claimPubkey}`);
-log(` ark balance ${ark.balance().spendableSat} sat`);
-log(` fee: max(${FEE.minSat}, ${FEE.baseSat} + ${FEE.ppm}ppm)`);
-try { log(` ln node ${(await ln.info()).id.slice(0, 16)}… outbound=${await ln.outboundSat()} sat`); }
-catch (e) { log(' WARNING: lightning backend unreachable:', e.message); }
-
-await resumeAll();
-setInterval(() => { resumeAll().catch(() => {}); }, 15_000);
-setInterval(() => { ark.sync().catch(() => {}); }, 30_000);
diff --git a/names/compose-libretranslate.yml b/names/compose-libretranslate.yml
deleted file mode 100644
index 80ed9c6..0000000
--- a/names/compose-libretranslate.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-# LibreTranslate for post translation (coinos v3). Reached only by the
-# names registrar over the docker network (POST /translate there); no port
-# on the host, no web UI. Models live in the lt-data volume.
-services:
- libretranslate:
- image: libretranslate/libretranslate:v1.9.6
- container_name: libretranslate
- environment:
- LT_LOAD_ONLY: en,es,fr,de,pt,id,tr,vi,ru,ar,ur,hi,bn,ja,ko,zh,it,nl,pl,uk,fa,he,th,sv,cs,el,ro,hu,fi,da,nb,ca
- LT_DISABLE_WEB_UI: "true"
- LT_CHAR_LIMIT: "5000"
- LT_THREADS: "4"
- LT_UPDATE_MODELS: "true"
- volumes:
- - lt-data:/home/libretranslate/.local
- networks: [net]
- deploy:
- resources:
- limits:
- memory: 8g
- restart: unless-stopped
-volumes:
- lt-data:
-networks:
- net:
- external: true
diff --git a/names/ownership.js b/names/ownership.js
deleted file mode 100644
index 19ae4ef..0000000
--- a/names/ownership.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// A migration grant names the identity that owns the address. Its manager
-// may submit the registration without becoming that identity.
-export function registrationIdentity({ existing, signer, manager, grant, granted }) {
- const pubkey = existing?.pubkey || (granted ? grant.pubkey : signer);
- const nominated = /^[0-9a-f]{64}$/.test(manager || '') && manager !== pubkey;
- return {
- pubkey,
- manager: nominated ? manager : existing?.manager || (granted ? grant.manager : undefined),
- };
-}
diff --git a/names/preview.js b/names/preview.js
deleted file mode 100644
index b9af975..0000000
--- a/names/preview.js
+++ /dev/null
@@ -1,133 +0,0 @@
-// Link previews for the wallet's feed: the Open Graph card a page describes
-// for itself (title, one line of description, a picture, the site's name).
-// A browser can't read another site's HTML, so the registrar fetches it
-// once on the reader's behalf and hands back only what a card needs.
-//
-// The server sees more than the internet does (containers, the LAN), so a
-// URL is fetched only after its host resolves to public addresses — and
-// again after every redirect. Bodies are capped, and only HTML is read.
-import { promises as dns } from 'node:dns';
-
-const MAX_BODY = 512 * 1024;
-const TIMEOUT_MS = 7000;
-const MAX_HOPS = 4;
-const UA = 'Mozilla/5.0 (compatible; coinos-link-preview/1.0; +https://coinos.io)';
-
-const isPrivate4 = (ip) => {
- const p = ip.split('.').map(Number);
- return p[0] === 10 || p[0] === 127 || p[0] === 0
- || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
- || (p[0] === 192 && p[1] === 168)
- || (p[0] === 169 && p[1] === 254)
- || (p[0] === 100 && p[1] >= 64 && p[1] <= 127)
- || p[0] >= 224;
-};
-const isPrivate6 = (ip) => {
- const s = ip.toLowerCase();
- if (s === '::1' || s === '::') return true;
- if (s.startsWith('::ffff:')) return isPrivate4(s.slice(7));
- return /^(fc|fd|fe[89ab])/.test(s);
-};
-export const isPrivateIp = (ip) => (ip.includes(':') ? isPrivate6(ip) : isPrivate4(ip));
-
-// A URL the server may fetch: http(s), a default port, a public host.
-async function publicUrl(str) {
- let u;
- try { u = new URL(str); } catch { return null; }
- if (!/^https?:$/.test(u.protocol)) return null;
- if (u.port && u.port !== '80' && u.port !== '443') return null;
- if (u.username || u.password) return null;
- const host = u.hostname.replace(/^\[|\]$/g, '');
- if (!host || host === 'localhost' || /\.(local|internal|localhost)$/i.test(host)) return null;
- let addrs;
- try { addrs = await dns.lookup(host, { all: true }); } catch { return null; }
- if (!addrs.length || addrs.some((a) => isPrivateIp(a.address))) return null;
- return u;
-}
-
-const ENTITIES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ', ndash: '–', mdash: '—', hellip: '…',
- lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', middot: '·', bull: '•', euro: '€', pound: '£' };
-const decodeEntities = (s) => String(s || '')
- .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ''; } })
- .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(Number(d)); } catch { return ''; } })
- .replace(/&([a-z]+);/gi, (m, e) => ENTITIES[e.toLowerCase()] ?? m);
-const tidy = (s, max) => decodeEntities(s).replace(/\s+/g, ' ').trim().slice(0, max);
-
-// Every <meta> in the head, whichever way its attributes are ordered.
-function metaTags(html) {
- const out = [];
- const re = /<meta\s+([^>]*?)\/?>/gi;
- let m;
- while ((m = re.exec(html))) {
- const attrs = {};
- const ar = /([a-zA-Z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
- let a;
- while ((a = ar.exec(m[1]))) attrs[a[1].toLowerCase()] = a[3] ?? a[4] ?? a[5] ?? '';
- const key = (attrs.property || attrs.name || attrs.itemprop || '').toLowerCase();
- if (key && attrs.content != null) out.push([key, attrs.content]);
- }
- return out;
-}
-
-// What a card shows, from a page's HTML. Exported for the tests.
-export function parsePreview(html, baseUrl) {
- const head = html.slice(0, MAX_BODY);
- const meta = new Map();
- for (const [k, v] of metaTags(head)) if (!meta.has(k) && v.trim()) meta.set(k, v);
- const pick = (...keys) => { for (const k of keys) if (meta.get(k)) return meta.get(k); return ''; };
- const titleTag = (head.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || '';
- const title = tidy(pick('og:title', 'twitter:title') || titleTag, 200);
- const description = tidy(pick('og:description', 'twitter:description', 'description'), 300);
- let image = pick('og:image:secure_url', 'og:image', 'twitter:image', 'twitter:image:src');
- if (image) {
- try { image = new URL(decodeEntities(image).trim(), baseUrl).href; } catch { image = ''; }
- if (!/^https?:/.test(image)) image = '';
- }
- let site = tidy(pick('og:site_name'), 80);
- if (!site) { try { site = new URL(baseUrl).hostname.replace(/^www\./, ''); } catch {} }
- if (!title && !image) return null;
- return { url: baseUrl, title, description, image, site };
-}
-
-// Read at most MAX_BODY bytes of a response.
-async function readCapped(res) {
- const reader = res.body?.getReader();
- if (!reader) return '';
- const chunks = []; let got = 0;
- while (got < MAX_BODY) {
- const { value, done } = await reader.read();
- if (done) break;
- chunks.push(value); got += value.length;
- }
- try { await reader.cancel(); } catch {}
- return new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
-}
-
-// Fetch a page and describe it, or null when there is nothing to show
-// (not HTML, unreachable, a private host, no title or picture).
-export async function fetchPreview(str) {
- let u = await publicUrl(str);
- for (let hop = 0; u && hop < MAX_HOPS; hop++) {
- let res;
- try {
- res = await fetch(u.href, {
- redirect: 'manual',
- signal: AbortSignal.timeout(TIMEOUT_MS),
- headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en' },
- });
- } catch { return null; }
- if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
- let next;
- try { next = new URL(res.headers.get('location'), u.href).href; } catch { return null; }
- try { await res.body?.cancel(); } catch {}
- u = await publicUrl(next);
- continue;
- }
- if (!res.ok) return null;
- const type = (res.headers.get('content-type') || '').toLowerCase();
- if (!/text\/html|application\/xhtml/.test(type)) { try { await res.body?.cancel(); } catch {} return null; }
- const html = await readCapped(res);
- return parsePreview(html, u.href);
- }
- return null;
-}
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'}`);
diff --git a/names/translate.js b/names/translate.js
deleted file mode 100644
index 36ab8cf..0000000
--- a/names/translate.js
+++ /dev/null
@@ -1,123 +0,0 @@
-// Post translation for coinos v3: a thin, cached front for a LibreTranslate
-// container on the same docker network. Notes are public, so nothing private
-// passes through here; the cache keys on the text itself, so a popular post
-// is translated once whoever asks.
-import { createHash } from 'node:crypto';
-import { translatable, translatePieces } from '../src/translate-split.js';
-
-const LT = process.env.LT_URL || 'http://libretranslate:5000';
-const MAX_CHARS = 5000;
-const CACHE_MAX = 20000;
-const BUSY_MAX = 4; // LibreTranslate is CPU-bound: a few at once, the rest wait their turn
-const cache = new Map(); // sha256(target|text) -> { text, source }
-let busy = 0;
-const waiting = [];
-let langs = null, langsAt = 0;
-
-async function slot() {
- if (busy < BUSY_MAX) { busy++; return; }
- await new Promise((r) => waiting.push(r));
- busy++;
-}
-function release() { busy--; const next = waiting.shift(); if (next) next(); }
-
-// The languages the container has models for: { code: [targets] }
-export async function languages() {
- if (langs && Date.now() - langsAt < 3600_000) return langs;
- const r = await fetch(LT + '/languages', { signal: AbortSignal.timeout(5000) });
- if (!r.ok) throw new Error('languages ' + r.status);
- const list = await r.json();
- langs = Object.fromEntries(list.map((l) => [l.code, l.targets || []]));
- if (langs['zh-Hans']) langs.zh = langs['zh-Hans']; // the app says 'zh'
- langsAt = Date.now();
- return langs;
-}
-
-// The app's locale codes ↔ LibreTranslate's ('zh' is Simplified there).
-const norm = (c) => String(c || '').toLowerCase().split(/[-_]/)[0];
-const toLt = (c) => (c === 'zh' ? 'zh-Hans' : c);
-
-export async function translate(text, target, source = 'auto') {
- text = String(text || '');
- target = norm(target);
- source = source && source !== 'auto' ? norm(source) : 'auto';
- if (!text.trim()) return { text: '', source };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- const have = await languages();
- if (!have[toLt(target)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const key = createHash('sha256').update(target + '|' + source + '|' + text).digest('hex');
- const hit = cache.get(key);
- if (hit) { cache.delete(key); cache.set(key, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: source === 'auto' ? null : source, nothing: true };
- await slot();
- try {
- const call = async (path, body) => {
- const r = await fetch(LT + path, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
- });
- const out = await r.json().catch(() => ({}));
- if (!r.ok) throw Object.assign(new Error(out.error || path + ' ' + r.status), { status: r.status === 400 ? 422 : 502 });
- return out;
- };
- let from = source, confidence = null;
- if (from === 'auto') {
- const d = await call('/detect', { q: [...prose.map((p) => p.prose), ...tags.map((p) => p.keep.slice(1).replace(/_/g, ' '))].join('\n') });
- from = norm(d[0]?.language) || null;
- confidence = d[0]?.confidence ?? null;
- if (!from) throw Object.assign(new Error('unknown language'), { status: 422 });
- }
- if (from === target) return { text, source: from, confidence };
- if (!have[toLt(from)]) throw Object.assign(new Error('unsupported language'), { status: 422 });
- const translated = await translatePieces(text, async (qs) => {
- const r = await call('/translate', { q: qs, source: toLt(from), target: toLt(target), format: 'text' });
- return Array.isArray(r.translatedText) ? r.translatedText : [r.translatedText];
- }, target);
- const out = { text: translated, source: from, confidence };
- cache.set(key, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
- } finally { release(); }
-}
-
-// DeepL, with the reader's own API key (sent per request; never stored, and
-// not part of the cache key beyond the provider). DeepL refuses browsers
-// (no CORS), which is the only reason this passes through here. A free key
-// ends in ":fx" and has its own host.
-const DEEPL_TARGET = { en: 'EN-US', pt: 'PT-BR', zh: 'ZH-HANS' };
-export async function translateDeepl(text, target, key, fetchImpl = fetch) {
- text = String(text || '');
- target = norm(target);
- if (!text.trim()) return { text: '', source: null };
- if (text.length > MAX_CHARS) throw Object.assign(new Error('too long'), { status: 413 });
- if (!/^[0-9a-f-]{20,}(:fx)?$/i.test(key)) throw Object.assign(new Error('bad DeepL key'), { status: 401 });
- const ck = createHash('sha256').update('deepl|' + target + '|' + text).digest('hex');
- const hit = cache.get(ck);
- if (hit) { cache.delete(ck); cache.set(ck, hit); return hit; }
- const { prose, tags } = translatable(text, target);
- if (!prose.length && !tags.length) return { text, source: null, nothing: true };
- const host = /:fx$/i.test(key) ? 'https://api-free.deepl.com' : 'https://api.deepl.com';
- let from = null;
- const translated = await translatePieces(text, async (qs) => {
- const r = await fetchImpl(host + '/v2/translate', {
- method: 'POST',
- headers: { 'content-type': 'application/json', authorization: 'DeepL-Auth-Key ' + key },
- body: JSON.stringify({ text: qs, target_lang: DEEPL_TARGET[target] || target.toUpperCase() }),
- signal: AbortSignal.timeout(20_000),
- });
- if (r.status === 403 || r.status === 401) throw Object.assign(new Error('DeepL refused the key'), { status: 401 });
- if (r.status === 456) throw Object.assign(new Error('DeepL quota exceeded'), { status: 402 });
- if (r.status === 400) throw Object.assign(new Error('DeepL can’t translate into that language'), { status: 422 });
- if (!r.ok) throw Object.assign(new Error('DeepL ' + r.status), { status: 502 });
- const j = await r.json();
- const out = (j.translations || []).map((x) => x.text);
- from ||= norm(j.translations?.[0]?.detected_source_language) || null;
- if (out.length !== qs.length) throw Object.assign(new Error('DeepL answered short'), { status: 502 });
- return out;
- }, target);
- const out = { text: from === target ? text : translated, source: from };
- cache.set(ck, out);
- if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
- return out;
-}
diff --git a/nwcpush/.gitignore b/nwcpush/.gitignore
deleted file mode 100644
index 45bbf0e..0000000
--- a/nwcpush/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-data/
-config.json
diff --git a/nwcpush/config.example.json b/nwcpush/config.example.json
deleted file mode 100644
index 6b1abd8..0000000
--- a/nwcpush/config.example.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "port": 8797,
- "relays": ["wss://relay.coinos.io", "wss://relay.damus.io", "wss://nos.lol"],
- "vapid": { "subject": "mailto:[email protected]", "publicKey": "REPLACE", "privateKey": "REPLACE" },
- "stateFile": "./data/registrations.json",
- "maxPubkeysPerDevice": 20,
- "staleDays": 30
-}
diff --git a/nwcpush/server.js b/nwcpush/server.js
deleted file mode 100644
index 6d56f84..0000000
--- a/nwcpush/server.js
+++ /dev/null
@@ -1,529 +0,0 @@
-// NWC push notifier — step 1 of making hal answer wallet-connect requests
-// while it is closed.
-//
-// hal is a web wallet: a service worker cannot hold a relay socket open, so
-// something has to tell the browser "a request arrived, wake up". That is all
-// this does. It watches the relays for NIP-47 requests (kind 23194) addressed
-// to the wallet-service pubkeys a device has registered, and sends that device
-// a Web Push. The service worker then does the actual work — fetch, decrypt,
-// pay, reply — in the browser, with the keys never leaving it.
-//
-// WHAT THIS SERVICE LEARNS: the wallet-service pubkeys you register, and the
-// timing of requests to them. That is a real metadata leak and is the price of
-// push. It never sees request contents (encrypted to keys it does not have),
-// never sees your wallet seed, and can never move funds. The worst it can do
-// is fail to wake you, or lie about a wake-up — both of which cost nothing.
-//
-// Deliberately dumb: no database, no accounts. Registrations live in one JSON
-// file and expire if a device stops refreshing them.
-
-import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
-import { dirname, join } from 'node:path';
-import { SimplePool } from 'nostr-tools/pool';
-import { verifyEvent } from 'nostr-tools/pure';
-import webpush from 'web-push';
-
-// crude per-IP publish limiter: 20/min is far above a wallet's real reply rate
-const pubRate = new Map();
-function rateOk(ip) {
- const now = Date.now();
- const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000);
- if (arr.length >= 20) return false;
- arr.push(now);
- pubRate.set(ip, arr);
- if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); }
- return true;
-}
-
-const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG
- || join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, relays: [...], vapid: { publicKey, privateKey, subject },
-// stateFile, maxPubkeysPerDevice, staleDays }
-
-const RELAYS = CFG.relays || ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
-const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'registrations.json');
-const MAX_PK = CFG.maxPubkeysPerDevice || 20;
-const STALE_MS = (CFG.staleDays || 30) * 86400_000;
-const REQ_KIND = 23194;
-const OFFER_KIND = 21001;
-
-webpush.setVapidDetails(CFG.vapid.subject, CFG.vapid.publicKey, CFG.vapid.privateKey);
-
-// Two ways to reach a device.
-//
-// Web Push is the browser's, and on Android it is Firebase's — a phone
-// without Google Play Services (GrapheneOS and friends) can't subscribe at
-// all. UnifiedPush is the other way: the user runs a distributor, the app
-// gets an endpoint, and anyone wanting to reach the device POSTs the payload
-// to it. No encryption envelope and no VAPID — the endpoint is the secret,
-// which is why we never log one in full.
-//
-// A subscription marked `unifiedpush` is the second kind; everything else is
-// a browser's. One function, so every send path serves both.
-const isUP = (sub) => !!(sub && sub.unifiedpush && typeof sub.endpoint === 'string');
-async function deliver(sub, payload, opts = {}) {
- if (!isUP(sub)) return webpush.sendNotification(sub, payload, opts);
- const res = await fetch(sub.endpoint, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: payload,
- signal: AbortSignal.timeout(8000),
- });
- if (!res.ok) {
- // shaped like web-push's errors so the callers' 404/410 pruning works
- const err = new Error(`unifiedpush ${res.status}`);
- err.statusCode = res.status;
- throw err;
- }
- return res;
-}
-const log = (...a) => console.log(new Date().toISOString(), ...a);
-
-// ---------------------------------------------------------------------------
-// registrations: { [servicePubkey]: [{ sub, id, updated }] }
-// ---------------------------------------------------------------------------
-
-let regs = {};
-// user-facing notifications: { [endpointId]: { sub, ptags: [], authors: [], updated } }
-// ptags → DMs / zap receipts p-tagged at the user; authors → community
-// stream pubkeys (kind 1059 wraps authored by a channel's derived key).
-let notifyRegs = {};
-(() => {
- try {
- const raw = JSON.parse(readFileSync(STATE, 'utf8'));
- if (raw && raw.nwc) { regs = raw.nwc; notifyRegs = raw.notify || {}; }
- else regs = raw || {}; // pre-notify state file
- } catch {}
-})();
-function persist() {
- mkdirSync(dirname(STATE), { recursive: true });
- const tmp = STATE + '.tmp';
- writeFileSync(tmp, JSON.stringify({ nwc: regs, notify: notifyRegs }));
- renameSync(tmp, STATE);
-}
-// A device that stops refreshing drops off, so a stale endpoint can't keep us
-// subscribed to a pubkey forever.
-function prune() {
- const cutoff = Date.now() - STALE_MS;
- let dropped = 0;
- for (const pk of Object.keys(regs)) {
- regs[pk] = (regs[pk] || []).filter((r) => (r.updated || 0) > cutoff);
- if (!regs[pk].length) { delete regs[pk]; dropped++; }
- }
- for (const id of Object.keys(notifyRegs)) {
- if ((notifyRegs[id].updated || 0) <= cutoff) { delete notifyRegs[id]; dropped++; }
- }
- if (dropped) { persist(); log(`pruned ${dropped} stale registration(s)`); resubscribe(); }
-}
-
-const watchedPubkeys = () => Object.keys(regs);
-
-// ---------------------------------------------------------------------------
-// relay watcher
-// ---------------------------------------------------------------------------
-
-const pool = new SimplePool();
-let sub = null;
-let lastResub = 0;
-
-const notifyPtags = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.ptags || []))];
-const notifyAuthors = () => [...new Set(Object.values(notifyRegs).flatMap((r) => r.authors || []))];
-
-let notifySub = null;
-function resubscribeNotify() {
- try { notifySub?.close(); } catch {}
- notifySub = null;
- const ptags = notifyPtags();
- const authors = notifyAuthors();
- if (!ptags.length && !authors.length) return;
- const filters = [];
- // DMs / direct invites / zap receipts land p-tagged at the user
- // ...as do replies and mentions (kind 1 naming the user) and reactions (7)
- if (ptags.length) filters.push({ kinds: [1059, 9735, 9737, 1, 7], '#p': ptags, since: Math.floor(Date.now() / 1000) });
- // community chat: wraps authored by a channel's derived stream key
- if (authors.length) filters.push({ kinds: [1059], authors, since: Math.floor(Date.now() / 1000) });
- // one sub per filter — the pool API takes a single filter object
- notifySub = filters.map((f) => pool.subscribeMany(RELAYS, f, { onevent: onNotifyEvent }));
- notifySub.close = function () { for (const x of this) { try { x.close(); } catch {} } };
- log(`notify: watching ${ptags.length} ptag(s), ${authors.length} stream author(s)`);
-}
-
-// throttle: chat bursts collapse to one push per device per window
-// DMs are throttled barely at all now: the device drops the ones it decides
-// are noise (a stranger, or our own sent-copy), and a server-side cooldown
-// would let a discarded push swallow the friend's message that followed it.
-const COOLDOWN = { payment: 10_000, zap: 10_000, dm: 1_000, chat: 90_000, mention: 5_000, reaction: 30_000 };
-const lastPush = new Map(); // endpointId:reason -> ts
-const seenNotifyEvents = new Map(); // event id -> ts
-function onNotifyEvent(ev) {
- if (seenNotifyEvents.has(ev.id)) return;
- seenNotifyEvents.set(ev.id, Date.now());
- if (seenNotifyEvents.size > 4000) {
- const cut = Date.now() - 600_000;
- for (const [k, t] of seenNotifyEvents) if (t < cut) seenNotifyEvents.delete(k);
- }
- const p = ev.tags?.find((t) => t[0] === 'p')?.[1];
- // A public reply or mention: the note is readable here, so the device
- // gets what it needs to say who and what — an excerpt, and whether it
- // answers a post (e tag) or just names the person.
- if (ev.kind === 1) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const reply = (ev.tags || []).some((t) => t[0] === 'e' && t[1]);
- const extra = { from: ev.pubkey, reply, text: String(ev.content || '').replace(/\s+/g, ' ').slice(0, 140) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (mine.includes(ev.pubkey)) continue; // their own post, not news
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'mention', extra).catch(() => {});
- }
- return;
- }
- // A like or emoji on the user's post. Opt-in by an explicit flag: an app
- // too old to send one has no words for it and would call it chat.
- if (ev.kind === 7) {
- const ps = (ev.tags || []).filter((t) => t[0] === 'p' && t[1]).map((t) => t[1]);
- const extra = { from: ev.pubkey, emoji: String(ev.content || '+').slice(0, 16) };
- for (const [id, r] of Object.entries(notifyRegs)) {
- const mine = r.ptags || [];
- if (r.reasons?.reaction !== true || mine.includes(ev.pubkey)) continue;
- if (ps.some((x) => mine.includes(x))) pushNotify(id, r, 'reaction', extra).catch(() => {});
- }
- return;
- }
- const zap = ev.kind === 9735 || ev.kind === 9737;
- const reason = zap ? 'payment'
- : p && notifyPtags().includes(p) ? 'dm' : 'chat';
- // A wrapped DM hides its sender from us by design, so we can't decide here
- // whether it's from a friend or a stranger — we hand the whole wrap to the
- // device, which holds the only key that can open it. Web Push tops out
- // around 4KB after encryption; anything larger goes as a bare nudge and the
- // device shows its generic notification.
- const extra = reason === 'dm' && JSON.stringify(ev).length < 3500 ? { wrap: ev } : {};
- // a coinos zap receipt says how much in the clear
- const amt = ev.kind === 9737 ? parseInt(ev.tags?.find((t) => t[0] === 'amount')?.[1], 10) : 0;
- if (amt > 0) extra.amountSat = amt;
- for (const [id, r] of Object.entries(notifyRegs)) {
- const hit = reason === 'chat'
- ? (r.authors || []).includes(ev.pubkey)
- : p && (r.ptags || []).includes(p);
- // a zap is its own category for apps that know one; older apps keep
- // getting it as a payment
- if (hit) pushNotify(id, r, zap && typeof r.reasons?.zap === 'boolean' ? 'zap' : reason, extra).catch(() => {});
- }
-}
-
-async function pushNotify(id, r, reason, extra) {
- if (r.reasons && r.reasons[reason] === false) return; // opted out of this category
- const key = id + ':' + reason;
- const last = lastPush.get(key) || 0;
- if (Date.now() - last < (COOLDOWN[reason] || 10_000)) return;
- lastPush.set(key, Date.now());
- const payload = JSON.stringify({ type: 'notify', reason, ...extra });
- // Android holds a 'normal' push while the phone dozes and batches it into
- // the next maintenance window — and drops it once TTL runs out first. A
- // phone in a pocket missed most zaps and mentions this way (a laptop never
- // dozes, so it got them all). Money and people talking to you go 'high'
- // and keep for a day; reactions and community chatter can wait.
- const loud = ['payment', 'zap', 'dm', 'mention'].includes(reason);
- const opts = loud ? { TTL: 86400, urgency: 'high' } : { TTL: 3600, urgency: 'normal' };
- try {
- try {
- await deliver(r.sub, payload, opts);
- } catch (e) {
- // A payload the push service won't take (413, or the library refusing
- // the size) must not cost the notification itself — send the bare nudge
- // and let the device show its generic message.
- if (!extra.wrap || (e.statusCode && e.statusCode !== 413)) throw e;
- log(`notify(dm): payload rejected (${e.statusCode || e.message}), retrying bare`);
- await deliver(r.sub, JSON.stringify({ type: 'notify', reason }), opts);
- }
- log(`notify(${reason}) -> device ${id}`);
- } catch (e) {
- // 404/410 = gone; 403 = the sub was made against a different VAPID key
- // and can NEVER receive a push (a stale key rotation), so prune it too —
- // the client re-subscribes fresh against the current key on next open.
- if (e.statusCode === 404 || e.statusCode === 410 || e.statusCode === 403) {
- delete notifyRegs[id];
- persist();
- resubscribeNotify();
- log(`dropped a dead notify endpoint ${id} (${e.statusCode})`);
- } else {
- log(`notify(${reason}) push failed for ${id}: ${e.statusCode || e.message}`);
- }
- }
-}
-
-function resubscribe() {
- resubscribeNotify();
- const pks = watchedPubkeys();
- try { sub?.close(); } catch {}
- sub = null;
- if (!pks.length) { log('nothing registered for nwc; not subscribed'); return; }
- lastResub = Date.now();
- sub = pool.subscribeMany(
- RELAYS,
- // only live traffic: a replayed old request must not wake devices.
- // NOTE: nostr-tools ≥2.23 wants a single filter object, NOT an array —
- // an array serializes as an invalid REQ that relays reject with CLOSED.
- { kinds: [REQ_KIND, OFFER_KIND], '#p': pks, since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- const target = ev.tags?.find((t) => t[0] === 'p')?.[1];
- if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); }
- },
- },
- );
- // Track replies for the requests we forwarded, so an auto-answering worker
- // can ask "was this already answered by an open device elsewhere?" before
- // it pays. Only e-tags of requests we have seen are recorded.
- try { answeredSub?.close(); } catch {}
- answeredSub = pool.subscribeMany(
- RELAYS,
- { kinds: [RES_KIND, OFFER_KIND], since: Math.floor(Date.now() / 1000) },
- {
- onevent: (ev) => {
- for (const t of ev.tags || []) {
- if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now());
- }
- },
- },
- );
- log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`);
-}
-
-// requests we pushed recently, and which of them got a reply
-const RES_KIND = 23195;
-let answeredSub = null;
-const recentReqs = new Map(); // request event id -> ts
-const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it
-// First come, first pays: request event id -> { by, ts }. Every awake device
-// of a wallet sees every pay request, and two spending at once each lock an
-// HTLC for the same invoice — the loser's refusal then reached the client as
-// a failure. A device claims a request before spending; a claim held by
-// someone else means stand down. Memory only, minutes of life, no keys.
-const claims = new Map();
-function noteRequest(id) {
- recentReqs.set(id, Date.now());
- if (recentReqs.size > 2000) pruneRequests();
-}
-function pruneRequests() {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k);
- for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k);
- for (const [k, c] of claims) if (c.ts < cut) claims.delete(k);
-}
-
-// De-dupe: the same event arrives from several relays.
-const recentlyWoken = new Map();
-async function wake(servicePk, eventId, ev) {
- const key = servicePk + ':' + eventId;
- if (recentlyWoken.has(key)) return;
- recentlyWoken.set(key, Date.now());
- if (recentlyWoken.size > 2000) {
- const cut = Date.now() - 300_000;
- for (const [k, t] of recentlyWoken) if (t < cut) recentlyWoken.delete(k);
- }
-
- const targets = regs[servicePk] || [];
- if (!targets.length) return;
- // The payload carries the (still encrypted, still client-signed) request
- // event so an auto-answering service worker can act on it without holding
- // a relay socket. Web Push payloads cap around 4KB — an oversized event is
- // omitted and the worker falls back to waking the user.
- const clean = ev && {
- id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at,
- kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig,
- };
- let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean });
- if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk });
- let ok = 0;
- for (const r of targets) {
- try {
- await deliver(r.sub, payload, { TTL: 60, urgency: 'high' });
- ok++;
- } catch (e) {
- // 404/410 mean the browser dropped the subscription: forget it.
- if (e.statusCode === 404 || e.statusCode === 410) {
- regs[servicePk] = (regs[servicePk] || []).filter((x) => x.id !== r.id);
- if (!regs[servicePk].length) delete regs[servicePk];
- persist();
- log(`dropped an expired push endpoint for ${servicePk.slice(0, 12)}`);
- } else {
- log('push failed:', e.statusCode || e.message);
- }
- }
- }
- log(`woke ${ok}/${targets.length} device(s) for ${servicePk.slice(0, 12)}`);
-}
-
-// ---------------------------------------------------------------------------
-// HTTP
-// ---------------------------------------------------------------------------
-
-const json = (body, status = 200) => new Response(JSON.stringify(body), {
- status,
- headers: {
- 'content-type': 'application/json',
- 'access-control-allow-origin': '*',
- 'access-control-allow-headers': 'content-type',
- 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
- },
-});
-
-const server = Bun.serve({
- port: CFG.port || 8797,
- async fetch(req) {
- const url = new URL(req.url);
- if (req.method === 'OPTIONS') return json({});
-
- if (url.pathname === '/health') {
- return json({
- ok: true, relays: RELAYS, watching: watchedPubkeys().length,
- subscribed: !!sub, lastResub,
- });
- }
-
- // The browser needs this to create a PushSubscription.
- if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey });
-
- // Publish-back for the auto-answering service worker: it cannot hold a
- // relay socket, so it hands us its (encrypted, service-key-signed) reply.
- // Strictly kind 23195, size-capped, signature-verified, rate-limited —
- // this is a reply pipe, not an open relay proxy.
- if (url.pathname === '/publish' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const ev = body?.event;
- if (!ev || ![RES_KIND, OFFER_KIND].includes(ev.kind)) return json({ error: 'kind 23195/21001 events only' }, 400);
- if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400);
- if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400);
- const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1];
- try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {}
- if (eTag) answeredIds.set(eTag, Date.now());
- log(`published a reply for request ${(eTag || '?').slice(0, 12)}`);
- return json({ ok: true });
- }
-
- // Server-to-server: the names registrar reports a settled receive for a
- // pubkey. Token-gated; pushes 'payment received' to that user's devices.
- if (url.pathname === '/notify' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- if (!CFG.notifyToken || body?.token !== CFG.notifyToken) return json({ error: 'forbidden' }, 403);
- const pk = body?.pubkey;
- if (!/^[0-9a-f]{64}$/.test(pk || '')) return json({ error: 'pubkey required' }, 400);
- // Network scoping: the same pubkey exists on mainnet and staging, so a
- // payment goes only to devices on its own network. A device that hasn't
- // re-registered since this shipped has net=null and still gets it (so
- // no one goes dark mid-rollout); once both sides send a net, mismatches
- // are filtered. A caller that omits net pushes to everyone (legacy).
- const wantNet = typeof body.net === 'string' ? body.net : null;
- let n = 0;
- for (const [id, r] of Object.entries(notifyRegs)) {
- if (!(r.ptags || []).includes(pk)) continue;
- if (wantNet && r.net && r.net !== wantNet) continue;
- pushNotify(id, r, 'payment', { amountSat: Number(body.amountSat) || undefined }).catch(() => {});
- n++;
- }
- return json({ ok: true, devices: n });
- }
-
- // Claim a pay request before spending on it. The first claimant (or the
- // same one again) gets ok:true; anyone else ok:false and stands down.
- if (url.pathname === '/claim' && req.method === 'POST') {
- const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
- if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const id = body?.event, by = String(body?.by || '').slice(0, 64);
- if (!/^[0-9a-f]{64}$/.test(id || '') || !by) return json({ error: 'event and by required' }, 400);
- if (claims.size > 5000) pruneRequests();
- const cur = claims.get(id);
- if (cur && cur.by !== by && Date.now() - cur.ts < 300_000) return json({ ok: false, by: cur.by });
- claims.set(id, { by, ts: Date.now() });
- return json({ ok: true });
- }
-
- // Was a request we pushed already answered by someone? Lets a worker
- // avoid double-paying (or shouting an error over) another device's reply.
- if (url.pathname === '/answered') {
- const id = url.searchParams.get('event') || '';
- return json({ answered: answeredIds.has(id) });
- }
-
- // A device registers its push endpoint plus the wallet-service pubkeys it
- // wants woken for. Re-post periodically to stay alive.
- if (url.pathname === '/register' && req.method === 'POST') {
- const body = await req.json().catch(() => null);
- const sub_ = body?.subscription;
- const hasNwc = Array.isArray(body?.servicePubkeys);
- const pks = (body?.servicePubkeys || []).filter((p) => /^[0-9a-f]{64}$/.test(p));
- // user-facing notification watch (messages + payments) — independent of
- // the NWC registration so either can refresh without clobbering the other
- if (body?.notify && sub_?.endpoint) {
- const hex = (a, cap) => [...new Set((a || []).filter((p) => /^[0-9a-f]{64}$/.test(p)))].slice(0, cap);
- const id = Bun.hash(sub_.endpoint).toString(36);
- // per-category opt-outs; absent means everything on (older clients)
- const reasons = {};
- for (const k of ['payment', 'zap', 'dm', 'chat', 'mention', 'reaction']) {
- if (typeof body.notify.reasons?.[k] === 'boolean') reasons[k] = body.notify.reasons[k];
- }
- notifyRegs[id] = {
- sub: sub_, updated: Date.now(),
- ptags: hex(body.notify.ptags, 8),
- authors: hex(body.notify.authors, 64),
- reasons,
- // the network this device is on: the same seed yields the same
- // pubkey on mainnet and staging, so a payment push is delivered
- // only to devices on the payment's network (see /notify)
- net: typeof body.notify.net === 'string' ? body.notify.net.slice(0, 16) : null,
- };
- persist();
- resubscribeNotify();
- if (!hasNwc) return json({ ok: true, notify: true });
- }
- if (!sub_?.endpoint || !pks.length) return json({ error: 'subscription and servicePubkeys required' }, 400);
- if (pks.length > MAX_PK) return json({ error: `at most ${MAX_PK} pubkeys` }, 400);
- // one id per endpoint so re-registering replaces rather than duplicates
- const id = Bun.hash(sub_.endpoint).toString(36);
- const known = new Set(pks);
- for (const pk of pks) {
- regs[pk] = (regs[pk] || []).filter((r) => r.id !== id);
- regs[pk].push({ sub: sub_, id, updated: Date.now() });
- }
- // drop this device from pubkeys it no longer cares about
- for (const pk of Object.keys(regs)) {
- if (known.has(pk)) continue;
- const before = regs[pk].length;
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- if (before !== (regs[pk]?.length ?? 0)) { /* changed */ }
- }
- persist();
- resubscribe();
- return json({ ok: true, watching: pks.length });
- }
-
- if (url.pathname === '/register' && req.method === 'DELETE') {
- const body = await req.json().catch(() => null);
- if (!body?.endpoint) return json({ error: 'endpoint required' }, 400);
- const id = Bun.hash(body.endpoint).toString(36);
- for (const pk of Object.keys(regs)) {
- regs[pk] = regs[pk].filter((r) => r.id !== id);
- if (!regs[pk].length) delete regs[pk];
- }
- delete notifyRegs[id];
- persist();
- resubscribe();
- return json({ ok: true });
- }
-
- return json({ error: 'not found' }, 404);
- },
-});
-
-log(`nwc push notifier on :${server.port}`);
-log(` relays: ${RELAYS.join(' ')}`);
-log(` watching ${watchedPubkeys().length} service pubkey(s)`);
-resubscribe();
-setInterval(prune, 3600_000);
-// relays drop long-lived subs; re-arm periodically
-setInterval(() => { if (watchedPubkeys().length) resubscribe(); }, 15 * 60_000);
diff --git a/tools/deepl-translate.test.js b/tools/deepl-translate.test.js
deleted file mode 100644
index 39975d0..0000000
--- a/tools/deepl-translate.test.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// names/translate.js translateDeepl: the reader's key goes to the right
-// DeepL host as a DeepL-Auth-Key header, only the prose is sent (links and
-// tags kept), the detected language comes back, and DeepL's refusals turn
-// into statuses the client can word (401 bad key, 402 quota).
-// Run: bun test tools/deepl-translate.test.js
-import { test, expect } from 'bun:test';
-import { translateDeepl } from '../names/translate.js';
-
-const fake = (status = 200, seen = []) => async (url, init) => {
- const body = JSON.parse(init.body);
- seen.push({ url, auth: init.headers.authorization, body });
- if (status !== 200) return new Response('{}', { status });
- return new Response(JSON.stringify({ translations: body.text.map((t) => ({ text: '[it] ' + t, detected_source_language: 'EN' })) }), { status: 200 });
-};
-
-test('a free key goes to api-free, as DeepL-Auth-Key, prose only', async () => {
- const seen = [];
- const out = await translateDeepl('Good morning everyone, look at this https://example.com/a.png #nostr', 'it', 'abcdef01-2345-6789-abcd-ef0123456789:fx', fake(200, seen));
- expect(seen[0].url).toBe('https://api-free.deepl.com/v2/translate');
- expect(seen[0].auth).toBe('DeepL-Auth-Key abcdef01-2345-6789-abcd-ef0123456789:fx');
- expect(seen[0].body.target_lang).toBe('IT');
- expect(seen[0].body.text.join(' ')).not.toContain('https://');
- expect(out.source).toBe('en');
- expect(out.text).toContain('[it] ');
- expect(out.text).toContain('https://example.com/a.png');
- expect(out.text).toContain('#nostr');
-});
-test('a paid key goes to api.deepl.com; English means EN-US', async () => {
- const seen = [];
- await translateDeepl('Buongiorno a tutti, come state oggi?', 'en', 'abcdef01-2345-6789-abcd-ef0123456780', fake(200, seen));
- expect(seen[0].url).toBe('https://api.deepl.com/v2/translate');
- expect(seen[0].body.target_lang).toBe('EN-US');
-});
-test('refusals: bad key 401, quota 402', async () => {
- await expect(translateDeepl('Hello there my friends, how are you', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(403))).rejects.toMatchObject({ status: 401 });
- await expect(translateDeepl('Hello there my friends, how are you all', 'it', 'abcdef01-2345-6789-abcd-ef0123456781:fx', fake(456))).rejects.toMatchObject({ status: 402 });
- await expect(translateDeepl('Hello', 'it', 'not a key')).rejects.toMatchObject({ status: 401 });
-});
diff --git a/tools/registry-ownership-test.js b/tools/registry-ownership-test.js
deleted file mode 100644
index 43683a1..0000000
--- a/tools/registry-ownership-test.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import assert from 'node:assert/strict';
-import { registrationIdentity } from '../names/ownership.js';
-
-const owner = 'a'.repeat(64), wallet = 'b'.repeat(64), stranger = 'c'.repeat(64);
-const grant = { pubkey: owner, manager: wallet };
-for (const signer of [owner, wallet]) {
- assert.deepEqual(registrationIdentity({ signer, grant, granted: true }), { pubkey: owner, manager: wallet });
-}
-console.log('✓ owner and manager registrations both preserve the migration identity');
-assert.deepEqual(registrationIdentity({ signer: wallet, manager: wallet, grant, granted: true }), { pubkey: owner, manager: wallet });
-console.log('✓ the signing wallet can remain the nominated manager');
-const existing = { pubkey: owner, manager: wallet };
-assert.deepEqual(registrationIdentity({ existing, signer: wallet, grant: { pubkey: stranger }, granted: false }), existing);
-console.log('✓ later wallet updates preserve existing ownership and management');
-assert.deepEqual(registrationIdentity({ signer: wallet, grant, granted: false }), { pubkey: wallet, manager: undefined });
-assert.deepEqual(registrationIdentity({ signer: owner, manager: wallet }), existing);
-assert.deepEqual(registrationIdentity({ signer: owner, manager: owner }), { pubkey: owner, manager: undefined });
-console.log('✓ ordinary claims retain their signer and ignore unauthorized grants');
diff --git a/tools/sync-blob-test.js b/tools/sync-blob-test.js
index ee1c60a..e8e25cb 100644
--- a/tools/sync-blob-test.js
+++ b/tools/sync-blob-test.js
@@ -1,6 +1,6 @@
// Oversized sync domains on Blossom: the envelope, the server's policy, the
// outbox's upload-then-publish flow, and a fresh device resolving a pointer.
-// Spawns blossom/server.js on a free port. Run: bun tools/sync-blob-test.js
+// Spawns coinosv3-server's blossom/server.js on a free port. Run: bun tools/sync-blob-test.js
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -30,7 +30,13 @@ const a = signer(), b = signer('someone else');
// ---- the server ------------------------------------------------------------
const port = 20000 + Math.floor(Math.random() * 10000);
const dataDir = mkdtempSync(join(tmpdir(), 'blossom-test-'));
-const server = Bun.spawn(['bun', new URL('../blossom/server.js', import.meta.url).pathname], {
+// The Blossom server lives in the coinosv3-server repo (a sibling checkout,
+// or COINOSV3_SERVER); this test is the wallet's side of the sync.
+import { existsSync } from 'node:fs';
+const serverRepo = process.env.COINOSV3_SERVER || join(process.env.HOME, 'coinosv3-server');
+const blossomJs = join(serverRepo, 'blossom', 'server.js');
+if (!existsSync(blossomJs)) { console.log(`skipped: no blossom server at ${blossomJs} (clone coinosv3-server beside this repo, or set COINOSV3_SERVER)`); process.exit(0); }
+const server = Bun.spawn(['bun', blossomJs], {
env: { ...process.env, BLOSSOM_DATA: dataDir, BLOSSOM_PORT: String(port), BLOSSOM_HOST: 'localhost', BLOSSOM_QUOTA_COUNT: '2', BLOSSOM_MAX_BLOB: String(200_000) },
stdout: 'ignore', stderr: 'inherit',
});