Skip to content

Describe dapp transactions from their bytes, not the API's account of them - #223

Merged
droplister merged 1 commit into
mainfrom
fix/provider-local-parse
Aug 3, 2026
Merged

Describe dapp transactions from their bytes, not the API's account of them#223
droplister merged 1 commit into
mainfrom
fix/provider-local-parse

Conversation

@droplister

Copy link
Copy Markdown
Member

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 from decodeRawTransaction, which is an HTTP call to a configurable Counterparty host (transaction.ts):

  • inputs and their addresses, output values/addresses/types, 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 verifyProviderTransaction was extracted from the API's output scripts, keyed by the API's first input txid:

counterpartyDataHex = extractPayloadFromOutputs(
  decoded.vout.map((vout: any) => vout.scriptPubKey?.hex ?? ''),
  inputs[0]!.txid
) ?? undefined;

Both sides of the comparison came from the same source, so agreement proved nothing about the bytes.

The change

New localTransactionParse.ts parses 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 from extractCounterpartyPayload(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:

  • Fee (item 3b). Input values aren't in the spending transaction, so they're still resolved from the chain — but now for every input. Previously one 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 rather than small.
  • Unattributable outputs (part of 3d). An output whose script can't be decoded is marked unknown rather 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.ts pins 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 are unknown rather than guessed, and OP_RETURN outputs carry their script for payload extraction.

  • tsc --noEmit clean; 683 tests across hooks/requests/bitcoin.
  • E2E locally: provider-transaction-signing.spec.ts 11/11, provider-integration.spec.ts 5/5.

Still open from item 3

3c (the fee warning band is wide, and warnings never block) and the rest of 3d (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

… 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
@droplister
droplister merged commit cf66979 into main Aug 3, 2026
38 checks passed
@droplister
droplister deleted the fix/provider-local-parse branch August 3, 2026 22:57
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