Describe dapp transactions from their bytes, not the API's account of them - #223
Merged
Conversation
… them A site hands over finished bytes to sign, and the approval screen has to say what they do. That description came from decodeRawTransaction -- an HTTP call to a configurable Counterparty host -- and every field on the screen was taken from its answer: inputs, outputs, addresses, amounts, txid, vsize. The signature commits to the bytes, not to the description, so a hostile or compromised API could show a benign transaction while a malicious one was signed. ADR-019 names that API untrusted, and the compose path already treats it that way. The cross-check did not close the gap either. It extracted the payload from the API's output scripts, keyed by the API's first input txid, so both sides of the comparison came from the same source and agreement proved nothing about the bytes. Transactions are now parsed locally with the same library the signer uses, and the payload is read from the raw hex via extractCounterpartyPayload -- the function the compose path already uses. The API decode is kept only where it belongs: as the independent second opinion in providerVerify, and for facts only a node has, such as which UTXOs carry assets. An unparseable transaction is reported as undescribable rather than deferring to a remote description of it. Input values are not in the transaction that spends them, so they are still resolved from the chain -- but now for every input. Previously a single API-supplied value suppressed the lookup for all of them, and unresolved inputs counted as zero, understating the fee that the fee-rate warning is computed from. An unresolved input now leaves the fee reported as unknown instead of small, and an output whose script cannot be attributed is marked unknown rather than given a guessed address. Claude-Session: https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PRIORITIES.md item 3 — the largest remaining trust hole. Closes 3, 3b and part of 3d.
The problem
A dapp hands over finished bytes via
xcp_signTransaction, and the approval screen has to describe them. That description came entirely fromdecodeRawTransaction, which is an HTTP call to a configurable Counterparty host (transaction.ts):txid,vsize— all from the response.The signature commits to the bytes, not to the description. So a hostile or compromised API — the exact threat ADR-019 names, and the one the compose path already defends against — could render a benign transaction while a malicious one gets signed.
The cross-check didn't close it either. The payload fed to
verifyProviderTransactionwas extracted from the API's output scripts, keyed by the API's first input txid:Both sides of the comparison came from the same source, so agreement proved nothing about the bytes.
The change
New
localTransactionParse.tsparses the raw transaction with@scure/btc-signer— the same library the signer and output policy already use — producing inputs, outputs, addresses, values, txid and vsize from the bytes themselves. The payload now comes fromextractCounterpartyPayload(rawTxHex), the function the compose path already uses.The API decode is kept exactly where it belongs: as the genuinely independent second opinion in
providerVerify, and for facts only a node has (which UTXOs carry assets). It is no longer the source of what the user reads.Two correctness fixes fall out:
unknownrather than given a guessed address.An unparseable transaction is reported as undescribable rather than falling back to a remote description of it.
Tests
localTransactionParse.test.tspins the parse against a real mainnet transaction (a captured commit tx: P2TR output at 875 sats plus P2WPKH change), and covers: input values are never invented, txid is derived from the same bytes, unparseable input returns null rather than a partial description, bare-multisig outputs areunknownrather than guessed, and OP_RETURN outputs carry their script for payload extraction.tsc --noEmitclean; 683 tests across hooks/requests/bitcoin.provider-transaction-signing.spec.ts11/11,provider-integration.spec.ts5/5.Still open from item 3
3c(the fee warning band is wide, and warnings never block) and the rest of3d(no deny-by-default output accounting on provider paths) are unchanged here — both are policy decisions rather than trust-boundary bugs.3e(raw-tx screen doesn't re-check the site is still connected) is untouched.https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj