Files
AutistMask/src/shared/transferAmount.js
clawbot 2f80a9bdb4
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
fix: sign the ERC-20 amount the confirmation screen displayed (closes #305)
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.
2026-08-20 12:31:28 +02:00

117 lines
5.0 KiB
JavaScript

// The base-unit amount an ERC-20 transfer from the wallet's own Send screen is
// encoded with.
//
// A token amount is a decimal string plus a scale, and the two come from
// different places. The confirmation screen renders the amount, the balance and
// the symbol from the block explorer's cached metadata (see
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
// decimals() read off the contract at signing time, and nothing compared the
// two. A token whose on-chain scale differs from the cached one — an
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
// entry — therefore signed an amount that was never displayed, off by a power
// of ten for every decimal place of disagreement.
//
// So the scale used to encode is the scale the screen rendered with, carried
// forward on the pending transaction, and the contract's own answer is read
// only to be compared with it. A disagreement is a refusal, never a preference
// for either number: the wallet cannot tell which of the two the user meant,
// and both candidate transfers move an amount nobody approved.
//
// This is the confirmTx counterpart to approvalVerify.js, which does the same
// job for the dApp approval path, and it takes the same stance: a quantity that
// cannot be compared with what was displayed has not been checked, so an absent
// or unusable value is refused rather than filled in.
//
// Every message here is shown to the user on the transaction error screen, so
// each is a full sentence and names the numbers it is refusing over.
const { parseUnits } = require("ethers");
// Solidity's decimals() returns a uint8, so anything outside that range is not
// an answer this wallet can use.
const MAX_DECIMALS = 255;
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
"The transfer was not sent, because the number of decimal places this" +
" amount was shown with is unknown, so the amount that would be signed" +
" cannot be shown to be the amount that was displayed.";
const UNREADABLE_CONTRACT_DECIMALS_MESSAGE =
"The transfer was not sent, because the token contract did not report a" +
" usable number of decimal places, so the amount that would be signed" +
" cannot be checked against the amount that was displayed.";
function mismatchMessage(displayed, onChain) {
return (
"The transfer was not sent. The token contract reports " +
onChain +
" decimal places, but the amount was displayed using " +
displayed +
", so signing it would move a different amount than the one shown." +
" Reopen the wallet to reload this token's details and try again."
);
}
// A decimals value from either source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, out of uint8 range, or of any other type at all is not.
//
// The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
// admit an empty array as a scale of zero and encode a whole-token transfer
// against it.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// The decimals the confirmation screen rendered an amount with, as a number.
// Throws when the pending transaction does not carry a usable one — which is
// also what keeps the gas estimate from quietly estimating a different transfer
// than the one that would be signed.
function displayedDecimals(value) {
const displayed = toDecimals(value);
if (displayed === null) {
throw new Error(UNKNOWN_DISPLAYED_DECIMALS_MESSAGE);
}
return displayed;
}
// The transfer amount in the token's base units, or a throw. `amount` is the
// decimal string the user typed and the screen displayed, `displayed` is the
// scale it was displayed at, and `onChain` is what the contract's decimals()
// answered at signing time. The two scales must agree.
function transferAmountUnits(amount, displayed, onChain) {
const shown = displayedDecimals(displayed);
const reported = toDecimals(onChain);
if (reported === null) {
throw new Error(UNREADABLE_CONTRACT_DECIMALS_MESSAGE);
}
if (reported !== shown) {
throw new Error(mismatchMessage(shown, reported));
}
return parseUnits(String(amount), shown);
}
module.exports = {
displayedDecimals,
transferAmountUnits,
mismatchMessage,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
};