From 754cf9686f0810b739856b36de68063e8d98769f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 03:41:15 +0100 Subject: [PATCH] fix: the bundled token list names tokens on Ethereum Mainnet only (closes #632) bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it, with the chain id of the transaction, balance or history entry it draws. The token sources the screens build carry that chain id. The Uniswap decoder sets the transaction's chain id on them. fetchTokenBalances() takes the chain id of the network it asks about. isSpoofedSymbol() takes the chain id of the token's network. It checks the bundled list's symbols only on Ethereum Mainnet. The native asset's labels stay reserved on every network. Twenty existing tests and four shared test helpers read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5 --- README.md | 66 ++++----- docs/README.md | 12 +- src/popup/views/addToken.js | 2 +- src/popup/views/addressToken.js | 12 +- src/popup/views/helpers.js | 13 +- src/popup/views/send.js | 8 +- src/popup/views/settingsAddToken.js | 2 +- src/shared/approvalAmount.js | 23 +-- src/shared/balances.js | 27 +++- src/shared/symbolSpoof.js | 14 +- src/shared/tokenList.js | 20 ++- src/shared/transactions.js | 9 +- src/shared/uniswap.js | 31 +++-- tests/approvalAmount.test.js | 7 +- tests/approvalBundledListSepolia.test.js | 169 +++++++++++++++++++++++ tests/bundledListScreens.test.js | 81 +++++++++++ tests/fabricatedDecimals.test.js | 4 +- tests/historyAfterNetworkSwitch.test.js | 2 + tests/holders.test.js | 8 +- tests/resolveTokenSymbol.test.js | 20 ++- tests/spoofedTrackedToken.test.js | 47 ++++++- tests/symbolSpoof.test.js | 126 ++++++++++++++--- tests/trackedTokenBalances.test.js | 77 ++++++++++- tests/transactions.test.js | 62 +++++++++ tests/unknownScaleSend.test.js | 5 +- 25 files changed, 732 insertions(+), 115 deletions(-) create mode 100644 tests/approvalBundledListSepolia.test.js create mode 100644 tests/bundledListScreens.test.js diff --git a/README.md b/README.md index 444c4f2..ea5fc85 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 @@ -1477,14 +1478,14 @@ tracked. A tracked token is shown when its balance is nonzero, and any other ERC-20 when its balance is nonzero and it is in the bundled known-token list or has 1,000 or -more holders. The balance list always drops a token that claims a bundled-list -symbol from another contract address, whatever the settings. The list also drops -every token that claims the native asset's symbol: `"ETH"`, or any network's -`nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the -"Hide tokens with fewer than 1,000 holders" setting governs the transaction -history and the send-screen token selector, not this list. A token's holder -count is unknown when the explorer reports none, or reports anything other than -a whole number written in digits alone, such as `1,000` or `1e3` +more holders. On Ethereum Mainnet, the balance list always drops a token that +claims a bundled token's symbol from another contract. On every network, it +drops a token that claims a label of the native asset. Those labels are `"ETH"` +and each network's `nativeCurrency`, such as `"SepoliaETH"`. The "Hide tokens +with fewer than 1,000 holders" setting does not change this list. It governs +only the transaction history and the send-screen token selector. A token's +holder count is unknown when the explorer reports none, or reports anything +other than a whole number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an unknown count as 1,000 or more, so such a token is shown only when it is on the bundled list or tracked. Every nonzero holding of a token shown is stored @@ -3236,26 +3237,25 @@ indexes it as a real token transfer. Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified on-chain and addresses EIP-55 checksummed; `TOKENS` in `src/shared/tokenList.js` is the authoritative set. It is bundled at build - time and only changes when that file is regenerated. Any token transfer - claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating - from an unrecognized contract address is identified as a spoof and filtered - from display. The fake "Ethereum" token in the attack above used symbol "ETH" - from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not - match the known WETH contract — so it would be caught by this check. Detecting - a spoof is also what adds a contract to the fraud contract blocklist below; - that is the only thing that populates it. In the transaction history the check - is the "Hide fake tokens impersonating a known symbol" setting, on by default; - with it off, spoofed transfers are shown and no new blocklist entries are - learned from them. The send-screen token selector and the balance list apply - the same check unconditionally, because they decide which tokens the user can - act on and what the user believes they own rather than what the history - displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`, - so they cannot answer the question differently. A symbol the list maps to no - contract at all — the native asset's labels: `"ETH"` and every network's - `nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by - no contract, so every ERC-20 claiming it is a spoof on all three. The user's - real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees - it. + time and only changes when that file is regenerated. On Ethereum Mainnet, a + token that claims a symbol from this list, such as "USDT" or "USDC", is a + spoof unless its contract is the listed one. The wallet hides it. The fake + "Ethereum" token in the attack above used symbol "ETH" from contract + `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`. No contract may bear "ETH", so + that token is a spoof. Detecting a spoof is also what adds a contract to the + fraud contract blocklist below; that is the only thing that populates it. In + the transaction history the check is the "Hide fake tokens impersonating a + known symbol" setting, on by default; with it off, spoofed transfers are shown + and no new blocklist entries are learned from them. The send-screen token + selector and the balance list apply the same check unconditionally, because + they decide which tokens the user can act on and what the user believes they + own rather than what the history displays. All three surfaces read the rule + from `src/shared/symbolSpoof.js`, so they cannot answer the question + differently. A symbol the list maps to no contract at all — the native asset's + labels: `"ETH"` and every network's `nativeCurrency`, such as `"SepoliaETH"`, + on every network — may be borne by no contract, so every ERC-20 claiming it is + a spoof on all three. The user's real ETH balance is not an ERC-20 and is read + over RPC, so the rule never sees it. - **Low-holder token filtering**: Token transfers from ERC-20 contracts with fewer than 1,000 holders are hidden from transaction history by default. diff --git a/docs/README.md b/docs/README.md index 865c3eb..621edbf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -377,11 +377,13 @@ point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed at build time and updated only when a new release ships a newer snapshot. If a transaction or balance claims to involve a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract, it is identified as a spoof and hidden. -In your transaction history this is the "Hide fake tokens impersonating a known -symbol" setting, which you can switch off; doing so also stops new entries being -added to the fraud contract blocklist below, since detecting a spoof is what -fills it. The send token list always applies the check. Your balance list always -applies the check too. +On Sepolia, only "ETH" and "SepoliaETH" count as known symbols, because the +bundled list holds Ethereum Mainnet tokens. In your transaction history the +check is the "Hide fake tokens impersonating a known symbol" setting, which you +can switch off; doing so also stops new entries being added to the fraud +contract blocklist below, since detecting a spoof is what fills it. The send +token list always applies the check. Your balance list always applies the check +too. **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden from transaction history and the send token list, and are left out of your diff --git a/src/popup/views/addToken.js b/src/popup/views/addToken.js index 83be6b3..5ae5390 100644 --- a/src/popup/views/addToken.js +++ b/src/popup/views/addToken.js @@ -87,7 +87,7 @@ function init(ctx) { } // The balance list never shows a token that the spoof rule // refuses, so such a token is not tracked. - if (isSpoofedSymbol(info.symbol, contractAddr)) { + if (isSpoofedSymbol(info.symbol, contractAddr, chainId)) { showFlash("Not added: this token fakes a known symbol."); if (addTokenScreenOpenings !== opening) return; infoEl.textContent = ""; diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index 40545a5..e815ede 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, @@ -50,11 +50,17 @@ function show() { const addr = wallet.addresses[state.selectedAddress]; const ai = state.selectedAddress; const tokenId = state.selectedToken; - const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); + const chainId = currentNetwork().chainId; + const knownToken = bundledToken(tokenId, chainId); currentSymbol = tokenId === "ETH" ? nativeCurrency() - : resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens); + : resolveSymbol( + tokenId, + addr.tokenBalances, + state.trackedTokens, + chainId, + ); $("address-token-title").textContent = wallet.name + diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 2753b1f..0be0ea0 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -314,14 +314,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: [], }; @@ -352,7 +355,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 // @@ -459,7 +462,11 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { if (seen.has(t.address.toLowerCase())) continue; // A token that the spoof rule refuses has no stored balance, and // gets no zero row either. - if (isSpoofedSymbol(t.symbol, t.address)) continue; + if ( + isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId) + ) { + continue; + } html += balanceLine( t.symbol, t.balanceNotFetched ? null : 0, diff --git a/src/popup/views/send.js b/src/popup/views/send.js index 4228bd6..37bfe43 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"); @@ -154,7 +154,9 @@ function renderSendTokenSelect(addr) { // out. Its token's own screen can still send it: there // state.selectedToken picks the token, not this list. if (isBelowOneMillionth(t.balance)) continue; - if (isSpoofedSymbol(t.symbol, t.address)) continue; + if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) { + continue; + } if (fraudSet.has(t.address.toLowerCase())) continue; // An unknown holder count does not withhold a token the user holds: // only a count the explorer actually reported as below the threshold @@ -238,6 +240,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 @@ -393,6 +396,7 @@ function init(_ctx) { token, addr.tokenBalances, state.trackedTokens, + currentNetwork().chainId, ); ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token)); } diff --git a/src/popup/views/settingsAddToken.js b/src/popup/views/settingsAddToken.js index 0aeeddd..1c4aa9f 100644 --- a/src/popup/views/settingsAddToken.js +++ b/src/popup/views/settingsAddToken.js @@ -167,7 +167,7 @@ function init(_ctx) { // refuses, so such a token is not tracked. The rule never refuses // a bundled token, so the "Top tokens:" buttons and the top 100 // dropdown need no such check. - if (isSpoofedSymbol(info.symbol, addr)) { + if (isSpoofedSymbol(info.symbol, addr, chainId)) { showFlash("Not added: this token fakes a known symbol."); infoEl.textContent = ""; infoEl.style.visibility = "hidden"; diff --git a/src/shared/approvalAmount.js b/src/shared/approvalAmount.js index 82991fe..7bbf752 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,18 +102,19 @@ 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 -// 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. +// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a +// spoof 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); + const chainId = sources && sources.chainId; + const bundled = bundledToken(lower, chainId); if (bundled && bundled.symbol) return bundled.symbol; const tracked = ((sources && sources.trackedTokens) || []).find( @@ -123,7 +126,7 @@ function resolveTokenSymbol(tokenAddress, sources) { if (reported) candidates.push(reported); for (const symbol of candidates) { - if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol; + if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol; } return null; } diff --git a/src/shared/balances.js b/src/shared/balances.js index 33f73ae..43b0e2f 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; @@ -202,7 +209,9 @@ async function fetchTokenBalances( // Every row here is an ERC-20 the explorer reported, so it has a // contract address; the native ETH balance is fetched over RPC in // refreshBalances and never passes through this loop. - if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue; + if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) { + continue; + } balances.push({ address: item.token.address_hash, @@ -253,17 +262,18 @@ async function fetchTokenBalances( // balance was not read, as lowercased contract addresses. A token is unread // when the spoof rule refuses it or its balanceOf fails. // -// `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( - (token) => !isSpoofedSymbol(token.symbol, token.address), + (token) => !isSpoofedSymbol(token.symbol, token.address, chainId), ); // Each token's balanceOf answer, or null where it failed. const balances = await Promise.all( @@ -309,7 +319,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, @@ -341,6 +351,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())); // The tracked tokens whose balance was not read at some address. @@ -406,6 +417,7 @@ async function refreshBalances( addr.address, blockscoutUrl, tracked, + chainId, signal, ); updates.push( @@ -416,6 +428,7 @@ async function refreshBalances( tracked, previous, explorerRequest, + chainId, signal, ), explorerRequest, diff --git a/src/shared/symbolSpoof.js b/src/shared/symbolSpoof.js index c2d5947..2c005f0 100644 --- a/src/shared/symbolSpoof.js +++ b/src/shared/symbolSpoof.js @@ -19,7 +19,8 @@ // them hid the other one's holders' money (issue #276). Membership, not // equality, is therefore the question — but it is the same question, asked of // a table that can now state the truth. Every address in a set is one the -// wallet ships as a real token; a contract outside the set is still a spoof. +// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract +// outside the set is still a spoof. // // The symbol is attacker-controlled — it is whatever the ERC-20 contract // returns — so the lookup is done on a normalized form (issue #260): the @@ -27,6 +28,7 @@ // since that is what the user acts on. const { KNOWN_SYMBOLS } = require("./tokenList"); +const { NETWORKS } = require("./networks"); // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // over the address, not part of its identity. @@ -93,18 +95,24 @@ function normalizeSymbol(symbol) { } // True when a token bearing `symbol` from contract `contractAddress` is -// impersonating a known symbol. +// impersonating a known symbol. `chainId` is the chain id of the token's +// network. +// +// The native asset's labels are known symbols on every network. The bundled +// list's symbols are known symbols only on Ethereum Mainnet, because the list +// holds mainnet contracts. // // An empty contract address is the native asset, which is never a spoof: // this is what keeps the user's real ETH out of the rule, and it holds for // any symbol that becomes null-mapped later, not just for ETH. -function isSpoofedSymbol(symbol, contractAddress) { +function isSpoofedSymbol(symbol, contractAddress, chainId) { const contract = normalizeAddress(contractAddress); if (!contract) return false; const sym = normalizeSymbol(symbol); if (!KNOWN_SYMBOLS.has(sym)) return false; const legit = KNOWN_SYMBOLS.get(sym); if (legit === null) return true; + if (chainId !== NETWORKS.mainnet.chainId) return false; return !legit.has(contract); } diff --git a/src/shared/tokenList.js b/src/shared/tokenList.js index 33ccbca..d02c123 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 @@ -3969,7 +3978,8 @@ for (const t of TOKENS) { // hides from the balance list, the history and the send selector at its own // address, so the user cannot spend it (issue #276). Naming every address // that bears the symbol is the only shape that says what is true; it does not -// loosen the rule, because a contract outside the set is still a spoof. +// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a +// spoof. const KNOWN_SYMBOLS = new Map(); KNOWN_SYMBOLS.set("ETH", null); for (const network of Object.values(NETWORKS)) { @@ -3988,14 +3998,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 +4023,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..522458c 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; } @@ -317,7 +317,10 @@ function filterTransactions(txs, filters = {}) { const contract = normalizeAddress(tx.contractAddress); // Filter spoofed known symbols and record the fraud contract - if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) { + if ( + hideSpoofed && + isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId) + ) { if (contract && !fraudSet.has(contract)) { fraudSet.add(contract); newFraud.push(contract); diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 5e5d107..8da031a 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 @@ -1246,6 +1246,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; @@ -2338,11 +2341,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; @@ -2469,13 +2472,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), + ), ); if (!present(s.recipient)) { details.push({ @@ -2497,7 +2506,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, @@ -2532,7 +2541,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: @@ -2598,7 +2607,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: @@ -2623,7 +2632,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..5f332c4 --- /dev/null +++ b/tests/approvalBundledListSepolia.test.js @@ -0,0 +1,169 @@ +// 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. The same screen for a mainnet transfer of +// USDC, while Sepolia is active, still names USDC. +// +// 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('