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:
parent
c68eaf53c2
commit
63e9b31c92
2 changed files with 432 additions and 26 deletions
|
|
@ -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 }] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue