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`); + } + }); + }); +});