fix: the popup's own ERC-20 send signs an amount it never displayed (indexer decimals vs contract decimals) #305
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the pre-1.0 deployability audit (#303). Blocker: signs a transfer the user did not approve.
src/popup/views/confirmTx.js:448-450:The confirmation screen is built entirely from the indexer's decimals (
src/shared/balances.js:75-76). The transfer is encoded from the contract'sdecimals(), read at signing time. Nothing compares the two. Unlike the dApp approval path there is noapprovalVerifyequivalent here at all —confirmTxsigns and broadcasts directly.Reproduction
Blockscout reports 6 decimals; an overlay makes
decimals()answer 18 at signing time only:The user approved
0.25. The signed artifact moves 250,000,000,000 tokens — 10^12 times more.Consequence
The user loses their entire balance of any token whose on-chain
decimals()disagrees with the indexer's cached value: an upgradeable or proxy token, a caller-dependent token, a stale or wrong indexer entry, or a compromised Blockscout. Precondition: the user sends a non-bundled ERC-20 through the wallet's own Send screen.Related: the Chrome e2e suite never clicks
#btn-confirm-send, so the popup's own Send → ConfirmTx → Sign & Send → WaitTx path has no end-to-end coverage at all. That is how this survived.Definition of done
pendingTx, not re-read at signing time.decimals()at signing time fails to broadcast.#btn-confirm-sendto a broadcast, and the signed artifact's amount is asserted against what the confirmation screen displayed.make checkgreen.Plan:
send.jscarriestokenDecimalson the object it hands toshowConfirmTx, taken from the sametokenBalancesentry the screen's symbol and balance come from. Every token the send dropdown offers is built fromaddr.tokenBalances, which onlyfetchTokenBalances()writes, so a selectable token always has a decimals value.src/shared/transferAmount.js, theconfirmTxcounterpart toapprovalVerify.js:transferAmountUnits(amount, displayedDecimals, contractDecimals)returns base units only when the two agree, and otherwise throws a full sentence naming both numbers. Absent or unusable displayed decimals is a refusal too — an amount that cannot be compared with what was shown has not been checked.confirmTx.jsencodes frompendingTx.tokenDecimalsin both places it encodes: the gas estimate (which no longer readsdecimals()at all) and the signing path, where the contract'sdecimals()is read purely to be compared. Neither value is silently preferred.Tests:
transferAmount.js(agreement, disagreement in both directions, absent displayed value, non-integer / out-of-uint8 values).#btn-confirm-sendto a broadcast, asserting the signed artifact'stransfer()amount against the amount read off the confirmation screen; and the same path with the fixture'sdecimals()flipped to 18 after the screen was built, asserting the error screen names both numbers and that nothing reachedeth_sendRawTransaction. The fixture gains adecimals()override and a receipt so the wait screen resolves rather than polling for the rest of the run.Built as planned, in #314 (branch
issue-305-erc20-decimals, basenext).The scale is carried on
pendingTx.tokenDecimalsfrom thetokenBalancesentry the screen's own amount, balance and symbol come from;confirmTxencodes from it in both places it encodes, and reads the contract'sdecimals()at signing time only to compare. A disagreement throws a sentence naming both numbers and reaches the error screen. The gas estimate no longer callsdecimals()at all.Verification:
make checkgreen: 758 tests in 32 suites,test-verify-build18 cases, lint and format clean in the pinned container.make test-e2egreen, 54/54. The reproduction from the issue body, now as an assertion:parseUnits(pendingTx.amount, await contract.decimals())gives 53/54 withnot ok 43, everything else green.Case 42 covers the path that had no coverage —
#btn-confirm-sendwas never clicked anywhere in the suite before this — and stays green under that mutation on purpose, since the fixture's two scales agree there; case 43 is the one that discriminates.