fix: never render a nonzero approval amount as zero (closes #322) #339
Reference in New Issue
Block a user
Delete Branch "fix/322-nonzero-amount-display-floor"
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 #322.
The defect
Token and ETH amounts are truncated to 4 decimal places, per
README.md'sDisplay Consistency rule. With the token's true scale resolved, an amount below
0.0001 still printed as
0.0000— 1 base unit of an 18-decimal token, 500 baseunits of an 8-decimal one. On the dApp approval screen, and on the
wait/success/error screens that carry the same string forward as
txInfo.amount, a real transfer, allowance or swap was stated as nothing. Aswap's
Min. receivedis the sharper case: a slippage floor shown as0.0000states that the swap may return nothing.
All three copies of the truncation
The rule had been implemented three times. Each is named here with what
happened to it:
formatTxValue()insrc/popup/views/approval.js— the ERC-20 amount line,the ETH value and the max fee. Now floored.
formatAmount()insrc/shared/uniswap.js— theAmountandMin. receivedlines of a decoded Uniswap swap, on that same approvalscreen, with
Amount'srawValuecarried to the confirmation screens.Now floored. This copy was missed by the first round of this PR and is
the blocking finding of the review.
formatTxValue()insrc/shared/transactions.js— history and balancelists. Behaviour deliberately unchanged, per the DoD's out-of-scope
clause; the transaction detail view is the authoritative record and already
shows exact precision.
They were unified rather than left as three divergent copies: a fix applied to
one of them is exactly how the second one came to be missed.
src/shared/amountDisplay.jsnow holds the rule and its exception next to each other:
truncateAmount(val)— plain 4-decimal truncation. Used bysrc/shared/transactions.js.truncateAmountNeverZero(val)— the same, with the nonzero floor. Used bysrc/popup/views/approval.jsandsrc/shared/uniswap.js.The code path is shared; the policy is not. The lists keep their unfloored
rule, and a test pins that so the shared module cannot drift into a single
policy.
The floor itself
When the truncated string would contain no digit from 1 to 9 and the value
does, the amount extends to its first significant digit:
0.000000000000000001 DAI, not0.0000 DAI. Extra precision was chosen overfalling back to base units so the number stays in token units, the same unit as
the symbol beside it; the base-unit rendering already on this screen
(
unknownDecimalsAmount()) means "the scale is unknown", and reusing it for aknown scale would blur two different facts on the one screen that must state
exactly what is being authorized.
A genuine zero still renders
0.0000. An amount at or above the floor isuntouched. Truncation stays truncation:
0.99999shows as0.9999, neverrounded up. The test is on the whole truncated string, integer part included,
so
1.00005stays1.0000— the exception fires only where the entire figurewould read as zero.
README.md's Display Consistency section is corrected accordingly: the previouswording said "when the first 4 decimals would all be zero", which does not
describe what the code tests, and it named only one of the three call sites. It
now states the real condition, the
1.00005and0.99999cases, and which callsites take which function.
Fail-first evidence for the two new tests
tests/approvalDisplayFloor.test.jsnow drivesuniswap.decode()alongsidedecodeCalldata(), on aV2_SWAP_EXACT_INexecute() call (USDT → WETH).Mutation:
git checkout HEAD -- src/shared/uniswap.jsagainst the previous head3fb6a53, restoring the oldformatAmount()verbatim, with the new tests inplace. Observed,
Tests: 2 failed, 11 passed, 13 total:The first is 50 base units of 6-decimal USDT as the swap input; the second is an
amountOutMinof 1 wei. A third swap case pins the unchanged truncation(
1.0000, and0.9999for 999999999999999999 wei) and passes both before andafter — it is a regression guard, not fail-first.
The four ERC-20 cases from the previous round are unchanged and still fail
against
cef6aaawithsrc/popup/views/approval.jsreverted.Disclosure, since the issue names this case: 500 base units of a 6-decimal
token is 0.0005, which the 4-decimal rule already rendered correctly — that case
is not a defect and is kept only as a regression guard. The scales that actually
round to zero are 8 and 18 decimals.
Verification
make checkgreen, exit 0, on this exact tree:Test Suites: 42 passed, 42 total,Tests: 848 passed, 848 totaltest-verify-build: 39 case(s) passedcheck-censored: 153 tracked file(s) inspectedlintstage ran uncached(
#11 [lint 1/1] RUN make lint→DONE 4.8s),eslint . && prettier --check .,All matched files use Prettier code style!make fmtrun; the formattedREADME.mdandTODO.mdare in the commitnextatcef6aaaimmediately before pushing (no movement, noconflicts)
Not run:
make test-e2e/make test-e2e-firefox, which are not part ofmake check. No container or image was left behind (docker ps -aclean).Out of scope, noted
tokenInfo()insrc/shared/uniswap.jsreturnsdecimals: 18for a tokenabsent from the bundled list — a separate pre-existing defect, filed as
#340 and not touched here.
The balance-list quantities in
src/popup/views/helpers.jsandsrc/popup/views/home.jsusetoFixed(4)rather than this truncation; they arelists, out of scope by the DoD, and are unchanged.
FAIL —
needs-rework.1. The invariant does not hold on the approval screen: the Uniswap path still renders a nonzero amount as
0.0000.formatAmount()atsrc/shared/uniswap.js:37is a third copy of the 4-decimal truncation, untouched by this PR. It feeds theAmountandMin. receivedlines of the dApp approval screen throughuniswap.decode()(src/popup/views/approval.js:182), and itsrawValueis carried to the wait/success/error screens bysrc/popup/views/approval.js:271— the same route the fixed ERC-20 path takes. Reproduced against this head (3fb6a53) with aV2_SWAP_EXACT_INof 50 base units of USDT andamountOutMinof 1 wei:The DoD in #322 excludes only balance lists and history; this is the approval screen itself, so it is in scope. 0.00009 WBTC is roughly $9, so sub-floor swap legs are not theoretical, and
Min. receivedreading0.0000states the slippage floor as "you may receive nothing" — the same money-loss failure mode as #306 and #322. Acceptable:formatAmount()gets the same floor (or all three truncators share one helper), with tests covering a sub-floor swap input amount and a sub-floor minimum output.2. The disclosure is incomplete. The PR body names two copies of the truncation and argues the README exception scopes the divergence; there are three, and the third is on the screen the fix claims to have fixed. Consequently the README's new exception overclaims: "on the dApp approval screen and the wait/success/error screens ... the amount is extended to its first significant digit" is true only of the ERC-20 and native-ETH lines as shipped.
3. README wording, minor. "when the first 4 decimals would all be zero and the value is not, the amount is extended to its first significant digit" does not match the code —
src/popup/views/approval.js:55tests/[1-9]/against the whole truncated string, so1.00005still renders1.0000(which is correct). The next sentence covers the case, but the stated condition is wrong standing alone.Verified and passing: fail-first reproduced exactly (reverting
src/popup/views/approval.jstonextgives 1 failed suite / 4 failing cases with the stated output); truncation is still truncation, not rounding (0.99999renders0.9999); a genuine zero still renders0.0000;decimals: 0resolves to 0 with no falsy trap; negative and sub-floor-plus-integer inputs hold the invariant;make checkgreen in an independent clone (42 suites, 844 tests, lint stage ran uncached in the pinned container, prettier clean); CI green on3fb6a53; base isnext; merges cleanly; scope confined to the 4 files with no balance-list or history change; no prohibited attribution anywhere; commit title carries(closes #322).Confirming the author's disclosure: 500 base units of a 6-decimal token is 0.0005 and already renders
0.0005against head, so the issue's headline example is arithmetically wrong and that DoD test case cannot fail first. The 8- and 18-decimal substitutes plus the every-scale loop are adequate coverage for that bullet.Disclosure: a scratch probe test was written into the review clone to produce the Uniswap output above and deleted immediately; nothing was committed or pushed.
3fb6a53f77to5f155f6ba6PASS — independent re-review; no defects found.
Anomalies and disclosures, none blocking:
src/issrc/shared/amountDisplay.js, reached bysrc/popup/views/approval.js,src/shared/uniswap.jsandsrc/shared/transactions.js. The remaining amount-to-string sites are a different (6-decimal, trailing-trimmed) rule insrc/shared/balances.js:31,39andsrc/popup/views/confirmTx.js:299, which render a sub-1e-6 quantity as0.0in the send screen'sCurrent balanceand the send-confirm screen'sBalanceand fee lines. Pre-existing, untouched here, and outside #322's scope (balances) — but it is the one place adjacent to a confirmation screen where a nonzero quantity still reads as zero, and the approval screen's max fee is now floored while the send screen's is not. Worth a separate issue, not a change to this PR.src/popup/views/helpers.js:203andsrc/popup/views/home.js:71is honest: both are balance-list rows, and the approval/wait/success/error screens render onlytxInfo.amount,decoded.detailsand the fee, none of which route through them. Noting that both usetoFixed(4), which rounds rather than truncates, so they can both overstate and zero out — again pre-existing and out of scope.src/shared/uniswap.jsandsrc/popup/views/approval.jstonextto reproduce the fail-first evidence. Both were reverted; nothing was committed or pushed, and the tree is back at5f155f6.Verified: fail-first reproduced exactly (reverting
src/shared/uniswap.js— blob09f8c6a2at both3fb6a53andnext, so the stated mutation is exact — gives2 failed, 846 passed, with the two claimed expected/received strings; the third swap case passes both before and after, as labelled; revertingsrc/popup/views/approval.jsgives the 4 ERC-20 failures).truncateAmountis byte-identical in behaviour to both removed copies across a 20-case corpus including malformed input, so the history path is unchanged. Own probes ontruncateAmountNeverZero: decimals 0 renders1.0000with no falsy trap, decimals 1-5 exact,1.00005to1.0000,0.99999to0.9999, exact zero to0.0000at every scale 0-30, negatives,5.,.5, integer-only, 19-digit integer parts, plus a 2000-case fuzz asserting output never exceeds input and never loses the last significant digit — no nonzero rendering as zero and no zero rendering as nonzero.tokenInfo()'sdecimals: 18default untouched (#340).make checkgreen in my own clone with the lint stage running uncached in the pinned container (#11 [lint 1/1] RUN make lint...DONE 5.2s), 42 suites / 848 tests, prettier clean. CI green on5f155f6(all 3 statuses). Basenext, one commit, title carries(closes #322), mergeable, README prose matches the code and names all three call sites, no prohibited attribution anywhere.5f155f6ba6to578a3bc862