toDecimals() accepted any uint8 scale, but formatUnits() and parseUnits() refuse more than 80 decimal places. A token reporting 81 to 255 made the formatter throw, and the catch in the swap decoder and in the ERC-20 decoder turned that into an undecoded approval screen with nothing saying why. MAX_DECIMALS is now 80, the formatter's own limit, so such a scale is treated exactly like an unknown one: both approval paths show the base-unit amount with the scale stated as unknown. The balance list, the history list and the Send screen use the same check. Model: opus-5-5
129 lines
5.8 KiB
JavaScript
129 lines
5.8 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, but ethers' formatUnits() and
|
|
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
|
|
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
|
|
// encoded, so it is not an answer this wallet can use, the same as no answer.
|
|
const MAX_DECIMALS = 80;
|
|
|
|
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 any 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, above MAX_DECIMALS, 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. Absence answers null and never a default, and a real scale of
|
|
// ZERO answers 0 — the two are different answers, which is the whole point:
|
|
// a falsy-collapsing `value || 18` cannot tell them apart, and neither can a
|
|
// reader of what it wrote (https://git.eeqj.de/sneak/AutistMask/issues/246).
|
|
//
|
|
// Exported because every module that has to decide whether it knows a token's
|
|
// scale needs exactly this test, and three separate copies of it is three
|
|
// places for the answer to drift: approvalAmount.js resolves the scale the
|
|
// approval screens display at, and balances.js decides what the explorer
|
|
// actually reported before it is stored.
|
|
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,
|
|
toDecimals,
|
|
MAX_DECIMALS,
|
|
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
|
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
|
};
|