parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them. An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed. Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
127 lines
5.6 KiB
JavaScript
127 lines
5.6 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 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, 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. 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,
|
|
};
|