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
+15 -3
View File
@@ -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, in a
"Native ETH transfer" type line, in the contract-recipient warning or in the
refusal of a fee above 1 ETH, 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 +1114,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 +1598,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 +2373,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.
+11
View File
@@ -45,6 +45,17 @@ 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, the
transaction history, the contract-recipient warning and the refusal of a fee
above the limit now read `nativeCurrency`, which is `ETH` on mainnet and
`SepoliaETH` on Sepolia. 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
+7 -14
View File
@@ -640,19 +640,13 @@
Double-check the address before sending.
</div>
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-contract-warning"
class="mb-2"
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
style="visibility: hidden"
>
<div
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
>
WARNING: The recipient is a smart contract. Sending ETH
or tokens directly to a contract may result in permanent
loss of funds.
</div>
</div>
></div>
<div
id="confirm-burn-warning"
class="mb-2"
@@ -690,14 +684,13 @@
Your balance does not cover this amount plus the network
fee. Please go back and send a smaller amount.
</div>
<!-- Its sentence names the network's native token, so show()
in confirmTx.js sets it. -->
<div
id="confirm-gas-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
>
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
></div>
<!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. -->
<div
+2
View File
@@ -7,6 +7,7 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -153,6 +154,7 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
nativeCurrency(),
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
+3 -1
View File
@@ -10,6 +10,7 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
balanceLine,
unknownableAmount,
renderAddressHtml,
@@ -106,7 +107,7 @@ function show() {
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = "ETH";
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
@@ -226,6 +227,7 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
nativeCurrency(),
);
const result = filterTransactions(rawTxs, {
hideSpoofedSymbols: state.hideSpoofedSymbols,
+5 -1
View File
@@ -10,6 +10,7 @@ const {
attachCopyHandlers,
onViewLeave,
formatFee,
nativeCurrency,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
@@ -330,7 +331,10 @@ function showTxApproval(details) {
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
ethValueFormatted +
" " +
nativeCurrency() +
(usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx);
+30 -8
View File
@@ -11,6 +11,7 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -90,7 +91,7 @@ function show(txInfo) {
// The raw symbol is the price-table key; the capped one is what the
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
const symbol = displaySymbol(rawSymbol);
// Transaction type
@@ -98,7 +99,7 @@ function show(txInfo) {
$("confirm-type").textContent =
"ERC-20 token transfer (" + symbol + ")";
} else {
$("confirm-type").textContent = "Native ETH transfer";
$("confirm-type").textContent = "Native " + symbol + " transfer";
}
// Token contract section (ERC-20 only)
@@ -162,7 +163,7 @@ function show(txInfo) {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
$("confirm-balance").textContent = valueWithUsd(
truncateAmountNeverZero(bal) + " ETH",
truncateAmountNeverZero(bal) + " " + symbol,
balUsd,
);
}
@@ -197,6 +198,19 @@ function show(txInfo) {
// estimate landing later never moves anything.
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
$("confirm-gas-error").textContent =
"You do not have enough " +
nativeCurrency() +
" to pay the network fee for this transfer. Please add " +
nativeCurrency() +
" to this address and try again.";
// Shown later, once checkRecipientHistory() finds a contract.
$("confirm-contract-warning").textContent =
"WARNING: The recipient is a smart contract. Sending " +
nativeCurrency() +
" or tokens directly to a contract may result in permanent loss of" +
" funds.";
// The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so
@@ -245,7 +259,9 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
const symbol = isErc20
? displaySymbol(txInfo.tokenSymbol || "?")
: nativeCurrency();
const { canSend, codes } = validateTransfer({
isErc20,
@@ -258,7 +274,7 @@ function renderValidation(txInfo) {
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// fee-unknown one, which show() sets.
// gas and fee-unknown ones, which show() sets.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
@@ -287,9 +303,13 @@ function renderValidation(txInfo) {
messages.push(
"Insufficient balance. You have " +
truncateAmountNeverZero(txInfo.balance || "0") +
" ETH but are trying to send " +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" ETH.",
" " +
symbol +
".",
);
}
@@ -382,7 +402,9 @@ async function estimateGas(txInfo) {
$("confirm-fee-reserve").textContent =
"up to " +
truncateAmountNeverZero(formatEther(gasCostWei)) +
" ETH reserved";
" " +
nativeCurrency() +
" reserved";
setVisible("confirm-fee-reserve", true);
} else {
// No spread to report: either there is no estimate, or the node
+14 -2
View File
@@ -267,6 +267,14 @@ function unknownableAmount(balance) {
return Number.isFinite(n) ? n : null;
}
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
// on Sepolia, as src/shared/networks.js names it. Every screen labels a native
// amount with this. The "ETH" that state.selectedToken and txInfo.token hold
// is the native token's id, not its label, and stays "ETH" on every network.
function nativeCurrency() {
return currentNetwork().nativeCurrency;
}
// A network fee in wei as the confirmation and approval screens both show it:
// the ETH figure through truncateAmountNeverZero(), then its USD value when the
// ETH price is known. The USD value is of the exact fee, not of the truncated
@@ -276,7 +284,10 @@ function formatFee(wei) {
const ethPrice = getPrice("ETH");
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
return (
truncateAmountNeverZero(eth) + " ETH" + (usd ? " (" + usd + ")" : "")
truncateAmountNeverZero(eth) +
" " +
nativeCurrency() +
(usd ? " (" + usd + ")" : "")
);
}
@@ -318,7 +329,7 @@ function balanceLine(symbol, amount, price, tokenId) {
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
"ETH",
nativeCurrency(),
parseFloat(addr.balance || "0"),
getPrice("ETH"),
"ETH",
@@ -675,6 +686,7 @@ module.exports = {
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
nativeCurrency,
formatFee,
addressColor,
addressDotHtml,
+7 -2
View File
@@ -10,6 +10,7 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
nativeCurrency,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
@@ -68,7 +69,7 @@ function renderTotalValue() {
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " ETH";
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd;
@@ -182,7 +183,11 @@ async function loadHomeTxs(ctx) {
try {
const fetches = allAddresses.map((addr) =>
fetchRecentTransactions(addr, state.blockscoutUrl),
fetchRecentTransactions(
addr,
state.blockscoutUrl,
nativeCurrency(),
),
);
const results = await Promise.all(fetches);
+5 -2
View File
@@ -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 = '<option value="ETH">ETH</option>';
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
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,
+6 -3
View File
@@ -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";
// 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 " + nativeCurrency() + " Transfer";
}
function blockieHtml(address) {
@@ -227,7 +230,7 @@ function populateOnChainDetails(txData) {
showDetailField(
"tx-detail-fee-section",
"tx-detail-fee",
feeEth + " ETH",
feeEth + " " + nativeCurrency(),
);
}
+4 -3
View File
@@ -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,
+15 -2
View File
@@ -54,9 +54,11 @@ const {
formatEther,
getAddress,
getBytes,
toQuantity,
verifyMessage,
verifyTypedData,
} = require("ethers");
const { networkByChainId } = 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,23 @@ 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; one
// that does not, or names a network not in networks.js, says ETH.
const network = present(tx.chainId)
? networkByChainId(toQuantity(tx.chainId))
: null;
const nativeCurrency = network ? network.nativeCurrency : "ETH";
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.",
);
}
}
+2 -2
View File
@@ -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
+7 -1
View File
@@ -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)) {
+13 -4
View File
@@ -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)),
);
+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([]);
});