decodeCalldata consulted only the 512-entry bundled list and defaulted to 18 decimals, so a transfer of 5,000 units of a 6-decimal token rendered "Amount 0.0000" and the user confirmed a drain reading zero. The same understatement applied to approve, where an unbounded allowance also rendered 0.0000. Decimals now resolve from the bundled list, then trackedTokens, then the address's explorer-reported entry, with uint8 validation and a refusal when sources for one contract disagree. When no source knows the scale, no formatUnits call is reached at all: the line renders raw base units with an explicit "decimals unknown" warning, and the same string reaches pendingTxDetails.amount so the status screens carry no formatted figure either. Verified failing first two independent ways: restoring the old `token ? token.decimals : 18` fails 6 of 15 new tests with the unknown case reporting "0.0000"; making the resolver return 18 rather than null on the unknown path fails a different 6, spanning resolver and render levels.
104 lines
4.2 KiB
JavaScript
104 lines
4.2 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.
|
|
//
|
|
// 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,
|
|
};
|