From 38e46183fba2a51e79e6f0f6efea5237312367de Mon Sep 17 00:00:00 2001 From: Manoj Kumar Date: Tue, 28 Jul 2026 15:47:01 +0530 Subject: [PATCH] test(abstract-utxo): pearl transaction flow and live-node cross-validation Cover the Pearl (Duplex) fixed-script lifecycle with 42 offline tests across two files. transactionFlow.ts (27 tests) exercises the full PSBT lifecycle - address generation, build, sign, verify, finalize, extract, serialize round-trip, parse - directly through fixedScriptWallet.BitGoPsbt. Pearl has no @bitgo/utxo-lib network, so none of the shared UTXO test helpers apply; AcidTest, testutil.constructPsbt and the utxoCoins fixtures all resolve a network. All three taptree leaves are exercised. Note the canonical signer order is user+bitgo, user+backup and backup+bitgo: wasm-utxo rejects the reversed forms with "Could not find control block for leaf script", so the third leaf is backup+bitgo rather than bitgo+backup as the TDD writes it. verifySignature, not the return of sign(), is used to assert signing succeeded - sign() reports the inputs it touched and returns the input index even for a key outside the chosen leaf. sandboxVectors.ts (15 tests) checks our output against a real pearld 1.0.2 regtest node from sandboxing (BitGo/coins-sandbox PR 898), rather than against itself. It deserializes the node's own raw coinbase transaction and computes the node's own txid; encodes the node's real p2tr scriptPubKeys and matches the node's bech32m data part exactly; and reproduces the witness shape (4 stack items, 64-byte BIP-340 signatures, leaf version 0xc0) and per-leaf taptree depth the node accepted. The sandbox spends used a NUMS internal key and 70-byte leaf scripts; BitGo's p2tr chain derives an internal key from the wallet keys and produces 68-byte leaves. This is BitGo's house construction rather than anything Pearl-specific - the same input built for btc yields a byte-identical witness - but it means the sandbox's on-chain-verified signatures describe a different script than the SDK generates and cannot be asserted against our output. Three tests pin this divergence, since it means BitGo's actual Pearl taptree has not yet been accepted by a pearld node; closing that needs a regtest broadcast of an SDK-built transaction. Both files were mutation-tested rather than trusted on a green run: reversing the taptree leaf order, substituting a taproot chain into the legacy-rejection list, corrupting the node txid, corrupting a node scriptPubKey byte, and using the wrong merkle depth each fail the suite as expected. Broadcasting and p2trMusig2 key-path signing are out of scope. Broadcast needs a live pearld node or Pearl in the BitGo test environment, neither of which exists yet (tracked in the wallet-platform onboarding epic). Musig2 key-path needs MuSig2 nonce rounds and is a later phase per the TDD; those chains are asserted to be buildable and to fail finalization without nonces, so the gap is visible rather than silent. The musig2 case signs before finalizing and asserts verifySignature is false and the finalize error names the missing nonces specifically, so it cannot be confused with a generic missing-signature failure. TICKET: CECHO-1806 Co-Authored-By: Claude Opus 5 (1M context) --- .../unit/impl/pearl/unit/sandboxVectors.ts | 319 ++++++++++++++ .../unit/impl/pearl/unit/transactionFlow.ts | 390 ++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 modules/abstract-utxo/test/unit/impl/pearl/unit/sandboxVectors.ts create mode 100644 modules/abstract-utxo/test/unit/impl/pearl/unit/transactionFlow.ts diff --git a/modules/abstract-utxo/test/unit/impl/pearl/unit/sandboxVectors.ts b/modules/abstract-utxo/test/unit/impl/pearl/unit/sandboxVectors.ts new file mode 100644 index 0000000000..30b3da9e9d --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/pearl/unit/sandboxVectors.ts @@ -0,0 +1,319 @@ +/** + * Pearl cross-validation against live-node vectors. + * + * Every constant below was produced by a real `pearld` 1.0.2 regtest node during + * sandboxing (BitGo/coins-sandbox#898) and is quoted from + * `prl/wasm-utxo-fixtures.json`, `prl/indexer-utxo-check.json` and + * `prl/prl_multisig_report.md`. + * + * The point of this file is to check our library against *external* output rather + * than against itself. `transactionFlow.ts` proves the PSBT lifecycle is + * internally consistent; it cannot prove a pearld node would accept the bytes, + * because the transactions it builds reference txids that never existed. These + * vectors close part of that gap. + * + * What is genuinely node-verified here: + * + * - deserializing a real pearld transaction and computing the node's own txid + * - encoding a real node scriptPubKey to an address, and restoring it exactly + * - the witness shape the node accepted for a 2-of-3 taproot script-path spend + * + * What is NOT covered, and why: see `KNOWN DIVERGENCE` below. The sandbox spends + * used a hand-rolled taptree with a NUMS internal key, which is *not* the taptree + * BitGo's `p2tr` chain builds. Their signatures and leaf scripts therefore cannot + * be compared against ours. + */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { address, BIP32, fixedScriptWallet, Transaction } from '@bitgo/wasm-utxo'; + +/* ------------------------------------------------------------------------- * + * Vectors captured from the live pearld regtest node (coins-sandbox#898) + * ------------------------------------------------------------------------- */ + +/** Coinbase transaction, verbatim from `indexer-utxo-check.json` -> rpcMethodChecks[15].sample */ +const NODE_COINBASE_HEX = + '010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff1051000d2f' + + '503253482f706561726c642fffffffff02af9829324b000000225120ca4c6e0c33e27b9897807a300e023b85d5e1ddbd' + + 'e2872bc4fb4966dfdd3fb1650000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c69068979' + + '9962b48bebd836974e8cf901200000000000000000000000000000000000000000000000000000000000000000000000' + + '00'; + +/** The txid the node itself reported for the above (prl_multisig_report.md:100). */ +const NODE_COINBASE_TXID = '272e5b8aee44eb4e65ab98979647f8e0e2f9a441521f3b4fad4aba91f675d295'; + +/** + * Real p2tr outputs from the node, as `[scriptType, scriptPubKey, address]` + * (`wasm-utxo-fixtures.json` -> addressFixtures). Addresses use the regtest `rprl` + * HRP; wasm-utxo has no Pearl regtest CoinName, so the comparison below is on the + * bech32m *data part*, which is HRP-independent. + */ +const NODE_P2TR_OUTPUTS = [ + { + scriptPubKey: '5120ca4c6e0c33e27b9897807a300e023b85d5e1ddbde2872bc4fb4966dfdd3fb165', + regtestAddress: 'rprl1pefxxurpnufae39uq0gcquq3msh27rhdau2rjh38mf9ndlhflk9jsk5zncq', + }, + { + scriptPubKey: '51208e972a76a884de6a67fe7d6c97cd8dca6074fc17f2a64a5b8592f9376dda07b4', + regtestAddress: 'rprl1p36tj5a4gsn0x5el704kf0nvdefs8flqh72ny5ku9jtunwmw6q76qhq6549', + }, +] as const; + +/** + * Witness shape the node accepted for a 2-of-3 taproot script-path spend + * (`wasm-utxo-fixtures.json` -> psbtSample, corroborated by prl_multisig_report.md). + */ +const NODE_WITNESS = { + stackSize: 4, + sigLength: 64, // BIP-340 Schnorr, no trailing sighash byte + controlBlockLength: 65, // 1 byte version|parity + 32 byte internal key + 32 byte merkle proof + leafVersion: 0xc0, +} as const; + +/* ------------------------------------------------------------------------- * + * Test wallet - our side of the comparison + * ------------------------------------------------------------------------- */ + +const roots = ['pearl-e2e-user', 'pearl-e2e-backup', 'pearl-e2e-bitgo'].map((seed) => BIP32.fromSeedSha256(seed)); +const xprvs = roots.map((k) => k.toBase58()) as [string, string, string]; +const xpubs = roots.map((k) => k.neutered().toBase58()) as [string, string, string]; +const walletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(xpubs); + +/* ------------------------------------------------------------------------- * + * Minimal segwit witness reader + * ------------------------------------------------------------------------- */ + +function readVarInt(buf: Buffer, offset: number): [number, number] { + const n = buf[offset]; + if (n < 0xfd) return [n, offset + 1]; + if (n === 0xfd) return [buf.readUInt16LE(offset + 1), offset + 3]; + if (n === 0xfe) return [buf.readUInt32LE(offset + 1), offset + 5]; + return [Number(buf.readBigUInt64LE(offset + 1)), offset + 9]; +} + +/** Witness stack of the first input of a segwit transaction. */ +function witnessOf(raw: Buffer): Buffer[] { + let o = 4; // version + assert.strictEqual(raw[o], 0x00, 'expected segwit marker'); + assert.strictEqual(raw[o + 1], 0x01, 'expected segwit flag'); + o += 2; + + const [inputCount, afterInputCount] = readVarInt(raw, o); + o = afterInputCount; + for (let i = 0; i < inputCount; i++) { + o += 36; // txid + vout + const [scriptLen, afterScript] = readVarInt(raw, o); + o = afterScript + scriptLen + 4; // scriptSig + sequence + } + + const [outputCount, afterOutputCount] = readVarInt(raw, o); + o = afterOutputCount; + for (let i = 0; i < outputCount; i++) { + o += 8; // value + const [scriptLen, afterScript] = readVarInt(raw, o); + o = afterScript + scriptLen; + } + + const [itemCount, afterItemCount] = readVarInt(raw, o); + o = afterItemCount; + const items: Buffer[] = []; + for (let i = 0; i < itemCount; i++) { + const [len, afterLen] = readVarInt(raw, o); + items.push(raw.subarray(afterLen, afterLen + len)); + o = afterLen + len; + } + return items; +} + +/** A fully-signed 1-in/1-out Pearl p2tr script-path spend. */ +function signedSpend(signer: 'user' | 'backup', cosigner: 'user' | 'backup' | 'bitgo') { + const keyFor = { user: xprvs[0], backup: xprvs[1], bitgo: xprvs[2] }; + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('pearl', walletKeys); + psbt.addWalletInput( + { txid: createHash('sha256').update(`${signer}-${cosigner}`).digest('hex'), vout: 0, value: 100_000n }, + walletKeys, + { scriptId: { chain: 30, index: 0 }, signPath: { signer, cosigner } } + ); + psbt.addWalletOutput(walletKeys, { chain: 31, index: 0, value: 90_000n }); + psbt.sign(keyFor[signer]); + psbt.sign(keyFor[cosigner]); + psbt.finalizeAllInputs(); + return Buffer.from(psbt.extractTransaction().toBytes()); +} + +describe('Pearl - cross-validation against live-node vectors', function () { + describe('transaction deserialization', function () { + it("computes the node's own txid for a real pearld transaction", function () { + // The strongest check available offline: the node published both the raw + // bytes and the txid, so agreement is genuinely external. + const tx = Transaction.fromBytes(Buffer.from(NODE_COINBASE_HEX, 'hex'), 'pearl'); + assert.strictEqual(tx.getId(), NODE_COINBASE_TXID); + }); + + it('round-trips the node transaction byte-for-byte', function () { + const raw = Buffer.from(NODE_COINBASE_HEX, 'hex'); + const tx = Transaction.fromBytes(raw, 'pearl'); + assert.deepStrictEqual(Buffer.from(tx.toBytes()), raw); + }); + + it('reads the taproot output the node created', function () { + const raw = Buffer.from(NODE_COINBASE_HEX, 'hex'); + // The coinbase pays to the first fixture scriptPubKey. + assert.ok( + raw.toString('hex').includes(NODE_P2TR_OUTPUTS[0].scriptPubKey), + 'coinbase should contain the known p2tr output script' + ); + }); + }); + + describe('address encoding', function () { + for (const { scriptPubKey, regtestAddress } of NODE_P2TR_OUTPUTS) { + it(`matches the node's bech32m data part for ${scriptPubKey.slice(0, 16)}...`, function () { + // bech32m is `hrp` + `1` + data + checksum. The HRP and checksum differ + // between regtest and mainnet by construction, but the data part encodes + // the witness program alone, so it must match the node exactly. + const nodeDataPart = regtestAddress.split('1').slice(1).join('1').slice(0, -6); + + for (const coinName of ['pearl', 'tpearl'] as const) { + const ours = address.fromOutputScriptWithCoin(Buffer.from(scriptPubKey, 'hex'), coinName); + const ourDataPart = ours.split('1').slice(1).join('1').slice(0, -6); + assert.strictEqual(ourDataPart, nodeDataPart, `${coinName}: bech32m data part must match the node`); + } + }); + + it(`restores the node's exact scriptPubKey for ${scriptPubKey.slice(0, 16)}...`, function () { + for (const coinName of ['pearl', 'tpearl'] as const) { + const ours = address.fromOutputScriptWithCoin(Buffer.from(scriptPubKey, 'hex'), coinName); + const restored = Buffer.from(address.toOutputScriptWithCoin(ours, coinName)).toString('hex'); + assert.strictEqual(restored, scriptPubKey, `${coinName}: round-trip must restore the node scriptPubKey`); + } + }); + } + + it('uses the HRPs the node uses, per network', function () { + const spk = Buffer.from(NODE_P2TR_OUTPUTS[0].scriptPubKey, 'hex'); + assert.ok(address.fromOutputScriptWithCoin(spk, 'pearl').startsWith('prl1p')); + assert.ok(address.fromOutputScriptWithCoin(spk, 'tpearl').startsWith('tprl1p')); + // The node's regtest HRP, for the record - wasm-utxo has no Pearl regtest coin. + assert.ok(NODE_P2TR_OUTPUTS[0].regtestAddress.startsWith('rprl1p')); + }); + }); + + describe('witness shape accepted by the node', function () { + it('produces the stack size and signature lengths the node accepted', function () { + const witness = witnessOf(signedSpend('user', 'bitgo')); + + assert.strictEqual(witness.length, NODE_WITNESS.stackSize, 'witness stack size must match the node'); + // [sig, sig, leafScript, controlBlock] + assert.strictEqual(witness[0].length, NODE_WITNESS.sigLength, 'BIP-340 signatures carry no sighash byte'); + assert.strictEqual(witness[1].length, NODE_WITNESS.sigLength); + // The node's sample was a user+bitgo spend, i.e. the shallow leaf. + assert.strictEqual(witness[3].length, NODE_WITNESS.controlBlockLength); + }); + + it('tags the control block with the leaf version the node saw', function () { + const witness = witnessOf(signedSpend('user', 'bitgo')); + const controlBlock = witness[3]; + // Low bit is the output key parity and varies per output; the rest is the + // tapscript leaf version. + assert.strictEqual(controlBlock[0] & 0xfe, NODE_WITNESS.leafVersion); + }); + + it('holds the stack size and signature lengths for every taptree leaf', function () { + for (const [signer, cosigner] of [ + ['user', 'bitgo'], + ['user', 'backup'], + ['backup', 'bitgo'], + ] as const) { + const witness = witnessOf(signedSpend(signer, cosigner)); + assert.strictEqual(witness.length, NODE_WITNESS.stackSize, `${signer}+${cosigner}`); + assert.strictEqual(witness[0].length, NODE_WITNESS.sigLength, `${signer}+${cosigner}`); + assert.strictEqual(witness[1].length, NODE_WITNESS.sigLength, `${signer}+${cosigner}`); + } + }); + + /** + * Control block size is `1 + 32 + 32 * merkleDepth`, so it reveals where each + * leaf sits in the tree. The depths below match the taptree the TDD specifies, + * + * branch(leaf0[user+bitgo], branch(leaf1[user+backup], leaf2[backup+bitgo])) + * + * with user+bitgo shallow and the other two a level deeper. The node's own + * 65-byte sample was a user+bitgo spend, which is why it saw depth 1. + */ + it('places each leaf at the depth the taptree implies', function () { + const expectedDepth: Record = { + 'user+bitgo': 1, + 'user+backup': 2, + 'backup+bitgo': 2, + }; + + for (const [signer, cosigner] of [ + ['user', 'bitgo'], + ['user', 'backup'], + ['backup', 'bitgo'], + ] as const) { + const controlBlock = witnessOf(signedSpend(signer, cosigner))[3]; + const depth = (controlBlock.length - 33) / 32; + assert.strictEqual(depth, expectedDepth[`${signer}+${cosigner}`], `${signer}+${cosigner} merkle depth`); + assert.strictEqual(controlBlock.length, 33 + 32 * depth, 'control block must be 1 + 32 + 32*depth bytes'); + } + }); + }); + + /** + * KNOWN DIVERGENCE - the sandbox taptree is not BitGo's taptree. + * + * The sandbox spends set the BIP-341 NUMS point + * `50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0` as the + * internal key and produced 70-byte leaf scripts. BitGo's `p2tr` chain instead + * derives an internal key from the wallet keys and produces 68-byte leaves. + * + * This is BitGo's house construction rather than anything Pearl-specific - + * building the same input for `btc` yields a byte-identical witness - but it + * does mean the sandbox's on-chain-verified addresses and signatures describe a + * different script than the SDK generates. So they cannot be asserted against + * our output, and BitGo's actual Pearl taptree has not yet been accepted by a + * pearld node. + * + * The assertions below pin the divergence so it is visible and cannot drift + * unnoticed. Closing it needs a regtest broadcast of an SDK-built transaction. + */ + describe('known divergence from the sandbox taptree', function () { + const NUMS_INTERNAL_KEY = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'; + + it('does not use the NUMS internal key the sandbox used', function () { + const controlBlock = witnessOf(signedSpend('user', 'bitgo'))[3]; + const internalKey = controlBlock.subarray(1, 33).toString('hex'); + assert.notStrictEqual( + internalKey, + NUMS_INTERNAL_KEY, + 'if this now matches, BitGo has moved to a NUMS internal key and the sandbox vectors became comparable' + ); + }); + + it('derives the same internal key for btc and pearl, showing it is not chain-specific', function () { + const internalKeys = (['btc', 'pearl'] as const).map((coinName) => { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty(coinName, walletKeys); + psbt.addWalletInput( + { txid: createHash('sha256').update('divergence').digest('hex'), vout: 0, value: 100_000n }, + walletKeys, + { scriptId: { chain: 30, index: 0 }, signPath: { signer: 'user', cosigner: 'bitgo' } } + ); + psbt.addWalletOutput(walletKeys, { chain: 31, index: 0, value: 90_000n }); + psbt.sign(xprvs[0]); + psbt.sign(xprvs[2]); + psbt.finalizeAllInputs(); + return witnessOf(Buffer.from(psbt.extractTransaction().toBytes()))[3].subarray(1, 33).toString('hex'); + }); + assert.strictEqual(internalKeys[0], internalKeys[1], 'internal key is derived from wallet keys, not the chain'); + }); + + it('produces 68-byte leaf scripts where the sandbox produced 70', function () { + const witness = witnessOf(signedSpend('user', 'bitgo')); + assert.strictEqual(witness[2].length, 68, 'BitGo 2-of-2 tapleaf'); + assert.notStrictEqual(witness[2].length, 70, 'sandbox hand-rolled tapleaf'); + }); + }); +}); diff --git a/modules/abstract-utxo/test/unit/impl/pearl/unit/transactionFlow.ts b/modules/abstract-utxo/test/unit/impl/pearl/unit/transactionFlow.ts new file mode 100644 index 0000000000..8d254a756b --- /dev/null +++ b/modules/abstract-utxo/test/unit/impl/pearl/unit/transactionFlow.ts @@ -0,0 +1,390 @@ +/** + * Pearl (Duplex) end-to-end transaction flow. + * + * Exercises the full fixed-script lifecycle for a taproot-only, wasm-only coin: + * + * address generation -> PSBT build -> sign -> verify -> finalize -> extract + * -> serialize round-trip -> parse + * + * Everything runs offline through `@bitgo/wasm-utxo`. Pearl has no + * `@bitgo/utxo-lib` network, so none of the utxo-lib based helpers (`AcidTest`, + * `testutil.constructPsbt`, the shared `utxoCoins` fixtures) can be used here - + * the PSBT is built directly with `fixedScriptWallet.BitGoPsbt`. + * + * Two deliberate scope limits: + * + * - **Broadcasting** is not covered. It needs either a live pearld node or Pearl + * deployed to the BitGo test environment; neither exists yet (wallet-platform + * onboarding is CECHO-1802). The flow is verified as far as a fully-signed, + * finalized transaction with a real txid, which is the last offline step. + * - **p2trMusig2 key-path signing** is not covered. It requires MuSig2 nonce + * exchange rounds and is explicitly a later phase in the onboarding TDD. The + * musig2 chains are still asserted to be *buildable* below. + */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo'; + +import { AbstractUtxoCoin } from '../../../../../src/abstractUtxoCoin'; +import { generateAddress } from '../../../../../src/address/fixedScript'; +import { Pearl, Tpearl } from '../../../../../src/impl/pearl'; + +type CoinName = 'pearl' | 'tpearl'; +type SignerKey = 'user' | 'backup' | 'bitgo'; + +/** Deterministic test keys, so extracted txids are stable across runs. */ +const roots = ['pearl-e2e-user', 'pearl-e2e-backup', 'pearl-e2e-bitgo'].map((seed) => BIP32.fromSeedSha256(seed)); +const xprvs = roots.map((k) => k.toBase58()) as [string, string, string]; +const xpubs = roots.map((k) => k.neutered().toBase58()) as [string, string, string]; + +const walletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs(xpubs); +const keychains = xpubs.map((pub) => ({ pub })); + +const xprvFor: Record = { user: xprvs[0], backup: xprvs[1], bitgo: xprvs[2] }; +const xpubFor: Record = { user: xpubs[0], backup: xpubs[1], bitgo: xpubs[2] }; + +/** + * The three leaves of Pearl's 2-of-3 taptree. + * + * Order matters: wasm-utxo accepts exactly these signer/cosigner pairings and + * rejects the reversed forms (`bitgo+user`, `backup+user`, `bitgo+backup`) with + * "Could not find control block for leaf script". Note the third leaf is + * `backup+bitgo`, not `bitgo+backup` as the TDD writes it. + */ +const SIGNER_PAIRS: { signer: SignerKey; cosigner: SignerKey }[] = [ + { signer: 'user', cosigner: 'bitgo' }, + { signer: 'user', cosigner: 'backup' }, + { signer: 'backup', cosigner: 'bitgo' }, +]; + +/** p2tr external/internal - the script-path chains this flow signs. */ +const P2TR_CHAINS = [30, 31] as const; +/** p2trMusig2 external/internal - buildable, but key-path signing is a later phase. */ +const MUSIG2_CHAINS = [40, 41] as const; +const ALL_CHAINS = [...P2TR_CHAINS, ...MUSIG2_CHAINS]; + +const INPUT_VALUE = 100_000n; +const OUTPUT_VALUE = 90_000n; + +/** A PSBT input needs a 32-byte txid; its provenance is irrelevant offline. */ +function fakeTxid(seed: string): string { + return createHash('sha256').update(seed).digest('hex'); +} + +/** Build a 1-in/1-out Pearl PSBT spending a wallet UTXO on the given chain. */ +function buildPsbt(coinName: CoinName, { signer, cosigner }: { signer: SignerKey; cosigner: SignerKey }, chain = 30) { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty(coinName, walletKeys); + psbt.addWalletInput( + { txid: fakeTxid(`${coinName}-${signer}-${cosigner}-${chain}`), vout: 0, value: INPUT_VALUE }, + walletKeys, + { scriptId: { chain, index: 0 }, signPath: { signer, cosigner } } + ); + // Pair each external chain with its internal (change) counterpart. + psbt.addWalletOutput(walletKeys, { chain: chain % 2 === 0 ? chain + 1 : chain, index: 0, value: OUTPUT_VALUE }); + return psbt; +} + +/** BitGoPsbt exposes serialization through the underlying wasm object. */ +function serialize(psbt: ReturnType): Buffer { + return Buffer.from(psbt.wasm.serialize()); +} + +describe('Pearl - end-to-end transaction flow', function () { + let bitgo: TestBitGoAPI; + + function getCoin(coinName: CoinName): AbstractUtxoCoin { + return bitgo.coin(coinName) as unknown as AbstractUtxoCoin; + } + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('pearl', Pearl.createInstance); + bitgo.safeRegister('tpearl', Tpearl.createInstance); + }); + + describe('address generation', function () { + it('derives bech32m addresses with the correct HRP per network', function () { + const mainnet = generateAddress('pearl', { keychains, chain: 30, index: 0 }); + const testnet = generateAddress('tpearl', { keychains, chain: 30, index: 0 }); + + assert.ok(mainnet.startsWith('prl1p'), `expected prl1p... got ${mainnet}`); + assert.ok(testnet.startsWith('tprl1p'), `expected tprl1p... got ${testnet}`); + // Same keys and script id on both networks - only the HRP differs. + assert.notStrictEqual(mainnet, testnet); + }); + + it('derives a distinct address for every supported chain and index', function () { + const seen = new Set(); + for (const chain of ALL_CHAINS) { + for (const index of [0, 1, 7]) { + const address = generateAddress('pearl', { keychains, chain, index }); + assert.ok(address.startsWith('prl1p'), `chain ${chain}/${index}: ${address}`); + assert.ok(!seen.has(address), `duplicate address for chain ${chain} index ${index}`); + seen.add(address); + } + } + assert.strictEqual(seen.size, ALL_CHAINS.length * 3); + }); + + it('is deterministic for the same keys and script id', function () { + assert.strictEqual( + generateAddress('pearl', { keychains, chain: 30, index: 0 }), + generateAddress('pearl', { keychains, chain: 30, index: 0 }) + ); + }); + + it('accepts its own generated addresses as valid', function () { + for (const coinName of ['pearl', 'tpearl'] as const) { + const coin = getCoin(coinName); + for (const chain of ALL_CHAINS) { + const address = generateAddress(coinName, { keychains, chain, index: 0 }); + assert.strictEqual(coin.isValidAddress(address), true, `${coinName} chain ${chain}: ${address}`); + } + } + }); + + it('rejects addresses from the other network', function () { + const mainnet = generateAddress('pearl', { keychains, chain: 30, index: 0 }); + const testnet = generateAddress('tpearl', { keychains, chain: 30, index: 0 }); + assert.strictEqual(getCoin('pearl').isValidAddress(testnet), false); + assert.strictEqual(getCoin('tpearl').isValidAddress(mainnet), false); + }); + }); + + describe('build -> sign -> finalize -> extract', function () { + for (const coinName of ['pearl', 'tpearl'] as const) { + for (const pair of SIGNER_PAIRS) { + it(`${coinName}: produces a broadcast-ready transaction signed by ${pair.signer}+${pair.cosigner}`, function () { + const psbt = buildPsbt(coinName, pair); + + psbt.sign(xprvFor[pair.signer]); + psbt.sign(xprvFor[pair.cosigner]); + + // verifySignature is the source of truth - sign() reports the inputs it + // touched, not whether a usable signature landed on the chosen leaf. + assert.strictEqual(psbt.verifySignature(0, xpubFor[pair.signer]), true); + assert.strictEqual(psbt.verifySignature(0, xpubFor[pair.cosigner]), true); + + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + + assert.match(tx.getId(), /^[0-9a-f]{64}$/, `unexpected txid ${tx.getId()}`); + + // A taproot script-path spend carries two 64-byte Schnorr signatures plus + // the leaf script and control block, so it is well clear of a bare skeleton. + const raw = Buffer.from(tx.toBytes()); + assert.ok(raw.length > 200, `expected a witness-bearing tx, got ${raw.length} bytes`); + }); + } + } + + it('signs both p2tr chains', function () { + for (const chain of P2TR_CHAINS) { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }, chain); + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.bitgo); + psbt.finalizeAllInputs(); + assert.match(psbt.extractTransaction().getId(), /^[0-9a-f]{64}$/, `chain ${chain} failed`); + } + }); + + it('builds p2trMusig2 inputs even though key-path signing is a later phase', function () { + for (const chain of MUSIG2_CHAINS) { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }, chain); + + // sign() reports these input indices even for musig2, but a partial + // signature without a prior nonce exchange verifies as invalid - it is + // not a usable signature yet. + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.bitgo); + assert.strictEqual(psbt.verifySignature(0, xpubFor.user), false, `chain ${chain}: no nonce exchange yet`); + assert.strictEqual(psbt.verifySignature(0, xpubFor.bitgo), false, `chain ${chain}: no nonce exchange yet`); + + // Finalizing must fail specifically because nonces are missing, not for + // the generic reason an unsigned input would fail for. + assert.throws( + () => psbt.finalizeAllInputs(), + /At least 2 public nonces are required/, + `chain ${chain} should fail on missing nonces, not on missing signatures` + ); + } + }); + + it('is deterministic - identical inputs produce an identical txid', function () { + const ids = [0, 1].map(() => { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.bitgo); + psbt.finalizeAllInputs(); + return psbt.extractTransaction().getId(); + }); + assert.strictEqual(ids[0], ids[1]); + }); + }); + + describe('signing policy', function () { + it('does not finalize while only half-signed', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + + assert.strictEqual(psbt.verifySignature(0, xpubFor.user), true); + assert.strictEqual(psbt.verifySignature(0, xpubFor.bitgo), false); + assert.throws(() => psbt.finalizeAllInputs(), 'a half-signed 2-of-3 input must not finalize'); + }); + + it('produces no valid signature for a key outside the chosen leaf', function () { + // The leaf is user+bitgo, so backup is not a participant. + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.backup); + + assert.strictEqual(psbt.verifySignature(0, xpubFor.user), true); + assert.strictEqual(psbt.verifySignature(0, xpubFor.backup), false, 'backup is not in the user+bitgo leaf'); + }); + + it('cannot finalize with a signer pair that does not match the leaf', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.backup); + // Two keys signed, but not the two the leaf requires. + assert.throws(() => psbt.finalizeAllInputs(), 'wrong pair must not satisfy the taproot leaf'); + }); + }); + + describe('serialize / deserialize round-trip', function () { + it('survives an unsigned PSBT round-trip and stays signable', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + const bytes = serialize(psbt); + + const reloaded = fixedScriptWallet.BitGoPsbt.fromBytes(bytes, 'pearl'); + assert.deepStrictEqual(serialize(reloaded), bytes, 'round-trip must be byte-identical'); + + reloaded.sign(xprvFor.user); + reloaded.sign(xprvFor.bitgo); + reloaded.finalizeAllInputs(); + assert.match(reloaded.extractTransaction().getId(), /^[0-9a-f]{64}$/); + }); + + it('carries a half-signed signature across the hand-off', function () { + // This is the production co-sign shape: one party signs and passes the PSBT on. + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + + const reloaded = fixedScriptWallet.BitGoPsbt.fromBytes(serialize(psbt), 'pearl'); + assert.strictEqual(reloaded.verifySignature(0, xpubFor.user), true, 'user signature should survive'); + + reloaded.sign(xprvFor.bitgo); + reloaded.finalizeAllInputs(); + assert.match(reloaded.extractTransaction().getId(), /^[0-9a-f]{64}$/); + }); + + it('reaches the same txid whether signed in one pass or two', function () { + const onePass = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + onePass.sign(xprvFor.user); + onePass.sign(xprvFor.bitgo); + onePass.finalizeAllInputs(); + + const halfSigned = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + halfSigned.sign(xprvFor.user); + const twoPass = fixedScriptWallet.BitGoPsbt.fromBytes(serialize(halfSigned), 'pearl'); + twoPass.sign(xprvFor.bitgo); + twoPass.finalizeAllInputs(); + + assert.strictEqual(onePass.extractTransaction().getId(), twoPass.extractTransaction().getId()); + }); + }); + + describe('parse', function () { + const parseOptions = { replayProtection: { publicKeys: [] } }; + + it('attributes input and output values to the wallet', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, parseOptions); + + assert.strictEqual(parsed.inputs.length, 1); + assert.strictEqual(parsed.outputs.length, 1); + assert.strictEqual(BigInt(parsed.inputs[0].value), INPUT_VALUE); + assert.strictEqual(BigInt(parsed.outputs[0].value), OUTPUT_VALUE); + }); + + it('parses a fully-signed PSBT the same way', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + psbt.sign(xprvFor.user); + psbt.sign(xprvFor.bitgo); + + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, parseOptions); + assert.strictEqual(BigInt(parsed.inputs[0].value), INPUT_VALUE); + assert.strictEqual(BigInt(parsed.outputs[0].value), OUTPUT_VALUE); + }); + + it('resolves output addresses with the Pearl HRP', function () { + for (const [coinName, hrp] of [ + ['pearl', 'prl1'], + ['tpearl', 'tprl1'], + ] as const) { + const psbt = buildPsbt(coinName, { signer: 'user', cosigner: 'bitgo' }); + const outputs = psbt.getOutputsWithAddress(); + assert.ok(outputs.length > 0); + for (const output of outputs) { + assert.ok(output.address.startsWith(hrp), `${coinName}: unexpected address ${output.address}`); + } + } + }); + + it('implies a fee from the input/output difference', function () { + const psbt = buildPsbt('pearl', { signer: 'user', cosigner: 'bitgo' }); + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, parseOptions); + const inputTotal = parsed.inputs.reduce((sum, i) => sum + BigInt(i.value), 0n); + const outputTotal = parsed.outputs.reduce((sum, o) => sum + BigInt(o.value), 0n); + assert.strictEqual(inputTotal - outputTotal, INPUT_VALUE - OUTPUT_VALUE); + }); + }); + + describe('script type restrictions', function () { + it('refuses to build a wallet input for a non-taproot chain', function () { + // 0 = p2sh, 10 = p2shP2wsh, 20 = p2wsh - none of these exist on Pearl. + // + // A signPath is supplied so the rejection cannot be confused with the + // "sign_path is required" error that taproot chains raise without one, and + // the message is matched so a taproot chain slipping into this list would + // fail the test rather than pass for the wrong reason. + for (const chain of [0, 10, 20]) { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('pearl', walletKeys); + assert.throws( + () => + psbt.addWalletInput({ txid: fakeTxid(`reject-${chain}`), vout: 0, value: INPUT_VALUE }, walletKeys, { + scriptId: { chain, index: 0 }, + signPath: { signer: 'user', cosigner: 'bitgo' }, + }), + /Unsupported script type/, + `chain ${chain} must be rejected for a taproot-only coin` + ); + } + }); + + it('requires a signPath for taproot chains', function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty('pearl', walletKeys); + assert.throws( + () => + psbt.addWalletInput({ txid: fakeTxid('no-signpath'), vout: 0, value: INPUT_VALUE }, walletKeys, { + scriptId: { chain: 30, index: 0 }, + }), + /sign_path is required/ + ); + }); + + it('refuses the reversed signer orderings that have no leaf', function () { + for (const pair of [ + { signer: 'bitgo', cosigner: 'user' }, + { signer: 'backup', cosigner: 'user' }, + { signer: 'bitgo', cosigner: 'backup' }, + ] as const) { + assert.throws(() => buildPsbt('pearl', pair), `${pair.signer}+${pair.cosigner} is not a leaf in the taptree`); + } + }); + }); +});