diff --git a/README.md b/README.md index 2ee60cc..a6b42e0 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 @@ -1473,24 +1474,23 @@ 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. A token claiming a symbol from the bundled list from any other -contract address is always dropped, tracked or not, and so is any token claiming -a symbol that belongs to the native asset and therefore has no legitimate -contract at all (`"ETH"`, and every network's `nativeCurrency`, such as -`"SepoliaETH"`, on every network). 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` (`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 exactly, however -small, but a holding below 0.000001 is left out of the balance lists, the -send-screen token selector, the address total and the remove-address warning -(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and -confirmation screens show it when its token is the one being sent. Tracked -tokens with a zero balance are listed as well while "Show tracked tokens with -zero balance" is on. +more holders. On Ethereum Mainnet, a token claiming a symbol from the bundled +list from any other contract address is always dropped, tracked or not. On every +network, a token claiming a label of the native asset is always dropped. Those +labels are `"ETH"` and each 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` (`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 exactly, however small, but a holding below 0.000001 is left out +of the balance lists, the send-screen token selector, the address total and the +remove-address warning (`isBelowOneMillionth()` in +`src/shared/amountDisplay.js`). The Send and confirmation screens show it when +its token is the one being sent. Tracked tokens with a zero balance are listed +as well while "Show tracked tokens with zero balance" is on. #### Stored state and its version @@ -3198,26 +3198,27 @@ 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, 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. On another network, the list's symbols are not + checked, because the list holds mainnet contracts. 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. - **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 9d8465f..2e26551 100644 --- a/docs/README.md +++ b/docs/README.md @@ -377,13 +377,15 @@ 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 balances apply it -too, with one exception: a token claiming the symbol "ETH" is not filtered -there, so a fake "ETH" token can still show up in your balance list even though -it is hidden from your transaction history and from the send token list. +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 balances apply it too, with one +exception: a token claiming the symbol "ETH" is not filtered there, so a fake +"ETH" token can still show up in your balance list even though it is hidden from +your transaction history and from the send token list. **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/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..be0796c 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 @@ -230,6 +232,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 +388,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..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 fe4e4bc..9ed86fd 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, @@ -249,17 +258,18 @@ 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( - (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( @@ -301,7 +311,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 +340,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 +404,7 @@ async function refreshBalances( addr.address, blockscoutUrl, tracked, + chainId, signal, ); updates.push( @@ -403,6 +415,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..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..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 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..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('