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.
+
-
- WARNING: The recipient is a smart contract. Sending ETH
- or tokens directly to a contract may result in permanent
- loss of funds.
-
-
+ >
+
- 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,
+ 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([]);
});