fix: the dApp approval screen renders a 5,000-token transfer as "Amount 0.0000" #306

Closed
opened 2026-08-20 11:58:48 +02:00 by clawbot · 2 comments
Collaborator

Found by the pre-1.0 deployability audit (#303). Blocker: the user approves a drain that the screen shows as zero.

src/popup/views/approval.js:60-62:

const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;

decodeCalldata consults only the 512-entry bundled list. It never reads state.trackedTokens and never reads the decimals Blockscout already reported and stored in addr.tokenBalances. Every token outside the bundled list is formatted as 18 decimals.

Reproduction

transfer(0xC0FFEE..., 5000000000) on a 6-decimal token = 5,000 tokens:

[P2] J: calldata = 0xa9059cbb...000012a05f200 (= 5000.000000 units at 6 decimals)
[P2] J: approval screen text -----
    Action        Token Transfer / Transfer ERC-20 token
    Token         Unknown token  0xE2E0000000000000000000000000000000000E2e
    Recipient     0xC0FfEE0000000000000000000000000000c0fFEe
    Amount        0.0000
    Value         0.0000 ETH

The same understatement applies to approve, where an allowance draining a 6-decimal token also renders 0.0000.

Consequence

The user reads 0.0000, sees a token they hold listed as "Unknown token", and confirms. They lose the whole balance, or grant an unbounded allowance. Precondition: any ERC-20 outside the bundled top-list — which is most of them, including anything the user added by contract address.

Definition of done

  • The approval screen resolves decimals from the bundled list, then state.trackedTokens, then the address's own Blockscout-reported decimals.
  • Where none of those is available it refuses to render a formatted amount: show the raw base-unit integer with an explicit "decimals unknown" warning, never a number that reads as zero.
  • Covers approve as well as transfer.
  • Test: a 6-decimal non-bundled token transfer renders the true quantity, and an unknown-decimals token renders raw units plus the warning rather than a formatted figure.
  • make check green.
Found by the pre-1.0 deployability audit (https://git.eeqj.de/sneak/AutistMask/issues/303). **Blocker: the user approves a drain that the screen shows as zero.** `src/popup/views/approval.js:60-62`: ```js const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase()); const tokenSymbol = token ? token.symbol : null; const tokenDecimals = token ? token.decimals : 18; ``` `decodeCalldata` consults **only** the 512-entry bundled list. It never reads `state.trackedTokens` and never reads the decimals Blockscout already reported and stored in `addr.tokenBalances`. Every token outside the bundled list is formatted as 18 decimals. ## Reproduction `transfer(0xC0FFEE..., 5000000000)` on a 6-decimal token = 5,000 tokens: ``` [P2] J: calldata = 0xa9059cbb...000012a05f200 (= 5000.000000 units at 6 decimals) [P2] J: approval screen text ----- Action Token Transfer / Transfer ERC-20 token Token Unknown token 0xE2E0000000000000000000000000000000000E2e Recipient 0xC0FfEE0000000000000000000000000000c0fFEe Amount 0.0000 Value 0.0000 ETH ``` The same understatement applies to `approve`, where an allowance draining a 6-decimal token also renders `0.0000`. ## Consequence The user reads `0.0000`, sees a token they hold listed as "Unknown token", and confirms. They lose the whole balance, or grant an unbounded allowance. Precondition: any ERC-20 outside the bundled top-list — which is most of them, including anything the user added by contract address. ## Definition of done - [ ] The approval screen resolves decimals from the bundled list, then `state.trackedTokens`, then the address's own Blockscout-reported decimals. - [ ] Where none of those is available it **refuses to render a formatted amount**: show the raw base-unit integer with an explicit "decimals unknown" warning, never a number that reads as zero. - [ ] Covers `approve` as well as `transfer`. - [ ] Test: a 6-decimal non-bundled token transfer renders the true quantity, and an unknown-decimals token renders raw units plus the warning rather than a formatted figure. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-20 11:58:48 +02:00
Author
Collaborator

Plan:

New src/shared/approvalAmount.js (pure, no DOM):

  • resolveTokenDecimals(address, { trackedTokens, wallets }) — bundled TOKEN_BY_ADDRESS, then state.trackedTokens, then the explorer-reported decimals on any address's tokenBalances entry for that contract. Returns null when no source has a usable uint8, and also when two explorer entries for the same contract disagree, since a disputed scale is not a known one.
  • unknownDecimalsAmount(raw) — the refusal rendering: "5000000000 base units (decimals unknown)". No formatUnits call is reached on that path, so no guessed-scale number can be produced at all.

decodeCalldata in src/popup/views/approval.js calls both, for approve and transfer alike; the Unlimited allowance rendering is unaffected because it never needed a scale. The value also feeds pendingTxDetails.amount, so the status screens carry the same string rather than a formatted one.

src/shared/transferAmount.js is the send path's counterpart but goes the other way (decimal string plus scale to base units, refusing on a scale mismatch at signing time); the only part that fits here is its uint8 bound, which is imported rather than restated. Rationale goes in the PR body.

Symbol resolution (the Unknown token line) is untouched — outside this issue's stated scope.

Plan: New `src/shared/approvalAmount.js` (pure, no DOM): - `resolveTokenDecimals(address, { trackedTokens, wallets })` — bundled `TOKEN_BY_ADDRESS`, then `state.trackedTokens`, then the explorer-reported `decimals` on any address's `tokenBalances` entry for that contract. Returns `null` when no source has a usable uint8, and also when two explorer entries for the same contract disagree, since a disputed scale is not a known one. - `unknownDecimalsAmount(raw)` — the refusal rendering: `"5000000000 base units (decimals unknown)"`. No `formatUnits` call is reached on that path, so no guessed-scale number can be produced at all. `decodeCalldata` in `src/popup/views/approval.js` calls both, for `approve` and `transfer` alike; the `Unlimited` allowance rendering is unaffected because it never needed a scale. The value also feeds `pendingTxDetails.amount`, so the status screens carry the same string rather than a formatted one. `src/shared/transferAmount.js` is the send path's counterpart but goes the other way (decimal string plus scale to base units, refusing on a scale mismatch at signing time); the only part that fits here is its uint8 bound, which is imported rather than restated. Rationale goes in the PR body. Symbol resolution (the `Unknown token` line) is untouched — outside this issue's stated scope.
Author
Collaborator

Built as described, on issue-306-approval-decimals; PR #321 (base next) carries the detail.

Verification: make check green — 36 suites, 788 tests, test-verify-build 18 cases, check-censored 144 files; lint executed inside the pinned container rather than off cache.

The new tests were proved non-vacuous by restoring only the old lookup (token ? token.decimals : 18): 6 of the 15 fail, and the unknown-decimals transfer case then receives exactly "0.0000" — this issue's screen output — while the other 782 tests in the suite stay green, so nothing existing depended on the 18 default.

Built as described, on `issue-306-approval-decimals`; PR [#321](https://git.eeqj.de/sneak/AutistMask/pulls/321) (base `next`) carries the detail. Verification: `make check` green — 36 suites, 788 tests, `test-verify-build` 18 cases, `check-censored` 144 files; lint executed inside the pinned container rather than off cache. The new tests were proved non-vacuous by restoring only the old lookup (`token ? token.decimals : 18`): 6 of the 15 fail, and the unknown-decimals `transfer` case then receives exactly `"0.0000"` — this issue's screen output — while the other 782 tests in the suite stay green, so nothing existing depended on the 18 default.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#306