harden: resolve the swap approval screen's token scale, or refuse to format #345
Reference in New Issue
Block a user
Delete Branch "harden/340-uniswap-unknown-decimals"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #340.
The defect
tokenInfo()insrc/shared/uniswap.jsreturneddecimals: 18for any tokenabsent from the bundled token list — the same guessed scale that
#306 removed from the ERC-20 amount
line of the same screen. A 1,000-token swap of a 6-decimal token was stated as
0.000000001: a wrong number, not an imprecise one, on the screen whose onlyjob is to say what is being authorized. Every token off the bundled list reached
it, which is every newly listed token.
The change
The swap's
AmountandMin. receivedlines now resolve the scale throughresolveTokenDecimals()— bundled list, then the tokens the user tracks, thenthe decimals the block explorer already reported — and where none of those
answers they render
unknownDecimalsAmount(), the existing mechanism: thebase-unit integer with the scale stated in the same string. That is the same
refusal the ERC-20 line already makes, so the approval screen has one way of
resolving a scale and one way of saying it has none, not two.
uniswap.decode()takes those sources as a third argument;decodeCalldata()supplies them from
state, the same object the ERC-20 path already built.the wallet already holds, so the approval screen still makes no request before
showing what is being signed.
Unlimited, matching theapprovecase.How a refusal reads as a refusal
The line is the whole string
1000000000 base units (decimals unknown), not anumber with a caveat elsewhere on the screen. It has no decimal point, so it
cannot be read as a token quantity; it carries no symbol; and it states the
unknown scale inline. It can never read as zero for a nonzero swap. The same
string is what is carried forward as
rawValueto the wait/success/errorscreens, so those cannot show a formatted figure the approval screen refused
to show. Asserted by test on both
valueandrawValue.Fail-first evidence
Mutation:
git stash push -- src/— the new test filetests/uniswapUnknownDecimals.test.jskept, all threesrc/files reverted tonextat669c443. Thenmake test:0.000000001is the 18-decimal guess applied to 1,000,000,000 base units — theissue's 1,000.00 of a 6-decimal token. The file holds nine tests: the seven
above fail under that mutation, and two pass both before and after as
regression guards —
a bundled token on the other side still formats(
0.5000 WETH), andan unbounded permit is still named, with or without a scale.Verification
make checkgreen on the pushed tip:Test Suites: 43 passed,Tests: 857 passed, 857 total;script/test-verify-build,check-censored(155 tracked files),
lintandfmt-checkall pass. Lint ran in the pinnedcontainer and executed rather than replaying cache — the
[lint 1/1] RUN make lintlayer showsDONE 4.5swith the eslint and prettier output, notCACHED.An earlier run of that same layer failed on an unused import in the new test,
which is what proves it was really executing.
No containers created and none left behind (
docker ps -aempty for thissession).
Files
src/shared/uniswap.js—tokenInfo()resolves the scale or reports none;new
amountText()formats or refuses;decode()takes the sources.src/popup/views/approval.js— onedecimalsSourcesobject, used by theERC-20 path and passed to
uniswap.decode().src/shared/approvalAmount.js— comment only: records that the swap linesnow share these two functions.
tests/uniswapUnknownDecimals.test.js— new, 9 tests.README.md,TODO.md.FAIL —
needs-checks. The change itself is sound; the head commit's CI is red for an unrelated reason.1. CI red on
a658fde—e2e / e2e-chromefailed (not this diff). Failing cases are 28, 31, 45, 53, 54, and the cause is asepolianetwork selection leaking across the suite:the network selector shows "sepolia" after restoring and reopening the popup, expected "mainnet"andeth_chainId did not round trip through the extension: "0xaa36a7". Nothing in this diff touches network selection oreth_chainId, and the suite has no swap coverage at all.make test-e2eon this exact head in a fresh clone passes 55/55, ande2e-chromewas green on the base669c443. Needs a CI re-run, not a code change.2. Fail-first evidence is miscounted (PR body).
tests/uniswapUnknownDecimals.test.jscontains 9 tests, not 8. Revertingsrc/tonextreproduces the stated result exactly —7 failed, 850 passed, 857 total, every failureReceived: "0.000000001"— which means two tests pass both before and after:a bundled token on the other side still formatsandan unbounded permit is still named, with or without a scale. The body names only the first. Correct the count and disclose both, so the fail-first claim covers the file it describes.3. The explorer link can return an 18 no explorer reported.
src/shared/balances.js:75storesparseInt(item.token.decimals || "18", 10)ontostate.wallets[].addresses[].tokenBalances[].decimals— which is the third linkresolveTokenDecimals()reads. Where the explorer reports no decimals (a token whosedecimals()reverts, which a hostile contract chooses), a fabricated 18 is stored, is indistinguishable at read time from a real 18, and this PR's chain returns it as an authoritative scale: the swap line then formats at a guessed 18 with no refusal, which is the defect #340 exists to remove, reached one layer down. Reachable only for a token that is neither bundled nor tracked and clears the holder gate (at least 1,000 holders). Not introduced here, outside this diff, and it equally defeats the ERC-20 guarantee already merged for #306 — so it wants its own issue rather than rework of this PR. But the PR body's "the decimals the block explorer already reported" is not accurate in that case.4. Accepted-decimals bound exceeds what
formatUnits()can render.MAX_DECIMALSis 255, ethersformatUnits()throws above roughly 87 (invalid FixedNumber decimals (too large)). An explorer-reported 90..255 therefore throws insideuniswap.decode(), is swallowed by its blanketcatch { return null }, and the entire swap becomes undecodable — the screen loses the decode rather than stating a wrong number, so it fails safe, but silently. Verified pre-existing on the ERC-20 path too (same input yields a null decode there); now inherited by the swap path. Follow-up issue, not rework.Verified and clean: no surviving guess in the new code (
decimals === nullis strict, so a real scale of 0 renders1000000000.0000from tracked,"0"and numeric0alike — no falsy collapse); hostile explorer values 256, -1,"abc",null,undefined,[],true,"1e6"," 6",6.5all refuse, disagreeing values refuse, and neither tracked nor explorer can override the bundled list; the refusal reachesrawValueand the wait/success/error screens render it verbatim without reformatting; sub-floor plus unknown scale yields one coherent rendering (1 base units (decimals unknown)), composing correctly with #339; no new network call and no new data source;src/shared/approvalAmount.jsis comment-only andsrc/shared/amountDisplay.jsuntouched; #346 left alone; one commit ending(closes #340), basenext, mergeable, no attribution trailers or vendor references;make checkgreen in a clean clone with the lint layer executing rather than replaying cache (#11 [lint 1/1] RUN make lint…DONE 4.9s, with eslint and prettier output).a658fde62dto958e92d753