Compare commits

..
Author SHA1 Message Date
sneak 93b54c85e8 README: second rework after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Fix the 23 findings of the second review and the two leftovers from the
first: SiteApproval's connection lifetime, the stateSchema.js module line,
the flash line and title bar, the six network-bound screens, the token-only
0.000001 cutoff, the 12-character symbol cap, the time display, the native
currency labels, the truncateAmountNeverZero screens, the browser suite
rules, the build receipt contents, the External services list, the worker
memory exceptions, POPUP_ONLY_TYPES, the undefined terms, the comma lists,
the Rationale, Entrypoints and TODO sections, and the dropped crypto comment
rule. A fact pass then checked every statement about the code and every
absolute against each case, and a newcomer read removed repeats.

Model: fable-5-1
2026-11-02 02:15:46 +00:00
sneak a17563b5bc docs: rework the README after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Corrects every statement the review found false against the code, among
them which methods the background relays and for whom, where Connected
Sites live, which fields the stored-data check and the defaults cover,
what the balance lists round, and which address rows are plain text. Puts
the test-file citations under Tests, defines each technical word on first
use, turns every comma-chained list into bullets, and adds the coverage the
review asked for: valid amounts, the Firefox minimum version, the RPC
privacy note, the schema-version rule, the browser-suite rules.

Part of #672.

Model: fable-5-1
2026-11-02 01:13:18 +00:00
sneak 49801b6d5c docs: rewrite README.md sentence by sentence against the prose rules
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Every sentence is written for a newcomer to read once: one idea, subject
and verb first, under about 25 words, bullets instead of comma lists, no
tables, no project history. The order is what the extension is, what it
talks to, how to build and load it, the settings, the design, then
development and tests. Every statement was checked against the code on
next; the disagreements with the old text are listed in the PR.

Part of #672.

Model: fable-5-1
2026-11-02 00:44:57 +00:00
clawbot 3191577f73 test: make test fails when jest force-stops a worker (closes #711)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a test left a timer running, jest force-stopped the worker that ran the test and printed "A worker process has failed to exit gracefully", but the run still passed. Both test scripts in package.json now run jest through script/lib/jest.js. The helper runs jest inside its own process, so the terminal output is unchanged, and it exits 1 when jest writes that warning to stderr. Jest 30 has no option that fails the run on it. The eslint.config.js comment on script/lib/ now says those files run on plain node and are never bundled.

Model: opus-5-5
2026-11-02 01:44:22 +01:00
clawbot acb2577fec test: the test suite exits on its own, without --forceExit (closes #585)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Some tests left timers running when they ended, and --forceExit hid them; both test scripts drop it. Six background test files left the background's 20-second timer running while an approval was pending; each now stubs it for the whole file. Five popup test files left a flash message's two-second timer running; they now use fake timers, and a wait on real zero-delay timeouts became jest.advanceTimersByTimeAsync(0). Two ethers timers can still run for up to 250 ms after a test ends; they stop before jest checks for open handles.

Model: opus-5-5
2026-10-28 01:09:12 +01:00
clawbot a33a40a6fa build: say beside the install why yarn prints the workspaces warning (closes #595)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Yarn 1 prints "Workspaces can only be enabled in private projects." for each dependency whose package.json has a workspaces field without "private": true. Two do: eslint 10.8.1, and babel-plugin-istanbul 7.0.1, which jest pulls in. No update removes the field, and removing the warning would mean downgrading both below what this repo and jest require. A one-line comment beside the install in script/bootstrap and in the Dockerfile now names both packages and says why the warning stays; every Dockerfile install runs script/bootstrap, so one comment there is enough.

Model: opus-5-5
2026-10-28 00:28:27 +01:00
clawbot 5192419cac docs: README matches ConfirmTx's wrong-password message and fee-bound refusal (closes #610)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
README's ConfirmTx section quoted "Wrong password." for a wrong password; the screen says "That password is incorrect. Please try again.", and the README now quotes that. The other password screens describe their error without quoting it, and DeleteWallet already quoted it right. The fee-bound refusal no longer says the RPC endpoint always fills in the gas limit and fees at signing: for an ETH amount that Max filled in, they come from the screen's own fee estimate. The transition names `assertWithinCeilings()` and lists what it checks.

Model: opus-5-5
2026-10-28 00:05:20 +01:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
39 changed files with 2056 additions and 4150 deletions
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
COPY script/ script/
COPY package.json yarn.lock ./
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
RUN script/bootstrap
COPY . .
+1671 -3489
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -377,15 +377,13 @@ point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
at build time and updated only when a new release ships a newer snapshot. If a
transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
but comes from an unrecognized contract, it is identified as a spoof and hidden.
On Sepolia, only "ETH" and "SepoliaETH" count as known symbols, because the
bundled list holds Ethereum Mainnet tokens. In your transaction history the
check is the "Hide fake tokens impersonating a known symbol" setting, which you
can switch off; doing so also stops new entries being added to the fraud
contract blocklist below, since detecting a spoof is what fills it. The send
token list always applies the check. Your balances apply it too, with one
exception: a token claiming the symbol "ETH" is not filtered there, so a fake
"ETH" token can still show up in your balance list even though it is hidden from
your transaction history and from the send token list.
In your transaction history this is the "Hide fake tokens impersonating a known
symbol" setting, which you can switch off; doing so also stops new entries being
added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balances apply it
too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your
+1 -2
View File
@@ -153,8 +153,7 @@ module.exports = [
},
},
// The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
// The files in script/lib/ run on plain node too, and are never bundled.
{
files: ["script/lib/**/*.js"],
languageOptions: {
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"test": "node script/lib/jest.js --maxWorkers=3",
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
+1
View File
@@ -118,6 +118,7 @@ ensure_yarn() {
fi
}
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
+40
View File
@@ -0,0 +1,40 @@
// Runs jest with the arguments it is given, and fails the run when jest had to
// force-stop the worker process that ran a test file. Jest itself only prints a
// warning when that happens, and its run still passes. The warning means a test
// left a timer or another open handle running. The test scripts in package.json
// run jest through this file.
//
// Jest runs inside this process and writes to the terminal directly, so its
// output looks the same as a plain jest run. This file only checks each write
// to stderr for the warning below.
"use strict";
// The start of the warning that jest prints on stderr after it force-stops a
// worker process.
const WORKER_WARNING = "A worker process has failed to exit gracefully";
let forceStopped = false;
const writeStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...rest) => {
if (String(chunk).includes(WORKER_WARNING)) {
forceStopped = true;
}
return writeStderr(chunk, ...rest);
};
// Runs after jest has finished, as the process exits.
process.on("exit", () => {
if (forceStopped) {
console.error(
"script/lib/jest.js: failing the run, because jest force-stopped " +
"a worker process. A test left a timer or another open " +
"handle running.",
);
process.exitCode = 1;
}
});
// Jest's own command-line program. It reads its arguments from process.argv.
require("jest/bin/jest");
+18
View File
@@ -5,7 +5,12 @@ const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() {
addTokenScreenOpenings++;
$("add-token-address").value = "";
$("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden";
@@ -55,6 +60,7 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try {
const info = await lookupTokenInfo(
contractAddr,
@@ -80,6 +86,17 @@ function init(ctx) {
});
await saveState();
ctx.doRefreshAndRender();
// If the lookup answers after AddToken was opened again, the
// token is still added, but the screen and the status line stay
// as they are.
if (addTokenScreenOpenings !== opening) return;
// The lookup is over, so a later Back onto AddToken shows no
// status.
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
// If the lookup answers while another screen shows, the token is
// still added but the screen does not change.
if (state.currentView !== "add-token") return;
// Pop the stack (back to address detail) and re-render it
// so the newly added token is visible immediately.
if (state.viewStack.length > 0) {
@@ -100,6 +117,7 @@ function init(ctx) {
? detail
: "Could not add the token.",
);
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+2 -4
View File
@@ -22,7 +22,7 @@ const {
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -52,8 +52,7 @@ function show() {
// Determine token symbol and balance
let symbol, amount, price;
const chainId = currentNetwork().chainId;
const knownToken = bundledToken(tokenId, chainId);
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
@@ -66,7 +65,6 @@ function show() {
tokenId,
addr.tokenBalances,
state.trackedTokens,
chainId,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
+2 -5
View File
@@ -313,17 +313,14 @@ function nativeCurrency() {
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
const net = chainId ? networkByChainId(chainId) : currentNetwork();
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
@@ -354,7 +351,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) {
// The symbol shown for a token line, from these sources in order:
//
// - the bundled list, for a token on Ethereum Mainnet
// - the bundled list
// - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on
//
+2 -6
View File
@@ -12,7 +12,7 @@ const {
goBack,
tokenSourcesByChainId,
} = require("./helpers");
const { state, currentAddress, currentNetwork } = require("../../shared/state");
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
@@ -154,9 +154,7 @@ function renderSendTokenSelect(addr) {
// out. Its token's own screen can still send it: there
// state.selectedToken picks the token, not this list.
if (isBelowOneMillionth(t.balance)) continue;
if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) {
continue;
}
if (isSpoofedSymbol(t.symbol, t.address)) continue;
if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold
@@ -232,7 +230,6 @@ function updateSendBalance() {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
@@ -388,7 +385,6 @@ function init(_ctx) {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
+10 -13
View File
@@ -29,7 +29,7 @@
// string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { bundledToken } = require("./tokenList");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the
@@ -53,14 +53,12 @@ function explorerDecimals(lower, wallets) {
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id
// of the token's network. The bundled list is read only when that network is
// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = bundledToken(lower, sources && sources.chainId);
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
@@ -102,19 +100,18 @@ function explorerSymbol(lower, wallets) {
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is as for resolveTokenDecimals().
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
// spoof is refused and the next source tried, so resolving a symbol never
// becomes a new way to claim a known ticker. The bundled list is the wallet's
// own data and is trusted as it is.
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const chainId = sources && sources.chainId;
const bundled = bundledToken(lower, chainId);
const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
@@ -126,7 +123,7 @@ function resolveTokenSymbol(tokenAddress, sources) {
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol;
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
}
return null;
}
+7 -20
View File
@@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { bundledToken } = require("./tokenList");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
@@ -106,9 +106,6 @@ function rawUnits(value) {
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
//
// `chainId` is the chain id of the network `blockscoutUrl` serves. The known
// token list is used only when that network is Ethereum Mainnet.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented
@@ -120,7 +117,6 @@ async function fetchTokenBalances(
address,
blockscoutUrl,
trackedTokens,
chainId,
signal,
) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
@@ -179,10 +175,7 @@ async function fetchTokenBalances(
// either way: copying another source into it would make
// explorerDecimals()'s disagreement check compare something other
// than explorer values.
const known = resolveTokenDecimals(tokenAddr, {
chainId,
trackedTokens,
});
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
@@ -198,7 +191,7 @@ async function fetchTokenBalances(
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = bundledToken(tokenAddr, chainId) !== null;
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
@@ -209,9 +202,7 @@ async function fetchTokenBalances(
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
continue;
}
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
balances.push({
address: item.token.address_hash,
@@ -258,18 +249,17 @@ async function fetchTokenBalances(
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
//
// `chainId` and `signal` are as for fetchTokenBalances().
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
chainId,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId),
(token) => !isSpoofedSymbol(token.symbol, token.address),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
@@ -311,7 +301,7 @@ async function fetchTrackedTokenBalances(
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
@@ -340,7 +330,6 @@ async function refreshBalances(
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const { chainId } = requireNetworkId(networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = [];
@@ -404,7 +393,6 @@ async function refreshBalances(
addr.address,
blockscoutUrl,
tracked,
chainId,
signal,
);
updates.push(
@@ -415,7 +403,6 @@ async function refreshBalances(
tracked,
previous,
explorerRequest,
chainId,
signal,
),
explorerRequest,
+3 -11
View File
@@ -19,8 +19,7 @@
// them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract
// outside the set is still a spoof.
// wallet ships as a real token; a contract outside the set is still a spoof.
//
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the
@@ -28,7 +27,6 @@
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList");
const { NETWORKS } = require("./networks");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.
@@ -95,24 +93,18 @@ function normalizeSymbol(symbol) {
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol. `chainId` is the chain id of the token's
// network.
//
// The native asset's labels are known symbols on every network. The bundled
// list's symbols are known symbols only on Ethereum Mainnet, because the list
// holds mainnet contracts.
// impersonating a known symbol.
//
// An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress, chainId) {
function isSpoofedSymbol(symbol, contractAddress) {
const contract = normalizeAddress(contractAddress);
if (!contract) return false;
const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
if (chainId !== NETWORKS.mainnet.chainId) return false;
return !legit.has(contract);
}
+4 -16
View File
@@ -3953,15 +3953,6 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
// The bundled list's entry for the contract at `address` on the network with
// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only.
// A contract at the same address on another network is another token, so any
// other chain id gets null.
function bundledToken(address, chainId) {
if (chainId !== NETWORKS.mainnet.chainId) return null;
return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null;
}
// 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
@@ -3978,8 +3969,7 @@ function bundledToken(address, chainId) {
// hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a
// spoof.
// 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)) {
@@ -3998,15 +3988,14 @@ for (const t of TOKENS) {
}
}
// Resolve a token symbol from multiple sources, never returning "?". The token
// is a contract on the network with chain id `chainId`.
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tb && tb.symbol) return tb.symbol;
const known = bundledToken(lower, chainId);
const known = TOKEN_BY_ADDRESS.get(lower);
if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
@@ -4023,6 +4012,5 @@ module.exports = {
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
bundledToken,
resolveSymbol,
};
+3 -6
View File
@@ -8,7 +8,7 @@
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { bundledToken } = require("./tokenList");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
@@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) {
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = bundledToken(to, chainId);
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) {
symbol = token.symbol;
}
@@ -317,10 +317,7 @@ function filterTransactions(txs, filters = {}) {
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract
if (
hideSpoofed &&
isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId)
) {
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
+11 -20
View File
@@ -452,8 +452,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets.
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
@@ -1240,9 +1240,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, commands, allowRevert } = release;
// The swap's tokens are contracts on the network with `chainId`, so they
// are named and scaled for that network.
const tokenSources = { ...sources, chainId };
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -2211,11 +2208,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Any other output keeps its own token and figure: a swap that pays
// its WETH to the user and then unwraps receives WETH, and one that
// buys USDC and then unwraps the WETH it did not spend receives USDC.
const inInfo = tokenInfo(inputToken, tokenSources);
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, tokenSources);
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -2340,19 +2337,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(
s.amount,
tokenInfo(s.token, tokenSources),
s.atMost,
).display
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(
tokenMovedLine(
"Token moved",
tokenInfo(s.token, tokenSources),
),
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
);
details.push({
label: "Moved to",
@@ -2364,7 +2355,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, tokenSources);
const info = permit2TokenInfo(m.token, sources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, info).display,
@@ -2399,7 +2390,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
const info = permit2TokenInfo(p.token, tokenSources);
const info = permit2TokenInfo(p.token, sources);
details.push({
label: "Permit2 allowance",
value:
@@ -2465,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, tokenSources);
const info = permit2TokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
@@ -2490,7 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
tokenInfo(getAddress(token), tokenSources),
tokenInfo(getAddress(token), sources),
),
);
}
+137
View File
@@ -0,0 +1,137 @@
// A contract lookup on AddToken can answer after the user has left AddToken.
// The token is still added, and the screen the user is on does not change
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
// still shows goes to AddressDetail. Driven through the real popup over a
// stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, then AddToken opened from it with
// TOKEN's contract address typed in.
async function openAddToken() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = TOKEN.address;
return boot;
}
// Presses Add with a contract lookup that answers only when the function
// returned here is called. That function waits for Add to finish.
function addWithPendingLookup(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click("btn-add-token-confirm");
return async () => {
answer({
symbol: TOKEN.symbol,
decimals: TOKEN.decimals,
name: TOKEN.name,
});
await adding;
};
}
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await answerLookup();
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
await boot.click("btn-settings");
await answerLookup();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("");
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
});
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await answerLookup();
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).toContain("TST");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
+6 -1
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// ------------------------------------------------------------ harness
@@ -188,7 +188,11 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
@@ -239,6 +243,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
+2 -5
View File
@@ -90,11 +90,9 @@ beforeEach(() => {
});
describe("resolveTokenDecimals", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
expect(resolveTokenDecimals(USDC, state)).toBe(6);
});
test("reads a token the user tracks", () => {
@@ -126,8 +124,7 @@ describe("resolveTokenDecimals", () => {
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
expect(resolveTokenDecimals(SLP, state)).toBe(0);
});
test("uses a tracked scale of zero", () => {
-169
View File
@@ -1,169 +0,0 @@
// The approval screen for a site's Sepolia transfer of the contract at USDC's
// mainnet address, while Sepolia is active.
//
// The bundled token list holds Ethereum Mainnet contracts. The contract at the
// same address on Sepolia is another token, but the screen named it USDC and
// scaled its amount at USDC's decimals
// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names
// a token only on Ethereum Mainnet. The same screen for a mainnet transfer of
// USDC, while Sepolia is active, still names USDC.
//
// Driven through the real approval screen, against the minimal DOM stub
// tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// USDC's address on Ethereum Mainnet, where the bundled list names it.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// FROM sends 1234500000 base units to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Sepolia active, for the site's transaction
// sending TRANSFER_DATA to the contract at USDC's address on the network with
// chain id `chainId`.
async function showTransfer(chainId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: USDC,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "sepolia";
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
state.currentView = null;
approval.init({});
await approval.show(1);
}
test("names no token, and states the amount in base units", async () => {
await showTransfer(NETWORKS.sepolia.chainId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// A transaction is named for its own network, not the active one.
test("a mainnet transfer of USDC is still named and scaled as USDC", async () => {
await showTransfer(NETWORKS.mainnet.chainId);
expect(state.currentView).toBe("approve-tx");
expect(decodedLine("Token")).toContain('<div class="font-bold">USDC</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 USDC</div>',
);
});
+11 -12
View File
@@ -537,7 +537,18 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve));
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
@@ -1883,18 +1894,6 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1];
}
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
+11
View File
@@ -103,7 +103,18 @@ function deferred() {
return { promise, resolve };
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
-81
View File
@@ -1,81 +0,0 @@
// The token screen, the Send screen and the confirmation screen for the
// contract at USDC's mainnet address. No address holds it and the user does
// not track it, so only the bundled token list can name it.
//
// The bundled token list holds Ethereum Mainnet contracts, so these screens
// name the token from it on Ethereum Mainnet only
// (https://git.eeqj.de/sneak/AutistMask/issues/632). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressToken for USDC's address, on `networkId`.
function bootOnToken(networkId) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: USDC,
}),
);
}
// AddressToken's Send, then Review for 1 token to RECIPIENT.
async function reviewSend(boot) {
await boot.click("btn-address-token-send");
expect(boot.visibleViews()).toEqual(["send"]);
boot.node("send-to").value = RECIPIENT;
boot.node("send-amount").value = "1";
await boot.click("btn-send-review");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
}
describe("on Ethereum Mainnet, the bundled list names the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("mainnet");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).toContain(
'<span class="text-muted">Decimals:</span> 6</div>',
);
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("mainnet");
await reviewSend(boot);
expect(boot.text("send-balance")).toContain("USDC");
expect(boot.text("confirm-amount")).toContain("USDC");
});
});
describe("with Sepolia active, the bundled list does not name the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("sepolia");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).not.toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).not.toContain("Decimals:");
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("sepolia");
await reviewSend(boot);
expect(boot.text("send-balance")).not.toContain("USDC");
expect(boot.text("confirm-amount")).not.toContain("USDC");
});
});
+11
View File
@@ -56,7 +56,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -64,7 +64,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -76,7 +76,18 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -57,7 +57,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
+11
View File
@@ -102,6 +102,17 @@ function makeDocument() {
// ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) {
return {
type: "hd",
+1 -3
View File
@@ -111,15 +111,13 @@ function respondWith(items) {
}
// Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh on Ethereum Mainnet would have
// left on state.
// approval screens read what a real refresh would have left on state.
async function fetchOnto(items, trackedTokens = []) {
respondWith(items);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
trackedTokens,
"0x1",
);
state.trackedTokens = trackedTokens;
state.wallets = [
+1 -7
View File
@@ -192,13 +192,7 @@ describe("the balance-list spam gate", () => {
},
}),
]);
// On Ethereum Mainnet, the only network the known-token list covers.
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
"0x1",
);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
+3 -17
View File
@@ -67,15 +67,13 @@ beforeEach(() => {
});
describe("resolveTokenSymbol", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC");
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
});
test("reads a token the user tracks", () => {
@@ -106,19 +104,7 @@ describe("resolveTokenSymbol", () => {
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull();
});
// The bundled list holds mainnet contracts, so on Sepolia any contract may
// carry one of its symbols.
test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = {
chainId: "0xaa36a7",
trackedTokens: state.trackedTokens,
};
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC");
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
+9 -1
View File
@@ -131,10 +131,14 @@ async function review(amount, token) {
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
state.wallets = [
{
@@ -167,6 +171,10 @@ beforeEach(() => {
state.currentView = null;
});
afterEach(() => {
jest.useRealTimers();
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
+9 -1
View File
@@ -277,14 +277,18 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
log.errorf.mockClear();
pageClosed = new AbortController();
@@ -297,6 +301,10 @@ beforeEach(() => {
mockNode.broadcast = [];
});
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
+16 -110
View File
@@ -72,11 +72,6 @@ const {
const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log");
const { NETWORKS } = require("../src/shared/networks");
// The bundled list's symbols are known symbols only on Ethereum Mainnet.
const MAINNET = NETWORKS.mainnet.chainId;
const SEPOLIA = NETWORKS.sepolia.chainId;
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so
@@ -121,29 +116,14 @@ describe("the shared rule", () => {
});
test("a known symbol from its own contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false);
expect(
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET),
).toBe(false);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
false,
);
});
test("a known symbol from another contract is a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).toBe(true);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry one of its
// symbols.
test("on Sepolia, a bundled symbol from any contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(false);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, SEPOLIA)).toBe(false);
});
test("on Sepolia, the native asset's labels are still reserved", () => {
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(true);
expect(isSpoofedSymbol("SepoliaETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(
true,
);
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
});
test("a symbol that is not in the table is not judged here", () => {
@@ -184,9 +164,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
});
test("non-breaking and other Unicode spaces do not either", () => {
@@ -265,9 +243,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT, MAINNET),
).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
@@ -295,10 +271,8 @@ describe("the shared rule: symbols that render as a known symbol", () => {
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe(
false,
);
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
});
// Normalization must not invent a match. Interior ASCII whitespace is
@@ -322,7 +296,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
@@ -343,7 +317,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address, MAINNET),
isSpoofedSymbol(t.symbol, t.address),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
@@ -386,9 +360,7 @@ describe("the shipped token list", () => {
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe(
false,
);
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
}
}
});
@@ -432,7 +404,7 @@ describe("the shipped token list", () => {
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
@@ -449,9 +421,7 @@ describe("the shipped token list", () => {
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
}
});
});
@@ -536,40 +506,10 @@ describe("surface 2: the Send token selector", () => {
}
beforeEach(() => {
state.networkId = "mainnet";
state.fraudContracts = [];
state.hideLowHolderTokens = true;
});
afterAll(() => {
state.networkId = "mainnet";
});
// A token reporting USDC's symbol from another contract.
function otherUsdc() {
return {
address: FAKE_ETH_CONTRACT,
symbol: "USDC",
decimals: 6,
balance: "12.5",
holders: 900000,
};
}
test("a bundled symbol from another contract is not selectable", () => {
render([otherUsdc()]);
expect(select.children).toEqual([]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("with Sepolia active, a bundled symbol from another contract is selectable", () => {
state.networkId = "sepolia";
render([otherUsdc()]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(FAKE_ETH_CONTRACT);
});
test("a fake ETH token is not selectable", () => {
render([
{
@@ -687,12 +627,7 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
@@ -706,40 +641,11 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("a bundled symbol from another contract is listed on Sepolia only", async () => {
respondWith([fakeEthItem({ symbol: "USDC", decimals: "6" })]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], MAINNET),
).toEqual([]);
const sepolia = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
SEPOLIA,
);
expect(sepolia).toHaveLength(1);
expect(sepolia[0].symbol).toBe("USDC");
});
test("a fake ETH token is filtered on Sepolia too", async () => {
respondWith([fakeEthItem()]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], SEPOLIA),
).toEqual([]);
});
// The trap in this change: the user's real ETH balance is not an ERC-20
// and is fetched over RPC in refreshBalances, so it never passes through
// this loop at all. An explorer row that is not an ERC-20 is dropped
+2 -75
View File
@@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) {
};
}
async function refresh(addresses, trackedTokens, networkId = "mainnet") {
async function refresh(addresses, trackedTokens) {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
networkId,
"mainnet",
new AbortController().signal,
);
return addresses;
@@ -307,76 +307,3 @@ test("a tracked token wearing a known symbol from another contract is not listed
);
expect(first.tokenBalances).toEqual([]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// Ethereum Mainnet contracts. A token outside the tracked tokens with an
// unknown holder count is listed only when the bundled list names it.
test("an untracked bundled token with an unknown holder count is listed on Ethereum Mainnet only", async () => {
explorerRows = [explorerRow(USDC, "USDC", 5000000, null)];
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
);
expect(mainnet.tokenBalances).toEqual([
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: null,
},
]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([]);
});
test("a tracked token at a bundled token's address takes the bundled scale on Ethereum Mainnet only", async () => {
holdings[USDC] = { [FIRST]: 5000000n };
// The tracked entry records no decimals, and Blockscout fails.
const tracked = [
{ address: USDC, symbol: "USDC", name: "USD Coin", decimals: null },
];
const row = (balance) => ({
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: null,
balance: balance,
holders: null,
});
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
);
expect(mainnet.tokenBalances).toEqual([row("5.0")]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([row(null)]);
});
test("with Sepolia active, a tracked token wearing a bundled symbol from another contract is listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ address: OTHER, symbol: "USDC", name: "Other", decimals: 6 }],
"sepolia",
);
expect(first.tokenBalances).toEqual([
{
address: OTHER,
name: "Other",
symbol: "USDC",
decimals: null,
balance: "5.0",
holders: null,
},
]);
});
-62
View File
@@ -144,7 +144,6 @@ function tokenTx(overrides = {}) {
isError: false,
contractAddress: USDC_CONTRACT,
holders: 3500000,
chainId: "0x1",
...overrides,
};
}
@@ -260,32 +259,6 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so its symbols are not known symbols on Sepolia.
test("on Sepolia, a bundled symbol from another contract is kept", () => {
const sepoliaToken = tokenTx({
symbol: "USDC",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
const result = filterTransactions([sepoliaToken], filters());
expect(result.transactions).toEqual([sepoliaToken]);
expect(result.newFraudContracts).toEqual([]);
});
test('on Sepolia, "ETH" as an ERC-20 is still spoofed', () => {
const fakeEth = tokenTx({
symbol: "ETH",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
expect(filterTransactions([fakeEth], filters()).transactions).toEqual(
[],
);
});
test("the genuine contract for that symbol is not spoofed", () => {
const genuine = tokenTx();
const result = filterTransactions([genuine], filters());
@@ -1522,41 +1495,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(mainnet[0].chainId).toBe("0x1");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list
// names a contract only on Ethereum Mainnet. On Sepolia, a call to the
// contract at USDC's mainnet address is labelled with the network's native
// currency. A call to any contract the wallet does not name gets that label.
test("a contract call on Sepolia is not named by the bundled list", async () => {
respondWith(
[
{
hash: "0x" + "b".repeat(64),
block_number: 21000095,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: USDC_CONTRACT, is_contract: true },
value: "0",
method: "approve",
status: "ok",
},
],
[],
);
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0xaa36a7",
);
expect(sepolia[0].directionLabel).toBe("Approve");
expect(sepolia[0].symbol).toBe("SepoliaETH");
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(mainnet[0].symbol).toBe("USDC");
});
test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith(
[],
+2 -3
View File
@@ -167,8 +167,7 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding
// the explorer does.
// refreshBalances() puts them. The node reports the holding the explorer does.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
@@ -177,7 +176,7 @@ async function fetchOnto(items) {
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1");
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
state.wallets = [
{
name: "Wallet 1",
+6
View File
@@ -357,6 +357,11 @@ describe("every way to a signature or the private key refuses a defective wallet
});
beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
@@ -398,6 +403,7 @@ describe("every way to a signature or the private key refuses a defective wallet
});
afterEach(() => {
jest.useRealTimers();
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;