From f47d17a98f4490941b65425b3cc2cba7c8c8bbfb 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 active network (closes #372) networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing read it: every screen wrote a hardcoded ETH, so on Sepolia the balance, the value and the fee all read ETH. A nativeCurrency() helper in the popup's helpers.js returns the active network's, and every native balance, value, fee, reserve, type line and history entry is labelled with it. The "ETH" that selectedToken and txInfo.token hold is the native token's id and is unchanged. The confirmation screen's not-enough-to-pay-the-fee sentence now names the coin to add, so show() sets it. Model: opus-5-5 --- README.md | 8 + TODO.md | 7 + src/popup/index.html | 7 +- src/popup/views/addressDetail.js | 2 + src/popup/views/addressToken.js | 4 +- src/popup/views/approval.js | 6 +- src/popup/views/confirmTx.js | 31 +++- src/popup/views/helpers.js | 16 +- src/popup/views/home.js | 9 +- src/popup/views/send.js | 7 +- src/popup/views/transactionDetail.js | 9 +- src/popup/views/txStatus.js | 7 +- src/shared/transactions.js | 17 +- tests/nativeCurrency.test.js | 245 +++++++++++++++++++++++++++ tests/transactions.test.js | 75 ++++++-- 15 files changed, 407 insertions(+), 43 deletions(-) create mode 100644 tests/nativeCurrency.test.js diff --git a/README.md b/README.md index 666f79a..d7e3fe7 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, or in a +"Native ETH transfer" type line, Sepolia shows `SepoliaETH`. The swap lines keep +`ETH`, the router's own name for the native currency, and the ETH/USD price +line, the contract-recipient warning and the refusal of a fee above 1 ETH keep +their 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 diff --git a/TODO.md b/TODO.md index 9d15cdd..a42564d 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,13 @@ 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, and the + transaction history now read `nativeCurrency`, which is `ETH` on mainnet and + `SepoliaETH` on Sepolia. - 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date reaches only to 275760-09-13, so a later deadline, such as the `uint256` diff --git a/src/popup/index.html b/src/popup/index.html index 208f185..892b819 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -690,14 +690,13 @@ Your balance does not cover this amount plus the network fee. Please go back and send a smaller amount. + + >
- 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..1562510 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"; + if (tx.symbol && tx.symbol !== nativeCurrency()) { + 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/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/nativeCurrency.test.js b/tests/nativeCurrency.test.js new file mode 100644 index 0000000..18adb89 --- /dev/null +++ b/tests/nativeCurrency.test.js @@ -0,0 +1,245 @@ +// 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. + +"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() {}, + }; +} + +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 { balanceLinesForAddress } = require("../src/popup/views/helpers"); + +const HOLDER = "0x" + "a".repeat(40); +const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; + +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 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([]); });