fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 30s

parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them.

An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed.

Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
This commit was merged in pull request #367.
This commit is contained in:
2026-08-23 21:19:04 +02:00
parent 75a5fa9891
commit 1b52aa1723
16 changed files with 1230 additions and 65 deletions

40
TODO.md
View File

@@ -122,6 +122,46 @@ but the review is broader than any of them.
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
that module is in the background bundle.
- 2026-08-23: An explorer that reports no `decimals` for a token no longer has a
scale invented for it before storage
([#349](https://git.eeqj.de/sneak/AutistMask/issues/349)).
`fetchTokenBalances()` did `parseInt(item.token.decimals || "18", 10)` on the
way in, so a token whose `decimals()` reverts was written to
`tokenBalances[].decimals` as a fabricated `18` that no reader could tell from
a real one. That is upstream of the resolve-or-refuse rule
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306),
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340)): both approval paths
read this stored value as an authoritative source, so the guess walked past
refusals that were intact and simply never fired. The stored value is now the
explorer's own answer or `null`, and both the ERC-20 amount line and the swap
lines reach `unknownDecimalsAmount()` on it. The history list's token
transfers carried the same `|| "18"` and now state base units with the scale
unknown rather than a quantity. A holding whose scale nothing knows carries
`balance: null` — unknown, not zero — and the balance list, the USD total, the
Send screen and the confirmation screen each say so instead of printing
`0.0000` for money that is really there. The uint8 check is one shared
`toDecimals()` rather than three copies, and it answers `0` for a real scale
of zero: `|| "18"` collapsed that to eighteen, the trap of
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
hold `18`s that cannot be told apart retroactively; they display exactly as
they do today until the next balance refresh, which rewrites `tokenBalances`
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
anywhere in `src/`; the literal `18`s that do remain are real data, not
defaults — 432 per-token `decimals: 18` entries in the bundled
`src/shared/tokenList.js`, and, outside that file, only native ETH's
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
is the explorer's answer alone and not the scale a screen renders at, so the
Send screen resolves through `resolveTokenDecimals()` like every other
consumer: reading the stored field raw carried a `null` into `estimateGas()`
for a bundled token such as WETH, which reported an unestimable network fee
and left Send disabled behind a message no retry could clear. Send resolves
with `wallets`, which adds the cross-address disagreement check the balance
list does not make, so the two can differ; where they do, the stored quantity
was computed at a scale Send has refused, and it is withdrawn with it. An
unknown scale is an unknown balance, and the user is told that rather than
that the fee could not be estimated.
- 2026-08-23: The background no longer reads or writes the shared `state`
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
also closes the cold-worker wrong-chain send