registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This is that contract: a certificate sits in a covenant whose address encodes its owner and expiry, and five paths move it. The owner renews, transfers, releases or migrates; anyone takes over an expired name at a price that glides from a premium to a floor, paying the previous owner a share while the grace period lasts and committing to the name a block ahead. The two changing values are the last constructor arguments, so they sit first in the redeem script and the contract re-creates itself by swapping 26 bytes. A resolver that knows the code can check any claimed owner and expiry against the address. Every number comes from registry-lifecycle.js, which was written as the fixture for this. The tests run each path in libauth's VM under the 2025 and 2026 rules, compare price and split to that model along the glide, and then break one rule at a time with valid signatures; the suite counts that the VM itself refused them. Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md says where it departs from the design: script cannot bound a height from above, so reclaim has no deadline and a commit cannot expire, which leaves commit hoarding open. That is the first thing a reviewer should read.
This commit is contained in:
parent
efea7e2a73
commit
9f6dd69bf3
9 changed files with 1154 additions and 0 deletions
26
contracts/commit-box.cash
Normal file
26
contracts/commit-box.cash
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
pragma cashscript ^0.13.0;
|
||||
|
||||
// CommitBox — the "commit" of a commit–reveal takeover
|
||||
// (Decentralized.DNS/DESIGN-covenant-registry.md §6.3).
|
||||
//
|
||||
// A taker creates one of these a block or more BEFORE taking a name over. Its
|
||||
// address reveals nothing: commitHash = hash256(name ‖ salt). The registry
|
||||
// covenant rebuilds this script from the taker's key hash and the revealed
|
||||
// salt, and requires input 1 of the takeover to be locked to it.
|
||||
//
|
||||
// The box enforces its own age (this.age), so a takeover cannot be assembled in
|
||||
// the same block as its commit, and it needs the taker's signature, so nobody
|
||||
// else can spend it out from under them.
|
||||
//
|
||||
// STATUS: chipnet draft, NOT REVIEWED — see name-registry.cash.
|
||||
contract CommitBox(bytes32 commitHash, bytes20 ownerPkh) {
|
||||
function spend(pubkey pk, sig s) {
|
||||
require(hash160(pk) == ownerPkh);
|
||||
require(checkSig(s, pk));
|
||||
require(this.age >= 1);
|
||||
// The commitment is data, not logic: it only has to be part of this
|
||||
// script so that it is part of the address. (The compiler rejects an
|
||||
// unused constructor argument.)
|
||||
require(commitHash.length == 32);
|
||||
}
|
||||
}
|
||||
39
contracts/commit-box.json
Normal file
39
contracts/commit-box.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"contractName": "CommitBox",
|
||||
"constructorInputs": [
|
||||
{ "name": "commitHash", "type": "bytes32" },
|
||||
{ "name": "ownerPkh", "type": "bytes20" }
|
||||
],
|
||||
"abi": [
|
||||
{
|
||||
"name": "spend",
|
||||
"inputs": [
|
||||
{ "name": "pk", "type": "pubkey" },
|
||||
{ "name": "s", "type": "sig" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"bytecode": "OP_2 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY OP_ROT OP_ROT OP_CHECKSIGVERIFY OP_1 OP_CHECKSEQUENCEVERIFY OP_DROP OP_SIZE OP_NIP 20 OP_NUMEQUAL",
|
||||
"source": "pragma cashscript ^0.13.0;\n\n// CommitBox — the \"commit\" of a commit–reveal takeover\n// (Decentralized.DNS/DESIGN-covenant-registry.md §6.3).\n//\n// A taker creates one of these a block or more BEFORE taking a name over. Its\n// address reveals nothing: commitHash = hash256(name ‖ salt). The registry\n// covenant rebuilds this script from the taker's key hash and the revealed\n// salt, and requires input 1 of the takeover to be locked to it.\n//\n// The box enforces its own age (this.age), so a takeover cannot be assembled in\n// the same block as its commit, and it needs the taker's signature, so nobody\n// else can spend it out from under them.\n//\n// STATUS: chipnet draft, NOT REVIEWED — see name-registry.cash.\ncontract CommitBox(bytes32 commitHash, bytes20 ownerPkh) {\n function spend(pubkey pk, sig s) {\n require(hash160(pk) == ownerPkh);\n require(checkSig(s, pk));\n require(this.age >= 1);\n // The commitment is data, not logic: it only has to be part of this\n // script so that it is part of the address. (The compiler rejects an\n // unused constructor argument.)\n require(commitHash.length == 32);\n }\n}\n",
|
||||
"fingerprint": "37cd443c35ce338bfe5e76055c161118d5a57a6d056f3cfd9484a571bb641d88",
|
||||
"debug": {
|
||||
"bytecode": "5279a97b887b7bad51b275827701209c",
|
||||
"sourceMap": "18:24:18:26;;:16::27:1;:31::39:0;:8::41:1;19:25:19:26:0;:28::30;:8::33:1;20:28:20:29:0;:8::31:1;;24:16:24:33;;:37::39:0;:8::41:1",
|
||||
"logs": [],
|
||||
"requires": [
|
||||
{ "ip": 6, "line": 18 },
|
||||
{ "ip": 9, "line": 19 },
|
||||
{ "ip": 11, "line": 20 },
|
||||
{ "ip": 17, "line": 24 }
|
||||
]
|
||||
},
|
||||
"compiler": {
|
||||
"name": "cashc",
|
||||
"version": "0.13.3",
|
||||
"options": {
|
||||
"enforceFunctionParameterTypes": true,
|
||||
"enforceLocktimeGuard": true
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-10-04T13:37:02.552Z"
|
||||
}
|
||||
160
contracts/name-registry.cash
Normal file
160
contracts/name-registry.cash
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
pragma cashscript ^0.13.0;
|
||||
|
||||
// NameRegistry v1 — the covenant that holds a name certificate and enforces
|
||||
// its lifecycle (Decentralized.DNS/DESIGN-covenant-registry.md).
|
||||
//
|
||||
// STATUS: chipnet draft. NOT REVIEWED. A flaw here either locks every name
|
||||
// forever or lets anyone take one; nothing built from this file may hold a
|
||||
// mainnet name until someone who did not write it has reviewed it.
|
||||
//
|
||||
// The certificate (an immutable NFT whose commitment is the name) sits in an
|
||||
// output locked by this script. Two values change over a name's life — who
|
||||
// owns it and when it expires — and they are the LAST two constructor
|
||||
// arguments, which CashScript pushes FIRST. The redeem script therefore begins
|
||||
//
|
||||
// 0x14 <ownerPkh:20> 0x04 <expiry:4> <everything else>
|
||||
//
|
||||
// and every path that keeps the name re-creates the covenant by replacing
|
||||
// those 26 bytes and requiring output 0 to be locked to the result. One
|
||||
// contract, one code hash per price tier; the state is in the address.
|
||||
//
|
||||
// Numbers must match Argus/src/lib/registry-lifecycle.js, which is the
|
||||
// reference: integer arithmetic, multiply before dividing.
|
||||
//
|
||||
// What script cannot do, stated once: it can prove a block height has been
|
||||
// REACHED (tx.time), never that one has NOT. So "the owner may reclaim only
|
||||
// until the grace period ends" is not enforceable; an owner can renew for as
|
||||
// long as nobody has taken the name over. The takeover price is safe against
|
||||
// this: a taker can only claim a height that is already past, and a later
|
||||
// height means a lower price they are entitled to anyway.
|
||||
contract NameRegistry(
|
||||
// ---- fixed for this covenant version and price tier ----
|
||||
bytes fundLock, // locking bytecode the fund's payments must go to
|
||||
pubkey stewardPk, // approves the CODE of a newer covenant version (migrate)
|
||||
bytes commitBoxCode, // redeem-script body of the CommitBox contract
|
||||
int renewalSats, // price of one year, for the owner
|
||||
int premiumSats, // takeover price at the moment of expiry
|
||||
int floorSats, // takeover price once the grace period has run out
|
||||
int graceBlocks, // length of the grace period
|
||||
int blocksPerYear,
|
||||
int ownerPercentIdle, // previous owner's share of a takeover during grace
|
||||
// ---- state: keep these LAST, in this order ----
|
||||
bytes4 expiry, // block height the paid period ends at
|
||||
bytes20 ownerPkh
|
||||
) {
|
||||
// Keep the name: pay for `years` more. Also the reclaim path — an expired
|
||||
// name renews exactly the same way, from its old expiry.
|
||||
function renew(pubkey pk, sig s, int years) {
|
||||
require(hash160(pk) == ownerPkh);
|
||||
require(checkSig(s, pk));
|
||||
require(years >= 1);
|
||||
|
||||
int newExpiry = int(expiry) + years * blocksPerYear;
|
||||
bytes newState = 0x14 + ownerPkh + 0x04 + toPaddedBytes(newExpiry, 4);
|
||||
bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);
|
||||
|
||||
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
|
||||
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
|
||||
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
|
||||
require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);
|
||||
|
||||
require(tx.outputs[1].lockingBytecode == fundLock);
|
||||
require(tx.outputs[1].value >= renewalSats * years);
|
||||
require(tx.outputs[1].tokenCategory == 0x);
|
||||
}
|
||||
|
||||
// Hand the name to another key (a sale, a gift), or re-create it for the
|
||||
// same key — which is how records are updated: the certificate has to move
|
||||
// for a resolver to accept an UPD. Expiry is untouched.
|
||||
function transfer(pubkey pk, sig s, bytes20 newOwnerPkh) {
|
||||
require(hash160(pk) == ownerPkh);
|
||||
require(checkSig(s, pk));
|
||||
|
||||
bytes newState = 0x14 + newOwnerPkh + 0x04 + expiry;
|
||||
bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);
|
||||
|
||||
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
|
||||
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
|
||||
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
|
||||
require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);
|
||||
}
|
||||
|
||||
// Take an expired name. No owner signature: anyone may, at a price that
|
||||
// glides from premiumSats at expiry down to floorSats at the end of the
|
||||
// grace period and stays there. During grace the previous owner is paid
|
||||
// ownerPercentIdle of the price; afterwards the fund receives all of it.
|
||||
//
|
||||
// Input 1 must be a CommitBox the taker created at least a block earlier,
|
||||
// committing to this name and to their own key. It hides which name is
|
||||
// wanted until the takeover itself, so the transaction cannot be copied
|
||||
// from the mempool by someone who had not already committed.
|
||||
function takeover(bytes20 newOwnerPkh, bytes salt) {
|
||||
int expiryHeight = int(expiry);
|
||||
require(tx.time >= expiryHeight);
|
||||
int height = tx.locktime;
|
||||
require(height < 500000000); // a block height, not a timestamp
|
||||
|
||||
int idleEnd = expiryHeight + graceBlocks;
|
||||
int price = floorSats;
|
||||
int ownerSats = 0;
|
||||
if (height < idleEnd) {
|
||||
price = floorSats + (premiumSats - floorSats) * (idleEnd - height) / graceBlocks;
|
||||
ownerSats = price * ownerPercentIdle / 100;
|
||||
}
|
||||
|
||||
// the commit
|
||||
bytes32 commitHash = hash256(tx.inputs[this.activeInputIndex].nftCommitment + salt);
|
||||
bytes boxRedeem = 0x14 + newOwnerPkh + 0x20 + commitHash + commitBoxCode;
|
||||
require(tx.inputs[1].lockingBytecode == new LockingBytecodeP2SH32(hash256(boxRedeem)));
|
||||
|
||||
// the name, under its new owner, for one fresh year
|
||||
bytes newState = 0x14 + newOwnerPkh + 0x04 + toPaddedBytes(height + blocksPerYear, 4);
|
||||
bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);
|
||||
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
|
||||
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
|
||||
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
|
||||
require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);
|
||||
|
||||
// the money
|
||||
require(tx.outputs[1].lockingBytecode == fundLock);
|
||||
require(tx.outputs[1].value >= price - ownerSats);
|
||||
require(tx.outputs[1].tokenCategory == 0x);
|
||||
if (ownerSats > 0) {
|
||||
require(tx.outputs[2].lockingBytecode == new LockingBytecodeP2PKH(ownerPkh));
|
||||
require(tx.outputs[2].value >= ownerSats);
|
||||
require(tx.outputs[2].tokenCategory == 0x);
|
||||
}
|
||||
}
|
||||
|
||||
// Give the name back: the certificate is destroyed. At most four outputs,
|
||||
// none of which may carry it — the owner cannot use this path to walk the
|
||||
// certificate out of the covenant into a plain wallet.
|
||||
function release(pubkey pk, sig s) {
|
||||
require(hash160(pk) == ownerPkh);
|
||||
require(checkSig(s, pk));
|
||||
|
||||
bytes category = tx.inputs[this.activeInputIndex].tokenCategory;
|
||||
require(tx.outputs.length <= 4);
|
||||
require(tx.outputs[0].tokenCategory != category);
|
||||
if (tx.outputs.length > 1) { require(tx.outputs[1].tokenCategory != category); }
|
||||
if (tx.outputs.length > 2) { require(tx.outputs[2].tokenCategory != category); }
|
||||
if (tx.outputs.length > 3) { require(tx.outputs[3].tokenCategory != category); }
|
||||
}
|
||||
|
||||
// Move to a newer covenant version. The owner chooses to; the steward has
|
||||
// signed the hash of the new version's CODE, once, for everyone. Neither
|
||||
// can act alone: the steward cannot move a name, and the owner cannot
|
||||
// migrate to a script the steward has not published. `newState` is whatever
|
||||
// the new version expects in front of its code.
|
||||
function migrate(pubkey pk, sig s, datasig approval, bytes newCode, bytes newState) {
|
||||
require(hash160(pk) == ownerPkh);
|
||||
require(checkSig(s, pk));
|
||||
require(checkDataSig(approval, hash256(newCode), stewardPk));
|
||||
|
||||
bytes32 newHash = hash256(newState + newCode);
|
||||
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
|
||||
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
|
||||
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
|
||||
require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);
|
||||
}
|
||||
}
|
||||
121
contracts/name-registry.json
Normal file
121
contracts/name-registry.json
Normal file
File diff suppressed because one or more lines are too long
26
src/gen-contract-artifacts.mjs
Normal file
26
src/gen-contract-artifacts.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Turn compiled contracts (contracts/*.json, cashc output) into small ES
|
||||
// modules the browser bundle and the gateway can import without a JSON loader.
|
||||
//
|
||||
// npx cashc contracts/name-registry.cash -o contracts/name-registry.json
|
||||
// npx cashc contracts/commit-box.cash -o contracts/commit-box.json
|
||||
// node src/gen-contract-artifacts.mjs
|
||||
//
|
||||
// The generated files are committed: a contract's bytecode IS its address, so
|
||||
// what the code builds must never depend on which compiler happens to be
|
||||
// installed. Recompile deliberately, and expect every address to change.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const TARGETS = [
|
||||
{ json: "name-registry.json", out: "name-registry-artifact.js", constant: "NAME_REGISTRY" },
|
||||
{ json: "commit-box.json", out: "commit-box-artifact.js", constant: "COMMIT_BOX" },
|
||||
];
|
||||
for (const t of TARGETS) {
|
||||
const a = JSON.parse(readFileSync(new URL(`../contracts/${t.json}`, import.meta.url), "utf8"));
|
||||
const artifact = { contractName: a.contractName, constructorInputs: a.constructorInputs, abi: a.abi, bytecode: a.bytecode, compiler: a.compiler };
|
||||
const out = `// Generated from contracts/${t.json} by src/gen-contract-artifacts.mjs — do not edit.
|
||||
// ${a.contractName} compiled with ${a.compiler?.name} ${a.compiler?.version}.
|
||||
export const ${t.constant} = ${JSON.stringify(artifact, null, 2)};
|
||||
`;
|
||||
writeFileSync(new URL(`./lib/${t.out}`, import.meta.url), out);
|
||||
console.log("wrote", t.out, `(${a.bytecode.split(" ").length} ops)`);
|
||||
}
|
||||
39
src/lib/commit-box-artifact.js
Normal file
39
src/lib/commit-box-artifact.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Generated from contracts/commit-box.json by src/gen-contract-artifacts.mjs — do not edit.
|
||||
// CommitBox compiled with cashc 0.13.3.
|
||||
export const COMMIT_BOX = {
|
||||
"contractName": "CommitBox",
|
||||
"constructorInputs": [
|
||||
{
|
||||
"name": "commitHash",
|
||||
"type": "bytes32"
|
||||
},
|
||||
{
|
||||
"name": "ownerPkh",
|
||||
"type": "bytes20"
|
||||
}
|
||||
],
|
||||
"abi": [
|
||||
{
|
||||
"name": "spend",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "s",
|
||||
"type": "sig"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bytecode": "OP_2 OP_PICK OP_HASH160 OP_ROT OP_EQUALVERIFY OP_ROT OP_ROT OP_CHECKSIGVERIFY OP_1 OP_CHECKSEQUENCEVERIFY OP_DROP OP_SIZE OP_NIP 20 OP_NUMEQUAL",
|
||||
"compiler": {
|
||||
"name": "cashc",
|
||||
"version": "0.13.3",
|
||||
"options": {
|
||||
"enforceFunctionParameterTypes": true,
|
||||
"enforceLocktimeGuard": true
|
||||
}
|
||||
}
|
||||
};
|
||||
147
src/lib/name-registry-artifact.js
Normal file
147
src/lib/name-registry-artifact.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Generated from contracts/name-registry.json by src/gen-contract-artifacts.mjs — do not edit.
|
||||
// NameRegistry compiled with cashc 0.13.3.
|
||||
export const NAME_REGISTRY = {
|
||||
"contractName": "NameRegistry",
|
||||
"constructorInputs": [
|
||||
{
|
||||
"name": "fundLock",
|
||||
"type": "bytes"
|
||||
},
|
||||
{
|
||||
"name": "stewardPk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "commitBoxCode",
|
||||
"type": "bytes"
|
||||
},
|
||||
{
|
||||
"name": "renewalSats",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "premiumSats",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "floorSats",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "graceBlocks",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "blocksPerYear",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "ownerPercentIdle",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "expiry",
|
||||
"type": "bytes4"
|
||||
},
|
||||
{
|
||||
"name": "ownerPkh",
|
||||
"type": "bytes20"
|
||||
}
|
||||
],
|
||||
"abi": [
|
||||
{
|
||||
"name": "renew",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "s",
|
||||
"type": "sig"
|
||||
},
|
||||
{
|
||||
"name": "years",
|
||||
"type": "int"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "transfer",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "s",
|
||||
"type": "sig"
|
||||
},
|
||||
{
|
||||
"name": "newOwnerPkh",
|
||||
"type": "bytes20"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "takeover",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "newOwnerPkh",
|
||||
"type": "bytes20"
|
||||
},
|
||||
{
|
||||
"name": "salt",
|
||||
"type": "bytes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "release",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "s",
|
||||
"type": "sig"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "migrate",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pk",
|
||||
"type": "pubkey"
|
||||
},
|
||||
{
|
||||
"name": "s",
|
||||
"type": "sig"
|
||||
},
|
||||
{
|
||||
"name": "approval",
|
||||
"type": "datasig"
|
||||
},
|
||||
{
|
||||
"name": "newCode",
|
||||
"type": "bytes"
|
||||
},
|
||||
{
|
||||
"name": "newState",
|
||||
"type": "bytes"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bytecode": "OP_11 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_PICK OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY OP_12 OP_PICK OP_1 OP_GREATERTHANOREQUAL OP_VERIFY OP_9 OP_ROLL OP_BIN2NUM OP_12 OP_PICK OP_9 OP_ROLL OP_MUL OP_ADD 14 OP_10 OP_ROLL OP_CAT OP_4 OP_CAT OP_SWAP OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_3 OP_ROLL OP_9 OP_ROLL OP_MUL OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ELSE OP_11 OP_PICK OP_1 OP_NUMEQUAL OP_IF OP_14 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_13 OP_PICK OP_HASH160 OP_12 OP_ROLL OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY 14 OP_SWAP OP_CAT OP_4 OP_CAT OP_10 OP_ROLL OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_2 OP_NUMEQUAL OP_IF OP_12 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_10 OP_ROLL OP_BIN2NUM OP_DUP OP_CHECKLOCKTIMEVERIFY OP_DROP OP_TXLOCKTIME OP_DUP 0065cd1d OP_LESSTHAN OP_VERIFY OP_SWAP OP_9 OP_PICK OP_ADD OP_8 OP_PICK OP_0 OP_2OVER OP_LESSTHAN OP_IF OP_10 OP_PICK OP_10 OP_PICK OP_12 OP_PICK OP_SUB OP_4 OP_PICK OP_6 OP_PICK OP_SUB OP_MUL OP_13 OP_PICK OP_DIV OP_ADD OP_ROT OP_DROP OP_SWAP OP_OVER OP_14 OP_PICK OP_MUL 64 OP_DIV OP_NIP OP_ENDIF OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT 11 OP_ROLL OP_CAT OP_HASH256 14 OP_6 OP_PICK OP_CAT 20 OP_CAT OP_SWAP OP_CAT OP_8 OP_ROLL OP_CAT OP_1 OP_UTXOBYTECODE aa20 OP_ROT OP_HASH256 OP_CAT 87 OP_CAT OP_EQUALVERIFY 14 OP_5 OP_ROLL OP_CAT OP_4 OP_CAT OP_4 OP_ROLL OP_11 OP_ROLL OP_ADD OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_4 OP_ROLL OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_ROT OP_2 OP_PICK OP_SUB OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_DUP OP_0 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTBYTECODE 76a914 OP_10 OP_PICK OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_OVER OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_3 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_ROLL OP_EQUALVERIFY OP_12 OP_ROLL OP_12 OP_ROLL OP_CHECKSIGVERIFY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_TXOUTPUTCOUNT OP_4 OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_TXOUTPUTCOUNT OP_1 OP_GREATERTHAN OP_IF OP_1 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_2 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_ROLL OP_4 OP_NUMEQUALVERIFY OP_11 OP_PICK OP_HASH160 OP_11 OP_ROLL OP_EQUALVERIFY OP_11 OP_ROLL OP_11 OP_ROLL OP_CHECKSIGVERIFY OP_10 OP_ROLL OP_11 OP_PICK OP_HASH256 OP_3 OP_ROLL OP_CHECKDATASIGVERIFY OP_10 OP_ROLL OP_10 OP_ROLL OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ENDIF OP_ENDIF OP_ENDIF OP_ENDIF",
|
||||
"compiler": {
|
||||
"name": "cashc",
|
||||
"version": "0.13.3",
|
||||
"options": {
|
||||
"enforceFunctionParameterTypes": true,
|
||||
"enforceLocktimeGuard": true
|
||||
}
|
||||
}
|
||||
};
|
||||
330
src/lib/registry-covenant.js
Normal file
330
src/lib/registry-covenant.js
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
// The name registry covenant — off-chain side.
|
||||
// (contracts/name-registry.cash, contracts/commit-box.cash;
|
||||
// Decentralized.DNS/DESIGN-covenant-registry.md)
|
||||
//
|
||||
// STATUS: chipnet draft, NOT REVIEWED. Nothing here may hold a mainnet name
|
||||
// before an outside review of the contract. See the contract's header.
|
||||
//
|
||||
// A covenant-held name is an output locked to
|
||||
//
|
||||
// P2SH32( hash256( STATE ‖ CODE ) )
|
||||
//
|
||||
// STATE 26 bytes: 0x14 <ownerPkh:20> 0x04 <expiry:u32le>
|
||||
// CODE the fixed constructor arguments (prices, grace, fund, steward,
|
||||
// commit-box code) followed by the compiled contract
|
||||
//
|
||||
// CODE is the same for every name in a price tier; STATE is what changes. A
|
||||
// resolver that knows CODE can check any claimed (owner, expiry) against the
|
||||
// output's address, which is how a covenant-held name is verified without
|
||||
// trusting whoever announced it.
|
||||
//
|
||||
// Prices come from registry-lifecycle.js, the reference the contract is tested
|
||||
// against (test/registry-covenant.test.mjs runs both and compares to the sat).
|
||||
|
||||
import {
|
||||
hexToBin, binToHex, encodeDataPush, bigIntToVmNumber, hash256, hash160, utf8ToBin,
|
||||
encodeLockingBytecodeP2sh32, encodeLockingBytecodeP2pkh, lockingBytecodeToCashAddress,
|
||||
numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256,
|
||||
generateSigningSerializationBCH, encodeTransaction, flattenBinArray,
|
||||
} from "@bitauth/libauth";
|
||||
import { NETWORK } from "./resolver-web.js";
|
||||
import { NAME_REGISTRY } from "./name-registry-artifact.js";
|
||||
import { COMMIT_BOX } from "./commit-box-artifact.js";
|
||||
import { asmToBytecode } from "./market-usd.js";
|
||||
import { toInput, toSource, tokenOf, signInputs, SIGHASH_ALL_FORKID } from "./market-tx.js";
|
||||
import { normalizeName, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT } from "./register-tx.js";
|
||||
import {
|
||||
BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT,
|
||||
renewalPriceSats, idlePremiumSats, openFloorSats, takeoverPriceSats, splitTakeover, phaseAt, PHASE,
|
||||
} from "./registry-lifecycle.js";
|
||||
|
||||
export const REGISTRY_VERSION = 1;
|
||||
export const STATE_BYTES = 26;
|
||||
const REGISTRY_BODY = asmToBytecode(NAME_REGISTRY.bytecode);
|
||||
/** Redeem-script body of the CommitBox — baked into the registry's CODE. */
|
||||
export const COMMIT_BOX_CODE = asmToBytecode(COMMIT_BOX.bytecode);
|
||||
|
||||
// ABI order in the artifact = the selector the unlocking script pushes.
|
||||
const FN = Object.fromEntries(NAME_REGISTRY.abi.map((f, i) => [f.name, i]));
|
||||
const pushInt = (n) => encodeDataPush(bigIntToVmNumber(BigInt(n)));
|
||||
const push = (b) => encodeDataPush(b);
|
||||
const bin = (v) => (v instanceof Uint8Array ? v : hexToBin(v));
|
||||
|
||||
// ---------- terms, state, addresses ----------
|
||||
|
||||
/**
|
||||
* The fixed terms of a covenant: one set per price tier.
|
||||
* @param {number} o.tier registry-lifecycle tier (decides the three prices)
|
||||
* @param {Uint8Array|string} o.fundLock locking bytecode fund payments go to
|
||||
* @param {Uint8Array|string} o.stewardPk compressed pubkey that approves newer versions
|
||||
*/
|
||||
export function registryTerms({ tier, fundLock, stewardPk }) {
|
||||
const f = bin(fundLock), s = bin(stewardPk);
|
||||
if (s.length !== 33) throw new Error("stewardPk must be a 33-byte compressed public key");
|
||||
if (!f.length) throw new Error("fundLock is required");
|
||||
return {
|
||||
tier,
|
||||
fundLock: f,
|
||||
stewardPk: s,
|
||||
commitBoxCode: COMMIT_BOX_CODE,
|
||||
renewalSats: renewalPriceSats(tier),
|
||||
premiumSats: idlePremiumSats(tier),
|
||||
floorSats: openFloorSats(tier),
|
||||
graceBlocks: GRACE_BLOCKS,
|
||||
blocksPerYear: BLOCKS_PER_YEAR,
|
||||
ownerPercentIdle: SPLIT.ownerPercentIdle,
|
||||
};
|
||||
}
|
||||
|
||||
/** CODE: everything after the 26 state bytes. Constructor args are pushed last-first. */
|
||||
export function registryCode(terms) {
|
||||
return flattenBinArray([
|
||||
pushInt(terms.ownerPercentIdle), pushInt(terms.blocksPerYear), pushInt(terms.graceBlocks),
|
||||
pushInt(terms.floorSats), pushInt(terms.premiumSats), pushInt(terms.renewalSats),
|
||||
push(terms.commitBoxCode), push(terms.stewardPk), push(terms.fundLock),
|
||||
REGISTRY_BODY,
|
||||
]);
|
||||
}
|
||||
|
||||
/** STATE: 0x14 <ownerPkh> 0x04 <expiry u32le>. */
|
||||
export function registryState({ ownerPkh, expiry }) {
|
||||
const pkh = bin(ownerPkh);
|
||||
if (pkh.length !== 20) throw new Error("ownerPkh must be 20 bytes");
|
||||
if (!Number.isInteger(expiry) || expiry <= 0 || expiry >= 0x7fffffff) throw new Error("expiry must be a block height");
|
||||
return flattenBinArray([Uint8Array.of(0x14), pkh, Uint8Array.of(0x04), numberToBinUint32LE(expiry)]);
|
||||
}
|
||||
export function parseRegistryState(redeemOrState) {
|
||||
const b = bin(redeemOrState);
|
||||
if (b.length < STATE_BYTES || b[0] !== 0x14 || b[21] !== 0x04) throw new Error("not a registry state prefix");
|
||||
return { ownerPkh: binToHex(b.slice(1, 21)), expiry: binToNumberUint32LE(b.slice(22, 26)) };
|
||||
}
|
||||
|
||||
export const registryRedeem = (terms, state) => flattenBinArray([registryState(state), registryCode(terms)]);
|
||||
export const p2sh32Lock = (redeem) => encodeLockingBytecodeP2sh32(hash256(redeem));
|
||||
export const registryLock = (terms, state) => p2sh32Lock(registryRedeem(terms, state));
|
||||
export function lockToAddress(lock, prefix = NETWORK.prefix, tokenSupport = true) {
|
||||
const r = lockingBytecodeToCashAddress({ bytecode: lock, prefix, tokenSupport });
|
||||
if (typeof r === "string") throw new Error(r);
|
||||
return r.address;
|
||||
}
|
||||
export const registryAddress = (terms, state, prefix) => lockToAddress(registryLock(terms, state), prefix);
|
||||
|
||||
/** What the steward signs, once, to approve a newer version's code: sha256 is applied by OP_CHECKDATASIG itself. */
|
||||
export const migrationMessage = (newCode) => hash256(bin(newCode));
|
||||
export function signMigrationApproval(stewardPrivateKey, newCode) {
|
||||
const sig = secp256k1.signMessageHashSchnorr(stewardPrivateKey, sha256.hash(migrationMessage(newCode)));
|
||||
if (typeof sig === "string") throw new Error(sig);
|
||||
return sig;
|
||||
}
|
||||
|
||||
// ---------- commit box ----------
|
||||
|
||||
/** commitHash = hash256(name ‖ salt): what the box's address hides until the takeover. */
|
||||
export const commitHashFor = (name, salt) => hash256(flattenBinArray([utf8ToBin(normalizeName(name)), bin(salt)]));
|
||||
export const commitBoxRedeem = ({ ownerPkh, commitHash }) =>
|
||||
flattenBinArray([Uint8Array.of(0x14), bin(ownerPkh), Uint8Array.of(0x20), bin(commitHash), COMMIT_BOX_CODE]);
|
||||
export const commitBoxLock = (o) => p2sh32Lock(commitBoxRedeem(o));
|
||||
|
||||
// ---------- what a takeover costs, in the contract's own arithmetic ----------
|
||||
|
||||
/**
|
||||
* Price and split at `height`, exactly as the contract computes them. Returns
|
||||
* null while the name is ACTIVE. Cross-checked against registry-lifecycle.js.
|
||||
*/
|
||||
export function takeoverQuote(terms, state, height) {
|
||||
const expiry = state.expiry;
|
||||
if (height < expiry) return null;
|
||||
const idleEnd = expiry + terms.graceBlocks;
|
||||
let price = BigInt(terms.floorSats), ownerSats = 0n;
|
||||
if (height < idleEnd) {
|
||||
price = BigInt(terms.floorSats) + ((BigInt(terms.premiumSats) - BigInt(terms.floorSats)) * BigInt(idleEnd - height)) / BigInt(terms.graceBlocks);
|
||||
ownerSats = (price * BigInt(terms.ownerPercentIdle)) / 100n;
|
||||
}
|
||||
return { priceSats: price, ownerSats, fundSats: price - ownerSats, newExpiry: height + terms.blocksPerYear };
|
||||
}
|
||||
|
||||
// ---------- unlocking ----------
|
||||
|
||||
function covenantSignature(transaction, sourceOutputs, inputIndex, redeem, privateKey) {
|
||||
const serialization = generateSigningSerializationBCH(
|
||||
{ inputIndex, sourceOutputs, transaction },
|
||||
{ coveredBytecode: redeem, signingSerializationType: Uint8Array.of(SIGHASH_ALL_FORKID) },
|
||||
);
|
||||
const s = secp256k1.signMessageHashSchnorr(privateKey, hash256(serialization));
|
||||
if (typeof s === "string") throw new Error(s);
|
||||
return flattenBinArray([s, Uint8Array.of(SIGHASH_ALL_FORKID)]);
|
||||
}
|
||||
const DUMMY_SIG = new Uint8Array(65);
|
||||
|
||||
/**
|
||||
* Unlocking bytecode for one registry path. `args` are the function's
|
||||
* arguments AFTER (pk, sig), already as raw byte arrays or numbers; the owner
|
||||
* paths get their signature from `key`. With `key: null` a same-size dummy is
|
||||
* used, for fee estimation.
|
||||
*/
|
||||
export function registryUnlock({ fn, redeem, transaction, sourceOutputs, inputIndex, key = null, args = [] }) {
|
||||
if (!(fn in FN)) throw new Error(`unknown registry function "${fn}"`);
|
||||
const encoded = args.map((a) => (typeof a === "number" || typeof a === "bigint" ? pushInt(a) : push(bin(a)))).reverse();
|
||||
const needsSig = fn !== "takeover";
|
||||
const head = [];
|
||||
if (needsSig) {
|
||||
const sig = key ? covenantSignature(transaction, sourceOutputs, inputIndex, redeem, key.privateKey) : DUMMY_SIG;
|
||||
head.push(push(sig), push(key ? key.publicKey : new Uint8Array(33)));
|
||||
}
|
||||
return flattenBinArray([...encoded, ...head, pushInt(FN[fn]), push(redeem)]);
|
||||
}
|
||||
|
||||
/** Unlocking bytecode for the commit box (the taker's signature over its redeem script). */
|
||||
export function commitBoxUnlock({ redeem, transaction, sourceOutputs, inputIndex, key = null }) {
|
||||
const sig = key ? covenantSignature(transaction, sourceOutputs, inputIndex, redeem, key.privateKey) : DUMMY_SIG;
|
||||
return flattenBinArray([push(sig), push(key ? key.publicKey : new Uint8Array(33)), push(redeem)]);
|
||||
}
|
||||
|
||||
// ---------- transactions ----------
|
||||
|
||||
const pkhLock = (pkh) => encodeLockingBytecodeP2pkh(bin(pkh));
|
||||
const certOut = (lock, cov) => ({ lockingBytecode: lock, valueSatoshis: BigInt(cov.satoshis) < CERTIFICATE_DUST_SAT ? CERTIFICATE_DUST_SAT : BigInt(cov.satoshis), token: tokenOf(cov.token) });
|
||||
const withSeq = (input, sequenceNumber) => ({ ...input, sequenceNumber });
|
||||
|
||||
/**
|
||||
* Assemble, fund and sign a transaction that spends covenant-style inputs.
|
||||
* special [{ utxo, sequence?, unlock(tx, sources, index, final) -> bytes }] — first inputs, in order
|
||||
* outputs the outputs the contract constrains, in their fixed positions
|
||||
* extra further outputs (an OP_RETURN and beacon dust, say), placed after them
|
||||
* coins plain coins that pay for it; change returns to changeLock
|
||||
*/
|
||||
// tamper TEST HOOK: (tx) => tx, applied to the unsigned transaction so a
|
||||
// test can break one rule and still have every signature valid
|
||||
function assemble({ special, outputs, extra = [], coins, changeLock, keyFor, locktime = 0, feeRate = 1, tamper = null }) {
|
||||
const fixed = [...outputs, ...extra];
|
||||
const need = fixed.reduce((a, o) => a + o.valueSatoshis, 0n) - special.reduce((a, s) => a + BigInt(s.utxo.satoshis), 0n);
|
||||
const sorted = [...coins].filter((u) => !u.token).sort((a, b) => (BigInt(b.satoshis) > BigInt(a.satoshis) ? 1 : -1));
|
||||
const chosen = [];
|
||||
let total = 0n;
|
||||
for (let round = 0; round <= sorted.length; round++) {
|
||||
const build = (withChange, change, final) => {
|
||||
const sources = [...special.map((s) => toSource(s.utxo)), ...chosen.map((u) => toSource(u))];
|
||||
const plain = {
|
||||
version: 2, locktime,
|
||||
inputs: [...special.map((s) => withSeq(toInput(s.utxo), s.sequence ?? 0xfffffffe)), ...chosen.map((u) => toInput(u))],
|
||||
outputs: [...fixed, ...(withChange ? [{ lockingBytecode: changeLock, valueSatoshis: change }] : [])],
|
||||
};
|
||||
const tx = final && tamper ? tamper(plain) : plain;
|
||||
const srcs = sources.map((src, i) => ({ ...src, sequenceNumber: tx.inputs[i].sequenceNumber }));
|
||||
// plain coins first, then the covenant inputs over their redeem scripts
|
||||
const skip = special.map((_, i) => i);
|
||||
const signed = final ? signInputs(tx, srcs, keyFor, { skip }).transaction : tx;
|
||||
const inputs = signed.inputs.map((inp, i) => (i < special.length ? { ...inp, unlockingBytecode: special[i].unlock(signed, srcs, i, final) } : inp));
|
||||
return { transaction: { ...signed, inputs }, sourceOutputs: srcs };
|
||||
};
|
||||
const fee = BigInt(Math.ceil((encodeTransaction(build(true, 1_000_000n, false).transaction).length + chosen.length * 107) * feeRate));
|
||||
if (total >= need + fee) {
|
||||
const change = total - need - fee;
|
||||
const withChange = change >= P2PKH_DUST_SAT;
|
||||
const built = build(withChange, change, true);
|
||||
const encoded = encodeTransaction(built.transaction);
|
||||
return { ...built, hex: binToHex(encoded), txid: binToHex(hash256(encoded).slice().reverse()), feeSats: withChange ? fee : total - need, changeSats: withChange ? change : 0n };
|
||||
}
|
||||
if (round === sorted.length) break;
|
||||
chosen.push(sorted[round]);
|
||||
total += BigInt(sorted[round].satoshis);
|
||||
}
|
||||
throw new Error(`not enough funds: need ${need} sat plus the fee, have ${total} sat`);
|
||||
}
|
||||
|
||||
const ownerKey = (keyFor, state) => {
|
||||
const k = keyFor(binToHex(pkhLock(state.ownerPkh)));
|
||||
if (!k) throw new Error("this wallet does not hold the owner key of this name");
|
||||
return k;
|
||||
};
|
||||
const registrySpecial = (cov, redeem, fn, key, args) => ({
|
||||
utxo: cov,
|
||||
unlock: (transaction, sourceOutputs, inputIndex, final) =>
|
||||
registryUnlock({ fn, redeem, transaction, sourceOutputs, inputIndex, key: final ? key : null, args }),
|
||||
});
|
||||
|
||||
/** Renew (or reclaim) for `years`. Pays renewalSats × years to the fund. */
|
||||
export function buildRegistryRenewTx({ cov, terms, state, years = 1, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
||||
if (!Number.isInteger(years) || years < 1) throw new Error("years must be a whole number, at least 1");
|
||||
const redeem = registryRedeem(terms, state);
|
||||
const next = { ownerPkh: state.ownerPkh, expiry: state.expiry + years * terms.blocksPerYear };
|
||||
const built = assemble({
|
||||
special: [registrySpecial(cov, redeem, "renew", ownerKey(keyFor, state), [years])],
|
||||
outputs: [certOut(registryLock(terms, next), cov), { lockingBytecode: terms.fundLock, valueSatoshis: BigInt(terms.renewalSats) * BigInt(years) }],
|
||||
extra, coins, changeLock, keyFor, feeRate, tamper,
|
||||
});
|
||||
return { ...built, state: next };
|
||||
}
|
||||
|
||||
/** Transfer to `newOwnerPkh` (or to the same key: the carrier for a records update). */
|
||||
export function buildRegistryTransferTx({ cov, terms, state, newOwnerPkh, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
||||
const redeem = registryRedeem(terms, state);
|
||||
const next = { ownerPkh: binToHex(bin(newOwnerPkh)), expiry: state.expiry };
|
||||
const built = assemble({
|
||||
special: [registrySpecial(cov, redeem, "transfer", ownerKey(keyFor, state), [bin(newOwnerPkh)])],
|
||||
outputs: [certOut(registryLock(terms, next), cov)],
|
||||
extra, coins, changeLock, keyFor, feeRate, tamper,
|
||||
});
|
||||
return { ...built, state: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an expired name over at `height` (which becomes the transaction's
|
||||
* locktime, so it cannot be mined earlier). `box` is the taker's CommitBox
|
||||
* UTXO, created at least a block before, for this name, salt and key.
|
||||
*/
|
||||
export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOwnerPkh, salt, box, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
||||
const quote = takeoverQuote(terms, state, height);
|
||||
if (!quote) throw new Error("the name is still ACTIVE — it cannot be taken over before it expires");
|
||||
const redeem = registryRedeem(terms, state);
|
||||
const boxRedeem = commitBoxRedeem({ ownerPkh: newOwnerPkh, commitHash: commitHashFor(name, salt) });
|
||||
const takerKey = keyFor(binToHex(pkhLock(newOwnerPkh)));
|
||||
if (!takerKey) throw new Error("this wallet does not hold the key the commit was made for");
|
||||
const next = { ownerPkh: binToHex(bin(newOwnerPkh)), expiry: quote.newExpiry };
|
||||
const built = assemble({
|
||||
special: [
|
||||
registrySpecial(cov, redeem, "takeover", null, [bin(newOwnerPkh), bin(salt)]),
|
||||
{ utxo: box, sequence: 1, unlock: (transaction, sourceOutputs, inputIndex, final) => commitBoxUnlock({ redeem: boxRedeem, transaction, sourceOutputs, inputIndex, key: final ? takerKey : null }) },
|
||||
],
|
||||
outputs: [
|
||||
certOut(registryLock(terms, next), cov),
|
||||
{ lockingBytecode: terms.fundLock, valueSatoshis: quote.fundSats },
|
||||
...(quote.ownerSats > 0n ? [{ lockingBytecode: pkhLock(state.ownerPkh), valueSatoshis: quote.ownerSats }] : []),
|
||||
],
|
||||
extra, coins, changeLock, keyFor, locktime: height, feeRate, tamper,
|
||||
});
|
||||
return { ...built, state: next, quote };
|
||||
}
|
||||
|
||||
/** Release: the certificate is destroyed. At most four outputs, none carrying it. */
|
||||
export function buildRegistryReleaseTx({ cov, terms, state, coins = [], changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
||||
if (extra.length > 3) throw new Error("a release may have at most four outputs in total");
|
||||
const redeem = registryRedeem(terms, state);
|
||||
// Output 0 must exist and must not carry the certificate: the change (or a dust output) takes that slot.
|
||||
const built = assemble({
|
||||
special: [registrySpecial(cov, redeem, "release", ownerKey(keyFor, state), [])],
|
||||
outputs: [{ lockingBytecode: changeLock, valueSatoshis: P2PKH_DUST_SAT }],
|
||||
extra, coins, changeLock, keyFor, feeRate, tamper,
|
||||
});
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate to a newer covenant version: `newCode` is its CODE (approved by the
|
||||
* steward's signature `approval`), `newState` whatever it expects in front.
|
||||
*/
|
||||
export function buildRegistryMigrateTx({ cov, terms, state, newCode, newState, approval, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
||||
const redeem = registryRedeem(terms, state);
|
||||
const newLock = p2sh32Lock(flattenBinArray([bin(newState), bin(newCode)]));
|
||||
return assemble({
|
||||
special: [registrySpecial(cov, redeem, "migrate", ownerKey(keyFor, state), [bin(approval), bin(newCode), bin(newState)])],
|
||||
outputs: [certOut(newLock, cov)],
|
||||
extra, coins, changeLock, keyFor, feeRate, tamper,
|
||||
});
|
||||
}
|
||||
|
||||
/** The phase a covenant-held name is in, from its state. */
|
||||
export const registryPhase = (terms, state, height) =>
|
||||
phaseAt({ height, expiryHeight: state.expiry, idleEndHeight: state.expiry + terms.graceBlocks });
|
||||
|
||||
export { PHASE, takeoverPriceSats, splitTakeover };
|
||||
266
test/registry-covenant.test.mjs
Normal file
266
test/registry-covenant.test.mjs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// The name registry covenant, run in libauth's BCH virtual machine.
|
||||
// (contracts/name-registry.cash, contracts/commit-box.cash)
|
||||
//
|
||||
// No network, no coins. Every path is built with the library and executed by
|
||||
// the VM, then broken one rule at a time: each negative case re-signs the
|
||||
// tampered transaction, so it fails because the CONTRACT refuses it and not
|
||||
// because a signature went stale.
|
||||
//
|
||||
// The prices are checked against registry-lifecycle.js to the satoshi. That
|
||||
// file is the reference; if the two ever disagree the contract is wrong.
|
||||
//
|
||||
// This is a draft contract and these tests are not a review. They show the
|
||||
// paths behave as designed, not that no other path exists.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as L from "@bitauth/libauth";
|
||||
import {
|
||||
registryTerms, registryRedeem, registryLock, registryCode, registryState, parseRegistryState, STATE_BYTES,
|
||||
commitBoxLock, commitHashFor, takeoverQuote, signMigrationApproval, p2sh32Lock, registryPhase,
|
||||
buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryTakeoverTx, buildRegistryReleaseTx, buildRegistryMigrateTx,
|
||||
} from "../src/lib/registry-covenant.js";
|
||||
import {
|
||||
BLOCKS_PER_YEAR, GRACE_BLOCKS, PHASE, takeoverPriceSats, splitTakeover, renewalPriceSats, phaseAt,
|
||||
} from "../src/lib/registry-lifecycle.js";
|
||||
|
||||
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, flattenBinArray } = L;
|
||||
|
||||
// Run under the rules live today and under the next upgrade's.
|
||||
const VMS = [["BCH 2025", L.createVirtualMachineBch2025()], ["BCH 2026", L.createVirtualMachineBch2026()]];
|
||||
const verdict = (built) => VMS.map(([label, vm]) => [label, vm.verify({ transaction: built.transaction, sourceOutputs: built.sourceOutputs })]);
|
||||
const accepts = (built, what) => { for (const [label, r] of verdict(built)) assert.equal(r, true, `${what} — ${label}: ${r}`); };
|
||||
// A negative case must be refused by the VIRTUAL MACHINE. If the library
|
||||
// throws before a transaction exists, the contract was never asked, and the
|
||||
// case proves nothing about it — so those are counted, and the last test
|
||||
// pins exactly which cases are allowed to end that way.
|
||||
const refusedByLibrary = [];
|
||||
let refusedByVm = 0;
|
||||
const rejects = (make, what) => {
|
||||
let built;
|
||||
try { built = make(); } catch (e) { refusedByLibrary.push(what); return; }
|
||||
for (const [label, r] of verdict(built)) assert.notEqual(r, true, `${what} — ${label} accepted it`);
|
||||
refusedByVm++;
|
||||
};
|
||||
|
||||
// ---------- 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) };
|
||||
};
|
||||
const owner = person(), taker = person(), buyer = person(), steward = person(), fund = 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 fakeTxid = (n) => n.toString(16).padStart(64, "0");
|
||||
const coin = (id, who, sats = 500_000_000n) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: who.lock });
|
||||
|
||||
const TIER = 3;
|
||||
const terms = registryTerms({ tier: TIER, fundLock: fund.lock, stewardPk: steward.pub });
|
||||
const NAME = "covenant.x";
|
||||
const CATEGORY = "ab".repeat(32);
|
||||
const EXPIRY = 900_000 + BLOCKS_PER_YEAR;
|
||||
const IDLE_END = EXPIRY + GRACE_BLOCKS;
|
||||
const state0 = { ownerPkh: owner.pkh, expiry: EXPIRY };
|
||||
const certToken = (name = NAME) => ({ category: CATEGORY, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(name)) } });
|
||||
const covUtxo = (state = state0, name = NAME) => ({ txid: fakeTxid(1), vout: 0, satoshis: 1000n, lockingBytecode: registryLock(terms, state), token: certToken(name) });
|
||||
const SALT = hexToBin("00112233445566778899aabbccddeeff");
|
||||
const boxUtxo = (who = taker, name = NAME, salt = SALT) => ({ txid: fakeTxid(7), vout: 0, satoshis: 1000n, lockingBytecode: commitBoxLock({ ownerPkh: who.pkh, commitHash: commitHashFor(name, salt) }) });
|
||||
|
||||
const outLockHex = (built, i) => binToHex(built.transaction.outputs[i].lockingBytecode);
|
||||
const setOut = (i, patch) => (tx) => ({ ...tx, outputs: tx.outputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
|
||||
|
||||
// ---------- the address is the state ----------
|
||||
|
||||
test("state prefix: 26 bytes the contract can swap, and nothing else moves", () => {
|
||||
const redeem = registryRedeem(terms, state0);
|
||||
assert.equal(binToHex(redeem.slice(0, 1)), "14");
|
||||
assert.equal(binToHex(redeem.slice(21, 22)), "04");
|
||||
assert.deepEqual(parseRegistryState(redeem), state0);
|
||||
assert.equal(binToHex(redeem.slice(STATE_BYTES)), binToHex(registryCode(terms)), "CODE is identical for every name in the tier");
|
||||
const other = registryRedeem(terms, { ownerPkh: taker.pkh, expiry: EXPIRY + 5 });
|
||||
assert.equal(binToHex(other.slice(STATE_BYTES)), binToHex(redeem.slice(STATE_BYTES)));
|
||||
assert.notEqual(binToHex(registryLock(terms, state0)), binToHex(registryLock(terms, { ...state0, expiry: EXPIRY + 1 })), "a different expiry is a different address");
|
||||
assert.throws(() => registryState({ ownerPkh: "00", expiry: 1 }));
|
||||
});
|
||||
|
||||
// ---------- renew ----------
|
||||
|
||||
const renew = (o = {}) => buildRegistryRenewTx({ cov: covUtxo(), terms, state: state0, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
|
||||
|
||||
test("renew: the owner extends the name and pays the fund the tier's price", () => {
|
||||
for (const years of [1, 3]) {
|
||||
const built = renew({ years });
|
||||
accepts(built, `renew ${years}y`);
|
||||
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: owner.pkh, expiry: EXPIRY + years * BLOCKS_PER_YEAR })));
|
||||
assert.equal(built.transaction.outputs[1].valueSatoshis, renewalPriceSats(TIER, years), "the price is the reference model's");
|
||||
assert.equal(binToHex(built.transaction.outputs[1].lockingBytecode), fund.lockHex);
|
||||
assert.deepEqual(built.state, { ownerPkh: owner.pkh, expiry: EXPIRY + years * BLOCKS_PER_YEAR });
|
||||
}
|
||||
});
|
||||
|
||||
test("renew: an expired name renews the same way (reclaim), from its old expiry", () => {
|
||||
// No height is involved at all: the owner can reclaim for as long as nobody has taken the name.
|
||||
accepts(renew({ years: 2 }), "reclaim");
|
||||
});
|
||||
|
||||
test("renew is refused when a rule is broken", () => {
|
||||
rejects(() => buildRegistryRenewTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: (h) => keyring(stranger)(h) ?? { privateKey: stranger.priv, publicKey: stranger.pub } }), "a stranger's signature");
|
||||
rejects(() => renew({ tamper: setOut(1, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), "one satoshi short");
|
||||
rejects(() => renew({ tamper: setOut(1, () => ({ lockingBytecode: owner.lock })) }), "the payment goes to the owner instead of the fund");
|
||||
rejects(() => renew({ years: 1, tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: owner.pkh, expiry: EXPIRY + 2 * BLOCKS_PER_YEAR }) })) }), "two years claimed, one paid");
|
||||
rejects(() => renew({ tamper: setOut(0, () => ({ lockingBytecode: owner.lock })) }), "the certificate leaves the covenant");
|
||||
rejects(() => renew({ tamper: setOut(0, (o) => ({ token: { ...o.token, nft: { ...o.token.nft, commitment: new TextEncoder().encode("other.x") } } })) }), "the certificate's name is changed");
|
||||
rejects(() => renew({ tamper: setOut(0, () => ({ token: undefined })) }), "the certificate is dropped");
|
||||
rejects(() => renew({ tamper: setOut(0, () => ({ valueSatoshis: 999n })) }), "the certificate's dust is skimmed");
|
||||
rejects(() => renew({ years: 0 }), "zero years");
|
||||
});
|
||||
|
||||
// ---------- transfer ----------
|
||||
|
||||
const transfer = (o = {}) => buildRegistryTransferTx({ cov: covUtxo(), terms, state: state0, newOwnerPkh: buyer.pkh, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
|
||||
|
||||
test("transfer: the owner hands the name over; expiry is untouched", () => {
|
||||
const built = transfer();
|
||||
accepts(built, "transfer");
|
||||
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: buyer.pkh, expiry: EXPIRY })));
|
||||
accepts(transfer({ newOwnerPkh: owner.pkh }), "transfer to self — the carrier for a records update");
|
||||
});
|
||||
|
||||
test("transfer cannot be used to walk the certificate out, or by anyone else", () => {
|
||||
rejects(() => transfer({ tamper: setOut(0, () => ({ lockingBytecode: buyer.lock })) }), "certificate sent to a plain address");
|
||||
rejects(() => transfer({ tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: buyer.pkh, expiry: EXPIRY + BLOCKS_PER_YEAR }) })) }), "a free year smuggled into a transfer");
|
||||
rejects(() => buildRegistryTransferTx({ cov: covUtxo(), terms, state: state0, newOwnerPkh: stranger.pkh, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "a stranger signs");
|
||||
});
|
||||
|
||||
// ---------- takeover ----------
|
||||
|
||||
const takeover = (height, o = {}) => buildRegistryTakeoverTx({ cov: covUtxo(), terms, state: state0, name: NAME, height, newOwnerPkh: taker.pkh, salt: SALT, box: boxUtxo(), coins: [coin(3, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker), ...o });
|
||||
|
||||
test("takeover price and split equal the reference model at every point of the glide", () => {
|
||||
const heights = [EXPIRY, EXPIRY + 1, EXPIRY + Math.floor(GRACE_BLOCKS / 3), EXPIRY + Math.floor(GRACE_BLOCKS / 2), IDLE_END - 1, IDLE_END, IDLE_END + 1, IDLE_END + 5 * BLOCKS_PER_YEAR];
|
||||
for (const height of heights) {
|
||||
const model = takeoverPriceSats({ height, expiryHeight: EXPIRY, idleEndHeight: IDLE_END, tier: TIER });
|
||||
const split = splitTakeover({ amountSats: model, phase: phaseAt({ height, expiryHeight: EXPIRY, idleEndHeight: IDLE_END }) });
|
||||
const quote = takeoverQuote(terms, state0, height);
|
||||
assert.equal(quote.priceSats, model, `price at ${height}`);
|
||||
assert.equal(quote.ownerSats, split.ownerSats, `owner share at ${height}`);
|
||||
assert.equal(quote.fundSats, split.fundSats, `fund share at ${height}`);
|
||||
|
||||
const built = takeover(height);
|
||||
accepts(built, `takeover at ${height}`);
|
||||
assert.equal(built.transaction.outputs[1].valueSatoshis, split.fundSats);
|
||||
assert.equal(outLockHex(built, 0), binToHex(registryLock(terms, { ownerPkh: taker.pkh, expiry: height + BLOCKS_PER_YEAR })), "one fresh year from the takeover height");
|
||||
rejects(() => takeover(height, { tamper: setOut(1, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), `fund underpaid by one satoshi at ${height}`);
|
||||
if (split.ownerSats > 0n) {
|
||||
assert.equal(built.transaction.outputs[2].valueSatoshis, split.ownerSats);
|
||||
assert.equal(outLockHex(built, 2), owner.lockHex, "the previous owner is paid at their key");
|
||||
rejects(() => takeover(height, { tamper: setOut(2, (o) => ({ valueSatoshis: o.valueSatoshis - 1n })) }), `previous owner underpaid at ${height}`);
|
||||
rejects(() => takeover(height, { tamper: setOut(2, () => ({ lockingBytecode: taker.lock })) }), `previous owner's share kept by the taker at ${height}`);
|
||||
} else {
|
||||
assert.equal(registryPhase(terms, state0, height), PHASE.open, "no owner share only once the grace period is over");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an ACTIVE name cannot be taken at any price", () => {
|
||||
assert.equal(takeoverQuote(terms, state0, EXPIRY - 1), null);
|
||||
assert.throws(() => takeover(EXPIRY - 1), /ACTIVE/);
|
||||
// Build a valid takeover, then claim an earlier height: the locktime check must refuse it.
|
||||
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: EXPIRY - 1 }) }), "locktime before expiry");
|
||||
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: 0 }) }), "no locktime");
|
||||
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, locktime: 1_800_000_000 }) }), "a timestamp in place of a height");
|
||||
});
|
||||
|
||||
test("a taker cannot claim a later height than the locktime to pay less", () => {
|
||||
// Price for IDLE_END (the floor) with a transaction that says it is only at EXPIRY.
|
||||
const floorQuote = takeoverQuote(terms, state0, IDLE_END);
|
||||
rejects(() => takeover(EXPIRY, { tamper: (tx) => ({ ...tx, outputs: tx.outputs.map((o, i) => (i === 1 ? { ...o, valueSatoshis: floorQuote.fundSats } : o)) }) }), "floor price paid during grace");
|
||||
rejects(() => takeover(EXPIRY, { tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: taker.pkh, expiry: EXPIRY + 3 * BLOCKS_PER_YEAR }) })) }), "three years claimed for the price of one");
|
||||
});
|
||||
|
||||
test("takeover needs the taker's own matured commit for this name", () => {
|
||||
accepts(takeover(EXPIRY + 10), "with the commit");
|
||||
rejects(() => takeover(EXPIRY + 10, { box: { ...boxUtxo(), lockingBytecode: taker.lock } }), "input 1 is a plain coin, not a commit box");
|
||||
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, "other.x") }), "a commit made for another name");
|
||||
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, NAME, hexToBin("ff".repeat(16))) }), "a commit made with another salt");
|
||||
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(stranger) }), "someone else's commit");
|
||||
rejects(() => takeover(EXPIRY + 10, { tamper: (tx) => ({ ...tx, inputs: tx.inputs.map((inp, i) => (i === 1 ? { ...inp, sequenceNumber: 0 } : inp)) }) }), "a commit spent in the block it was made in");
|
||||
rejects(() => takeover(EXPIRY + 10, { tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: stranger.pkh, expiry: EXPIRY + 10 + BLOCKS_PER_YEAR }) })) }), "the name goes to a key the commit was not made for");
|
||||
});
|
||||
|
||||
test("takeover keeps the certificate in the covenant", () => {
|
||||
rejects(() => takeover(IDLE_END, { tamper: setOut(0, () => ({ lockingBytecode: taker.lock })) }), "certificate sent to the taker's wallet");
|
||||
rejects(() => takeover(IDLE_END, { tamper: setOut(0, () => ({ token: undefined })) }), "certificate dropped");
|
||||
});
|
||||
|
||||
// ---------- release ----------
|
||||
|
||||
const release = (o = {}) => buildRegistryReleaseTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
|
||||
|
||||
test("release destroys the certificate; it cannot be used to take it out", () => {
|
||||
const built = release();
|
||||
accepts(built, "release");
|
||||
assert.ok(built.transaction.outputs.every((o) => !o.token));
|
||||
accepts(release({ extra: [{ lockingBytecode: Uint8Array.of(0x6a, 0x01, 0x00), valueSatoshis: 0n }, { lockingBytecode: fund.lock, valueSatoshis: 600n }] }), "release with a REL payload and beacon dust");
|
||||
for (const i of [0, 1, 2]) {
|
||||
rejects(() => release({ extra: [{ lockingBytecode: Uint8Array.of(0x6a, 0x01, 0x00), valueSatoshis: 0n }, { lockingBytecode: fund.lock, valueSatoshis: 600n }], tamper: setOut(i, () => ({ lockingBytecode: owner.lock, valueSatoshis: 1000n, token: { category: hexToBin(CATEGORY), amount: 0n, nft: { capability: "none", commitment: new TextEncoder().encode(NAME) } } })) }), `certificate recreated in output ${i}`);
|
||||
}
|
||||
rejects(() => release({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs.slice(0, 1), ...Array(4).fill({ lockingBytecode: owner.lock, valueSatoshis: 546n })] }) }), "five outputs: one would be unchecked");
|
||||
rejects(() => buildRegistryReleaseTx({ cov: covUtxo(), terms, state: state0, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "a stranger releases someone's name");
|
||||
});
|
||||
|
||||
// ---------- migrate ----------
|
||||
|
||||
const NEW_CODE = flattenBinArray([Uint8Array.of(0x51), new TextEncoder().encode("registry v2 code")]);
|
||||
const NEW_STATE = flattenBinArray([Uint8Array.of(0x14), hexToBin(owner.pkh)]);
|
||||
const approval = signMigrationApproval(steward.priv, NEW_CODE);
|
||||
const migrate = (o = {}) => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
|
||||
|
||||
test("migrate: owner AND steward — neither can move a name alone", () => {
|
||||
const built = migrate();
|
||||
accepts(built, "migrate");
|
||||
assert.equal(outLockHex(built, 0), binToHex(p2sh32Lock(flattenBinArray([NEW_STATE, NEW_CODE]))));
|
||||
// the same approval serves any state: it covers the code, signed once for everyone
|
||||
accepts(migrate({ newState: flattenBinArray([Uint8Array.of(0x14), hexToBin(buyer.pkh)]) }), "another state under the same approved code");
|
||||
|
||||
rejects(() => migrate({ approval: signMigrationApproval(stranger.priv, NEW_CODE) }), "code approved by someone who is not the steward");
|
||||
rejects(() => migrate({ newCode: flattenBinArray([NEW_CODE, Uint8Array.of(0x00)]) }), "code the steward did not sign");
|
||||
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, Uint8Array.of(0x51)) }), "an approval for different code");
|
||||
rejects(() => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "the steward's approval without the owner");
|
||||
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: owner.lock })) }), "certificate sent to a plain address under cover of a migration");
|
||||
rejects(() => migrate({ tamper: setOut(0, () => ({ token: undefined })) }), "certificate dropped");
|
||||
});
|
||||
|
||||
// ---------- a life ----------
|
||||
|
||||
test("a name's life: renew, sell, lapse, be taken over, be renewed by its new owner", () => {
|
||||
let state = state0;
|
||||
let cov = covUtxo(state);
|
||||
const step = (built, next) => { accepts(built, "life"); cov = { txid: built.txid, vout: 0, satoshis: built.transaction.outputs[0].valueSatoshis, lockingBytecode: built.transaction.outputs[0].lockingBytecode, token: certToken() }; state = next; };
|
||||
|
||||
let b = buildRegistryRenewTx({ cov, terms, state, years: 2, coins: [coin(20, owner)], changeLock: owner.lock, keyFor: keyring(owner) });
|
||||
step(b, b.state);
|
||||
b = buildRegistryTransferTx({ cov, terms, state, newOwnerPkh: buyer.pkh, coins: [coin(21, owner)], changeLock: owner.lock, keyFor: keyring(owner) });
|
||||
step(b, b.state);
|
||||
assert.equal(state.expiry, EXPIRY + 2 * BLOCKS_PER_YEAR);
|
||||
rejects(() => buildRegistryRenewTx({ cov, terms, state, coins: [coin(22, owner)], changeLock: owner.lock, keyFor: keyring(owner, buyer) === null ? null : (h) => (h === owner.lockHex ? { privateKey: owner.priv, publicKey: owner.pub } : h === buyer.lockHex ? { privateKey: owner.priv, publicKey: owner.pub } : null) }), "the old owner cannot renew a name they sold");
|
||||
|
||||
const height = state.expiry + 100; // the buyer lets it lapse
|
||||
b = buildRegistryTakeoverTx({ cov, terms, state, name: NAME, height, newOwnerPkh: taker.pkh, salt: SALT, box: boxUtxo(), coins: [coin(23, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker) });
|
||||
assert.equal(binToHex(b.transaction.outputs[2].lockingBytecode), buyer.lockHex, "the buy-out goes to whoever owned it when it lapsed");
|
||||
step(b, b.state);
|
||||
assert.equal(state.ownerPkh, taker.pkh);
|
||||
|
||||
b = buildRegistryRenewTx({ cov, terms, state, coins: [coin(24, taker)], changeLock: taker.lock, keyFor: keyring(taker) });
|
||||
step(b, b.state);
|
||||
assert.equal(state.expiry, height + 2 * BLOCKS_PER_YEAR);
|
||||
});
|
||||
|
||||
// ---------- the negatives are real ----------
|
||||
|
||||
test("every negative case was refused by the contract itself, not by the library", () => {
|
||||
assert.deepEqual(refusedByLibrary.sort(), ["zero years"], "only these may be stopped before the VM runs");
|
||||
assert.ok(refusedByVm >= 45, `expected the VM to refuse at least 45 tampered transactions, it refused ${refusedByVm}`);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue