From c8d465e2e444af8577adb47aa17dd0b15604ada6 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 17 Sep 2026 04:04:22 +0200 Subject: [PATCH] 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. --- src/gateway/public-gateway.mjs | 222 +++++++++++++++++++++++++++- src/lib/bns.js | 56 ++++++- src/lib/market-tx.js | 262 +++++++++++++++++++++++++++++++++ src/lib/register-tx.js | 157 +++++++++++++++++++- src/lib/registrar.js | 182 ++++++++++++++++++----- src/lib/resolver-web.js | 60 +++++++- src/web/register-entry.js | 4 + 7 files changed, 889 insertions(+), 54 deletions(-) create mode 100644 src/lib/market-tx.js diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 30dbd4b..7c124ad 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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/ -> owner-signed (x-bns-ts / x-bns-sig over + // sha256("BNS-MARKET1\n\n")) + // GET /api/market/ -> 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, 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= -> pending requests for that TLD (owner's dashboard) + // GET /api/cosign/ -> one request (buyer polls for status) + // DELETE /api/cosign/ -> TLD-owner-signed ("BNS-COSIGN1\n\n") 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//. Writes go there and ONLY // there, whatever the on-chain s3 record says — so no name can be used diff --git a/src/lib/bns.js b/src/lib/bns.js index 67674f1..0ed6e06 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -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); diff --git a/src/lib/market-tx.js b/src/lib/market-tx.js new file mode 100644 index 0000000..1ab02ee --- /dev/null +++ b/src/lib/market-tx.js @@ -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) { + // + 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"); +} diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js index dbece97..658a3b0 100644 --- a/src/lib/register-tx.js +++ b/src/lib/register-tx.js @@ -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 "