Skip to content

feat: add canBeMalleable flag to broadcast/send responses#599

Open
jeremytsng wants to merge 4 commits intomainfrom
fix/btc-snap-mellability-handling
Open

feat: add canBeMalleable flag to broadcast/send responses#599
jeremytsng wants to merge 4 commits intomainfrom
fix/btc-snap-mellability-handling

Conversation

@jeremytsng
Copy link
Copy Markdown

@jeremytsng jeremytsng commented Apr 27, 2026

Explanation

Surfaces a canBeMalleable: boolean on every snap response that returns a post-broadcast txid, so consumers can detect when a transaction's id may be rewritten by a third party before confirmation.

Current state. Every consumer-facing path that broadcasts a transaction returns the client-computed txid immediately after broadcast, with no signal about whether that id may change before confirmation. For legacy address types (P2PKH) the txid is malleable: a third party can rewrite the signature in scriptSig and produce a different valid txid for the same effective transaction. A dApp that trusts the returned txid for finality decisions before block confirmation can therefore lose track of its own transactions on legacy accounts.

Solution. A new helper canAccountTxidBeMalleated(addressType) in packages/snap/src/entities/account.ts is the single source of truth for the rule:

addressType canBeMalleable
p2pkh (BIP44 legacy) true
p2sh (BIP49 wrapped SegWit) false
p2wpkh (BIP84 native SegWit) false
p2wsh false
p2tr (Taproot) false

Note that addressType: 'p2sh' in this codebase specifically means BIP49 nested SegWit (sh(wpkh(...))), not generic legacy P2SH — its scriptSig is a fixed canonical push of the witness program and signatures live in the witness, so the txid is not malleable. Today every account is P2WPKH (enforced at KeyringHandler.ts for v1), so the flag is always false in production; the implementation is in place for the day legacy support is reconsidered. The exhaustive switch throws AssertionError on an unknown AddressType, so a future variant cannot leak a non-boolean to consumers.

Wired through every consumer-facing post-broadcast txid path:

  • AccountUseCases#broadcast is now the chokepoint: it returns BroadcastResult { txid, canBeMalleable }, so the three public broadcast methods (signPsbt, broadcastPsbt, sendTransfer) can't return a txid without the flag.
  • KeyringRequestHandler propagates the flag in its signPsbt / broadcastPsbt / sendTransfer responses, and asserts on protocol violation (txid set without flag) — mirroring the existing assertion in RpcHandler.
  • RpcHandler propagates the flag in executeSendFlow, signAndSend, and the confirmSend path.
  • mapPsbtToTransaction (used by confirmSend) now returns KeyringTransactionWithMalleability, an additive widening of the KeyringTransaction shape.
  • openrpc.json documents canBeMalleable on both sendTransactionResponse schemas and on confirmSend's KeyringTransaction return.

Limitation (out of scope for this PR). The flag is computed from the snap account's address type, which is correct for transactions the snap builds itself. For externally-provided PSBTs broadcast via broadcastPsbt or signPsbt({ broadcast: true, fill: false }), foreign legacy inputs from collaborative transactions could still produce a malleable txid even when the snap's account is P2WPKH. Per-input analysis is a follow-up — for the immediate audit concern (single-account broadcasts trusted by dApps) this PR is sufficient, and the limitation is documented in the JSDoc on BroadcastResult.

References

  • Internal security audit finding: "Transaction malleability is not handled correctly".

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

Note

Medium Risk
Changes public RPC/keyring response shapes for all post-broadcast transaction flows, which can break consumers that validate response schemas strictly. Logic is straightforward but touches core send/broadcast paths and adds new invariants that can throw on mismatched use-case outputs.

Overview
Surfaces a new canBeMalleable: boolean flag anywhere the snap returns a post-broadcast txid/transactionId, allowing clients to detect when a txid may be rewritten before confirmation (legacy p2pkh only).

This adds a single source of truth helper (canAccountTxidBeMalleated) and threads the flag through AccountUseCases broadcast results, RpcHandler send responses, keyring submit responses (signPsbt/broadcastPsbt/sendTransfer), and confirmSend transaction mapping; handlers now assert if a txid is returned without the flag. OpenRPC docs, changelog, and integration/unit tests are updated accordingly.

Reviewed by Cursor Bugbot for commit b781669. Bugbot is set up for automated code reviews on this repo. Configure here.

Surface a `canBeMalleable: boolean` on every snap response that returns
a post-broadcast txid, so consumers can detect when a transaction's id
may be rewritten by a third party before confirmation.

Computed from the source account's address type: only legacy P2PKH
(BIP44) keeps signatures in scriptSig and is therefore malleable. The
codebase's `p2sh` is BIP49 wrapped SegWit (sh(wpkh(...))) — signatures
live in the witness, so it is not malleable. Today every account is
P2WPKH, so the flag is always false; the implementation is in place
for when legacy support is added.

Wired through every consumer-facing txid path:
- AccountUseCases#broadcast (now the chokepoint, returns BroadcastResult)
- signPsbt / broadcastPsbt / sendTransfer (use-case layer)
- KeyringRequestHandler signPsbt / broadcastPsbt / sendTransfer
- RpcHandler executeSendFlow / signAndSend / confirmSend
- mapPsbtToTransaction (KeyringTransaction shape used by confirmSend)

KeyringRequestHandler asserts that signPsbt cannot return a txid
without the flag, mirroring the existing RpcHandler assertion. The
helper's exhaustive switch throws AssertionError on unknown
AddressType so a future variant cannot leak a non-boolean to consumers.

OpenRPC schemas updated for both sendTransactionResponse shapes and
confirmSend's KeyringTransaction return.

Limitation: the flag is computed from the snap account's address type,
which is correct for transactions the snap builds itself. For
externally-provided PSBTs broadcast via broadcastPsbt or
signPsbt({broadcast:true,fill:false}), foreign legacy inputs from
collaborative transactions could still produce a malleable txid; per-
input analysis is a follow-up.
@jeremytsng jeremytsng requested a review from a team as a code owner April 27, 2026 11:25
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ad3413e. Configure here.

Comment thread packages/snap/src/handlers/RpcHandler.ts
Cursor review noted the combined `!txid || canBeMalleable === undefined`
guard threw 'Missing transaction ID' regardless of which condition
fired, masking missing-flag protocol violations. Split the guard so
each branch reports its own cause, matching the message used in
KeyringRequestHandler.
@jeremytsng
Copy link
Copy Markdown
Author

@metamaskbot publish-preview

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/bitcoin-wallet-snap": "1.10.1-preview-541fd6f"
}

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