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.
282 lines
18 KiB
JavaScript
282 lines
18 KiB
JavaScript
// 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);
|
||
});
|