covenants: the subscription and the delivery escrow, on one shared base (draft)
The roadmap asks for the covenant capability to be built once and used three times. covenant.js is the shared part: artifact to bytecode, redeem script and lock, signing a covenant input, an unlocking script for any function, and assembling a transaction around covenant inputs. The registry library now sits on it, and so do the two new contracts. Subscription: the buyer funds a balance. Anyone may send one period's price to the host once per period, the rest going back to the same script; the buyer may take the balance back at any time with only their signature. A draw spends its input alone, because with two inputs one payment and one return could satisfy both and the host would keep a balance. Delivery escrow: a payment that can be collected only by the transaction that mints the agreed certificate to the buyer and publishes the agreed registration, or refunded by the buyer after a wait. It forbids any other token in the transaction: resolvers accept an update from any token of a name's category, so a deliverer who minted a second one alongside the certificate could later rewrite the buyer's name. It also sits at a fixed input, so two payments cannot be collected against one certificate. Neither can see the registry: an escrow cannot know the name was taken between payment and delivery. The contract says so. Drafts, not reviewed. The tests run every path in the VM under the 2025 and 2026 rules and count that the VM itself refused the tampered ones.
This commit is contained in:
parent
3c04f971a9
commit
9aa8b6b33f
12 changed files with 979 additions and 111 deletions
77
contracts/delivery-escrow.cash
Normal file
77
contracts/delivery-escrow.cash
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
pragma cashscript ^0.13.0;
|
||||||
|
|
||||||
|
// DeliveryEscrow — payment for a name that can only be collected by the
|
||||||
|
// transaction that registers it to the buyer.
|
||||||
|
// (Decentralized.DNS/DESIGN-trustless-purchase.md §4.)
|
||||||
|
//
|
||||||
|
// STATUS: chipnet draft. NOT REVIEWED.
|
||||||
|
//
|
||||||
|
// A buyer who cannot build a registration themselves (pay-by-QR, any wallet)
|
||||||
|
// pays this script instead of paying a registrar. The money then has exactly
|
||||||
|
// two ways out:
|
||||||
|
//
|
||||||
|
// deliver() anyone may spend it — in a transaction that mints the agreed
|
||||||
|
// certificate to the buyer and publishes the agreed registration.
|
||||||
|
// "Took the money and did not register" is not a risk the buyer
|
||||||
|
// has to weigh; it is a transaction that cannot be built.
|
||||||
|
// refund() the buyer takes it back with their signature once it is
|
||||||
|
// `refundAfter` blocks old.
|
||||||
|
//
|
||||||
|
// What deliver() pins down, output by output:
|
||||||
|
// 0 the certificate — a NEW token category created by this transaction
|
||||||
|
// (its id is input 0's outpoint), an NFT with no capability, commitment =
|
||||||
|
// the name, no fungible amount, locked to the buyer's lock
|
||||||
|
// 1 the registration payload, byte for byte as agreed (OP_RETURN)
|
||||||
|
// 2 the discovery beacon, at least its dust
|
||||||
|
// … and NO other output may carry a token. Without that, the deliverer could
|
||||||
|
// mint a second, minting-capable token of the same category alongside the
|
||||||
|
// certificate and later use it to update the buyer's name.
|
||||||
|
//
|
||||||
|
// The escrow must be input 1 (input 0 is the genesis outpoint). That makes it
|
||||||
|
// one escrow per transaction: two payments for the same name could otherwise
|
||||||
|
// both be collected against a single certificate.
|
||||||
|
//
|
||||||
|
// WHAT IT CANNOT PROMISE. Script sees this transaction, not the registry. If
|
||||||
|
// the name was registered by someone else between payment and delivery, a
|
||||||
|
// deliverer can still mint a certificate that satisfies every line here and
|
||||||
|
// that no resolver will honour ("first valid registration wins" is a resolver
|
||||||
|
// rule). The window is the buyer's to keep short: check the name is free, pay,
|
||||||
|
// and refund if delivery does not come.
|
||||||
|
contract DeliveryEscrow(
|
||||||
|
bytes20 buyerPkh,
|
||||||
|
bytes certLock, // where the certificate must go (the buyer's token address, or their covenant)
|
||||||
|
bytes nameCommitment, // the on-chain name, as the certificate's commitment
|
||||||
|
bytes payloadLock, // the full OP_RETURN locking bytecode of the agreed REG payload
|
||||||
|
bytes beaconLock,
|
||||||
|
int beaconDust,
|
||||||
|
int refundAfter
|
||||||
|
) {
|
||||||
|
function deliver() {
|
||||||
|
require(this.activeInputIndex == 1);
|
||||||
|
require(tx.inputs[0].outpointIndex == 0);
|
||||||
|
bytes category = tx.inputs[0].outpointTransactionHash;
|
||||||
|
|
||||||
|
require(tx.outputs[0].lockingBytecode == certLock);
|
||||||
|
require(tx.outputs[0].tokenCategory == category);
|
||||||
|
require(tx.outputs[0].nftCommitment == nameCommitment);
|
||||||
|
require(tx.outputs[0].tokenAmount == 0);
|
||||||
|
|
||||||
|
require(tx.outputs[1].lockingBytecode == payloadLock);
|
||||||
|
require(tx.outputs[1].tokenCategory == 0x);
|
||||||
|
|
||||||
|
require(tx.outputs[2].lockingBytecode == beaconLock);
|
||||||
|
require(tx.outputs[2].value >= beaconDust);
|
||||||
|
require(tx.outputs[2].tokenCategory == 0x);
|
||||||
|
|
||||||
|
require(tx.outputs.length <= 6);
|
||||||
|
if (tx.outputs.length > 3) { require(tx.outputs[3].tokenCategory == 0x); }
|
||||||
|
if (tx.outputs.length > 4) { require(tx.outputs[4].tokenCategory == 0x); }
|
||||||
|
if (tx.outputs.length > 5) { require(tx.outputs[5].tokenCategory == 0x); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function refund(pubkey pk, sig s) {
|
||||||
|
require(hash160(pk) == buyerPkh);
|
||||||
|
require(checkSig(s, pk));
|
||||||
|
require(this.age >= refundAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
contracts/delivery-escrow.json
Normal file
59
contracts/delivery-escrow.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"contractName": "DeliveryEscrow",
|
||||||
|
"constructorInputs": [
|
||||||
|
{ "name": "buyerPkh", "type": "bytes20" },
|
||||||
|
{ "name": "certLock", "type": "bytes" },
|
||||||
|
{ "name": "nameCommitment", "type": "bytes" },
|
||||||
|
{ "name": "payloadLock", "type": "bytes" },
|
||||||
|
{ "name": "beaconLock", "type": "bytes" },
|
||||||
|
{ "name": "beaconDust", "type": "int" },
|
||||||
|
{ "name": "refundAfter", "type": "int" }
|
||||||
|
],
|
||||||
|
"abi": [
|
||||||
|
{ "name": "deliver", "inputs": [] },
|
||||||
|
{
|
||||||
|
"name": "refund",
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "pk", "type": "pubkey" },
|
||||||
|
{ "name": "s", "type": "sig" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bytecode": "OP_7 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_INPUTINDEX OP_1 OP_NUMEQUALVERIFY OP_0 OP_OUTPOINTINDEX OP_0 OP_NUMEQUALVERIFY OP_0 OP_OUTPOINTTXHASH OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_ROT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_0 OP_NUMEQUALVERIFY OP_1 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_2 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_ROT OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_TXOUTPUTCOUNT OP_6 OP_LESSTHANOREQUAL OP_VERIFY OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_4 OP_GREATERTHAN OP_IF OP_4 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_5 OP_GREATERTHAN OP_IF OP_5 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_DROP OP_1 OP_ELSE OP_7 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_7 OP_PICK OP_HASH160 OP_EQUALVERIFY OP_7 OP_ROLL OP_7 OP_ROLL OP_CHECKSIGVERIFY OP_5 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ENDIF",
|
||||||
|
"source": "pragma cashscript ^0.13.0;\n\n// DeliveryEscrow — payment for a name that can only be collected by the\n// transaction that registers it to the buyer.\n// (Decentralized.DNS/DESIGN-trustless-purchase.md §4.)\n//\n// STATUS: chipnet draft. NOT REVIEWED.\n//\n// A buyer who cannot build a registration themselves (pay-by-QR, any wallet)\n// pays this script instead of paying a registrar. The money then has exactly\n// two ways out:\n//\n// deliver() anyone may spend it — in a transaction that mints the agreed\n// certificate to the buyer and publishes the agreed registration.\n// \"Took the money and did not register\" is not a risk the buyer\n// has to weigh; it is a transaction that cannot be built.\n// refund() the buyer takes it back with their signature once it is\n// `refundAfter` blocks old.\n//\n// What deliver() pins down, output by output:\n// 0 the certificate — a NEW token category created by this transaction\n// (its id is input 0's outpoint), an NFT with no capability, commitment =\n// the name, no fungible amount, locked to the buyer's lock\n// 1 the registration payload, byte for byte as agreed (OP_RETURN)\n// 2 the discovery beacon, at least its dust\n// … and NO other output may carry a token. Without that, the deliverer could\n// mint a second, minting-capable token of the same category alongside the\n// certificate and later use it to update the buyer's name.\n//\n// The escrow must be input 1 (input 0 is the genesis outpoint). That makes it\n// one escrow per transaction: two payments for the same name could otherwise\n// both be collected against a single certificate.\n//\n// WHAT IT CANNOT PROMISE. Script sees this transaction, not the registry. If\n// the name was registered by someone else between payment and delivery, a\n// deliverer can still mint a certificate that satisfies every line here and\n// that no resolver will honour (\"first valid registration wins\" is a resolver\n// rule). The window is the buyer's to keep short: check the name is free, pay,\n// and refund if delivery does not come.\ncontract DeliveryEscrow(\n bytes20 buyerPkh,\n bytes certLock, // where the certificate must go (the buyer's token address, or their covenant)\n bytes nameCommitment, // the on-chain name, as the certificate's commitment\n bytes payloadLock, // the full OP_RETURN locking bytecode of the agreed REG payload\n bytes beaconLock,\n int beaconDust,\n int refundAfter\n) {\n function deliver() {\n require(this.activeInputIndex == 1);\n require(tx.inputs[0].outpointIndex == 0);\n bytes category = tx.inputs[0].outpointTransactionHash;\n\n require(tx.outputs[0].lockingBytecode == certLock);\n require(tx.outputs[0].tokenCategory == category);\n require(tx.outputs[0].nftCommitment == nameCommitment);\n require(tx.outputs[0].tokenAmount == 0);\n\n require(tx.outputs[1].lockingBytecode == payloadLock);\n require(tx.outputs[1].tokenCategory == 0x);\n\n require(tx.outputs[2].lockingBytecode == beaconLock);\n require(tx.outputs[2].value >= beaconDust);\n require(tx.outputs[2].tokenCategory == 0x);\n\n require(tx.outputs.length <= 6);\n if (tx.outputs.length > 3) { require(tx.outputs[3].tokenCategory == 0x); }\n if (tx.outputs.length > 4) { require(tx.outputs[4].tokenCategory == 0x); }\n if (tx.outputs.length > 5) { require(tx.outputs[5].tokenCategory == 0x); }\n }\n\n function refund(pubkey pk, sig s) {\n require(hash160(pk) == buyerPkh);\n require(checkSig(s, pk));\n require(this.age >= refundAfter);\n }\n}\n",
|
||||||
|
"fingerprint": "9c93f44bcd3875104d52642c9bd01751a2198b56dc00aaabd301d616f9047cc3",
|
||||||
|
"debug": {
|
||||||
|
"bytecode": "5779009c63c0519d00c9009d00c800cd537a8800d18800d27b8800d3009d51cd7b8851d1008852cd7b8852cc7ba26952d10088c456a169c453a06353d1008868c454a06354d1008868c455a06355d10088686d755167577a519d5779a988577a577aad557ab26d6d6d5168",
|
||||||
|
"sourceMap": "49:4:70:5;;;;;50:16:50:37;:41::42;:8::44:1;51:26:51:27:0;:16::42:1;:46::47:0;:8::49:1;52:35:52:36:0;:25::61:1;54:27:54:28:0;:16::45:1;:49::57:0;;:8::59:1;55:27:55:28:0;:16::43:1;:8::57;56:27:56:28:0;:16::43:1;:47::61:0;:8::63:1;57:27:57:28:0;:16::41:1;:45::46:0;:8::48:1;59:27:59:28:0;:16::45:1;:49::60:0;:8::62:1;60:27:60:28:0;:16::43:1;:47::49:0;:8::51:1;62:27:62:28:0;:16::45:1;:49::59:0;:8::61:1;63:27:63:28:0;:16::35:1;:39::49:0;:16:::1;:8::51;64:27:64:28:0;:16::43:1;:47::49:0;:8::51:1;66:16:66:33:0;:37::38;:16:::1;:8::40;67:12:67:29:0;:32::33;:12:::1;:35::82:0;:56::57;:45::72:1;:76::78:0;:37::80:1;:35::82;68:12:68:29:0;:32::33;:12:::1;:35::82:0;:56::57;:45::72:1;:76::78:0;:37::80:1;:35::82;69:12:69:29:0;:32::33;:12:::1;:35::82:0;:56::57;:45::72:1;:76::78:0;:37::80:1;:35::82;49:23:70:5;;;:4;72::76::0;;;;73:24:73:26;;:16::27:1;:8::41;74:25:74:26:0;;:28::30;;:8::33:1;75:28:75:39:0;;:8::41:1;72:38:76:5;;;;40:0:77:1",
|
||||||
|
"logs": [],
|
||||||
|
"requires": [
|
||||||
|
{ "ip": 14, "line": 50 },
|
||||||
|
{ "ip": 18, "line": 51 },
|
||||||
|
{ "ip": 25, "line": 54 },
|
||||||
|
{ "ip": 28, "line": 55 },
|
||||||
|
{ "ip": 32, "line": 56 },
|
||||||
|
{ "ip": 36, "line": 57 },
|
||||||
|
{ "ip": 40, "line": 59 },
|
||||||
|
{ "ip": 44, "line": 60 },
|
||||||
|
{ "ip": 48, "line": 62 },
|
||||||
|
{ "ip": 53, "line": 63 },
|
||||||
|
{ "ip": 57, "line": 64 },
|
||||||
|
{ "ip": 61, "line": 66 },
|
||||||
|
{ "ip": 69, "line": 67 },
|
||||||
|
{ "ip": 78, "line": 68 },
|
||||||
|
{ "ip": 87, "line": 69 },
|
||||||
|
{ "ip": 100, "line": 73 },
|
||||||
|
{ "ip": 105, "line": 74 },
|
||||||
|
{ "ip": 108, "line": 75 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"compiler": {
|
||||||
|
"name": "cashc",
|
||||||
|
"version": "0.13.3",
|
||||||
|
"options": {
|
||||||
|
"enforceFunctionParameterTypes": true,
|
||||||
|
"enforceLocktimeGuard": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-10-04T14:02:01.200Z"
|
||||||
|
}
|
||||||
56
contracts/subscription.cash
Normal file
56
contracts/subscription.cash
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
pragma cashscript ^0.13.0;
|
||||||
|
|
||||||
|
// Subscription — a prepaid balance the host can draw from once per period,
|
||||||
|
// and the buyer can take back at any time.
|
||||||
|
// (Argus/src/lib/plans.js SUBSCRIPTION_ROADMAP; ROADMAP-MAINNET.md §9 step 4.)
|
||||||
|
//
|
||||||
|
// STATUS: chipnet draft. NOT REVIEWED.
|
||||||
|
//
|
||||||
|
// The buyer funds this script. Three things then hold, and the buyer can check
|
||||||
|
// each from the script alone:
|
||||||
|
//
|
||||||
|
// · the balance stays theirs — cancel() needs only their signature, no
|
||||||
|
// notice, no cooperation, and returns whatever is left;
|
||||||
|
// · the host cannot take more than one period's price per period — pull()
|
||||||
|
// pays exactly the host's lock at least `priceSats`, sends the rest back to
|
||||||
|
// this same script, and can only run on an output that is `periodBlocks`
|
||||||
|
// old, which the re-created output is not;
|
||||||
|
// · nobody else gains anything — pull() needs no signature, so the host does
|
||||||
|
// not have to be online with a key, but its only possible beneficiary is
|
||||||
|
// the host's lock.
|
||||||
|
//
|
||||||
|
// A pull spends this input ALONE. With two inputs, one "price to the host"
|
||||||
|
// output and one "rest back to the script" output could satisfy both, and the
|
||||||
|
// host would collect two balances for one payment. One input means the fee
|
||||||
|
// comes out of the balance too, bounded by `maxFeeSats`.
|
||||||
|
//
|
||||||
|
// The first period is not special-cased: the balance is drawn one period
|
||||||
|
// after it is funded. A host that wants payment up front takes the first
|
||||||
|
// period in the funding transaction itself.
|
||||||
|
contract Subscription(bytes20 buyerPkh, bytes hostLock, int priceSats, int periodBlocks, int maxFeeSats) {
|
||||||
|
// One period's payment to the host.
|
||||||
|
function pull() {
|
||||||
|
require(this.age >= periodBlocks);
|
||||||
|
require(tx.inputs.length == 1);
|
||||||
|
|
||||||
|
int balance = tx.inputs[0].value;
|
||||||
|
require(tx.outputs[0].lockingBytecode == hostLock);
|
||||||
|
require(tx.outputs[0].value >= priceSats);
|
||||||
|
require(tx.outputs[0].tokenCategory == 0x);
|
||||||
|
|
||||||
|
// Everything else stays in the subscription, less a bounded fee. When
|
||||||
|
// what would remain is too small to be an output, it is the fee.
|
||||||
|
int rest = balance - priceSats - maxFeeSats;
|
||||||
|
if (rest >= 546) {
|
||||||
|
require(tx.outputs[1].lockingBytecode == tx.inputs[0].lockingBytecode);
|
||||||
|
require(tx.outputs[1].value >= rest);
|
||||||
|
require(tx.outputs[1].tokenCategory == 0x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The buyer ends it and takes the balance. No conditions on where it goes.
|
||||||
|
function cancel(pubkey pk, sig s) {
|
||||||
|
require(hash160(pk) == buyerPkh);
|
||||||
|
require(checkSig(s, pk));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
contracts/subscription.json
Normal file
49
contracts/subscription.json
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"contractName": "Subscription",
|
||||||
|
"constructorInputs": [
|
||||||
|
{ "name": "buyerPkh", "type": "bytes20" },
|
||||||
|
{ "name": "hostLock", "type": "bytes" },
|
||||||
|
{ "name": "priceSats", "type": "int" },
|
||||||
|
{ "name": "periodBlocks", "type": "int" },
|
||||||
|
{ "name": "maxFeeSats", "type": "int" }
|
||||||
|
],
|
||||||
|
"abi": [
|
||||||
|
{ "name": "pull", "inputs": [] },
|
||||||
|
{
|
||||||
|
"name": "cancel",
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "pk", "type": "pubkey" },
|
||||||
|
{ "name": "s", "type": "sig" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bytecode": "OP_5 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_3 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_DROP OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_0 OP_UTXOVALUE OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_3 OP_PICK OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ROT OP_SUB OP_ROT OP_SUB OP_DUP 2202 OP_GREATERTHANOREQUAL OP_IF OP_1 OP_OUTPUTBYTECODE OP_0 OP_UTXOBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_OVER OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_DROP OP_1 OP_ELSE OP_5 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_5 OP_PICK OP_HASH160 OP_EQUALVERIFY OP_2ROT OP_CHECKSIGVERIFY OP_2DROP OP_2DROP OP_1 OP_ENDIF",
|
||||||
|
"source": "pragma cashscript ^0.13.0;\n\n// Subscription — a prepaid balance the host can draw from once per period,\n// and the buyer can take back at any time.\n// (Argus/src/lib/plans.js SUBSCRIPTION_ROADMAP; ROADMAP-MAINNET.md §9 step 4.)\n//\n// STATUS: chipnet draft. NOT REVIEWED.\n//\n// The buyer funds this script. Three things then hold, and the buyer can check\n// each from the script alone:\n//\n// · the balance stays theirs — cancel() needs only their signature, no\n// notice, no cooperation, and returns whatever is left;\n// · the host cannot take more than one period's price per period — pull()\n// pays exactly the host's lock at least `priceSats`, sends the rest back to\n// this same script, and can only run on an output that is `periodBlocks`\n// old, which the re-created output is not;\n// · nobody else gains anything — pull() needs no signature, so the host does\n// not have to be online with a key, but its only possible beneficiary is\n// the host's lock.\n//\n// A pull spends this input ALONE. With two inputs, one \"price to the host\"\n// output and one \"rest back to the script\" output could satisfy both, and the\n// host would collect two balances for one payment. One input means the fee\n// comes out of the balance too, bounded by `maxFeeSats`.\n//\n// The first period is not special-cased: the balance is drawn one period\n// after it is funded. A host that wants payment up front takes the first\n// period in the funding transaction itself.\ncontract Subscription(bytes20 buyerPkh, bytes hostLock, int priceSats, int periodBlocks, int maxFeeSats) {\n // One period's payment to the host.\n function pull() {\n require(this.age >= periodBlocks);\n require(tx.inputs.length == 1);\n\n int balance = tx.inputs[0].value;\n require(tx.outputs[0].lockingBytecode == hostLock);\n require(tx.outputs[0].value >= priceSats);\n require(tx.outputs[0].tokenCategory == 0x);\n\n // Everything else stays in the subscription, less a bounded fee. When\n // what would remain is too small to be an output, it is the fee.\n int rest = balance - priceSats - maxFeeSats;\n if (rest >= 546) {\n require(tx.outputs[1].lockingBytecode == tx.inputs[0].lockingBytecode);\n require(tx.outputs[1].value >= rest);\n require(tx.outputs[1].tokenCategory == 0x);\n }\n }\n\n // The buyer ends it and takes the balance. No conditions on where it goes.\n function cancel(pubkey pk, sig s) {\n require(hash160(pk) == buyerPkh);\n require(checkSig(s, pk));\n }\n}\n",
|
||||||
|
"fingerprint": "ed3b45444d0d4a41357fcd312e039baf769a8bc2e8a2d66c19624cae5078f046",
|
||||||
|
"debug": {
|
||||||
|
"bytecode": "5579009c63537ab275c3519d00c600cd537a8800cc5379a26900d100887b947b9476022202a26351cd00c78851cc78a26951d10088686d755167557a519d5579a98871ad6d6d5168",
|
||||||
|
"sourceMap": "32:4:49:5;;;;;33:28:33:40;;:8::42:1;;34:16:34:32:0;:36::37;:8::39:1;36:32:36:33:0;:22::40:1;37:27:37:28:0;:16::45:1;:49::57:0;;:8::59:1;38:27:38:28:0;:16::35:1;:39::48:0;;:16:::1;:8::50;39:27:39:28:0;:16::43:1;:47::49:0;:8::51:1;43:29:43:38:0;:19:::1;:41::51:0;:19:::1;44:12:44:16:0;:20::23;:12:::1;:25:48:9:0;45:31:45:32;:20::49:1;:63::64:0;:53::81:1;:12::83;46:31:46:32:0;:20::39:1;:43::47:0;:20:::1;:12::49;47:31:47:32:0;:20::47:1;:51::53:0;:12::55:1;44:25:48:9;32:20:49:5;;;:4;52::55::0;;;;53:24:53:26;;:16::27:1;:8::41;54:25:54:30:0;:8::33:1;52:38:55:5;;;30:0:56:1",
|
||||||
|
"logs": [],
|
||||||
|
"requires": [
|
||||||
|
{ "ip": 12, "line": 33 },
|
||||||
|
{ "ip": 16, "line": 34 },
|
||||||
|
{ "ip": 23, "line": 37 },
|
||||||
|
{ "ip": 29, "line": 38 },
|
||||||
|
{ "ip": 33, "line": 39 },
|
||||||
|
{ "ip": 46, "line": 45 },
|
||||||
|
{ "ip": 51, "line": 46 },
|
||||||
|
{ "ip": 55, "line": 47 },
|
||||||
|
{ "ip": 68, "line": 53 },
|
||||||
|
{ "ip": 70, "line": 54 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"compiler": {
|
||||||
|
"name": "cashc",
|
||||||
|
"version": "0.13.3",
|
||||||
|
"options": {
|
||||||
|
"enforceFunctionParameterTypes": true,
|
||||||
|
"enforceLocktimeGuard": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-10-04T14:02:00.557Z"
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
//
|
//
|
||||||
// npx cashc contracts/name-registry.cash -o contracts/name-registry.json
|
// npx cashc contracts/name-registry.cash -o contracts/name-registry.json
|
||||||
// npx cashc contracts/commit-box.cash -o contracts/commit-box.json
|
// npx cashc contracts/commit-box.cash -o contracts/commit-box.json
|
||||||
|
// npx cashc contracts/delivery-escrow.cash -o contracts/delivery-escrow.json
|
||||||
|
// npx cashc contracts/subscription.cash -o contracts/subscription.json
|
||||||
// node src/gen-contract-artifacts.mjs
|
// node src/gen-contract-artifacts.mjs
|
||||||
//
|
//
|
||||||
// The generated files are committed: a contract's bytecode IS its address, so
|
// The generated files are committed: a contract's bytecode IS its address, so
|
||||||
|
|
@ -13,6 +15,8 @@ import { readFileSync, writeFileSync } from "node:fs";
|
||||||
const TARGETS = [
|
const TARGETS = [
|
||||||
{ json: "name-registry.json", out: "name-registry-artifact.js", constant: "NAME_REGISTRY" },
|
{ json: "name-registry.json", out: "name-registry-artifact.js", constant: "NAME_REGISTRY" },
|
||||||
{ json: "commit-box.json", out: "commit-box-artifact.js", constant: "COMMIT_BOX" },
|
{ json: "commit-box.json", out: "commit-box-artifact.js", constant: "COMMIT_BOX" },
|
||||||
|
{ json: "delivery-escrow.json", out: "delivery-escrow-artifact.js", constant: "DELIVERY_ESCROW" },
|
||||||
|
{ json: "subscription.json", out: "subscription-artifact.js", constant: "SUBSCRIPTION" },
|
||||||
];
|
];
|
||||||
for (const t of TARGETS) {
|
for (const t of TARGETS) {
|
||||||
const a = JSON.parse(readFileSync(new URL(`../contracts/${t.json}`, import.meta.url), "utf8"));
|
const a = JSON.parse(readFileSync(new URL(`../contracts/${t.json}`, import.meta.url), "utf8"));
|
||||||
|
|
|
||||||
154
src/lib/covenant.js
Normal file
154
src/lib/covenant.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// The covenant capability, once.
|
||||||
|
//
|
||||||
|
// ROADMAP-MAINNET.md §9 step 4 asks for one shared primitive behind three
|
||||||
|
// contracts — the name registry, the delivery escrow and the subscription —
|
||||||
|
// because all three are "value locked under conditions, with a timelock".
|
||||||
|
// This file is that shared part: turning a compiled CashScript artifact into
|
||||||
|
// bytecode, building a redeem script and its P2SH32 lock, signing a covenant
|
||||||
|
// input, building an unlocking script for one of a contract's functions, and
|
||||||
|
// assembling, funding and signing a transaction around covenant inputs.
|
||||||
|
//
|
||||||
|
// The contracts themselves, and what each one promises, live in
|
||||||
|
// registry-covenant.js contracts/name-registry.cash, commit-box.cash
|
||||||
|
// escrow-covenant.js contracts/delivery-escrow.cash
|
||||||
|
// subscription-covenant.js contracts/subscription.cash
|
||||||
|
//
|
||||||
|
// STATUS: chipnet drafts, NOT REVIEWED. See REGISTRY-COVENANT-v1.md.
|
||||||
|
|
||||||
|
import {
|
||||||
|
hexToBin, binToHex, encodeDataPush, bigIntToVmNumber, hash256,
|
||||||
|
encodeLockingBytecodeP2sh32, encodeLockingBytecodeP2pkh, lockingBytecodeToCashAddress,
|
||||||
|
secp256k1, generateSigningSerializationBCH, encodeTransaction, flattenBinArray, OpcodesBCH,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
import { NETWORK } from "./resolver-web.js";
|
||||||
|
import { toInput, toSource, signInputs, SIGHASH_ALL_FORKID } from "./market-tx.js";
|
||||||
|
import { P2PKH_DUST_SAT } from "./register-tx.js";
|
||||||
|
|
||||||
|
export const pushInt = (n) => encodeDataPush(bigIntToVmNumber(BigInt(n)));
|
||||||
|
export const push = (b) => encodeDataPush(b);
|
||||||
|
export const bin = (v) => (v instanceof Uint8Array ? v : hexToBin(v));
|
||||||
|
export const pkhLock = (pkh) => encodeLockingBytecodeP2pkh(bin(pkh));
|
||||||
|
|
||||||
|
/** cashc "bytecode" (asm text) -> script bytes. */
|
||||||
|
export function asmToBytecode(asm) {
|
||||||
|
const opcodeOf = (tok) => { const v = OpcodesBCH[tok]; if (v === undefined) throw new Error("unknown opcode " + tok); return Uint8Array.of(v); };
|
||||||
|
return flattenBinArray(asm.trim().split(/\s+/).filter(Boolean).map((tok) => (tok.startsWith("OP_") ? opcodeOf(tok) : encodeDataPush(hexToBin(tok)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** name -> selector index, in the artifact's ABI order. A one-function contract has no selector. */
|
||||||
|
export const selectorsOf = (artifact) => Object.fromEntries(artifact.abi.map((f, i) => [f.name, i]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redeem script: the constructor arguments, pushed LAST-FIRST, then the body.
|
||||||
|
* `args` are given in the contract's own (declaration) order, each already a
|
||||||
|
* push (use pushInt / push).
|
||||||
|
*/
|
||||||
|
export const redeemScript = (body, args) => flattenBinArray([...[...args].reverse(), body]);
|
||||||
|
|
||||||
|
export const p2sh32Lock = (redeem) => encodeLockingBytecodeP2sh32(hash256(redeem));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A signature (Schnorr, ALL|FORKID) by `privateKey` over input `inputIndex`, covering the redeem script. */
|
||||||
|
export 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);
|
||||||
|
const DUMMY_PUB = new Uint8Array(33);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlocking bytecode for one function of a contract.
|
||||||
|
* selector the function's index, or null for a single-function contract
|
||||||
|
* args the function's arguments in declaration order: numbers/bigints
|
||||||
|
* are pushed as script numbers, byte arrays as data, and the two
|
||||||
|
* markers SIG and PUBKEY are replaced by the signer's (`key`)
|
||||||
|
* With `key: null` the markers become same-size dummies, for fee estimation.
|
||||||
|
*/
|
||||||
|
export const SIG = Symbol("sig");
|
||||||
|
export const PUBKEY = Symbol("pubkey");
|
||||||
|
export function unlockScript({ selector = null, redeem, args = [], key = null, transaction, sourceOutputs, inputIndex }) {
|
||||||
|
const encoded = args.map((a) => {
|
||||||
|
if (a === SIG) return push(key ? covenantSignature(transaction, sourceOutputs, inputIndex, redeem, key.privateKey) : DUMMY_SIG);
|
||||||
|
if (a === PUBKEY) return push(key ? key.publicKey : DUMMY_PUB);
|
||||||
|
return typeof a === "number" || typeof a === "bigint" ? pushInt(a) : push(bin(a));
|
||||||
|
}).reverse();
|
||||||
|
return flattenBinArray([...encoded, ...(selector === null ? [] : [pushInt(selector)]), push(redeem)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain P2PKH coin that must sit at a FIXED input position among the
|
||||||
|
* covenant inputs (a token genesis has to be input 0, for one). `assemble`
|
||||||
|
* signs ordinary coins itself but always places them after the special
|
||||||
|
* inputs; this lets one be a special input and still be signed normally.
|
||||||
|
*/
|
||||||
|
export function p2pkhSpecial(utxo, keyFor) {
|
||||||
|
return {
|
||||||
|
utxo,
|
||||||
|
unlock: (transaction, sourceOutputs, inputIndex, final) => {
|
||||||
|
if (!final) return flattenBinArray([push(DUMMY_SIG), push(DUMMY_PUB)]);
|
||||||
|
const lock = sourceOutputs[inputIndex].lockingBytecode;
|
||||||
|
const key = keyFor(binToHex(lock));
|
||||||
|
if (!key) throw new Error(`no key for the coin at input ${inputIndex}`);
|
||||||
|
return flattenBinArray([push(covenantSignature(transaction, sourceOutputs, inputIndex, lock, key.privateKey)), push(key.publicKey)]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const withSeq = (input, sequenceNumber) => ({ ...input, sequenceNumber });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble, fund and sign a transaction that spends covenant inputs.
|
||||||
|
* special [{ utxo, sequence?, unlock(tx, sources, index, final) -> bytes }] — the first inputs, in order
|
||||||
|
* outputs the outputs a 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
|
||||||
|
* minFee a floor for the fee, when the contract itself fixes one
|
||||||
|
* tamper TEST HOOK: (tx) => tx, applied to the unsigned transaction so a
|
||||||
|
* test can break one rule and still have every signature valid
|
||||||
|
*/
|
||||||
|
export function assemble({ special, outputs, extra = [], coins = [], changeLock = null, keyFor = () => null, locktime = 0, feeRate = 1, noChange = false, 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 canChange = !noChange && !!changeLock;
|
||||||
|
const fee = BigInt(Math.ceil((encodeTransaction(build(canChange, 1_000_000n, false).transaction).length + chosen.length * 107) * feeRate));
|
||||||
|
if (total >= need + fee) {
|
||||||
|
const change = total - need - fee;
|
||||||
|
const withChange = canChange && 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`);
|
||||||
|
}
|
||||||
63
src/lib/delivery-escrow-artifact.js
Normal file
63
src/lib/delivery-escrow-artifact.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// Generated from contracts/delivery-escrow.json by src/gen-contract-artifacts.mjs — do not edit.
|
||||||
|
// DeliveryEscrow compiled with cashc 0.13.3.
|
||||||
|
export const DELIVERY_ESCROW = {
|
||||||
|
"contractName": "DeliveryEscrow",
|
||||||
|
"constructorInputs": [
|
||||||
|
{
|
||||||
|
"name": "buyerPkh",
|
||||||
|
"type": "bytes20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "certLock",
|
||||||
|
"type": "bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nameCommitment",
|
||||||
|
"type": "bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "payloadLock",
|
||||||
|
"type": "bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "beaconLock",
|
||||||
|
"type": "bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "beaconDust",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "refundAfter",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"abi": [
|
||||||
|
{
|
||||||
|
"name": "deliver",
|
||||||
|
"inputs": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "refund",
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "pk",
|
||||||
|
"type": "pubkey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "s",
|
||||||
|
"type": "sig"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bytecode": "OP_7 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_INPUTINDEX OP_1 OP_NUMEQUALVERIFY OP_0 OP_OUTPOINTINDEX OP_0 OP_NUMEQUALVERIFY OP_0 OP_OUTPOINTTXHASH OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_ROT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_0 OP_NUMEQUALVERIFY OP_1 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_2 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_ROT OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_TXOUTPUTCOUNT OP_6 OP_LESSTHANOREQUAL OP_VERIFY OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_4 OP_GREATERTHAN OP_IF OP_4 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_5 OP_GREATERTHAN OP_IF OP_5 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_DROP OP_1 OP_ELSE OP_7 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_7 OP_PICK OP_HASH160 OP_EQUALVERIFY OP_7 OP_ROLL OP_7 OP_ROLL OP_CHECKSIGVERIFY OP_5 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ENDIF",
|
||||||
|
"compiler": {
|
||||||
|
"name": "cashc",
|
||||||
|
"version": "0.13.3",
|
||||||
|
"options": {
|
||||||
|
"enforceFunctionParameterTypes": true,
|
||||||
|
"enforceLocktimeGuard": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
101
src/lib/escrow-covenant.js
Normal file
101
src/lib/escrow-covenant.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Delivery escrow covenant — off-chain side (contracts/delivery-escrow.cash;
|
||||||
|
// Decentralized.DNS/DESIGN-trustless-purchase.md §4).
|
||||||
|
//
|
||||||
|
// STATUS: chipnet draft, NOT REVIEWED.
|
||||||
|
//
|
||||||
|
// The buyer pays the script's address. The payment can be collected only by a
|
||||||
|
// transaction that mints the agreed certificate to the buyer and publishes the
|
||||||
|
// agreed registration; otherwise the buyer refunds it after `refundAfter`
|
||||||
|
// blocks. The registrar never holds money it could keep without delivering.
|
||||||
|
//
|
||||||
|
// The terms are everything the buyer agreed to: the name, where the
|
||||||
|
// certificate goes, and the exact records. They are all in the address, so
|
||||||
|
// both sides can recompute it and neither can change a term afterwards.
|
||||||
|
|
||||||
|
import { binToHex, utf8ToBin, hexToBin } from "@bitauth/libauth";
|
||||||
|
import { NETWORK } from "./resolver-web.js";
|
||||||
|
import { DELIVERY_ESCROW } from "./delivery-escrow-artifact.js";
|
||||||
|
import {
|
||||||
|
asmToBytecode, selectorsOf, redeemScript, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
|
||||||
|
unlockScript, SIG, PUBKEY, assemble, p2pkhSpecial,
|
||||||
|
} from "./covenant.js";
|
||||||
|
import { normalizeName, encodePayload, opReturnScript, BEACON_DUST_SAT, CERTIFICATE_DUST_SAT } from "./register-tx.js";
|
||||||
|
|
||||||
|
const BODY = asmToBytecode(DELIVERY_ESCROW.bytecode);
|
||||||
|
const FN = selectorsOf(DELIVERY_ESCROW);
|
||||||
|
export const ESCROW_DEFAULT_REFUND_BLOCKS = 144; // about a day
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} o.name
|
||||||
|
* @param {object} [o.records] the records the registration will publish
|
||||||
|
* @param {string|Uint8Array} o.buyerPkh who may refund
|
||||||
|
* @param {Uint8Array} o.certLock where the certificate must go
|
||||||
|
* @param {Uint8Array} o.beaconLock the discovery beacon's locking bytecode
|
||||||
|
* @param {number} [o.refundAfter] blocks until the buyer may refund (1..65535)
|
||||||
|
*/
|
||||||
|
export function escrowTerms({ name, records = {}, buyerPkh, certLock, beaconLock, refundAfter = ESCROW_DEFAULT_REFUND_BLOCKS }) {
|
||||||
|
const rawName = normalizeName(name);
|
||||||
|
const pkh = bin(buyerPkh);
|
||||||
|
if (pkh.length !== 20) throw new Error("buyerPkh must be 20 bytes");
|
||||||
|
if (!Number.isInteger(refundAfter) || refundAfter < 1 || refundAfter > 0xffff) throw new Error("refundAfter must be 1..65535 blocks");
|
||||||
|
return {
|
||||||
|
name: rawName,
|
||||||
|
records,
|
||||||
|
buyerPkh: binToHex(pkh),
|
||||||
|
certLock: bin(certLock),
|
||||||
|
nameCommitment: utf8ToBin(rawName),
|
||||||
|
payloadLock: opReturnScript(encodePayload("REG", rawName, records)),
|
||||||
|
beaconLock: bin(beaconLock),
|
||||||
|
beaconDust: BEACON_DUST_SAT,
|
||||||
|
refundAfter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const escrowRedeem = (t) => redeemScript(BODY, [
|
||||||
|
push(bin(t.buyerPkh)), push(t.certLock), push(t.nameCommitment), push(t.payloadLock), push(t.beaconLock),
|
||||||
|
pushInt(t.beaconDust), pushInt(t.refundAfter),
|
||||||
|
]);
|
||||||
|
export const escrowLock = (t) => p2sh32Lock(escrowRedeem(t));
|
||||||
|
export const escrowAddress = (t, prefix = NETWORK.prefix) => lockToAddress(escrowLock(t), prefix, false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver: register the name to the buyer and collect the escrow.
|
||||||
|
* escrow the escrow's UTXO
|
||||||
|
* genesis a plain coin of the deliverer's AT OUTPUT INDEX 0 — its txid
|
||||||
|
* becomes the certificate's category, which is what makes the
|
||||||
|
* certificate new
|
||||||
|
* coins more of the deliverer's coins, if the two inputs do not cover it
|
||||||
|
* The escrow's value, less costs, returns to `changeLock` (the deliverer).
|
||||||
|
*/
|
||||||
|
export function buildEscrowDeliverTx({ escrow, terms, genesis, coins = [], changeLock, keyFor, feeRate = 1, tamper = null }) {
|
||||||
|
if (genesis.vout !== 0) throw new Error("the genesis coin must be at output index 0 (CashTokens requires it to create a category)");
|
||||||
|
if (genesis.token) throw new Error("the genesis coin must not carry a token");
|
||||||
|
const redeem = escrowRedeem(terms);
|
||||||
|
const built = assemble({
|
||||||
|
special: [
|
||||||
|
p2pkhSpecial(genesis, keyFor),
|
||||||
|
{ utxo: escrow, unlock: () => unlockScript({ selector: FN.deliver, redeem, args: [] }) },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: terms.certLock, valueSatoshis: CERTIFICATE_DUST_SAT, token: { amount: 0n, category: hexToBin(genesis.txid), nft: { capability: "none", commitment: terms.nameCommitment } } },
|
||||||
|
{ lockingBytecode: terms.payloadLock, valueSatoshis: 0n },
|
||||||
|
{ lockingBytecode: terms.beaconLock, valueSatoshis: BigInt(terms.beaconDust) },
|
||||||
|
],
|
||||||
|
coins, changeLock, keyFor, feeRate, tamper,
|
||||||
|
});
|
||||||
|
return { ...built, category: genesis.txid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refund: the buyer takes the payment back once it is `refundAfter` blocks old. */
|
||||||
|
export function buildEscrowRefundTx({ escrow, terms, toLock, keyFor, feeRate = 1, tamper = null }) {
|
||||||
|
const redeem = escrowRedeem(terms);
|
||||||
|
const key = keyFor(binToHex(pkhLock(terms.buyerPkh)));
|
||||||
|
if (!key) throw new Error("this wallet does not hold the buyer's key for this escrow");
|
||||||
|
return assemble({
|
||||||
|
special: [{
|
||||||
|
utxo: escrow, sequence: terms.refundAfter,
|
||||||
|
unlock: (transaction, sourceOutputs, inputIndex, final) =>
|
||||||
|
unlockScript({ selector: FN.refund, redeem, transaction, sourceOutputs, inputIndex, key: final ? key : null, args: [PUBKEY, SIG] }),
|
||||||
|
}],
|
||||||
|
outputs: [], changeLock: toLock, keyFor, feeRate, tamper,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
// Decentralized.DNS/DESIGN-covenant-registry.md)
|
// Decentralized.DNS/DESIGN-covenant-registry.md)
|
||||||
//
|
//
|
||||||
// STATUS: chipnet draft, NOT REVIEWED. Nothing here may hold a mainnet name
|
// STATUS: chipnet draft, NOT REVIEWED. Nothing here may hold a mainnet name
|
||||||
// before an outside review of the contract. See the contract's header.
|
// before an outside review of the contract. See the contract's header and
|
||||||
|
// Decentralized.DNS/REGISTRY-COVENANT-v1.md.
|
||||||
//
|
//
|
||||||
// A covenant-held name is an output locked to
|
// A covenant-held name is an output locked to
|
||||||
//
|
//
|
||||||
|
|
@ -20,18 +21,18 @@
|
||||||
//
|
//
|
||||||
// Prices come from registry-lifecycle.js, the reference the contract is tested
|
// 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).
|
// against (test/registry-covenant.test.mjs runs both and compares to the sat).
|
||||||
|
// The generic machinery — redeem scripts, signing, funding — is covenant.js.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hexToBin, binToHex, encodeDataPush, bigIntToVmNumber, hash256, hash160, utf8ToBin,
|
binToHex, hash256, utf8ToBin, numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256, flattenBinArray,
|
||||||
encodeLockingBytecodeP2sh32, encodeLockingBytecodeP2pkh, lockingBytecodeToCashAddress,
|
|
||||||
numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256,
|
|
||||||
generateSigningSerializationBCH, encodeTransaction, flattenBinArray,
|
|
||||||
} from "@bitauth/libauth";
|
} from "@bitauth/libauth";
|
||||||
import { NETWORK } from "./resolver-web.js";
|
|
||||||
import { NAME_REGISTRY } from "./name-registry-artifact.js";
|
import { NAME_REGISTRY } from "./name-registry-artifact.js";
|
||||||
import { COMMIT_BOX } from "./commit-box-artifact.js";
|
import { COMMIT_BOX } from "./commit-box-artifact.js";
|
||||||
import { asmToBytecode } from "./market-usd.js";
|
import {
|
||||||
import { toInput, toSource, tokenOf, signInputs, SIGHASH_ALL_FORKID } from "./market-tx.js";
|
asmToBytecode, selectorsOf, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
|
||||||
|
unlockScript, SIG, PUBKEY, assemble,
|
||||||
|
} from "./covenant.js";
|
||||||
|
import { tokenOf } from "./market-tx.js";
|
||||||
import { normalizeName, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT } from "./register-tx.js";
|
import { normalizeName, P2PKH_DUST_SAT, CERTIFICATE_DUST_SAT } from "./register-tx.js";
|
||||||
import {
|
import {
|
||||||
BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT,
|
BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT,
|
||||||
|
|
@ -43,12 +44,7 @@ export const STATE_BYTES = 26;
|
||||||
const REGISTRY_BODY = asmToBytecode(NAME_REGISTRY.bytecode);
|
const REGISTRY_BODY = asmToBytecode(NAME_REGISTRY.bytecode);
|
||||||
/** Redeem-script body of the CommitBox — baked into the registry's CODE. */
|
/** Redeem-script body of the CommitBox — baked into the registry's CODE. */
|
||||||
export const COMMIT_BOX_CODE = asmToBytecode(COMMIT_BOX.bytecode);
|
export const COMMIT_BOX_CODE = asmToBytecode(COMMIT_BOX.bytecode);
|
||||||
|
const FN = selectorsOf(NAME_REGISTRY);
|
||||||
// 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 ----------
|
// ---------- terms, state, addresses ----------
|
||||||
|
|
||||||
|
|
@ -100,14 +96,9 @@ export function parseRegistryState(redeemOrState) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registryRedeem = (terms, state) => flattenBinArray([registryState(state), registryCode(terms)]);
|
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 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);
|
export const registryAddress = (terms, state, prefix) => lockToAddress(registryLock(terms, state), prefix);
|
||||||
|
export { p2sh32Lock, lockToAddress };
|
||||||
|
|
||||||
/** What the steward signs, once, to approve a newer version's code: sha256 is applied by OP_CHECKDATASIG itself. */
|
/** 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 const migrationMessage = (newCode) => hash256(bin(newCode));
|
||||||
|
|
@ -143,104 +134,23 @@ export function takeoverQuote(terms, state, height) {
|
||||||
return { priceSats: price, ownerSats, fundSats: price - ownerSats, newExpiry: height + terms.blocksPerYear };
|
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 ----------
|
// ---------- 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 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 ownerKey = (keyFor, state) => {
|
||||||
const k = keyFor(binToHex(pkhLock(state.ownerPkh)));
|
const k = keyFor(binToHex(pkhLock(state.ownerPkh)));
|
||||||
if (!k) throw new Error("this wallet does not hold the owner key of this name");
|
if (!k) throw new Error("this wallet does not hold the owner key of this name");
|
||||||
return k;
|
return k;
|
||||||
};
|
};
|
||||||
|
// One registry input. Owner paths take (pubkey, sig, ...args); takeover has no signature.
|
||||||
const registrySpecial = (cov, redeem, fn, key, args) => ({
|
const registrySpecial = (cov, redeem, fn, key, args) => ({
|
||||||
utxo: cov,
|
utxo: cov,
|
||||||
unlock: (transaction, sourceOutputs, inputIndex, final) =>
|
unlock: (transaction, sourceOutputs, inputIndex, final) => unlockScript({
|
||||||
registryUnlock({ fn, redeem, transaction, sourceOutputs, inputIndex, key: final ? key : null, args }),
|
selector: FN[fn], redeem, transaction, sourceOutputs, inputIndex,
|
||||||
|
key: final ? key : null,
|
||||||
|
args: fn === "takeover" ? args : [PUBKEY, SIG, ...args],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Renew (or reclaim) for `years`. Pays renewalSats × years to the fund. */
|
/** Renew (or reclaim) for `years`. Pays renewalSats × years to the fund. */
|
||||||
|
|
@ -284,7 +194,11 @@ export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOw
|
||||||
const built = assemble({
|
const built = assemble({
|
||||||
special: [
|
special: [
|
||||||
registrySpecial(cov, redeem, "takeover", null, [bin(newOwnerPkh), bin(salt)]),
|
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 }) },
|
{
|
||||||
|
utxo: box, sequence: 1, // the box enforces an age of one block
|
||||||
|
unlock: (transaction, sourceOutputs, inputIndex, final) =>
|
||||||
|
unlockScript({ redeem: boxRedeem, transaction, sourceOutputs, inputIndex, key: final ? takerKey : null, args: [PUBKEY, SIG] }),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
outputs: [
|
outputs: [
|
||||||
certOut(registryLock(terms, next), cov),
|
certOut(registryLock(terms, next), cov),
|
||||||
|
|
@ -300,13 +214,12 @@ export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOw
|
||||||
export function buildRegistryReleaseTx({ cov, terms, state, coins = [], changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
|
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");
|
if (extra.length > 3) throw new Error("a release may have at most four outputs in total");
|
||||||
const redeem = registryRedeem(terms, state);
|
const redeem = registryRedeem(terms, state);
|
||||||
// Output 0 must exist and must not carry the certificate: the change (or a dust output) takes that slot.
|
// Output 0 must exist and must not carry the certificate: a dust output takes that slot.
|
||||||
const built = assemble({
|
return assemble({
|
||||||
special: [registrySpecial(cov, redeem, "release", ownerKey(keyFor, state), [])],
|
special: [registrySpecial(cov, redeem, "release", ownerKey(keyFor, state), [])],
|
||||||
outputs: [{ lockingBytecode: changeLock, valueSatoshis: P2PKH_DUST_SAT }],
|
outputs: [{ lockingBytecode: changeLock, valueSatoshis: P2PKH_DUST_SAT }],
|
||||||
extra, coins, changeLock, keyFor, feeRate, tamper,
|
extra, coins, changeLock, keyFor, feeRate, tamper,
|
||||||
});
|
});
|
||||||
return built;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
55
src/lib/subscription-artifact.js
Normal file
55
src/lib/subscription-artifact.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Generated from contracts/subscription.json by src/gen-contract-artifacts.mjs — do not edit.
|
||||||
|
// Subscription compiled with cashc 0.13.3.
|
||||||
|
export const SUBSCRIPTION = {
|
||||||
|
"contractName": "Subscription",
|
||||||
|
"constructorInputs": [
|
||||||
|
{
|
||||||
|
"name": "buyerPkh",
|
||||||
|
"type": "bytes20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hostLock",
|
||||||
|
"type": "bytes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "priceSats",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "periodBlocks",
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maxFeeSats",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"abi": [
|
||||||
|
{
|
||||||
|
"name": "pull",
|
||||||
|
"inputs": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cancel",
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "pk",
|
||||||
|
"type": "pubkey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "s",
|
||||||
|
"type": "sig"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bytecode": "OP_5 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_3 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_DROP OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_0 OP_UTXOVALUE OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_3 OP_PICK OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ROT OP_SUB OP_ROT OP_SUB OP_DUP 2202 OP_GREATERTHANOREQUAL OP_IF OP_1 OP_OUTPUTBYTECODE OP_0 OP_UTXOBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_OVER OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_DROP OP_1 OP_ELSE OP_5 OP_ROLL OP_1 OP_NUMEQUALVERIFY OP_5 OP_PICK OP_HASH160 OP_EQUALVERIFY OP_2ROT OP_CHECKSIGVERIFY OP_2DROP OP_2DROP OP_1 OP_ENDIF",
|
||||||
|
"compiler": {
|
||||||
|
"name": "cashc",
|
||||||
|
"version": "0.13.3",
|
||||||
|
"options": {
|
||||||
|
"enforceFunctionParameterTypes": true,
|
||||||
|
"enforceLocktimeGuard": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
98
src/lib/subscription-covenant.js
Normal file
98
src/lib/subscription-covenant.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Subscription covenant — off-chain side (contracts/subscription.cash).
|
||||||
|
//
|
||||||
|
// STATUS: chipnet draft, NOT REVIEWED. See the contract's header for what it
|
||||||
|
// promises and Decentralized.DNS/REGISTRY-COVENANT-v1.md for the family.
|
||||||
|
//
|
||||||
|
// The buyer funds the script's address with a plain payment. The host (or
|
||||||
|
// anyone) may then send one period's price to the host once per period; the
|
||||||
|
// buyer may take the balance back whenever they like.
|
||||||
|
//
|
||||||
|
// The first draw is possible one period after funding. A host that wants the
|
||||||
|
// first period up front takes it as a second output of the funding
|
||||||
|
// transaction; the contract does not need to know.
|
||||||
|
|
||||||
|
import { binToHex, encodeTransaction, hash256 } from "@bitauth/libauth";
|
||||||
|
import { SUBSCRIPTION } from "./subscription-artifact.js";
|
||||||
|
import {
|
||||||
|
asmToBytecode, selectorsOf, redeemScript, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
|
||||||
|
unlockScript, SIG, PUBKEY, assemble,
|
||||||
|
} from "./covenant.js";
|
||||||
|
import { toInput, toSource } from "./market-tx.js";
|
||||||
|
import { P2PKH_DUST_SAT } from "./register-tx.js";
|
||||||
|
|
||||||
|
const BODY = asmToBytecode(SUBSCRIPTION.bytecode);
|
||||||
|
const FN = selectorsOf(SUBSCRIPTION);
|
||||||
|
export const SUBSCRIPTION_DEFAULT_MAX_FEE_SAT = 1000n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|Uint8Array} o.buyerPkh who may cancel
|
||||||
|
* @param {Uint8Array} o.hostLock where each period's payment goes
|
||||||
|
* @param {bigint|number} o.priceSats one period's price
|
||||||
|
* @param {number} o.periodBlocks blocks between draws (a relative lock: 1..65535)
|
||||||
|
* @param {bigint|number} [o.maxFeeSats] the most a draw may spend on its own fee
|
||||||
|
*/
|
||||||
|
export function subscriptionTerms({ buyerPkh, hostLock, priceSats, periodBlocks, maxFeeSats = SUBSCRIPTION_DEFAULT_MAX_FEE_SAT }) {
|
||||||
|
const pkh = bin(buyerPkh);
|
||||||
|
if (pkh.length !== 20) throw new Error("buyerPkh must be 20 bytes");
|
||||||
|
if (!(BigInt(priceSats) >= P2PKH_DUST_SAT)) throw new Error(`priceSats must be at least ${P2PKH_DUST_SAT}`);
|
||||||
|
if (!Number.isInteger(periodBlocks) || periodBlocks < 1 || periodBlocks > 0xffff) throw new Error("periodBlocks must be 1..65535");
|
||||||
|
if (!(BigInt(maxFeeSats) >= 300n)) throw new Error("maxFeeSats must leave room for a real fee (at least 300)");
|
||||||
|
return { buyerPkh: binToHex(pkh), hostLock: bin(hostLock), priceSats: BigInt(priceSats), periodBlocks, maxFeeSats: BigInt(maxFeeSats) };
|
||||||
|
}
|
||||||
|
export const subscriptionRedeem = (t) =>
|
||||||
|
redeemScript(BODY, [push(bin(t.buyerPkh)), push(t.hostLock), pushInt(t.priceSats), pushInt(t.periodBlocks), pushInt(t.maxFeeSats)]);
|
||||||
|
export const subscriptionLock = (t) => p2sh32Lock(subscriptionRedeem(t));
|
||||||
|
export const subscriptionAddress = (t, prefix) => lockToAddress(subscriptionLock(t), prefix, false);
|
||||||
|
|
||||||
|
/** How many whole periods a balance pays for, counting each draw's worst-case fee. */
|
||||||
|
export function periodsCovered(terms, balanceSats) {
|
||||||
|
const per = terms.priceSats + terms.maxFeeSats;
|
||||||
|
return BigInt(balanceSats) / per;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One period's draw. Needs no key: the transaction pays the host and returns
|
||||||
|
* the rest to the subscription, and that is all it is able to do.
|
||||||
|
* `utxo` is the subscription's current output; it must be periodBlocks old.
|
||||||
|
*/
|
||||||
|
export function buildSubscriptionPullTx({ utxo, terms, feeRate = 1, tamper = null }) {
|
||||||
|
const redeem = subscriptionRedeem(terms);
|
||||||
|
const balance = BigInt(utxo.satoshis);
|
||||||
|
if (balance < terms.priceSats) throw new Error(`the subscription holds ${balance} sat, less than one period (${terms.priceSats})`);
|
||||||
|
const unlocking = unlockScript({ selector: FN.pull, redeem, args: [] });
|
||||||
|
const input = { ...toInput(utxo, unlocking), sequenceNumber: terms.periodBlocks };
|
||||||
|
const mustReturn = balance - terms.priceSats - terms.maxFeeSats; // the contract's own figure
|
||||||
|
const keeps = mustReturn >= P2PKH_DUST_SAT;
|
||||||
|
const draft = (rest) => ({
|
||||||
|
version: 2, locktime: 0, inputs: [input],
|
||||||
|
outputs: [{ lockingBytecode: terms.hostLock, valueSatoshis: terms.priceSats }, ...(keeps ? [{ lockingBytecode: utxo.lockingBytecode, valueSatoshis: rest }] : [])],
|
||||||
|
});
|
||||||
|
let fee = BigInt(Math.ceil(encodeTransaction(draft(balance)).length * feeRate));
|
||||||
|
if (fee > terms.maxFeeSats) throw new Error(`a draw would cost ${fee} sat in fees, more than the ${terms.maxFeeSats} the subscription allows`);
|
||||||
|
const rest = keeps ? balance - terms.priceSats - fee : 0n;
|
||||||
|
if (!keeps) fee = balance - terms.priceSats; // too little left to return: it is the fee
|
||||||
|
let transaction = draft(rest);
|
||||||
|
if (tamper) transaction = tamper(transaction);
|
||||||
|
const sourceOutputs = [{ ...toSource(utxo), sequenceNumber: transaction.inputs[0].sequenceNumber }];
|
||||||
|
const encoded = encodeTransaction(transaction);
|
||||||
|
return {
|
||||||
|
transaction, sourceOutputs, hex: binToHex(encoded), txid: binToHex(hash256(encoded).slice().reverse()),
|
||||||
|
feeSats: fee, hostSats: terms.priceSats, restSats: rest,
|
||||||
|
next: keeps ? { txid: binToHex(hash256(encoded).slice().reverse()), vout: 1, satoshis: rest, lockingBytecode: utxo.lockingBytecode } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The buyer ends the subscription; the balance, less the fee, goes to `toLock`. */
|
||||||
|
export function buildSubscriptionCancelTx({ utxo, terms, toLock, keyFor, feeRate = 1, tamper = null }) {
|
||||||
|
const redeem = subscriptionRedeem(terms);
|
||||||
|
const key = keyFor(binToHex(pkhLock(terms.buyerPkh)));
|
||||||
|
if (!key) throw new Error("this wallet does not hold the buyer's key for this subscription");
|
||||||
|
return assemble({
|
||||||
|
special: [{
|
||||||
|
utxo,
|
||||||
|
unlock: (transaction, sourceOutputs, inputIndex, final) =>
|
||||||
|
unlockScript({ selector: FN.cancel, redeem, transaction, sourceOutputs, inputIndex, key: final ? key : null, args: [PUBKEY, SIG] }),
|
||||||
|
}],
|
||||||
|
outputs: [], changeLock: toLock, keyFor, feeRate, tamper,
|
||||||
|
});
|
||||||
|
}
|
||||||
239
test/covenant-family.test.mjs
Normal file
239
test/covenant-family.test.mjs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// The subscription and the delivery escrow, run in libauth's BCH virtual
|
||||||
|
// machine (contracts/subscription.cash, contracts/delivery-escrow.cash).
|
||||||
|
//
|
||||||
|
// Same method as registry-covenant.test.mjs: build each path with the library,
|
||||||
|
// execute it, then break one rule at a time with every signature still valid,
|
||||||
|
// and require the VM itself to refuse. The last test pins which negative cases
|
||||||
|
// were stopped by the library before a transaction existed.
|
||||||
|
//
|
||||||
|
// Draft contracts; these tests are not a review.
|
||||||
|
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as L from "@bitauth/libauth";
|
||||||
|
import {
|
||||||
|
subscriptionTerms, subscriptionRedeem, subscriptionLock, subscriptionAddress, periodsCovered,
|
||||||
|
buildSubscriptionPullTx, buildSubscriptionCancelTx,
|
||||||
|
} from "../src/lib/subscription-covenant.js";
|
||||||
|
import { escrowTerms, escrowLock, escrowAddress, buildEscrowDeliverTx, buildEscrowRefundTx } from "../src/lib/escrow-covenant.js";
|
||||||
|
import { parseOpReturn } from "../src/lib/bns.js";
|
||||||
|
import { buildIndexFromSnapshot } from "../src/lib/resolver-web.js";
|
||||||
|
|
||||||
|
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, encodeTransaction } = L;
|
||||||
|
const VMS = [["BCH 2025", L.createVirtualMachineBch2025()], ["BCH 2026", L.createVirtualMachineBch2026()]];
|
||||||
|
const verdict = (b) => VMS.map(([label, vm]) => [label, vm.verify({ transaction: b.transaction, sourceOutputs: b.sourceOutputs })]);
|
||||||
|
const accepts = (b, what) => { for (const [label, r] of verdict(b)) assert.equal(r, true, `${what} — ${label}: ${r}`); };
|
||||||
|
const refusedByLibrary = [];
|
||||||
|
let refusedByVm = 0;
|
||||||
|
const rejects = (make, what) => {
|
||||||
|
let b;
|
||||||
|
try { b = make(); } catch (e) { refusedByLibrary.push(what); return; }
|
||||||
|
for (const [label, r] of verdict(b)) assert.notEqual(r, true, `${what} — ${label} accepted it`);
|
||||||
|
refusedByVm++;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 buyer = person(), host = person(), registrar = person(), stranger = person(), beacon = person();
|
||||||
|
const keyring = (...people) => (lockHex) => { const p = people.find((x) => x.lockHex === lockHex); return p ? { privateKey: p.priv, publicKey: p.pub } : null; };
|
||||||
|
const asAnyone = (p) => () => ({ privateKey: p.priv, publicKey: p.pub });
|
||||||
|
const fakeTxid = (n) => n.toString(16).padStart(64, "0");
|
||||||
|
const coin = (id, who, sats = 1_000_000n, vout = 0) => ({ txid: fakeTxid(id), vout, satoshis: sats, lockingBytecode: who.lock });
|
||||||
|
const setOut = (i, patch) => (tx) => ({ ...tx, outputs: tx.outputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
|
||||||
|
const setIn = (i, patch) => (tx) => ({ ...tx, inputs: tx.inputs.map((o, k) => (k === i ? { ...o, ...patch(o) } : o)) });
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Subscription
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const SUB = subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 50_000n, periodBlocks: 4320, maxFeeSats: 1000n });
|
||||||
|
const subUtxo = (sats = 200_000n, id = 1) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: subscriptionLock(SUB) });
|
||||||
|
const pull = (o = {}) => buildSubscriptionPullTx({ utxo: subUtxo(), terms: SUB, ...o });
|
||||||
|
|
||||||
|
test("subscription: the terms are the address", () => {
|
||||||
|
assert.notEqual(binToHex(subscriptionLock(SUB)), binToHex(subscriptionLock({ ...SUB, priceSats: 50_001n })));
|
||||||
|
assert.notEqual(binToHex(subscriptionLock(SUB)), binToHex(subscriptionLock({ ...SUB, periodBlocks: 4321 })));
|
||||||
|
assert.match(subscriptionAddress(SUB), /^bchtest:p/);
|
||||||
|
assert.equal(periodsCovered(SUB, 200_000n), 3n);
|
||||||
|
assert.throws(() => subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 10n, periodBlocks: 10 }), /at least/);
|
||||||
|
assert.throws(() => subscriptionTerms({ buyerPkh: buyer.pkh, hostLock: host.lock, priceSats: 50_000n, periodBlocks: 0 }), /1\.\.65535/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription: one period's draw pays the host and returns the rest, with no key at all", () => {
|
||||||
|
const b = pull();
|
||||||
|
accepts(b, "pull");
|
||||||
|
assert.equal(b.transaction.inputs.length, 1);
|
||||||
|
assert.equal(b.transaction.inputs[0].sequenceNumber, 4320, "the input carries the period as its relative lock");
|
||||||
|
assert.equal(binToHex(b.transaction.outputs[0].lockingBytecode), host.lockHex);
|
||||||
|
assert.equal(b.transaction.outputs[0].valueSatoshis, 50_000n);
|
||||||
|
assert.equal(binToHex(b.transaction.outputs[1].lockingBytecode), binToHex(subscriptionLock(SUB)), "the rest stays in the same subscription");
|
||||||
|
assert.equal(b.transaction.outputs[1].valueSatoshis, 200_000n - 50_000n - b.feeSats);
|
||||||
|
assert.ok(b.feeSats <= SUB.maxFeeSats && b.feeSats >= BigInt(encodeTransaction(b.transaction).length));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription: the balance is drawn down period by period, and the dust at the end is the fee", () => {
|
||||||
|
let utxo = subUtxo(160_000n), drawn = 0n, draws = 0;
|
||||||
|
for (;;) {
|
||||||
|
let b;
|
||||||
|
try { b = buildSubscriptionPullTx({ utxo, terms: SUB }); } catch { break; }
|
||||||
|
accepts(b, `draw ${draws + 1}`);
|
||||||
|
drawn += b.hostSats; draws++;
|
||||||
|
if (!b.next) { assert.equal(b.transaction.outputs.length, 1, "nothing worth returning: one output"); break; }
|
||||||
|
utxo = b.next;
|
||||||
|
}
|
||||||
|
assert.equal(draws, 3);
|
||||||
|
assert.equal(drawn, 150_000n);
|
||||||
|
|
||||||
|
// A balance of one price and a little: nothing is worth returning, so the
|
||||||
|
// draw has a single output and the remainder is its fee.
|
||||||
|
const last = buildSubscriptionPullTx({ utxo: subUtxo(50_900n), terms: SUB });
|
||||||
|
accepts(last, "final draw");
|
||||||
|
assert.equal(last.transaction.outputs.length, 1);
|
||||||
|
assert.equal(last.feeSats, 900n);
|
||||||
|
assert.equal(last.next, null);
|
||||||
|
assert.throws(() => buildSubscriptionPullTx({ utxo: subUtxo(49_999n), terms: SUB }), /less than one period/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription: the host cannot draw early, twice, or more than the price", () => {
|
||||||
|
rejects(() => pull({ tamper: setIn(0, () => ({ sequenceNumber: 4319 })) }), "one block before the period is up");
|
||||||
|
rejects(() => pull({ tamper: setIn(0, () => ({ sequenceNumber: 0xfffffffe })) }), "no relative lock at all");
|
||||||
|
rejects(() => pull({ tamper: setOut(1, (o) => ({ valueSatoshis: 200_000n - 50_000n - 1001n })) }), "one satoshi more than the allowed fee kept back");
|
||||||
|
rejects(() => pull({ tamper: setOut(1, () => ({ lockingBytecode: host.lock })) }), "the rest sent to the host instead of back to the subscription");
|
||||||
|
rejects(() => pull({ tamper: setOut(1, () => ({ lockingBytecode: subscriptionLock({ ...SUB, priceSats: 60_000n }) })) }), "the rest moved into a subscription with a higher price");
|
||||||
|
rejects(() => pull({ tamper: (tx) => ({ ...tx, outputs: [tx.outputs[0]] }) }), "the rest not returned at all");
|
||||||
|
rejects(() => pull({ tamper: setOut(0, () => ({ lockingBytecode: stranger.lock })) }), "the payment sent to someone who is not the host");
|
||||||
|
rejects(() => pull({ tamper: setOut(0, () => ({ valueSatoshis: 49_999n })) }), "less than the price to the host");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription: two balances cannot be drawn against one payment", () => {
|
||||||
|
// The bug this guards against: with two inputs, ONE host output and ONE
|
||||||
|
// return output would satisfy both scripts, and the host keeps a balance.
|
||||||
|
const a = pull();
|
||||||
|
const second = subUtxo(200_000n, 2);
|
||||||
|
const tx = {
|
||||||
|
...a.transaction,
|
||||||
|
inputs: [a.transaction.inputs[0], { ...a.transaction.inputs[0], outpointTransactionHash: hexToBin(second.txid) }],
|
||||||
|
outputs: [a.transaction.outputs[0], a.transaction.outputs[1], { lockingBytecode: host.lock, valueSatoshis: 150_000n }],
|
||||||
|
};
|
||||||
|
const sources = [a.sourceOutputs[0], { ...a.sourceOutputs[0], outpointTransactionHash: hexToBin(second.txid) }];
|
||||||
|
rejects(() => ({ transaction: tx, sourceOutputs: sources }), "two subscription inputs in one transaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscription: the buyer cancels whenever they like; nobody else can", () => {
|
||||||
|
const b = buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: buyer.lock, keyFor: keyring(buyer) });
|
||||||
|
accepts(b, "cancel");
|
||||||
|
assert.equal(b.transaction.outputs.length, 1);
|
||||||
|
assert.equal(binToHex(b.transaction.outputs[0].lockingBytecode), buyer.lockHex);
|
||||||
|
assert.equal(b.transaction.outputs[0].valueSatoshis, 200_000n - b.feeSats);
|
||||||
|
assert.equal(b.transaction.inputs[0].sequenceNumber, 0xfffffffe, "no waiting period for the buyer");
|
||||||
|
rejects(() => buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: host.lock, keyFor: asAnyone(host) }), "the host cancels and takes the balance");
|
||||||
|
rejects(() => buildSubscriptionCancelTx({ utxo: subUtxo(), terms: SUB, toLock: stranger.lock, keyFor: asAnyone(stranger) }), "a stranger cancels");
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Delivery escrow
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
const NAME = "escrowed.x";
|
||||||
|
const RECORDS = { u: "https://buyer.example" };
|
||||||
|
const ESC = escrowTerms({ name: NAME, records: RECORDS, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock, refundAfter: 144 });
|
||||||
|
const escUtxo = (terms = ESC, sats = 300_000n, id = 5) => ({ txid: fakeTxid(id), vout: 0, satoshis: sats, lockingBytecode: escrowLock(terms) });
|
||||||
|
const deliver = (o = {}) => buildEscrowDeliverTx({ escrow: escUtxo(), terms: ESC, genesis: coin(9, registrar, 20_000n), changeLock: registrar.lock, keyFor: keyring(registrar), ...o });
|
||||||
|
const tokenOf = (category, commitment, capability = "none", amount = 0n) => ({ amount, category: hexToBin(category), nft: { capability, commitment: new TextEncoder().encode(commitment) } });
|
||||||
|
|
||||||
|
test("escrow: every term is in the address", () => {
|
||||||
|
const variants = [
|
||||||
|
{ name: "other.x" }, { records: { u: "https://someone-else.example" } }, { certLock: registrar.lock },
|
||||||
|
{ beaconLock: stranger.lock }, { refundAfter: 145 }, { buyerPkh: stranger.pkh },
|
||||||
|
];
|
||||||
|
for (const v of variants) {
|
||||||
|
const t = escrowTerms({ name: NAME, records: RECORDS, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock, refundAfter: 144, ...v });
|
||||||
|
assert.notEqual(binToHex(escrowLock(t)), binToHex(escrowLock(ESC)), `changing ${Object.keys(v)[0]} must change the address`);
|
||||||
|
}
|
||||||
|
assert.match(escrowAddress(ESC), /^bchtest:p/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escrow: delivery registers the name to the buyer, and only then pays", () => {
|
||||||
|
const b = deliver();
|
||||||
|
accepts(b, "deliver");
|
||||||
|
const [cert, payload, dust, change] = b.transaction.outputs;
|
||||||
|
assert.equal(binToHex(cert.lockingBytecode), buyer.lockHex, "the certificate goes to the buyer");
|
||||||
|
assert.equal(binToHex(cert.token.category), fakeTxid(9), "a new category: the genesis coin's txid");
|
||||||
|
assert.equal(cert.token.nft.capability, "none");
|
||||||
|
assert.equal(new TextDecoder().decode(cert.token.nft.commitment), NAME);
|
||||||
|
assert.deepEqual(parseOpReturn(binToHex(payload.lockingBytecode)), { o: "REG", n: NAME, r: RECORDS }, "the agreed registration, exactly");
|
||||||
|
assert.equal(binToHex(dust.lockingBytecode), beacon.lockHex);
|
||||||
|
assert.equal(binToHex(change.lockingBytecode), registrar.lockHex);
|
||||||
|
assert.ok(change.valueSatoshis > 300_000n, "the deliverer collects the escrow");
|
||||||
|
|
||||||
|
// and what it builds is a registration a resolver accepts
|
||||||
|
const verbose = {
|
||||||
|
txid: b.txid, vin: [{ txid: fakeTxid(9), vout: 0 }],
|
||||||
|
vout: b.transaction.outputs.map((o, n) => ({ n, scriptPubKey: { hex: binToHex(o.lockingBytecode), address: n === 0 ? "bchtest:buyer" : undefined }, ...(o.token ? { tokenData: { category: binToHex(o.token.category), nft: { capability: o.token.nft.capability, commitment: binToHex(o.token.nft.commitment) } } } : {}) })),
|
||||||
|
};
|
||||||
|
const index = buildIndexFromSnapshot({ snapshot: { history: [{ tx_hash: b.txid, height: 100 }], txs: { [b.txid]: verbose } } });
|
||||||
|
assert.equal(index.get(NAME).category, fakeTxid(9));
|
||||||
|
assert.deepEqual(index.get(NAME).records, RECORDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escrow: the money cannot be collected without the agreed delivery", () => {
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, () => ({ lockingBytecode: registrar.lock })) }), "the certificate goes to the registrar");
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, (o) => ({ token: tokenOf(fakeTxid(9), "other.x") })) }), "a certificate for a different name");
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, () => ({ token: undefined })) }), "no certificate at all");
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "minting") })) }), "a certificate that can mint more of itself");
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "mutable") })) }), "a certificate whose name can be rewritten");
|
||||||
|
rejects(() => deliver({ tamper: setOut(0, () => ({ token: tokenOf(fakeTxid(9), NAME, "none", 5n) })) }), "fungible tokens minted alongside");
|
||||||
|
rejects(() => deliver({ tamper: setOut(1, () => ({ lockingBytecode: escrowTerms({ name: NAME, records: { u: "https://registrar.example" }, buyerPkh: buyer.pkh, certLock: buyer.lock, beaconLock: beacon.lock }).payloadLock })) }), "different records published");
|
||||||
|
rejects(() => deliver({ tamper: setOut(2, () => ({ lockingBytecode: registrar.lock })) }), "the beacon is not paid, so no resolver would see the name");
|
||||||
|
rejects(() => deliver({ tamper: setOut(2, () => ({ valueSatoshis: 599n })) }), "the beacon paid less than its dust");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escrow: the deliverer cannot keep a second token of the buyer's category", () => {
|
||||||
|
// A minting token of the same category would let its holder issue a
|
||||||
|
// transaction resolvers accept as an update of the buyer's name.
|
||||||
|
for (const i of [3]) {
|
||||||
|
rejects(() => deliver({ tamper: setOut(i, () => ({ token: tokenOf(fakeTxid(9), "", "minting") })) }), `a minting token of the category kept in output ${i}`);
|
||||||
|
}
|
||||||
|
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs.slice(0, 3), { lockingBytecode: registrar.lock, valueSatoshis: 1000n, token: tokenOf(fakeTxid(9), "", "minting") }, ...tx.outputs.slice(3)] }) }), "…slipped in as an extra output");
|
||||||
|
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, ...Array(3).fill({ lockingBytecode: registrar.lock, valueSatoshis: 546n })] }) }), "seven outputs: one would go unchecked");
|
||||||
|
rejects(() => deliver({ tamper: setOut(2, (o) => ({ token: tokenOf(fakeTxid(9), "", "minting") })) }), "a token riding on the beacon output");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escrow: the certificate must be new, and one delivery collects one payment", () => {
|
||||||
|
rejects(() => deliver({ tamper: setIn(0, () => ({ outpointIndex: 1 })) }), "input 0 is not a genesis outpoint");
|
||||||
|
rejects(() => deliver({ tamper: (tx) => ({ ...tx, inputs: [tx.inputs[1], tx.inputs[0]] }) }), "the escrow is not input 1");
|
||||||
|
rejects(() => deliver({ genesis: coin(9, registrar, 20_000n, 1) }), "library: genesis coin not at index 0");
|
||||||
|
// Two payments for the same name in one transaction: the second sits at input 2.
|
||||||
|
const a = deliver();
|
||||||
|
const second = escUtxo(ESC, 300_000n, 6);
|
||||||
|
const dup = {
|
||||||
|
...a.transaction,
|
||||||
|
inputs: [...a.transaction.inputs.slice(0, 2), { ...a.transaction.inputs[1], outpointTransactionHash: hexToBin(second.txid) }, ...a.transaction.inputs.slice(2)],
|
||||||
|
};
|
||||||
|
const sources = [...a.sourceOutputs.slice(0, 2), { ...a.sourceOutputs[1], outpointTransactionHash: hexToBin(second.txid) }, ...a.sourceOutputs.slice(2)];
|
||||||
|
rejects(() => ({ transaction: dup, sourceOutputs: sources }), "two escrows collected against one certificate");
|
||||||
|
});
|
||||||
|
|
||||||
|
const refund = (o = {}) => buildEscrowRefundTx({ escrow: escUtxo(), terms: ESC, toLock: buyer.lock, keyFor: keyring(buyer), ...o });
|
||||||
|
|
||||||
|
test("escrow: the buyer refunds after the wait, and only the buyer, and only after it", () => {
|
||||||
|
const b = refund();
|
||||||
|
accepts(b, "refund");
|
||||||
|
assert.equal(b.transaction.inputs[0].sequenceNumber, 144);
|
||||||
|
assert.equal(b.transaction.outputs[0].valueSatoshis, 300_000n - b.feeSats);
|
||||||
|
rejects(() => refund({ tamper: setIn(0, () => ({ sequenceNumber: 143 })) }), "one block before the wait is over");
|
||||||
|
rejects(() => refund({ tamper: setIn(0, () => ({ sequenceNumber: 0xfffffffe })) }), "an immediate refund");
|
||||||
|
rejects(() => buildEscrowRefundTx({ escrow: escUtxo(), terms: ESC, toLock: registrar.lock, keyFor: asAnyone(registrar) }), "the registrar 'refunds' to itself");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- the negatives are real ----------
|
||||||
|
|
||||||
|
test("every negative case was refused by the contract itself, not by the library", () => {
|
||||||
|
assert.deepEqual(refusedByLibrary.sort(), ["library: genesis coin not at index 0"]);
|
||||||
|
assert.ok(refusedByVm >= 28, `expected the VM to refuse at least 28 tampered transactions, it refused ${refusedByVm}`);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue