covenants: close the holes an adversarial review found

migrate let the owner supply the new state, so an owner could put a script
of their own in front of the approved code and take the certificate out of
the covenant, or rewrite owner and expiry on the way. The contract now
carries its own state over and the owner supplies only the code. The
steward's approval covers source and destination code, so it cannot be
replayed in another tier or network.

The delivery escrow could be collected with a second protocol message after
the agreed one; it now allows at most four outputs, the fourth plain change.
A subscription draw could pay its fee allowance to a third output; it now
has one or two outputs and pays the host exactly the price.

Smaller: the commit salt is exactly 32 bytes (one box could serve several
names), expiry stays a block height, and relative-lock terms are range
checked in script. Escrow quotes are refused for co-sign TLDs, where a
delivery would satisfy the contract and register nothing.
This commit is contained in:
Local Dev 2026-10-04 16:53:56 +02:00
parent 4fb37e0683
commit e0eca1190e
14 changed files with 357 additions and 149 deletions

View file

@ -23,9 +23,19 @@ pragma cashscript ^0.13.0;
// 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.
// 3 optional: the deliverer's change — no token, and not a data output
// and nothing else. Two things are being kept out:
// · a second token of the certificate's category, which its holder could
// later use to update the buyer's name;
// · a second data output. A transaction is read as carrying ONE protocol
// message; a later OP_RETURN would replace the agreed registration in
// every resolver, and the deliverer would be paid for a certificate
// that registers nothing.
//
// It cannot deliver under a TLD that requires its owner's co-signature: that
// rule wants the TLD's certificate in an output of the registration, and no
// extra token is allowed here. The library refuses to build an escrow for
// such a name (escrow-covenant.js assertTldAllowsEscrow).
//
// 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
@ -63,15 +73,17 @@ contract DeliveryEscrow(
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); }
require(tx.outputs.length <= 4);
if (tx.outputs.length > 3) {
require(tx.outputs[3].tokenCategory == 0x);
require(tx.outputs[3].lockingBytecode.split(1)[0] != 0x6a);
}
}
function refund(pubkey pk, sig s) {
require(hash160(pk) == buyerPkh);
require(checkSig(s, pk));
require(within(refundAfter, 1, 65536)); // a 16-bit block count, see subscription.cash
require(this.age >= refundAfter);
}
}

View file

@ -19,32 +19,32 @@
]
}
],
"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",
"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_4 OP_LESSTHANOREQUAL OP_VERIFY OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_3 OP_OUTPUTBYTECODE OP_1 OP_SPLIT OP_DROP 6a OP_EQUAL OP_NOT OP_VERIFY 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_PICK OP_1 000001 OP_WITHIN OP_VERIFY 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// 3 optional: the deliverer's change — no token, and not a data output\n// and nothing else. Two things are being kept out:\n// · a second token of the certificate's category, which its holder could\n// later use to update the buyer's name;\n// · a second data output. A transaction is read as carrying ONE protocol\n// message; a later OP_RETURN would replace the agreed registration in\n// every resolver, and the deliverer would be paid for a certificate\n// that registers nothing.\n//\n// It cannot deliver under a TLD that requires its owner's co-signature: that\n// rule wants the TLD's certificate in an output of the registration, and no\n// extra token is allowed here. The library refuses to build an escrow for\n// such a name (escrow-covenant.js assertTldAllowsEscrow).\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 <= 4);\n if (tx.outputs.length > 3) {\n require(tx.outputs[3].tokenCategory == 0x);\n require(tx.outputs[3].lockingBytecode.split(1)[0] != 0x6a);\n }\n }\n\n function refund(pubkey pk, sig s) {\n require(hash160(pk) == buyerPkh);\n require(checkSig(s, pk));\n require(within(refundAfter, 1, 65536)); // a 16-bit block count, see subscription.cash\n require(this.age >= refundAfter);\n }\n}\n",
"fingerprint": "4f43d98c69ef538b7c7588fda9b49c1f18c4f712a6ea3a5e98c7cc6d24a36b1f",
"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",
"bytecode": "5779009c63c0519d00c9009d00c800cd537a8800d18800d27b8800d3009d51cd7b8851d1008852cd7b8852cc7ba26952d10088c454a169c453a06353d1008853cd517f75016a879169686d755167577a519d5779a988577a577aad55795103000001a569557ab26d6d6d5168",
"sourceMap": "59:4:81:5;;;;;60:16:60:37;:41::42;:8::44:1;61:26:61:27:0;:16::42:1;:46::47:0;:8::49:1;62:35:62:36:0;:25::61:1;64:27:64:28:0;:16::45:1;:49::57:0;;:8::59:1;65:27:65:28:0;:16::43:1;:8::57;66:27:66:28:0;:16::43:1;:47::61:0;:8::63:1;67:27:67:28:0;:16::41:1;:45::46:0;:8::48:1;69:27:69:28:0;:16::45:1;:49::60:0;:8::62:1;70:27:70:28:0;:16::43:1;:47::49:0;:8::51:1;72:27:72:28:0;:16::45:1;:49::59:0;:8::61:1;73:27:73:28:0;:16::35:1;:39::49:0;:16:::1;:8::51;74:27:74:28:0;:16::43:1;:47::49:0;:8::51:1;76:16:76:33:0;:37::38;:16:::1;:8::40;77:12:77:29:0;:32::33;:12:::1;:35:80:9:0;78:31:78:32;:20::47:1;:51::53:0;:12::55:1;79:31:79:32:0;:20::49:1;:56::57:0;:20::58:1;:::61;:65::69:0;:20:::1;;:12::71;77:35:80:9;59:23:81:5;;;:4;83::88::0;;;;84:24:84:26;;:16::27:1;:8::41;85:25:85:26:0;;:28::30;;:8::33:1;86:23:86:34:0;;:36::37;:39::44;:16::45:1;:8::47;87:28:87:39:0;;:8::41:1;83:38:88:5;;;;50:0:89: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 }
{ "ip": 14, "line": 60 },
{ "ip": 18, "line": 61 },
{ "ip": 25, "line": 64 },
{ "ip": 28, "line": 65 },
{ "ip": 32, "line": 66 },
{ "ip": 36, "line": 67 },
{ "ip": 40, "line": 69 },
{ "ip": 44, "line": 70 },
{ "ip": 48, "line": 72 },
{ "ip": 53, "line": 73 },
{ "ip": 57, "line": 74 },
{ "ip": 61, "line": 76 },
{ "ip": 69, "line": 78 },
{ "ip": 78, "line": 79 },
{ "ip": 91, "line": 84 },
{ "ip": 96, "line": 85 },
{ "ip": 102, "line": 86 },
{ "ip": 105, "line": 87 }
]
},
"compiler": {
@ -55,5 +55,5 @@
"enforceLocktimeGuard": true
}
},
"updatedAt": "2026-10-04T14:02:01.200Z"
"updatedAt": "2026-10-04T14:37:29.033Z"
}

View file

@ -30,7 +30,7 @@ pragma cashscript ^0.13.0;
contract NameRegistry(
// ---- fixed for this covenant version and price tier ----
bytes fundLock, // locking bytecode the fund's payments must go to
pubkey stewardPk, // approves the CODE of a newer covenant version (migrate)
pubkey stewardPk, // approves moving from this code to a newer version's (migrate)
bytes commitBoxCode, // redeem-script body of the CommitBox contract
int renewalSats, // price of one year, for the owner
int premiumSats, // takeover price at the moment of expiry
@ -50,6 +50,10 @@ contract NameRegistry(
require(years >= 1);
int newExpiry = int(expiry) + years * blocksPerYear;
// Stay a block height. At 500,000,000 a locktime is read as a
// timestamp, takeover could never satisfy its height check, and the
// name would be untakeable for ever.
require(newExpiry < 500000000);
bytes newState = 0x14 + ownerPkh + 0x04 + toPaddedBytes(newExpiry, 4);
bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);
@ -102,12 +106,17 @@ contract NameRegistry(
ownerSats = price * ownerPercentIdle / 100;
}
// the commit
// the commit. The salt has a fixed size: the name and the salt are
// hashed end to end, so with a free-length salt a commit for "pay"
// would also be a commit for "payroll" (the rest of the name passed
// off as the start of the salt).
require(salt.length == 32);
bytes32 commitHash = hash256(tx.inputs[this.activeInputIndex].nftCommitment + salt);
bytes boxRedeem = 0x14 + newOwnerPkh + 0x20 + commitHash + commitBoxCode;
require(tx.inputs[1].lockingBytecode == new LockingBytecodeP2SH32(hash256(boxRedeem)));
// the name, under its new owner, for one fresh year
require(height + blocksPerYear < 500000000);
bytes newState = 0x14 + newOwnerPkh + 0x04 + toPaddedBytes(height + blocksPerYear, 4);
bytes32 newHash = hash256(newState + this.activeBytecode.split(26)[1]);
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
@ -142,16 +151,27 @@ contract NameRegistry(
}
// Move to a newer covenant version. The owner chooses to; the steward has
// signed the hash of the new version's CODE, once, for everyone. Neither
// can act alone: the steward cannot move a name, and the owner cannot
// migrate to a script the steward has not published. `newState` is whatever
// the new version expects in front of its code.
function migrate(pubkey pk, sig s, datasig approval, bytes newCode, bytes newState) {
// approved moving from THIS code to the new code, once, for everyone in
// this covenant. Neither can act alone.
//
// The owner and the expiry carry over unchanged, in the same 26 bytes, in
// front of the new code. Nothing here is free for the owner to choose:
// · the state is rebuilt by the contract, so the owner cannot put a
// script of their own in front of the approved code (which would turn
// that code into inert data behind their own signature check), and
// cannot give themselves a later expiry on the way;
// · the approval is over (this code, new code), so a signature made for
// one covenant — another tier, another network's fund — is worthless
// in any other.
// A newer version must therefore keep this state layout.
function migrate(pubkey pk, sig s, datasig approval, bytes newCode) {
require(hash160(pk) == ownerPkh);
require(checkSig(s, pk));
require(checkDataSig(approval, hash256(newCode), stewardPk));
bytes32 newHash = hash256(newState + newCode);
bytes32 fromCode = hash256(this.activeBytecode.split(26)[1]);
require(checkDataSig(approval, hash256(fromCode + hash256(newCode)), stewardPk));
bytes32 newHash = hash256(0x14 + ownerPkh + 0x04 + expiry + newCode);
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2SH32(newHash));
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);

File diff suppressed because one or more lines are too long

View file

@ -12,12 +12,19 @@ pragma cashscript ^0.13.0;
// · 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;
// pays the host's lock exactly `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.
// not have to be online with a key, but it has at most two outputs: the
// price to the host, exactly, and the rest back to this script. Whatever
// the fee allowance does not use goes to the miner, not to whoever built
// the transaction.
//
// ONE BALANCE, ONE OUTPUT. The once-per-period limit is a property of an
// output, not of an address. A buyer who pays the same address twice has two
// balances, and each can be drawn every period. To add funds: cancel and fund
// again, in one transaction if you like.
//
// 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
@ -30,12 +37,17 @@ pragma cashscript ^0.13.0;
contract Subscription(bytes20 buyerPkh, bytes hostLock, int priceSats, int periodBlocks, int maxFeeSats) {
// One period's payment to the host.
function pull() {
// A relative lock is a 16-bit block count. Anything outside that range
// would be read as something else entirely: only its low bits, a
// number of seconds, or no lock at all.
require(within(periodBlocks, 1, 65536));
require(this.age >= periodBlocks);
require(tx.inputs.length == 1);
require(tx.outputs.length <= 2);
int balance = tx.inputs[0].value;
require(tx.outputs[0].lockingBytecode == hostLock);
require(tx.outputs[0].value >= priceSats);
require(tx.outputs[0].value == priceSats);
require(tx.outputs[0].tokenCategory == 0x);
// Everything else stays in the subscription, less a bounded fee. When
@ -45,6 +57,10 @@ contract Subscription(bytes20 buyerPkh, bytes hostLock, int priceSats, int perio
require(tx.outputs[1].lockingBytecode == tx.inputs[0].lockingBytecode);
require(tx.outputs[1].value >= rest);
require(tx.outputs[1].tokenCategory == 0x);
} else {
// Nothing is returned, so there must be nothing else: the leftover
// is the fee, not a second output for whoever built the draw.
require(tx.outputs.length == 1);
}
}

View file

@ -17,24 +17,27 @@
]
}
],
"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",
"bytecode": "OP_5 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_3 OP_PICK OP_1 000001 OP_WITHIN OP_VERIFY OP_3 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_DROP OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_TXOUTPUTCOUNT OP_2 OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_UTXOVALUE OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_3 OP_PICK OP_NUMEQUALVERIFY 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_ELSE OP_TXOUTPUTCOUNT OP_1 OP_NUMEQUALVERIFY 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 it has at most two outputs: the\n// price to the host, exactly, and the rest back to this script. Whatever\n// the fee allowance does not use goes to the miner, not to whoever built\n// the transaction.\n//\n// ONE BALANCE, ONE OUTPUT. The once-per-period limit is a property of an\n// output, not of an address. A buyer who pays the same address twice has two\n// balances, and each can be drawn every period. To add funds: cancel and fund\n// again, in one transaction if you like.\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 // A relative lock is a 16-bit block count. Anything outside that range\n // would be read as something else entirely: only its low bits, a\n // number of seconds, or no lock at all.\n require(within(periodBlocks, 1, 65536));\n require(this.age >= periodBlocks);\n require(tx.inputs.length == 1);\n require(tx.outputs.length <= 2);\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 } else {\n // Nothing is returned, so there must be nothing else: the leftover\n // is the fee, not a second output for whoever built the draw.\n require(tx.outputs.length == 1);\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": "d5796aeddba44e7674c0caad36eb5c7ab658a0a4fe9c7ebf8701acb5cc8ba302",
"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",
"bytecode": "5579009c6353795103000001a569537ab275c3519dc452a16900c600cd537a8800cc53799d00d100887b947b9476022202a26351cd00c78851cc78a26951d1008867c4519d686d755167557a519d5579a98871ad6d6d5168",
"sourceMap": "39:4:65:5;;;;;43:23:43:35;;:37::38;:40::45;:16::46:1;:8::48;44:28:44:40:0;;:8::42:1;;45:16:45:32:0;:36::37;:8::39:1;46:16:46:33:0;:37::38;:16:::1;:8::40;48:32:48:33:0;:22::40:1;49:27:49:28:0;:16::45:1;:49::57:0;;:8::59:1;50:27:50:28:0;:16::35:1;:39::48:0;;:8::50:1;51:27:51:28:0;:16::43:1;:47::49:0;:8::51:1;55:29:55:38:0;:19:::1;:41::51:0;:19:::1;56:12:56:16:0;:20::23;:12:::1;:25:60:9:0;57:31:57:32;:20::49:1;:63::64:0;:53::81:1;:12::83;58:31:58:32:0;:20::39:1;:43::47:0;:20:::1;:12::49;59:31:59:32:0;:20::47:1;:51::53:0;:12::55:1;60:15:64:9:0;63:20:63:37;:41::42;:12::44:1;60:15:64:9;39:20:65:5;;;:4;68::71::0;;;;69:24:69:26;;:16::27:1;:8::41;70:25:70:30:0;:8::33:1;68:38:71:5;;;37:0:72: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 }
{ "ip": 15, "line": 43 },
{ "ip": 18, "line": 44 },
{ "ip": 22, "line": 45 },
{ "ip": 26, "line": 46 },
{ "ip": 33, "line": 49 },
{ "ip": 38, "line": 50 },
{ "ip": 42, "line": 51 },
{ "ip": 55, "line": 57 },
{ "ip": 60, "line": 58 },
{ "ip": 64, "line": 59 },
{ "ip": 68, "line": 63 },
{ "ip": 81, "line": 69 },
{ "ip": 83, "line": 70 }
]
},
"compiler": {
@ -45,5 +48,5 @@
"enforceLocktimeGuard": true
}
},
"updatedAt": "2026-10-04T14:02:00.557Z"
"updatedAt": "2026-10-04T14:39:23.518Z"
}

View file

@ -51,7 +51,7 @@ export const DELIVERY_ESCROW = {
]
}
],
"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",
"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_4 OP_LESSTHANOREQUAL OP_VERIFY OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_3 OP_OUTPUTBYTECODE OP_1 OP_SPLIT OP_DROP 6a OP_EQUAL OP_NOT OP_VERIFY 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_PICK OP_1 000001 OP_WITHIN OP_VERIFY OP_5 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ENDIF",
"compiler": {
"name": "cashc",
"version": "0.13.3",

View file

@ -13,7 +13,7 @@
// 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 { NETWORK, cosignRequired } from "./resolver-web.js";
import { DELIVERY_ESCROW } from "./delivery-escrow-artifact.js";
import {
asmToBytecode, selectorsOf, redeemScript, pushInt, push, bin, pkhLock, p2sh32Lock, lockToAddress,
@ -50,6 +50,18 @@ export function escrowTerms({ name, records = {}, buyerPkh, certLock, beaconLock
refundAfter,
};
}
/**
* An escrow cannot deliver under a TLD that requires its owner's co-signature:
* that rule wants the TLD's certificate in an output of the registration, and
* the contract allows no token but the name's own. The payment would still be
* collectable — for a registration no resolver accepts. Call this with the
* TLD's entry (fetchTldMap) before showing a buyer an escrow address.
*/
export function assertTldAllowsEscrow(tldEntry, height = 0, tld = "") {
if (tldEntry && cosignRequired(tldEntry, height)) {
throw new Error(`names under .${tld || "this TLD"} need the TLD owner's co-signature, which a delivery escrow cannot carry — register through the co-sign flow instead`);
}
}
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),

View file

@ -127,15 +127,11 @@ export const NAME_REGISTRY = {
{
"name": "newCode",
"type": "bytes"
},
{
"name": "newState",
"type": "bytes"
}
]
}
],
"bytecode": "OP_11 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_PICK OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY OP_12 OP_PICK OP_1 OP_GREATERTHANOREQUAL OP_VERIFY OP_9 OP_ROLL OP_BIN2NUM OP_12 OP_PICK OP_9 OP_ROLL OP_MUL OP_ADD 14 OP_10 OP_ROLL OP_CAT OP_4 OP_CAT OP_SWAP OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_3 OP_ROLL OP_9 OP_ROLL OP_MUL OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ELSE OP_11 OP_PICK OP_1 OP_NUMEQUAL OP_IF OP_14 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_13 OP_PICK OP_HASH160 OP_12 OP_ROLL OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY 14 OP_SWAP OP_CAT OP_4 OP_CAT OP_10 OP_ROLL OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_2 OP_NUMEQUAL OP_IF OP_12 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_10 OP_ROLL OP_BIN2NUM OP_DUP OP_CHECKLOCKTIMEVERIFY OP_DROP OP_TXLOCKTIME OP_DUP 0065cd1d OP_LESSTHAN OP_VERIFY OP_SWAP OP_9 OP_PICK OP_ADD OP_8 OP_PICK OP_0 OP_2OVER OP_LESSTHAN OP_IF OP_10 OP_PICK OP_10 OP_PICK OP_12 OP_PICK OP_SUB OP_4 OP_PICK OP_6 OP_PICK OP_SUB OP_MUL OP_13 OP_PICK OP_DIV OP_ADD OP_ROT OP_DROP OP_SWAP OP_OVER OP_14 OP_PICK OP_MUL 64 OP_DIV OP_NIP OP_ENDIF OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT 11 OP_ROLL OP_CAT OP_HASH256 14 OP_6 OP_PICK OP_CAT 20 OP_CAT OP_SWAP OP_CAT OP_8 OP_ROLL OP_CAT OP_1 OP_UTXOBYTECODE aa20 OP_ROT OP_HASH256 OP_CAT 87 OP_CAT OP_EQUALVERIFY 14 OP_5 OP_ROLL OP_CAT OP_4 OP_CAT OP_4 OP_ROLL OP_11 OP_ROLL OP_ADD OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_4 OP_ROLL OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_ROT OP_2 OP_PICK OP_SUB OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_DUP OP_0 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTBYTECODE 76a914 OP_10 OP_PICK OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_OVER OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_3 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_ROLL OP_EQUALVERIFY OP_12 OP_ROLL OP_12 OP_ROLL OP_CHECKSIGVERIFY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_TXOUTPUTCOUNT OP_4 OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_TXOUTPUTCOUNT OP_1 OP_GREATERTHAN OP_IF OP_1 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_2 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_ROLL OP_4 OP_NUMEQUALVERIFY OP_11 OP_PICK OP_HASH160 OP_11 OP_ROLL OP_EQUALVERIFY OP_11 OP_ROLL OP_11 OP_ROLL OP_CHECKSIGVERIFY OP_10 OP_ROLL OP_11 OP_PICK OP_HASH256 OP_3 OP_ROLL OP_CHECKDATASIGVERIFY OP_10 OP_ROLL OP_10 OP_ROLL OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ENDIF OP_ENDIF OP_ENDIF OP_ENDIF",
"bytecode": "OP_11 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_PICK OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY OP_12 OP_PICK OP_1 OP_GREATERTHANOREQUAL OP_VERIFY OP_9 OP_ROLL OP_BIN2NUM OP_12 OP_PICK OP_9 OP_ROLL OP_MUL OP_ADD OP_DUP 0065cd1d OP_LESSTHAN OP_VERIFY 14 OP_10 OP_ROLL OP_CAT OP_4 OP_CAT OP_SWAP OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_3 OP_ROLL OP_9 OP_ROLL OP_MUL OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ELSE OP_11 OP_PICK OP_1 OP_NUMEQUAL OP_IF OP_14 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_13 OP_PICK OP_HASH160 OP_12 OP_ROLL OP_EQUALVERIFY OP_13 OP_ROLL OP_13 OP_ROLL OP_CHECKSIGVERIFY 14 OP_SWAP OP_CAT OP_4 OP_CAT OP_10 OP_ROLL OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_2 OP_NUMEQUAL OP_IF OP_12 OP_ROLL OP_SIZE 14 OP_EQUALVERIFY OP_10 OP_ROLL OP_BIN2NUM OP_DUP OP_CHECKLOCKTIMEVERIFY OP_DROP OP_TXLOCKTIME OP_DUP 0065cd1d OP_LESSTHAN OP_VERIFY OP_SWAP OP_9 OP_PICK OP_ADD OP_8 OP_PICK OP_0 OP_2OVER OP_LESSTHAN OP_IF OP_10 OP_PICK OP_10 OP_PICK OP_12 OP_PICK OP_SUB OP_4 OP_PICK OP_6 OP_PICK OP_SUB OP_MUL OP_13 OP_PICK OP_DIV OP_ADD OP_ROT OP_DROP OP_SWAP OP_OVER OP_14 OP_PICK OP_MUL 64 OP_DIV OP_NIP OP_ENDIF OP_16 OP_PICK OP_SIZE OP_NIP 20 OP_NUMEQUALVERIFY OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT 11 OP_ROLL OP_CAT OP_HASH256 14 OP_6 OP_PICK OP_CAT 20 OP_CAT OP_SWAP OP_CAT OP_8 OP_ROLL OP_CAT OP_1 OP_UTXOBYTECODE aa20 OP_ROT OP_HASH256 OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_3 OP_PICK OP_12 OP_PICK OP_ADD 0065cd1d OP_LESSTHAN OP_VERIFY 14 OP_5 OP_ROLL OP_CAT OP_4 OP_CAT OP_4 OP_ROLL OP_11 OP_ROLL OP_ADD OP_4 OP_NUM2BIN OP_CAT OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE OP_4 OP_ROLL OP_EQUALVERIFY OP_1 OP_OUTPUTVALUE OP_ROT OP_2 OP_PICK OP_SUB OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_DUP OP_0 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTBYTECODE 76a914 OP_10 OP_PICK OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_2 OP_OUTPUTVALUE OP_OVER OP_GREATERTHANOREQUAL OP_VERIFY OP_2 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_PICK OP_3 OP_NUMEQUAL OP_IF OP_12 OP_PICK OP_HASH160 OP_11 OP_ROLL OP_EQUALVERIFY OP_12 OP_ROLL OP_12 OP_ROLL OP_CHECKSIGVERIFY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_TXOUTPUTCOUNT OP_4 OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_TXOUTPUTCOUNT OP_1 OP_GREATERTHAN OP_IF OP_1 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_2 OP_GREATERTHAN OP_IF OP_2 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_TXOUTPUTCOUNT OP_3 OP_GREATERTHAN OP_IF OP_3 OP_OUTPUTTOKENCATEGORY OP_OVER OP_EQUAL OP_NOT OP_VERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_ROLL OP_4 OP_NUMEQUALVERIFY OP_11 OP_PICK OP_HASH160 OP_11 OP_PICK OP_EQUALVERIFY OP_12 OP_ROLL OP_12 OP_ROLL OP_CHECKSIGVERIFY OP_ACTIVEBYTECODE 1a OP_SPLIT OP_NIP OP_HASH256 OP_12 OP_ROLL OP_SWAP OP_13 OP_PICK OP_HASH256 OP_CAT OP_HASH256 OP_3 OP_ROLL OP_CHECKDATASIGVERIFY 14 OP_10 OP_ROLL OP_CAT OP_4 OP_CAT OP_9 OP_ROLL OP_CAT OP_9 OP_ROLL OP_CAT OP_HASH256 OP_0 OP_OUTPUTBYTECODE aa20 OP_ROT OP_CAT 87 OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCOMMITMENT OP_INPUTINDEX OP_UTXOTOKENCOMMITMENT OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ENDIF OP_ENDIF OP_ENDIF OP_ENDIF",
"compiler": {
"name": "cashc",
"version": "0.13.3",

View file

@ -100,18 +100,38 @@ export const registryLock = (terms, state) => p2sh32Lock(registryRedeem(terms, s
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));
export function signMigrationApproval(stewardPrivateKey, newCode) {
const sig = secp256k1.signMessageHashSchnorr(stewardPrivateKey, sha256.hash(migrationMessage(newCode)));
/**
* What the steward signs to approve a migration: "names held by THIS code may
* move to THAT code". Both halves matter. An approval over the new code alone
* could be replayed in any covenant sharing the steward key — another tier,
* another network — so the source is part of the message.
* (sha256 is applied by OP_CHECKDATASIG itself.)
*/
export const migrationMessage = (fromCode, newCode) =>
hash256(flattenBinArray([hash256(bin(fromCode)), hash256(bin(newCode))]));
/** @param terms the covenant the names are migrating OUT of */
export function signMigrationApproval(stewardPrivateKey, terms, newCode) {
const sig = secp256k1.signMessageHashSchnorr(stewardPrivateKey, sha256.hash(migrationMessage(registryCode(terms), newCode)));
if (typeof sig === "string") throw new Error(sig);
return sig;
}
/** Where a name lands after migrating: its unchanged state in front of the new code. */
export const migratedLock = (state, newCode) => p2sh32Lock(flattenBinArray([registryState(state), bin(newCode)]));
// ---------- commit box ----------
/** commitHash = hash256(name ‖ salt): what the box's address hides until the takeover. */
export const commitHashFor = (name, salt) => hash256(flattenBinArray([utf8ToBin(normalizeName(name)), bin(salt)]));
export const COMMIT_SALT_BYTES = 32;
/**
* commitHash = hash256(name ‖ salt): what the box's address hides until the
* takeover. The salt is exactly 32 bytes — the contract requires it — because
* name and salt are hashed end to end: with a free-length salt, a commit for
* "pay" would also be one for "payroll".
*/
export function commitHashFor(name, salt) {
const s = bin(salt);
if (s.length !== COMMIT_SALT_BYTES) throw new Error(`the commit salt must be exactly ${COMMIT_SALT_BYTES} bytes`);
return hash256(flattenBinArray([utf8ToBin(normalizeName(name)), s]));
}
export const commitBoxRedeem = ({ ownerPkh, commitHash }) =>
flattenBinArray([Uint8Array.of(0x14), bin(ownerPkh), Uint8Array.of(0x20), bin(commitHash), COMMIT_BOX_CODE]);
export const commitBoxLock = (o) => p2sh32Lock(commitBoxRedeem(o));
@ -223,15 +243,15 @@ export function buildRegistryReleaseTx({ cov, terms, state, coins = [], changeLo
}
/**
* Migrate to a newer covenant version: `newCode` is its CODE (approved by the
* steward's signature `approval`), `newState` whatever it expects in front.
* Migrate to a newer covenant version: `newCode` is its CODE, approved for
* THIS covenant by the steward's signature `approval`. The owner and the
* expiry carry over unchanged; the contract rebuilds the state itself.
*/
export function buildRegistryMigrateTx({ cov, terms, state, newCode, newState, approval, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
export function buildRegistryMigrateTx({ cov, terms, state, newCode, approval, coins, changeLock, keyFor, extra = [], feeRate = 1, tamper = null }) {
const redeem = registryRedeem(terms, state);
const newLock = p2sh32Lock(flattenBinArray([bin(newState), bin(newCode)]));
return assemble({
special: [registrySpecial(cov, redeem, "migrate", ownerKey(keyFor, state), [bin(approval), bin(newCode), bin(newState)])],
outputs: [certOut(newLock, cov)],
special: [registrySpecial(cov, redeem, "migrate", ownerKey(keyFor, state), [bin(approval), bin(newCode)])],
outputs: [certOut(migratedLock(state, newCode), cov)],
extra, coins, changeLock, keyFor, feeRate, tamper,
});
}

View file

@ -43,7 +43,7 @@ export const SUBSCRIPTION = {
]
}
],
"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",
"bytecode": "OP_5 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_3 OP_PICK OP_1 000001 OP_WITHIN OP_VERIFY OP_3 OP_ROLL OP_CHECKSEQUENCEVERIFY OP_DROP OP_TXINPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_TXOUTPUTCOUNT OP_2 OP_LESSTHANOREQUAL OP_VERIFY OP_0 OP_UTXOVALUE OP_0 OP_OUTPUTBYTECODE OP_3 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTVALUE OP_3 OP_PICK OP_NUMEQUALVERIFY 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_ELSE OP_TXOUTPUTCOUNT OP_1 OP_NUMEQUALVERIFY 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",

View file

@ -7,6 +7,10 @@
// anyone) may then send one period's price to the host once per period; the
// buyer may take the balance back whenever they like.
//
// One balance is one output. Paying the same address a second time creates a
// second balance, and each can be drawn once per period: to add funds, cancel
// and fund again.
//
// 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.

View file

@ -15,7 +15,7 @@ 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 { escrowTerms, escrowLock, escrowAddress, buildEscrowDeliverTx, buildEscrowRefundTx, assertTldAllowsEscrow } from "../src/lib/escrow-covenant.js";
import { parseOpReturn } from "../src/lib/bns.js";
import { buildIndexFromSnapshot } from "../src/lib/resolver-web.js";
@ -110,6 +110,33 @@ test("subscription: the host cannot draw early, twice, or more than the price",
rejects(() => pull({ tamper: setOut(0, () => ({ valueSatoshis: 49_999n })) }), "less than the price to the host");
});
test("subscription: the fee allowance is nobody's to take", () => {
// The first draft required "at least" the price and did not limit outputs:
// the unused part of the fee allowance could be added to the host's output
// or sent to whoever built the transaction. Now it can only be the fee.
rejects(() => pull({ tamper: setOut(0, (o) => ({ valueSatoshis: o.valueSatoshis + 700n })) }), "the host's output topped up from the fee allowance");
rejects(() => pull({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: stranger.lock, valueSatoshis: 700n }] }) }), "a third output paying whoever built the draw");
// At the end of a balance, where nothing is returned, the leftover is still not theirs.
const last = (o = {}) => buildSubscriptionPullTx({ utxo: subUtxo(51_545n), terms: SUB, ...o });
accepts(last(), "last draw");
rejects(() => last({ tamper: (tx) => ({ ...tx, outputs: [tx.outputs[0], { lockingBytecode: stranger.lock, valueSatoshis: 1245n }] }) }), "the last 1,245 satoshis taken by a stranger");
});
test("subscription: a period outside the range of a relative lock is refused, not misread", () => {
// The library refuses these terms; the contract must too, because an address
// built by someone else's code is only as safe as the contract.
const odd = (periodBlocks) => ({ ...SUB, periodBlocks });
const pullWith = (terms, sequenceNumber) => () => {
const utxo = { txid: fakeTxid(1), vout: 0, satoshis: 200_000n, lockingBytecode: subscriptionLock(terms) };
return buildSubscriptionPullTx({ utxo, terms, tamper: setIn(0, () => ({ sequenceNumber })) });
};
rejects(pullWith(odd(0x10001), 1), "0x10001 read as one block");
rejects(pullWith(odd(0x10000), 0), "0x10000 read as no wait");
rejects(pullWith(odd(0), 0), "a period of zero");
rejects(pullWith(odd(0x400001), 0x400001), "a period read as seconds");
rejects(pullWith(odd(0x80000001), 0xffffffff), "a period with the disable bit: no lock at all");
});
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.
@ -200,10 +227,42 @@ test("escrow: the deliverer cannot keep a second token of the buyer's category",
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: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: registrar.lock, valueSatoshis: 546n }] }) }), "five 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: a second data output cannot replace the agreed registration", () => {
// Found by review. Resolvers read one protocol message per transaction; the
// first draft pinned output 1 and let later outputs be anything token-free,
// so a later OP_RETURN decided what the resolver saw. The deliverer was paid
// and the buyer's name registered to nobody — or with other records.
const opReturn = (payload) => { const d = Buffer.from("BNS1" + JSON.stringify(payload)); return Uint8Array.from([0x6a, ...(d.length <= 75 ? [d.length] : [0x4c, d.length]), ...d]); };
const hijacks = [
["other records for the same name", { o: "REG", n: NAME, r: { u: "https://deliverer.example" } }],
["an UPD, so nothing registers", { o: "UPD", n: NAME, r: {} }],
["a REG for another name", { o: "REG", n: "elsewhere.x", r: {} }],
];
for (const [what, payload] of hijacks) {
rejects(() => deliver({ tamper: setOut(3, () => ({ lockingBytecode: opReturn(payload), valueSatoshis: 0n })) }), `output 3 is a data output: ${what}`);
rejects(() => deliver({ tamper: (tx) => ({ ...tx, outputs: [...tx.outputs, { lockingBytecode: opReturn(payload), valueSatoshis: 0n }] }) }), `a fifth output: ${what}`);
}
rejects(() => deliver({ tamper: setOut(3, () => ({ lockingBytecode: Uint8Array.of(0x6a), valueSatoshis: 0n })) }), "a bare OP_RETURN in the change slot");
});
test("escrow: a co-signed TLD is refused up front, and a bad refund wait is refused by the contract", () => {
const cosignTld = { category: "f".repeat(64), records: { policy: "cosign" }, updates: [{ height: 1, txid: "0".repeat(64), records: { policy: "cosign" } }] };
assert.throws(() => assertTldAllowsEscrow(cosignTld, 100, "private"), /co-signature/);
assertTldAllowsEscrow({ category: "f".repeat(64), records: {}, updates: [{ height: 1, txid: "0".repeat(64), records: {} }] }, 100, "open");
assertTldAllowsEscrow(null, 100, "unknown");
const bad = (refundAfter) => ({ ...ESC, refundAfter });
const refundWith = (terms, sequenceNumber) => () =>
buildEscrowRefundTx({ escrow: escUtxo(terms), terms, toLock: buyer.lock, keyFor: keyring(buyer), tamper: setIn(0, () => ({ sequenceNumber })) });
rejects(refundWith(bad(0x10000), 0), "a wait of 0x10000 read as no wait");
rejects(refundWith(bad(0x400001), 0x400001), "a wait read as seconds");
rejects(refundWith(bad(0x80000001), 0xffffffff), "a wait with the disable bit");
});
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");
@ -235,5 +294,5 @@ test("escrow: the buyer refunds after the wait, and only the buyer, and only aft
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}`);
assert.ok(refusedByVm >= 46, `expected the VM to refuse at least 46 tampered transactions, it refused ${refusedByVm}`);
});

View file

@ -17,12 +17,14 @@ import assert from "node:assert/strict";
import * as L from "@bitauth/libauth";
import {
registryTerms, registryRedeem, registryLock, registryCode, registryState, parseRegistryState, STATE_BYTES,
commitBoxLock, commitHashFor, takeoverQuote, signMigrationApproval, p2sh32Lock, registryPhase,
commitBoxLock, commitBoxRedeem, commitHashFor, takeoverQuote, signMigrationApproval, migratedLock, registryPhase, COMMIT_SALT_BYTES,
buildRegistryRenewTx, buildRegistryTransferTx, buildRegistryTakeoverTx, buildRegistryReleaseTx, buildRegistryMigrateTx,
} from "../src/lib/registry-covenant.js";
import {
BLOCKS_PER_YEAR, GRACE_BLOCKS, PHASE, takeoverPriceSats, splitTakeover, renewalPriceSats, phaseAt,
} from "../src/lib/registry-lifecycle.js";
import { unlockScript, assemble, p2sh32Lock, selectorsOf, PUBKEY, SIG } from "../src/lib/covenant.js";
import { NAME_REGISTRY } from "../src/lib/name-registry-artifact.js";
const { hexToBin, binToHex, hash160, secp256k1, encodeLockingBytecodeP2pkh, generatePrivateKey, flattenBinArray } = L;
@ -65,7 +67,7 @@ const IDLE_END = EXPIRY + GRACE_BLOCKS;
const state0 = { ownerPkh: owner.pkh, expiry: EXPIRY };
const certToken = (name = NAME) => ({ category: CATEGORY, amount: 0n, nft: { capability: "none", commitment: binToHex(new TextEncoder().encode(name)) } });
const covUtxo = (state = state0, name = NAME) => ({ txid: fakeTxid(1), vout: 0, satoshis: 1000n, lockingBytecode: registryLock(terms, state), token: certToken(name) });
const SALT = hexToBin("00112233445566778899aabbccddeeff");
const SALT = hexToBin("00112233445566778899aabbccddeeff".repeat(2)); // 32 bytes: the contract requires exactly that
const boxUtxo = (who = taker, name = NAME, salt = SALT) => ({ txid: fakeTxid(7), vout: 0, satoshis: 1000n, lockingBytecode: commitBoxLock({ ownerPkh: who.pkh, commitHash: commitHashFor(name, salt) }) });
const outLockHex = (built, i) => binToHex(built.transaction.outputs[i].lockingBytecode);
@ -184,7 +186,7 @@ test("takeover needs the taker's own matured commit for this name", () => {
accepts(takeover(EXPIRY + 10), "with the commit");
rejects(() => takeover(EXPIRY + 10, { box: { ...boxUtxo(), lockingBytecode: taker.lock } }), "input 1 is a plain coin, not a commit box");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, "other.x") }), "a commit made for another name");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, NAME, hexToBin("ff".repeat(16))) }), "a commit made with another salt");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(taker, NAME, hexToBin("ff".repeat(32))) }), "a commit made with another salt");
rejects(() => takeover(EXPIRY + 10, { box: boxUtxo(stranger) }), "someone else's commit");
rejects(() => takeover(EXPIRY + 10, { tamper: (tx) => ({ ...tx, inputs: tx.inputs.map((inp, i) => (i === 1 ? { ...inp, sequenceNumber: 0 } : inp)) }) }), "a commit spent in the block it was made in");
rejects(() => takeover(EXPIRY + 10, { tamper: setOut(0, () => ({ lockingBytecode: registryLock(terms, { ownerPkh: stranger.pkh, expiry: EXPIRY + 10 + BLOCKS_PER_YEAR }) })) }), "the name goes to a key the commit was not made for");
@ -213,26 +215,88 @@ test("release destroys the certificate; it cannot be used to take it out", () =>
// ---------- migrate ----------
const NEW_CODE = flattenBinArray([Uint8Array.of(0x51), new TextEncoder().encode("registry v2 code")]);
const NEW_STATE = flattenBinArray([Uint8Array.of(0x14), hexToBin(owner.pkh)]);
const approval = signMigrationApproval(steward.priv, NEW_CODE);
const migrate = (o = {}) => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
// A stand-in for a newer version's CODE. It runs behind the 26 state bytes
// (two pushes), which it drops; then it is spendable by the owner's key.
const NEW_CODE = flattenBinArray([Uint8Array.of(0x6d), Uint8Array.of(0x21), owner.pub, Uint8Array.of(0xac)]); // OP_2DROP <pub> OP_CHECKSIG
const approval = signMigrationApproval(steward.priv, terms, NEW_CODE);
const migrate = (o = {}) => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, approval, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner), ...o });
test("migrate: owner AND steward — neither can move a name alone", () => {
test("migrate: owner AND steward — neither can move a name alone, and the state carries over", () => {
const built = migrate();
accepts(built, "migrate");
assert.equal(outLockHex(built, 0), binToHex(p2sh32Lock(flattenBinArray([NEW_STATE, NEW_CODE]))));
// the same approval serves any state: it covers the code, signed once for everyone
accepts(migrate({ newState: flattenBinArray([Uint8Array.of(0x14), hexToBin(buyer.pkh)]) }), "another state under the same approved code");
assert.equal(outLockHex(built, 0), binToHex(migratedLock(state0, NEW_CODE)), "same owner, same expiry, in front of the approved code");
rejects(() => migrate({ approval: signMigrationApproval(stranger.priv, NEW_CODE) }), "code approved by someone who is not the steward");
rejects(() => migrate({ approval: signMigrationApproval(stranger.priv, terms, NEW_CODE) }), "code approved by someone who is not the steward");
rejects(() => migrate({ newCode: flattenBinArray([NEW_CODE, Uint8Array.of(0x00)]) }), "code the steward did not sign");
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, Uint8Array.of(0x51)) }), "an approval for different code");
rejects(() => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, newState: NEW_STATE, approval, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "the steward's approval without the owner");
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, terms, Uint8Array.of(0x51)) }), "an approval for different code");
rejects(() => buildRegistryMigrateTx({ cov: covUtxo(), terms, state: state0, newCode: NEW_CODE, approval, coins: [coin(2, stranger)], changeLock: stranger.lock, keyFor: () => ({ privateKey: stranger.priv, publicKey: stranger.pub }) }), "the steward's approval without the owner");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: owner.lock })) }), "certificate sent to a plain address under cover of a migration");
rejects(() => migrate({ tamper: setOut(0, () => ({ token: undefined })) }), "certificate dropped");
});
test("migrate: the owner chooses nothing — no script in front of the code, no new expiry, no new owner", () => {
// The first draft let the owner supply the bytes placed in front of the
// approved code. An owner could put their own signature check there and a
// push opcode that swallowed the approved code as data: the certificate left
// the covenant under any approval the steward had ever made. The contract
// now rebuilds the state itself, so every one of these targets is refused.
const push = (b) => (b.length <= 75 ? flattenBinArray([Uint8Array.of(b.length), b]) : flattenBinArray([Uint8Array.of(0x4c, b.length), b]));
const swallowed = flattenBinArray([Uint8Array.of(0x21), owner.pub, Uint8Array.of(0xad), push(NEW_CODE)]); // <pub> OP_CHECKSIGVERIFY PUSH(approved code)
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: p2sh32Lock(swallowed) })) }), "the approved code wrapped as data behind the owner's own signature check");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: migratedLock({ ownerPkh: owner.pkh, expiry: 0x7ffffffe }, NEW_CODE) })) }), "a migration that also grants itself forty thousand years");
rejects(() => migrate({ tamper: setOut(0, () => ({ lockingBytecode: migratedLock({ ownerPkh: buyer.pkh, expiry: EXPIRY }, NEW_CODE) })) }), "a migration that also changes the owner");
});
test("migrate: an approval is for one covenant and is worthless in another", () => {
// Same steward, same new code — but the approval was made for tier 4's covenant.
const otherTerms = registryTerms({ tier: 4, fundLock: fund.lock, stewardPk: steward.pub });
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, otherTerms, NEW_CODE) }), "an approval made for another tier's covenant");
const otherFund = registryTerms({ tier: TIER, fundLock: stranger.lock, stewardPk: steward.pub });
rejects(() => migrate({ approval: signMigrationApproval(steward.priv, otherFund, NEW_CODE) }), "an approval made for a covenant with another fund (another network's, say)");
});
// ---------- bounds the review asked for ----------
test("renew cannot push the expiry past the range of block heights", () => {
const nearEnd = { ownerPkh: owner.pkh, expiry: 500_000_000 - BLOCKS_PER_YEAR };
const cov = covUtxo(nearEnd);
// one more year would be exactly 500,000,000: read as a timestamp, never takeable again
rejects(() => buildRegistryRenewTx({ cov, terms, state: nearEnd, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner) }), "expiry of exactly 500,000,000");
const justUnder = { ownerPkh: owner.pkh, expiry: 500_000_000 - BLOCKS_PER_YEAR - 1 };
accepts(buildRegistryRenewTx({ cov: covUtxo(justUnder), terms, state: justUnder, years: 1, coins: [coin(2, owner)], changeLock: owner.lock, keyFor: keyring(owner) }), "expiry of 499,999,999");
});
test("a commit is for one name: the salt has a fixed size, so nothing can slide between name and salt", () => {
assert.equal(COMMIT_SALT_BYTES, 32);
assert.throws(() => commitHashFor(NAME, hexToBin("00".repeat(16))), /exactly 32 bytes/);
// The ambiguity, with real bytes: hash256("covenant" ‖ ".x" ‖ S) is one hash,
// whether you call the name "covenant" with salt ".x"‖S or "covenant.x" with salt S.
// Build a takeover of "covenant.x" from a box made as if for a 34-byte salt on a shorter name,
// by hand (the library refuses to), and confirm the CONTRACT refuses the odd-sized salt.
const longSalt = flattenBinArray([new TextEncoder().encode("xx"), SALT]); // 34 bytes
const { hash256 } = L;
const commitHash = hash256(flattenBinArray([new TextEncoder().encode(NAME), longSalt]));
const boxRedeem = commitBoxRedeem({ ownerPkh: taker.pkh, commitHash });
const box = { txid: fakeTxid(7), vout: 0, satoshis: 1000n, lockingBytecode: p2sh32Lock(boxRedeem) };
const height = IDLE_END, quote = takeoverQuote(terms, state0, height);
const redeem = registryRedeem(terms, state0);
const FN = selectorsOf(NAME_REGISTRY);
const takerKey = { privateKey: taker.priv, publicKey: taker.pub };
const cov = covUtxo();
rejects(() => assemble({
special: [
{ utxo: cov, unlock: () => unlockScript({ selector: FN.takeover, redeem, args: [hexToBin(taker.pkh), longSalt] }) },
{ utxo: box, sequence: 1, unlock: (transaction, sourceOutputs, inputIndex, final) => unlockScript({ redeem: boxRedeem, transaction, sourceOutputs, inputIndex, key: final ? takerKey : null, args: [PUBKEY, SIG] }) },
],
outputs: [
{ lockingBytecode: registryLock(terms, { ownerPkh: taker.pkh, expiry: quote.newExpiry }), valueSatoshis: 1000n, token: { amount: 0n, category: hexToBin(CATEGORY), nft: { capability: "none", commitment: new TextEncoder().encode(NAME) } } },
{ lockingBytecode: fund.lock, valueSatoshis: quote.fundSats },
],
coins: [coin(3, taker, 50_000_000_000n)], changeLock: taker.lock, keyFor: keyring(taker), locktime: height,
}), "a takeover with a 34-byte salt, everything else in order");
});
// ---------- a life ----------
test("a name's life: renew, sell, lapse, be taken over, be renewed by its new owner", () => {
@ -262,5 +326,5 @@ test("a name's life: renew, sell, lapse, be taken over, be renewed by its new ow
test("every negative case was refused by the contract itself, not by the library", () => {
assert.deepEqual(refusedByLibrary.sort(), ["zero years"], "only these may be stopped before the VM runs");
assert.ok(refusedByVm >= 45, `expected the VM to refuse at least 45 tampered transactions, it refused ${refusedByVm}`);
assert.ok(refusedByVm >= 54, `expected the VM to refuse at least 54 tampered transactions, it refused ${refusedByVm}`);
});