From 9a2910358ddde665c0d4be3e53d996fb57a7f7fc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 21:53:00 +0000 Subject: [PATCH] chore: the native token's label follows the network (closes #372) networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing read it: every screen wrote ETH. The wallet's balances and the Send and confirmation screens now use the active network's. A transaction's figures use the network its chain id names, through nativeCurrencyByChainId(): the approval value and fee, the wait, success and error screens, history entries, the detail screen and the fee-limit refusal, so a site switching networks cannot make one read as another network's coin. The "ETH" that selectedToken and txInfo.token hold is the native token's id and is unchanged. A token reporting any network's nativeCurrency is a spoof, and the detail screen calls an entry a token transfer when it has a token contract. Model: opus-5-5 --- README.md | 24 +- TODO.md | 12 + src/popup/index.html | 21 +- src/popup/views/addressDetail.js | 3 +- src/popup/views/addressToken.js | 6 +- src/popup/views/approval.js | 23 +- src/popup/views/confirmTx.js | 52 ++- src/popup/views/helpers.js | 28 +- src/popup/views/home.js | 16 +- src/popup/views/send.js | 7 +- src/popup/views/transactionDetail.js | 22 +- src/popup/views/txStatus.js | 18 +- src/shared/approvalVerify.js | 15 +- src/shared/networks.js | 10 + src/shared/symbolSpoof.js | 4 +- src/shared/tokenList.js | 8 +- src/shared/transactions.js | 25 +- tests/approvalVerify.test.js | 18 ++ tests/nativeCurrency.test.js | 461 +++++++++++++++++++++++++++ tests/transactions.test.js | 79 ++++- 20 files changed, 769 insertions(+), 83 deletions(-) create mode 100644 tests/nativeCurrency.test.js diff --git a/README.md b/README.md index 57016b4..278c5a6 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,20 @@ 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 a network's `nativeCurrency` in +`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The +wallet's balances and the Send and confirmation screens, which send on the +active network, use the active network's. A transaction's figures use the one of +the network its chain id names, whichever network is active: the value and fee +on the approval screen, the amount on the wait, success and error screens, the +transaction history and the transaction detail screen, and the refusal of a fee +above 1 ETH. A chain id that names no network reads `ETH`. 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 that refusal, +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 +1120,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 +1604,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 +2379,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..8021497 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,18 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: The native token's label follows the 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`. Every native + figure now reads `nativeCurrency`, which is `ETH` on mainnet and `SepoliaETH` + on Sepolia: the balance lists, Send and confirmation screens and the + contract-recipient warning the active network's; the approval, wait, success, + error and transaction detail screens, the transaction history and the refusal + of a fee above the limit that of the network the transaction's chain id names. + 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, + currentNetwork().chainId, + ), ); 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..871aeff 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -21,6 +21,7 @@ const { goBack, } = require("./helpers"); const { state } = require("../../shared/state"); +const { nativeCurrencyByChainId } = require("../../shared/networks"); const { formatEther, formatUnits } = require("ethers"); const makeBlockie = require("ethereum-blockies-base64"); const { log, debugFetch } = require("../../shared/log"); @@ -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 " + nativeCurrencyByChainId(tx.chainId) + " Transfer"; } function blockieHtml(address) { @@ -84,6 +87,11 @@ function show(tx) { isContractCall: tx.isContractCall || false, method: tx.method || null, contractAddress: tx.contractAddress || null, + // The network the history entry was read from. The type line and + // the fee are in its native currency, not the active network's: + // a site can switch the active network before a later popup + // shows this screen again. + chainId: tx.chainId, }, }; render(); @@ -179,7 +187,7 @@ function render() { if (el) el.classList.add("hidden"); } - loadFullTxDetails(tx.hash, tx.to); + loadFullTxDetails(tx.hash, tx.to, tx.chainId); const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = @@ -197,7 +205,7 @@ function showDetailField(sectionId, contentId, value) { section.classList.remove("hidden"); } -function populateOnChainDetails(txData) { +function populateOnChainDetails(txData, chainId) { // Block number if (txData.block_number != null) { const blockLink = explorerUrl("block", String(txData.block_number)); @@ -227,7 +235,7 @@ function populateOnChainDetails(txData) { showDetailField( "tx-detail-fee-section", "tx-detail-fee", - feeEth + " ETH", + feeEth + " " + nativeCurrencyByChainId(chainId), ); } @@ -287,7 +295,7 @@ function populateOnChainDetails(txData) { } } -async function loadFullTxDetails(txHash, toAddress) { +async function loadFullTxDetails(txHash, toAddress, chainId) { const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); @@ -304,7 +312,7 @@ async function loadFullTxDetails(txHash, toAddress) { const txData = await resp.json(); // Populate on-chain detail fields (block, nonce, gas, fee) - populateOnChainDetails(txData); + populateOnChainDetails(txData, chainId); const inputData = txData.raw_input || txData.input || null; if (!inputData || inputData === "0x") return; diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index 56537f4..0ee254d 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -16,6 +16,7 @@ const { } = require("./helpers"); const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const { state } = require("../../shared/state"); +const { nativeCurrencyByChainId } = require("../../shared/networks"); const { getProvider } = require("../../shared/balances"); const { log } = require("../../shared/log"); @@ -86,9 +87,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) { endWait(); const id = waitId; + // A native amount, here and on the success and error screens, is in the + // native currency of txInfo.chainId, the network the transaction was sent + // on, not the active network's: a site can switch the active network + // while this screen is open or before a later popup resumes it. const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrencyByChainId(txInfo.chainId) : displaySymbol(txInfo.tokenSymbol || "?"); $("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); @@ -193,9 +198,10 @@ function showWait(txInfo, txHash) { // an object merely missing one of them throws a TypeError out of // restoreView() — which init() does not guard, skipping the rest of popup // init and leaving wait-tx on screen with no back control. A non-numeric -// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and -// txInfo.tokenSymbol are deliberately unchecked: they are compared and -// coalesced rather than dereferenced, and tokenSymbol is null for ETH. +// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token, +// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are +// compared and coalesced rather than dereferenced, and tokenSymbol is null for +// ETH. function restoreWait() { const d = state.viewData; if (!d || !d.pendingWait) return false; @@ -223,7 +229,7 @@ function showSuccess(txInfo, txHash, blockNumber) { const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrencyByChainId(txInfo.chainId) : displaySymbol(txInfo.tokenSymbol || "?"); state.viewData = { amount: txInfo.amount, @@ -320,7 +326,7 @@ function showError(txInfo, txHash, message) { const symbol = txInfo.token === "ETH" - ? "ETH" + ? nativeCurrencyByChainId(txInfo.chainId) : displaySymbol(txInfo.tokenSymbol || "?"); state.viewData = { amount: txInfo.amount, diff --git a/src/shared/approvalVerify.js b/src/shared/approvalVerify.js index a642f7e..cf5e737 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 { nativeCurrencyByChainId } = 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,21 @@ 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. + const nativeCurrency = nativeCurrencyByChainId( + present(tx.chainId) ? toQuantity(tx.chainId) : null, + ); 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/networks.js b/src/shared/networks.js index fcc486f..591f965 100644 --- a/src/shared/networks.js +++ b/src/shared/networks.js @@ -76,6 +76,15 @@ function networkByChainId(chainId) { return null; } +// The native currency of the network with this chain id. A transaction's +// value and fee are labelled with the one of the chain the transaction is on, +// which need not be the active network. `ETH` when the chain id is missing or +// no network here has it. +function nativeCurrencyByChainId(chainId) { + const network = networkByChainId(chainId); + return network ? network.nativeCurrency : "ETH"; +} + // Build a block explorer link for the given path type and value. // type: "address" | "tx" | "token" | "block" function explorerLink(network, type, value) { @@ -89,5 +98,6 @@ module.exports = { isKnownNetworkId, networkById, networkByChainId, + nativeCurrencyByChainId, explorerLink, }; 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..51702e1 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -11,6 +11,7 @@ const { log, debugFetch } = require("./log"); const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { isSpoofedSymbol } = require("./symbolSpoof"); +const { nativeCurrencyByChainId } = require("./networks"); // The uint8 test every scale in this wallet goes through. Shared, not copied: // a scale is either reported or it is unknown, and "unknown" must mean the // same thing here as it does on the screens that refuse to format one. @@ -28,7 +29,7 @@ function normalizeAddress(addr) { return (addr || "").toLowerCase(); } -function parseTx(tx, addrLower) { +function parseTx(tx, addrLower, chainId) { const from = tx.from?.hash || ""; const to = tx.to?.hash || ""; const rawWei = tx.value || "0"; @@ -36,7 +37,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 = nativeCurrencyByChainId(chainId); let value = formatTxValue(formatEther(rawWei)); let exactValue = formatEther(rawWei); let rawAmount = rawWei; @@ -90,10 +91,11 @@ function parseTx(tx, addrLower) { holders: null, isContractCall: toIsContract, method: method, + chainId: chainId, }; } -function parseTokenTransfer(tt, addrLower) { +function parseTokenTransfer(tt, addrLower, chainId) { const from = tt.from?.hash || ""; const to = tt.to?.hash || ""; // The explorer's own answer, or null. Never a default: a transfer of @@ -139,6 +141,7 @@ function parseTokenTransfer(tt, addrLower) { // low-holder filter declines to judge a null, so a legitimate token // is not hidden because a field went missing upstream. holders: parseHoldersCount(tt.token?.holders_count), + chainId: chainId, }; } @@ -221,7 +224,15 @@ function mergeTransactions(txs, tokenTransfers) { return merged; } -async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { +// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry +// carries it, and a native entry is labelled with that network's +// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`). +async function fetchRecentTransactions( + address, + blockscoutUrl, + chainId, + count = 25, +) { log.debugf("fetchRecentTransactions", address); const addrLower = normalizeAddress(address); @@ -254,8 +265,10 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) { const ttJson = ttResp.ok ? await ttResp.json() : {}; const txs = mergeTransactions( - (txJson.items || []).map((tx) => parseTx(tx, addrLower)), - (ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)), + (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)), + (ttJson.items || []).map((tt) => + parseTokenTransfer(tt, addrLower, chainId), + ), ); const result = txs.slice(0, count); 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..ffa859b --- /dev/null +++ b/tests/nativeCurrency.test.js @@ -0,0 +1,461 @@ +// 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, and that a transaction's figures +// carry its own network's label when another network is active. + +"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; + } + async getTransactionReceipt() { + return { blockNumber: 21000000 }; + } + } + 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, +})); + +// Signing a send succeeds without a key, and sending answers with a hash. +jest.mock("../src/shared/vault", () => ({ + ...jest.requireActual("../src/shared/vault"), + decryptWithPassword: async () => "secret", +})); +jest.mock("../src/shared/wallet", () => ({ + ...jest.requireActual("../src/shared/wallet"), + getSignerForAddress: () => ({ + connect: () => ({ + populateTransaction: async (request) => request, + sendTransaction: async () => ({ hash: "0x" + "3".repeat(64) }), + }), + }), +})); + +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 txStatus = require("../src/popup/views/txStatus"); +const { balanceLinesForAddress } = require("../src/popup/views/helpers"); +const { filterTransactions } = require("../src/shared/transactions"); +const { debugFetch } = require("../src/shared/log"); + +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 network with `chainId`. +async function approveTx(chainId) { + approvalDetails = { + type: "tx", + origin: "https://dapp.example", + approvedFrom: HOLDER, + approvedTx: { + to: RECIPIENT, + value: "10000000000000000", + data: "0x", + 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(NETWORKS[networkId].chainId); + 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(NETWORKS[networkId].chainId); + 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", + chainId: NETWORKS[networkId].chainId, + }; + 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 + + ".", + ); + }); +}); + +// A transaction's value and fee are in the native currency of the network the +// transaction is on, which need not be the active one. A site can switch the +// active network after its transaction is prepared and back before it is +// signed, and a popup opened after a switch shows a sent or listed transaction +// again. The wallet's balances follow the active network; these do not. +describe.each([ + ["mainnet", "sepolia", "ETH"], + ["sepolia", "mainnet", "SepoliaETH"], +])( + "a %s transaction shown with %s active reads %s", + (txNetworkId, activeNetworkId, symbol) => { + const chainId = NETWORKS[txNetworkId].chainId; + const hash = "0x" + "3".repeat(64); + + beforeEach(() => { + elements.clear(); + clearPrices(); + state.networkId = activeNetworkId; + state.wallets = [ + { + name: "Wallet 1", + addresses: [{ address: HOLDER, balance: "1.5" }], + }, + ]; + state.selectedWallet = 0; + state.selectedAddress = 0; + state.trackedTokens = []; + state.fraudContracts = []; + state.currentView = null; + txStatus.init({ doRefreshAndRender() {} }); + }); + + afterEach(() => { + txStatus.endWait(); + }); + + test("the approval screen's value and fee", async () => { + await approveTx(chainId); + expect(text("approve-tx-network")).toBe(NETWORKS[txNetworkId].name); + expect(text("approve-tx-value")).toBe("0.0100 " + symbol); + expect(text("approve-tx-fee")).toBe("0.0004 " + symbol); + }); + + test("the wait, success and error screens", async () => { + const txInfo = { + from: HOLDER, + to: RECIPIENT, + amount: "0.0100", + token: "ETH", + tokenSymbol: null, + chainId, + }; + txStatus.showWait(txInfo, hash); + expect(text("wait-tx-summary")).toBe("0.0100 " + symbol); + txStatus.showError(txInfo, hash, "Failed."); + expect(text("error-tx-summary")).toBe("0.0100 " + symbol); + // A later popup resumes the wait, and the receipt is there. + state.viewData = { + pendingWait: { txInfo, hash, broadcastTime: Date.now() }, + }; + txStatus.restoreWait(); + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + expect(text("success-tx-summary")).toBe("0.0100 " + symbol); + }); + + // Sent from the Send screen on the transaction's network, then + // resumed by a popup that opens after the active network changed. + test("the wait screen after a send", async () => { + state.networkId = txNetworkId; + await confirmSend("0.1"); + confirmTx.init({}); + global.document.getElementById("confirm-tx-password").value = "pw"; + await global.document + .getElementById("btn-confirm-send") + .handlers.get("click")(); + expect(text("wait-tx-summary")).toBe("0.1 " + symbol); + state.networkId = activeNetworkId; + txStatus.restoreWait(); + expect(text("wait-tx-summary")).toBe("0.1 " + symbol); + }); + + test("the transaction detail screen's type line and fee", async () => { + debugFetch.mockImplementationOnce(async () => ({ + ok: true, + status: 200, + json: async () => ({ fee: { value: "21000000000000" } }), + })); + transactionDetail.show({ + hash, + from: RECIPIENT, + to: HOLDER, + value: "1.0000", + exactValue: "1.0", + symbol, + timestamp: 1790000000, + isError: false, + direction: "received", + directionLabel: "Received", + contractAddress: null, + chainId, + }); + expect(text("tx-detail-type")).toBe( + "Native " + symbol + " Transfer", + ); + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + expect( + global.document.getElementById("tx-detail-fee").innerHTML, + ).toContain("0.000021 " + symbol); + }); + }, +); diff --git a/tests/transactions.test.js b/tests/transactions.test.js index e1353ba..5db89f2 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, "0x1"); 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, "0x1"); 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, "0x1"); 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, "0x1"); 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, "0x1"); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); }); @@ -1427,12 +1427,13 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); expect(txs).toHaveLength(1); expect(txs[0].symbol).toBe("USDC"); expect(txs[0].exactValue).toBe("1.0"); expect(txs[0].direction).toBe("sent"); expect(txs[0].contractAddress).toBe(USDC_CONTRACT); + expect(txs[0].chainId).toBe("0x1"); // The surviving row is the token row, and the filters keep it. const kept = filterTransactions(txs, filters()).transactions; expect(kept).toHaveLength(1); @@ -1452,10 +1453,46 @@ 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, "0x1", 2); expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); }); + // https://git.eeqj.de/sneak/AutistMask/issues/372: the caller hands in + // the chain id of the network the explorer serves. Every entry carries + // it, and a native entry is labelled with that network's nativeCurrency. + test("a native entry is labelled by the chain id 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, + "0xaa36a7", + ); + expect(sepolia[0].symbol).toBe("SepoliaETH"); + expect(sepolia[0].value).toBe("0.0100"); + expect(sepolia[0].chainId).toBe("0xaa36a7"); + const mainnet = await fetchRecentTransactions( + VICTIM, + BLOCKSCOUT, + "0x1", + ); + expect(mainnet[0].symbol).toBe("ETH"); + expect(mainnet[0].chainId).toBe("0x1"); + }); + test("the fake token transfer survives fetching and is then filtered", async () => { respondWith( [], @@ -1476,7 +1513,7 @@ describe("fetchRecentTransactions merge and dedup", () => { ], ); - const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT); + const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"); expect(txs).toHaveLength(1); expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); expect(txs[0].holders).toBe(0); @@ -1515,19 +1552,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, + "0x1", + ); 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, + "0x1", + ); 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, + "0x1", + ); expect(filterTransactions(txs, filters()).transactions).toEqual( txs, ); @@ -1539,7 +1588,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, + "0x1", + ); expect(txs[0].holders).toBe(0); expect(filterTransactions(txs, filters()).transactions).toEqual([]); }); @@ -1555,7 +1608,7 @@ describe("fetchRecentTransactions merge and dedup", () => { }, })); await expect( - fetchRecentTransactions(VICTIM, BLOCKSCOUT), + fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"), ).resolves.toEqual([]); }); -- 2.54.0