feat(bns): name marketplace and TLD-owner co-sign rule
Two gaps the owner panel left open. First, a hidden TLD was only a UI gate: anyone could still broadcast a REG under it and every indexer admitted it. Second, there was no way to sell a name without trusting the other side. Co-sign rule (consensus, applied in lockstep by bns.js and resolver-web.js): a REG under a TLD whose records at that height say policy "cosign" or hidden 1 is indexed only if the transaction carries the TLD's own certificate. The certificate can only be spent by the owner's key and is re-issued to them in the same transaction, so it is a co-signature nobody can forge and nothing is consumed. The TLD map now keeps the TUPD timeline so policy is evaluated at the REG height. Owners register under their private TLDs with the certificate added from their own wallet; third parties under a "cosign" TLD build the full transaction, sign their inputs and queue it at /api/cosign, where the owner approves it from the dashboard (signCosignRequest refuses to sign unless the certificate returns to the same locking script). Marketplace: a listing is the seller's certificate input plus a price output signed SIGHASH_SINGLE|ANYONECANPAY, stored by the gateway as a bulletin board (/api/market, verified against the on-chain owner and pruned when the certificate moves). The buyer completes it in one transaction, so the seller is paid exactly when the name moves. Cancelling also spends the certificate once so the offer is void. Site: market.html, Sell sub-tab and Pending approvals in the portal, Market link in nav and footer, six dictionaries extended, cache tags bumped. Verified on chipnet: cosigned.sc registered by a throwaway wallet through the queue with the .sc certificate back at the owner; aloevera.test listed and delisted through the API. Ariadne's resolver-web.js copy and the mobile Bns.java port still need the co-sign rule; until then they admit REGs this index rejects.
This commit is contained in:
parent
1cedac22c6
commit
c8d465e2e4
7 changed files with 889 additions and 54 deletions
|
|
@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
|
|||
// are visible from the top of the file.
|
||||
import {
|
||||
utf8ToBin, base64ToBin, sha256, ripemd160,
|
||||
secp256k1, encodeCashAddress,
|
||||
secp256k1, encodeCashAddress, cashAddressToLockingBytecode, hexToBin, binToHex,
|
||||
} from "@bitauth/libauth";
|
||||
import WebSocket from "ws";
|
||||
// resolver-web.js lives at ../lib/, not alongside this file — the previous
|
||||
|
|
@ -30,6 +30,7 @@ import WebSocket from "ws";
|
|||
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";
|
||||
import { verifyListing } from "./market-tx.js";
|
||||
|
||||
// TLD-beacon snapshot cache. Rebuilds by walking TLD_BEACON via electrum,
|
||||
// verbose-fetching every tx, and passing history+txs to buildTldSnapshot.
|
||||
|
|
@ -101,6 +102,84 @@ function writeHiddenTlds(set) {
|
|||
_writeFile(HIDDEN_TLDS_FILE, JSON.stringify({ hidden: [...set].sort() }, null, 2) + "\n", "utf8");
|
||||
}
|
||||
let HIDDEN_TLDS = readHiddenTlds();
|
||||
|
||||
// ---------- marketplace + co-sign storage and helpers ----------
|
||||
const MARKET_FILE = process.env.BNS_MARKET_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "market.json");
|
||||
const COSIGN_FILE = process.env.BNS_COSIGN_FILE || _join(_dirname(fileURLToPath(import.meta.url)), "cosign.json");
|
||||
function readJsonList(file) { try { if (!_existsSync(file)) return []; const j = JSON.parse(_readFile(file, "utf8")); return Array.isArray(j) ? j : []; } catch { return []; } }
|
||||
const readMarket = () => readJsonList(MARKET_FILE);
|
||||
const writeMarket = (list) => _writeFile(MARKET_FILE, JSON.stringify(list, null, 2) + "\n", "utf8");
|
||||
const readCosign = () => readJsonList(COSIGN_FILE);
|
||||
const writeCosign = (list) => _writeFile(COSIGN_FILE, JSON.stringify(list, null, 2) + "\n", "utf8");
|
||||
|
||||
function lockingHexOf(cashaddr) {
|
||||
const r = cashAddressToLockingBytecode(cashaddr);
|
||||
if (typeof r === "string") throw new Error(`bad address ${cashaddr}: ${r}`);
|
||||
return binToHex(r.bytecode);
|
||||
}
|
||||
const scripthashOfLock = (lockHex) => binToHex(sha256.hash(hexToBin(lockHex)).slice().reverse());
|
||||
|
||||
// Recover the signer of a BCH message signature over sha256(msg) and return
|
||||
// it as a CashAddress with the same prefix as `likeAddress`.
|
||||
function recoverSigner(sigB64, msg, likeAddress) {
|
||||
try {
|
||||
const sigBytes = base64ToBin(sigB64);
|
||||
if (sigBytes.length !== 65) return null;
|
||||
const digest = sha256.hash(utf8ToBin(msg));
|
||||
const pub = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), (sigBytes[0] - 27) & 3, digest);
|
||||
if (typeof pub === "string") return null;
|
||||
const prefix = String(likeAddress || "bchtest:").split(":")[0] || "bchtest";
|
||||
const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(pub)) });
|
||||
return typeof enc === "string" ? enc : (enc?.address ?? null);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// One full-electrum call (beacon-only indexers don't serve listunspent).
|
||||
async function withFullElectrum(fn) {
|
||||
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 = null, lastErr = null;
|
||||
for (const s of candidates) { try { el = await connectAny([s], WebSocket); break; } catch (e) { lastErr = e; } }
|
||||
if (!el) throw new Error("no full electrum reachable: " + String(lastErr?.message || lastErr));
|
||||
try { return await fn(el); } finally { try { el.close(); } catch {} }
|
||||
}
|
||||
async function listUnspentForLock(lockHex) {
|
||||
return withFullElectrum((el) => el.call("blockchain.scripthash.listunspent", [scripthashOfLock(lockHex), "include_tokens"]));
|
||||
}
|
||||
async function outpointUnspent(lockHex, txid, vout) {
|
||||
const list = await listUnspentForLock(lockHex).catch(() => null);
|
||||
if (!list) return true; // electrum blip: don't reject; the buyer's broadcast is the final check
|
||||
return list.some((u) => u.tx_hash === txid && u.tx_pos === vout);
|
||||
}
|
||||
async function findTokenUtxo(lockHex, category, commitmentHex) {
|
||||
const list = await listUnspentForLock(lockHex);
|
||||
const u = list.find((x) => x.token_data?.category === category && x.token_data?.nft?.commitment === commitmentHex);
|
||||
if (!u) return null;
|
||||
return { txid: u.tx_hash, vout: u.tx_pos, satoshis: String(u.value), lockingBytecode: lockHex,
|
||||
token: { category: u.token_data.category, amount: String(u.token_data.amount ?? 0), nft: { capability: u.token_data.nft.capability, commitment: u.token_data.nft.commitment } } };
|
||||
}
|
||||
// Listings whose certificate is still where the seller left it. Cached 30 s.
|
||||
let _marketCheck = { at: 0, list: null };
|
||||
async function activeListings() {
|
||||
if (_marketCheck.list && Date.now() - _marketCheck.at < 30000) return _marketCheck.list;
|
||||
const stored = readMarket();
|
||||
if (!stored.length) { _marketCheck = { at: Date.now(), list: [] }; return []; }
|
||||
const idx = await getIndex();
|
||||
const keep = [];
|
||||
const byLock = new Map();
|
||||
for (const l of stored) {
|
||||
const entry = idx.get(l.name);
|
||||
if (!entry || entry.owner !== l.seller || entry.category !== l.category) continue; // sold or moved
|
||||
const lock = lockingHexOf(l.seller);
|
||||
if (!byLock.has(lock)) byLock.set(lock, await listUnspentForLock(lock).catch(() => null));
|
||||
const list = byLock.get(lock);
|
||||
if (list && !list.some((u) => u.tx_hash === l.source_output.txid && u.tx_pos === l.source_output.vout)) continue; // spent
|
||||
keep.push({ ...l, records: entry.records ?? {} });
|
||||
}
|
||||
if (keep.length !== stored.length) writeMarket(keep);
|
||||
_marketCheck = { at: Date.now(), list: keep };
|
||||
return keep;
|
||||
}
|
||||
const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500";
|
||||
const SUB_BASE = (process.env.BNS_SUB_BASE || "bch.navigate.st").toLowerCase(); // subdomain suffix
|
||||
const aws = new AwsClient({
|
||||
|
|
@ -989,6 +1068,147 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// --- marketplace: atomic name sales ---
|
||||
// A listing is a seller-signed partial transaction (see market-tx.js).
|
||||
// GET /api/market -> { count, listings: [...] } (spent/invalid ones pruned)
|
||||
// POST /api/market body=listing -> verifies signature, ownership and that the
|
||||
// certificate UTXO is unspent, then stores it
|
||||
// DELETE /api/market/<name> -> owner-signed (x-bns-ts / x-bns-sig over
|
||||
// sha256("BNS-MARKET1\n<name>\n<ts>"))
|
||||
// GET /api/market/<name> -> one listing
|
||||
if (req.url === "/api/market" || req.url.startsWith("/api/market/") || req.url.startsWith("/api/market?")) {
|
||||
res.setHeader("access-control-allow-origin", "*");
|
||||
res.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
|
||||
res.setHeader("content-type", "application/json");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
const one = req.url.startsWith("/api/market/") ? decodeURIComponent(req.url.slice("/api/market/".length).split("?")[0]) : "";
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const listings = await activeListings();
|
||||
if (one) {
|
||||
const name = normalizeName(one);
|
||||
const l = listings.find((x) => x.name === name);
|
||||
if (!l) { res.writeHead(404); res.end(JSON.stringify({ error: "not for sale" })); return; }
|
||||
res.writeHead(200); res.end(JSON.stringify(l, null, 2)); return;
|
||||
}
|
||||
res.setHeader("cache-control", "public, max-age=15");
|
||||
res.writeHead(200); res.end(JSON.stringify({ count: listings.length, listings }, null, 2)); return;
|
||||
}
|
||||
if (req.method === "POST" && !one) {
|
||||
const chunks = []; let total = 0;
|
||||
for await (const c of req) { total += c.length; if (total > 64 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "listing > 64 KB" })); return; } chunks.push(c); }
|
||||
let listing; try { listing = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
|
||||
const name = normalizeName(String(listing?.name || ""));
|
||||
const entry = (await getIndex()).get(name);
|
||||
if (!entry) { res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
|
||||
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "index has no owner for this name yet — retry in 30s" })); return; }
|
||||
const ownerLock = lockingHexOf(entry.owner);
|
||||
const check = verifyListing(listing, { category: entry.category, ownerLockingBytecode: ownerLock });
|
||||
if (!check.ok) { res.writeHead(400); res.end(JSON.stringify({ error: "listing rejected: " + check.error })); return; }
|
||||
const unspent = await outpointUnspent(ownerLock, listing.source_output.txid, listing.source_output.vout);
|
||||
if (!unspent) { res.writeHead(409); res.end(JSON.stringify({ error: "the certificate UTXO in this listing is already spent" })); return; }
|
||||
const stored = {
|
||||
v: 1, name, tld: name.includes(".") ? name.split(".").pop() : "bch", seller: entry.owner, category: entry.category,
|
||||
price_sats: String(check.price_sats), partial_tx: listing.partial_tx, source_output: listing.source_output,
|
||||
records: entry.records ?? {}, created_at: new Date().toISOString(),
|
||||
};
|
||||
const all = readMarket().filter((x) => x.name !== name);
|
||||
all.push(stored); writeMarket(all); _marketCheck = { at: 0, list: null };
|
||||
res.writeHead(200); res.end(JSON.stringify({ ok: true, listing: stored })); return;
|
||||
}
|
||||
if (req.method === "DELETE" && one) {
|
||||
const name = normalizeName(one);
|
||||
const entry = (await getIndex()).get(name);
|
||||
if (!entry?.owner) { res.writeHead(404); res.end(JSON.stringify({ error: "unknown name" })); return; }
|
||||
const ts = Number(req.headers["x-bns-ts"] || 0);
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or stale" })); return; }
|
||||
const who = recoverSigner(String(req.headers["x-bns-sig"] || ""), `BNS-MARKET1\n${name}\n${ts}`, entry.owner);
|
||||
if (who !== entry.owner) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match current on-chain NFT owner" })); return; }
|
||||
writeMarket(readMarket().filter((x) => x.name !== name)); _marketCheck = { at: 0, list: null };
|
||||
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, removed: true })); return;
|
||||
}
|
||||
res.writeHead(405); res.end(JSON.stringify({ error: "GET, POST or DELETE" }));
|
||||
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `market error: ${e.message}` })); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- TLD certificate UTXO (for co-signed registrations) ---
|
||||
// GET /api/tld-cert/<tld> -> { tld, owner, utxo: {txid, vout, satoshis, lockingBytecode, token} }
|
||||
if (req.url.startsWith("/api/tld-cert/")) {
|
||||
res.setHeader("access-control-allow-origin", "*");
|
||||
res.setHeader("content-type", "application/json");
|
||||
const tld = decodeURIComponent(req.url.slice("/api/tld-cert/".length).split("?")[0]).toLowerCase();
|
||||
try {
|
||||
const snap = await getTldSnapshot();
|
||||
const t = (snap.tlds || []).find((x) => x.tld === tld);
|
||||
if (!t) { res.writeHead(404); res.end(JSON.stringify({ error: `.${tld} is not registered` })); return; }
|
||||
if (!t.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "no owner recorded" })); return; }
|
||||
const utxo = await findTokenUtxo(lockingHexOf(t.owner), t.category, Buffer.from(tld, "utf8").toString("hex"));
|
||||
if (!utxo) { res.writeHead(404); res.end(JSON.stringify({ error: "certificate UTXO not found at the recorded owner (moved?)" })); return; }
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.writeHead(200); res.end(JSON.stringify({ tld, owner: t.owner, category: t.category, utxo }, null, 2));
|
||||
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- co-sign queue: registrations under private TLDs awaiting the TLD owner ---
|
||||
// POST /api/cosign body { name, tld, buyer, tx, sourceOutputs, note? } -> { id }
|
||||
// GET /api/cosign?tld=<tld> -> pending requests for that TLD (owner's dashboard)
|
||||
// GET /api/cosign/<id> -> one request (buyer polls for status)
|
||||
// DELETE /api/cosign/<id> -> TLD-owner-signed ("BNS-COSIGN1\n<id>\n<ts>") after approving
|
||||
// (or the buyer, signed with the buyer address)
|
||||
// The gateway stores and relays; it signs nothing. The owner signs the TLD
|
||||
// certificate input in the dashboard and broadcasts.
|
||||
if (req.url === "/api/cosign" || req.url.startsWith("/api/cosign/") || req.url.startsWith("/api/cosign?")) {
|
||||
res.setHeader("access-control-allow-origin", "*");
|
||||
res.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
|
||||
res.setHeader("content-type", "application/json");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
const u = new URL(req.url, "http://x");
|
||||
const id = req.url.startsWith("/api/cosign/") ? decodeURIComponent(req.url.slice("/api/cosign/".length).split("?")[0]) : "";
|
||||
try {
|
||||
const all = readCosign().filter((r) => Date.now() - Date.parse(r.created_at) < 7 * 24 * 3600 * 1000);
|
||||
if (req.method === "GET") {
|
||||
if (id) { const r = all.find((x) => x.id === id); if (!r) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; } res.writeHead(200); res.end(JSON.stringify(r, null, 2)); return; }
|
||||
const tld = (u.searchParams.get("tld") || "").toLowerCase();
|
||||
const list = tld ? all.filter((r) => r.tld === tld) : all.map((r) => ({ id: r.id, name: r.name, tld: r.tld, buyer: r.buyer, created_at: r.created_at }));
|
||||
res.setHeader("cache-control", "no-store");
|
||||
res.writeHead(200); res.end(JSON.stringify({ count: list.length, requests: list }, null, 2)); return;
|
||||
}
|
||||
if (req.method === "POST" && !id) {
|
||||
const chunks = []; let total = 0;
|
||||
for await (const c of req) { total += c.length; if (total > 256 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "request > 256 KB" })); return; } chunks.push(c); }
|
||||
let body; try { body = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON" })); return; }
|
||||
const name = normalizeName(String(body?.name || ""));
|
||||
const tld = name.includes(".") ? name.split(".").pop() : "bch";
|
||||
if (!/^[0-9a-f]+$/i.test(String(body.tx || "")) || !Array.isArray(body.sourceOutputs) || !body.buyer) { res.writeHead(400); res.end(JSON.stringify({ error: "need { name, buyer, tx (hex), sourceOutputs[] }" })); return; }
|
||||
if ((await getIndex()).has(name)) { res.writeHead(409); res.end(JSON.stringify({ error: `"${name}" is already registered` })); return; }
|
||||
const rid = createHash("sha256").update(body.tx).digest("hex").slice(0, 16);
|
||||
const rec = { id: rid, name, tld, buyer: String(body.buyer), tx: body.tx, sourceOutputs: body.sourceOutputs, note: String(body.note || "").slice(0, 280), created_at: new Date().toISOString() };
|
||||
writeCosign([...all.filter((r) => r.id !== rid), rec]);
|
||||
res.writeHead(200); res.end(JSON.stringify({ ok: true, id: rid })); return;
|
||||
}
|
||||
if (req.method === "DELETE" && id) {
|
||||
const r = all.find((x) => x.id === id);
|
||||
if (!r) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }
|
||||
const ts = Number(req.headers["x-bns-ts"] || 0);
|
||||
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or stale" })); return; }
|
||||
const snap = await getTldSnapshot();
|
||||
const t = (snap.tlds || []).find((x) => x.tld === r.tld);
|
||||
const allowed = [t?.owner, r.buyer].filter(Boolean);
|
||||
const sig = String(req.headers["x-bns-sig"] || "");
|
||||
const ok = allowed.some((addr) => recoverSigner(sig, `BNS-COSIGN1\n${id}\n${ts}`, addr) === addr);
|
||||
if (!ok) { res.writeHead(403); res.end(JSON.stringify({ error: "only the TLD owner or the buyer can remove this request" })); return; }
|
||||
writeCosign(all.filter((x) => x.id !== id));
|
||||
res.writeHead(200); res.end(JSON.stringify({ ok: true, id, removed: true })); return;
|
||||
}
|
||||
res.writeHead(405); res.end(JSON.stringify({ error: "GET, POST or DELETE" }));
|
||||
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `cosign error: ${e.message}` })); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- site files (Sirius Studio) ---
|
||||
// The name's own folder on Sia is bns/<name>/. Writes go there and ONLY
|
||||
// there, whatever the on-chain s3 record says — so no name can be used
|
||||
|
|
|
|||
|
|
@ -131,13 +131,48 @@ async function buildTldMap(provider) {
|
|||
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
||||
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
||||
if (!mint) continue;
|
||||
tlds.set(tld, { category: mint.tokenData.category, regHeight: height, regTxid: tx_hash });
|
||||
tlds.set(tld, {
|
||||
category: mint.tokenData.category, regHeight: height, regTxid: tx_hash,
|
||||
records: payload.r ?? {},
|
||||
updates: [{ height, txid: tx_hash, records: payload.r ?? {} }],
|
||||
});
|
||||
} else if (payload.o === "TUPD") {
|
||||
// Authoritative only if the tx re-issues the TLD NFT (same as tld-index.js).
|
||||
const t = tlds.get(tld);
|
||||
if (!t) continue;
|
||||
if (!tx.vout.some((v) => v.tokenData && v.tokenData.category === t.category)) continue;
|
||||
t.records = payload.r ?? {};
|
||||
t.updates.push({ height, txid: tx_hash, records: t.records });
|
||||
}
|
||||
// TUPDs are ignored for the enforcement map — we only need existence + registration height.
|
||||
}
|
||||
return tlds;
|
||||
}
|
||||
|
||||
// Policy in force at `height` (0 = mempool → latest). KEEP IN LOCKSTEP with
|
||||
// resolver-web.js tldRecordsAt / cosignRequired / carriesTldCertificate.
|
||||
export function tldRecordsAt(tldEntry, height) {
|
||||
if (!tldEntry) return {};
|
||||
const ups = tldEntry.updates || [];
|
||||
if (!ups.length) return tldEntry.records || {};
|
||||
let rec = {};
|
||||
for (const u of ups) {
|
||||
if (height > 0 && (u.height <= 0 || u.height > height)) continue;
|
||||
rec = u.records || {};
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
// Co-sign rule: a REG under a TLD that is private (`hidden: 1`) or has
|
||||
// `policy: "cosign"` at that height is admitted only if the same transaction
|
||||
// carries the TLD's certificate in an output — proof the TLD owner signed.
|
||||
export function cosignRequired(tldEntry, height) {
|
||||
const r = tldRecordsAt(tldEntry, height);
|
||||
return r.policy === "cosign" || r.hidden === 1 || r.hidden === true || r.hidden === "1";
|
||||
}
|
||||
export function carriesTldCertificate(vouts, tldEntry, tld) {
|
||||
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
||||
return (vouts || []).some((v) => v.tokenData && v.tokenData.category === tldEntry.category && v.tokenData.nft?.commitment === commitment);
|
||||
}
|
||||
|
||||
// The gate the design specifies (Decentralized.DNS/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. Mempool-friendly: if either side is
|
||||
|
|
@ -156,11 +191,14 @@ function tldAdmits(tld, regHeight, tldMap) {
|
|||
// the design doc. Off by default while the mainnet migration and grandfathering
|
||||
// story lands; a chipnet deployment can flip it on immediately to prove the
|
||||
// path end-to-end.
|
||||
export async function buildIndex(provider, { enforceTld = false } = {}) {
|
||||
// In enforcement mode, fetch the TLD map first — we need it before we
|
||||
// decide which name REGs to keep. Both fetches share `provider`, so a single
|
||||
// electrum connection carries both.
|
||||
const tldMap = enforceTld ? await buildTldMap(provider) : null;
|
||||
export async function buildIndex(provider, { enforceTld = false, cosignGate = true } = {}) {
|
||||
// Fetch the TLD map first — needed for the existence gate when enforcing
|
||||
// and always for the co-sign rule. Both fetches share `provider`.
|
||||
let tldMap = null;
|
||||
if (enforceTld || cosignGate) {
|
||||
try { tldMap = await buildTldMap(provider); }
|
||||
catch (e) { if (enforceTld) throw e; tldMap = null; }
|
||||
}
|
||||
|
||||
const history = await provider.getHistory(BEACON);
|
||||
history.sort(byHeightThenTxid);
|
||||
|
|
@ -191,6 +229,10 @@ export async function buildIndex(provider, { enforceTld = false } = {}) {
|
|||
// TLD gate — see design §4 / tldAdmits above. Only applies to REG; UPDs
|
||||
// are transitions on names already admitted, so no gate needed there.
|
||||
if (enforceTld && !tldAdmits(tldOf(name), height, tldMap)) continue;
|
||||
if (cosignGate && tldMap) {
|
||||
const tldEntry = tldMap.get(tldOf(name));
|
||||
if (tldEntry && cosignRequired(tldEntry, height) && !carriesTldCertificate(tx.vout, tldEntry, tldOf(name))) continue;
|
||||
}
|
||||
// the same tx must mint a certificate whose commitment is the name
|
||||
const commitment = Buffer.from(name, "utf8").toString("hex");
|
||||
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
||||
|
|
|
|||
262
src/lib/market-tx.js
Normal file
262
src/lib/market-tx.js
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
// Atomic name sales without a covenant.
|
||||
//
|
||||
// A listing is a PARTIAL transaction the seller signs once:
|
||||
// input 0 = the name's certificate UTXO (the NFT)
|
||||
// output 0 = the asking price, paid to the seller
|
||||
// signed with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY | SIGHASH_FORKID (0xc3).
|
||||
// That signature commits to input 0 and output 0 only, so anyone can append
|
||||
// their own inputs (funding) and outputs (the certificate to themselves, the
|
||||
// BNS UPD payload, beacon dust, change) and broadcast. The seller is paid in
|
||||
// the same transaction that moves the certificate — or not at all.
|
||||
//
|
||||
// Cancelling a listing = the seller spends the certificate UTXO (a no-op UPD
|
||||
// is enough); the partial signature then refers to a spent outpoint.
|
||||
//
|
||||
// The completed transaction carries a UPD payload + beacon dust so every BNS
|
||||
// indexer sees the ownership change (the carrier output decides the owner).
|
||||
//
|
||||
// Shared by the browser bundle (seller/buyer) and the gateway (listing
|
||||
// validation). No wallet or network access in here.
|
||||
|
||||
import {
|
||||
encodeTransaction,
|
||||
decodeTransaction,
|
||||
generateSigningSerializationBCH,
|
||||
hash256,
|
||||
hash160,
|
||||
secp256k1,
|
||||
hexToBin,
|
||||
binToHex,
|
||||
utf8ToBin,
|
||||
} from "@bitauth/libauth";
|
||||
import {
|
||||
BEACON,
|
||||
BEACON_DUST_SAT,
|
||||
CERTIFICATE_DUST_SAT,
|
||||
P2PKH_DUST_SAT,
|
||||
encodePayload,
|
||||
opReturnScript,
|
||||
normalizeName,
|
||||
selectInputs,
|
||||
placeholderUnlocker,
|
||||
} from "./register-tx.js";
|
||||
|
||||
export const SIGHASH_ALL_FORKID = 0x41;
|
||||
export const SIGHASH_SINGLE_ANYONECANPAY_FORKID = 0xc3;
|
||||
|
||||
const toInput = (utxo, unlocking) => ({
|
||||
outpointTransactionHash: hexToBin(utxo.txid),
|
||||
outpointIndex: utxo.vout,
|
||||
sequenceNumber: 0xfffffffe,
|
||||
unlockingBytecode: unlocking ?? placeholderUnlocker(),
|
||||
});
|
||||
const tokenOf = (t) => (t ? {
|
||||
amount: BigInt(t.amount ?? 0),
|
||||
category: hexToBin(t.category),
|
||||
...(t.nft ? { nft: { capability: t.nft.capability, commitment: hexToBin(t.nft.commitment || "") } } : {}),
|
||||
} : undefined);
|
||||
// JSON round-trip for libauth source outputs (gateway queues, WC payloads).
|
||||
export const sourceOutputToJson = (so) => ({
|
||||
lockingBytecode: so.lockingBytecode instanceof Uint8Array ? binToHex(so.lockingBytecode) : String(so.lockingBytecode),
|
||||
valueSatoshis: String(so.valueSatoshis),
|
||||
...(so.token ? {
|
||||
token: {
|
||||
category: so.token.category instanceof Uint8Array ? binToHex(so.token.category) : String(so.token.category),
|
||||
amount: String(so.token.amount ?? 0),
|
||||
...(so.token.nft ? { nft: { capability: so.token.nft.capability, commitment: so.token.nft.commitment instanceof Uint8Array ? binToHex(so.token.nft.commitment) : String(so.token.nft.commitment || "") } } : {}),
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
export const sourceOutputFromJson = (j) => ({
|
||||
lockingBytecode: hexToBin(j.lockingBytecode),
|
||||
valueSatoshis: BigInt(j.valueSatoshis ?? j.satoshis),
|
||||
...(j.token ? { token: tokenOf(j.token) } : {}),
|
||||
});
|
||||
const toSource = (utxo, unlocking) => ({
|
||||
...toInput(utxo, unlocking),
|
||||
lockingBytecode: utxo.lockingBytecode instanceof Uint8Array ? utxo.lockingBytecode : hexToBin(utxo.lockingBytecode),
|
||||
valueSatoshis: BigInt(utxo.satoshis),
|
||||
...(utxo.token ? { token: tokenOf(utxo.token) } : {}),
|
||||
});
|
||||
|
||||
function signInput(transaction, sourceOutputs, inputIndex, key, sighash) {
|
||||
const source = sourceOutputs[inputIndex];
|
||||
const serialization = generateSigningSerializationBCH(
|
||||
{ inputIndex, sourceOutputs, transaction },
|
||||
{ coveredBytecode: source.lockingBytecode, signingSerializationType: Uint8Array.of(sighash) },
|
||||
);
|
||||
const digest = hash256(serialization);
|
||||
const signature = secp256k1.signMessageHashSchnorr(key.privateKey, digest);
|
||||
if (typeof signature === "string") throw new Error(`signing failed on input ${inputIndex}: ${signature}`);
|
||||
const sig = Uint8Array.from([...signature, sighash]);
|
||||
return Uint8Array.from([sig.length, ...sig, key.publicKey.length, ...key.publicKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign only the inputs the caller has keys for; inputs whose keyFor returns
|
||||
* null keep their existing unlocking bytecode (a co-signer's, or the seller's
|
||||
* SINGLE|ANYONECANPAY signature on a listing).
|
||||
*/
|
||||
export function signInputs(transaction, sourceOutputs, keyFor, { sighash = SIGHASH_ALL_FORKID, skip = [] } = {}) {
|
||||
const signed = {
|
||||
...transaction,
|
||||
inputs: transaction.inputs.map((input, i) => {
|
||||
if (skip.includes(i)) return input;
|
||||
const key = keyFor(binToHex(sourceOutputs[i].lockingBytecode));
|
||||
if (!key) return input;
|
||||
return { ...input, unlockingBytecode: signInput(transaction, sourceOutputs, i, key, sighash) };
|
||||
}),
|
||||
};
|
||||
const encoded = encodeTransaction(signed);
|
||||
return { transaction: signed, hex: binToHex(encoded), txid: binToHex(hash256(encoded).slice().reverse()) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seller side: a partially signed sale of `certificateUtxo` for `priceSats`.
|
||||
* Returns everything a buyer (and the gateway) needs to complete and verify.
|
||||
*/
|
||||
export function buildListing({ name, certificateUtxo, priceSats, sellerAddress, addressToLockingBytecode, keyFor }) {
|
||||
const rawName = normalizeName(name);
|
||||
const price = BigInt(priceSats);
|
||||
if (price < P2PKH_DUST_SAT) throw new Error(`price must be at least ${P2PKH_DUST_SAT} sat`);
|
||||
if (!certificateUtxo?.token?.nft) throw new Error("certificateUtxo does not carry a certificate");
|
||||
if (certificateUtxo.token.nft.commitment !== binToHex(utf8ToBin(rawName))) throw new Error("certificate commitment does not match the name");
|
||||
const sellerLock = addressToLockingBytecode(sellerAddress);
|
||||
const source = toSource(certificateUtxo);
|
||||
const transaction = {
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
inputs: [toInput(certificateUtxo)],
|
||||
outputs: [{ lockingBytecode: sellerLock, valueSatoshis: price }],
|
||||
};
|
||||
const key = keyFor(binToHex(source.lockingBytecode));
|
||||
if (!key) throw new Error("this wallet does not hold the key for the certificate");
|
||||
transaction.inputs[0].unlockingBytecode = signInput(transaction, [source], 0, key, SIGHASH_SINGLE_ANYONECANPAY_FORKID);
|
||||
return {
|
||||
v: 1,
|
||||
name: rawName,
|
||||
seller: sellerAddress,
|
||||
price_sats: price.toString(),
|
||||
category: certificateUtxo.token.category,
|
||||
partial_tx: binToHex(encodeTransaction(transaction)),
|
||||
source_output: {
|
||||
txid: certificateUtxo.txid,
|
||||
vout: certificateUtxo.vout,
|
||||
satoshis: BigInt(certificateUtxo.satoshis).toString(),
|
||||
lockingBytecode: binToHex(source.lockingBytecode),
|
||||
// JSON-safe copy: chain UTXOs carry the fungible amount as a BigInt.
|
||||
token: { category: certificateUtxo.token.category, amount: String(certificateUtxo.token.amount ?? 0), nft: certificateUtxo.token.nft ? { capability: certificateUtxo.token.nft.capability, commitment: certificateUtxo.token.nft.commitment } : undefined },
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseP2pkhUnlock(unlocking) {
|
||||
// <sigLen><sig...><pkLen><pk...>
|
||||
const sigLen = unlocking[0];
|
||||
const sig = unlocking.slice(1, 1 + sigLen);
|
||||
const pkLen = unlocking[1 + sigLen];
|
||||
const pubkey = unlocking.slice(2 + sigLen, 2 + sigLen + pkLen);
|
||||
return { sig, pubkey };
|
||||
}
|
||||
const p2pkhLock = (pubkey) => Uint8Array.from([0x76, 0xa9, 0x14, ...hash160(pubkey), 0x88, 0xac]);
|
||||
|
||||
/**
|
||||
* Check a listing without touching the network: structure, commitment,
|
||||
* seller lock, and that input 0's SINGLE|ANYONECANPAY signature is valid
|
||||
* for exactly this outpoint and this payment output. `expected` comes from
|
||||
* the caller's own index: { category, name, ownerLockingBytecode? }.
|
||||
*/
|
||||
export function verifyListing(listing, expected = {}) {
|
||||
const fail = (why) => ({ ok: false, error: why });
|
||||
try {
|
||||
if (!listing || listing.v !== 1) return fail("unsupported listing version");
|
||||
const rawName = normalizeName(listing.name);
|
||||
const decoded = decodeTransaction(hexToBin(listing.partial_tx));
|
||||
if (typeof decoded === "string") return fail("partial_tx does not decode: " + decoded);
|
||||
if (decoded.inputs.length !== 1 || decoded.outputs.length < 1) return fail("partial_tx must have exactly one input and a payment output");
|
||||
const so = listing.source_output;
|
||||
if (!so?.token?.nft) return fail("source_output carries no certificate");
|
||||
if (binToHex(decoded.inputs[0].outpointTransactionHash) !== so.txid || decoded.inputs[0].outpointIndex !== so.vout) return fail("input does not spend source_output");
|
||||
if (so.token.nft.commitment !== binToHex(utf8ToBin(rawName))) return fail("certificate commitment does not match the name");
|
||||
if (expected.category && so.token.category !== expected.category) return fail("certificate category does not match the registry");
|
||||
const price = decoded.outputs[0].valueSatoshis;
|
||||
if (price < P2PKH_DUST_SAT) return fail("price below dust");
|
||||
if (BigInt(listing.price_sats) !== price) return fail("price_sats does not match output 0");
|
||||
const { sig, pubkey } = parseP2pkhUnlock(decoded.inputs[0].unlockingBytecode);
|
||||
if (sig.length !== 65 || sig[64] !== SIGHASH_SINGLE_ANYONECANPAY_FORKID) return fail("input 0 is not signed SINGLE|ANYONECANPAY");
|
||||
const lock = hexToBin(so.lockingBytecode);
|
||||
if (binToHex(p2pkhLock(pubkey)) !== binToHex(lock)) return fail("public key does not own the certificate output");
|
||||
if (expected.ownerLockingBytecode && binToHex(lock) !== expected.ownerLockingBytecode) return fail("certificate is not held by the current owner");
|
||||
if (binToHex(decoded.outputs[0].lockingBytecode) !== binToHex(lock)) return fail("payment does not go to the certificate holder");
|
||||
const source = { ...decoded.inputs[0], lockingBytecode: lock, valueSatoshis: BigInt(so.satoshis), token: tokenOf(so.token) };
|
||||
const serialization = generateSigningSerializationBCH(
|
||||
{ inputIndex: 0, sourceOutputs: [source], transaction: decoded },
|
||||
{ coveredBytecode: lock, signingSerializationType: Uint8Array.of(SIGHASH_SINGLE_ANYONECANPAY_FORKID) },
|
||||
);
|
||||
if (!secp256k1.verifySignatureSchnorr(sig.slice(0, 64), pubkey, hash256(serialization))) return fail("seller signature is invalid");
|
||||
return { ok: true, name: rawName, price_sats: price, seller_lock: binToHex(lock), category: so.token.category };
|
||||
} catch (e) { return fail(String(e?.message || e)); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Buyer side: complete a listing. Appends funding inputs and the outputs
|
||||
* that hand the certificate to the buyer and tell the indexers about it.
|
||||
* Returns an unsigned-for-the-buyer transaction; sign with signInputs()
|
||||
* (input 0 keeps the seller's signature).
|
||||
*/
|
||||
export function completeSale({ listing, buyerAddress, buyerTokenAddress, utxos, records = {}, beaconAddress = BEACON, feeRate = 1, addressToLockingBytecode }) {
|
||||
const check = verifyListing(listing);
|
||||
if (!check.ok) throw new Error("bad listing: " + check.error);
|
||||
const rawName = check.name;
|
||||
const decoded = decodeTransaction(hexToBin(listing.partial_tx));
|
||||
const so = listing.source_output;
|
||||
const sellerInput = decoded.inputs[0];
|
||||
const sellerSource = { ...sellerInput, lockingBytecode: hexToBin(so.lockingBytecode), valueSatoshis: BigInt(so.satoshis), token: tokenOf(so.token) };
|
||||
const payment = decoded.outputs[0];
|
||||
const price = payment.valueSatoshis;
|
||||
|
||||
const buyerLock = addressToLockingBytecode(buyerAddress);
|
||||
const certLock = addressToLockingBytecode(buyerTokenAddress ?? buyerAddress);
|
||||
const beaconLock = addressToLockingBytecode(beaconAddress);
|
||||
const payload = encodePayload("UPD", rawName, records);
|
||||
// The certificate's own sats come in with input 0 and go out with the
|
||||
// certificate output; the buyer funds price + beacon dust + fee.
|
||||
const fixedOut = price + CERTIFICATE_DUST_SAT + BEACON_DUST_SAT - BigInt(so.satoshis);
|
||||
let selection = selectInputs(utxos, fixedOut + 400n, { requireGenesis: false });
|
||||
const measure = (tx) => encodeTransaction(tx).length;
|
||||
for (let round = 0; round < 6; round++) {
|
||||
const { inputs, totalSats } = selection;
|
||||
const build = (withChange, changeSats) => ({
|
||||
version: 2, locktime: 0,
|
||||
inputs: [sellerInput, ...inputs.map((u) => toInput(u))],
|
||||
outputs: [
|
||||
payment,
|
||||
{ lockingBytecode: certLock, valueSatoshis: CERTIFICATE_DUST_SAT, token: tokenOf(so.token) },
|
||||
{ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n },
|
||||
{ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT },
|
||||
...(withChange ? [{ lockingBytecode: buyerLock, valueSatoshis: changeSats }] : []),
|
||||
],
|
||||
});
|
||||
const fee = BigInt(Math.ceil(measure(build(true, 1_000_000n)) * feeRate));
|
||||
const need = fixedOut + fee;
|
||||
if (totalSats < need) {
|
||||
const grown = selectInputs(utxos, need + 200n, { requireGenesis: false });
|
||||
if (grown.inputs.length > inputs.length) { selection = grown; continue; }
|
||||
const err = new Error(`not enough funds: need ${need} sat, have ${totalSats} sat`);
|
||||
err.needSats = need; err.haveSats = totalSats; throw err;
|
||||
}
|
||||
let change = totalSats - need;
|
||||
const withChange = change >= P2PKH_DUST_SAT;
|
||||
if (!withChange) change = 0n;
|
||||
const transaction = build(withChange, change);
|
||||
return {
|
||||
name: rawName,
|
||||
transaction,
|
||||
sourceOutputs: [sellerSource, ...inputs.map((u) => toSource(u))],
|
||||
sellerInputIndex: 0,
|
||||
costs: { priceSats: price, beaconDustSats: BEACON_DUST_SAT, certificateDustSats: CERTIFICATE_DUST_SAT, chainFeeSats: withChange ? fee : totalSats - fixedOut, changeSats: change, totalInputSats: totalSats },
|
||||
};
|
||||
}
|
||||
throw new Error("could not converge on a fee — please retry");
|
||||
}
|
||||
|
|
@ -269,6 +269,13 @@ export function buildRegistrationTx({
|
|||
beaconAddress = BEACON,
|
||||
feeRate = 1,
|
||||
addressToLockingBytecode,
|
||||
// Co-sign for private / cosign-policy TLDs: the TLD's own certificate UTXO
|
||||
// is spent as an extra input and re-issued unchanged to
|
||||
// `cosignTokenAddress` (default: the owner's token address). Indexers
|
||||
// admit a REG under such a TLD only when the TLD certificate appears in
|
||||
// the transaction's outputs — proof the TLD owner signed.
|
||||
cosignUtxo = null,
|
||||
cosignTokenAddress = null,
|
||||
}) {
|
||||
if (typeof addressToLockingBytecode !== "function") {
|
||||
throw new Error("addressToLockingBytecode must be injected (see chain.js)");
|
||||
|
|
@ -277,6 +284,9 @@ export function buildRegistrationTx({
|
|||
const payload = encodePayload("REG", rawName, records);
|
||||
const serviceFee = BigInt(feeSats ?? 0n);
|
||||
const tldFee = BigInt(tldFeeSats ?? 0n);
|
||||
if (cosignUtxo && !cosignUtxo.token?.nft) throw new Error("cosignUtxo does not carry a TLD certificate");
|
||||
const cosignSats = cosignUtxo ? BigInt(cosignUtxo.satoshis) : 0n;
|
||||
const cosignLock = cosignUtxo ? addressToLockingBytecode(cosignTokenAddress ?? ownerTokenAddress ?? ownerAddress) : null;
|
||||
if (serviceFee > 0n && !feeAddress) throw new Error("feeSats set but feeAddress missing");
|
||||
if (tldFee > 0n && !tldFeeAddress) throw new Error("tldFeeSats set but tldFeeAddress missing");
|
||||
|
||||
|
|
@ -289,16 +299,19 @@ export function buildRegistrationTx({
|
|||
const tldFeeLock = tldFee > 0n ? addressToLockingBytecode(tldFeeAddress) : null;
|
||||
|
||||
// Everything the transaction must pay out, before miner fee and change.
|
||||
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee + tldFee;
|
||||
// The co-sign certificate's sats come in and go straight back out.
|
||||
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT + serviceFee + tldFee + cosignSats;
|
||||
|
||||
// Iterate: pick inputs, measure the real encoded size, re-check funding.
|
||||
let selection = selectInputs(utxos, fixedOut + 300n); // 300 = first-pass fee guess
|
||||
let selection = selectInputs(utxos, fixedOut + 300n - cosignSats); // 300 = first-pass fee guess
|
||||
let chainFee = 0n;
|
||||
let change = 0n;
|
||||
let withChange = true;
|
||||
|
||||
for (let round = 0; round < 6; round++) {
|
||||
const { inputs, totalSats } = selection;
|
||||
const { inputs: coinInputs } = selection;
|
||||
const inputs = cosignUtxo ? [...coinInputs, cosignUtxo] : coinInputs;
|
||||
const totalSats = selection.totalSats + cosignSats;
|
||||
const genesisUtxo = inputs[0];
|
||||
const category = hexToBin(genesisUtxo.txid);
|
||||
|
||||
|
|
@ -319,6 +332,19 @@ export function buildRegistrationTx({
|
|||
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
|
||||
if (tldFeeLock) outputs.push({ lockingBytecode: tldFeeLock, valueSatoshis: tldFee });
|
||||
if (feeLock) outputs.push({ lockingBytecode: feeLock, valueSatoshis: serviceFee });
|
||||
if (cosignUtxo) {
|
||||
// The TLD certificate, re-issued unchanged: same category, same
|
||||
// commitment, back to the TLD owner. Its presence is the co-signature.
|
||||
outputs.push({
|
||||
lockingBytecode: cosignLock,
|
||||
valueSatoshis: cosignSats,
|
||||
token: {
|
||||
amount: BigInt(cosignUtxo.token.amount ?? 0),
|
||||
category: hexToBin(cosignUtxo.token.category),
|
||||
nft: { capability: cosignUtxo.token.nft.capability, commitment: hexToBin(cosignUtxo.token.nft.commitment || "") },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
|
||||
};
|
||||
|
||||
|
|
@ -328,8 +354,8 @@ export function buildRegistrationTx({
|
|||
const need = fixedOut + chainFee;
|
||||
|
||||
if (totalSats < need) {
|
||||
const grown = selectInputs(utxos, need + 200n);
|
||||
if (grown.inputs.length > inputs.length) { selection = grown; continue; }
|
||||
const grown = selectInputs(utxos, need + 200n - cosignSats);
|
||||
if (grown.inputs.length > coinInputs.length) { selection = grown; continue; }
|
||||
throw new InsufficientFundsError(
|
||||
`not enough funds: need ${need} sat, have ${totalSats} sat`,
|
||||
{ needSats: need, haveSats: totalSats },
|
||||
|
|
@ -344,8 +370,8 @@ export function buildRegistrationTx({
|
|||
chainFee = totalSats - fixedOut;
|
||||
const minFee = BigInt(Math.ceil(measure(noChange) * feeRate));
|
||||
if (chainFee < minFee) {
|
||||
const grown = selectInputs(utxos, fixedOut + minFee + P2PKH_DUST_SAT + 200n);
|
||||
if (grown.inputs.length > inputs.length) { selection = grown; continue; }
|
||||
const grown = selectInputs(utxos, fixedOut + minFee + P2PKH_DUST_SAT + 200n - cosignSats);
|
||||
if (grown.inputs.length > coinInputs.length) { selection = grown; continue; }
|
||||
throw new InsufficientFundsError(
|
||||
`not enough funds: need ${fixedOut + minFee} sat, have ${totalSats} sat`,
|
||||
{ needSats: fixedOut + minFee, haveSats: totalSats },
|
||||
|
|
@ -363,6 +389,7 @@ export function buildRegistrationTx({
|
|||
outputMap.beacon = i++;
|
||||
if (tldFeeLock) outputMap.tldOwnerFee = i++;
|
||||
if (feeLock) outputMap.serviceFee = i++;
|
||||
if (cosignUtxo) outputMap.cosignCertificate = i++;
|
||||
|
||||
return {
|
||||
name: rawName,
|
||||
|
|
@ -627,6 +654,122 @@ export function buildUpdateTx({
|
|||
throw new Error("could not converge on a fee — please retry");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TUPD transaction — mirror of buildUpdateTx for TLD-registry NFTs.
|
||||
*
|
||||
* Differences from buildUpdateTx:
|
||||
* • payload op = "TUPD" (not "UPD")
|
||||
* • commitment = TLD label (not "<label>.<tld>")
|
||||
* • beacon = TLD_BEACON by default (not the name beacon)
|
||||
*
|
||||
* Ownership proof: an input carries the TLD's category (the TREG NFT). This
|
||||
* mirrors how UPD proves name ownership. Same fee-selection loop, same
|
||||
* OP_RETURN + beacon dust structure.
|
||||
*/
|
||||
export function buildUpdateTldTx({
|
||||
tld,
|
||||
ownerAddress,
|
||||
ownerTokenAddress,
|
||||
records = {},
|
||||
certificateUtxo,
|
||||
utxos,
|
||||
beaconAddress = TLD_BEACON,
|
||||
feeRate = 1,
|
||||
addressToLockingBytecode,
|
||||
}) {
|
||||
if (typeof addressToLockingBytecode !== "function") {
|
||||
throw new Error("addressToLockingBytecode must be injected (see chain.js)");
|
||||
}
|
||||
const rawTld = normalizeTld(tld);
|
||||
if (!certificateUtxo?.token?.nft) throw new Error("certificateUtxo does not carry a certificate");
|
||||
const expected = binToHex(utf8ToBin(rawTld));
|
||||
if (certificateUtxo.token.nft.commitment !== expected) {
|
||||
throw new Error(
|
||||
`TLD certificate commitment mismatch: holds "${binToUtf8(hexToBin(certificateUtxo.token.nft.commitment))}", ` +
|
||||
`asked to update ".${rawTld}"`,
|
||||
);
|
||||
}
|
||||
const payload = encodePayload("TUPD", rawTld, records);
|
||||
|
||||
const ownerLock = addressToLockingBytecode(ownerAddress);
|
||||
const certLock = addressToLockingBytecode(ownerTokenAddress ?? ownerAddress);
|
||||
const beaconLock = addressToLockingBytecode(beaconAddress);
|
||||
|
||||
const fixedOut = CERTIFICATE_DUST_SAT + BEACON_DUST_SAT;
|
||||
let funding = selectInputs(utxos, fixedOut + 400n, { requireGenesis: false });
|
||||
let chainFee = 0n;
|
||||
let change = 0n;
|
||||
let withChange = true;
|
||||
|
||||
for (let round = 0; round < 6; round++) {
|
||||
const inputs = [certificateUtxo, ...funding.inputs];
|
||||
const totalSats = certificateUtxo.satoshis + funding.totalSats;
|
||||
|
||||
const build = (includeChange, changeSats) => {
|
||||
const outputs = [];
|
||||
if (includeChange) outputs.push({ lockingBytecode: ownerLock, valueSatoshis: changeSats });
|
||||
outputs.push({
|
||||
lockingBytecode: certLock,
|
||||
valueSatoshis: CERTIFICATE_DUST_SAT,
|
||||
token: {
|
||||
amount: 0n,
|
||||
category: hexToBin(certificateUtxo.token.category),
|
||||
nft: { capability: "none", commitment: utf8ToBin(rawTld) },
|
||||
},
|
||||
});
|
||||
outputs.push({ lockingBytecode: opReturnScript(payload), valueSatoshis: 0n });
|
||||
outputs.push({ lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT });
|
||||
return { version: 2, locktime: 0, inputs: inputs.map(toInput), outputs };
|
||||
};
|
||||
|
||||
const size = measure(build(true, 1_000_000n));
|
||||
chainFee = BigInt(Math.ceil(size * feeRate));
|
||||
const need = fixedOut + chainFee;
|
||||
|
||||
if (totalSats < need) {
|
||||
const grown = selectInputs(utxos, need + 200n, { requireGenesis: false });
|
||||
if (grown.inputs.length > funding.inputs.length) { funding = grown; continue; }
|
||||
throw new InsufficientFundsError(
|
||||
`not enough funds: need ${need} sat, have ${totalSats} sat`,
|
||||
{ needSats: need, haveSats: totalSats },
|
||||
);
|
||||
}
|
||||
|
||||
change = totalSats - need;
|
||||
withChange = change >= P2PKH_DUST_SAT;
|
||||
if (!withChange) { chainFee = totalSats - fixedOut; change = 0n; }
|
||||
|
||||
const transaction = build(withChange, change);
|
||||
const outputMap = {};
|
||||
let i = 0;
|
||||
if (withChange) outputMap.change = i++;
|
||||
outputMap.certificate = i++;
|
||||
outputMap.opReturn = i++;
|
||||
outputMap.beacon = i++;
|
||||
|
||||
return {
|
||||
tld: rawTld,
|
||||
displayName: `.${rawTld}`,
|
||||
category: certificateUtxo.token.category,
|
||||
transaction,
|
||||
sourceOutputs: inputs.map(toSourceOutput),
|
||||
inputUtxos: inputs,
|
||||
outputMap,
|
||||
payload,
|
||||
costs: {
|
||||
certificateDustSats: CERTIFICATE_DUST_SAT,
|
||||
beaconDustSats: BEACON_DUST_SAT,
|
||||
serviceFeeSats: 0n,
|
||||
chainFeeSats: chainFee,
|
||||
changeSats: change,
|
||||
totalInputSats: totalSats,
|
||||
netCostSats: chainFee + BEACON_DUST_SAT,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("could not converge on a fee — please retry");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-off "prepare wallet" self-send used when a wallet holds no
|
||||
* coin at vout 0. Output 0 goes back to the owner, which is genesis-eligible.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ import {
|
|||
import { signTransaction, txidOfHex } from "./sign-tx.js";
|
||||
import { buildIndex, fetchTldMap } from "./resolver-web.js";
|
||||
import { REGISTRAR } from "../registrar-config.js";
|
||||
import { hexToBin, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth";
|
||||
import { hexToBin, binToHex, decodeTransaction, lockingBytecodeToCashAddress, sha256, utf8ToBin, binToBase64 } from "@bitauth/libauth";
|
||||
import { signInputs, sourceOutputToJson, sourceOutputFromJson } from "./market-tx.js";
|
||||
|
||||
// Revenue split: percentage of the name-registration service fee that flows
|
||||
// to the TLD's owner (whoever holds the TLD's on-chain NFT). The rest is the
|
||||
|
|
@ -119,6 +120,8 @@ export async function quoteRegistration(client, {
|
|||
utxos,
|
||||
serviceFee = REGISTRAR.serviceFee,
|
||||
feeRate = REGISTRAR.feeRate,
|
||||
cosignUtxo = null,
|
||||
cosignTokenAddress = null,
|
||||
}) {
|
||||
const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress]));
|
||||
// Revenue split: 90% of the service fee flows to whoever owns the TLD
|
||||
|
|
@ -157,9 +160,21 @@ export async function quoteRegistration(client, {
|
|||
beaconAddress: REGISTRAR.beacon,
|
||||
feeRate,
|
||||
addressToLockingBytecode,
|
||||
cosignUtxo,
|
||||
cosignTokenAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The TLD certificate UTXO held by this wallet, if any — needed to co-sign a
|
||||
* registration under a private (hidden / cosign-policy) TLD the wallet owns.
|
||||
*/
|
||||
export function findTldCertificateUtxo(utxos, { tld, category }) {
|
||||
const commitment = [...new TextEncoder().encode(String(tld).toLowerCase())]
|
||||
.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return utxos.find((u) => u.token?.nft && (!category || u.token.category === category) && u.token.nft.commitment === commitment) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a name using the built-in wallet: the buyer's own key signs, and the
|
||||
* certificate is minted to the buyer's own address. We never hold either.
|
||||
|
|
@ -167,12 +182,134 @@ export async function quoteRegistration(client, {
|
|||
* @param {object} o.wallet a BuiltInWallet
|
||||
* @param {(step: string, detail?: object) => void} [o.onProgress]
|
||||
*/
|
||||
// CashTokens can only create a category by spending an outpoint at index 0.
|
||||
// A wallet that has never registered before may not have one; make it one.
|
||||
// Returns the (possibly replaced) coin list and the prep txid, if any.
|
||||
async function ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress }) {
|
||||
if (hasGenesisInput(utxos)) return { utxos, prepTxid: null };
|
||||
onProgress("preparing-wallet");
|
||||
const prep = buildPrepTx({
|
||||
ownerAddress: wallet.address,
|
||||
utxos,
|
||||
feeRate,
|
||||
addressToLockingBytecode,
|
||||
});
|
||||
const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => wallet.keyFor(h));
|
||||
const prepTxid = await broadcastTx(client, signedPrep.hex);
|
||||
onProgress("prepared", { txid: prepTxid });
|
||||
// Spend the prep tx's outputs directly rather than waiting for the indexer.
|
||||
const fresh = signedPrep.transaction.outputs.slice(0, 2).map((o, vout) => ({
|
||||
txid: prepTxid,
|
||||
vout,
|
||||
satoshis: o.valueSatoshis,
|
||||
address: wallet.address,
|
||||
lockingBytecode: addressToLockingBytecode(wallet.address),
|
||||
}));
|
||||
return { utxos: fresh, prepTxid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Third-party registration under a `cosign`-policy TLD. Builds the complete
|
||||
* REG with the TLD owner's certificate as the LAST input (re-issued to the
|
||||
* owner's token address, which is the co-signature the index requires),
|
||||
* signs only this wallet's inputs and returns a JSON-safe request for the
|
||||
* gateway's /api/cosign queue. The owner finishes it with signCosignRequest.
|
||||
*
|
||||
* @param {object} o.tldCert the /api/tld-cert/<tld> response: { owner, category, utxo }
|
||||
*/
|
||||
export async function buildCosignRequest(client, {
|
||||
wallet,
|
||||
name,
|
||||
records = {},
|
||||
serviceFee = REGISTRAR.serviceFee,
|
||||
feeRate = REGISTRAR.feeRate,
|
||||
tldCert,
|
||||
onProgress = () => {},
|
||||
}) {
|
||||
if (!tldCert?.utxo?.token?.nft || !tldCert.owner) throw new Error("tldCert must be the /api/tld-cert response ({ owner, utxo })");
|
||||
onProgress("checking-availability");
|
||||
const availability = await checkAvailability(client, name);
|
||||
if (!availability.available) throw new Error(`"${availability.name}" is already registered`);
|
||||
|
||||
onProgress("loading-coins");
|
||||
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
|
||||
const spendable = utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n);
|
||||
if (spendable === 0n) throw new Error("wallet is empty — fund the address first");
|
||||
let prepTxid = null;
|
||||
({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress }));
|
||||
|
||||
onProgress("building");
|
||||
const built = await quoteRegistration(client, {
|
||||
name,
|
||||
ownerAddress: wallet.address,
|
||||
records,
|
||||
utxos,
|
||||
serviceFee,
|
||||
feeRate,
|
||||
cosignUtxo: { ...tldCert.utxo, address: tldCert.owner },
|
||||
cosignTokenAddress: toTokenAddress(tldCert.owner),
|
||||
});
|
||||
const cosignInputIndex = built.transaction.inputs.length - 1;
|
||||
|
||||
onProgress("signing");
|
||||
// The builder leaves a size placeholder in every unlocking script; blank
|
||||
// the certificate input so the request carries a visibly unsigned slot.
|
||||
// (BCH sighash never covers other inputs' scripts, so our signatures stay
|
||||
// valid whatever the owner puts there.)
|
||||
const draft = { ...built.transaction, inputs: built.transaction.inputs.map((inp, i) => (i === cosignInputIndex ? { ...inp, unlockingBytecode: new Uint8Array() } : inp)) };
|
||||
const signed = signInputs(draft, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [cosignInputIndex] });
|
||||
return {
|
||||
name: built.name,
|
||||
displayName: built.displayName,
|
||||
category: built.category,
|
||||
tx: signed.hex,
|
||||
sourceOutputs: built.sourceOutputs.map(sourceOutputToJson),
|
||||
cosignInputIndex,
|
||||
costs: built.costs,
|
||||
prepTxid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TLD owner side of a co-sign request: sign the inputs this wallet holds the
|
||||
* key for (the TLD certificate) and return the fully signed hex to broadcast.
|
||||
* Refuses to sign unless every certificate input we sign is re-issued, with
|
||||
* its satoshis, to the very same locking script — a request can never move
|
||||
* the TLD certificate away from its owner.
|
||||
*/
|
||||
export function signCosignRequest(request, keyFor) {
|
||||
const decoded = decodeTransaction(hexToBin(String(request.tx || "")));
|
||||
if (typeof decoded === "string") throw new Error("invalid transaction: " + decoded);
|
||||
const sourceOutputs = (request.sourceOutputs || []).map(sourceOutputFromJson);
|
||||
if (sourceOutputs.length !== decoded.inputs.length) throw new Error("sourceOutputs do not match the transaction inputs");
|
||||
const skip = [];
|
||||
let signedInputs = 0;
|
||||
decoded.inputs.forEach((_inp, i) => {
|
||||
const so = sourceOutputs[i];
|
||||
if (!keyFor(binToHex(so.lockingBytecode))) { skip.push(i); return; }
|
||||
if (so.token?.nft) {
|
||||
const back = decoded.outputs.some((o) => o.token
|
||||
&& binToHex(o.token.category) === binToHex(so.token.category)
|
||||
&& binToHex(o.token.nft?.commitment ?? new Uint8Array()) === binToHex(so.token.nft.commitment ?? new Uint8Array())
|
||||
&& binToHex(o.lockingBytecode) === binToHex(so.lockingBytecode)
|
||||
&& o.valueSatoshis >= so.valueSatoshis);
|
||||
if (!back) throw new Error("refusing to sign: the request would not return your TLD certificate to you");
|
||||
}
|
||||
signedInputs += 1;
|
||||
});
|
||||
const out = signInputs(decoded, sourceOutputs, keyFor, { skip });
|
||||
return { hex: out.hex, txid: out.txid, signedInputs };
|
||||
}
|
||||
|
||||
export async function registerWithBuiltInWallet(client, {
|
||||
wallet,
|
||||
name,
|
||||
records = {},
|
||||
serviceFee = REGISTRAR.serviceFee,
|
||||
feeRate = REGISTRAR.feeRate,
|
||||
// { tld, category }: co-sign with the TLD certificate this wallet holds
|
||||
// (private TLD the owner registers under). See buildRegistrationTx.
|
||||
cosign = null,
|
||||
onProgress = () => {},
|
||||
}) {
|
||||
onProgress("checking-availability");
|
||||
|
|
@ -183,42 +320,15 @@ export async function registerWithBuiltInWallet(client, {
|
|||
let utxos = await getUtxosForAddresses(client, wallet.watchedAddresses);
|
||||
const spendable = utxos.filter((u) => !u.token).reduce((s, u) => s + u.satoshis, 0n);
|
||||
if (spendable === 0n) throw new Error("wallet is empty — fund the address first");
|
||||
|
||||
// CashTokens can only create a category by spending an outpoint at index 0.
|
||||
// A wallet that has never registered before may not have one; make it one.
|
||||
let prepTxid = null;
|
||||
if (!hasGenesisInput(utxos)) {
|
||||
onProgress("preparing-wallet");
|
||||
const prep = buildPrepTx({
|
||||
ownerAddress: wallet.address,
|
||||
utxos,
|
||||
feeRate,
|
||||
addressToLockingBytecode,
|
||||
});
|
||||
const signedPrep = signTransaction(prep.transaction, prep.sourceOutputs, (h) => wallet.keyFor(h));
|
||||
prepTxid = await broadcastTx(client, signedPrep.hex);
|
||||
onProgress("prepared", { txid: prepTxid });
|
||||
// Spend the prep tx's output 0 directly rather than waiting for the indexer.
|
||||
utxos = [
|
||||
{
|
||||
txid: prepTxid,
|
||||
vout: 0,
|
||||
satoshis: signedPrep.transaction.outputs[0].valueSatoshis,
|
||||
address: wallet.address,
|
||||
lockingBytecode: addressToLockingBytecode(wallet.address),
|
||||
},
|
||||
...(signedPrep.transaction.outputs.length > 1
|
||||
? [{
|
||||
txid: prepTxid,
|
||||
vout: 1,
|
||||
satoshis: signedPrep.transaction.outputs[1].valueSatoshis,
|
||||
address: wallet.address,
|
||||
lockingBytecode: addressToLockingBytecode(wallet.address),
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
let cosignUtxo = null;
|
||||
if (cosign) {
|
||||
cosignUtxo = findTldCertificateUtxo(utxos, cosign);
|
||||
if (!cosignUtxo) throw new Error(`this wallet does not hold the certificate for .${cosign.tld} — only the TLD owner can co-sign`);
|
||||
}
|
||||
|
||||
let prepTxid = null;
|
||||
({ utxos, prepTxid } = await ensureGenesisInput(client, { wallet, utxos, feeRate, onProgress }));
|
||||
|
||||
onProgress("building");
|
||||
const built = await quoteRegistration(client, {
|
||||
name,
|
||||
|
|
@ -227,6 +337,8 @@ export async function registerWithBuiltInWallet(client, {
|
|||
utxos,
|
||||
serviceFee,
|
||||
feeRate,
|
||||
cosignUtxo,
|
||||
cosignTokenAddress: cosignUtxo ? toTokenAddress(wallet.address) : null,
|
||||
});
|
||||
|
||||
onProgress("signing");
|
||||
|
|
|
|||
|
|
@ -307,13 +307,54 @@ export async function fetchTldMap(el) {
|
|||
regHeight: height,
|
||||
regTxid: tx_hash,
|
||||
mintScriptHex: mint.scriptPubKey?.hex ?? null,
|
||||
records: payload.r ?? {},
|
||||
// Policy timeline — every authoritative TUPD in order, so a REG can be
|
||||
// judged against the policy in force AT ITS HEIGHT (a TLD going
|
||||
// private later must not invalidate names registered before).
|
||||
updates: [{ height, txid: tx_hash, records: payload.r ?? {} }],
|
||||
});
|
||||
} else if (payload.o === "TUPD") {
|
||||
// Authoritative only if the tx re-issues the TLD's NFT (same rule as
|
||||
// tld-index.js): an output carrying the category proves the owner signed.
|
||||
const t = tlds.get(tld);
|
||||
if (!t) continue;
|
||||
if (!vouts.some((v) => v.tokenData && v.tokenData.category === t.category)) continue;
|
||||
t.records = payload.r ?? {};
|
||||
t.updates.push({ height, txid: tx_hash, records: t.records });
|
||||
}
|
||||
// TUPDs are irrelevant to the enforcement map — we only need existence + regHeight.
|
||||
}
|
||||
return tlds;
|
||||
}
|
||||
|
||||
// TLD policy in force at `height` (0 = mempool → latest). Confirmed REGs only
|
||||
// see confirmed TUPDs at or below their height; mempool REGs see everything.
|
||||
export function tldRecordsAt(tldEntry, height) {
|
||||
if (!tldEntry) return {};
|
||||
const ups = tldEntry.updates || [];
|
||||
if (!ups.length) return tldEntry.records || {};
|
||||
let rec = {};
|
||||
for (const u of ups) {
|
||||
if (height > 0 && (u.height <= 0 || u.height > height)) continue;
|
||||
rec = u.records || {};
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Co-sign rule (consensus among BNS clients; KEEP IN LOCKSTEP with bns.js):
|
||||
// a REG under a TLD whose policy at that height is `cosign` — or whose
|
||||
// listing is switched off (`hidden: 1`, a private TLD) — is admitted only if
|
||||
// the REG transaction also carries the TLD's own certificate in an output.
|
||||
// Only an input holding that immutable NFT can produce such an output, so
|
||||
// the TLD owner necessarily signed the transaction.
|
||||
export function cosignRequired(tldEntry, height) {
|
||||
const r = tldRecordsAt(tldEntry, height);
|
||||
return r.policy === "cosign" || r.hidden === 1 || r.hidden === true || r.hidden === "1";
|
||||
}
|
||||
export function carriesTldCertificate(vouts, tldEntry, tld) {
|
||||
const commitHex = strToHex(tld);
|
||||
return (vouts || []).some((v) => v.tokenData && v.tokenData.category === tldEntry.category && v.tokenData.nft && v.tokenData.nft.commitment === commitHex);
|
||||
}
|
||||
|
||||
|
||||
// ---- the resolver ----
|
||||
// Pass `client` to reuse an already-open Electrum connection (the registrar
|
||||
|
|
@ -334,12 +375,17 @@ function ownerFromVout(vout) {
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false, enforceTld = false } = {}) {
|
||||
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false, enforceTld = false, cosignGate = true } = {}) {
|
||||
const el = client ?? (await connectAny(electrum, WS, { directIP }));
|
||||
try {
|
||||
// Fetch the TLD map first (single electrum connection, one extra roundtrip).
|
||||
// We need it before deciding which name REGs to keep. Only when enforcing.
|
||||
const tldMap = enforceTld ? await fetchTldMap(el) : null;
|
||||
// We need it before deciding which name REGs to keep: for the existence
|
||||
// gate when enforcing, and always for the co-sign rule (cosignGate).
|
||||
let tldMap = null;
|
||||
if (enforceTld || cosignGate) {
|
||||
try { tldMap = await fetchTldMap(el); }
|
||||
catch (e) { if (enforceTld) throw e; tldMap = null; /* cosign gate degrades open if the TLD beacon is unreadable */ }
|
||||
}
|
||||
|
||||
// With a pool, take the UNION of every server's beacon history. A single
|
||||
// server can silently omit a registration, and because "first valid REG
|
||||
|
|
@ -387,6 +433,12 @@ export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, c
|
|||
// TLD gate — see §4 of the design. Only applies to REG; UPDs modify
|
||||
// names already admitted, so no gate needed there.
|
||||
if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue;
|
||||
// Co-sign gate: private / cosign-policy TLDs need the TLD certificate
|
||||
// in this same transaction. See cosignRequired() above.
|
||||
if (cosignGate && tldMap) {
|
||||
const tldEntry = tldMap.get(tldOfKey(key));
|
||||
if (tldEntry && cosignRequired(tldEntry, height) && !carriesTldCertificate(vouts, tldEntry, tldOfKey(key))) continue;
|
||||
}
|
||||
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
|
||||
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
|
||||
if (!mint) continue;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export {
|
|||
splitServiceFee,
|
||||
setRecordsWithBuiltInWallet,
|
||||
setRecordsWithExternalWallet,
|
||||
setTldRecordsWithBuiltInWallet,
|
||||
signRecordsManifest,
|
||||
findCertificateUtxo,
|
||||
getBalance,
|
||||
} from "../lib/registrar.js";
|
||||
|
|
@ -37,6 +39,8 @@ export {
|
|||
InsufficientFundsError,
|
||||
} from "../lib/register-tx.js";
|
||||
export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js";
|
||||
export { buildListing, completeSale, verifyListing, signInputs, sourceOutputToJson, sourceOutputFromJson } from "../lib/market-tx.js";
|
||||
export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest } from "../lib/registrar.js";
|
||||
export { toTokenAddress, addressToLockingBytecode, addressToScripthash, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
|
||||
export { resolveName, buildIndex } from "../lib/resolver-web.js";
|
||||
export {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue