fix(gateway): restore signed-records + TLD-beacon endpoints lost in the SPA deploy

The 17:23 UTC deploy today pushed the repo copy of the gateway over the
VPS, but that copy predated the signed-records work (/api/records,
/api/dns, TLD_BEACON-sourced /api/tlds, holdings with tlds/wallet/chain),
which had only ever lived on the VPS. Three-way merge of that version with
the SPA-fallback change, plus the resolver's NFT-owner tracking that the
records routes verify against. The repo is now the only deploy source.
This commit is contained in:
Local Dev 2026-09-16 21:28:01 +02:00
parent c68eaf53c2
commit 63e9b31c92
2 changed files with 432 additions and 26 deletions

View file

@ -14,12 +14,53 @@ import http from "http";
import https from "https";
import tls from "tls";
import { AwsClient } from "aws4fetch";
// libauth is present so the next drop's signature-verify path is a
// contained edit (no dep install between now and then). Only utf8ToBin
// and sha256 are used today; the rest are named here so the imports
// are visible from the top of the file.
import {
utf8ToBin, base64ToBin, sha256, ripemd160,
secp256k1, encodeCashAddress,
} from "@bitauth/libauth";
import WebSocket from "ws";
// resolver-web.js lives at ../lib/, not alongside this file — the previous
// `./resolver-web.js` import was broken (file was moved into lib/ but this
// import wasn't updated). Fix while touching the file.
import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM } from "./resolver-web.js";
import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM, TLD_BEACON_SCRIPTHASH } from "./resolver-web.js";
import { pickRecord, isSubdomainOf } from "./record-picker.js";
import { buildTldSnapshot } from "./tld-index.js";
// TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum,
// verbose-fetching every tx, and passing history+txs to buildTldSnapshot.
// This is the AUTHORITATIVE source for the per-TLD-NFT registry — the old
// `tlds.bch` root NFT is legacy and no longer authoritative (see
// Decentralized.DNS/DESIGN-tld-registry.md §9). Cached 30s.
let _tldSnap = { at: 0, snap: null };
async function getTldSnapshot() {
if (_tldSnap.snap && Date.now() - _tldSnap.at < 30000) return _tldSnap.snap;
const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/;
const candidates = CHIPNET_ELECTRUM
.map((s) => (typeof s === "string" ? { url: s } : s))
.filter((s) => !BNS_INDEXER_URLS.test(s.url));
let el;
for (const s of candidates) {
try { el = await connectAny([s], WebSocket); break; } catch { /* try next */ }
}
if (!el) throw new Error("no full electrum reachable");
try {
const history = typeof el.unionHistory === "function"
? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history
: await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
catch { /* unreadable — skip */ }
}
const snap = buildTldSnapshot({ history, txs });
_tldSnap = { at: Date.now(), snap };
return snap;
} finally { try { el.close?.(); } catch {} }
}
// Full-index cache (rebuilt every 30s) for fast availability lookups.
let _idx = { at: 0, index: null };
@ -66,6 +107,34 @@ const aws = new AwsClient({
service: "s3", region: "us-east-1",
});
// In-memory highest-seen seq per name, used by both the POST (records
// write) and GET (dns read) sides to reject rollback of a signed
// manifest. Loses state on gateway restart — that's acceptable in v1
// since Sia's newer-writer-wins covers the fresh state, and a restart
// window is a narrow attack surface. A future v2 can persist this to
// disk alongside the beacon snapshot.
const _dnsSeqCache = new Map();
// ---------- signed records manifest helpers ----------
// canonicalize(): recursively sort object keys, drop null/undefined so
// signable bytes are reproducible cross-implementation. Arrays keep order.
function canonicalize(x) {
if (x === null || x === undefined) return undefined;
if (Array.isArray(x)) return x.map(canonicalize).filter((v) => v !== undefined);
if (typeof x === "object") {
const out = {};
for (const k of Object.keys(x).sort()) {
const v = canonicalize(x[k]);
if (v !== undefined) out[k] = v;
}
return out;
}
return x;
}
function withoutKey(obj, k) {
const { [k]: _drop, ...rest } = obj;
return rest;
}
const cache = new Map();
async function resolve(host) {
const c = cache.get(host);
@ -446,8 +515,12 @@ const server = http.createServer(async (req, res) => {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
try {
const idx = await getIndex();
const observed = new Map(); // tld -> count of names
// Authoritative TLD list: walk TLD_BEACON, each per-TLD NFT (TREG) is
// its own first-class registration. Legacy `tlds.bch` root record is
// still used only to derive `advertised` (which TLDs the operator
// wants shops to promote).
const [snap, idx] = await Promise.all([getTldSnapshot(), getIndex()]);
const observed = new Map(); // tld -> count of registered names
for (const key of idx.keys()) {
const dot = key.indexOf(".");
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
@ -456,26 +529,51 @@ const server = http.createServer(async (req, res) => {
const anchor = idx.get("tlds.bch");
const advertisedRaw = (anchor?.records?.tlds ?? "").trim();
const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : [];
const known = new Set([...advertised, ...observed.keys()]);
// include_hidden=1 returns every TLD (used by the admin panel);
// otherwise HIDDEN_TLDS are filtered from the public listing.
const url = new URL(req.url, "http://x");
const includeHidden = url.searchParams.get("include_hidden") === "1";
const tlds = [...known].sort()
.filter((tld) => includeHidden || !HIDDEN_TLDS.has(tld))
.map((tld) => ({
tld,
registered_count: observed.get(tld) || 0,
in_advertised: advertised.includes(tld),
hidden: HIDDEN_TLDS.has(tld) || undefined,
}));
// Each row carries the owner's on-chain policy (`records` from the
// latest TREG/TUPD) so shops can price and gate without walking the
// beacon themselves:
// price_usd — flat USD price the owner set for every name under
// the TLD (records.price); null = tier pricing
// hidden — off the public listing. hidden_by tells why:
// "owner" (records.hidden=1, the owner switched the
// TLD off) or "operator" (HIDDEN_TLDS file).
// policy — records.policy (open | frozen | covenant), default open
const ownerHidden = (rec) => rec.hidden === 1 || rec.hidden === true || rec.hidden === "1";
const priceOf = (rec) => (typeof rec.price === "number" && Number.isFinite(rec.price) && rec.price >= 0 ? rec.price : null);
const all = [...snap.tlds]
.sort((a, b) => a.tld.localeCompare(b.tld))
.map((t) => {
const rec = t.records && typeof t.records === "object" ? t.records : {};
const byOwner = ownerHidden(rec);
const byOperator = HIDDEN_TLDS.has(t.tld);
return {
tld: t.tld,
registered_count: observed.get(t.tld) || 0,
in_advertised: advertised.includes(t.tld),
hidden: (byOwner || byOperator) || undefined,
hidden_by: byOwner ? "owner" : byOperator ? "operator" : undefined,
price_usd: priceOf(rec),
policy: typeof rec.policy === "string" ? rec.policy : "open",
records: rec,
owner: t.owner ?? null,
category: t.category,
reg_height: t.regHeight ?? null,
updated_txid: t.updatedTxid ?? null,
};
});
const tlds = all.filter((t) => includeHidden || !t.hidden);
res.writeHead(200);
res.end(JSON.stringify({
source: "tlds.bch",
height: anchor?.height ?? null,
updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null,
source: "TLD_BEACON",
height: snap.asOfHeight ?? null,
updated_txid: snap.asOfTxid ?? null,
root: snap.root ?? null,
advertised,
hidden_count: HIDDEN_TLDS.size,
hidden_count: all.filter((t) => t.hidden).length,
tlds,
}, null, 2));
} catch (e) {
@ -595,6 +693,10 @@ const server = http.createServer(async (req, res) => {
if (req.url === "/api/holdings" || req.url.startsWith("/api/holdings/") || req.url.startsWith("/api/holdings?")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
let scripthashes = [];
if (req.method === "POST") {
try {
@ -633,13 +735,30 @@ const server = http.createServer(async (req, res) => {
return;
}
try {
const utxoLists = await Promise.all(scripthashes.map((sh) =>
el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => [])
));
// Single round-trip: fetch all UTXOs + the chain head in parallel.
// headers.subscribe returns the current tip on first call; keeping the
// socket-level subscription is fine because we close the electrum
// connection right after in the `finally` block.
const [utxoLists, tipHeader] = await Promise.all([
Promise.all(scripthashes.map((sh) =>
el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => [])
)),
el.call("blockchain.headers.subscribe", []).catch(() => null),
]);
const categories = new Set();
let sats = 0n;
for (const list of utxoLists) {
for (const u of list) if (u.token_data?.nft) categories.add(u.token_data.category);
for (const u of list) {
// Sum spendable balance across every watched address.
const v = u.value;
if (typeof v === "number") sats += BigInt(v);
else if (typeof v === "string") sats += BigInt(v);
if (u.token_data?.nft) categories.add(u.token_data.category);
}
}
// Cross-join against the name index (REG NFTs) and the TLD snapshot
// (TREG NFTs). Both share the same "category → entry" shape; the
// wallet just holds one or the other.
const idx = await getIndex();
const names = [];
for (const [key, entry] of idx) {
@ -658,9 +777,39 @@ const server = http.createServer(async (req, res) => {
});
}
names.sort((a, b) => a.name.localeCompare(b.name));
// TLDs — walk the TLD_BEACON snapshot (already cached by
// getTldSnapshot) and pick out categories owned by this wallet.
// Silent on a snapshot fetch failure — never let a beacon hiccup
// break the names response.
const tlds = [];
try {
const snap = await getTldSnapshot();
for (const t of snap.tlds ?? []) {
if (!categories.has(t.category)) continue;
tlds.push({
tld: t.tld,
category: t.category,
records: t.records ?? null,
reg_height: t.regHeight ?? null,
reg_txid: t.regTxid ?? null,
updated_txid: t.updatedTxid ?? null,
owner: t.owner ?? null,
});
}
tlds.sort((a, b) => a.tld.localeCompare(b.tld));
} catch { /* leave tlds empty */ }
const wallet = {
sats: sats.toString(), // BigInt-safe wire format
utxo_count: utxoLists.reduce((n, l) => n + l.length, 0),
};
const chain = tipHeader && typeof tipHeader.height === "number"
? { height: tipHeader.height }
: null;
const body = req.method === "POST"
? { count: names.length, scripthashes_queried: scripthashes.length, names }
: { scripthash: scripthashes[0], count: names.length, names };
? { count: names.length, tld_count: tlds.length, scripthashes_queried: scripthashes.length,
names, tlds, wallet, chain }
: { scripthash: scripthashes[0], count: names.length, tld_count: tlds.length,
names, tlds, wallet, chain };
res.writeHead(200);
res.end(JSON.stringify(body, null, 2));
} catch (e) {
@ -686,12 +835,244 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({
name: key, tld, registered: !!entry,
records: entry?.records ?? null, category: entry?.category ?? null,
owner: entry?.owner ?? null,
}));
} catch (e) {
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
return;
}
// --- signed records manifest ---
// GET /api/records/<name> -> reads the current signed manifest from
// the name's Sia bucket at _records.json.
// Public. See DESIGN-signed-records-manifest.md.
// POST /api/records/<name> body = manifest JSON, verifies:
// (1) sig matches current on-chain NFT owner,
// (2) seq strictly greater than current seq,
// (3) name field matches path,
// then uploads to Sia at _records.json.
if (req.url.startsWith("/api/records/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const raw = decodeURIComponent(req.url.slice("/api/records/".length).split("?")[0]);
let name;
try { name = normalizeName(raw); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
// Where the manifest lives — same Sia bucket the on-chain s3 record points at.
// If the name has no s3 pointer, records-manifest can't be published for it.
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const s3Base = entry.records?.s3;
if (!s3Base) {
res.writeHead(409);
res.end(JSON.stringify({ error: `no s3 pointer on chain — set records.s3 first, then records-manifest has somewhere to live` }));
return;
}
const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json";
// ---- GET ----
if (req.method === "GET") {
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest yet", name })); return; }
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const body = await up.text();
res.writeHead(200); res.end(body);
} catch (e) {
res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` }));
}
return;
}
// ---- POST ----
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "GET or POST" })); return; }
if (!entry.owner) {
res.writeHead(503);
res.end(JSON.stringify({ error: "gateway index has no owner recorded for this name yet — retry after next index refresh (30s)" }));
return;
}
try {
// Read body (128 KB cap).
const chunks = [];
let total = 0;
for await (const c of req) {
total += c.length;
if (total > 128 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "manifest > 128 KB" })); return; }
chunks.push(c);
}
const bodyText = Buffer.concat(chunks).toString("utf8");
let manifest;
try { manifest = JSON.parse(bodyText); }
catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
// Schema checks.
if (manifest.v !== 1) { res.writeHead(400); res.end(JSON.stringify({ error: "unsupported v (expected 1)" })); return; }
if (manifest.name !== name) { res.writeHead(400); res.end(JSON.stringify({ error: `name field "${manifest.name}" does not match path "${name}"` })); return; }
if (typeof manifest.seq !== "number" || !Number.isFinite(manifest.seq) || manifest.seq < 0) {
res.writeHead(400); res.end(JSON.stringify({ error: "seq must be a non-negative integer" })); return;
}
if (typeof manifest.sig !== "string" || !manifest.sig) {
res.writeHead(400); res.end(JSON.stringify({ error: "sig required (base64 BCH message signature, 65 bytes)" })); return;
}
// Seq monotonicity — read the current manifest if any.
try {
const cur = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (cur.ok) {
const cj = JSON.parse(await cur.text());
if (typeof cj.seq === "number" && manifest.seq <= cj.seq) {
res.writeHead(409); res.end(JSON.stringify({ error: `stale seq ${manifest.seq} <= current ${cj.seq}` })); return;
}
}
} catch { /* first write */ }
// Signature verification. The signable payload is the manifest
// WITHOUT the sig field, canonicalized: sorted keys, no whitespace,
// nulls omitted. See DESIGN-signed-records-manifest.md.
const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig")));
const digest = sha256.hash(utf8ToBin(canonical));
let sigBytes;
try { sigBytes = base64ToBin(manifest.sig); }
catch { res.writeHead(400); res.end(JSON.stringify({ error: "sig is not valid base64" })); return; }
if (sigBytes.length !== 65) {
res.writeHead(400); res.end(JSON.stringify({ error: `sig must decode to 65 bytes (got ${sigBytes.length})` })); return;
}
// BCH message-signature envelope: [flag, r|s]. flag = 27+recovery
// (+4 if compressed pubkey). Compressed is the standard.
const flag = sigBytes[0];
const recovery = (flag - 27) & 3;
const compactSig = sigBytes.slice(1);
const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest);
if (typeof recovered === "string") {
res.writeHead(400); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return;
}
// Derive p2pkh CashAddress from recovered pubkey, using the same
// prefix as the on-chain owner address (chipnet = "bchtest",
// mainnet = "bitcoincash"). The prefix comes from the owner
// string itself so we don't have to hardcode chain.
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const pubkeyHash = ripemd160.hash(sha256.hash(recovered));
const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash });
// libauth returns { address } in newer versions and a bare string in
// older ones — unwrap either shape.
const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? "");
if (derivedAddr !== entry.owner) {
res.writeHead(403);
res.end(JSON.stringify({
error: "signature does not match current on-chain NFT owner",
derived: derivedAddr,
current: entry.owner,
}));
return;
}
// Upload signed blob to Sia at `_records.json`.
const put = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`, {
method: "PUT",
body: Buffer.from(bodyText, "utf8"),
headers: { "content-type": "application/json" },
});
if (!put.ok) {
const detail = await put.text().catch(() => "");
res.writeHead(502);
res.end(JSON.stringify({ error: `sia write failed: ${put.status} ${detail.slice(0, 200)}` }));
return;
}
// Uploaded — bump the highest-seen seq for this name so
// subsequent GET /api/dns/<name> won't accept a replay under any
// circumstances (Sia's read-consistency + our seq cache both agree).
_dnsSeqCache.set(name, manifest.seq);
res.writeHead(200);
res.end(JSON.stringify({ ok: true, name, seq: manifest.seq, sia_key: manifestKey, bytes: total }));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: `records-manifest error: ${e.message}` }));
}
return;
}
// --- resolver-side DNS read (verified) ---
// GET /api/dns/<name> -> { name, seq, updated_at, dns, verified_owner }
//
// This is the "resolver read" side of the signed-records-manifest flow.
// Fetches the name's _records.json from Sia, verifies:
// (1) sig recovers to the current NFT holder,
// (2) seq is >= the highest we've cached (rejects replays even if
// Sia serves stale data),
// then returns only the verified `dns` block. Callers (mail bridges,
// DNS shims, Theseus, Ariadne) can trust the response without doing
// their own on-chain lookups.
if (req.url.startsWith("/api/dns/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type");
res.setHeader("access-control-max-age", "86400");
res.setHeader("content-type", "application/json");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET only" })); return; }
const raw = decodeURIComponent(req.url.slice("/api/dns/".length).split("?")[0]);
let name;
try { name = normalizeName(raw); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
const entry = (await getIndex()).get(name);
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const s3Base = entry.records?.s3;
if (!s3Base) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest — name has no s3 pointer on chain", name })); return; }
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "gateway index has no owner recorded yet — retry after next index refresh" })); return; }
const manifestKey = s3Base.endsWith("/") ? s3Base + "_records.json" : s3Base + "/_records.json";
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${manifestKey}`);
if (up.status === 404 || up.status === 403) { res.writeHead(404); res.end(JSON.stringify({ error: "no manifest published yet", name })); return; }
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const bodyText = await up.text();
let manifest;
try { manifest = JSON.parse(bodyText); }
catch { res.writeHead(502); res.end(JSON.stringify({ error: "manifest on sia is not valid JSON" })); return; }
if (manifest.v !== 1) { res.writeHead(502); res.end(JSON.stringify({ error: `unsupported manifest v=${manifest.v}` })); return; }
if (manifest.name !== name) { res.writeHead(502); res.end(JSON.stringify({ error: "manifest name field does not match request" })); return; }
// Replay defence — reject anything with a seq we've seen a HIGHER
// value of. Newer manifests are always accepted.
const seenSeq = _dnsSeqCache.get(name);
if (seenSeq !== undefined && manifest.seq < seenSeq) {
res.writeHead(502); res.end(JSON.stringify({ error: `stale manifest — seq ${manifest.seq} < seen ${seenSeq}` })); return;
}
// Signature check — recover pubkey from sig+digest, derive
// CashAddress with the owner's prefix, compare to entry.owner.
if (typeof manifest.sig !== "string") { res.writeHead(502); res.end(JSON.stringify({ error: "manifest has no sig" })); return; }
const canonical = JSON.stringify(canonicalize(withoutKey(manifest, "sig")));
const digest = sha256.hash(utf8ToBin(canonical));
let sigBytes;
try { sigBytes = base64ToBin(manifest.sig); }
catch { res.writeHead(502); res.end(JSON.stringify({ error: "sig not valid base64" })); return; }
if (sigBytes.length !== 65) { res.writeHead(502); res.end(JSON.stringify({ error: `sig must be 65 bytes (got ${sigBytes.length})` })); return; }
const flag = sigBytes[0];
const recovery = (flag - 27) & 3;
const compactSig = sigBytes.slice(1);
const recovered = secp256k1.recoverPublicKeyCompressed(compactSig, recovery, digest);
if (typeof recovered === "string") { res.writeHead(502); res.end(JSON.stringify({ error: `sig recovery failed: ${recovered}` })); return; }
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const pubkeyHash = ripemd160.hash(sha256.hash(recovered));
const _enc = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: pubkeyHash });
const derivedAddr = typeof _enc === "string" ? _enc : (_enc?.address ?? "");
if (derivedAddr !== entry.owner) {
res.writeHead(502);
res.end(JSON.stringify({ error: "manifest signature does not match current NFT owner", derived: derivedAddr, current: entry.owner }));
return;
}
// Verified. Cache the seq and hand back the DNS block.
_dnsSeqCache.set(name, manifest.seq);
res.setHeader("Cache-Control", "public, max-age=30");
res.writeHead(200);
res.end(JSON.stringify({
name,
seq: manifest.seq,
updated_at: manifest.updated_at ?? null,
dns: manifest.dns ?? {},
verified_owner: entry.owner,
}, null, 2));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: `dns resolve error: ${e.message}` }));
}
return;
}
// --- registrar API: all names sharing a label across every TLD ---
// GET /api/labels/<label> -> { label, matches: [{ name, tld, records, category }] }

View file

@ -323,6 +323,17 @@ export async function fetchTldMap(el) {
// DESIGN-tld-registry.md §4): a name REG is admitted iff its TLD has a live
// TLD certificate on the TLD beacon at a height ≤ the REG's height. Off by
// default while the mainnet migration story lands.
// BCHN's verbose tx returns scriptPubKey.addresses[] (array); older nodes
// return .address (string). Accept either so `entry.owner` populates
// regardless of node version. Same shape helper as tld-index.js.
function ownerFromVout(vout) {
if (!vout || !vout.scriptPubKey) return null;
const sp = vout.scriptPubKey;
if (typeof sp.address === "string") return sp.address;
if (Array.isArray(sp.addresses) && sp.addresses.length) return sp.addresses[0];
return null;
}
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false, enforceTld = false } = {}) {
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
@ -382,14 +393,26 @@ export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, c
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
// The mint output's scriptPubKey.address is the initial owner —
// needed for the signed-records-manifest write path (POST
// /api/records/<name>) so the gateway can reject a signed
// manifest that wasn't signed by the current NFT holder.
// Updated in the UPD branch below on every ownership-touching tx.
owner: ownerFromVout(mint),
});
} else if (payload.o === "UPD") {
const entry = names.get(key);
if (!entry) continue;
const proves = vouts.some((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proves) continue;
// The vout that carries the same category IS the new NFT holder.
// (Consensus: only an input that held the category could produce an
// output that carries it; the tx author decides which address gets
// it.) Update entry.owner to that vout's address so the records-
// manifest verifier reads the CURRENT holder, not the mint address.
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
entry.owner = ownerFromVout(proving) ?? entry.owner;
}
}
// Attached rather than returned separately so every existing caller keeps
@ -442,14 +465,16 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
owner: ownerFromVout(mint),
});
} else if (payload.o === "UPD") {
const entry = names.get(key);
if (!entry) continue;
const proves = vouts.some((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proves) continue;
const proving = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!proving) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
entry.owner = ownerFromVout(proving) ?? entry.owner;
}
}
return names;