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