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).