Skip to content

feat(abstract-utxo): add safe signing helpers for external PSBTs - #9856

Draft
ralph-bitgo[bot] wants to merge 2 commits into
masterfrom
WCN-1994-sign-external-psbt
Draft

ralph-bitgo[bot] wants to merge 2 commits into
masterfrom
WCN-1994-sign-external-psbt

Conversation

@ralph-bitgo

@ralph-bitgo ralph-bitgo Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

What

  • Adds signExternalPsbt(psbtHex, coinName, signer) to @bitgo/abstract-utxo (recovery module): signs an externally supplied (untrusted) PSBT with a single key under the SIGHASH_ALL-only policy. It enforces the sighash policy before signing, verifies the output set is byte-identical to a pre-signing snapshot, re-checks the policy on the re-parsed serialized result, and determines the signed inputs by verifying the signer's signature on every input (BitGoPsbt.sign reports attempted inputs — a key matching no input still returns indexes — so a key that signs nothing is rejected explicitly).
  • Adds assertExternalPsbtSighashPolicy(psbtHex, coinName) for callers that hand the PSBT to a different signer (e.g. the SDK signer): runs the policy check alone.
  • Both accept SDK UtxoCoinNames (normalized via the existing toWasmUtxoCoinName) and are exported from the package root.
  • Unit tests in test/unit/recovery/signExternalPsbt.ts: signed happy path (single + multi input, xprv string and BIP32 instance signers), all unsafe modes rejected before signing, unsafe type on a later input named in the error, omitted type accepted, non-matching signer rejected, BCH SIGHASH_ALL|FORKID accepted, and a PSBT already carrying a non-SIGHASH_ALL signature rejected.

Why

Wallet-recovery tooling signs externally pasted PSBTs with user keys. A foreign PSBT can request SIGHASH_NONE/SINGLE/ANYONECANPAY per input so the user signature does not bind to the recipient output — the output-swap drain demonstrated in AnchorWatch finding SIG-001 (WCN-1994). Consumers (wallet-recovery-wizard) should get the complete policy from the SDK instead of reimplementing it per app, with the network-aware rules (BCH FORKID, Taproot SIGHASH_DEFAULT) owned by wasm-utxo.

Test plan

  • Unit tests run against a locally built wasm-utxo with the sighash policy primitives: 14 passing; tsc --noEmit clean (module + tests)
  • yarn.lock bumped to the published @bitgo/wasm-utxo@5.6.0 (BitGoWASM#416, merged and released); abstract-utxo dependency range raised to ^5.6.0 because signExternalPsbt requires the new primitives — 9aa397a
  • Full CI green against the real release: BitGo SDK (all unit-test/browser-test/docker-build/all-checks jobs) and Audit API Spec both pass

Stack

This PR is part 2 of 3 in the WCN-1994 stack. Review and merge in order:

  1. BitGoWASM @bitgo/wasm-utxo — sighash policy primitives (BitGoPsbt.assertSighashAllPolicy / getInputSighashTypes) — feat(wasm-utxo): add sighash policy primitives to BitGoPsbt BitGoWASM#416
  2. This PR — abstract-utxo safe external-PSBT signing helpers (publishes as @bitgo/abstract-utxo ≥ 13.3.0)
  3. wallet-recovery-wizard PR Add Celo USD ERC20 token #753 — WRW "Sign Unsigned PSBT" flow switches to these helpers

Step 1 is merged and published @bitgo/wasm-utxo@5.6.0; the lockfile bump is included here (9aa397a). After this PR merges and publishes @bitgo/abstract-utxo ≥ 13.3.0, refresh package-lock.json in part 3.

Ticket: WCN-1994

@linear-code

linear-code Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

WCN-1994

Add signExternalPsbt and assertExternalPsbtSighashPolicy to the
recovery module.

signExternalPsbt signs an externally supplied (untrusted) PSBT with a
single key under the SIGHASH_ALL-only policy: it enforces the wasm-utxo
sighash policy before signing, verifies the output set is byte-identical
to a pre-signing snapshot, re-checks the policy on the re-parsed
serialized result, and determines the signed inputs by verifying the
signer's signature on each input (BitGoPsbt.sign reports attempted
inputs, so a key matching no input still returns indexes).
assertExternalPsbtSighashPolicy runs the policy check alone, for callers
that hand the PSBT to a different signer (e.g. the SDK signer).

Why: wallet recovery tooling signs externally pasted PSBTs with user
keys, and a foreign PSBT can request SIGHASH_NONE/SINGLE/ANYONECANPAY
per input so the user signature does not bind to the recipient output -
the output-swap drain from WCN-1994. Consumers (wallet-recovery-wizard)
get the complete policy from the SDK instead of reimplementing it, with
network-aware rules (BCH FORKID, Taproot SIGHASH_DEFAULT) owned by
wasm-utxo.

Requires the wasm-utxo sighash policy primitives; the yarn.lock bump to
the published wasm-utxo release follows after the BitGoWASM PR lands.

Ticket: WCN-1994
Session-Id: 5c05e047-31d0-43fa-91ab-f1c595b37850
Task-Id: f7cdbdd7-f9f0-4164-a69b-0c7e58e609a8
@ralph-bitgo
ralph-bitgo Bot force-pushed the WCN-1994-sign-external-psbt branch from e20f799 to 6c4ab26 Compare September 29, 2026 21:15
@ralph-bitgo

ralph-bitgo Bot commented Sep 30, 2026

Copy link
Copy Markdown
Author

CI status: the failing checks fail only on the documented stack dependency, not on this diff's logic.

Root cause of every red check: CI installs @bitgo/wasm-utxo from yarn.lock, which resolves 5.5.1 — the published release that predates the sighash policy primitives this PR calls. Every failing job (unit-test on Node 22/24/26, browser-test, docker-build, Audit API Spec's "Generate head API spec") stops at the same three typecheck errors, e.g.:

@bitgo/abstract-utxo: src/recovery/signExternalPsbt.ts(65,5):
error TS2339: Property 'assertSighashAllPolicy' does not exist on type 'BitGoPsbt<ParsedOutput>'.

That method is added by the stack's part 1: BitGo/BitGoWASM#416 (CI fully green there, including the wasm-pack node/chrome legs).

Evidence the code itself is sound: the same 14 unit tests (test/unit/recovery/signExternalPsbt.ts) and a strict tsc --noEmit pass locally against a built wasm-utxo with the primitives — covering the unsafe-mode rejections, the BCH SIGHASH_ALL|FORKID case, Taproot SIGHASH_DEFAULT, tampered foreign signatures, and the no-match signer guard. Prettier/eslint are clean on this push (the formatting warnings from the first CI run were fixed in 6c4ab26).

To green: merge BitGoWASM#416 → let release publish @bitgo/wasm-utxo ≥ 5.6.0 → update yarn.lock here → CI re-runs the full suite.

Ticket: WCN-1994

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Unit tests are failing on Node 26.x (Current release line, non-blocking). This is not an LTS version yet, so it does not block merge, but it signals an incompatibility to fix before Node 26.x becomes LTS.

View run

@ralph-bitgo

ralph-bitgo Bot commented Sep 30, 2026

Copy link
Copy Markdown
Author

Update on the failing tests: resolved — BitGoWASM#416 merged and published @bitgo/wasm-utxo@5.6.0 (npm shasum e6d09c2b…, dist-tag latest, published 15:22Z), so the yarn.lock entry was bumped to it in 9aa397a (and abstract-utxo's range raised to ^5.6.0, since signExternalPsbt requires the new primitives — ^5.5.1 would still have installed a release without them).

CI on 9aa397a is now fully green: BitGo SDK (unit-test on Node 22/24/26, browser-test, docker-build, all-checks — all jobs success) and Audit API Spec both pass against the real release. The only prior failure mode was TS2339: Property 'assertSighashAllPolicy' does not exist on type 'BitGoPsbt<ParsedOutput>' against lockfile-pinned 5.5.1 — gone now.

Merge order remainder: merge this PR → release publishes @bitgo/abstract-utxo ≥ 13.3.0 → refresh package-lock.json in wallet-recovery-wizard#753.

Ticket: WCN-1994

Update the lockfile to the wasm-utxo release that publishes the sighash
policy primitives (BitGoPsbt.assertSighashAllPolicy /
getInputSighashTypes, BitGoWASM#416). Raise the @bitgo/wasm-utxo
dependency range to ^5.6.0 in every module that declares it
(abstract-utxo, utxo-bin, utxo-core, utxo-descriptors, utxo-ord,
utxo-staking): signExternalPsbt requires the new primitives, so the
previous ^5.5.1 range would still install a release without them, and
one consistent minimum avoids mixing the old and new ranges across the
monorepo.

Why: the CI failures on this branch came from the lockfile resolving
wasm-utxo 5.5.1, which predates the primitives; with 5.6.0 published,
the full suite can build and test against the real release.

Ticket: WCN-1994
Session-Id: 5c05e047-31d0-43fa-91ab-f1c595b37850
Task-Id: f7cdbdd7-f9f0-4164-a69b-0c7e58e609a8
Requested-By: Otto Allmendinger <otto@bitgo.com>
@ralph-bitgo
ralph-bitgo Bot force-pushed the WCN-1994-sign-external-psbt branch from 9aa397a to 7d14e06 Compare September 30, 2026 16:07

This branch has not been deployed

No deployments
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.

2 participants