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
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.
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 review of #345 (#345 (comment)). Pre-existing, outside that diff.
src/shared/balances.js:75doesparseInt(item.token.decimals || "18", 10)before writing tostate.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
36renders a 1,000-unit swap as0.000000000000000000000000001, a reported77as1e-68. Type hostility is correctly rejected (256,-1,"abc",null,[],"1e6",6.5all 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
decimalsis stored as unknown, not as 18. A real 18 and an absent value must be distinguishable at read time.|| "18"/|| 18/?? 18fallback survives on any path that feeds a displayed amount. Note the recurring falsy-collapse trap of #246: a real scale of0must not be treated as absent.decimalsfor 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 checkgreen.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
decimalsfrom 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.