|
| 1 | +import * as assert from "assert"; |
| 2 | +import * as crypto from "crypto"; |
| 3 | +import { Descriptor } from "../js/index.js"; |
| 4 | +import { getUnspendableKey } from "../js/testutils/descriptor/descriptors.js"; |
| 5 | + |
| 6 | +// sBTC protocol uses two taproot script leaves: |
| 7 | +// 1. Deposit leaf: allows the signers to spend with a protocol payload |
| 8 | +// 2. Reclaim leaf: allows the depositors to reclaim after a timelock |
| 9 | + |
| 10 | +const SIGNERS_KEY = "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421"; |
| 11 | + |
| 12 | +// BIP341 "nothing up my sleeve" unspendable internal key — used so the taproot address |
| 13 | +// can only be spent via script path (no key-path spend). |
| 14 | +const UNSPENDABLE_KEY = getUnspendableKey(); |
| 15 | + |
| 16 | +const DEPOSIT_LEAF = |
| 17 | + "c:and_v(payload_drop(" + |
| 18 | + "0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" + |
| 19 | + "),pk_k(" + |
| 20 | + SIGNERS_KEY + |
| 21 | + "))"; |
| 22 | + |
| 23 | +const RECLAIM_LEAF = |
| 24 | + "and_v(r:older(1),multi_a(2," + |
| 25 | + "4d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1e," + |
| 26 | + "639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943," + |
| 27 | + "d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2))"; |
| 28 | + |
| 29 | +// Reference vectors from rust-miniscript test_payload_drop_stacks_vectors. |
| 30 | +// Deposit leaf: OP_PUSHBYTES_30 <metadata> OP_DROP OP_PUSHBYTES_32 <key> OP_CHECKSIG |
| 31 | +const DEPOSIT_SCRIPT_HEX = |
| 32 | + "1e0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" + |
| 33 | + "7520c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421ac"; |
| 34 | +const DEPOSIT_LEAF_HASH = "b14bbf1c6699b64429be4f11e1d4df7b75f16f68e7a86cb91c58daf024d0b379"; |
| 35 | +// Reclaim leaf: OP_1 OP_CSV OP_DROP + 2-of-3 multi_a |
| 36 | +const RECLAIM_SCRIPT_HEX = |
| 37 | + "51b275" + |
| 38 | + "204d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1eac" + |
| 39 | + "20639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943ba" + |
| 40 | + "20d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2ba529c"; |
| 41 | +const RECLAIM_LEAF_HASH = "1e379caf8335dc3bd0af785d32d8135647ffa2ee76dd2c1bcc663ff424602ac0"; |
| 42 | +// P2TR output: OP_1 OP_PUSHBYTES_32 <tweaked-x-only-pubkey> |
| 43 | +const SCRIPT_PUBKEY_HEX = "5120f3b3930e1e7103753b62e5cfee821b5bfa942eacb868e1d625243df606882dff"; |
| 44 | + |
| 45 | +// BIP341 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || data) |
| 46 | +function taggedHash(tag: string, data: Buffer): Buffer { |
| 47 | + const tagHash = crypto.createHash("sha256").update(tag).digest(); |
| 48 | + return crypto |
| 49 | + .createHash("sha256") |
| 50 | + .update(Buffer.concat([tagHash, tagHash, data])) |
| 51 | + .digest(); |
| 52 | +} |
| 53 | + |
| 54 | +// BIP341 tap leaf hash: tagged_hash("TapLeaf", version || compact_size(len) || script) |
| 55 | +// version 0xc0 = TapScript; compact_size is a single byte for scripts shorter than 253 bytes. |
| 56 | +function tapLeafHash(scriptHex: string): string { |
| 57 | + const script = Buffer.from(scriptHex, "hex"); |
| 58 | + const data = Buffer.concat([Buffer.from([0xc0, script.length]), script]); |
| 59 | + return taggedHash("TapLeaf", data).toString("hex"); |
| 60 | +} |
| 61 | + |
| 62 | +function getSbtcDescriptor(depositLeaf: string, reclaimLeaf: string) { |
| 63 | + return `tr(${UNSPENDABLE_KEY},{${depositLeaf},${reclaimLeaf}})`; |
| 64 | +} |
| 65 | + |
| 66 | +// Types matching the node() structure for the sBTC taproot descriptor |
| 67 | +type DefiniteKey = { Single: string }; |
| 68 | + |
| 69 | +type SbtcDepositLeaf = { |
| 70 | + Check: { |
| 71 | + AndV: [{ PayloadDrop: string }, { PkK: DefiniteKey }]; |
| 72 | + }; |
| 73 | +}; |
| 74 | + |
| 75 | +type SbtcReclaimLeaf = { |
| 76 | + AndV: [{ Drop: { Older: { relLockTime: number } } }, { MultiA: DefiniteKey[] }]; |
| 77 | +}; |
| 78 | + |
| 79 | +type SbtcDescriptorNode = { |
| 80 | + Tr: [DefiniteKey, { Tree: [SbtcDepositLeaf, SbtcReclaimLeaf] }]; |
| 81 | +}; |
| 82 | + |
| 83 | +describe("sBTC taproot descriptor", function () { |
| 84 | + // Use fromStringExt with { drop: true } to enable r:older() in taproot |
| 85 | + const descriptor = Descriptor.fromString( |
| 86 | + getSbtcDescriptor(DEPOSIT_LEAF, RECLAIM_LEAF), |
| 87 | + "definite", |
| 88 | + ); |
| 89 | + |
| 90 | + it("parses successfully with fromStringExt", () => { |
| 91 | + // Key test: Descriptor.fromStringExt({ drop: true }) handles r:older() with targeted drop permission |
| 92 | + assert.ok(descriptor, "Descriptor should parse successfully"); |
| 93 | + }); |
| 94 | + |
| 95 | + it("has expected taproot structure", () => { |
| 96 | + const node = descriptor.node() as SbtcDescriptorNode; |
| 97 | + // Definite descriptors wrap keys in { Single: "..." } |
| 98 | + assert.deepStrictEqual( |
| 99 | + node.Tr[0], |
| 100 | + { Single: UNSPENDABLE_KEY }, |
| 101 | + "Should have correct internal key", |
| 102 | + ); |
| 103 | + assert.ok(node.Tr[1].Tree, "Should have taproot tree structure"); |
| 104 | + assert.strictEqual(node.Tr[1].Tree.length, 2, "Should have two leaves"); |
| 105 | + }); |
| 106 | + |
| 107 | + describe("deposit leaf", function () { |
| 108 | + it("has correct structure with payload_drop", () => { |
| 109 | + const node = descriptor.node() as SbtcDescriptorNode; |
| 110 | + const depositLeaf = node.Tr[1].Tree[0]; |
| 111 | + |
| 112 | + assert.deepStrictEqual(depositLeaf, { |
| 113 | + Check: { |
| 114 | + AndV: [ |
| 115 | + { PayloadDrop: "0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" }, |
| 116 | + { PkK: { Single: "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421" } }, |
| 117 | + ], |
| 118 | + }, |
| 119 | + }); |
| 120 | + }); |
| 121 | + |
| 122 | + it("has correct script hex and tap leaf hash", () => { |
| 123 | + assert.strictEqual(tapLeafHash(DEPOSIT_SCRIPT_HEX), DEPOSIT_LEAF_HASH); |
| 124 | + }); |
| 125 | + }); |
| 126 | + |
| 127 | + describe("reclaim leaf", function () { |
| 128 | + it("has correct structure with r:older (Drop wrapper)", () => { |
| 129 | + const node = descriptor.node() as SbtcDescriptorNode; |
| 130 | + const reclaimLeaf = node.Tr[1].Tree[1]; |
| 131 | + |
| 132 | + // Verify the r:older pattern creates a Drop wrapper |
| 133 | + assert.ok(reclaimLeaf.AndV, "Should have AndV structure"); |
| 134 | + assert.ok(reclaimLeaf.AndV[0].Drop, "Should have Drop wrapper for r:older"); |
| 135 | + assert.ok(reclaimLeaf.AndV[0].Drop.Older, "Should contain Older inside Drop"); |
| 136 | + assert.strictEqual( |
| 137 | + reclaimLeaf.AndV[0].Drop.Older.relLockTime, |
| 138 | + 1, |
| 139 | + "Should have locktime of 1", |
| 140 | + ); |
| 141 | + |
| 142 | + // Verify the multi_a is the second part |
| 143 | + assert.ok(reclaimLeaf.AndV[1].MultiA, "Should have MultiA as second element"); |
| 144 | + }); |
| 145 | + |
| 146 | + it("has correct script hex and tap leaf hash", () => { |
| 147 | + assert.strictEqual(tapLeafHash(RECLAIM_SCRIPT_HEX), RECLAIM_LEAF_HASH); |
| 148 | + }); |
| 149 | + }); |
| 150 | + |
| 151 | + describe("P2TR output", function () { |
| 152 | + it("produces correct script pubkey", () => { |
| 153 | + const scriptPubkeyBytes = descriptor.scriptPubkey(); |
| 154 | + assert.strictEqual(Buffer.from(scriptPubkeyBytes).toString("hex"), SCRIPT_PUBKEY_HEX); |
| 155 | + }); |
| 156 | + }); |
| 157 | +}); |
0 commit comments