From d19cd6bde72525febcbe1e8c429a84f5fa54eada Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 22:29:38 +0200 Subject: [PATCH] BNS: commit-reveal for first registrations A registration shows its name to the mempool before it is mined, and "first REG wins" gives the name to whoever copies it with a better fee. A registrant can now publish a hash first (CMT) and reveal a block later: the REG carries the salt, and counts only if a commit of sha256(salt, name, certificate lock) is already 1..144 blocks deep. Someone who learns the name from the reveal has no such commit. Who wins a contested name: the first valid REG opens a window of six blocks, and within it the REG with the oldest commit takes the name; a REG without a commit ranks by its own block. After the window the registration is final. The addendum first let an older commit win for as long as the commit lived, which left every registration open for a week and let a standing commit beat any buyer; and a commit now lives a day, so keeping one alive costs more than owning the name. Optional on chipnet, where plain registrations keep working exactly as before (the live registry reduces to the same index). Required on mainnet, which starts empty. A registration straight into the covenant commits to the covenant address, fixing owner and expiry at commit time. One implementation for the JavaScript reducers; the Android reducer is now a pure function and agrees with it on every history the tests build. --- src/gateway/public-gateway.mjs | 5 + src/generate-network-constants.mjs | 5 + src/lib/bns.js | 47 +++-- src/lib/register-tx.js | 84 ++++++++- src/lib/registrar.js | 76 ++++++++ src/lib/registry-covenant.js | 39 +++- src/lib/resolver-web.d.ts | 10 + src/lib/resolver-web.js | 184 ++++++++++++++++--- src/web/register-entry.js | 3 +- test/commit-reveal.test.mjs | 282 +++++++++++++++++++++++++++++ test/covenant-names.test.mjs | 11 ++ 11 files changed, 697 insertions(+), 49 deletions(-) create mode 100644 test/commit-reveal.test.mjs diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 5d04dc3..2abd4aa 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -1267,6 +1267,11 @@ const server = http.createServer(async (req, res) => { name: key, tld, registered: !!entry, records: entry?.records ?? null, category: entry?.category ?? null, owner: entry?.owner ?? null, + // For contestBlocks after a name's first valid registration, a + // registration with an older commit can still take it + // (PROTOCOL-ADDENDUM-commit-reveal.md). `final_at` is the first + // height at which this one can no longer be displaced. + ...(entry ? { registered_at: entry.height > 0 ? entry.height : null, final_at: entry.height > 0 ? entry.height + (NETWORK.commitReveal?.contestBlocks ?? 0) + 1 : null } : {}), // A covenant-held name: who holds it and until when, checked against // the certificate's address. status "open" = lapsed, resolves to nothing. ...(entry?.covenant ? { diff --git a/src/generate-network-constants.mjs b/src/generate-network-constants.mjs index 0b7e0e1..cc6efb7 100644 --- a/src/generate-network-constants.mjs +++ b/src/generate-network-constants.mjs @@ -60,6 +60,11 @@ ${net.electrum.map(javaServer).join("\n")} public static final String[][] BLOCKLISTS = { ${(net.blocklists || []).map((l) => ` { ${q(l.name)}, ${q(l.beacon)}, ${q(l.scripthash)} },`).join("\n")} }; + // Commit–reveal for first registrations (PROTOCOL-ADDENDUM-commit-reveal.md). + public static final boolean COMMIT_REQUIRED = ${net.commitReveal?.required ? "true" : "false"}; + public static final long COMMIT_MIN_AGE = ${net.commitReveal?.minAge ?? 1}L; + public static final long COMMIT_MAX_AGE = ${net.commitReveal?.maxAge ?? 0}L; + public static final long CONTEST_BLOCKS = ${net.commitReveal?.contestBlocks ?? 0}L; // The registry covenant (PROTOCOL-ADDENDUM-covenant-names.md): a // covenant-held name sits at P2SH32(hash256(STATE + head + tail)). // COVENANT_TAIL is null while the network has none pinned. diff --git a/src/lib/bns.js b/src/lib/bns.js index ce9c86c..d7a0851 100644 --- a/src/lib/bns.js +++ b/src/lib/bns.js @@ -1,7 +1,7 @@ // BNS1 core: payload codec, chain indexer/resolver, and tx builders. import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js"; import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js"; -import { reduceBlocklist, soleCertificate, admitCovenantState, applyLifecycle, historyHeight } from "./resolver-web.js"; +import { reduceBlocklist, soleCertificate, admitCovenantState, applyLifecycle, historyHeight, registrationContest } from "./resolver-web.js"; // ---------- payload codec ---------- @@ -56,6 +56,8 @@ export function parseOpReturn(scriptHex) { // (PROTOCOL-ADDENDUM-blocklist.md); an RPT's `n` is an array. // KEEP IN LOCKSTEP with resolver-web.js parseOpReturnHex — memory rule. if (!KNOWN_OPS.has(p.o)) return null; + // CMT names nothing, only a hash (PROTOCOL-ADDENDUM-commit-reveal.md) + if (p.o === "CMT") return typeof p.h === "string" && /^[0-9a-f]{64}$/.test(p.h) ? p : null; if (typeof p.n === "string") return p; if (p.o === "RPT" && Array.isArray(p.n) && p.n.every((x) => typeof x === "string")) return p; } catch { /* not ours */ } @@ -77,7 +79,7 @@ export function onlyPayload(vouts) { // The set of payload opcodes this codec recognises. New opcodes go here AND in // resolver-web.js — divergence is a consensus split. -export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]); +export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK", "CMT"]); // ---------- indexer / resolver ---------- @@ -257,31 +259,40 @@ export async function buildIndex(provider, { enforceTld = false, cosignGate = tr const byCategory = new Map(); const released = new Map(); // name -> height of its release - for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) { + const ordered = orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs); + // Which REG takes a name (commit–reveal, the contest) is decided in ONE + // place, resolver-web.js; this reducer only says what makes a REG valid. + const contest = registrationContest({ + ordered, txs, + admits: (regTx, p, regHeight) => { + // TLD gate — see design §4 / tldAdmits above. + if (enforceTld && !tldAdmits(tldOf(p.n), regHeight, tldMap)) return null; + if (cosignGate && tldMap) { + const tldEntry = tldMap.get(tldOf(p.n)); + if (tldEntry && cosignRequired(tldEntry, regHeight) && !carriesTldCertificate(regTx.vout, tldEntry, tldOf(p.n))) return null; + } + // the same tx must mint a certificate whose commitment is the name, + // and it must be the only token of its category (resolver-web.js) + const commitment = Buffer.from(p.n, "utf8").toString("hex"); + const mint = regTx.vout.find((v) => v.tokenData?.nft?.commitment === commitment); + return mint && soleCertificate(regTx, mint) ? mint : null; + }, + }); + for (const { tx_hash, height } of ordered) { const tx = txs.get(tx_hash); const payload = onlyPayload(tx.vout); if (!payload) continue; const name = payload.n; - if (!RAWNAME_RE.test(name)) continue; + if (typeof name !== "string" || !RAWNAME_RE.test(name)) continue; // a CMT names nothing if (payload.o === "REG") { - if (names.has(name)) continue; // first valid REG wins + if (names.has(name)) continue; // taken if (!mayReRegister(released.get(name), height)) continue; // one-block gap after a release - // 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); - if (!mint) continue; - // the certificate must be the only token of its category (resolver-web.js) - if (!soleCertificate(tx, mint)) continue; + const won = contest.decide(tx_hash, name); + if (!won) continue; + const mint = won.mint; const entry = { name, category: mint.tokenData.category, records: payload.r ?? {}, txid: tx_hash, height, updatedTxid: null, diff --git a/src/lib/register-tx.js b/src/lib/register-tx.js index e7c802a..ebb1924 100644 --- a/src/lib/register-tx.js +++ b/src/lib/register-tx.js @@ -36,6 +36,7 @@ import { binToUtf8, encodeTransaction, decodeTransaction, + sha256, } from "@bitauth/libauth"; import { NETWORK } from "./resolver-web.js"; @@ -110,8 +111,10 @@ export function displayName(rawName) { // `claim` is a covenant-held name's state, { o: ownerPkh hex, e: expiry } // (PROTOCOL-ADDENDUM-covenant-names.md); absent for an ordinary name. -export function encodePayload(op, rawName, records, claim = null) { - const json = JSON.stringify(claim ? { o: op, n: rawName, r: records ?? {}, c: claim } : { o: op, n: rawName, r: records ?? {} }); +// `salt` (hex) makes a REG the reveal of an earlier commit +// (PROTOCOL-ADDENDUM-commit-reveal.md). +export function encodePayload(op, rawName, records, claim = null, salt = null) { + const json = JSON.stringify({ o: op, n: rawName, r: records ?? {}, ...(claim ? { c: claim } : {}), ...(salt ? { s: salt } : {}) }); const bytes = utf8ToBin(PREFIX + json); if (bytes.length > MAX_PAYLOAD_BYTES) { throw new Error(`records too large (${bytes.length} > ${MAX_PAYLOAD_BYTES} bytes)`); @@ -124,6 +127,35 @@ export function payloadBudget(rawName, op = "REG") { return MAX_PAYLOAD_BYTES - encodePayload(op, rawName, {}).length; } +// ---------- commit–reveal (PROTOCOL-ADDENDUM-commit-reveal.md) ---------- + +/** A registration's salt: 16 random bytes, hex in the reveal's `s`. */ +export const REG_SALT_BYTES = 16; +export function newRegistrationSalt() { + return crypto.getRandomValues(new Uint8Array(REG_SALT_BYTES)); +} +const asBytes = (x) => (x instanceof Uint8Array ? x : hexToBin(x)); +/** + * What a CMT commits to: sha256(salt ‖ name ‖ lock), where `lock` is the + * locking bytecode the certificate will be minted to. Binding the lock means + * a commit cannot be revealed to anyone else's address; the salt keeps the + * name from being found by hashing a wordlist. + */ +export function commitmentHash(salt, rawName, certificateLock) { + const s = asBytes(salt); + if (s.length !== REG_SALT_BYTES) throw new Error(`the registration salt must be exactly ${REG_SALT_BYTES} bytes`); + const name = utf8ToBin(normalizeName(rawName)); + const lock = asBytes(certificateLock); + const all = new Uint8Array(s.length + name.length + lock.length); + all.set(s); all.set(name, s.length); all.set(lock, s.length + name.length); + return sha256.hash(all); +} +export function encodeCommitPayload(commitment) { + const h = typeof commitment === "string" ? commitment : binToHex(commitment); + if (!/^[0-9a-f]{64}$/.test(h)) throw new Error("a commitment is 32 bytes"); + return utf8ToBin(PREFIX + JSON.stringify({ o: "CMT", h })); +} + // ---------- script helpers ---------- function pushData(data) { @@ -277,12 +309,17 @@ export function buildRegistrationTx({ // the transaction's outputs — proof the TLD owner signed. cosignUtxo = null, cosignTokenAddress = null, + // Commit–reveal: the salt of a commit made earlier for this name and this + // owner (commitmentHash). The REG then carries it as `s`. + salt = null, }) { if (typeof addressToLockingBytecode !== "function") { throw new Error("addressToLockingBytecode must be injected (see chain.js)"); } const rawName = normalizeName(name); - const payload = encodePayload("REG", rawName, records); + const saltHex = salt ? binToHex(asBytes(salt)) : null; + if (saltHex && saltHex.length !== REG_SALT_BYTES * 2) throw new Error(`the registration salt must be exactly ${REG_SALT_BYTES} bytes`); + const payload = encodePayload("REG", rawName, records, null, saltHex); 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"); @@ -420,6 +457,47 @@ export function buildRegistrationTx({ throw new Error("could not converge on a fee — please retry"); } +/** + * Build a CMT transaction: the commitment in an OP_RETURN, the beacon's dust, + * change. It mints nothing and says nothing about the name. + * Change is output 0, so it can serve as the reveal's genesis input. + */ +export function buildCommitTx({ commitment, utxos, changeAddress, beaconAddress = BEACON, feeRate = 1, addressToLockingBytecode }) { + if (typeof addressToLockingBytecode !== "function") throw new Error("addressToLockingBytecode must be injected (see chain.js)"); + const payload = encodeCommitPayload(commitment); + const changeLock = addressToLockingBytecode(changeAddress); + const beaconLock = addressToLockingBytecode(beaconAddress); + let selection = selectInputs(utxos, BEACON_DUST_SAT + P2PKH_DUST_SAT + 400n, { requireGenesis: false }); + for (let round = 0; round < 6; round++) { + const { inputs, totalSats } = selection; + const build = (changeSats) => ({ + version: 2, locktime: 0, inputs: inputs.map(toInput), + outputs: [ + { lockingBytecode: changeLock, valueSatoshis: changeSats }, + { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }, + { lockingBytecode: beaconLock, valueSatoshis: BEACON_DUST_SAT }, + ], + }); + const chainFee = BigInt(Math.ceil(measure(build(1_000_000n)) * feeRate)); + const need = BEACON_DUST_SAT + chainFee + P2PKH_DUST_SAT; + if (totalSats < need) { + const grown = selectInputs(utxos, need + 200n, { requireGenesis: false }); + if (grown.inputs.length > inputs.length) { selection = grown; continue; } + throw new InsufficientFundsError(`not enough funds: need ${need} sat, have ${totalSats} sat`, { needSats: need, haveSats: totalSats }); + } + const change = totalSats - BEACON_DUST_SAT - chainFee; + return { + transaction: build(change), + sourceOutputs: inputs.map(toSourceOutput), + inputUtxos: inputs, + outputMap: { change: 0, opReturn: 1, beacon: 2 }, + payload, + costs: { beaconDustSats: BEACON_DUST_SAT, chainFeeSats: chainFee, changeSats: change, netCostSats: BEACON_DUST_SAT + chainFee }, + }; + } + throw new Error("could not converge on a fee — please retry"); +} + /** * Build a TLD-registry TREG transaction — mint a per-TLD certificate onto the * TLD beacon. Same shape as buildRegistrationTx (genesis-input rule, dust diff --git a/src/lib/registrar.js b/src/lib/registrar.js index bf6a254..4c2d7ad 100644 --- a/src/lib/registrar.js +++ b/src/lib/registrar.js @@ -18,6 +18,9 @@ import { buildTldRegistrationTx, buildUpdateTx, buildReleaseTx, + buildCommitTx, + commitmentHash, + newRegistrationSalt, buildUpdateTldTx, buildPrepTx, hasGenesisInput, @@ -132,6 +135,7 @@ export async function quoteRegistration(client, { feeRate = REGISTRAR.feeRate, cosignUtxo = null, cosignTokenAddress = null, + salt = null, }) { const coins = utxos ?? (await getUtxosForAddresses(client, [ownerAddress])); // Revenue split: 90% of the service fee flows to whoever owns the TLD @@ -172,6 +176,7 @@ export async function quoteRegistration(client, { addressToLockingBytecode, cosignUtxo, cosignTokenAddress, + salt, }); } @@ -322,8 +327,19 @@ export async function registerWithBuiltInWallet(client, { // { tld, category }: co-sign with the TLD certificate this wallet holds // (private TLD the owner registers under). See buildRegistrationTx. cosign = null, + // Commit–reveal: the record commitWithBuiltInWallet returned for this name. + // The registration is then the reveal of that commit. + commit = null, onProgress = () => {}, }) { + if (commit) { + if (normalizeName(commit.name) !== normalizeName(name)) throw new Error("this commit was made for another name"); + if (commit.lock !== binToHex(addressToLockingBytecode(wallet.address))) throw new Error("this commit was made for another owner"); + const state = await commitState(client, commit); + if (!state.mature) throw new Error(state.expired + ? "this commit is too old to reveal — commit again" + : "this commit is not in a block yet — the registration can be sent after the next block"); + } onProgress("checking-availability"); const availability = await checkAvailability(client, name); if (!availability.available) throw new Error(availability.releasePending @@ -353,6 +369,7 @@ export async function registerWithBuiltInWallet(client, { feeRate, cosignUtxo, cosignTokenAddress: cosignUtxo ? toTokenAddress(wallet.address) : null, + salt: commit ? commit.salt : null, }); onProgress("signing"); @@ -376,6 +393,65 @@ export async function registerWithBuiltInWallet(client, { } +// ---------- commit–reveal (PROTOCOL-ADDENDUM-commit-reveal.md) ---------- + +/** + * Step one of a commit–reveal registration: publish the commitment. + * + * Returns the PENDING COMMIT — { name, salt, lock, commitment, txid, + * createdAt }, plain JSON. The caller must store it somewhere that survives + * a reload BEFORE doing anything else: the salt exists nowhere else, and + * without it the commit can never be revealed. + * + * `lock` is where the certificate will go. It defaults to the wallet's own + * address; pass `certificateLock` to commit for another destination (a + * covenant address — registryCommitment — or a buyer the registrar mints for). + */ +export async function commitWithBuiltInWallet(client, { wallet, name, certificateLock = null, feeRate = REGISTRAR.feeRate, onProgress = () => {} }) { + const rawName = normalizeName(name); + const lock = certificateLock ?? addressToLockingBytecode(wallet.address); + const salt = newRegistrationSalt(); + const commitment = commitmentHash(salt, rawName, lock); + onProgress("loading-coins"); + const utxos = await getUtxosForAddresses(client, wallet.watchedAddresses); + onProgress("building"); + const built = buildCommitTx({ commitment, utxos, changeAddress: wallet.address, feeRate, addressToLockingBytecode }); + onProgress("signing"); + const signed = signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h)); + // Hand the record over before broadcasting, so a caller that persists in + // onProgress has the salt even if the broadcast's reply is lost. + const pending = { name: rawName, salt: binToHex(salt), lock: binToHex(lock), commitment: binToHex(commitment), txid: signed.txid, createdAt: Date.now() }; + onProgress("committing", { pending }); + onProgress("broadcasting"); + const txid = await broadcastTx(client, signed.hex); + onProgress("committed", { txid, pending }); + return { ...pending, txid, costs: built.costs, hex: signed.hex }; +} + +/** + * Where a pending commit stands: { confirmations, height, mature, expired, + * revealBy }. `mature` = it can be revealed in the next block; `expired` = + * too old, commit again. Heights are null until the commit is in a block. + */ +export async function commitState(client, pending) { + const rules = NETWORK.commitReveal ?? { minAge: 1, maxAge: 0 }; + let tx = null; + try { tx = await getTransaction(client, pending.txid, true); } catch { /* not seen yet */ } + const confirmations = tx?.confirmations ?? 0; + if (!(confirmations >= 1)) return { confirmations: 0, height: null, mature: false, expired: false, revealBy: null }; + const head = await client.call("blockchain.headers.subscribe", []); + const tip = head?.height ?? 0; + const height = tip - confirmations + 1; + // The reveal is mined at tip + 1 at the earliest. + const ageAtNextBlock = tip + 1 - height; + return { + confirmations, height, + mature: ageAtNextBlock >= rules.minAge && ageAtNextBlock <= rules.maxAge, + expired: ageAtNextBlock > rules.maxAge, + revealBy: height + rules.maxAge, + }; +} + /** * Register a TLD certificate on the TLD-registry beacon. Same shape as * registerWithBuiltInWallet but calls buildTldRegistrationTx (payload op TREG, diff --git a/src/lib/registry-covenant.js b/src/lib/registry-covenant.js index 18208a3..e9c3795 100644 --- a/src/lib/registry-covenant.js +++ b/src/lib/registry-covenant.js @@ -33,7 +33,7 @@ import { unlockScript, SIG, PUBKEY, assemble, p2pkhSpecial, } from "./covenant.js"; import { tokenOf } from "./market-tx.js"; -import { normalizeName, encodePayload, opReturnScript, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT, BEACON_DUST_SAT } from "./register-tx.js"; +import { normalizeName, encodePayload, opReturnScript, commitmentHash, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT, BEACON_DUST_SAT } from "./register-tx.js"; import { BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT, renewalPriceSats, idlePremiumSats, openFloorSats, takeoverPriceSats, splitTakeover, phaseAt, PHASE, @@ -168,9 +168,9 @@ export function takeoverQuote(terms, state, height) { export const covenantClaimOf = (state) => ({ o: binToHex(bin(state.ownerPkh)), e: state.expiry }); /** [payload, beacon dust] — `state` null for a release, which has nothing to claim. */ -export function registryAnnouncement({ name, records = {}, beaconLock }, state, op = "UPD") { +export function registryAnnouncement({ name, records = {}, beaconLock, salt = null }, state, op = "UPD") { if (!beaconLock) throw new Error("announce.beaconLock is required"); - const payload = encodePayload(op, normalizeName(name), records, state ? covenantClaimOf(state) : null); + const payload = encodePayload(op, normalizeName(name), records, state ? covenantClaimOf(state) : null, salt ? binToHex(bin(salt)) : null); return [ { lockingBytecode: opReturnScript(payload), valueSatoshis: 0n }, { lockingBytecode: bin(beaconLock), valueSatoshis: BEACON_DUST_SAT }, @@ -180,6 +180,39 @@ const announced = (announce, state, extra, op) => (announce ? [...registryAnnoun // ---------- transactions ---------- +/** + * What to commit to before registering `name` straight into the covenant: the + * certificate will be minted to the covenant address of exactly this state, + * so owner AND expiry are fixed at commit time. Count the expiry from the tip + * at the moment of committing — the reveal is mined later, which only leaves + * margin under the paid-entry rule. + */ +export const registryCommitment = ({ name, terms, state, salt }) => commitmentHash(salt, name, registryLock(terms, state)); + +/** + * Register a NEW name straight into the covenant: mint the certificate to the + * covenant address, pay the fund for `years`, announce the state. `genesis` + * is a plain coin at output index 0 (its txid becomes the category). With + * `salt`, this is the reveal of a commit made with registryCommitment() for + * the same state. + */ +export function buildRegistryRegisterTx({ genesis, terms, name, records = {}, state, years = 1, salt = null, beaconLock, coins = [], changeLock, keyFor, feeRate = 1 }) { + if (genesis.vout !== 0) throw new Error("the genesis coin must be at output index 0 (CashTokens requires it to create a category)"); + if (genesis.token) throw new Error("the genesis coin must not carry a token"); + if (!Number.isInteger(years) || years < 1) throw new Error("years must be a whole number, at least 1"); + const raw = normalizeName(name); + const built = assemble({ + special: [p2pkhSpecial(genesis, keyFor)], + outputs: [ + { lockingBytecode: registryLock(terms, state), valueSatoshis: CERTIFICATE_DUST_SAT, token: { amount: 0n, category: bin(genesis.txid), nft: { capability: "none", commitment: utf8ToBin(raw) } } }, + { lockingBytecode: terms.fundLock, valueSatoshis: BigInt(terms.renewalSats) * BigInt(years) }, + ], + extra: registryAnnouncement({ name, records, beaconLock, salt }, state, "REG"), + coins, changeLock, keyFor, feeRate, + }); + return { ...built, state, category: genesis.txid }; +} + /** * Put an ordinary name into the covenant. `cert` is the certificate's UTXO, * held by a plain key. No contract runs here — the certificate is simply sent diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index 9f86b5c..4f12f38 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -62,6 +62,16 @@ export interface BnsNetwork { export function onlyPayload(vouts: any[]): any | null; /** REG/TREG: is `mint` the only token of its category there is or can ever be? */ export function soleCertificate(tx: any, mint: any): boolean; +/** sha256(salt ‖ name ‖ certificate lock), hex — what a CMT commits to. */ +export function commitmentHex(saltHex: string, rawName: string, lockHex: string): string; +/** Which REG takes a name under commit–reveal (PROTOCOL-ADDENDUM-commit-reveal.md). */ +export function registrationContest(o: { + ordered: Array<{ tx_hash: string; height: number }>; + txs: Map; + network?: any; + keyOf?: (rawName: string) => string; + admits: (tx: any, payload: any, height: number) => any | null; +}): { decide(tx_hash: string, key: string): { mint: any; commit: { height: number; txid: string } | null } | null; commits: Map; rules: any }; /** A covenant-held name's verified state (PROTOCOL-ADDENDUM-covenant-names.md). */ export interface CovenantState { ownerPkh: string; expiry: number; tier: number; version: number } export const COVENANT_MAX_EXPIRY: number; diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index f605257..f628c5c 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -97,6 +97,17 @@ export const NETWORKS = { scripthash: "fa14c5fd7a22bd1856b44168823a1ab3ff50961c917dafbcba749aeaa7b26646", }, ], + // Commit–reveal for first registrations (PROTOCOL-ADDENDUM-commit-reveal.md). + // required false: a REG without a salt is still valid (BNS1). + // minAge/maxAge how old, in blocks, the commit must be when its reveal + // is mined. maxAge is a day, not the week first proposed: + // a standing commit has to cost more to keep alive than + // the name costs to own, or hoarding commits is cheaper + // than registering. + // contestBlocks for this many blocks after a name's first valid REG, a + // REG with an OLDER commit takes the name instead. After + // that the registration is final. + commitReveal: { required: false, minAge: 1, maxAge: 144, contestBlocks: 6 }, // The registry covenant a resolver recognises (PROTOCOL-ADDENDUM-covenant-names.md). // A covenant-held name sits at P2SH32(hash256(STATE ‖ head ‖ tail)); given // these bytes a resolver checks a claimed owner and expiry against the @@ -158,6 +169,9 @@ export const NETWORKS = { // NOT PINNED: the blocklist beacon is one of the addresses reserved before // the mainnet beacon goes live (ROADMAP-MAINNET.md §6). blocklists: [], + // A mainnet registry starts empty, so there is nothing to grandfather: + // every registration is a reveal. + commitReveal: { required: true, minAge: 1, maxAge: 144, contestBlocks: 6 }, // NOT PINNED: needs the fund's and the steward's mainnet keys. covenant: null, }, @@ -343,8 +357,11 @@ function parseOpReturnHex(hex) { // RPT / BLK / UBK — blocklist report, block and unblock, on a list's // own address (PROTOCOL-ADDENDUM-blocklist.md). An RPT // covers several names, so its `n` is an array. + // CMT — commitment to a registration; it names nothing, only a + // hash `h` (PROTOCOL-ADDENDUM-commit-reveal.md). // KEEP IN LOCKSTEP with bns.js parseOpReturn — memory rule. if (!KNOWN_OPS.has(p.o)) return null; + if (p.o === "CMT") return typeof p.h === "string" && /^[0-9a-f]{64}$/.test(p.h) ? p : null; if (typeof p.n === "string") return p; if (p.o === "RPT" && Array.isArray(p.n) && p.n.every((x) => typeof x === "string")) return p; } catch {} @@ -375,7 +392,7 @@ export function onlyPayload(vouts) { // The set of payload opcodes this codec recognises. New opcodes go here AND in // bns.js — divergence is a consensus split. -export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK"]); +export const KNOWN_OPS = new Set(["REG", "UPD", "REL", "TREG", "TUPD", "RPT", "BLK", "UBK", "CMT"]); // ---- ordering ---- @@ -610,6 +627,106 @@ export function mayReRegister(releasedAt, height) { return !(height > 0) || height > releasedAt; } +// ---- commit–reveal (Decentralized.DNS/PROTOCOL-ADDENDUM-commit-reveal.md) ---- +// A REG shows its name to the mempool before it is mined, and "first REG +// wins" hands the name to whoever copies it with a better fee. So a +// registrant first publishes a hash (CMT), waits a block, and then reveals: +// the REG carries the salt `s`, and is valid only if a commit of +// sha256( salt ‖ name ‖ lockingBytecode of the certificate output ) +// is already minAge..maxAge blocks deep. Someone who learns the name from +// the reveal has no such commit and cannot make one in the past. +// +// WHO WINS. The first valid REG of a free name opens a contest lasting +// `contestBlocks`. Every valid REG of that name mined inside it competes, and +// the one with the OLDEST COMMIT takes the name; a REG with no commit ranks by +// its own block. After the window the registration is final. (The addendum +// first let an older commit win for as long as the commit lived, which meant +// no registration was final for a week and a standing commit beat any buyer.) +// +// All three reducers get their REG decisions here, so they cannot drift. +// KEEP IN LOCKSTEP with RegistrationContest in the mobile Bns.java. + +export function commitmentHex(saltHex, rawName, lockHex) { + return bytesToHex(sha256(hexToBytes(saltHex + strToHex(rawName) + lockHex))); +} + +/** + * @param ordered the beacon history in reducing order: [{ tx_hash, height }] + * @param txs Map txid -> verbose transaction + * @param keyOf raw on-chain name -> the reducer's index key + * @param admits (tx, payload, height) -> the certificate output of a REG + * that passes every rule except this one, or null + * @returns {{ decide(tx_hash, key): null | { mint, commit }, commits: Map }} + * decide() is asked, in order, about each REG of a name that is free at + * that point. It answers with the certificate output when this transaction + * takes the name, and null when it does not (or does not yet). + */ +export function registrationContest({ ordered, txs, network = NETWORK, keyOf = (raw) => raw, admits }) { + const rules = getNetwork(network).commitReveal || { required: false, minAge: 1, maxAge: 0, contestBlocks: 0 }; + const commits = new Map(); // h -> { height, txid, used }; the earliest commit of each hash + const regs = new Map(); // key -> [{ tx_hash, height, payload }] in order + for (const { tx_hash, height } of ordered) { + const tx = txs.get(tx_hash); + const p = tx ? onlyPayload(tx.vout) : null; + if (!p) continue; + if (p.o === "CMT") { + if (!commits.has(p.h)) commits.set(p.h, { height, txid: tx_hash, used: false }); + } else if (p.o === "REG" && typeof p.n === "string" && RAWNAME_RE.test(p.n)) { + const key = keyOf(p.n); + if (!regs.has(key)) regs.set(key, []); + regs.get(key).push({ tx_hash, height, payload: p, pos: regs.get(key).length }); + } + } + const INF = Infinity; + const candidate = (r) => { + const mint = admits(txs.get(r.tx_hash), r.payload, r.height); + if (!mint) return null; + const s = r.payload.s; + if (s === undefined) { + if (rules.required) return null; + return { r, mint, commit: null, rank: [r.height > 0 ? r.height : INF, r.tx_hash, r.pos] }; + } + if (typeof s !== "string" || !/^[0-9a-f]{32}$/.test(s) || !mint.scriptPubKey || typeof mint.scriptPubKey.hex !== "string") return null; + const commit = commits.get(commitmentHex(s, r.payload.n, mint.scriptPubKey.hex)); + if (!commit || commit.used || !(commit.height > 0)) return null; // a commit counts once it is in a block + if (r.height > 0) { + const age = r.height - commit.height; + if (age < rules.minAge || age > rules.maxAge) return null; + } // an unconfirmed reveal is judged when it confirms + // Rank: the commit's block, then its txid; two reveals of the SAME commit + // (same owner, name and salt) in the order they come. + return { r, mint, commit, rank: [commit.height, commit.txid, r.pos] }; + }; + const better = (a, b) => { + for (let i = 0; i < 3; i++) if (a.rank[i] !== b.rank[i]) return a.rank[i] < b.rank[i]; + return false; + }; + const awarded = new Map(); // key -> txid of a later REG that out-ranked the opener + function decide(tx_hash, key) { + const list = regs.get(key) || []; + const at = list.findIndex((r) => r.tx_hash === tx_hash); + if (at < 0) return null; + const won = awarded.get(key); + if (won && won !== tx_hash) return null; + const me = candidate(list[at]); + if (!me) { if (won) awarded.delete(key); return null; } + if (!won) { + const limit = (list[at].height > 0 ? list[at].height : INF) + rules.contestBlocks; + let best = me; + for (const r of list.slice(at + 1)) { + if (!(r.height > 0 ? r.height <= limit : limit === INF)) continue; + const other = candidate(r); + if (other && better(other, best)) best = other; + } + if (best !== me) { awarded.set(key, best.r.tx_hash); return null; } + } + awarded.delete(key); + if (me.commit) me.commit.used = true; // one commit backs one registration + return { mint: me.mint, commit: me.commit }; + } + return { decide, commits, rules }; +} + export async function buildIndex({ network = NETWORK, electrum = getNetwork(network).electrum, WebSocket: WS, client, directIP = false, enforceTld = false, cosignGate = true, height: atHeight = null } = {}) { const net = requireBeacons(network); const el = client ?? (await connectAny(electrum, WS, { directIP })); @@ -654,32 +771,43 @@ export async function buildIndex({ network = NETWORK, electrum = getNetwork(netw const names = new Map(); const released = new Map(); // name -> height of its release (see mayReRegister) - for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) { + const ordered = orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs); + // Every rule a REG must pass on its own; which REG takes a contested name + // is registrationContest's business. + const contest = registrationContest({ + ordered, txs, network: net, keyOf: canonicalKey, + admits: (regTx, p, regHeight) => { + const key = canonicalKey(p.n); + // TLD gate — see §4 of the design. + if (enforceTld && !tldAdmits(tldOfKey(key), regHeight, tldMap)) return null; + // 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, regHeight) && !carriesTldCertificate(regTx.vout || [], tldEntry, tldOfKey(key))) return null; + } + const commitHex = strToHex(p.n); // commitment matches the raw on-chain name + const mint = (regTx.vout || []).find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex); + // the certificate must be the only token of its category + return mint && soleCertificate(regTx, mint) ? mint : null; + }, + }); + for (const { tx_hash, height } of ordered) { const tx = txs.get(tx_hash); const vouts = tx.vout || []; const payload = onlyPayload(vouts); if (!payload) continue; const rawName = payload.n; - if (!RAWNAME_RE.test(rawName)) continue; + if (typeof rawName !== "string" || !RAWNAME_RE.test(rawName)) continue; // a CMT names nothing const key = canonicalKey(rawName); // "