A name whose certificate sits in the registry covenant has an owner and an expiry that are hashed into its address, not written on the chain. Until now a resolver saw only "held by some script address", forever. The transaction that moves the certificate now says the state (`c` in the payload) and the resolver checks it against the address, using the covenant's code from the network table. Nobody is trusted about who owns such a name or until when. Entering is the one step script cannot police - a certificate can be sent to a covenant address with any expiry - so resolvers set the price: the entering transaction pays the fund for whole years and may claim no more than it paid for. Once a name is inside, only a verified state speaks for it; otherwise an owner could announce their way back to a name that never expires. A name past its grace period stays taken but resolves to nothing, judged at the chain tip (the indexer now answers what that is) or, failing that, at the newest block in the beacon history, so a stale index lapses a name late and never early. Same rules in the gateway, the Ariadne resolver and the Android resolver; the builders announce every move, and there is one to put an existing name into the covenant. Chipnet's fund and steward are the operator key.
382 lines
22 KiB
JavaScript
382 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"]);
|
|
});
|