Skip to content

Commit 38e4618

Browse files
manojkumar138claude
andcommitted
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) <noreply@anthropic.com>
1 parent 00e2e7c commit 38e4618

2 files changed

Lines changed: 709 additions & 0 deletions

File tree

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
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

Comments
 (0)