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); // "