The approval and transaction-status screens read a token's scale from the bundled list, the tokens the user tracks, then the block explorer, but read its symbol from the bundled list alone. A token the user added by hand was scaled correctly yet labelled "Unknown token", and a non-bundled ERC-20 was carried onto the wait screen as ETH. resolveTokenSymbol() now draws the symbol through the same sources and precedence as the scale, and the ERC-20 and Uniswap swap lines both use it. A tracked or explorer-reported name stays subject to the spoof rule, so it cannot claim a bundled or native ticker. Folds in #354. Model: opus-4-8 Co-authored-by: clawbot <clawbot@noreply.example.org>
144 lines
6.3 KiB
JavaScript
144 lines
6.3 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. toDecimals() is that check, shared with the
|
|
// send path rather than copied: the bundled list stores numbers, the
|
|
// explorer's copy arrives as a string, and a token the user added by hand
|
|
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
|
// enumerated rather than coerced.
|
|
const { toDecimals } = require("./transferAmount");
|
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Every symbol the explorer reported for this contract, across the addresses
|
|
// whose balances have been fetched. The counterpart to explorerDecimals(): one
|
|
// contract, so the reports should agree, and a set that does not agree is a
|
|
// name this screen has no way to choose between.
|
|
function explorerSymbol(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;
|
|
if (!tb.symbol) continue;
|
|
if (found !== null && found !== tb.symbol) return null;
|
|
found = tb.symbol;
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// The symbol to label a token with, or null when no source the wallet trusts
|
|
// names one — in which case the screen keeps saying `Unknown token` rather than
|
|
// guessing. The bundled list, then the tokens the user tracks, then what the
|
|
// explorer reported: the same sources and the same precedence
|
|
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
|
|
// the same place and the two can no longer disagree about which sources they
|
|
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
|
|
//
|
|
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
|
|
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
|
|
// native ticker from a contract not entitled to it is refused and the next
|
|
// source tried, so resolving a symbol never becomes a new way to claim a known
|
|
// ticker. The bundled list is the wallet's own data and is trusted as it is.
|
|
function resolveTokenSymbol(tokenAddress, sources) {
|
|
const lower = (tokenAddress || "").toLowerCase();
|
|
if (!lower) return null;
|
|
|
|
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
|
if (bundled && bundled.symbol) return bundled.symbol;
|
|
|
|
const tracked = ((sources && sources.trackedTokens) || []).find(
|
|
(t) => (t.address || "").toLowerCase() === lower,
|
|
);
|
|
const candidates = [];
|
|
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
|
|
const reported = explorerSymbol(lower, sources && sources.wallets);
|
|
if (reported) candidates.push(reported);
|
|
|
|
for (const symbol of candidates) {
|
|
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 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,
|
|
resolveTokenSymbol,
|
|
unknownDecimalsAmount,
|
|
};
|