diff --git a/README.md b/README.md index 2ee60cc..8eef17e 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,20 +1474,20 @@ 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 +more holders. On Ethereum Mainnet, a token that claims a bundled token's symbol +from another contract 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"`. The balance +list applies all three rules whatever the settings say. The "Hide tokens with +fewer than 1,000 holders" setting 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 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 @@ -3198,26 +3199,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 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..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 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('
') + .find((line) => + line.startsWith(`
${label}
`), + ); +} + +// Open the approval screen, with Sepolia active, for the site's transaction +// sending TRANSFER_DATA to the contract at USDC's address on the network with +// chain id `chainId`. +async function showTransfer(chainId) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (msg, reply) => { + if (!reply) return; + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: FROM, + approvedTx: { + type: 2, + from: FROM, + chainId, + nonce: "0x7", + gasLimit: "0x30d40", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: USDC, + value: "0x0", + data: TRANSFER_DATA, + accessList: [], + }, + }); + }, + }; + state.networkId = "sepolia"; + state.trackedTokens = []; + state.networkTrackedTokens = {}; + state.activeAddress = FROM; + state.wallets = [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [ + { address: FROM, index: 0, balance: "0", tokenBalances: [] }, + ], + }, + ]; + state.currentView = null; + approval.init({}); + await approval.show(1); +} + +test("names no token, and states the amount in base units", async () => { + await showTransfer(NETWORKS.sepolia.chainId); + expect(state.currentView).toBe("approve-tx"); + expect(node("approve-tx-to").innerHTML).not.toContain("USDC"); + expect(decodedLine("Token")).toContain( + '
Unknown token
', + ); + expect(decodedLine("Amount")).toContain( + '
1234500000 base units (decimals unknown)
', + ); +}); + +// A transaction is named for its own network, not the active one. +test("a mainnet transfer of USDC is still named and scaled as USDC", async () => { + await showTransfer(NETWORKS.mainnet.chainId); + expect(state.currentView).toBe("approve-tx"); + expect(decodedLine("Token")).toContain('
USDC
'); + expect(decodedLine("Amount")).toContain( + '
1234.5000 USDC
', + ); +}); diff --git a/tests/bundledListScreens.test.js b/tests/bundledListScreens.test.js new file mode 100644 index 0000000..e876687 --- /dev/null +++ b/tests/bundledListScreens.test.js @@ -0,0 +1,81 @@ +// The token screen, the Send screen and the confirmation screen for the +// contract at USDC's mainnet address. No address holds it and the user does +// not track it, so only the bundled token list can name it. +// +// The bundled token list holds Ethereum Mainnet contracts, so these screens +// name the token from it on Ethereum Mainnet only +// (https://git.eeqj.de/sneak/AutistMask/issues/632). Driven through the real +// popup over a stored profile. + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, +} = require("./support/popupBoot"); + +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; + +afterEach(() => { + cleanupPopup(); +}); + +// The popup reopened onto AddressToken for USDC's address, on `networkId`. +function bootOnToken(networkId) { + return bootPopup( + unversionedValidProfile({ + networkId, + currentView: "address-token", + viewStack: ["main", "address"], + selectedWallet: 0, + selectedAddress: 0, + selectedToken: USDC, + }), + ); +} + +// AddressToken's Send, then Review for 1 token to RECIPIENT. +async function reviewSend(boot) { + await boot.click("btn-address-token-send"); + expect(boot.visibleViews()).toEqual(["send"]); + boot.node("send-to").value = RECIPIENT; + boot.node("send-amount").value = "1"; + await boot.click("btn-send-review"); + expect(boot.visibleViews()).toEqual(["confirm-tx"]); +} + +describe("on Ethereum Mainnet, the bundled list names the token", () => { + test("on the token screen", async () => { + const boot = await bootOnToken("mainnet"); + expect(boot.visibleViews()).toEqual(["address-token"]); + expect(boot.text("address-token-title")).toContain("USDC"); + const info = boot.node("address-token-contract-info").innerHTML; + expect(info).toContain( + 'Decimals: 6
', + ); + }); + + test("on the Send screen and the confirmation screen", async () => { + const boot = await bootOnToken("mainnet"); + await reviewSend(boot); + expect(boot.text("send-balance")).toContain("USDC"); + expect(boot.text("confirm-amount")).toContain("USDC"); + }); +}); + +describe("with Sepolia active, the bundled list does not name the token", () => { + test("on the token screen", async () => { + const boot = await bootOnToken("sepolia"); + expect(boot.visibleViews()).toEqual(["address-token"]); + expect(boot.text("address-token-title")).not.toContain("USDC"); + const info = boot.node("address-token-contract-info").innerHTML; + expect(info).not.toContain("Decimals:"); + }); + + test("on the Send screen and the confirmation screen", async () => { + const boot = await bootOnToken("sepolia"); + await reviewSend(boot); + expect(boot.text("send-balance")).not.toContain("USDC"); + expect(boot.text("confirm-amount")).not.toContain("USDC"); + }); +}); diff --git a/tests/fabricatedDecimals.test.js b/tests/fabricatedDecimals.test.js index 30a419d..7c2f8bb 100644 --- a/tests/fabricatedDecimals.test.js +++ b/tests/fabricatedDecimals.test.js @@ -111,13 +111,15 @@ function respondWith(items) { } // Fetch and place the result exactly where refreshBalances() places it, so the -// approval screens read what a real refresh would have left on state. +// approval screens read what a real refresh on Ethereum Mainnet would have +// left on state. async function fetchOnto(items, trackedTokens = []) { respondWith(items); const balances = await fetchTokenBalances( HOLDER, BLOCKSCOUT, trackedTokens, + "0x1", ); state.trackedTokens = trackedTokens; state.wallets = [ diff --git a/tests/holders.test.js b/tests/holders.test.js index 818eb64..be7effe 100644 --- a/tests/holders.test.js +++ b/tests/holders.test.js @@ -192,7 +192,13 @@ describe("the balance-list spam gate", () => { }, }), ]); - const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); + // On Ethereum Mainnet, the only network the known-token list covers. + const balances = await fetchTokenBalances( + HOLDER, + BLOCKSCOUT, + [], + "0x1", + ); expect(balances).toHaveLength(1); expect(balances[0].holders).toBeNull(); }); diff --git a/tests/resolveTokenSymbol.test.js b/tests/resolveTokenSymbol.test.js index d093e87..ef97e49 100644 --- a/tests/resolveTokenSymbol.test.js +++ b/tests/resolveTokenSymbol.test.js @@ -67,13 +67,15 @@ beforeEach(() => { }); describe("resolveTokenSymbol", () => { + // The bundled list is read only for a token on Ethereum Mainnet. test("reads the bundled list", () => { - expect(resolveTokenSymbol(USDC, state)).toBe("USDC"); + expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC"); }); test("prefers the bundled list over a tracked entry", () => { state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }]; - expect(resolveTokenSymbol(USDC, state)).toBe("USDC"); + const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; + expect(resolveTokenSymbol(USDC, sources)).toBe("USDC"); }); test("reads a token the user tracks", () => { @@ -104,7 +106,19 @@ describe("resolveTokenSymbol", () => { test("rejects a tracked entry claiming a bundled ticker it is not", () => { // NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC. state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }]; - expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull(); + const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; + expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull(); + }); + + // The bundled list holds mainnet contracts, so on Sepolia any contract may + // carry one of its symbols. + test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => { + state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }]; + const sources = { + chainId: "0xaa36a7", + trackedTokens: state.trackedTokens, + }; + expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC"); }); test("rejects an explorer entry claiming the native ETH ticker", () => { diff --git a/tests/symbolSpoof.test.js b/tests/symbolSpoof.test.js index fc5fd82..d116526 100644 --- a/tests/symbolSpoof.test.js +++ b/tests/symbolSpoof.test.js @@ -72,6 +72,11 @@ const { const { renderSendTokenSelect } = require("../src/popup/views/send"); const { state } = require("../src/shared/state"); const { debugFetch } = require("../src/shared/log"); +const { NETWORKS } = require("../src/shared/networks"); + +// The bundled list's symbols are known symbols only on Ethereum Mainnet. +const MAINNET = NETWORKS.mainnet.chainId; +const SEPOLIA = NETWORKS.sepolia.chainId; // The fake "Ethereum" token with symbol "ETH" from the attack documented in // README.md, given a holder count high enough to clear every other filter so @@ -116,14 +121,29 @@ describe("the shared rule", () => { }); test("a known symbol from its own contract is not a spoof", () => { - expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false); - expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe( - false, - ); + expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false); + expect( + isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET), + ).toBe(false); }); test("a known symbol from another contract is a spoof", () => { - expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true); + expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).toBe(true); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds + // mainnet contracts, so on Sepolia any contract may carry one of its + // symbols. + test("on Sepolia, a bundled symbol from any contract is not a spoof", () => { + expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(false); + expect(isSpoofedSymbol("USDC", USDC_CONTRACT, SEPOLIA)).toBe(false); + }); + + test("on Sepolia, the native asset's labels are still reserved", () => { + expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(true); + expect(isSpoofedSymbol("SepoliaETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe( + true, + ); }); test("a symbol that is not in the table is not judged here", () => { @@ -164,7 +184,9 @@ describe("the shared rule: symbols that render as a known symbol", () => { test("ASCII padding does not buy a pass", () => { expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true); - expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true); + expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe( + true, + ); }); test("non-breaking and other Unicode spaces do not either", () => { @@ -243,7 +265,9 @@ describe("the shared rule: symbols that render as a known symbol", () => { test("compatibility forms fold onto the symbol they imitate", () => { expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true); - expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true); + expect( + isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT, MAINNET), + ).toBe(true); }); // The two knowingly open classes, asserted here so that the boundary is @@ -271,8 +295,10 @@ describe("the shared rule: symbols that render as a known symbol", () => { }); test("a genuine contract still bears its own padded symbol", () => { - expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false); - expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false); + expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false); + expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe( + false, + ); }); // Normalization must not invent a match. Interior ASCII whitespace is @@ -296,7 +322,7 @@ describe("the shared rule: symbols that render as a known symbol", () => { expect(symbol).toMatch(/^[!-~]+$/); if (addresses === null) continue; for (const address of addresses) { - expect(isSpoofedSymbol(symbol, address)).toBe(false); + expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false); } } }); @@ -317,7 +343,7 @@ describe("the shared rule: symbols that render as a known symbol", () => { describe("the shipped token list", () => { test("no bundled token is filtered at its own address", () => { const filtered = TOKENS.filter((t) => - isSpoofedSymbol(t.symbol, t.address), + isSpoofedSymbol(t.symbol, t.address, MAINNET), ).map((t) => t.symbol + " @ " + t.address); expect(filtered).toEqual([]); }); @@ -360,7 +386,9 @@ describe("the shipped token list", () => { expect(shared.length).toBeGreaterThan(0); for (const [, list] of shared) { for (const t of list) { - expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false); + expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe( + false, + ); } } }); @@ -404,7 +432,7 @@ describe("the shipped token list", () => { [...addresses].sort(), ); for (const address of addresses) { - expect(isSpoofedSymbol(symbol, address)).toBe(false); + expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false); } } }); @@ -421,7 +449,9 @@ describe("the shipped token list", () => { } for (const [symbol, list] of bySymbol) { if (list.length < 2) continue; - expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true); + expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe( + true, + ); } }); }); @@ -506,10 +536,40 @@ describe("surface 2: the Send token selector", () => { } beforeEach(() => { + state.networkId = "mainnet"; state.fraudContracts = []; state.hideLowHolderTokens = true; }); + afterAll(() => { + state.networkId = "mainnet"; + }); + + // A token reporting USDC's symbol from another contract. + function otherUsdc() { + return { + address: FAKE_ETH_CONTRACT, + symbol: "USDC", + decimals: 6, + balance: "12.5", + holders: 900000, + }; + } + + test("a bundled symbol from another contract is not selectable", () => { + render([otherUsdc()]); + expect(select.children).toEqual([]); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds + // mainnet contracts, so on Sepolia any contract may carry its symbols. + test("with Sepolia active, a bundled symbol from another contract is selectable", () => { + state.networkId = "sepolia"; + render([otherUsdc()]); + expect(select.children).toHaveLength(1); + expect(select.children[0].value).toBe(FAKE_ETH_CONTRACT); + }); + test("a fake ETH token is not selectable", () => { render([ { @@ -627,7 +687,12 @@ describe("surface 3: the balance list", () => { decimals: "6", }), ]); - const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); + const balances = await fetchTokenBalances( + HOLDER, + BLOCKSCOUT, + [], + MAINNET, + ); expect(balances).toHaveLength(1); expect(balances[0].symbol).toBe(" USDC "); }); @@ -641,11 +706,40 @@ describe("surface 3: the balance list", () => { decimals: "6", }), ]); - const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); + const balances = await fetchTokenBalances( + HOLDER, + BLOCKSCOUT, + [], + MAINNET, + ); expect(balances).toHaveLength(1); expect(balances[0].symbol).toBe("USDC"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds + // mainnet contracts, so on Sepolia any contract may carry its symbols. + test("a bundled symbol from another contract is listed on Sepolia only", async () => { + respondWith([fakeEthItem({ symbol: "USDC", decimals: "6" })]); + expect( + await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], MAINNET), + ).toEqual([]); + const sepolia = await fetchTokenBalances( + HOLDER, + BLOCKSCOUT, + [], + SEPOLIA, + ); + expect(sepolia).toHaveLength(1); + expect(sepolia[0].symbol).toBe("USDC"); + }); + + test("a fake ETH token is filtered on Sepolia too", async () => { + respondWith([fakeEthItem()]); + expect( + await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], SEPOLIA), + ).toEqual([]); + }); + // The trap in this change: the user's real ETH balance is not an ERC-20 // and is fetched over RPC in refreshBalances, so it never passes through // this loop at all. An explorer row that is not an ERC-20 is dropped diff --git a/tests/trackedTokenBalances.test.js b/tests/trackedTokenBalances.test.js index d00291f..0ab03c9 100644 --- a/tests/trackedTokenBalances.test.js +++ b/tests/trackedTokenBalances.test.js @@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) { }; } -async function refresh(addresses, trackedTokens) { +async function refresh(addresses, trackedTokens, networkId = "mainnet") { await refreshBalances( [{ addresses }], RPC_URL, EXPLORER_URL, trackedTokens, - "mainnet", + networkId, new AbortController().signal, ); return addresses; @@ -307,3 +307,76 @@ test("a tracked token wearing a known symbol from another contract is not listed ); expect(first.tokenBalances).toEqual([]); }); + +// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds +// Ethereum Mainnet contracts. A token outside the tracked tokens with an +// unknown holder count is listed only when the bundled list names it. +test("an untracked bundled token with an unknown holder count is listed on Ethereum Mainnet only", async () => { + explorerRows = [explorerRow(USDC, "USDC", 5000000, null)]; + const [mainnet] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [], + ); + expect(mainnet.tokenBalances).toEqual([ + { + address: USDC, + name: "USDC", + symbol: "USDC", + decimals: 6, + balance: "5.0", + holders: null, + }, + ]); + const [sepolia] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [], + "sepolia", + ); + expect(sepolia.tokenBalances).toEqual([]); +}); + +test("a tracked token at a bundled token's address takes the bundled scale on Ethereum Mainnet only", async () => { + holdings[USDC] = { [FIRST]: 5000000n }; + // The tracked entry records no decimals, and Blockscout fails. + const tracked = [ + { address: USDC, symbol: "USDC", name: "USD Coin", decimals: null }, + ]; + const row = (balance) => ({ + address: USDC, + name: "USD Coin", + symbol: "USDC", + decimals: null, + balance: balance, + holders: null, + }); + const [mainnet] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + tracked, + ); + expect(mainnet.tokenBalances).toEqual([row("5.0")]); + const [sepolia] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + tracked, + "sepolia", + ); + expect(sepolia.tokenBalances).toEqual([row(null)]); +}); + +test("with Sepolia active, a tracked token wearing a bundled symbol from another contract is listed", async () => { + holdings[OTHER] = { [FIRST]: 5000000n }; + const [first] = await refresh( + [{ address: FIRST, tokenBalances: [] }], + [{ address: OTHER, symbol: "USDC", name: "Other", decimals: 6 }], + "sepolia", + ); + expect(first.tokenBalances).toEqual([ + { + address: OTHER, + name: "Other", + symbol: "USDC", + decimals: null, + balance: "5.0", + holders: null, + }, + ]); +}); diff --git a/tests/transactions.test.js b/tests/transactions.test.js index 73d0b64..0ce34d4 100644 --- a/tests/transactions.test.js +++ b/tests/transactions.test.js @@ -144,6 +144,7 @@ function tokenTx(overrides = {}) { isError: false, contractAddress: USDC_CONTRACT, holders: 3500000, + chainId: "0x1", ...overrides, }; } @@ -259,6 +260,32 @@ describe("known-symbol spoof verification", () => { expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]); }); + // https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds + // mainnet contracts, so its symbols are not known symbols on Sepolia. + test("on Sepolia, a bundled symbol from another contract is kept", () => { + const sepoliaToken = tokenTx({ + symbol: "USDC", + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 5000000, + chainId: "0xaa36a7", + }); + const result = filterTransactions([sepoliaToken], filters()); + expect(result.transactions).toEqual([sepoliaToken]); + expect(result.newFraudContracts).toEqual([]); + }); + + test('on Sepolia, "ETH" as an ERC-20 is still spoofed', () => { + const fakeEth = tokenTx({ + symbol: "ETH", + contractAddress: NOVEL_SPAM_CONTRACT, + holders: 5000000, + chainId: "0xaa36a7", + }); + expect(filterTransactions([fakeEth], filters()).transactions).toEqual( + [], + ); + }); + test("the genuine contract for that symbol is not spoofed", () => { const genuine = tokenTx(); const result = filterTransactions([genuine], filters()); @@ -1495,6 +1522,41 @@ describe("fetchRecentTransactions merge and dedup", () => { expect(mainnet[0].chainId).toBe("0x1"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list + // names a contract only on Ethereum Mainnet. On Sepolia, a call to the + // contract at USDC's mainnet address is labelled with the network's native + // currency. A call to any contract the wallet does not name gets that label. + test("a contract call on Sepolia is not named by the bundled list", async () => { + respondWith( + [ + { + hash: "0x" + "b".repeat(64), + block_number: 21000095, + timestamp: TS, + from: { hash: VICTIM }, + to: { hash: USDC_CONTRACT, is_contract: true }, + value: "0", + method: "approve", + status: "ok", + }, + ], + [], + ); + const sepolia = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "0xaa36a7", + ); + expect(sepolia[0].directionLabel).toBe("Approve"); + expect(sepolia[0].symbol).toBe("SepoliaETH"); + const mainnet = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "0x1", + ); + expect(mainnet[0].symbol).toBe("USDC"); + }); + test("the fake token transfer survives fetching and is then filtered", async () => { respondWith( [], diff --git a/tests/unknownScaleSend.test.js b/tests/unknownScaleSend.test.js index d386604..104ac46 100644 --- a/tests/unknownScaleSend.test.js +++ b/tests/unknownScaleSend.test.js @@ -167,7 +167,8 @@ function row(token = {}, value = FIVE_WETH) { } // Fetch the explorer's rows through the real fetcher and put them exactly where -// refreshBalances() puts them. The node reports the holding the explorer does. +// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding +// the explorer does. async function fetchOnto(items) { mockProvider.tokenUnits = BigInt(items[0].value); debugFetch.mockImplementation(async () => ({ @@ -176,7 +177,7 @@ async function fetchOnto(items) { statusText: "OK", json: async () => items, })); - const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []); + const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1"); state.wallets = [ { name: "Wallet 1",