chore: the native token's label follows the active network (closes #372)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

networks.js gives each network a nativeCurrency (ETH, SepoliaETH) and nothing
read it: every screen wrote ETH. A nativeCurrency() helper returns the active
network's, and every native balance, value, fee, reserve, type line, history
entry, the contract-recipient warning and the fee-limit refusal use it. 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, as one
reporting ETH already was, and the transaction detail screen calls an entry a
token transfer when it has a token contract, not by its symbol.

Model: opus-5-5
This commit is contained in:
2026-10-04 22:16:43 +00:00
committed by sneak
parent 2b97aae04a
commit 8a39df08b0
19 changed files with 549 additions and 61 deletions
+18
View File
@@ -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 () => {
+323
View File
@@ -0,0 +1,323 @@
// The native token's label on the screens that show a native amount.
//
// src/shared/networks.js gives each network a nativeCurrency, `ETH` on mainnet
// and `SepoliaETH` on Sepolia, and nothing read it: every screen wrote a
// hardcoded "ETH", so on Sepolia the balance, the value and the fee all read
// ETH (https://git.eeqj.de/sneak/AutistMask/issues/372). Each line is asserted
// on both networks, through the real Send, confirmation and approval screens,
// with only the node and the DOM stubbed. So is that a token cannot pass for
// the native token by reporting its label.
"use strict";
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async lookupAddress() {
return null;
}
// 10 gwei expected, 20 gwei reserved per gas.
async getFeeData() {
return { maxFeePerGas: 20000000000n, gasPrice: 10000000000n };
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
}
return {
...actual,
JsonRpcProvider: StubProvider,
Network: { from: () => ({}) },
};
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
// The approval the background hands the approval screen. Set per test.
let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub(),
runtime: {
connect: () => ({
postMessage() {},
disconnect() {},
onDisconnect: { addListener() {} },
}),
sendMessage(message, callback) {
callback(
message.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: undefined,
);
},
},
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
children: [],
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
// Views reach for .parentElement to hide whole sections.
get parentElement() {
return global.document.getElementById(id + "-parent");
},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { clearPrices } = require("../src/shared/prices");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
const { balanceLinesForAddress } = require("../src/popup/views/helpers");
const { filterTransactions } = require("../src/shared/transactions");
const HOLDER = "0x" + "a".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// A token contract that is not in the bundled token list.
const TOKEN_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
function text(id) {
return global.document.getElementById(id).textContent;
}
// Press Review on the Send screen for a native send of `amount`, and show the
// confirmation screen it leads to with its fee estimate settled.
async function confirmSend(amount) {
let txInfo = null;
send.init({ showConfirmTx: (info) => (txInfo = info) });
state.selectedToken = "ETH";
global.document.getElementById("send-to").value = RECIPIENT;
global.document.getElementById("send-amount").value = amount;
await global.document
.getElementById("btn-send-review")
.handlers.get("click")();
confirmTx.show(txInfo);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// The approval screen for a dApp transaction sending 0.01 of the native token
// with 21000 gas at up to 20 gwei, on the active network.
async function approveTx() {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
value: "10000000000000000",
data: "0x",
chainId: NETWORKS[state.networkId].chainId,
gasLimit: "21000",
maxFeePerGas: "20000000000",
nonce: 0,
},
};
await approval.show("1");
}
describe.each([
["mainnet", "ETH"],
["sepolia", "SepoliaETH"],
])("on %s the native token reads %s", (networkId, symbol) => {
beforeEach(() => {
elements.clear();
clearPrices();
state.networkId = networkId;
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: HOLDER, balance: "1.5" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.trackedTokens = [];
state.fraudContracts = [];
state.currentView = null;
});
test("the balance", async () => {
const addr = state.wallets[0].addresses[0];
expect(balanceLinesForAddress(addr, [], false)).toContain(
`<span>${symbol}</span><span>1.5000</span>`,
);
state.selectedToken = "ETH";
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 1.5000 " + symbol);
await confirmSend("0.1");
expect(text("confirm-balance")).toBe("1.5000 " + symbol);
});
test("the value", async () => {
await confirmSend("0.1");
expect(text("confirm-type")).toBe("Native " + symbol + " transfer");
expect(text("confirm-amount")).toBe("0.1 " + symbol);
await approveTx();
expect(text("approve-tx-value")).toBe("0.0100 " + symbol);
});
test("the fee", async () => {
await confirmSend("0.1");
// 21000 gas at 10 gwei expected, at 20 gwei reserved.
expect(text("confirm-fee-amount")).toBe("~0.0002 " + symbol);
expect(text("confirm-fee-reserve")).toBe(
"up to 0.0004 " + symbol + " reserved",
);
expect(text("confirm-gas-error")).toContain(
"You do not have enough " + symbol + " to pay the network fee",
);
await approveTx();
expect(text("approve-tx-fee")).toBe("0.0004 " + symbol);
});
test("the contract-recipient warning", async () => {
await confirmSend("0.1");
expect(text("confirm-contract-warning")).toContain(
"Sending " + symbol + " or tokens directly to a contract",
);
});
// A token reports whatever symbol it likes. One reporting the label the
// wallet shows its native token under, on this network or any other, is
// a fake, exactly as one reporting `ETH` always was.
test.each(["ETH", symbol])(
"a token claiming %s is dropped from the history and the Send selector",
(claim) => {
const result = filterTransactions(
[
{
hash: "0x" + "1".repeat(64),
symbol: claim,
contractAddress: TOKEN_CONTRACT,
holders: 900000,
valueGwei: null,
isContractCall: false,
},
],
{ hideSpoofedSymbols: true },
);
expect(result.transactions).toEqual([]);
expect(result.newFraudContracts).toEqual([TOKEN_CONTRACT]);
send.renderSendTokenSelect({
address: HOLDER,
tokenBalances: [
{
address: TOKEN_CONTRACT,
symbol: claim,
decimals: 18,
balance: "5",
holders: 900000,
},
],
});
expect(
global.document.getElementById("send-token").children,
).toEqual([]);
},
);
// The detail screen tells the two apart by the token contract, which only
// a token transfer has, so a token reporting the native label still reads
// as a token transfer.
test("the transaction detail screen's type line", () => {
const entry = {
hash: "0x" + "2".repeat(64),
from: RECIPIENT,
to: HOLDER,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "received",
directionLabel: "Received",
};
transactionDetail.show({ ...entry, contractAddress: null });
expect(text("tx-detail-type")).toBe("Native " + symbol + " Transfer");
transactionDetail.show({ ...entry, contractAddress: TOKEN_CONTRACT });
expect(text("tx-detail-type")).toBe("ERC-20 Token Transfer");
});
test("the insufficient-balance error", async () => {
await confirmSend("2");
expect(
global.document.getElementById("confirm-errors").innerHTML,
).toContain(
"You have 1.5000 " +
symbol +
" but are trying to send 2 " +
symbol +
".",
);
});
});
+62 -13
View File
@@ -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([]);
});