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 "