Skip to content

fix(bitcoin-wallet-snap): preserve template output order when filling a PSBT - #157

Open
jeremytsng wants to merge 1 commit into
mainfrom
fix/btc-output-reordering-fx-fail
Open

fix(bitcoin-wallet-snap): preserve template output order when filling a PSBT#157
jeremytsng wants to merge 1 commit into
mainfrom
fix/btc-output-reordering-fx-fail

Conversation

@jeremytsng

@jeremytsng jeremytsng commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Explanation

The problem

When a bridge gives us a PSBT, the snap doesn't sign it as-is. It rebuilds the transaction with BDK and copies the outputs across. Protocols read those outputs by position — deposit first, memo second, change last — so the rebuild has to keep that order. Two things broke it.

First, any output belonging to the wallet was used as BDK's drain output, and BDK always puts the drain output last. So if one of our outputs sat anywhere but last, it got moved to the end and everything after it shifted up.

Second, nothing checked the result. The only test was whether the number of outputs had shrunk, so a transaction that no longer matched the template was signed and broadcast anyway.

The fix

Only a trailing output of ours becomes the drain output, since that's the only position BDK can keep. Any earlier one stays a normal recipient with its position and amount intact, and BDK adds its own change after it.

#fillPsbt now also checks its own work before returning. Every template output has to appear at the same index, with the same script and amount. The drain output is exempt from the amount check, because it takes the excess by design, and an extra change output at the end is fine. Anything else throws instead of being signed.

This covers everything that fills a PSBT: fillPsbt, signPsbt({ fill: true }), computeFee, and the signAndSendTransaction call that bridging uses.

Verification

5 new unit tests, and the package suite passes (595 tests).

Checked against the @metamask/bitcoindevkit binary the snap actually bundles. A template of [deposit, ours, OP_RETURN] used to come back as [deposit, OP_RETURN, ours]. It now comes back as [deposit, ours, OP_RETURN, change].

#158 adds a regtest test that fails without this change.

References

Found while investigating a bridge deposit that was never credited: the transaction that confirmed didn't match the output order of the quote's PSBT.

Not covered here: signPsbt({ fill: false }) signs a PSBT the caller built, so there's no template to compare it against.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

@jeremytsng
jeremytsng force-pushed the fix/btc-output-reordering-fx-fail branch 2 times, most recently from 30f6166 to 54d49c5 Compare August 17, 2026 17:03
@jeremytsng
jeremytsng force-pushed the fix/btc-output-reordering-fx-fail branch 2 times, most recently from cd9168d to 0712819 Compare August 19, 2026 13:17
@jeremytsng
jeremytsng marked this pull request as ready for review August 19, 2026 18:38
@jeremytsng
jeremytsng requested a review from a team as a code owner August 19, 2026 18:38
@jeremytsng
jeremytsng deployed to default-branch August 19, 2026 18:38 — with GitHub Actions Active
@Battambang
Battambang requested a balanced review from Copilot August 20, 2026 08:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Preserves PSBT template output ordering during wallet transaction rebuilding.

Changes:

  • Restricts drain handling to trailing wallet-owned outputs.
  • Validates rebuilt output scripts, positions, and values.
  • Adds regression tests, changelog entry, and updated bundle hash.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
AccountUseCases.ts Updates PSBT rebuilding and validation.
AccountUseCases.test.ts Adds output-order regression tests.
snap.manifest.json Updates the bundle checksum.
CHANGELOG.md Documents the fix.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/bitcoin-wallet-snap/CHANGELOG.md Outdated
Comment thread packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts Outdated
@Battambang

Battambang commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

@jeremytsng Could you please test with some swap providers in MM with a preview-build to secure nothing is breaking up on their side. outputs order is a high requirement to them.

@jeremytsng
jeremytsng force-pushed the fix/btc-output-reordering-fx-fail branch from 0712819 to 303e410 Compare August 20, 2026 19:04
@jeremytsng

Copy link
Copy Markdown
Contributor Author

@metamaskbot publish-preview

@github-actions

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/bitcoin-wallet-snap@2.0.1-preview-303e410
@metamask-previews/snap-networks-utils@1.0.0-preview-303e410
@metamask-previews/solana-wallet-snap@6.0.0-preview-303e410
@metamask-previews/stellar-wallet-snap@0.0.1-preview-303e410
@metamask-previews/tron-wallet-snap@3.1.0-preview-303e410

@jeremytsng

Copy link
Copy Markdown
Contributor Author

@Battambang i tested with the preview build against mainnet providers, and output order was preserved in both swaps and didnt see any validation errors

Screenshot 2026-08-27 at 00 59 21 Screenshot 2026-08-27 at 01 17 43

@jeremytsng
jeremytsng enabled auto-merge August 26, 2026 18:27
… a PSBT

Only treat a wallet-owned template output as the drain output when it is the
last output. BDK appends the drain output, so a wallet-owned output placed
anywhere earlier was silently moved to the end of the transaction, reordering
templates that put change before another output.

Verify the built transaction against the template before returning it: every
template output must appear at its original index with its original script and
value, except the drain output, which takes the excess. Beyond the template,
only a single appended output is tolerated, and it has to belong to the wallet.
The previous check compared only the number of outputs, so a transaction whose
outputs diverged from the template could still be signed and broadcast.
@jeremytsng
jeremytsng force-pushed the fix/btc-output-reordering-fx-fail branch from 303e410 to adc5a96 Compare August 26, 2026 18:27
(txout, index) =>
builtOutputs[index]?.script_pubkey.to_hex_string() ===
txout.script_pubkey.to_hex_string() &&
(txout === drainOutput ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. The new check is blind at the last index on the retry path (AccountUseCases.ts:849)

drainOutput is computed once at :785, but the fallback rebuild at :810-825 re-adds every template output via addRecipientByScript — no drain is configured on that attempt. The txout === drainOutput exemption still short-circuits the value comparison there, even though the value is now fully caller-specified.

Reproduced it:

template:        [deposit 100000, change(mine) 5000]
1st finish():    [deposit]                            // sub-dust drain dropped -> triggers retry
2nd finish():    [deposit 100000, change 1]           // fixed recipient, value diverged
=> fillPsbt RESOLVES (should throw)

That's the full-balance / dust-drain case, so it's exactly where the user's remaining balance sits. The symmetric non-last case does throw, so the gap is specific to the exempted index.

Suggested fix — track whether the drain was actually configured on the attempt that produced builtPsbt:

let drainConfigured = drainOutput !== undefined;
// ...inside the retry block, after switching to all-fixed recipients:
drainConfigured = false;

then gate the exemption on drainConfigured && txout === drainOutput.

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.

3 participants