diff --git a/README.md b/README.md index 2ee60cc..32d25c0 100644 --- a/README.md +++ b/README.md @@ -1188,11 +1188,12 @@ The wallet takes the `decimals` from these sources, in this order "Use Blockscout" is on (`tokenSourcesByChainId()` in `src/popup/views/helpers.js`). -The token's name comes from the same sources. While "Use Blockscout" is off, a -token gets a line under it when the bundled list and the tracked tokens lack its -name or decimals. The line says "Blockscout's report of your balances is not -used to name this token, because "Use Blockscout" is off in Settings." These -screens show the line: +The token's name comes from the same sources. The bundled token list is used +only for a token on Ethereum Mainnet. While "Use Blockscout" is off, a token +gets a line under it when the bundled list and the tracked tokens lack its name +or decimals. The line says "Blockscout's report of your balances is not used to +name this token, because "Use Blockscout" is off in Settings." These screens +show the line: - TxApproval and SignApproval - WaitTx, SuccessTx and ErrorTx, which follow TxApproval diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 1af8e46..ce7db0e 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -22,7 +22,7 @@ const { timeAgo, } = require("./helpers"); const { state, saveState, currentNetwork } = require("../../shared/state"); -const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); +const { bundledToken, resolveSymbol } = require("../../shared/tokenList"); const { formatUsd, getPrice } = require("../../shared/prices"); const { fetchRecentTransactions, @@ -52,7 +52,8 @@ function show() { // Determine token symbol and balance let symbol, amount, price; - const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); + const chainId = currentNetwork().chainId; + const knownToken = bundledToken(tokenId, chainId); if (tokenId === "ETH") { symbol = nativeCurrency(); amount = parseFloat(addr.balance || "0"); @@ -65,6 +66,7 @@ function show() { tokenId, addr.tokenBalances, state.trackedTokens, + chainId, ); // null when the scale is unknown: no quantity to show, and none to // price. balanceLine() states that rather than printing 0.0000. diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index fc56238..c523cc0 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -313,14 +313,17 @@ function nativeCurrency() { // The wallets' balances hold what Blockscout reported for each token. So the // wallets are returned only while "Use Blockscout" is on. function tokenSourcesByChainId(chainId) { - const net = chainId ? networkByChainId(chainId) : currentNetwork(); + const chainIdOrActive = chainId || currentNetwork().chainId; + const net = networkByChainId(chainIdOrActive); if (net && net.id === state.networkId) { return { + chainId: chainIdOrActive, trackedTokens: state.trackedTokens, wallets: state.useBlockscout ? state.wallets : [], }; } return { + chainId: chainIdOrActive, trackedTokens: (net && state.networkTrackedTokens[net.id]) || [], wallets: [], }; @@ -351,7 +354,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) { // The symbol shown for a token line, from these sources in order: // -// - the bundled list +// - the bundled list, for a token on Ethereum Mainnet // - the tokens the user tracks // - the explorer's report, while "Use Blockscout" is on // diff --git a/src/popup/views/send.js b/src/popup/views/send.js index a699562..3d4c65e 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -12,7 +12,7 @@ const { goBack, tokenSourcesByChainId, } = require("./helpers"); -const { state, currentAddress } = require("../../shared/state"); +const { state, currentAddress, currentNetwork } = require("../../shared/state"); let ctx; const { getProvider } = require("../../shared/balances"); const { resolveTokenDecimals } = require("../../shared/approvalAmount"); @@ -230,6 +230,7 @@ function updateSendBalance() { token, addr.tokenBalances, state.trackedTokens, + currentNetwork().chainId, ); // A null balance is a holding whose scale is unknown. Saying a figure // for it would be a claim about the amount, so it reads as the @@ -385,6 +386,7 @@ function init(_ctx) { token, addr.tokenBalances, state.trackedTokens, + currentNetwork().chainId, ); ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token)); } diff --git a/src/shared/approvalAmount.js b/src/shared/approvalAmount.js index 82991fe..be7c926 100644 --- a/src/shared/approvalAmount.js +++ b/src/shared/approvalAmount.js @@ -29,7 +29,7 @@ // 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 { bundledToken } = require("./tokenList"); const { isSpoofedSymbol } = require("./symbolSpoof"); // Every decimals the explorer reported for this contract, across all the @@ -53,12 +53,14 @@ function explorerDecimals(lower, wallets) { } // The decimals to render a token amount with, or null when nothing knows. -// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`. +// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id +// of the token's network. The bundled list is read only when that network is +// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`. function resolveTokenDecimals(tokenAddress, sources) { const lower = (tokenAddress || "").toLowerCase(); if (!lower) return null; - const bundled = TOKEN_BY_ADDRESS.get(lower); + const bundled = bundledToken(lower, sources && sources.chainId); if (bundled) { const d = toDecimals(bundled.decimals); if (d !== null) return d; @@ -100,7 +102,7 @@ function explorerSymbol(lower, wallets) { // 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`. +// trust. `sources` is as for resolveTokenDecimals(). // // 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 @@ -111,7 +113,7 @@ function resolveTokenSymbol(tokenAddress, sources) { const lower = (tokenAddress || "").toLowerCase(); if (!lower) return null; - const bundled = TOKEN_BY_ADDRESS.get(lower); + const bundled = bundledToken(lower, sources && sources.chainId); if (bundled && bundled.symbol) return bundled.symbol; const tracked = ((sources && sources.trackedTokens) || []).find( diff --git a/src/shared/balances.js b/src/shared/balances.js index fe4e4bc..fb793ee 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants"); const { NETWORKS } = require("./networks"); const { log, debugFetch, urlOrigin } = require("./log"); const { deriveAddressFromXpub } = require("./wallet"); -const { TOKEN_BY_ADDRESS } = require("./tokenList"); +const { bundledToken } = require("./tokenList"); const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { isSpoofedSymbol } = require("./symbolSpoof"); const { toDecimals } = require("./transferAmount"); @@ -106,6 +106,9 @@ function rawUnits(value) { // balance is read through the RPC endpoint instead, and its row here gives that // entry its holder count and `decimals` (fetchTrackedTokenBalances()). // +// `chainId` is the chain id of the network `blockscoutUrl` serves. The known +// token list is used only when that network is Ethereum Mainnet. +// // `decimals` and `balance` are each null when the answer is unknown, the same // way `holders` already is. Absence is never filled in here: this is the // upstream of every screen that displays a token amount, so a value invented @@ -117,6 +120,7 @@ async function fetchTokenBalances( address, blockscoutUrl, trackedTokens, + chainId, signal, ) { if (!(await storedSettingIsOn("useBlockscout"))) return []; @@ -175,7 +179,10 @@ async function fetchTokenBalances( // either way: copying another source into it would make // explorerDecimals()'s disagreement check compare something other // than explorer values. - const known = resolveTokenDecimals(tokenAddr, { trackedTokens }); + const known = resolveTokenDecimals(tokenAddr, { + chainId, + trackedTokens, + }); const scale = known !== null ? known : decimals; // null is a holding of an amount that cannot be stated, which is // not the same as a holding of zero, and must never render as one. @@ -191,7 +198,7 @@ async function fetchTokenBalances( // through to the views, where the two low-holder filters treat // an unknown count as "do not judge" rather than as zero. const holders = parseHoldersCount(item.token.holders_count); - const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr); + const isKnown = bundledToken(tokenAddr, chainId) !== null; const isTracked = trackedSet.has(tokenAddr); const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD; @@ -249,13 +256,14 @@ async function fetchTokenBalances( // zero from the tracked list. A token whose symbol the spoof rule refuses gets // no entry either, as in fetchTokenBalances(). // -// `signal` is as for fetchTokenBalances(). +// `chainId` and `signal` are as for fetchTokenBalances(). async function fetchTrackedTokenBalances( provider, address, trackedTokens, previous, explorerRequest, + chainId, signal, ) { const listed = trackedTokens.filter( @@ -301,7 +309,7 @@ async function fetchTrackedTokenBalances( if (raw === 0n) return; const entry = entryIn(reported); const decimals = entry ? entry.decimals : null; - const known = resolveTokenDecimals(lower, { trackedTokens }); + const known = resolveTokenDecimals(lower, { chainId, trackedTokens }); const scale = known !== null ? known : decimals; rows.push({ address: token.address, @@ -330,6 +338,7 @@ async function refreshBalances( ) { log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); const provider = getProvider(rpcUrl, networkId); + const { chainId } = requireNetworkId(networkId); const tracked = trackedTokens || []; const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); const updates = []; @@ -393,6 +402,7 @@ async function refreshBalances( addr.address, blockscoutUrl, tracked, + chainId, signal, ); updates.push( @@ -403,6 +413,7 @@ async function refreshBalances( tracked, previous, explorerRequest, + chainId, signal, ), explorerRequest, diff --git a/src/shared/tokenList.js b/src/shared/tokenList.js index 33ccbca..0db1802 100644 --- a/src/shared/tokenList.js +++ b/src/shared/tokenList.js @@ -3953,6 +3953,15 @@ for (const t of TOKENS) { TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t); } +// The bundled list's entry for the contract at `address` on the network with +// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only. +// A contract at the same address on another network is another token, so any +// other chain id gets null. +function bundledToken(address, chainId) { + if (chainId !== NETWORKS.mainnet.chainId) return null; + return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null; +} + // Build a map of symbol (uppercased) -> the set of contract addresses // (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // "ETH" maps to null: the native asset has no contract, so no contract may @@ -3988,14 +3997,15 @@ for (const t of TOKENS) { } } -// Resolve a token symbol from multiple sources, never returning "?". -function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) { +// Resolve a token symbol from multiple sources, never returning "?". The token +// is a contract on the network with chain id `chainId`. +function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) { const lower = (tokenAddress || "").toLowerCase(); const tb = (tokenBalances || []).find( (t) => t.address.toLowerCase() === lower, ); if (tb && tb.symbol) return tb.symbol; - const known = TOKEN_BY_ADDRESS.get(lower); + const known = bundledToken(lower, chainId); if (known && known.symbol) return known.symbol; const tracked = (trackedTokens || []).find( (t) => t.address.toLowerCase() === lower, @@ -4012,5 +4022,6 @@ module.exports = { USD_PRICE_FEEDS, PRICE_FEED_BY_ADDRESS, getTopTokens, + bundledToken, resolveSymbol, }; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index bf3e22b..d88d2f5 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -8,7 +8,7 @@ const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("./log"); -const { TOKEN_BY_ADDRESS } = require("./tokenList"); +const { bundledToken } = require("./tokenList"); const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { isSpoofedSymbol } = require("./symbolSpoof"); const { nativeCurrencyByChainId } = require("./networks"); @@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) { let direction = normalizeAddress(from) === addrLower ? "sent" : "received"; let directionLabel = direction === "sent" ? "Sent" : "Received"; if (toIsContract && method && method !== "transfer") { - const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to)); + const token = bundledToken(to, chainId); if (token) { symbol = token.symbol; } diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6e140b7..4966139 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -452,8 +452,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as -// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are -// on `state`; resolveTokenDecimals() reads the bundled list, then those. +// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the +// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets. // // A null `address` means UNDETERMINED — the calldata named no currency for // that side — and is refused rather than named. It is not native ETH: Uniswap @@ -1240,6 +1240,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const release = routerRelease(toAddress, chainId); if (release === null) return null; const { commandTypeMask, commands, allowRevert } = release; + // The swap's tokens are contracts on the network with `chainId`, so they + // are named and scaled for that network. + const tokenSources = { ...sources, chainId }; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; @@ -2208,11 +2211,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // Any other output keeps its own token and figure: a swap that pays // its WETH to the user and then unwraps receives WETH, and one that // buys USDC and then unwraps the WETH it did not spend receives USDC. - const inInfo = tokenInfo(inputToken, sources); + const inInfo = tokenInfo(inputToken, tokenSources); const outInfo = outputUnwrapped || (hasUnwrapWeth && !outputEstablished) ? { symbol: "ETH", decimals: 18, address: null } - : tokenInfo(outputToken, sources); + : tokenInfo(outputToken, tokenSources); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; @@ -2337,13 +2340,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) { details.push({ label: "Moved from the sender", value: present(s.amount) - ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost) - .display + ? spentText( + s.amount, + tokenInfo(s.token, tokenSources), + s.atMost, + ).display : UNDECODED_CURRENCY, }); if (present(s.token)) { details.push( - tokenMovedLine("Token moved", tokenInfo(s.token, sources)), + tokenMovedLine( + "Token moved", + tokenInfo(s.token, tokenSources), + ), ); details.push({ label: "Moved to", @@ -2355,7 +2364,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // where it goes, an address on a line of its own. for (const m of movedFromSender) { - const info = permit2TokenInfo(m.token, sources); + const info = permit2TokenInfo(m.token, tokenSources); details.push({ label: "Moved from the sender", value: amountText(m.amount, info).display, @@ -2390,7 +2399,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); continue; } - const info = permit2TokenInfo(p.token, sources); + const info = permit2TokenInfo(p.token, tokenSources); details.push({ label: "Permit2 allowance", value: @@ -2456,7 +2465,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); } for (const m of movedIntoRouter) { - const info = permit2TokenInfo(m.token, sources); + const info = permit2TokenInfo(m.token, tokenSources); paidTo.push({ label: "Paid to", value: @@ -2481,7 +2490,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { paidTo.push( tokenMovedLine( "Token moved into the router", - tokenInfo(getAddress(token), sources), + tokenInfo(getAddress(token), tokenSources), ), ); } diff --git a/tests/approvalAmount.test.js b/tests/approvalAmount.test.js index 2825d12..229f00a 100644 --- a/tests/approvalAmount.test.js +++ b/tests/approvalAmount.test.js @@ -90,9 +90,11 @@ beforeEach(() => { }); describe("resolveTokenDecimals", () => { + // The bundled list is read only for a token on Ethereum Mainnet. test("prefers the bundled list", () => { state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }]; - expect(resolveTokenDecimals(USDC, state)).toBe(6); + const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; + expect(resolveTokenDecimals(USDC, sources)).toBe(6); }); test("reads a token the user tracks", () => { @@ -124,7 +126,8 @@ describe("resolveTokenDecimals", () => { // answers 0 is used rather than fallen past like the unusable entry above. test("uses a bundled scale of zero", () => { state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }]; - expect(resolveTokenDecimals(SLP, state)).toBe(0); + const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; + expect(resolveTokenDecimals(SLP, sources)).toBe(0); }); test("uses a tracked scale of zero", () => { diff --git a/tests/approvalBundledListSepolia.test.js b/tests/approvalBundledListSepolia.test.js new file mode 100644 index 0000000..b775d8c --- /dev/null +++ b/tests/approvalBundledListSepolia.test.js @@ -0,0 +1,157 @@ +// The approval screen for a site's Sepolia transfer of the contract at USDC's +// mainnet address, while Sepolia is active. +// +// The bundled token list holds Ethereum Mainnet contracts. The contract at the +// same address on Sepolia is another token, but the screen named it USDC and +// scaled its amount at USDC's decimals +// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names +// a token only on Ethereum Mainnet. +// +// Driven through the real approval screen, against the minimal DOM stub +// tests/approvalTokenNetwork.test.js uses. + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { Interface } = require("ethers"); +const { state } = require("../src/shared/state"); +const { NETWORKS } = require("../src/shared/networks"); +const approval = require("../src/popup/views/approval"); + +const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; +// USDC's address on Ethereum Mainnet, where the bundled list names it. +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + +// FROM sends 1234500000 base units to RECIPIENT. +const TRANSFER_DATA = new Interface([ + "function transfer(address to, uint256 amount)", +]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + disabled: false, + style: {}, + dataset: {}, + listeners: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: (name, fn) => { + el.listeners[name] = el.listeners[name] || []; + el.listeners[name].push(fn); + }, + querySelectorAll: () => [], + appendChild: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug, non-testnet popup is in. + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// One line of the decoded details: its label and what is drawn under it. +function decodedLine(label) { + return node("approve-tx-decoded-details") + .innerHTML.split('