Skip to content

test(abstract-utxo): Pearl transaction flow and live-node cross-validation - #9367

Draft
manojkumar138 wants to merge 2 commits into
masterfrom
CECHO-1806-pearl-e2e-tests
Draft

test(abstract-utxo): Pearl transaction flow and live-node cross-validation#9367
manojkumar138 wants to merge 2 commits into
masterfrom
CECHO-1806-pearl-e2e-tests

Conversation

@manojkumar138

@manojkumar138 manojkumar138 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Test coverage for the Pearl (Duplex) fixed-script lifecycle — 42 tests across two files:

File Tests What it establishes
transactionFlow.ts 27 the PSBT lifecycle is internally consistent
sandboxVectors.ts 15 our output agrees with a real pearld node

The split is deliberate. The first file builds transactions whose inputs reference txids that never existed, so it can only prove our libraries agree with themselves. The second checks against output captured from a live pearld 1.0.2 regtest node during sandboxing (coins-sandbox#898).

Scope — what is and isn't covered

Stage Covered How
Address generation derived + cross-validated against node scriptPubKeys
PSBT construction ⚠️ synthetic inputs (sha256 txids, not real UTXOs)
Unspent selection not exercised
Fee calculation values hardcoded; no fee-rate logic involved
Signing (2-of-3 taproot) real BIP-340 sigs, all three taptree leaves
Finalize / extract valid raw tx with a real txid
Transaction parsing parses the node's own raw transaction
Consensus validity ⚠️ structure matches what the node accepted; bytes never submitted
Broadcasting needs a live node or Pearl in the BitGo test env (CECHO-1802)

Single-input/single-output only; no dust boundaries, multi-recipient, or RBF.

What is genuinely node-verified

Transaction deserialization. Given the node's raw coinbase hex, our library computes the node's own txid 272e5b8a…d295 and round-trips byte-exactly. The node published both the bytes and the txid, so agreement is external.

Address encoding. For the node's real p2tr scriptPubKeys, our bech32m data part matches the node exactly — e.g. efxxurpnufae39uq0gcquq3msh27rhdau2rjh38mf9ndlhflk9js. Only HRP and checksum differ, as they must: the node ran regtest (rprl) and wasm-utxo has no Pearl regtest CoinName. Decoding restores the node's scriptPubKey exactly.

Witness shape. 4 stack items, 64-byte BIP-340 signatures with no sighash byte, leaf version 0xc0 — the shape the node accepted.

Taptree depth. Control block size is 1 + 32 + 32*depth, so it reveals each leaf's position. The measured depths confirm the TDD's taptree: user+bitgo shallow at depth 1, user+backup and backup+bitgo at depth 2. The node's 65-byte sample was a user+bitgo spend, which is why it saw depth 1.

⚠️ Known divergence — the sandbox taptree is not BitGo's taptree

The sandbox spends set the BIP-341 NUMS point 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, not a Pearl bug — building the same input for btc yields a byte-identical witness, and the derived internal key is identical across both coins. But it has two consequences worth attention:

  1. 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.
  2. BitGo's actual Pearl taptree has not yet been accepted by a pearld node. The sandbox proof does not transfer.

Three tests pin this so it cannot drift unnoticed, including one that fails if BitGo ever moves to a NUMS internal key (at which point the sandbox vectors become comparable).

Worth noting the TDD lists "NUMS internal key" as a chain parameter, which is misleading — it was a property of the sandbox's hand-rolled script. Anything else derived from that section may inherit the same assumption.

Three assumptions the tests corrected

The TDD's third taptree leaf is written backwards. It says [bitgo+backup]; wasm-utxo only accepts backup+bitgo and rejects the reverse with Could not find control block for leaf script.

sign()'s return value proves nothing. It returns the input index even for a key outside the chosen leaf. verifySignature is the source of truth; finalization enforces the leaf.

One test was vacuous until mutation testing caught it. "Rejects non-taproot chains" passed even with chain 30 substituted in, because chain 30 also throws without a signPath — a different error. It now supplies a signPath and matches /Unsupported script type/.

Mutation testing

I mutated each suite rather than trusting a green run. All caught:

Mutation Result
reverse the third taptree leaf order 2 tests fail
put a taproot chain in the reject list 1 test fails
corrupt the node txid by one character 1 test fails
corrupt a node scriptPubKey byte 2 tests fail
wrong merkle depth for user+backup 1 test fails

Verification

abstract-utxo: 1594 passing (1552 → +42), zero failures. Prettier and eslint clean.

Next step to close the remaining gap

A regtest broadcast of an SDK-built transaction — that is the only thing that proves BitGo's Pearl taptree is consensus-valid. Blocked by the TDD's own risks: no public pearld Docker image, and ZK-PoW CGo crashes on Apple Silicon.

Related

TICKET: CECHO-1806

🤖 Generated with Claude Code

Cover the full offline fixed-script lifecycle for Pearl: address
generation, PSBT build, sign, verify, finalize, extract, serialize
round-trip and parse. 27 cases.

Pearl has no @bitgo/utxo-lib network, so none of the utxo-lib based test
helpers apply - AcidTest, testutil.constructPsbt and the shared utxoCoins
fixtures all resolve a network. The PSBT is built directly with
fixedScriptWallet.BitGoPsbt instead.

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 rather than the return of sign() is used to assert
signing succeeded. sign() reports the inputs it touched, and it returns
the input index even for a key that is not part of the chosen leaf, so it
cannot distinguish a usable signature from a discarded one.

Two scope limits, both asserted rather than left implicit:

  - Broadcasting needs a live pearld node or Pearl in the BitGo test
    environment, neither of which exists yet (CECHO-1802). The flow is
    verified as far as a finalized transaction with a real txid.
  - p2trMusig2 key-path signing 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.

TICKET: CECHO-1806

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@manojkumar138
manojkumar138 requested a review from a team as a code owner July 28, 2026 06:58
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

CECHO-1806

@manojkumar138
manojkumar138 marked this pull request as draft July 28, 2026 07:04
Add sandboxVectors.ts, checking our library against output from a real
pearld 1.0.2 regtest node (BitGo/coins-sandbox#898) rather than against
itself. transactionFlow.ts proves the PSBT lifecycle is internally
consistent but cannot prove a node would accept the bytes, since its
inputs reference txids that never existed.

Genuinely externally verified:

  - deserializing the node's raw coinbase transaction and computing the
    node's own txid, 272e5b8a...d295, plus a byte-exact round-trip
  - encoding the node's real p2tr scriptPubKeys to addresses: the bech32m
    data part matches the node exactly (HRP and checksum differ by
    construction, since the node ran regtest and wasm-utxo has no Pearl
    regtest CoinName), and decoding restores the node's scriptPubKey
  - the witness shape the node accepted: 4 stack items, 64-byte BIP-340
    signatures with no sighash byte, leaf version 0xc0

Control block size is 1 + 32 + 32*depth, so it reveals each leaf's
position. The measured depths confirm the taptree the TDD specifies:
user+bitgo shallow at depth 1, user+backup and backup+bitgo at depth 2.
The node's 65-byte sample was a user+bitgo spend, which is why it saw
depth 1.

KNOWN DIVERGENCE, asserted rather than left implicit: 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 addresses and signatures describe a
different script than the SDK generates, so they cannot be compared. Three
tests pin the divergence so it cannot drift unnoticed.

Consequence worth flagging: 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.

TICKET: CECHO-1806

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@manojkumar138 manojkumar138 changed the title test(abstract-utxo): add Pearl end-to-end transaction flow test(abstract-utxo): Pearl transaction flow and live-node cross-validation Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant