fix: sign the ERC-20 amount the send screen displayed (closes #305) #314
Reference in New Issue
Block a user
Delete Branch "issue-305-erc20-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 #305.
What changed
src/shared/transferAmount.js(new):transferAmountUnits(amount, displayedDecimals, contractDecimals)returns base units only when the two scales agree, and otherwise throws a full sentence naming both. An absent or unusable displayed value is a refusal too. This is theconfirmTxcounterpart toapprovalVerify.jsand takes the same stance: a quantity that cannot be compared with what was displayed has not been checked. Decimal types are enumerated rather than coerced —Number([])is0, and a coercing check would admit an empty array as a scale of zero.src/popup/views/send.js: carriestokenDecimalson the pending transaction, from the sametokenBalancesentry the screen's amount, balance and symbol come from.src/popup/views/confirmTx.js: encodes from that carried value in both places it encodes. The gas estimate no longer readsdecimals()at all, so it estimates the transfer that would actually be signed; the signing path reads the contract'sdecimals()purely to compare, and refuses on a disagreement rather than preferring either value.README.md: the new ConfirmTx transition.TODO.md: Completed Steps entry.Tests
tests/transferAmount.test.js: agreement, disagreement in both directions, absent displayed scale, non-integer / out-of-uint8 / wrong-type values from either side, an amount finer than the token's scale, and that every refusal is a full sentence.tests/e2e/run.js: the suite had never clicked#btn-confirm-send, so the popup's own Send → ConfirmTx → Sign & Send → WaitTx path had no coverage at all — which is how this shipped. Two new cases:transfer()hand-decoded out of the raw signed bytes (not through an ethersInterfacebuilt from the extension's own ABI) and its amount asserted against the amount read off#confirm-amount;decimals()flipped to 18 after the screen was built and its estimate taken, asserting the error screen names both scales and thatbroadcastTransactionsdid not grow.tests/e2e/network.js:opts.tokenDecimalsOverride(the lying contract, read at request time) andopts.seedReceipt(so the wait screen resolves to the success view rather than polling for the rest of the run).eth_getTransactionReceiptmoved out of the constant table into its own branch.Verification
make checkgreen on the rebased branch at8fadc41:make test-e2egreen, 54/54, run in the pinned container. The two new cases and their probe lines:Mutation check, so the new coverage is not vacuous: with only the signing-side comparison reverted to
parseUnits(pendingTx.amount, await contract.decimals()), the suite goes to 53/54 withnot ok 43and everything else green. Case 42 stays green under that mutation by design — the fixture's two scales agree, so it covers the path, and case 43 makes the discrimination.The Firefox suite was not run: it has no ConfirmTx coverage and nothing here touches its fixtures.
PASS. Independently verified in a fresh clone at
8fadc41:make checkgreen (32 suites / 758 tests,test-verify-build18 cases, lint stage executed in-container in 5.8s, notCACHED),make test-e2egreen 54/54 with the same probe lines, CI green on the head commit, basenext, mergeable withnext, one commit titled(closes #305)carrying theTODO.mdupdate, no attribution trailers.Mutation results, both performed here rather than taken on trust:
parseUnits(pendingTx.amount, await contract.decimals()): 53/54,not ok 43and nothing else. Confirms the author's claim, including that case 42 stays green under it.send.jshandingconfirmTxa scale other than thetokenBalancesentry's):not ok 42andnot ok 43. The other way this can break is caught too.Coverage limit, stated because no test establishes it: the fixture's explorer and contract scales agree, so nothing in the suite would catch a carried value that came from a second lookup which merely happens to agree. That the value is the same one the screen rendered from was established by inspection instead —
send.jsreadstb.decimalsoff the sametbobject it readstb.balancefrom,tb.balanceis formatted withtb.decimalsinfetchTokenBalances(), andaddr.tokenBalancesis written by nothing else.ERC20_ABIhas exactly two encode sites, both now on the carried value;approval.jsonly decodes.Non-blocking, worth a follow-up rather than a rework:
tests/e2e/network.js,ethCallResult():word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals)ignores an override of0because it is falsy, so a future test making the contract claim zero decimals — a real lying-token case — would pass vacuously against the fixture's own 6. Acceptable looks like an explicit== nullcheck. The JSDoc also types the override{string}where it is a scale.env.routeOptsnor the popup's screen when they fail:seedReceipt = falseandtokenDecimalsOverride = nullsit after awaits that can throw. Observed in mutation 2 above — two genuine failures cascaded into seven unrelated ones (48 through 54, "the extension opened no approval window"). Given the suite's known load sensitivity (#287) a single flake here reads as an eight-failure run. Afinallyrestoring both flags and returning to the address screen would contain it.#view-error-txtimeout, so its stronger assertion (nothing reachedeth_sendRawTransaction) never executes on the failing path. Asserting the broadcast count before waiting on the screen would make the failure both faster and more precise.mismatchMessageis exported fromsrc/shared/transferAmount.jsand used nowhere outside it.transferAmountUnits("-1", 6, 6n)returns-1000000n. Unreachable —send.jsandvalidateTransfer()both refuse a negative amount and ethers will not ABI-encode a negativeuint256— so no funds risk, but the module is the last gate before encoding.Probed and clean:
absent,null,undefined,0,"0","","007","255","256","6.0","0x6","1e1","+6","-0", leading/trailing whitespace,NaN,Infinity, floats, negatives,-1n,2n**70n,true,{},[],[6],new Number(6), and an object with avalueOf— every one refuses rather than defaulting, and theNumber([]) === 0claim holds because arrays never reach a numeric branch. Refusals reach the user as full sentences on ErrorTx, not as an unhandled throw; a missing carried scale also fails the estimate, andFEE_UNAVAILABLEkeeps Send disabled.