From 90e16feb9c6e8454210151212417d00fb1fa8081 Mon Sep 17 00:00:00 2001 From: droplister Date: Mon, 3 Aug 2026 18:59:25 -0400 Subject: [PATCH] Show the fee the transaction pays, not the one the response claims checkTransactionFee already recomputes the miner fee from the transaction's own inputs and outputs, resolving input values from the chain rather than from the response. That number was used to accept or reject the transaction and then discarded: the review screen rendered result.btc_fee, the composer's own assertion about itself. The bound is deliberately loose -- max(10_000, rate * vsize * 10) -- because a legitimate composer needs room, so a response can pass it while reporting a smaller fee than the transaction actually pays, and the user signs against the number on the screen. The composer now stores the computed fee in place of the reported one, which corrects every review screen at once since they all render that field. A response that stated a different fee also produces a warning, since contradicting a stated value is worth surfacing. A response that stated no fee at all is corrected silently: filling in a value nobody claimed is not a discrepancy. The existing compose test turned out to be a live example -- its fixture asserts btc_fee 5000 while its inputs minus its single output leave 4840 -- and nothing had ever checked, which is the defect in miniature. Claude-Session: https://claude.ai/code/session_01CcjnCrgosSeshymXLBxdGj --- .../__tests__/composer-context.test.tsx | 11 +++++++- src/contexts/composer-context.tsx | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/contexts/__tests__/composer-context.test.tsx b/src/contexts/__tests__/composer-context.test.tsx index 24d9a23f..49db1b77 100644 --- a/src/contexts/__tests__/composer-context.test.tsx +++ b/src/contexts/__tests__/composer-context.test.tsx @@ -176,9 +176,18 @@ describe('ComposerContext', () => { await waitFor(() => { expect(result.current.state.step).toBe('review'); - expect(result.current.state.apiResponse).toEqual(apiResponse); }); + // The composer stores the fee the transaction actually pays, not the one the response + // claims. This fixture is a good example of why: it asserts btc_fee 5000, while its inputs + // (100,000, from the stubbed resolver) minus its single 95,160 sat output leave 4,840. Every + // review screen renders `result.btc_fee`, so substituting it here is what makes them honest. + expect(result.current.state.apiResponse).toEqual({ + ...apiResponse, + result: { ...apiResponse.result, btc_fee: 4840 }, + }); + expect(result.current.state.verificationWarnings.join(' ')).toContain('4840'); + // The context converts FormData to plain object before calling composeApi const expectedData = { amount: '100', diff --git a/src/contexts/composer-context.tsx b/src/contexts/composer-context.tsx index 5e71edf0..02dd996b 100644 --- a/src/contexts/composer-context.tsx +++ b/src/contexts/composer-context.tsx @@ -362,7 +362,8 @@ export function ComposerProvider({ if (signal.aborted) return; // Call compose API (UTXO selection is handled internally by compose functions) - const response = await composeApi(dataForApi); + // Reassigned below if verification finds the reported fee differs from the real one. + let response = await composeApi(dataForApi); // Check if aborted after API call if (signal.aborted) return; @@ -473,6 +474,29 @@ export function ComposerProvider({ throw new Error(feeCheck.error || 'Transaction fee verification failed'); } + // The fee the review screen shows must be the one just computed from the transaction's own + // inputs and outputs, not `btc_fee` as the response asserts it. The bound above is + // deliberately loose enough to accommodate legitimate composers, so a response can pass it + // while claiming a smaller fee than the transaction actually pays — and the user would sign + // against the claim. Replacing the field here fixes every review screen at once, since they + // all render `result.btc_fee`. + if (feeCheck.computedFee !== undefined) { + const reportedFee = response.result.btc_fee; + // Contradicting a stated fee is worth telling the user about; filling in one the response + // never stated is not, so absence is corrected silently rather than reported as a + // discrepancy. + if (typeof reportedFee === 'number' && reportedFee !== feeCheck.computedFee) { + verificationWarnings.push( + `This transaction pays a ${feeCheck.computedFee} sat miner fee, though the composer ` + + `reported ${reportedFee}. The amount shown is the one the transaction pays.` + ); + } + response = { + ...response, + result: { ...response.result, btc_fee: feeCheck.computedFee }, + }; + } + // Account for every output: each must be the data output, an address the request names, or // change to one of our own addresses. Anything else rejects the transaction, so a response // that adds a recipient fails closed even though no field-level check covers it (ADR-019).