fix: balances.js fabricates decimals 18 before storage, laundering a guess into both approval paths #349

Closed
opened 2026-08-23 16:04:25 +02:00 by clawbot · 1 comment
Collaborator

Found by the review of #345 (#345 (comment)). Pre-existing, outside that diff.

src/shared/balances.js:75 does parseInt(item.token.decimals || "18", 10) before writing to state.wallets[].addresses[].tokenBalances[].decimals.

An explorer that reports no decimals — a token whose decimals() reverts — therefore yields a fabricated 18 that is indistinguishable from a real 18 at read time. Every later reader trusts it as resolved.

This defeats the guarantee of an issue already closed as fixed. #306 established "resolve the real scale or refuse to format" for the ERC-20 approval line, and #340 extended it to the swap line — but both read this stored value as an authoritative source, so a fabricated 18 flows straight through and the amount formats at a guessed scale with no refusal. The refusal path is intact; it simply never fires, because the guess was laundered upstream of it.

This is the real remaining hole in the resolve-or-refuse rule.

Open design question, recorded here rather than filed separately

The same review probed the trust chain and confirmed that an explorer-reported scale does size the number the user authorizes: a reported 36 renders a 1,000-unit swap as 0.000000000000000000000000001, a reported 77 as 1e-68. Type hostility is correctly rejected (256, -1, "abc", null, [], "1e6", 6.5 all refuse), disagreeing values across addresses refuse, and the bundled list can never be overridden — so this is bounded, and it is the deliberate design already merged for #306, not something new.

Still worth deciding explicitly: should an explorer-sourced scale be trusted to size an approval at all, or should a token that is neither bundled nor tracked be treated as unknown scale? Fixing the fabrication below does not answer that. Recommendation: fix the fabrication now, and treat tightening the trust model as a separate decision, since it would change behaviour on the already-merged ERC-20 path too.

Definition of done

  • An absent explorer decimals is stored as unknown, not as 18. A real 18 and an absent value must be distinguishable at read time.
  • Both approval paths (ERC-20 and swap) reach their existing refusal when the stored scale is unknown, rather than formatting at a fabricated 18.
  • No || "18" / || 18 / ?? 18 fallback survives on any path that feeds a displayed amount. Note the recurring falsy-collapse trap of #246: a real scale of 0 must not be treated as absent.
  • Test: an explorer response with no decimals for a token reaches a refusal on both the ERC-20 and swap approval lines. Fails against current head — state the mutation and observed result.
  • make check green.
Found by the review of https://git.eeqj.de/sneak/AutistMask/pulls/345 (https://git.eeqj.de/sneak/AutistMask/pulls/345#issuecomment-69081). Pre-existing, outside that diff. `src/shared/balances.js:75` does `parseInt(item.token.decimals || "18", 10)` **before writing** to `state.wallets[].addresses[].tokenBalances[].decimals`. An explorer that reports no decimals — a token whose `decimals()` reverts — therefore yields a **fabricated 18 that is indistinguishable from a real 18 at read time**. Every later reader trusts it as resolved. This defeats the guarantee of an issue already closed as fixed. https://git.eeqj.de/sneak/AutistMask/issues/306 established "resolve the real scale or refuse to format" for the ERC-20 approval line, and https://git.eeqj.de/sneak/AutistMask/issues/340 extended it to the swap line — but both read this stored value as an authoritative source, so a fabricated 18 flows straight through and the amount formats at a guessed scale with no refusal. The refusal path is intact; it simply never fires, because the guess was laundered upstream of it. This is the real remaining hole in the resolve-or-refuse rule. ## Open design question, recorded here rather than filed separately The same review probed the trust chain and confirmed that an explorer-reported scale **does size the number the user authorizes**: a reported `36` renders a 1,000-unit swap as `0.000000000000000000000000001`, a reported `77` as `1e-68`. Type hostility is correctly rejected (`256`, `-1`, `"abc"`, `null`, `[]`, `"1e6"`, `6.5` all refuse), disagreeing values across addresses refuse, and the bundled list can never be overridden — so this is bounded, and it is the deliberate design already merged for #306, not something new. Still worth deciding explicitly: should an explorer-sourced scale be trusted to size an approval at all, or should a token that is neither bundled nor tracked be treated as unknown scale? Fixing the fabrication below does not answer that. Recommendation: fix the fabrication now, and treat tightening the trust model as a separate decision, since it would change behaviour on the already-merged ERC-20 path too. ## Definition of done - [ ] An absent explorer `decimals` is stored as unknown, not as 18. A real 18 and an absent value must be distinguishable at read time. - [ ] Both approval paths (ERC-20 and swap) reach their existing refusal when the stored scale is unknown, rather than formatting at a fabricated 18. - [ ] No `|| "18"` / `|| 18` / `?? 18` fallback survives on any path that feeds a displayed amount. Note the recurring falsy-collapse trap of https://git.eeqj.de/sneak/AutistMask/issues/246: a real scale of `0` must not be treated as absent. - [ ] Test: an explorer response with no `decimals` for a token reaches a refusal on both the ERC-20 and swap approval lines. Fails against current head — state the mutation and observed result. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-23 16:04:25 +02:00
Author
Collaborator

Settling the open design question recorded in the issue body, so it does not outlive the fix: an explorer-sourced scale stays trusted. No further work; the resolve-or-refuse rule is complete once the fabrication is gone.

Reasoning: the wallet already trusts the explorer for the balances themselves. An explorer that lies about a token's scale can equally lie about the quantity, so sourcing decimals from it adds no trust assumption that is not already load-bearing — and the endpoint is user-configurable, which puts it in the same position as the RPC. The residual is a plausible-but-wrong value from a compromised explorer, which is bounded by the checks already in place: hostile types (256, -1, "abc", null, [], "1e6", 6.5) refuse, values disagreeing across addresses refuse, and the bundled list can never be overridden.

The alternative — treating any token that is neither bundled nor tracked as unknown scale — would refuse to state an amount for most real tokens, which trades a bounded and already-accepted trust dependency for a large loss of usefulness on the screen that most needs to be informative.

What this fix removes is different in kind and is the part that mattered: a scale nobody reported being invented and stored as though it had been.

Settling the open design question recorded in the issue body, so it does not outlive the fix: **an explorer-sourced scale stays trusted.** No further work; the resolve-or-refuse rule is complete once the fabrication is gone. Reasoning: the wallet already trusts the explorer for the balances themselves. An explorer that lies about a token's scale can equally lie about the quantity, so sourcing `decimals` from it adds no trust assumption that is not already load-bearing — and the endpoint is user-configurable, which puts it in the same position as the RPC. The residual is a plausible-but-wrong value from a compromised explorer, which is bounded by the checks already in place: hostile types (`256`, `-1`, `"abc"`, `null`, `[]`, `"1e6"`, `6.5`) refuse, values disagreeing across addresses refuse, and the bundled list can never be overridden. The alternative — treating any token that is neither bundled nor tracked as unknown scale — would refuse to state an amount for most real tokens, which trades a bounded and already-accepted trust dependency for a large loss of usefulness on the screen that most needs to be informative. What this fix removes is different in kind and is the part that mattered: a scale **nobody reported** being invented and stored as though it had been.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#349