diff --git a/README.md b/README.md index 57016b4..fcfcc93 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,14 @@ wherever shown. If a formatting rule applies in one place, it applies in every place. Users should never see the same value rendered differently on two screens. +The native token's label is the active network's `nativeCurrency` in +`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. Wherever +this document shows ETH as the label of a native balance, value or fee, in a +"Native ETH transfer" type line, in the contract-recipient warning or in the +refusal of a fee above 1 ETH, Sepolia shows `SepoliaETH`. The swap lines keep +`ETH`, the router's own name for the native currency, and the ETH/USD price +line, shown on mainnet only, keeps its fixed wording. + **Specific Exception — Truncation:** On some non-critical display locations, we may truncate _a small number_ of characters from the middle of an address solely due to display size constraints. Wherever possible, and, notably, **in all @@ -1106,7 +1114,8 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the user, or has 1,000 or more holders; a token claiming a symbol from the bundled list from any other contract address is always dropped, and so is any token claiming a symbol that belongs to the native asset and therefore has no -legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide +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. `fetchTokenBalances()` stores every nonzero holding of a token it admits, however small, but a holding below @@ -1589,7 +1598,9 @@ view would leave a wallet one click from deletion. - "Transaction" heading, "Back" button - Transaction hash: full hash (tap to copy) + etherscan link - Type: transaction classification — one of: Native ETH Transfer, ERC-20 - Token Transfer, Swap, Token Approval, Contract Call, Contract Creation + Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A + transfer with a token contract is an ERC-20 Token Transfer whatever symbol + the token reports. - Status: "Success" or "Failed" - From: blockie + color dot + full address (tap to copy) + etherscan link; ENS name if available @@ -2362,7 +2373,8 @@ indexes it as a real token transfer. 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 — `"ETH"`, the native asset, is the only one — may be borne by + 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. diff --git a/TODO.md b/TODO.md index fa65fb0..ecb02ec 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,17 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: The native token's label follows the active network + ([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives + each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`, + so on Sepolia the balance, the value and the fee all read `ETH`. The balance + lists, Send, confirmation, approval, wait, success and error screens, the + transaction history, the contract-recipient warning and the refusal of a fee + above the limit now read `nativeCurrency`, which is `ETH` on mainnet and + `SepoliaETH` on Sepolia. A token claiming any network's `nativeCurrency` is + dropped as a fake, as one claiming `ETH` already was, and the transaction + detail screen calls an entry a token transfer when it has a token contract, + not by its symbol. - 2026-10-04: A popup that is already open when the stored profile becomes unreadable moves to the recovery screen ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on diff --git a/src/popup/index.html b/src/popup/index.html index 208f185..83635ee 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -640,19 +640,13 @@ Double-check the address before sending. + + >
+ + >
- fetchRecentTransactions(addr, state.blockscoutUrl), + fetchRecentTransactions( + addr, + state.blockscoutUrl, + nativeCurrency(), + ), ); const results = await Promise.all(fetches); diff --git a/src/popup/views/send.js b/src/popup/views/send.js index 779ba6d..6b1133d 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -5,6 +5,8 @@ const { showFlash, addressTitle, displaySymbol, + escapeHtml, + nativeCurrency, renderAddressHtml, attachCopyHandlers, goBack, @@ -124,7 +126,7 @@ function updateToValidation() { function renderSendTokenSelect(addr) { const sel = $("send-token"); - sel.innerHTML = ''; + sel.innerHTML = ``; const fraudSet = new Set( (state.fraudContracts || []).map((a) => a.toLowerCase()), ); @@ -204,7 +206,8 @@ function updateSendBalance() { $("send-balance").textContent = "Current balance: " + truncateAmountNeverZero(addr.balance || "0") + - " ETH"; + " " + + nativeCurrency(); } else { const symbol = resolveSymbol( token, diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index f13eb10..2e9a9a9 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -18,6 +18,7 @@ const { etherscanLinkHtml, explorerUrl, displaySymbol, + nativeCurrency, goBack, } = require("./helpers"); const { state } = require("../../shared/state"); @@ -41,8 +42,10 @@ function getTransactionType(tx) { return "Token Approval"; return "Contract Call"; } - if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer"; - return "Native ETH Transfer"; + // By the token contract, not the symbol: a token chooses its own symbol + // and can report the native token's, but only a token transfer has one. + if (tx.contractAddress) return "ERC-20 Token Transfer"; + return "Native " + nativeCurrency() + " Transfer"; } function blockieHtml(address) { @@ -227,7 +230,7 @@ function populateOnChainDetails(txData) { showDetailField( "tx-detail-fee-section", "tx-detail-fee", - feeEth + " ETH", + feeEth + " " + nativeCurrency(), ); } diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index 56537f4..6adb14a 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -12,6 +12,7 @@ const { etherscanLinkHtml, explorerUrl, displaySymbol, + nativeCurrency, clearViewStack, } = require("./helpers"); const { resolveTokenSymbol } = require("../../shared/approvalAmount"); @@ -88,7 +89,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) { const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrency() : displaySymbol(txInfo.tokenSymbol || "?"); $("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); @@ -223,7 +224,7 @@ function showSuccess(txInfo, txHash, blockNumber) { const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrency() : displaySymbol(txInfo.tokenSymbol || "?"); state.viewData = { amount: txInfo.amount, @@ -320,7 +321,7 @@ function showError(txInfo, txHash, message) { const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrency() : displaySymbol(txInfo.tokenSymbol || "?"); state.viewData = { amount: txInfo.amount, diff --git a/src/shared/approvalVerify.js b/src/shared/approvalVerify.js index a642f7e..d63a999 100644 --- a/src/shared/approvalVerify.js +++ b/src/shared/approvalVerify.js @@ -54,9 +54,11 @@ const { formatEther, getAddress, getBytes, + toQuantity, verifyMessage, verifyTypedData, } = require("ethers"); +const { networkByChainId } = require("./networks"); // The only transaction types this wallet signs: legacy, EIP-2930 and // EIP-1559. populateTransaction() produces nothing else, so nothing else can @@ -407,12 +409,23 @@ function assertWithinCeilings(tx) { price = normalizeQuantity(tx.gasPrice, "gas price"); } if (price !== null && gasLimit * price > MAX_TOTAL_FEE) { + // The fee is paid in the native currency of the network the + // transaction is for. Every caller's transaction names it; one + // that does not, or names a network not in networks.js, says ETH. + const network = present(tx.chainId) + ? networkByChainId(toQuantity(tx.chainId)) + : null; + const nativeCurrency = network ? network.nativeCurrency : "ETH"; throw refuse( "This transaction would allow a network fee of up to " + formatEther(gasLimit * price) + - " ETH, which is more than the " + + " " + + nativeCurrency + + ", which is more than the " + formatEther(MAX_TOTAL_FEE) + - " ETH this wallet will sign for.", + " " + + nativeCurrency + + " this wallet will sign for.", ); } } diff --git a/src/shared/symbolSpoof.js b/src/shared/symbolSpoof.js index 5caea3d..c2d5947 100644 --- a/src/shared/symbolSpoof.js +++ b/src/shared/symbolSpoof.js @@ -11,8 +11,8 @@ // KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses // that may bear it, or to null. Null means the symbol belongs to the native // asset, which has no contract at all, so no contract may bear it and every -// one that does is a spoof. "ETH" is the only such entry today; the rule is -// written so that a second one needs no change here or at any call site. +// one that does is a spoof. "ETH" is one such entry, and every network's +// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network. // // The value is a set because a ticker is not unique: seven symbols in the // bundled list belong to two real contracts each, and answering with one of diff --git a/src/shared/tokenList.js b/src/shared/tokenList.js index a960105..7acec67 100644 --- a/src/shared/tokenList.js +++ b/src/shared/tokenList.js @@ -6,6 +6,7 @@ // 511 tokens. const { debugFetch } = require("./log"); +const { NETWORKS } = require("./networks"); const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick"; @@ -3610,7 +3611,9 @@ for (const t of TOKENS) { // 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 -// bear its symbol. +// bear its symbol. So does every network's `nativeCurrency` in networks.js +// (`SepoliaETH`), on every network, since that is the label the wallet shows +// its native asset under on that network. // // The value is a set and not a single address because tickers are not unique // and the list above proves it: seven of these 512 tokens share a symbol with @@ -3624,6 +3627,9 @@ for (const t of TOKENS) { // loosen the rule, because 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)) { + KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null); +} for (const t of TOKENS) { const upper = t.symbol.toUpperCase(); if (!KNOWN_SYMBOLS.has(upper)) { diff --git a/src/shared/transactions.js b/src/shared/transactions.js index 3ae38da..7e85aa2 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -28,7 +28,7 @@ function normalizeAddress(addr) { return (addr || "").toLowerCase(); } -function parseTx(tx, addrLower) { +function parseTx(tx, addrLower, nativeCurrency) { const from = tx.from?.hash || ""; const to = tx.to?.hash || ""; const rawWei = tx.value || "0"; @@ -36,7 +36,7 @@ function parseTx(tx, addrLower) { const method = tx.method || null; // For contract calls, produce a meaningful label instead of "0.0000 ETH" - let symbol = "ETH"; + let symbol = nativeCurrency; let value = formatTxValue(formatEther(rawWei)); let exactValue = formatEther(rawWei); let rawAmount = rawWei; @@ -221,7 +221,14 @@ function mergeTransactions(txs, tokenTransfers) { return merged; } -async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { +// `nativeCurrency` is the active network's native token symbol from +// networks.js (`ETH`, `SepoliaETH`), which a native entry is labelled with. +async function fetchRecentTransactions( + address, + blockscoutUrl, + nativeCurrency, + count = 25, +) { log.debugf("fetchRecentTransactions", address); const addrLower = normalizeAddress(address); @@ -254,7 +261,9 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { const ttJson = ttResp.ok ? await ttResp.json() : {}; const txs = mergeTransactions( - (txJson.items || []).map((tx) => parseTx(tx, addrLower)), + (txJson.items || []).map((tx) => + parseTx(tx, addrLower, nativeCurrency), + ), (ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)), ); diff --git a/tests/approvalVerify.test.js b/tests/approvalVerify.test.js index 1e0ba83..6b2a13c 100644 --- a/tests/approvalVerify.test.js +++ b/tests/approvalVerify.test.js @@ -599,6 +599,24 @@ describe("verifySignedTx field comparison", () => { expect(e.message).toContain("1.0 ETH"); } }); + + // The fee is in the native currency of the network the transaction is + // for, whether its chain id is the hex string the background prepares + // or the number ethers parses from a signed transaction. + test.each([ + ["0x1", "ETH"], + [1n, "ETH"], + ["0xaa36a7", "SepoliaETH"], + [11155111n, "SepoliaETH"], + ])("the refusal on chain %p names %s", (chainId, nativeCurrency) => { + expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow( + "up to 3000.0 " + + nativeCurrency + + ", which is more than the 1.0 " + + nativeCurrency + + " this wallet", + ); + }); }); test("every field mismatch is a refusal, not a warning", async () => { diff --git a/tests/nativeCurrency.test.js b/tests/nativeCurrency.test.js new file mode 100644 index 0000000..9b95193 --- /dev/null +++ b/tests/nativeCurrency.test.js @@ -0,0 +1,323 @@ +// The native token's label on the screens that show a native amount. +// +// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet +// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a +// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read +// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted +// on both networks, through the real Send, confirmation and approval screens, +// with only the node and the DOM stubbed. So is that a token cannot pass for +// the native token by reporting its label. + +"use strict"; + +jest.mock("ethers", () => { + const actual = jest.requireActual("ethers"); + class StubProvider { + async lookupAddress() { + return null; + } + // 10 gwei expected, 20 gwei reserved per gas. + async getFeeData() { + return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n }; + } + async estimateGas() { + return 21000n; + } + async getCode() { + return "0x"; + } + async getTransactionCount() { + return 1; + } + } + return { + ...actual, + JsonRpcProvider: StubProvider, + Network: { from: () => ({}) }, + }; +}); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: jest.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + })), + urlOrigin: () => "", + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +global.fetch = jest.fn(() => { + throw new Error("tests must not perform network requests"); +}); + +// The approval the background hands the approval screen. Set per test. +let approvalDetails = null; + +const { makeStorageStub } = require("./support/storageStub"); +global.chrome = { + storage: makeStorageStub(), + runtime: { + connect: () => ({ + postMessage() {}, + disconnect() {}, + onDisconnect: { addListener() {} }, + }), + sendMessage(message, callback) { + callback( + message.type === "AUTISTMASK_GET_APPROVAL" + ? approvalDetails + : undefined, + ); + }, + }, +}; + +// A stub DOM: every id resolves to a recording element. +const elements = new Map(); + +function makeEl(id) { + const handlers = new Map(); + return { + id, + textContent: "", + innerHTML: "", + value: "", + disabled: false, + style: {}, + dataset: {}, + classList: { + add() {}, + remove() {}, + toggle() {}, + contains: () => false, + }, + handlers, + children: [], + addEventListener(name, fn) { + handlers.set(name, fn); + }, + appendChild(child) { + this.children.push(child); + return child; + }, + querySelectorAll: () => [], + querySelector: () => null, + remove() {}, + focus() {}, + // Views reach for .parentElement to hide whole sections. + get parentElement() { + return global.document.getElementById(id + "-parent"); + }, + }; +} + +global.document = { + getElementById(id) { + if (!elements.has(id)) elements.set(id, makeEl(id)); + return elements.get(id); + }, + createElement: (tag) => makeEl(tag), + body: { prepend() {}, appendChild() {} }, + addEventListener() {}, +}; +global.navigator = { clipboard: { writeText() {} } }; + +const { state } = require("../src/shared/state"); +const { NETWORKS } = require("../src/shared/networks"); +const { clearPrices } = require("../src/shared/prices"); +const send = require("../src/popup/views/send"); +const confirmTx = require("../src/popup/views/confirmTx"); +const approval = require("../src/popup/views/approval"); +const transactionDetail = require("../src/popup/views/transactionDetail"); +const { balanceLinesForAddress } = require("../src/popup/views/helpers"); +const { filterTransactions } = require("../src/shared/transactions"); + +const HOLDER = "0x" + "a".repeat(40); +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; +// A token contract that is not in the bundled token list. +const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82"; + +function text(id) { + return global.document.getElementById(id).textContent; +} + +// Press Review on the Send screen for a native send of `amount`, and show the +// confirmation screen it leads to with its fee estimate settled. +async function confirmSend(amount) { + let txInfo = null; + send.init({ showConfirmTx: (info) => (txInfo = info) }); + state.selectedToken = "ETH"; + global.document.getElementById("send-to").value = RECIPIENT; + global.document.getElementById("send-amount").value = amount; + await global.document + .getElementById("btn-send-review") + .handlers.get("click")(); + confirmTx.show(txInfo); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); +} + +// The approval screen for a dApp transaction sending 0.01 of the native token +// with 21000 gas at up to 20 gwei, on the active network. +async function approveTx() { + approvalDetails = { + type: "tx", + origin: "https://dapp.example", + approvedFrom: HOLDER, + approvedTx: { + to: RECIPIENT, + value: "10000000000000000", + data: "0x", + chainId: NETWORKS[state.networkId].chainId, + gasLimit: "21000", + maxFeePerGas: "20000000000", + nonce: 0, + }, + }; + await approval.show("1"); +} + +describe.each([ + ["mainnet", "ETH"], + ["sepolia", "SepoliaETH"], +])("on %s the native token reads %s", (networkId, symbol) => { + beforeEach(() => { + elements.clear(); + clearPrices(); + state.networkId = networkId; + state.wallets = [ + { + name: "Wallet 1", + addresses: [{ address: HOLDER, balance: "1.5" }], + }, + ]; + state.selectedWallet = 0; + state.selectedAddress = 0; + state.trackedTokens = []; + state.fraudContracts = []; + state.currentView = null; + }); + + test("the balance", async () => { + const addr = state.wallets[0].addresses[0]; + expect(balanceLinesForAddress(addr, [], false)).toContain( + `${symbol}1.5000`, + ); + state.selectedToken = "ETH"; + send.updateSendBalance(); + expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol); + await confirmSend("0.1"); + expect(text("confirm-balance")).toBe("1.5000 " + symbol); + }); + + test("the value", async () => { + await confirmSend("0.1"); + expect(text("confirm-type")).toBe("Native " + symbol + " transfer"); + expect(text("confirm-amount")).toBe("0.1 " + symbol); + await approveTx(); + expect(text("approve-tx-value")).toBe("0.0100 " + symbol); + }); + + test("the fee", async () => { + await confirmSend("0.1"); + // 21000 gas at 10 gwei expected, at 20 gwei reserved. + expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol); + expect(text("confirm-fee-reserve")).toBe( + "up to 0.0004 " + symbol + " reserved", + ); + expect(text("confirm-gas-error")).toContain( + "You do not have enough " + symbol + " to pay the network fee", + ); + await approveTx(); + expect(text("approve-tx-fee")).toBe("0.0004 " + symbol); + }); + + test("the contract-recipient warning", async () => { + await confirmSend("0.1"); + expect(text("confirm-contract-warning")).toContain( + "Sending " + symbol + " or tokens directly to a contract", + ); + }); + + // A token reports whatever symbol it likes. One reporting the label the + // wallet shows its native token under, on this network or any other, is + // a fake, exactly as one reporting `ETH` always was. + test.each(["ETH", symbol])( + "a token claiming %s is dropped from the history and the Send selector", + (claim) => { + const result = filterTransactions( + [ + { + hash: "0x" + "1".repeat(64), + symbol: claim, + contractAddress: TOKEN_CONTRACT, + holders: 900000, + valueGwei: null, + isContractCall: false, + }, + ], + { hideSpoofedSymbols: true }, + ); + expect(result.transactions).toEqual([]); + expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]); + + send.renderSendTokenSelect({ + address: HOLDER, + tokenBalances: [ + { + address: TOKEN_CONTRACT, + symbol: claim, + decimals: 18, + balance: "5", + holders: 900000, + }, + ], + }); + expect( + global.document.getElementById("send-token").children, + ).toEqual([]); + }, + ); + + // The detail screen tells the two apart by the token contract, which only + // a token transfer has, so a token reporting the native label still reads + // as a token transfer. + test("the transaction detail screen's type line", () => { + const entry = { + hash: "0x" + "2".repeat(64), + from: RECIPIENT, + to: HOLDER, + value: "1.0000", + exactValue: "1.0", + symbol, + timestamp: 1790000000, + isError: false, + direction: "received", + directionLabel: "Received", + }; + transactionDetail.show({ ...entry, contractAddress: null }); + expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer"); + transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT }); + expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer"); + }); + + test("the insufficient-balance error", async () => { + await confirmSend("2"); + expect( + global.document.getElementById("confirm-errors").innerHTML, + ).toContain( + "You have 1.5000 " + + symbol + + " but are trying to send 2 " + + symbol + + ".", + ); + }); +}); diff --git a/tests/transactions.test.js b/tests/transactions.test.js index e1353ba..36d8815 100644 --- a/tests/transactions.test.js +++ b/tests/transactions.test.js @@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => { test("queries only the two Blockscout endpoints for the address", async () => { respondWith([], []); - await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(debugFetch).toHaveBeenCalledTimes(2); const urls = debugFetch.mock.calls.map((c) => c[0]); expect(urls).toContain( @@ -1283,7 +1283,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs).toHaveLength(1); const merged = txs[0]; // The received leg (the swap output) supplies the display amount. @@ -1320,7 +1320,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs).toHaveLength(1); expect(txs[0].symbol).toBe("USDC"); expect(txs[0].value).toBe("1500.5000"); @@ -1346,7 +1346,7 @@ describe("fetchRecentTransactions merge and dedup", () => { }); respondWith([], [leg("1000000"), leg("2000000")]); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs).toHaveLength(1); // Keyed by hash plus contract, so the later leg wins. expect(txs[0].exactValue).toBe("2.0"); @@ -1386,7 +1386,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); }); @@ -1427,7 +1427,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs).toHaveLength(1); expect(txs[0].symbol).toBe("USDC"); expect(txs[0].exactValue).toBe("1.0"); @@ -1452,10 +1452,43 @@ describe("fetchRecentTransactions merge and dedup", () => { }); respondWith([item(6), item(8), item(7)], []); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, 2); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH", 2); expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); }); + // https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in + // the active network's nativeCurrency, and a native entry carries it. + test("a native entry is labelled with the native token symbol handed in", async () => { + respondWith( + [ + { + hash: "0x" + "a".repeat(64), + block_number: 21000090, + timestamp: TS, + from: { hash: ORDINARY_PEER }, + to: { hash: VICTIM, is_contract: false }, + value: "10000000000000000", + method: null, + status: "ok", + }, + ], + [], + ); + const sepolia = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "SepoliaETH", + ); + expect(sepolia[0].symbol).toBe("SepoliaETH"); + expect(sepolia[0].value).toBe("0.0100"); + const mainnet = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "ETH", + ); + expect(mainnet[0].symbol).toBe("ETH"); + }); + test("the fake token transfer survives fetching and is then filtered", async () => { respondWith( [], @@ -1476,7 +1509,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"); expect(txs).toHaveLength(1); expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); expect(txs[0].holders).toBe(0); @@ -1515,19 +1548,31 @@ describe("fetchRecentTransactions merge and dedup", () => { test("an omitted holders_count parses to null", async () => { respondWith([], spamTransferWithToken(OMITTED)); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "ETH", + ); expect(txs[0].holders).toBeNull(); }); test("a null holders_count parses to null", async () => { respondWith([], spamTransferWithToken(NULLED)); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "ETH", + ); expect(txs[0].holders).toBeNull(); }); test("the transfer survives the low-holder filter", async () => { respondWith([], spamTransferWithToken(OMITTED)); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "ETH", + ); expect(filterTransactions(txs, filters()).transactions).toEqual( txs, ); @@ -1539,7 +1584,11 @@ describe("fetchRecentTransactions merge and dedup", () => { // holder count is the only rule that can catch it. test('a reported holders_count of "0" still parses to 0 and is filtered', async () => { respondWith([], spamTransferWithToken(ZERO)); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "ETH", + ); expect(txs[0].holders).toBe(0); expect(filterTransactions(txs, filters()).transactions).toEqual([]); }); @@ -1555,7 +1604,7 @@ describe("fetchRecentTransactions merge and dedup", () => { }, })); await expect( - fetchRecentTransactions(VICTIM, BLOCKSCOUT), + fetchRecentTransactions(VICTIM, BLOCKSCOUT, "ETH"), ).resolves.toEqual([]); });