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