|
| 1 | +/** |
| 2 | + * Pearl cross-validation against live-node vectors. |
| 3 | + * |
| 4 | + * Every constant below was produced by a real `pearld` 1.0.2 regtest node during |
| 5 | + * sandboxing (BitGo/coins-sandbox#898) and is quoted from |
| 6 | + * `prl/wasm-utxo-fixtures.json`, `prl/indexer-utxo-check.json` and |
| 7 | + * `prl/prl_multisig_report.md`. |
| 8 | + * |
| 9 | + * The point of this file is to check our library against *external* output rather |
| 10 | + * than against itself. `transactionFlow.ts` proves the PSBT lifecycle is |
| 11 | + * internally consistent; it cannot prove a pearld node would accept the bytes, |
| 12 | + * because the transactions it builds reference txids that never existed. These |
| 13 | + * vectors close part of that gap. |
| 14 | + * |
| 15 | + * What is genuinely node-verified here: |
| 16 | + * |
| 17 | + * - deserializing a real pearld transaction and computing the node's own txid |
| 18 | + * - encoding a real node scriptPubKey to an address, and restoring it exactly |
| 19 | + * - the witness shape the node accepted for a 2-of-3 taproot script-path spend |
| 20 | + * |
| 21 | + * What is NOT covered, and why: see `KNOWN DIVERGENCE` below. The sandbox spends |
| 22 | + * used a hand-rolled taptree with a NUMS internal key, which is *not* the taptree |
| 23 | + * BitGo's `p2tr` chain builds. Their signatures and leaf scripts therefore cannot |
| 24 | + * be compared against ours. |
| 25 | + */ |
| 26 | +import assert from 'node:assert/strict'; |
| 27 | +import { createHash } from 'node:crypto'; |
| 28 | + |
| 29 | +import { address, BIP32, fixedScriptWallet, Transaction } from '@bitgo/wasm-utxo'; |
| 30 | + |
| 31 | +/* ------------------------------------------------------------------------- * |
| 32 | + * Vectors captured from the live pearld regtest node (coins-sandbox#898) |
| 33 | + * ------------------------------------------------------------------------- */ |
| 34 | + |
| 35 | +/** Coinbase transaction, verbatim from `indexer-utxo-check.json` -> rpcMethodChecks[15].sample */ |
| 36 | +const NODE_COINBASE_HEX = |
| 37 | + '010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff1051000d2f' + |
| 38 | + '503253482f706561726c642fffffffff02af9829324b000000225120ca4c6e0c33e27b9897807a300e023b85d5e1ddbd' + |
| 39 | + 'e2872bc4fb4966dfdd3fb1650000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c69068979' + |
| 40 | + '9962b48bebd836974e8cf901200000000000000000000000000000000000000000000000000000000000000000000000' + |
| 41 | + '00'; |
| 42 | + |
| 43 | +/** The txid the node itself reported for the above (prl_multisig_report.md:100). */ |
| 44 | +const NODE_COINBASE_TXID = '272e5b8aee44eb4e65ab98979647f8e0e2f9a441521f3b4fad4aba91f675d295'; |
| 45 | + |
| 46 | +/** |
| 47 | + * Real p2tr outputs from the node, as `[scriptType, scriptPubKey, address]` |
| 48 | + * (`wasm-utxo-fixtures.json` -> addressFixtures). Addresses use the regtest `rprl` |
| 49 | + * HRP; wasm-utxo has no Pearl regtest CoinName, so the comparison below is on the |
| 50 | + * bech32m *data part*, which is HRP-independent. |
| 51 | + */ |
| 52 | +const NODE_P2TR_OUTPUTS = [ |
| 53 | + { |
| 54 | + scriptPubKey: '5120ca4c6e0c33e27b9897807a300e023b85d5e1ddbde2872bc4fb4966dfdd3fb165', |
| 55 | + regtestAddress: 'rprl1pefxxurpnufae39uq0gcquq3msh27rhdau2rjh38mf9ndlhflk9jsk5zncq', |
| 56 | + }, |
| 57 | + { |
| 58 | + scriptPubKey: '51208e972a76a884de6a67fe7d6c97cd8dca6074fc17f2a64a5b8592f9376dda07b4', |
| 59 | + regtestAddress: 'rprl1p36tj5a4gsn0x5el704kf0nvdefs8flqh72ny5ku9jtunwmw6q76qhq6549', |
| 60 | + }, |
| 61 | +] as const; |
| 62 | + |
| 63 | +/** |
| 64 | + * Witness shape the node accepted for a 2-of-3 taproot script-path spend |
| 65 | + * (`wasm-utxo-fixtures.json` -> psbtSample, corroborated by prl_multisig_report.md). |
| 66 | + */ |
| 67 | +const NODE_WITNESS = { |
| 68 | + stackSize: 4, |
| 69 | + sigLength: 64, // BIP-340 Schnorr, no trailing sighash byte |
| 70 | + controlBlockLength: 65, // 1 byte version|parity + 32 byte internal key + 32 byte merkle proof |
| 71 | + leafVersion: 0xc0, |
| 72 | +} as const; |
| 73 | + |
| 74 | +/* ------------------------------------------------------------------------- * |
| 75 | + * Test wallet - our side of the comparison |
| 76 | + * ------------------------------------------------------------------------- */ |
| 77 | + |
| 78 | +const roots = ['pearl-e2e-user', 'pearl-e2e-backup', 'pearl-e2e-bitgo'].map((seed) => BIP32.fromSeedSha256(seed)); |
| 79 | +const xprvs = roots.map((k) => k.toBase58()) as [string, string, string]; |
| 80 | +const xpubs = roots.map((k) => k.neutered().toBase58()) as [string, string, string]; |
| 81 | +const walletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(xpubs); |
| 82 | + |
| 83 | +/* ------------------------------------------------------------------------- * |
| 84 | + * Minimal segwit witness reader |
| 85 | + * ------------------------------------------------------------------------- */ |
| 86 | + |
| 87 | +function readVarInt(buf: Buffer, offset: number): [number, number] { |
| 88 | + const n = buf[offset]; |
| 89 | + if (n < 0xfd) return [n, offset + 1]; |
| 90 | + if (n === 0xfd) return [buf.readUInt16LE(offset + 1), offset + 3]; |
| 91 | + if (n === 0xfe) return [buf.readUInt32LE(offset + 1), offset + 5]; |
| 92 | + return [Number(buf.readBigUInt64LE(offset + 1)), offset + 9]; |
| 93 | +} |
| 94 | + |
| 95 | +/** Witness stack of the first input of a segwit transaction. */ |
| 96 | +function witnessOf(raw: Buffer): Buffer[] { |
| 97 | + let o = 4; // version |
| 98 | + assert.strictEqual(raw[o], 0x00, 'expected segwit marker'); |
| 99 | + assert.strictEqual(raw[o + 1], 0x01, 'expected segwit flag'); |
| 100 | + o += 2; |
| 101 | + |
| 102 | + const [inputCount, afterInputCount] = readVarInt(raw, o); |
| 103 | + o = afterInputCount; |
| 104 | + for (let i = 0; i < inputCount; i++) { |
| 105 | + o += 36; // txid + vout |
| 106 | + const [scriptLen, afterScript] = readVarInt(raw, o); |
| 107 | + o = afterScript + scriptLen + 4; // scriptSig + sequence |
| 108 | + } |
| 109 | + |
| 110 | + const [outputCount, afterOutputCount] = readVarInt(raw, o); |
| 111 | + o = afterOutputCount; |
| 112 | + for (let i = 0; i < outputCount; i++) { |
| 113 | + o += 8; // value |
| 114 | + const [scriptLen, afterScript] = readVarInt(raw, o); |
| 115 | + o = afterScript + scriptLen; |
| 116 | + } |
| 117 | + |
| 118 | + const [itemCount, afterItemCount] = readVarInt(raw, o); |
| 119 | + o = afterItemCount; |
| 120 | + const items: Buffer[] = []; |
| 121 | + for (let i = 0; i < itemCount; i++) { |
| 122 | + const [len, afterLen] = readVarInt(raw, o); |
| 123 | + items.push(raw.subarray(afterLen, afterLen + len)); |
| 124 | + o = afterLen + len; |
| 125 | + } |
| 126 | + return items; |
| 127 | +} |
| 128 | + |
| 129 | +/** A fully-signed 1-in/1-out Pearl p2tr script-path spend. */ |
| 130 | +function signedSpend(signer: 'user' | 'backup', cosigner: 'user' | 'backup' | 'bitgo') { |
| 131 | + const keyFor = { user: xprvs[0], backup: xprvs[1], bitgo: xprvs[2] }; |
| 132 | + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('pearl', walletKeys); |
| 133 | + psbt.addWalletInput( |
| 134 | + { txid: createHash('sha256').update(`${signer}-${cosigner}`).digest('hex'), vout: 0, value: 100_000n }, |
| 135 | + walletKeys, |
| 136 | + { scriptId: { chain: 30, index: 0 }, signPath: { signer, cosigner } } |
| 137 | + ); |
| 138 | + psbt.addWalletOutput(walletKeys, { chain: 31, index: 0, value: 90_000n }); |
| 139 | + psbt.sign(keyFor[signer]); |
| 140 | + psbt.sign(keyFor[cosigner]); |
| 141 | + psbt.finalizeAllInputs(); |
| 142 | + return Buffer.from(psbt.extractTransaction().toBytes()); |
| 143 | +} |
| 144 | + |
| 145 | +describe('Pearl - cross-validation against live-node vectors', function () { |
| 146 | + describe('transaction deserialization', function () { |
| 147 | + it("computes the node's own txid for a real pearld transaction", function () { |
| 148 | + // The strongest check available offline: the node published both the raw |
| 149 | + // bytes and the txid, so agreement is genuinely external. |
| 150 | + const tx = Transaction.fromBytes(Buffer.from(NODE_COINBASE_HEX, 'hex'), 'pearl'); |
| 151 | + assert.strictEqual(tx.getId(), NODE_COINBASE_TXID); |
| 152 | + }); |
| 153 | + |
| 154 | + it('round-trips the node transaction byte-for-byte', function () { |
| 155 | + const raw = Buffer.from(NODE_COINBASE_HEX, 'hex'); |
| 156 | + const tx = Transaction.fromBytes(raw, 'pearl'); |
| 157 | + assert.deepStrictEqual(Buffer.from(tx.toBytes()), raw); |
| 158 | + }); |
| 159 | + |
| 160 | + it('reads the taproot output the node created', function () { |
| 161 | + const raw = Buffer.from(NODE_COINBASE_HEX, 'hex'); |
| 162 | + // The coinbase pays to the first fixture scriptPubKey. |
| 163 | + assert.ok( |
| 164 | + raw.toString('hex').includes(NODE_P2TR_OUTPUTS[0].scriptPubKey), |
| 165 | + 'coinbase should contain the known p2tr output script' |
| 166 | + ); |
| 167 | + }); |
| 168 | + }); |
| 169 | + |
| 170 | + describe('address encoding', function () { |
| 171 | + for (const { scriptPubKey, regtestAddress } of NODE_P2TR_OUTPUTS) { |
| 172 | + it(`matches the node's bech32m data part for ${scriptPubKey.slice(0, 16)}...`, function () { |
| 173 | + // bech32m is `hrp` + `1` + data + checksum. The HRP and checksum differ |
| 174 | + // between regtest and mainnet by construction, but the data part encodes |
| 175 | + // the witness program alone, so it must match the node exactly. |
| 176 | + const nodeDataPart = regtestAddress.split('1').slice(1).join('1').slice(0, -6); |
| 177 | + |
| 178 | + for (const coinName of ['pearl', 'tpearl'] as const) { |
| 179 | + const ours = address.fromOutputScriptWithCoin(Buffer.from(scriptPubKey, 'hex'), coinName); |
| 180 | + const ourDataPart = ours.split('1').slice(1).join('1').slice(0, -6); |
| 181 | + assert.strictEqual(ourDataPart, nodeDataPart, `${coinName}: bech32m data part must match the node`); |
| 182 | + } |
| 183 | + }); |
| 184 | + |
| 185 | + it(`restores the node's exact scriptPubKey for ${scriptPubKey.slice(0, 16)}...`, function () { |
| 186 | + for (const coinName of ['pearl', 'tpearl'] as const) { |
| 187 | + const ours = address.fromOutputScriptWithCoin(Buffer.from(scriptPubKey, 'hex'), coinName); |
| 188 | + const restored = Buffer.from(address.toOutputScriptWithCoin(ours, coinName)).toString('hex'); |
| 189 | + assert.strictEqual(restored, scriptPubKey, `${coinName}: round-trip must restore the node scriptPubKey`); |
| 190 | + } |
| 191 | + }); |
| 192 | + } |
| 193 | + |
| 194 | + it('uses the HRPs the node uses, per network', function () { |
| 195 | + const spk = Buffer.from(NODE_P2TR_OUTPUTS[0].scriptPubKey, 'hex'); |
| 196 | + assert.ok(address.fromOutputScriptWithCoin(spk, 'pearl').startsWith('prl1p')); |
| 197 | + assert.ok(address.fromOutputScriptWithCoin(spk, 'tpearl').startsWith('tprl1p')); |
| 198 | + // The node's regtest HRP, for the record - wasm-utxo has no Pearl regtest coin. |
| 199 | + assert.ok(NODE_P2TR_OUTPUTS[0].regtestAddress.startsWith('rprl1p')); |
| 200 | + }); |
| 201 | + }); |
| 202 | + |
| 203 | + describe('witness shape accepted by the node', function () { |
| 204 | + it('produces the stack size and signature lengths the node accepted', function () { |
| 205 | + const witness = witnessOf(signedSpend('user', 'bitgo')); |
| 206 | + |
| 207 | + assert.strictEqual(witness.length, NODE_WITNESS.stackSize, 'witness stack size must match the node'); |
| 208 | + // [sig, sig, leafScript, controlBlock] |
| 209 | + assert.strictEqual(witness[0].length, NODE_WITNESS.sigLength, 'BIP-340 signatures carry no sighash byte'); |
| 210 | + assert.strictEqual(witness[1].length, NODE_WITNESS.sigLength); |
| 211 | + // The node's sample was a user+bitgo spend, i.e. the shallow leaf. |
| 212 | + assert.strictEqual(witness[3].length, NODE_WITNESS.controlBlockLength); |
| 213 | + }); |
| 214 | + |
| 215 | + it('tags the control block with the leaf version the node saw', function () { |
| 216 | + const witness = witnessOf(signedSpend('user', 'bitgo')); |
| 217 | + const controlBlock = witness[3]; |
| 218 | + // Low bit is the output key parity and varies per output; the rest is the |
| 219 | + // tapscript leaf version. |
| 220 | + assert.strictEqual(controlBlock[0] & 0xfe, NODE_WITNESS.leafVersion); |
| 221 | + }); |
| 222 | + |
| 223 | + it('holds the stack size and signature lengths for every taptree leaf', function () { |
| 224 | + for (const [signer, cosigner] of [ |
| 225 | + ['user', 'bitgo'], |
| 226 | + ['user', 'backup'], |
| 227 | + ['backup', 'bitgo'], |
| 228 | + ] as const) { |
| 229 | + const witness = witnessOf(signedSpend(signer, cosigner)); |
| 230 | + assert.strictEqual(witness.length, NODE_WITNESS.stackSize, `${signer}+${cosigner}`); |
| 231 | + assert.strictEqual(witness[0].length, NODE_WITNESS.sigLength, `${signer}+${cosigner}`); |
| 232 | + assert.strictEqual(witness[1].length, NODE_WITNESS.sigLength, `${signer}+${cosigner}`); |
| 233 | + } |
| 234 | + }); |
| 235 | + |
| 236 | + /** |
| 237 | + * Control block size is `1 + 32 + 32 * merkleDepth`, so it reveals where each |
| 238 | + * leaf sits in the tree. The depths below match the taptree the TDD specifies, |
| 239 | + * |
| 240 | + * branch(leaf0[user+bitgo], branch(leaf1[user+backup], leaf2[backup+bitgo])) |
| 241 | + * |
| 242 | + * with user+bitgo shallow and the other two a level deeper. The node's own |
| 243 | + * 65-byte sample was a user+bitgo spend, which is why it saw depth 1. |
| 244 | + */ |
| 245 | + it('places each leaf at the depth the taptree implies', function () { |
| 246 | + const expectedDepth: Record<string, number> = { |
| 247 | + 'user+bitgo': 1, |
| 248 | + 'user+backup': 2, |
| 249 | + 'backup+bitgo': 2, |
| 250 | + }; |
| 251 | + |
| 252 | + for (const [signer, cosigner] of [ |
| 253 | + ['user', 'bitgo'], |
| 254 | + ['user', 'backup'], |
| 255 | + ['backup', 'bitgo'], |
| 256 | + ] as const) { |
| 257 | + const controlBlock = witnessOf(signedSpend(signer, cosigner))[3]; |
| 258 | + const depth = (controlBlock.length - 33) / 32; |
| 259 | + assert.strictEqual(depth, expectedDepth[`${signer}+${cosigner}`], `${signer}+${cosigner} merkle depth`); |
| 260 | + assert.strictEqual(controlBlock.length, 33 + 32 * depth, 'control block must be 1 + 32 + 32*depth bytes'); |
| 261 | + } |
| 262 | + }); |
| 263 | + }); |
| 264 | + |
| 265 | + /** |
| 266 | + * KNOWN DIVERGENCE - the sandbox taptree is not BitGo's taptree. |
| 267 | + * |
| 268 | + * The sandbox spends set the BIP-341 NUMS point |
| 269 | + * `50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0` as the |
| 270 | + * internal key and produced 70-byte leaf scripts. BitGo's `p2tr` chain instead |
| 271 | + * derives an internal key from the wallet keys and produces 68-byte leaves. |
| 272 | + * |
| 273 | + * This is BitGo's house construction rather than anything Pearl-specific - |
| 274 | + * building the same input for `btc` yields a byte-identical witness - but it |
| 275 | + * does mean the sandbox's on-chain-verified addresses and signatures describe a |
| 276 | + * different script than the SDK generates. So they cannot be asserted against |
| 277 | + * our output, and BitGo's actual Pearl taptree has not yet been accepted by a |
| 278 | + * pearld node. |
| 279 | + * |
| 280 | + * The assertions below pin the divergence so it is visible and cannot drift |
| 281 | + * unnoticed. Closing it needs a regtest broadcast of an SDK-built transaction. |
| 282 | + */ |
| 283 | + describe('known divergence from the sandbox taptree', function () { |
| 284 | + const NUMS_INTERNAL_KEY = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; |
| 285 | + |
| 286 | + it('does not use the NUMS internal key the sandbox used', function () { |
| 287 | + const controlBlock = witnessOf(signedSpend('user', 'bitgo'))[3]; |
| 288 | + const internalKey = controlBlock.subarray(1, 33).toString('hex'); |
| 289 | + assert.notStrictEqual( |
| 290 | + internalKey, |
| 291 | + NUMS_INTERNAL_KEY, |
| 292 | + 'if this now matches, BitGo has moved to a NUMS internal key and the sandbox vectors became comparable' |
| 293 | + ); |
| 294 | + }); |
| 295 | + |
| 296 | + it('derives the same internal key for btc and pearl, showing it is not chain-specific', function () { |
| 297 | + const internalKeys = (['btc', 'pearl'] as const).map((coinName) => { |
| 298 | + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty(coinName, walletKeys); |
| 299 | + psbt.addWalletInput( |
| 300 | + { txid: createHash('sha256').update('divergence').digest('hex'), vout: 0, value: 100_000n }, |
| 301 | + walletKeys, |
| 302 | + { scriptId: { chain: 30, index: 0 }, signPath: { signer: 'user', cosigner: 'bitgo' } } |
| 303 | + ); |
| 304 | + psbt.addWalletOutput(walletKeys, { chain: 31, index: 0, value: 90_000n }); |
| 305 | + psbt.sign(xprvs[0]); |
| 306 | + psbt.sign(xprvs[2]); |
| 307 | + psbt.finalizeAllInputs(); |
| 308 | + return witnessOf(Buffer.from(psbt.extractTransaction().toBytes()))[3].subarray(1, 33).toString('hex'); |
| 309 | + }); |
| 310 | + assert.strictEqual(internalKeys[0], internalKeys[1], 'internal key is derived from wallet keys, not the chain'); |
| 311 | + }); |
| 312 | + |
| 313 | + it('produces 68-byte leaf scripts where the sandbox produced 70', function () { |
| 314 | + const witness = witnessOf(signedSpend('user', 'bitgo')); |
| 315 | + assert.strictEqual(witness[2].length, 68, 'BitGo 2-of-2 tapleaf'); |
| 316 | + assert.notStrictEqual(witness[2].length, 70, 'sandbox hand-rolled tapleaf'); |
| 317 | + }); |
| 318 | + }); |
| 319 | +}); |
0 commit comments