383 lines
22 KiB
JavaScript
383 lines
22 KiB
JavaScript
|
|
// Resolvers reading covenant-held names.
|
||
|
|
// (Decentralized.DNS/PROTOCOL-ADDENDUM-covenant-names.md)
|
||
|
|
//
|
||
|
|
// A covenant-held name's owner and expiry are hashed into its address. The
|
||
|
|
// transaction says what they are; the resolver checks the claim against the
|
||
|
|
// address, decides whether an entry was paid for, and stops resolving a name
|
||
|
|
// whose grace period has passed.
|
||
|
|
//
|
||
|
|
// Every transaction here is built by the real builders and, where it spends
|
||
|
|
// the covenant, accepted by the VM first — then turned into the verbose form
|
||
|
|
// an electrum server returns and fed to all three JavaScript reducers, which
|
||
|
|
// must agree.
|
||
|
|
|
||
|
|
import test from "node:test";
|
||
|
|
import assert from "node:assert/strict";
|
||
|
|
import * as L from "@bitauth/libauth";
|
||
|
|
import {
|
||
|
|
NETWORKS, buildIndex, buildIndexFromSnapshot, covenantLockHex, covenantTierIndex, covenantClaim, covenantPhase,
|
||
|
|
pkhToAddress, addressToLockHex, historyHeight,
|
||
|
|
} from "../src/lib/resolver-web.js";
|
||
|
|
import * as legacy from "../src/lib/bns.js";
|
||
|
|
import { covenantTable } from "../src/generate-covenant-table.mjs";
|
||
|
|
import {
|
||
|
|
registryTerms, registryLock, commitBoxLock, commitHashFor, covenantClaimOf, registryAnnouncement,
|
||
|
|
buildRegistryEnterTx, buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryTakeoverTx, buildRegistryReleaseTx,
|
||
|
|
} from "../src/lib/registry-covenant.js";
|
||
|
|
import { TIERS, tierForName, BLOCKS_PER_YEAR, GRACE_BLOCKS } from "../src/lib/registry-lifecycle.js";
|
||
|
|
import { lockToAddress } from "../src/lib/covenant.js";
|
||
|
|
import { encodePayload, opReturnScript } from "../src/lib/register-tx.js";
|
||
|
|
|
||
|
|
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey } = L;
|
||
|
|
const NET = NETWORKS.chipnet;
|
||
|
|
const COV = NET.covenant;
|
||
|
|
const VM = L.createVirtualMachineBch2025();
|
||
|
|
|
||
|
|
// ---------- cast ----------
|
||
|
|
const person = () => {
|
||
|
|
const priv = generatePrivateKey(() => crypto.getRandomValues(new Uint8Array(32)));
|
||
|
|
const pub = secp256k1.derivePublicKeyCompressed(priv);
|
||
|
|
const pkh = hash160(pub);
|
||
|
|
const lock = encodeLockingBytecodeP2pkh(pkh);
|
||
|
|
return { priv, pub, pkh: binToHex(pkh), lock, lockHex: binToHex(lock), address: lockToAddress(lock, NET.prefix, false) };
|
||
|
|
};
|
||
|
|
const owner = person(), buyer = person(), taker = person(), stranger = person();
|
||
|
|
const keyring = (...people) => (lockHex) => { const p = people.find((x) => x.lockHex === lockHex); return p ? { privateKey: p.priv, publicKey: p.pub } : null; };
|
||
|
|
const KEYS = keyring(owner, buyer, taker, stranger);
|
||
|
|
const fundLock = hexToBin(COV.fundLock);
|
||
|
|
const beaconLock = hexToBin(addressToLockHex(NET.beacon));
|
||
|
|
const termsFor = (name) => registryTerms({ tier: tierForName(name), fundLock, stewardPk: hexToBin(COV.steward) });
|
||
|
|
let coinSeq = 100;
|
||
|
|
const coin = (who, sats = 50_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 = (built) => ({
|
||
|
|
txid: built.txid,
|
||
|
|
vin: built.transaction.inputs.map((i) => ({ txid: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })),
|
||
|
|
vout: built.transaction.outputs.map((o, n) => {
|
||
|
|
const hex = binToHex(o.lockingBytecode);
|
||
|
|
let address;
|
||
|
|
try { address = lockToAddress(o.lockingBytecode, NET.prefix, false); } catch { address = undefined; }
|
||
|
|
return {
|
||
|
|
n, value: Number(o.valueSatoshis) / 1e8,
|
||
|
|
scriptPubKey: { hex, ...(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) } } : {}) } } : {}),
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const vmAccepts = (built, what) => assert.equal(VM.verify({ transaction: built.transaction, sourceOutputs: built.sourceOutputs }), true, `${what}: the VM must accept it`);
|
||
|
|
const nextCov = (built, token) => ({ txid: built.txid, vout: 0, satoshis: built.transaction.outputs[0].valueSatoshis, lockingBytecode: built.transaction.outputs[0].lockingBytecode, token });
|
||
|
|
|
||
|
|
// A plain registration, hand-made (no contract involved).
|
||
|
|
let catSeq = 1;
|
||
|
|
function registration(name, who, records = { u: "https://first.example" }) {
|
||
|
|
const category = (catSeq++).toString(16).padStart(2, "0").repeat(32);
|
||
|
|
const token = { category, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(name)) } };
|
||
|
|
const txid = "f" + (catSeq).toString(16).padStart(63, "0");
|
||
|
|
const tx = {
|
||
|
|
txid, vin: [{ txid: category, vout: 0 }],
|
||
|
|
vout: [
|
||
|
|
{ n: 0, value: 0.00001, scriptPubKey: { hex: who.lockHex, address: who.address }, tokenData: { category, amount: "0", nft: { capability: "none", commitment: token.nft.commitment } } },
|
||
|
|
{ n: 1, value: 0, scriptPubKey: { hex: binToHex(opReturnScript(encodePayload("REG", name, records))) } },
|
||
|
|
{ n: 2, value: 0.000006, scriptPubKey: { hex: binToHex(beaconLock), address: NET.beacon } },
|
||
|
|
],
|
||
|
|
};
|
||
|
|
return { tx, cert: { txid, vout: 0, satoshis: 1000n, lockingBytecode: who.lock, token } };
|
||
|
|
}
|
||
|
|
const announce = (name, records) => ({ name, records, beaconLock });
|
||
|
|
|
||
|
|
// ---------- the three reducers, which must agree ----------
|
||
|
|
async function reduce(entries, height) {
|
||
|
|
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 }, height });
|
||
|
|
const el = {
|
||
|
|
call: async (method, [a]) => method === "blockchain.scripthash.get_history" ? history.map((x) => ({ ...x }))
|
||
|
|
: method === "blockchain.headers.subscribe" ? { height }
|
||
|
|
: txs[a],
|
||
|
|
};
|
||
|
|
const live = await buildIndex({ client: el, cosignGate: false });
|
||
|
|
const old = await legacy.buildIndex({ getHistory: async () => history.map((x) => ({ ...x })), getRawTransactionObject: async (id) => txs[id] }, { cosignGate: false, height });
|
||
|
|
const shape = (idx) => [...idx].map(([k, e]) => JSON.stringify([k, e.records, e.covenant ?? null, e.status ?? null, e.expiry ?? null, e.lapsedRecords ?? null])).sort();
|
||
|
|
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);
|
||
|
|
return snap;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- the table ----------
|
||
|
|
|
||
|
|
test("the network table holds the contract's code, byte for byte", () => {
|
||
|
|
assert.deepEqual(covenantTable({ fundLock, stewardPk: hexToBin(COV.steward) }), COV, "regenerate: node src/generate-covenant-table.mjs");
|
||
|
|
for (const t of TIERS) {
|
||
|
|
const terms = registryTerms({ tier: t.tier, fundLock, stewardPk: hexToBin(COV.steward) });
|
||
|
|
const state = { ownerPkh: owner.pkh, expiry: 123456 + t.tier };
|
||
|
|
assert.equal(covenantLockHex(COV, t.tier - 1, state.ownerPkh, state.expiry), binToHex(registryLock(terms, state)), `tier ${t.tier}`);
|
||
|
|
}
|
||
|
|
for (const name of ["a.x", "ab.x", "abc.x", "abcd.x", "abcde.x", "abcdefg.x", "abcdefgh.x", "a-much-longer-label.test", "bare"]) {
|
||
|
|
assert.equal(covenantTierIndex(name, COV) + 1, tierForName(name), name);
|
||
|
|
}
|
||
|
|
assert.equal(NETWORKS.mainnet.covenant, null, "mainnet is not pinned");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("pkhToAddress is the inverse of addressToLockHex", () => {
|
||
|
|
for (const p of [owner, buyer, taker]) {
|
||
|
|
assert.equal(pkhToAddress(p.pkh, NET.prefix), p.address);
|
||
|
|
assert.equal(addressToLockHex(pkhToAddress(p.pkh, NET.prefix)), p.lockHex);
|
||
|
|
}
|
||
|
|
assert.equal(pkhToAddress(owner.pkh, "bitcoincash"), lockToAddress(owner.lock, "bitcoincash", false));
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- entering ----------
|
||
|
|
|
||
|
|
const NAME = "holdme.x";
|
||
|
|
const H_REG = 1000, TIP = 1999, H_ENTER = 2000;
|
||
|
|
|
||
|
|
function entered({ name = NAME, who = owner, years = 1, height = TIP, records = { u: "https://held.example" } } = {}) {
|
||
|
|
const reg = registration(name, who);
|
||
|
|
const terms = termsFor(name);
|
||
|
|
const enter = buildRegistryEnterTx({ cert: reg.cert, terms, name, records, ownerPkh: who.pkh, height, years, beaconLock, coins: [coin(who)], changeLock: who.lock, keyFor: KEYS });
|
||
|
|
return { reg, terms, enter, cov: nextCov(enter, reg.cert.token), state: enter.state };
|
||
|
|
}
|
||
|
|
|
||
|
|
test("a paid entry makes the name covenant-held: owner and expiry come from the verified claim", async () => {
|
||
|
|
const { reg, enter, state } = entered();
|
||
|
|
const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], H_ENTER);
|
||
|
|
const e = idx.get(NAME);
|
||
|
|
assert.deepEqual(e.covenant, { ownerPkh: owner.pkh, expiry: TIP + BLOCKS_PER_YEAR, tier: 3, version: 1 });
|
||
|
|
assert.equal(e.owner, owner.address, "the owner is the key in the state, not the covenant's address");
|
||
|
|
assert.deepEqual(e.records, { u: "https://held.example" });
|
||
|
|
assert.equal(e.status, "active");
|
||
|
|
assert.equal(e.expiry, state.expiry);
|
||
|
|
assert.equal(e.idleEnd, state.expiry + GRACE_BLOCKS);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("the lifecycle: resolves while active and idle, not once open — and stays taken", async () => {
|
||
|
|
const { reg, enter, state } = entered();
|
||
|
|
const at = async (h) => (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], h)).get(NAME);
|
||
|
|
assert.equal((await at(state.expiry - 1)).status, "active");
|
||
|
|
const idle = await at(state.expiry);
|
||
|
|
assert.equal(idle.status, "idle");
|
||
|
|
assert.deepEqual(idle.records, { u: "https://held.example" }, "the owner keeps the records through the grace period");
|
||
|
|
assert.equal((await at(state.expiry + GRACE_BLOCKS - 1)).status, "idle");
|
||
|
|
const open = await at(state.expiry + GRACE_BLOCKS);
|
||
|
|
assert.equal(open.status, "open");
|
||
|
|
assert.deepEqual(open.records, {}, "a lapsed name resolves to nothing");
|
||
|
|
assert.deepEqual(open.lapsedRecords, { u: "https://held.example" });
|
||
|
|
|
||
|
|
// Its certificate still exists: a fresh registration of the same name is refused.
|
||
|
|
const again = registration(NAME, stranger, { u: "https://squatter.example" });
|
||
|
|
const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER], [again.tx, state.expiry + GRACE_BLOCKS + 5]], state.expiry + GRACE_BLOCKS + 10);
|
||
|
|
assert.equal(idx.get(NAME).category, reg.cert.token.category);
|
||
|
|
assert.deepEqual(idx.get(NAME).records, {});
|
||
|
|
|
||
|
|
assert.deepEqual(covenantPhase(open.covenant, state.expiry + GRACE_BLOCKS), { status: "open", expiry: state.expiry, idleEnd: state.expiry + GRACE_BLOCKS });
|
||
|
|
assert.equal(covenantPhase(null, 5), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an entry that was not paid for leaves an ordinary name", async () => {
|
||
|
|
const plain = async (enter, height, why) => {
|
||
|
|
const idx = await reduce([[enter.reg.tx, H_REG], [enter.tx, height]], Math.max(height, H_ENTER));
|
||
|
|
const e = idx.get(enter.name ?? NAME);
|
||
|
|
assert.equal(e.covenant, undefined, why);
|
||
|
|
assert.equal(e.status, undefined, why);
|
||
|
|
assert.deepEqual(e.records, { u: "https://held.example" }, `${why}: it is still a valid update of an ordinary name`);
|
||
|
|
return e;
|
||
|
|
};
|
||
|
|
const base = entered();
|
||
|
|
const fundIndex = verbose(base.enter).vout.findIndex((v) => v.scriptPubKey.hex === COV.fundLock && !v.tokenData);
|
||
|
|
assert.equal(fundIndex, 1);
|
||
|
|
|
||
|
|
// one satoshi short
|
||
|
|
const short = verbose(base.enter);
|
||
|
|
short.vout[1].value = (COV.tiers[2].renewalSats - 1) / 1e8;
|
||
|
|
await plain({ reg: base.reg, tx: short }, H_ENTER, "one satoshi short of a year");
|
||
|
|
|
||
|
|
// paid to somebody else
|
||
|
|
const elsewhere = verbose(base.enter);
|
||
|
|
elsewhere.vout[1].scriptPubKey = { hex: stranger.lockHex, address: stranger.address };
|
||
|
|
await plain({ reg: base.reg, tx: elsewhere }, H_ENTER, "paid to another address");
|
||
|
|
|
||
|
|
// a token output to the fund is not a payment
|
||
|
|
const tokenPay = verbose(base.enter);
|
||
|
|
tokenPay.vout[1].tokenData = { category: "ee".repeat(32), amount: "5" };
|
||
|
|
await plain({ reg: base.reg, tx: tokenPay }, H_ENTER, "the fund output carries a token");
|
||
|
|
|
||
|
|
// one year paid, two claimed
|
||
|
|
const far = entered({ height: TIP + BLOCKS_PER_YEAR });
|
||
|
|
await plain({ reg: far.reg, tx: verbose(far.enter) }, H_ENTER, "expiry further out than was paid for");
|
||
|
|
|
||
|
|
// not in a block yet
|
||
|
|
const e = await plain({ reg: base.reg, tx: verbose(base.enter) }, 0, "unconfirmed");
|
||
|
|
assert.notEqual(e.owner, owner.address, "until it confirms, the holder is just the address the certificate sits at");
|
||
|
|
|
||
|
|
// two years paid, two claimed: fine
|
||
|
|
const two = entered({ years: 2 });
|
||
|
|
const idx = await reduce([[two.reg.tx, H_REG], [verbose(two.enter), H_ENTER]], H_ENTER);
|
||
|
|
assert.equal(idx.get(NAME).covenant.expiry, TIP + 2 * BLOCKS_PER_YEAR);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a claim that does not hash to the address is not a claim", async () => {
|
||
|
|
const base = entered();
|
||
|
|
const holder = verbose(base.enter).vout[0];
|
||
|
|
const good = covenantClaimOf(base.state);
|
||
|
|
assert.ok(covenantClaim({ c: good }, holder, NAME));
|
||
|
|
const bad = [
|
||
|
|
{ ...good, o: buyer.pkh }, { ...good, e: good.e + 1 }, { ...good, e: String(good.e) }, { ...good, o: good.o.toUpperCase() },
|
||
|
|
{ o: good.o }, { e: good.e }, null, "x", { ...good, e: 500000000 }, { ...good, e: 0 }, { ...good, e: good.e + 0.5 },
|
||
|
|
];
|
||
|
|
for (const c of bad) assert.equal(covenantClaim({ c }, holder, NAME), null, JSON.stringify(c));
|
||
|
|
// the right state under another tier's prices is another address
|
||
|
|
assert.equal(covenantClaim({ c: good }, holder, "ab.x"), null);
|
||
|
|
assert.equal(covenantClaim({ c: good }, holder, "a-long-label.x"), null);
|
||
|
|
// a network with no covenant pinned knows no covenant-held names
|
||
|
|
assert.equal(covenantClaim({ c: good }, holder, NAME, NETWORKS.mainnet), null);
|
||
|
|
|
||
|
|
// In the index: a short name entered at the cheap tier's address stays ordinary.
|
||
|
|
const reg = registration("ab.x", owner);
|
||
|
|
const cheap = registryTerms({ tier: 4, fundLock, stewardPk: hexToBin(COV.steward) });
|
||
|
|
const enter = buildRegistryEnterTx({ cert: reg.cert, terms: cheap, name: "ab.x", records: { u: "https://held.example" }, ownerPkh: owner.pkh, height: TIP, beaconLock, coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
|
||
|
|
const idx = await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], H_ENTER);
|
||
|
|
assert.equal(idx.get("ab.x").covenant, undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- inside ----------
|
||
|
|
|
||
|
|
test("inside the covenant: updates, transfers and renewals are read from announced states", async () => {
|
||
|
|
const { reg, terms, enter, cov, state } = entered();
|
||
|
|
const chain = [[reg.tx, H_REG], [verbose(enter), H_ENTER]];
|
||
|
|
|
||
|
|
// records update: a transfer to the same key
|
||
|
|
const upd = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: owner.pkh, announce: announce(NAME, { u: "https://second.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
|
||
|
|
vmAccepts(upd, "records update");
|
||
|
|
chain.push([verbose(upd), 2100]);
|
||
|
|
let e = (await reduce(chain, 2100)).get(NAME);
|
||
|
|
assert.deepEqual(e.records, { u: "https://second.example" });
|
||
|
|
assert.equal(e.owner, owner.address);
|
||
|
|
|
||
|
|
// transfer to the buyer
|
||
|
|
const xfer = buildRegistryTransferTx({ cov: nextCov(upd, cov.token), terms, state: upd.state, newOwnerPkh: buyer.pkh, announce: announce(NAME, { u: "https://buyer.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
|
||
|
|
vmAccepts(xfer, "transfer");
|
||
|
|
chain.push([verbose(xfer), 2200]);
|
||
|
|
e = (await reduce(chain, 2200)).get(NAME);
|
||
|
|
assert.equal(e.owner, buyer.address);
|
||
|
|
assert.equal(e.covenant.expiry, state.expiry, "a transfer does not move the expiry");
|
||
|
|
|
||
|
|
// renewal by the buyer
|
||
|
|
const renew = buildRegistryRenewTx({ cov: nextCov(xfer, cov.token), terms, state: xfer.state, years: 2, announce: announce(NAME, { u: "https://buyer.example" }), coins: [coin(buyer)], changeLock: buyer.lock, keyFor: KEYS });
|
||
|
|
vmAccepts(renew, "renew");
|
||
|
|
chain.push([verbose(renew), 2300]);
|
||
|
|
e = (await reduce(chain, state.expiry + GRACE_BLOCKS + 1)).get(NAME);
|
||
|
|
assert.equal(e.covenant.expiry, state.expiry + 2 * BLOCKS_PER_YEAR);
|
||
|
|
assert.equal(e.status, "active", "the renewal was announced, so the name did not lapse");
|
||
|
|
|
||
|
|
// an unannounced renewal is valid on chain and invisible here: the name lapses in the index
|
||
|
|
const quiet = (await reduce(chain.slice(0, -1), state.expiry + GRACE_BLOCKS + 1)).get(NAME);
|
||
|
|
assert.equal(quiet.status, "open");
|
||
|
|
assert.deepEqual(quiet.records, {});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("once inside, a transaction without a verified state changes nothing", async () => {
|
||
|
|
const { reg, terms, enter, cov, state } = entered();
|
||
|
|
const base = [[reg.tx, H_REG], [verbose(enter), H_ENTER]];
|
||
|
|
const real = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: owner.pkh, announce: announce(NAME, { u: "https://hijack.example" }), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
|
||
|
|
const payloadAt = verbose(real).vout.findIndex((v) => v.scriptPubKey.hex.startsWith("6a"));
|
||
|
|
const withPayload = (payload) => { const t = verbose(real); t.vout[payloadAt].scriptPubKey.hex = binToHex(opReturnScript(payload)); return t; };
|
||
|
|
|
||
|
|
const cases = [
|
||
|
|
["no claim at all — the way back to a name that never expires", encodePayload("UPD", NAME, { u: "https://hijack.example" })],
|
||
|
|
["a claim for another owner", encodePayload("UPD", NAME, { u: "https://hijack.example" }, { o: stranger.pkh, e: state.expiry })],
|
||
|
|
["a claim for a later expiry", encodePayload("UPD", NAME, { u: "https://hijack.example" }, { o: owner.pkh, e: state.expiry + BLOCKS_PER_YEAR })],
|
||
|
|
];
|
||
|
|
for (const [what, payload] of cases) {
|
||
|
|
const e = (await reduce([...base, [withPayload(payload), 2100]], 2100)).get(NAME);
|
||
|
|
assert.deepEqual(e.records, { u: "https://held.example" }, what);
|
||
|
|
assert.equal(e.covenant.expiry, state.expiry, what);
|
||
|
|
assert.equal(e.owner, owner.address, what);
|
||
|
|
}
|
||
|
|
// and the honest announcement of the same transaction is accepted
|
||
|
|
assert.deepEqual((await reduce([...base, [verbose(real), 2100]], 2100)).get(NAME).records, { u: "https://hijack.example" });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- leaving ----------
|
||
|
|
|
||
|
|
test("a takeover brings a lapsed name back, for its new owner", async () => {
|
||
|
|
const { reg, terms, enter, cov, state } = entered();
|
||
|
|
const openAt = state.expiry + GRACE_BLOCKS + 100;
|
||
|
|
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||
|
|
const box = { txid: "b0".repeat(32), vout: 0, satoshis: 1000n, lockingBytecode: commitBoxLock({ ownerPkh: taker.pkh, commitHash: commitHashFor(NAME, salt) }) };
|
||
|
|
const take = buildRegistryTakeoverTx({ cov, terms, state, name: NAME, height: openAt, newOwnerPkh: taker.pkh, salt, box, announce: announce(NAME, { u: "https://taker.example" }), coins: [coin(taker)], changeLock: taker.lock, keyFor: KEYS });
|
||
|
|
vmAccepts(take, "takeover");
|
||
|
|
const before = (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER]], openAt)).get(NAME);
|
||
|
|
assert.deepEqual(before.records, {});
|
||
|
|
const after = (await reduce([[reg.tx, H_REG], [verbose(enter), H_ENTER], [verbose(take), openAt + 1]], openAt + 1)).get(NAME);
|
||
|
|
assert.equal(after.owner, taker.address);
|
||
|
|
assert.deepEqual(after.records, { u: "https://taker.example" });
|
||
|
|
assert.equal(after.status, "active");
|
||
|
|
assert.equal(after.covenant.expiry, openAt + BLOCKS_PER_YEAR);
|
||
|
|
assert.equal(after.category, reg.cert.token.category, "the same certificate: a takeover is not a new registration");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a release from the covenant frees the name like any other", async () => {
|
||
|
|
const { reg, terms, enter, cov, state } = entered();
|
||
|
|
const rel = buildRegistryReleaseTx({ cov, terms, state, announce: announce(NAME), coins: [coin(owner)], changeLock: owner.lock, keyFor: KEYS });
|
||
|
|
vmAccepts(rel, "release");
|
||
|
|
const chain = [[reg.tx, H_REG], [verbose(enter), H_ENTER], [verbose(rel), 2100]];
|
||
|
|
const idx = await reduce(chain, 2100);
|
||
|
|
assert.equal(idx.has(NAME), false);
|
||
|
|
assert.equal(idx.released.get(NAME), 2100);
|
||
|
|
const again = registration(NAME, stranger, { u: "https://next.example" });
|
||
|
|
assert.equal((await reduce([...chain, [again.tx, 2100]], 2101)).has(NAME), false, "not in the release's own block");
|
||
|
|
assert.deepEqual((await reduce([...chain, [again.tx, 2101]], 2101)).get(NAME).records, { u: "https://next.example" });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- registering straight in ----------
|
||
|
|
|
||
|
|
test("a registration can go straight into the covenant, on the same terms as an entry", async () => {
|
||
|
|
const name = "straight.x";
|
||
|
|
const terms = termsFor(name);
|
||
|
|
const state = { ownerPkh: owner.pkh, expiry: TIP + BLOCKS_PER_YEAR };
|
||
|
|
const make = (paid) => {
|
||
|
|
const r = registration(name, owner, { u: "https://straight.example" });
|
||
|
|
r.tx.vout[0].scriptPubKey = { hex: binToHex(registryLock(terms, state)) };
|
||
|
|
r.tx.vout[1].scriptPubKey.hex = binToHex(registryAnnouncement({ name, records: { u: "https://straight.example" }, beaconLock }, state, "REG")[0].lockingBytecode);
|
||
|
|
if (paid) r.tx.vout.push({ n: 3, value: COV.tiers[covenantTierIndex(name, COV)].renewalSats / 1e8, scriptPubKey: { hex: COV.fundLock } });
|
||
|
|
return r.tx;
|
||
|
|
};
|
||
|
|
const held = (await reduce([[make(true), H_ENTER]], H_ENTER)).get(name);
|
||
|
|
assert.equal(held.covenant.expiry, state.expiry);
|
||
|
|
assert.equal(held.owner, owner.address);
|
||
|
|
const unpaid = (await reduce([[make(false), H_ENTER]], H_ENTER)).get(name);
|
||
|
|
assert.equal(unpaid.covenant, undefined, "registered, but an ordinary name");
|
||
|
|
assert.deepEqual(unpaid.records, { u: "https://straight.example" });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------- which height ----------
|
||
|
|
|
||
|
|
test("expiry is judged at the tip when known, else at the newest block in the history — never ahead of the chain", async () => {
|
||
|
|
const { reg, enter, state } = entered();
|
||
|
|
const history = [{ tx_hash: reg.tx.txid, height: H_REG }, { tx_hash: enter.txid, height: H_ENTER }];
|
||
|
|
const txs = { [reg.tx.txid]: reg.tx, [enter.txid]: verbose(enter) };
|
||
|
|
assert.equal(historyHeight(history), H_ENTER);
|
||
|
|
assert.equal(historyHeight([{ height: 0 }, { height: -1 }]), 0);
|
||
|
|
|
||
|
|
const noTip = buildIndexFromSnapshot({ snapshot: { history, txs } });
|
||
|
|
assert.equal(noTip.height, H_ENTER);
|
||
|
|
assert.equal(noTip.get(NAME).status, "active");
|
||
|
|
const stamped = buildIndexFromSnapshot({ snapshot: { history, txs, height: state.expiry + GRACE_BLOCKS } });
|
||
|
|
assert.equal(stamped.get(NAME).status, "open");
|
||
|
|
|
||
|
|
// a server that will not say the tip: fall back to the history
|
||
|
|
const el = { call: async (m, [a]) => { if (m === "blockchain.scripthash.get_history") return history.map((x) => ({ ...x })); if (m === "blockchain.headers.subscribe") throw new Error("unsupported"); return txs[a]; } };
|
||
|
|
const live = await buildIndex({ client: el, cosignGate: false });
|
||
|
|
assert.equal(live.height, H_ENTER);
|
||
|
|
// an explicit height wins
|
||
|
|
assert.equal((await buildIndex({ client: el, cosignGate: false, height: state.expiry })).get(NAME).status, "idle");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("ordinary names are untouched: no status, no expiry, same entry as before", async () => {
|
||
|
|
const reg = registration("plain.x", owner);
|
||
|
|
const e = (await reduce([[reg.tx, H_REG]], 10_000_000)).get("plain.x");
|
||
|
|
assert.deepEqual(Object.keys(e).sort(), ["category", "height", "name", "owner", "records", "txid", "updatedTxid"]);
|
||
|
|
});
|