// 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, };