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
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:decodeCalldataconsults only the 512-entry bundled list. It never readsstate.trackedTokensand never reads the decimals Blockscout already reported and stored inaddr.tokenBalances. Every token outside the bundled list is formatted as 18 decimals.Reproduction
transfer(0xC0FFEE..., 5000000000)on a 6-decimal token = 5,000 tokens:The same understatement applies to
approve, where an allowance draining a 6-decimal token also renders0.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
state.trackedTokens, then the address's own Blockscout-reported decimals.approveas well astransfer.make checkgreen.Plan:
New
src/shared/approvalAmount.js(pure, no DOM):resolveTokenDecimals(address, { trackedTokens, wallets })— bundledTOKEN_BY_ADDRESS, thenstate.trackedTokens, then the explorer-reporteddecimalson any address'stokenBalancesentry for that contract. Returnsnullwhen 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)". NoformatUnitscall is reached on that path, so no guessed-scale number can be produced at all.decodeCalldatainsrc/popup/views/approval.jscalls both, forapproveandtransferalike; theUnlimitedallowance rendering is unaffected because it never needed a scale. The value also feedspendingTxDetails.amount, so the status screens carry the same string rather than a formatted one.src/shared/transferAmount.jsis 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 tokenline) is untouched — outside this issue's stated scope.Built as described, on
issue-306-approval-decimals; PR #321 (basenext) carries the detail.Verification:
make checkgreen — 36 suites, 788 tests,test-verify-build18 cases,check-censored144 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-decimalstransfercase 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.