tokenInfo() in src/shared/uniswap.js returned decimals: 18 for any token absent from the bundled token list, the same guessed scale that issue 306 removed from the ERC-20 amount line of the same screen. A 1,000-token swap of a 6-decimal token was therefore stated as 0.000000001, a wrong number rather than an imprecise one, and every newly listed token reached it. The swap's Amount and Min. received lines now resolve the scale through resolveTokenDecimals() -- the bundled list, then the tokens the user tracks, then the decimals the block explorer reported -- and where none of those answers they render unknownDecimalsAmount(), the base-unit integer with the scale stated, which is the same refusal the ERC-20 line already makes. No new data source and no network call: the scale comes only from what the wallet already holds, so the screen still makes no request before showing what is being signed. uniswap.decode() takes those sources as a third argument, supplied by decodeCalldata() from state alongside the ones the ERC-20 path already used. An unbounded permit needs no scale to describe and is still shown as Unlimited. README.md records the rule as a Display Consistency exception.
108 lines
4.4 KiB
JavaScript
108 lines
4.4 KiB
JavaScript
// The scale an ERC-20 amount in a dApp's calldata is displayed with, and what
|
|
// to display when there is no such scale.
|
|
//
|
|
// The approval screen decodes `transfer` and `approve` calldata into a
|
|
// quantity the user confirms against. That quantity is a base-unit integer,
|
|
// and turning it into a number a person can read needs the token's decimals.
|
|
// Assuming a scale is how a drain gets confirmed: a `transfer` of 5000000000
|
|
// units of a 6-decimal token is 5,000 tokens, but formatted with the ERC-20
|
|
// default of 18 it reads `0.0000`, and a user who reads zero signs.
|
|
//
|
|
// So a scale is either found or the amount is not formatted. Decimals are
|
|
// looked for in the bundled token list, then in the tokens the user tracks,
|
|
// then in what the block explorer reported for the contract; where none of
|
|
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
|
|
// unknown scale stated, and no formatUnits() call is reached at all.
|
|
//
|
|
// The Uniswap decoder's Amount and Min. received lines land on this same
|
|
// screen and use these same two functions, so there is one way of resolving a
|
|
// scale and one way of saying there is none.
|
|
//
|
|
// This is the display counterpart to transferAmount.js, which takes the same
|
|
// stance on the wallet's own send path: an amount whose scale is unknown or
|
|
// disputed is refused rather than guessed at.
|
|
|
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
|
// reporting that call's result.
|
|
const { MAX_DECIMALS } = require("./transferAmount");
|
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
|
|
// A decimals value as a number, or null if it is not one. The bundled list
|
|
// stores numbers, the explorer's copy arrives as a string, and a token the
|
|
// user added by hand can carry whatever lookupTokenInfo() got back, so the
|
|
// accepted types are enumerated rather than coerced: Number([]) is 0 and
|
|
// Number(true) is 1, so a coercing check would read an empty array as a scale
|
|
// of zero and format the amount as whole tokens.
|
|
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;
|
|
}
|
|
|
|
// Every decimals the explorer reported for this contract, across all the
|
|
// addresses whose balances have been fetched. They describe one contract, so
|
|
// they should agree; a set that does not agree is a scale in dispute, and this
|
|
// screen has no way to tell which member is the true one.
|
|
function explorerDecimals(lower, wallets) {
|
|
let found = null;
|
|
for (const wallet of wallets || []) {
|
|
for (const addr of wallet.addresses || []) {
|
|
for (const tb of addr.tokenBalances || []) {
|
|
if ((tb.address || "").toLowerCase() !== lower) continue;
|
|
const d = toDecimals(tb.decimals);
|
|
if (d === null) continue;
|
|
if (found !== null && found !== d) return null;
|
|
found = d;
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// The decimals to render a token amount with, or null when nothing knows.
|
|
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
|
|
function resolveTokenDecimals(tokenAddress, sources) {
|
|
const lower = (tokenAddress || "").toLowerCase();
|
|
if (!lower) return null;
|
|
|
|
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
|
if (bundled) {
|
|
const d = toDecimals(bundled.decimals);
|
|
if (d !== null) return d;
|
|
}
|
|
|
|
const tracked = ((sources && sources.trackedTokens) || []).find(
|
|
(t) => (t.address || "").toLowerCase() === lower,
|
|
);
|
|
if (tracked) {
|
|
const d = toDecimals(tracked.decimals);
|
|
if (d !== null) return d;
|
|
}
|
|
|
|
return explorerDecimals(lower, sources && sources.wallets);
|
|
}
|
|
|
|
// What the amount line reads when the scale is unknown. The base units are
|
|
// exact and the caveat is part of the same string, so the number on the screen
|
|
// cannot be mistaken for a token quantity, and it can never read as zero for a
|
|
// transfer that is not zero.
|
|
function unknownDecimalsAmount(rawAmount) {
|
|
return String(rawAmount) + " base units (decimals unknown)";
|
|
}
|
|
|
|
module.exports = {
|
|
resolveTokenDecimals,
|
|
unknownDecimalsAmount,
|
|
};
|