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.
This commit is contained in:
Local Dev 2026-10-05 22:29:38 +02:00
parent 03b52727bf
commit d19cd6bde7
11 changed files with 697 additions and 49 deletions

View file

@ -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 ? {

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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<string, any>;
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<string, { height: number; txid: string; used: boolean }>; 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;

View file

@ -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); // "<label>.<tld>"
if (payload.o === "REG") {
if (names.has(key)) continue; // first valid REG wins
if (names.has(key)) continue; // taken
if (!mayReRegister(released.get(key), height)) continue; // one-block gap after a release
// TLD gate — see §4 of the design. Only applies to REG; UPDs modify
// names already admitted, so no gate needed there.
if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue;
// Co-sign gate: private / cosign-policy TLDs need the TLD certificate
// in this same transaction. See cosignRequired() above.
if (cosignGate && tldMap) {
const tldEntry = tldMap.get(tldOfKey(key));
if (tldEntry && cosignRequired(tldEntry, height) && !carriesTldCertificate(vouts, tldEntry, tldOfKey(key))) continue;
}
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
if (!soleCertificate(tx, mint)) continue; // the certificate must be the only token of its category
const won = contest.decide(tx_hash, key); // valid, and the one that takes the name?
if (!won) continue;
const mint = won.mint;
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,
@ -763,22 +891,30 @@ export function buildIndexFromSnapshot({ snapshot, enforceTld = false, tldMap =
const history = [...snapshot.history].sort(byHeightThenTxid);
const names = new Map();
const released = new Map();
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txMap.has(h.tx_hash)), txMap)) {
const ordered = orderByDependency(history.filter((h) => txMap.has(h.tx_hash)), txMap);
const contest = registrationContest({
ordered, txs: txMap, network: net, keyOf: canonicalKey,
admits: (regTx, p, regHeight) => {
if (enforceTld && !tldAdmits(tldOfKey(canonicalKey(p.n)), regHeight, tldMap)) return null;
const commitHex = strToHex(p.n);
const mint = (regTx.vout || []).find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
return mint && soleCertificate(regTx, mint) ? mint : null;
},
});
for (const { tx_hash, height } of ordered) {
const tx = txMap.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);
if (payload.o === "REG") {
if (names.has(key)) continue;
if (!mayReRegister(released.get(key), height)) continue;
if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue;
const commitHex = strToHex(rawName);
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
if (!soleCertificate(tx, mint)) continue;
const won = contest.decide(tx_hash, key);
if (!won) continue;
const mint = won.mint;
names.set(key, {
name: key, category: mint.tokenData.category, records: payload.r ?? {},
txid: tx_hash, height, updatedTxid: null,

View file

@ -43,7 +43,8 @@ export {
} from "../lib/register-tx.js";
export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js";
export { buildListing, completeSale, verifyListing, signInputs, sourceOutputToJson, sourceOutputFromJson } from "../lib/market-tx.js";
export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest } from "../lib/registrar.js";
export { findTldCertificateUtxo, buildCosignRequest, signCosignRequest, commitWithBuiltInWallet, commitState } from "../lib/registrar.js";
export { commitmentHash, newRegistrationSalt, buildCommitTx, REG_SALT_BYTES } from "../lib/register-tx.js";
export { usdListingRedeem, usdListingAddress, payoutSats, parseOracleMessage, verifyOracleMessage, buildUsdListingTx, buildUsdBuyTx, buildUsdCancelTx, verifyUsdListing, DEFAULT_MAX_AGE_SECS } from "../lib/market-usd.js";
export { toTokenAddress, addressToLockingBytecode, addressToScripthash, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
export { resolveName, buildIndex, NETWORK, NETWORKS, getNetwork, requireBeacons, DEFAULT_NETWORK_ID } from "../lib/resolver-web.js";

282
test/commit-reveal.test.mjs Normal file
View file

@ -0,0 +1,282 @@
// Commit–reveal for first registrations.
// (Decentralized.DNS/PROTOCOL-ADDENDUM-commit-reveal.md)
//
// A REG shows its name to the mempool; a commit made a block earlier is what
// lets its author keep it. These tests build real CMT and REG transactions
// with the library and run them through every JavaScript reducer, which must
// agree.
import test from "node:test";
import { writeFileSync } from "node:fs";
import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import { NETWORKS, buildIndex, buildIndexFromSnapshot, commitmentHex, onlyPayload } from "../src/lib/resolver-web.js";
import * as legacy from "../src/lib/bns.js";
import {
buildCommitTx, buildRegistrationTx, buildUpdateTx, buildReleaseTx, commitmentHash, newRegistrationSalt, encodeCommitPayload, encodePayload, opReturnScript, REG_SALT_BYTES,
} from "../src/lib/register-tx.js";
import { addressToLockingBytecode } from "../src/lib/chain.js";
import { lockToAddress } from "../src/lib/covenant.js";
import { registryTerms, registryLock, registryCommitment, buildRegistryRegisterTx } from "../src/lib/registry-covenant.js";
import { tierForName, BLOCKS_PER_YEAR } from "../src/lib/registry-lifecycle.js";
const { hexToBin, binToHex, hash160, hash256, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, encodeTransaction } = L;
const NET = NETWORKS.chipnet;
const RULES = NET.commitReveal;
const STRICT = { ...NET, commitReveal: { ...RULES, required: true } };
const person = () => {
const priv = generatePrivateKey(() => crypto.getRandomValues(new Uint8Array(32)));
const pub = secp256k1.derivePublicKeyCompressed(priv);
const lock = encodeLockingBytecodeP2pkh(hash160(pub));
return { priv, pub, pkh: binToHex(hash160(pub)), lock, lockHex: binToHex(lock), address: lockToAddress(lock, NET.prefix, false) };
};
const alice = person(), bob = person(), mallory = person();
let coinSeq = 1;
const coin = (who, sats = 10_000_000n) => ({ txid: (coinSeq++).toString(16).padStart(64, "0"), vout: 0, satoshis: sats, lockingBytecode: who.lock });
// ---------- libauth transaction -> what an electrum server returns ----------
const verbose = (transaction) => {
const txid = binToHex(hash256(encodeTransaction(transaction)).slice().reverse());
return {
txid,
vin: transaction.inputs.map((i) => ({ txid: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })),
vout: transaction.outputs.map((o, n) => {
let address;
try { address = lockToAddress(o.lockingBytecode, NET.prefix, false); } catch { address = undefined; }
return {
n, value: Number(o.valueSatoshis) / 1e8,
scriptPubKey: { hex: binToHex(o.lockingBytecode), ...(address ? { address } : {}) },
...(o.token ? { tokenData: { category: binToHex(o.token.category), amount: String(o.token.amount ?? 0n), ...(o.token.nft ? { nft: { capability: o.token.nft.capability, commitment: binToHex(o.token.nft.commitment) } } : {}) } } : {}),
};
}),
};
};
// ---------- the two transactions ----------
const commitFor = (who, name, salt = newRegistrationSalt(), lock = who.lock) => ({
salt,
tx: verbose(buildCommitTx({ commitment: commitmentHash(salt, name, lock), utxos: [coin(who)], changeAddress: who.address, addressToLockingBytecode }).transaction),
});
const reg = (who, name, { salt = null, records = { u: `https://${who === alice ? "alice" : who === bob ? "bob" : "mallory"}.example` } } = {}) =>
verbose(buildRegistrationTx({ name, ownerAddress: who.address, records, utxos: [coin(who)], salt, addressToLockingBytecode }).transaction);
const withPayload = (tx, payload) => {
const t = structuredClone(tx);
const at = t.vout.findIndex((v) => v.scriptPubKey.hex.startsWith("6a"));
t.vout[at].scriptPubKey.hex = binToHex(opReturnScript(new TextEncoder().encode("BNS1" + JSON.stringify(payload))));
return t;
};
const payloadOf = (tx) => onlyPayload(tx.vout);
// BNS_DUMP=<file>: write every reduction as a vector, so the Android reducer
// can be run against the same histories on the desktop (see the memory note).
const DUMPED = [];
const dump = (history, txs, height, idx) => {
if (!process.env.BNS_DUMP) return;
DUMPED.push({ history, txs, height, expect: [...idx].map(([k, e]) => ({ name: k, category: e.category, txid: e.txid, records: e.records, ownerPkh: e.covenant?.ownerPkh ?? null, expiry: e.covenant?.expiry ?? 0, status: e.status ?? null })) });
};
process.on("exit", () => { if (process.env.BNS_DUMP && DUMPED.length) writeFileSync(process.env.BNS_DUMP, JSON.stringify({ vectors: DUMPED })); });
// ---------- reducers ----------
async function reduce(entries, { network = NET } = {}) {
const history = entries.map(([tx, h]) => ({ tx_hash: tx.txid, height: h }));
const txs = Object.fromEntries(entries.map(([tx]) => [tx.txid, tx]));
const snap = buildIndexFromSnapshot({ snapshot: { history, txs }, network });
const el = { call: async (method, [a]) => (method === "blockchain.scripthash.get_history" ? history.map((x) => ({ ...x })) : method === "blockchain.headers.subscribe" ? { height: 0 } : txs[a]) };
const live = await buildIndex({ client: el, cosignGate: false, network });
const shape = (idx) => [...idx].map(([k, e]) => JSON.stringify([k, e.category, e.txid, e.records])).sort();
assert.deepEqual(shape(live), shape(snap), "buildIndex and buildIndexFromSnapshot disagree");
if (network === NET) {
const old = await legacy.buildIndex({ getHistory: async () => history.map((x) => ({ ...x })), getRawTransactionObject: async (id) => txs[id] }, { cosignGate: false });
assert.deepEqual(shape(old), shape(snap), "bns.js and resolver-web.js disagree");
}
if (network === NET) dump(history, txs, 0, snap);
return snap;
}
const owner = (idx, name) => idx.get(name)?.owner ?? null;
const NAME = "wanted.x";
// ---------- the commitment ----------
test("the commitment is sha256(salt ‖ name ‖ certificate lock), the same in the builder and the resolver", () => {
const salt = newRegistrationSalt();
assert.equal(salt.length, REG_SALT_BYTES);
assert.equal(binToHex(commitmentHash(salt, NAME, alice.lock)), commitmentHex(binToHex(salt), NAME, alice.lockHex));
// bare labels are hashed as they appear on chain
assert.equal(binToHex(commitmentHash(salt, "plain.bch", alice.lock)), commitmentHex(binToHex(salt), "plain", alice.lockHex));
// each ingredient matters
const base = commitmentHex(binToHex(salt), NAME, alice.lockHex);
assert.notEqual(commitmentHex(binToHex(newRegistrationSalt()), NAME, alice.lockHex), base);
assert.notEqual(commitmentHex(binToHex(salt), "wanted.y", alice.lockHex), base);
assert.notEqual(commitmentHex(binToHex(salt), NAME, bob.lockHex), base);
assert.throws(() => commitmentHash(new Uint8Array(15), NAME, alice.lock), /16 bytes/);
// a commit says nothing but a hash, and is a message like any other
assert.deepEqual(JSON.parse(new TextDecoder().decode(encodeCommitPayload(base)).slice(4)), { o: "CMT", h: base });
const c = commitFor(alice, NAME);
assert.equal(payloadOf(c.tx).o, "CMT");
assert.equal(c.tx.vout.some((v) => v.tokenData), false, "a commit mints nothing");
assert.ok(c.tx.vout.some((v) => v.scriptPubKey.address === NET.beacon), "and pays the beacon, to be found");
});
// ---------- a reveal ----------
test("a reveal registers the name when its commit is a block deep", async () => {
const c = commitFor(alice, NAME);
const r = reg(alice, NAME, { salt: c.salt });
assert.equal(payloadOf(r).s, binToHex(c.salt));
const idx = await reduce([[c.tx, 100], [r, 101]]);
assert.equal(owner(idx, NAME), alice.address);
assert.deepEqual(idx.get(NAME).records, { u: "https://alice.example" });
// the edges of the window
assert.ok((await reduce([[c.tx, 100], [r, 100 + RULES.maxAge]])).has(NAME), "the oldest a commit may be");
// not in a block yet: accepted for now, judged again when it confirms
assert.ok((await reduce([[c.tx, 100], [r, 0]])).has(NAME));
});
test("a reveal without a usable commit registers nothing", async () => {
const c = commitFor(alice, NAME);
const r = reg(alice, NAME, { salt: c.salt });
const free = async (entries, why) => assert.equal((await reduce(entries)).has(NAME), false, why);
await free([[r, 101]], "no commit at all");
await free([[c.tx, 101], [r, 101]], "commit in the same block");
await free([[c.tx, 102], [r, 101]], "commit after the reveal");
await free([[c.tx, 100], [r, 100 + RULES.maxAge + 1]], "commit too old");
await free([[c.tx, 0], [r, 0]], "commit not in a block yet");
await free([[c.tx, 100], [reg(alice, NAME, { salt: newRegistrationSalt() }), 101]], "another salt");
await free([[c.tx, 100], [reg(mallory, NAME, { salt: c.salt }), 101]], "revealed to another owner: the commit binds the address");
await free([[c.tx, 100], [reg(alice, "wanted.y", { salt: c.salt }), 101]], "another name");
const p = payloadOf(r);
for (const s of [p.s.slice(2), p.s.toUpperCase(), p.s + "00", null, 5, ""]) {
await free([[c.tx, 100], [withPayload(r, { ...p, s }), 101]], `malformed salt ${JSON.stringify(s)}`);
}
// a commit and a registration in one transaction is two messages, so none
const both = structuredClone(r);
both.vout.push({ n: both.vout.length, value: 0, scriptPubKey: { hex: binToHex(opReturnScript(encodeCommitPayload("ab".repeat(32)))) } });
await free([[c.tx, 100], [both, 101]], "CMT and REG in one transaction");
// the same commit published again later does not make it younger
const again = verbose(buildCommitTx({ commitment: commitmentHash(c.salt, NAME, alice.lock), utxos: [coin(mallory)], changeAddress: mallory.address, addressToLockingBytecode }).transaction);
await free([[c.tx, 100], [again, 400], [r, 401]], "the earliest commit of a hash is the one that counts");
});
// ---------- who wins ----------
test("the point of it: the earlier commit wins even when its reveal is mined later", async () => {
const a = commitFor(alice, NAME), b = commitFor(bob, NAME);
const ra = reg(alice, NAME, { salt: a.salt }), rb = reg(bob, NAME, { salt: b.salt });
// Alice committed first. Bob's reveal is mined first.
let idx = await reduce([[a.tx, 100], [b.tx, 101], [rb, 105], [ra, 105 + RULES.contestBlocks]]);
assert.equal(owner(idx, NAME), alice.address, "inside the contest window the older commit takes the name");
assert.equal(idx.get(NAME).txid, ra.txid);
// …and in the same block, whatever the txids say
idx = await reduce([[a.tx, 100], [b.tx, 101], [rb, 105], [ra, 105]]);
assert.equal(owner(idx, NAME), alice.address);
// Until Alice's reveal exists, the name is Bob's.
assert.equal(owner(await reduce([[a.tx, 100], [b.tx, 101], [rb, 105]]), NAME), bob.address);
// Commits in the same block: the lower commit txid.
const first = a.tx.txid < b.tx.txid ? alice : bob;
assert.equal(owner(await reduce([[a.tx, 100], [b.tx, 100], [rb, 105], [ra, 106]]), NAME), first.address);
});
test("after the contest window a registration is final", async () => {
const a = commitFor(alice, NAME), b = commitFor(bob, NAME);
const ra = reg(alice, NAME, { salt: a.salt }), rb = reg(bob, NAME, { salt: b.salt });
const idx = await reduce([[a.tx, 100], [b.tx, 101], [rb, 105], [ra, 105 + RULES.contestBlocks + 1]]);
assert.equal(owner(idx, NAME), bob.address, "an older commit revealed too late does not take a name back");
// A standing commit, kept for the day someone wants the name, is worth nothing once they have it.
const hoard = commitFor(mallory, NAME);
const late = reg(mallory, NAME, { salt: hoard.salt });
assert.equal(owner(await reduce([[hoard.tx, 50], [b.tx, 101], [rb, 105], [late, 105 + RULES.contestBlocks + 1]]), NAME), bob.address);
});
test("a front-runner who copies the name out of the mempool loses", async () => {
const a = commitFor(alice, NAME);
const ra = reg(alice, NAME, { salt: a.salt });
const copy = reg(mallory, NAME); // no commit: Mallory learned the name from Alice's reveal
// same block as the reveal, a block before it (a miner who held Alice's back), or after
for (const [hCopy, hReveal] of [[105, 105], [105, 106], [105, 105 + RULES.contestBlocks], [106, 105]]) {
const idx = await reduce([[a.tx, 100], [copy, hCopy], [ra, hReveal]]);
assert.equal(owner(idx, NAME), alice.address, `copy at ${hCopy}, reveal at ${hReveal}`);
}
// Mallory commits the moment she sees the reveal: her commit is younger, and too young.
const m = commitFor(mallory, NAME);
const rm = reg(mallory, NAME, { salt: m.salt });
assert.equal(owner(await reduce([[a.tx, 100], [m.tx, 105], [ra, 105], [rm, 106]]), NAME), alice.address);
assert.equal(owner(await reduce([[a.tx, 100], [m.tx, 105], [rm, 106], [ra, 107]]), NAME), alice.address, "even with Alice's reveal held back two blocks");
// What the window does not cover: a reveal censored for longer than the window.
assert.equal(owner(await reduce([[a.tx, 100], [m.tx, 105], [rm, 106], [ra, 106 + RULES.contestBlocks + 1]]), NAME), mallory.address);
});
test("the loser of a contest holds nothing: its updates are ignored", async () => {
const a = commitFor(alice, NAME);
const ra = reg(alice, NAME, { salt: a.salt });
const copy = reg(mallory, NAME);
const cat = copy.vout.find((v) => v.tokenData).tokenData.category;
const cert = { txid: copy.txid, vout: copy.vout.findIndex((v) => v.tokenData), satoshis: 1000n, lockingBytecode: mallory.lock, token: { category: cat, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(NAME)) } } };
const upd = verbose(buildUpdateTx({ name: NAME, records: { u: "https://phishing.example" }, certificateUtxo: cert, utxos: [coin(mallory)], ownerAddress: mallory.address, addressToLockingBytecode }).transaction);
const idx = await reduce([[a.tx, 100], [copy, 105], [upd, 106], [ra, 107]]);
assert.equal(owner(idx, NAME), alice.address);
assert.deepEqual(idx.get(NAME).records, { u: "https://alice.example" });
});
test("registrations without a commit behave exactly as before", async () => {
const first = reg(alice, NAME), second = reg(bob, NAME);
assert.equal(payloadOf(first).s, undefined);
assert.equal(owner(await reduce([[first, 100], [second, 101]]), NAME), alice.address);
assert.equal(owner(await reduce([[second, 101], [first, 100]]), NAME), alice.address);
const low = first.txid < second.txid ? alice : bob;
assert.equal(owner(await reduce([[first, 100], [second, 100]]), NAME), low.address, "same block: the lower txid, as always");
assert.equal(owner(await reduce([[first, 0], [second, 100]]), NAME), bob.address, "confirmed beats unconfirmed");
});
test("one commit backs one registration", async () => {
const a = commitFor(alice, NAME);
const ra = reg(alice, NAME, { salt: a.salt });
const cat = ra.vout.find((v) => v.tokenData).tokenData.category;
const cert = { txid: ra.txid, vout: ra.vout.findIndex((v) => v.tokenData), satoshis: 1000n, lockingBytecode: alice.lock, token: { category: cat, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(NAME)) } } };
const rel = verbose(buildReleaseTx({ name: NAME, certificateUtxo: cert, utxos: [coin(alice)], ownerAddress: alice.address, addressToLockingBytecode }).transaction);
const again = reg(alice, NAME, { salt: a.salt });
const idx = await reduce([[a.tx, 100], [ra, 101], [rel, 102], [again, 104]]);
assert.equal(idx.has(NAME), false, "the commit was used by the first registration");
// a fresh commit registers it again
const b = commitFor(alice, NAME);
assert.ok((await reduce([[a.tx, 100], [ra, 101], [rel, 102], [b.tx, 103], [reg(alice, NAME, { salt: b.salt }), 104]])).has(NAME));
});
// ---------- where it is mandatory ----------
test("where commit–reveal is required, a registration without a salt is void", async () => {
assert.equal(NETWORKS.mainnet.commitReveal.required, true, "mainnet starts with commit–reveal from block one");
assert.equal(RULES.required, false, "chipnet keeps accepting plain registrations");
const plain = reg(mallory, NAME);
assert.equal((await reduce([[plain, 100]], { network: STRICT })).has(NAME), false);
const a = commitFor(alice, NAME);
const ra = reg(alice, NAME, { salt: a.salt });
const idx = await reduce([[plain, 99], [a.tx, 100], [ra, 101]], { network: STRICT });
assert.equal(owner(idx, NAME), alice.address);
});
// ---------- straight into the covenant ----------
test("a covenant registration commits to the covenant address — owner and expiry are fixed at commit time", async () => {
const name = "vaulted.x";
const COV = NET.covenant;
const terms = registryTerms({ tier: tierForName(name), fundLock: hexToBin(COV.fundLock), stewardPk: hexToBin(COV.steward) });
const keyFor = (lockHex) => (lockHex === alice.lockHex ? { privateKey: alice.priv, publicKey: alice.pub } : null);
const beaconLock = addressToLockingBytecode(NET.beacon);
const state = { ownerPkh: alice.pkh, expiry: 100 + BLOCKS_PER_YEAR }; // counted from the tip when committing
const salt = newRegistrationSalt();
const commitment = registryCommitment({ name, terms, state, salt });
assert.equal(binToHex(commitment), commitmentHex(binToHex(salt), name, binToHex(registryLock(terms, state))));
const cmt = verbose(buildCommitTx({ commitment, utxos: [coin(alice)], changeAddress: alice.address, addressToLockingBytecode }).transaction);
const make = (st) => verbose(buildRegistryRegisterTx({ genesis: coin(alice), terms, name, records: { u: "https://vault.example" }, state: st, salt, beaconLock, coins: [coin(alice)], changeLock: alice.lock, keyFor }).transaction);
const e = (await reduce([[cmt, 100], [make(state), 103]], { network: STRICT })).get(name);
assert.equal(e.owner, alice.address);
assert.deepEqual(e.covenant, { ownerPkh: alice.pkh, expiry: state.expiry, tier: tierForName(name), version: 1 });
assert.equal(e.status, "active");
// the same salt cannot be revealed into a different state: it is another address
assert.equal((await reduce([[cmt, 100], [make({ ...state, expiry: state.expiry + 1 }), 103]], { network: STRICT })).has(name), false);
assert.equal((await reduce([[cmt, 100], [make({ ...state, ownerPkh: mallory.pkh }), 103]], { network: STRICT })).has(name), false);
});

View file

@ -12,6 +12,7 @@
// must agree.
import test from "node:test";
import { writeFileSync } from "node:fs";
import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import {
@ -86,6 +87,15 @@ function registration(name, who, records = { u: "https://first.example" }) {
}
const announce = (name, records) => ({ name, records, beaconLock });
// BNS_DUMP=<file>: write every reduction as a vector, so the Android reducer
// can be run against the same histories on the desktop (see the memory note).
const DUMPED = [];
const dump = (history, txs, height, idx) => {
if (!process.env.BNS_DUMP) return;
DUMPED.push({ history, txs, height, expect: [...idx].map(([k, e]) => ({ name: k, category: e.category, txid: e.txid, records: e.records, ownerPkh: e.covenant?.ownerPkh ?? null, expiry: e.covenant?.expiry ?? 0, status: e.status ?? null })) });
};
process.on("exit", () => { if (process.env.BNS_DUMP && DUMPED.length) writeFileSync(process.env.BNS_DUMP, JSON.stringify({ vectors: DUMPED })); });
// ---------- the three reducers, which must agree ----------
async function reduce(entries, height) {
const history = entries.map(([tx, h]) => ({ tx_hash: tx.txid, height: h }));
@ -102,6 +112,7 @@ async function reduce(entries, height) {
assert.deepEqual(shape(live), shape(snap), "buildIndex and buildIndexFromSnapshot disagree");
assert.deepEqual(shape(old), shape(snap), "bns.js and resolver-web.js disagree");
assert.equal(snap.height, height);
dump(history, txs, height, snap);
return snap;
}