Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/contexts/__tests__/composer-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 25 additions & 1 deletion src/contexts/composer-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ export function ComposerProvider<T>({
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;
Expand Down Expand Up @@ -473,6 +474,29 @@ export function ComposerProvider<T>({
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).
Expand Down
Loading