fix: sign the ERC-20 amount the send screen displayed (closes #305)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 23s

The wallet's own Send screen renders the amount, the balance and the symbol
from the block explorer's cached decimals, but confirmTx encoded the transfer
from decimals() read off the contract at signing time and nothing compared the
two. A token whose on-chain scale disagrees with the cached one -- an
upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
entry, a compromised Blockscout -- therefore signed an amount that was never
displayed, off by a power of ten for every decimal place of disagreement. The
reproduction on the issue approves 0.25 and signs 250,000,000,000.

The scale is now carried forward on the pending transaction, taken from the
same tokenBalances entry the screen's own numbers come from, and the contract's
decimals() is read at signing time only to be compared with it. A disagreement
is a refusal that names both numbers, never a preference for either: both
candidate transfers move an amount nobody approved. New
src/shared/transferAmount.js holds that check, as the confirmTx counterpart to
approvalVerify.js, and takes the same stance on an absent or unusable value --
a quantity that cannot be compared with what was displayed has not been
checked. The gas estimate encodes from the same carried value and no longer
reads decimals() at all, so the estimate is for the transfer that would be
signed.

Nothing in the e2e suite had ever clicked #btn-confirm-send, so the popup's own
Send -> ConfirmTx -> Sign & Send -> WaitTx path had no coverage at all, which is
how this shipped. It is now driven end to end to a broadcast, with the
transfer() amount hand-decoded out of the raw signed bytes and asserted against
the amount read off the confirmation screen, plus a case where the fixture's
decimals() starts answering 18 after the screen was built and nothing reaches
eth_sendRawTransaction. The fixture gains that override and a receipt, so the
wait screen resolves to the success view instead of polling for the rest of the
run.
This commit is contained in:
2026-08-20 10:14:55 +00:00
parent ff3387d8cf
commit 8fadc4107f
8 changed files with 570 additions and 16 deletions

21
TODO.md
View File

@@ -44,6 +44,27 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-20: The wallet's own ERC-20 send signs the amount it displayed
([#305](https://git.eeqj.de/sneak/AutistMask/issues/305)). The confirmation
screen renders from the block explorer's cached decimals; the transfer was
encoded from `decimals()` read off the contract at signing time, and nothing
compared the two, so a token whose on-chain scale disagreed — an upgradeable
or proxy token, a stale explorer entry, a compromised Blockscout — signed an
amount that was never on screen, off by a power of ten per decimal place of
disagreement. The scale is now carried forward on the pending transaction from
the same balance entry the screen's amount, balance and symbol come from, and
the contract's answer is read at signing time only to be compared with it: a
disagreement is a refusal naming both numbers, never a preference for either
(`src/shared/transferAmount.js`, the `confirmTx` counterpart to
`approvalVerify.js`). The gas estimate encodes from the same carried value and
no longer reads `decimals()` at all. Nothing in the e2e suite had ever clicked
`#btn-confirm-send`, which is how this shipped: the popup's own Send →
ConfirmTx → Sign & Send → WaitTx path now runs end to end to a broadcast, with
the `transfer()` amount decoded out of the raw signed bytes and asserted
against what the screen displayed, and a companion case where the contract
starts answering a different scale after the screen was built and nothing
reaches the RPC. Reverting only the signing-side comparison turns that second
case red and leaves the other 53 green.
- 2026-08-17: The Settings screen is driven in a browser, and every element id
the popup looks up is checked statically. Nothing exercised Settings in the
e2e suite, and jest runs with no DOM, so the densest run of `$("...")` lookups