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.
+
- You do not have enough ETH to pay the network fee for this
- transfer. Please add ETH to this address and try again.
-
+ >
- 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([]);
});