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.
The send screen was built from the indexer's decimals while the transfer was
encoded from the contract's decimals() read at signing time, with nothing
comparing them. A token whose scales disagree moved 10^12 times the approved
amount.
The displayed scale is now carried on pendingTx from the same tokenBalances
entry the amount, balance and symbol were rendered from, and both encode sites
use it. transferAmount.js refuses rather than falling back when the two scales
disagree or either is unusable.
Adds the first end-to-end coverage of the popup's own Send -> ConfirmTx ->
Sign & Send path; #btn-confirm-send had never been clicked by any test.