diff --git a/contracts/delivery-escrow.cash b/contracts/delivery-escrow.cash new file mode 100644 index 0000000..2cb74b3 --- /dev/null +++ b/contracts/delivery-escrow.cash @@ -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); + } +} diff --git a/contracts/delivery-escrow.json b/contracts/delivery-escrow.json new file mode 100644 index 0000000..c375ab1 --- /dev/null +++ b/contracts/delivery-escrow.json @@ -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" +} \ No newline at end of file diff --git a/contracts/subscription.cash b/contracts/subscription.cash new file mode 100644 index 0000000..45d37d1 --- /dev/null +++ b/contracts/subscription.cash @@ -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)); + } +} diff --git a/contracts/subscription.json b/contracts/subscription.json new file mode 100644 index 0000000..3024341 --- /dev/null +++ b/contracts/subscription.json @@ -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" +} \ No newline at end of file diff --git a/src/gen-contract-artifacts.mjs b/src/gen-contract-artifacts.mjs index 108e2ec..d6e90e7 100644 --- a/src/gen-contract-artifacts.mjs +++ b/src/gen-contract-artifacts.mjs @@ -3,6 +3,8 @@ // // 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/delivery-escrow.cash -o contracts/delivery-escrow.json +// npx cashc contracts/subscription.cash -o contracts/subscription.json // node src/gen-contract-artifacts.mjs // // 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 = [ { 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: "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) { const a = JSON.parse(readFileSync(new URL(`../contracts/${t.json}`, import.meta.url), "utf8")); diff --git a/src/lib/covenant.js b/src/lib/covenant.js new file mode 100644 index 0000000..6697075 --- /dev/null +++ b/src/lib/covenant.js @@ -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`); +} diff --git a/src/lib/delivery-escrow-artifact.js b/src/lib/delivery-escrow-artifact.js new file mode 100644 index 0000000..ffba9e2 --- /dev/null +++ b/src/lib/delivery-escrow-artifact.js @@ -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 + } + } +}; diff --git a/src/lib/escrow-covenant.js b/src/lib/escrow-covenant.js new file mode 100644 index 0000000..d5046d6 --- /dev/null +++ b/src/lib/escrow-covenant.js @@ -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, + }); +} diff --git a/src/lib/registry-covenant.js b/src/lib/registry-covenant.js index debad3d..bf96ed3 100644 --- a/src/lib/registry-covenant.js +++ b/src/lib/registry-covenant.js @@ -3,7 +3,8 @@ // 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. +// 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 // @@ -20,18 +21,18 @@ // // 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). +// The generic machinery — redeem scripts, signing, funding — is covenant.js. import { - hexToBin, binToHex, encodeDataPush, bigIntToVmNumber, hash256, hash160, utf8ToBin, - encodeLockingBytecodeP2sh32, encodeLockingBytecodeP2pkh, lockingBytecodeToCashAddress, - numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256, - generateSigningSerializationBCH, encodeTransaction, flattenBinArray, + binToHex, hash256, utf8ToBin, numberToBinUint32LE, binToNumberUint32LE, secp256k1, sha256, 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 { + 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 { BLOCKS_PER_YEAR, GRACE_BLOCKS, SPLIT, @@ -43,12 +44,7 @@ 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)); +const FN = selectorsOf(NAME_REGISTRY); // ---------- terms, state, addresses ---------- @@ -100,14 +96,9 @@ export function parseRegistryState(redeemOrState) { } 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); +export { p2sh32Lock, lockToAddress }; /** 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)); @@ -143,104 +134,23 @@ export function takeoverQuote(terms, state, height) { 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; }; +// One registry input. Owner paths take (pubkey, sig, ...args); takeover has no signature. 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 }), + unlock: (transaction, sourceOutputs, inputIndex, final) => unlockScript({ + 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. */ @@ -284,7 +194,11 @@ export function buildRegistryTakeoverTx({ cov, terms, state, name, height, newOw 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 }) }, + { + 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: [ 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 }) { 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({ + // Output 0 must exist and must not carry the certificate: a dust output takes that slot. + return assemble({ special: [registrySpecial(cov, redeem, "release", ownerKey(keyFor, state), [])], outputs: [{ lockingBytecode: changeLock, valueSatoshis: P2PKH_DUST_SAT }], extra, coins, changeLock, keyFor, feeRate, tamper, }); - return built; } /** diff --git a/src/lib/subscription-artifact.js b/src/lib/subscription-artifact.js new file mode 100644 index 0000000..c2e18f7 --- /dev/null +++ b/src/lib/subscription-artifact.js @@ -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 + } + } +}; diff --git a/src/lib/subscription-covenant.js b/src/lib/subscription-covenant.js new file mode 100644 index 0000000..36c6148 --- /dev/null +++ b/src/lib/subscription-covenant.js @@ -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, + }); +} diff --git a/test/covenant-family.test.mjs b/test/covenant-family.test.mjs new file mode 100644 index 0000000..2c877a1 --- /dev/null +++ b/test/covenant-family.test.mjs @@ -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}`); +});