diff --git a/contracts/commit-box.cash b/contracts/commit-box.cash new file mode 100644 index 0000000..b7e1fb0 --- /dev/null +++ b/contracts/commit-box.cash @@ -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); + } +} diff --git a/contracts/commit-box.json b/contracts/commit-box.json new file mode 100644 index 0000000..18b0ef0 --- /dev/null +++ b/contracts/commit-box.json @@ -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" +} \ No newline at end of file diff --git a/contracts/name-registry.cash b/contracts/name-registry.cash new file mode 100644 index 0000000..09ddd52 --- /dev/null +++ b/contracts/name-registry.cash @@ -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 0x04 +// +// 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); + } +} diff --git a/contracts/name-registry.json b/contracts/name-registry.json new file mode 100644 index 0000000..3f70f40 --- /dev/null +++ b/contracts/name-registry.json @@ -0,0 +1,121 @@ +{ + "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", + "source": "pragma cashscript ^0.13.0;\n\n// NameRegistry v1 — the covenant that holds a name certificate and enforces\n// its lifecycle (Decentralized.DNS/DESIGN-covenant-registry.md).\n//\n// STATUS: chipnet draft. NOT REVIEWED. A flaw here either locks every name\n// forever or lets anyone take one; nothing built from this file may hold a\n// mainnet name until someone who did not write it has reviewed it.\n//\n// The certificate (an immutable NFT whose commitment is the name) sits in an\n// output locked by this script. Two values change over a name's life — who\n// owns it and when it expires — and they are the LAST two constructor\n// arguments, which CashScript pushes FIRST. The redeem script therefore begins\n//\n// 0x14 0x04 \n//\n// and every path that keeps the name re-creates the covenant by replacing\n// those 26 bytes and requiring output 0 to be locked to the result. One\n// contract, one code hash per price tier; the state is in the address.\n//\n// Numbers must match Argus/src/lib/registry-lifecycle.js, which is the\n// reference: integer arithmetic, multiply before dividing.\n//\n// What script cannot do, stated once: it can prove a block height has been\n// REACHED (tx.time), never that one has NOT. So \"the owner may reclaim only\n// until the grace period ends\" is not enforceable; an owner can renew for as\n// long as nobody has taken the name over. The takeover price is safe against\n// this: a taker can only claim a height that is already past, and a later\n// height means a lower price they are entitled to anyway.\ncontract NameRegistry(\n // ---- fixed for this covenant version and price tier ----\n bytes fundLock, // locking bytecode the fund's payments must go to\n pubkey stewardPk, // approves the CODE of a newer covenant version (migrate)\n bytes commitBoxCode, // redeem-script body of the CommitBox contract\n int renewalSats, // price of one year, for the owner\n int premiumSats, // takeover price at the moment of expiry\n int floorSats, // takeover price once the grace period has run out\n int graceBlocks, // length of the grace period\n int blocksPerYear,\n int ownerPercentIdle, // previous owner's share of a takeover during grace\n // ---- state: keep these LAST, in this order ----\n bytes4 expiry, // block height the paid period ends at\n bytes20 ownerPkh\n) {\n // Keep the name: pay for `years` more. Also the reclaim path — an expired\n // name renews exactly the same way, from its old expiry.\n function renew(pubkey pk, sig s, int years) {\n require(hash160(pk) == ownerPkh);\n require(checkSig(s, pk));\n require(years >= 1);\n\n int newExpiry = int(expiry) + years * blocksPerYear;\n bytes newState = 0x14 + ownerPkh + 0x04 + toPaddedBytes(newExpiry, 4);\n bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);\n\n require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);\n\n require(tx.outputs[1].lockingBytecode == fundLock);\n require(tx.outputs[1].value >= renewalSats * years);\n require(tx.outputs[1].tokenCategory == 0x);\n }\n\n // Hand the name to another key (a sale, a gift), or re-create it for the\n // same key — which is how records are updated: the certificate has to move\n // for a resolver to accept an UPD. Expiry is untouched.\n function transfer(pubkey pk, sig s, bytes20 newOwnerPkh) {\n require(hash160(pk) == ownerPkh);\n require(checkSig(s, pk));\n\n bytes newState = 0x14 + newOwnerPkh + 0x04 + expiry;\n bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);\n\n require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);\n }\n\n // Take an expired name. No owner signature: anyone may, at a price that\n // glides from premiumSats at expiry down to floorSats at the end of the\n // grace period and stays there. During grace the previous owner is paid\n // ownerPercentIdle of the price; afterwards the fund receives all of it.\n //\n // Input 1 must be a CommitBox the taker created at least a block earlier,\n // committing to this name and to their own key. It hides which name is\n // wanted until the takeover itself, so the transaction cannot be copied\n // from the mempool by someone who had not already committed.\n function takeover(bytes20 newOwnerPkh, bytes salt) {\n int expiryHeight = int(expiry);\n require(tx.time >= expiryHeight);\n int height = tx.locktime;\n require(height < 500000000); // a block height, not a timestamp\n\n int idleEnd = expiryHeight + graceBlocks;\n int price = floorSats;\n int ownerSats = 0;\n if (height < idleEnd) {\n price = floorSats + (premiumSats - floorSats) * (idleEnd - height) / graceBlocks;\n ownerSats = price * ownerPercentIdle / 100;\n }\n\n // the commit\n bytes32 commitHash = hash256(tx.inputs[this.activeInputIndex].nftCommitment + salt);\n bytes boxRedeem = 0x14 + newOwnerPkh + 0x20 + commitHash + commitBoxCode;\n require(tx.inputs[1].lockingBytecode == new LockingBytecodeP2SH32(hash256(boxRedeem)));\n\n // the name, under its new owner, for one fresh year\n bytes newState = 0x14 + newOwnerPkh + 0x04 + toPaddedBytes(height + blocksPerYear, 4);\n bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);\n require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);\n\n // the money\n require(tx.outputs[1].lockingBytecode == fundLock);\n require(tx.outputs[1].value >= price - ownerSats);\n require(tx.outputs[1].tokenCategory == 0x);\n if (ownerSats > 0) {\n require(tx.outputs[2].lockingBytecode == new LockingBytecodeP2PKH(ownerPkh));\n require(tx.outputs[2].value >= ownerSats);\n require(tx.outputs[2].tokenCategory == 0x);\n }\n }\n\n // Give the name back: the certificate is destroyed. At most four outputs,\n // none of which may carry it — the owner cannot use this path to walk the\n // certificate out of the covenant into a plain wallet.\n function release(pubkey pk, sig s) {\n require(hash160(pk) == ownerPkh);\n require(checkSig(s, pk));\n\n bytes category = tx.inputs[this.activeInputIndex].tokenCategory;\n require(tx.outputs.length <= 4);\n require(tx.outputs[0].tokenCategory != category);\n if (tx.outputs.length > 1) { require(tx.outputs[1].tokenCategory != category); }\n if (tx.outputs.length > 2) { require(tx.outputs[2].tokenCategory != category); }\n if (tx.outputs.length > 3) { require(tx.outputs[3].tokenCategory != category); }\n }\n\n // Move to a newer covenant version. The owner chooses to; the steward has\n // signed the hash of the new version's CODE, once, for everyone. Neither\n // can act alone: the steward cannot move a name, and the owner cannot\n // migrate to a script the steward has not published. `newState` is whatever\n // the new version expects in front of its code.\n function migrate(pubkey pk, sig s, datasig approval, bytes newCode, bytes newState) {\n require(hash160(pk) == ownerPkh);\n require(checkSig(s, pk));\n require(checkDataSig(approval, hash256(newCode), stewardPk));\n\n bytes32 newHash = hash256(newState + newCode);\n require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));\n require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);\n require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);\n require(tx.outputs[0].value >= tx.inputs[this.activeInputIndex].value);\n }\n}\n", + "fingerprint": "bd756d3c667bfe0a857b74d48d9ccea9a69ea51ffb2bd9a6de0f12f919719453", + "debug": { + "bytecode": "5b79009c635c79a95b79885d7a5d7aad5c7951a269597a815c79597a959301145a7a7e547e7c54807ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a26951cd8851cc537a597a95a26951d100886d6d6d7551675b79519c635e7a820114885d79a95c7a885d7a5d7aad01147c7e547e5a7a7ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a2696d6d6d6d6d51675b79529c635c7a820114885a7a8176b175c576040065cd1d9f697c597993587900709f635a795a795c79945479567994955d7996937b757c785e79950164967768c0cf01117a7eaa011456797e01207e7c7e587a7e51c702aa207baa7e01877e880114557a7e547e547a5b7a9354807ec1011a7f777eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a26951cd547a8851cc7b527994a26951d100887600a06352cd0376a9145a797e0288ac7e8852cc78a26952d10088686d6d6d6d6d51675b79539c635c79a95b7a885c7a5c7aadc0cec454a16900d178879169c451a06351d17887916968c452a06352d17887916968c453a06353d178879169686d6d6d6d6d6d51675b7a549d5b79a95b7a885b7a5b7aad5a7a5b79aa537abb5a7a5a7a7eaa00cd02aa207b7e01877e8800d1c0ce8800d2c0cf8800ccc0c6a2696d6d6d6d755168686868", + "sourceMap": "47:4:64:5;;;;;48:24:48:26;;:16::27:1;:31::39:0;;:8::41:1;49:25:49:26:0;;:28::30;;:8::33:1;50:16:50:21:0;;:25::26;:16:::1;:8::28;52:28:52:34:0;;:24::35:1;:38::43:0;;:46::59;;:38:::1;:24;53:25:53:29:0;:32::40;;:25:::1;:43::47:0;:25:::1;:64::73:0;:75::76;:50::77:1;:25;54:45:54:64:0;:71::73;:45::74:1;:::77;:34;:26::78;56:27:56:28:0;:16::45:1;:49::83:0;:75::82;:49::83:1;;;:8::85;57:27:57:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;58:27:58:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;59:27:59:28:0;:16::35:1;:49::70:0;:39::77:1;:16;:8::79;61:27:61:28:0;:16::45:1;:8::59;62:27:62:28:0;:16::35:1;:39::50:0;;:53::58;;:39:::1;:16;:8::60;63:27:63:28:0;:16::43:1;:47::49:0;:8::51:1;47:48:64:5;;;;;:4;69::80::0;;;;;:40:69:59;;;;;70:24:70:26;;:16::27:1;:31::39:0;;:8::41:1;71:25:71:26:0;;:28::30;;:8::33:1;73:25:73:29:0;:32::43;:25:::1;:46::50:0;:25:::1;:53::59:0;;:25:::1;74:45:74:64:0;:71::73;:45::74:1;:::77;:34;:26::78;76:27:76:28:0;:16::45:1;:49::83:0;:75::82;:49::83:1;;;:8::85;77:27:77:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;78:27:78:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;79:27:79:28:0;:16::35:1;:49::70:0;:39::77:1;:16;:8::79;69:61:80:5;;;;;;:4;91::127::0;;;;;:22:91:41;;;;;92:31:92:37;;:27::38:1;93::93:39:0;:8::41:1;;94:21:94:32:0;95:16:95:22;:25::34;:16:::1;:8::36;97:22:97:34:0;:37::48;;:22:::1;98:20:98:29:0;;99:24:99:25;100:12:100:28;::::1;:30:103:9:0;101:20:101:29;;:33::44;;:47::56;;:33:::1;:61::68:0;;:71::77;;:61:::1;:32::78;:81::92:0;;:32:::1;:20;:12::93;;;102:24:102:29:0;:32::48;;:24:::1;:51::54:0;:24:::1;:12::55;100:30:103:9;106:47:106:68:0;:37::83:1;:86::90:0;;:37:::1;:29::91;107:26:107:30:0;:33::44;;:26:::1;:47::51:0;:26:::1;:54::64:0;:26:::1;:67::80:0;;:26:::1;108::108:27:0;:16::44:1;:48::93:0;:82::91;:74::92:1;:48::93;;;:8::95;111:25:111:29:0;:32::43;;:25:::1;:46::50:0;:25:::1;:67::73:0;;:76::89;;:67:::1;:91::92:0;:53::93:1;:25;112:45:112:64:0;:71::73;:45::74:1;:::77;:34;:26::78;113:27:113:28:0;:16::45:1;:49::83:0;:75::82;:49::83:1;;;:8::85;114:27:114:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;115:27:115:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;116:27:116:28:0;:16::35:1;:49::70:0;:39::77:1;:16;:8::79;119:27:119:28:0;:16::45:1;:49::57:0;;:8::59:1;120:27:120:28:0;:16::35:1;:39::44:0;:47::56;;:39:::1;:16;:8::58;121:27:121:28:0;:16::43:1;:47::49:0;:8::51:1;122:12:122:21:0;:24::25;:12:::1;:27:126:9:0;123:31:123:32;:20::49:1;:53::87:0;:78::86;;:53::87:1;;;:12::89;124:31:124:32:0;:20::39:1;:43::52:0;:20:::1;:12::54;125:31:125:32:0;:20::47:1;:51::53:0;:12::55:1;122:27:126:9;91:55:127:5;;;;;;:4;132::142::0;;;;;133:24:133:26;;:16::27:1;:31::39:0;;:8::41:1;134:25:134:26:0;;:28::30;;:8::33:1;136:35:136:56:0;:25::71:1;137:16:137:33:0;:37::38;:16:::1;:8::40;138:27:138:28:0;:16::43:1;:47::55:0;:16:::1;;:8::57;139:12:139:29:0;:32::33;:12:::1;:35::88:0;:56::57;:45::72:1;:76::84:0;:45:::1;;:37::86;:35::88;140:12:140:29:0;:32::33;:12:::1;:35::88:0;:56::57;:45::72:1;:76::84:0;:45:::1;;:37::86;:35::88;141:12:141:29:0;:32::33;:12:::1;:35::88:0;:56::57;:45::72:1;:76::84:0;:45:::1;;:37::86;:35::88;132:39:142:5;;;;;;;:4;149::159::0;;;;150:24:150:26;;:16::27:1;:31::39:0;;:8::41:1;151:25:151:26:0;;:28::30;;:8::33:1;152:29:152:37:0;;:47::54;;:39::55:1;:57::66:0;;:8::69:1;154:34:154:42:0;;:45::52;;:34:::1;:26::53;155:27:155:28:0;:16::45:1;:49::83:0;:75::82;:49::83:1;;;:8::85;156:27:156:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;157:27:157:28:0;:16::43:1;:57::78:0;:47::93:1;:8::95;158:27:158:28:0;:16::35:1;:49::70:0;:39::77:1;:16;:8::79;149:88:159:5;;;;;;30:0:160:1;;;", + "logs": [], + "requires": [ + { "ip": 21, "line": 48 }, + { "ip": 26, "line": 49 }, + { "ip": 31, "line": 50 }, + { "ip": 64, "line": 56 }, + { "ip": 69, "line": 57 }, + { "ip": 74, "line": 58 }, + { "ip": 80, "line": 59 }, + { "ip": 83, "line": 61 }, + { "ip": 92, "line": 62 }, + { "ip": 96, "line": 63 }, + { "ip": 118, "line": 70 }, + { "ip": 123, "line": 71 }, + { "ip": 145, "line": 76 }, + { "ip": 150, "line": 77 }, + { "ip": 155, "line": 78 }, + { "ip": 161, "line": 79 }, + { "ip": 183, "line": 93 }, + { "ip": 189, "line": 95 }, + { "ip": 253, "line": 108 }, + { "ip": 281, "line": 113 }, + { "ip": 286, "line": 114 }, + { "ip": 291, "line": 115 }, + { "ip": 297, "line": 116 }, + { "ip": 302, "line": 119 }, + { "ip": 310, "line": 120 }, + { "ip": 314, "line": 121 }, + { "ip": 327, "line": 123 }, + { "ip": 332, "line": 124 }, + { "ip": 336, "line": 125 }, + { "ip": 355, "line": 133 }, + { "ip": 360, "line": 134 }, + { "ip": 366, "line": 137 }, + { "ip": 372, "line": 138 }, + { "ip": 382, "line": 139 }, + { "ip": 393, "line": 140 }, + { "ip": 404, "line": 141 }, + { "ip": 423, "line": 150 }, + { "ip": 428, "line": 151 }, + { "ip": 436, "line": 152 }, + { "ip": 450, "line": 155 }, + { "ip": 455, "line": 156 }, + { "ip": 460, "line": 157 }, + { "ip": 466, "line": 158 } + ], + "sourceTags": "97:101:pv;163:167:pv" + }, + "compiler": { + "name": "cashc", + "version": "0.13.3", + "options": { + "enforceFunctionParameterTypes": true, + "enforceLocktimeGuard": true + } + }, + "updatedAt": "2026-10-04T13:36:52.297Z" +} \ No newline at end of file diff --git a/src/gen-contract-artifacts.mjs b/src/gen-contract-artifacts.mjs new file mode 100644 index 0000000..108e2ec --- /dev/null +++ b/src/gen-contract-artifacts.mjs @@ -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)`); +} diff --git a/src/lib/commit-box-artifact.js b/src/lib/commit-box-artifact.js new file mode 100644 index 0000000..c86008d --- /dev/null +++ b/src/lib/commit-box-artifact.js @@ -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 + } + } +}; diff --git a/src/lib/name-registry-artifact.js b/src/lib/name-registry-artifact.js new file mode 100644 index 0000000..9768df3 --- /dev/null +++ b/src/lib/name-registry-artifact.js @@ -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 + } + } +}; diff --git a/src/lib/registry-covenant.js b/src/lib/registry-covenant.js new file mode 100644 index 0000000..debad3d --- /dev/null +++ b/src/lib/registry-covenant.js @@ -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 0x04 +// 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 0x04 . */ +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 }; diff --git a/test/registry-covenant.test.mjs b/test/registry-covenant.test.mjs new file mode 100644 index 0000000..946f6e3 --- /dev/null +++ b/test/registry-covenant.test.mjs @@ -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}`); +});