Compare commits

Author SHA1 Message Date
sneak 9858541bf8 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 always went to AddressDetail, even after the user had opened another screen. The token is still added when the network has not changed. The popup now goes to AddressDetail only when AddToken shows and has not been opened again since Add was pressed. It tells this from state.currentView and from a count of AddToken's openings, as Send does. An answer from an earlier opening leaves the status line as it is. Otherwise the status line is cleared once the token is added, so Back onto AddToken no longer shows "Looking up token...".

Model: opus-5-5
2026-10-27 21:24:30 +00:00
30 changed files with 90 additions and 1278 deletions
-1
View File
@@ -10,7 +10,6 @@ WORKDIR /app
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ 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 RUN script/bootstrap
COPY . . COPY . .
+26 -38
View File
@@ -905,8 +905,8 @@ src/
prices.js — ETH/USD and token/USD from Chainlink price feeds prices.js — ETH/USD and token/USD from Chainlink price feeds
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
the active network, read from storage read from storage for the requests those settings gate
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded) price feeds (hardcoded)
@@ -1473,20 +1473,21 @@ tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. The balance list always drops a token that claims a bundled-list more holders. A token claiming a symbol from the bundled list from any other
symbol from another contract address, whatever the settings. The list also drops contract address is always dropped, tracked or not, and so is any token claiming
every token that claims the native asset's symbol: `"ETH"`, or any network's a symbol that belongs to the native asset and therefore has no legitimate
`nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
"Hide tokens with fewer than 1,000 holders" setting governs the transaction `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
history and the send-screen token selector, not this list. A token's holder tokens with fewer than 1,000 holders" setting governs the transaction history
count is unknown when the explorer reports none, or reports anything other than and the send-screen token selector, not this list. A token's holder count is
a whole number written in digits alone, such as `1,000` or `1e3` unknown when the explorer reports none, or reports anything other than a whole
(`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
unknown count as 1,000 or more, so such a token is shown only when it is on the in `src/shared/holders.js`). This list does not take an unknown count as 1,000
bundled list or tracked. Every nonzero holding of a token shown is stored or more, so such a token is shown only when it is on the bundled list or
exactly, however small, but a holding below 0.000001 is left out of the balance tracked. Every nonzero holding of a token shown is stored exactly, however
lists, the send-screen token selector, the address total and the remove-address small, but a holding below 0.000001 is left out of the balance lists, the
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. zero balance" is on.
@@ -2052,21 +2053,15 @@ view would leave a wallet one click from deletion.
fee estimate and the contract and never-sent recipient checks ask that fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's network's RPC endpoint, and the Etherscan label check asks that network's
explorer explorer
- "Sign & Send" (correct password) when the gas limit or a fee is over its - "Sign & Send" (correct password) when the gas limit, a fee per gas or the
bound → nothing is signed; "The gas limit or fee the RPC endpoint quoted total fee the RPC endpoint fills in at signing is over its bound in
for this transaction is more than this wallet will sign for, so it was not `assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
sent. Please go back and try again." in its own box, no screen change. For signed; "The gas limit or fee the RPC endpoint quoted for this transaction
an ETH amount that Send's "Max" filled in, the gas limit and fees are is more than this wallet will sign for, so it was not sent. Please go back
those of this screen's own fee estimate (see Amount above). For any other and try again." in its own box, the same sentence for all three, no screen
send, the RPC endpoint fills in the gas limit and fees at signing. Just change
before signing, `assertWithinCeilings()` (`src/shared/approvalVerify.js`) - "Sign & Send" (wrong password) → "Wrong password." on the password error
refuses the send when any of these is over its bound: line, no screen change
- the gas limit
- each fee per gas the transaction sets
- the total fee: the gas limit times `maxFeePerGas`, or times `gasPrice`
when the transaction has no `maxFeePerGas`
- "Sign & Send" (wrong password) → "That password is incorrect. Please try
again." on the password error line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is - "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not screen with another address selected. When the selected wallet does not
@@ -2238,8 +2233,6 @@ view would leave a wallet one click from deletion.
**AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add" with a token that the spoof rule refuses → nothing is tracked; "Not
added: this token fakes a known symbol." flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or - "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change after AddToken was opened again → tracks the token, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2291,8 +2284,6 @@ view would leave a wallet one click from deletion.
- Either "Save" stores its URL only if the input still shows that URL when - Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not the endpoint answers. Otherwise a flash message says that the URL was not
saved saved
- If the user taps the same "Save" again before the endpoint answers, the
earlier tap stores nothing and shows no flash message
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -2550,9 +2541,6 @@ view would leave a wallet one click from deletion.
"Added SYMBOL" flash message (no screen change) "Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address with a token that the spoof rule refuses →
nothing is tracked; "Not added: this token fakes a known symbol." flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
lookup answers → nothing is tracked; "The active network changed before lookup answers → nothing is tracked; "The active network changed before
the lookup finished, so the token was not added." flash message, no screen the lookup finished, so the token was not added." flash message, no screen
+4 -2
View File
@@ -380,8 +380,10 @@ but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known 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 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 added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balance list always fills it. The send token list always applies the check. Your balances apply it
applies the check too. 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 **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 from transaction history and the send token list, and are left out of your
+2 -1
View File
@@ -153,7 +153,8 @@ module.exports = [
}, },
}, },
// The files in script/lib/ run on plain node too, and are never bundled. // The helpers the script/ entrypoints call: plain node programs too, run
// from a shell script rather than from yarn, and never bundled.
{ {
files: ["script/lib/**/*.js"], files: ["script/lib/**/*.js"],
languageOptions: { languageOptions: {
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "node script/lib/jest.js --maxWorkers=3", "test": "jest --forceExit --maxWorkers=3",
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose", "test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
-1
View File
@@ -118,7 +118,6 @@ ensure_yarn() {
fi fi
} }
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
install_js_deps() { install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
-40
View File
@@ -1,40 +0,0 @@
// 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");
-11
View File
@@ -3,7 +3,6 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before // Counts the times AddToken has opened. A contract lookup started before
@@ -78,15 +77,6 @@ function init(ctx) {
); );
return; return;
} }
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked.
if (isSpoofedSymbol(info.symbol, contractAddr)) {
showFlash("Not added: this token fakes a known symbol.");
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
@@ -127,7 +117,6 @@ function init(ctx) {
? detail ? detail
: "Could not add the token.", : "Could not add the token.",
); );
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = ""; infoEl.textContent = "";
infoEl.style.visibility = "hidden"; infoEl.style.visibility = "hidden";
} }
+14 -47
View File
@@ -22,7 +22,6 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -99,7 +98,6 @@ let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
async function loadTransactions(address) { async function loadTransactions(address) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -116,74 +114,43 @@ async function loadTransactions(address) {
}); });
const txs = result.transactions; const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so // Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just // that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names. // the ones that were originally entered as ENS names.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
names = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
// The rows then show no ENS names. ensNameMap = new Map();
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+14 -49
View File
@@ -29,7 +29,6 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -171,7 +170,6 @@ function show() {
} }
async function loadTransactions(address, tokenId) { async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -188,6 +186,16 @@ async function loadTransactions(address, tokenId) {
}); });
let txs = result.transactions; let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only // Filter to this token only
if (tokenId === "ETH") { if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null); txs = txs.filter((tx) => tx.contractAddress === null);
@@ -199,75 +207,32 @@ async function loadTransactions(address, tokenId) {
); );
} }
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse // Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address. // lookups work for every displayed address.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
names = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
// The rows then show no ENS names. ensNameMap = new Map();
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
-4
View File
@@ -33,7 +33,6 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
@@ -448,9 +447,6 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) { if (showZero && trackedTokens) {
for (const t of trackedTokens) { for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue; if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (isSpoofedSymbol(t.symbol, t.address)) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
0, 0,
+6 -34
View File
@@ -44,7 +44,6 @@ const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
function findActiveAddr() { function findActiveAddr() {
@@ -184,7 +183,6 @@ async function loadHomeTxs(ctx) {
} }
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
@@ -204,15 +202,6 @@ async function loadHomeTxs(ctx) {
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25 // Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set(); const seen = new Set();
let merged = []; let merged = [];
@@ -226,24 +215,14 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters); const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts, inside the save and // Persist any newly discovered fraud contracts
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) { if (filtered.newFraudContracts.length > 0) {
let saved = false; for (const addr of filtered.newFraudContracts) {
await saveState((stored) => { if (!state.fraudContracts.includes(addr)) {
if (state.networkId !== networkId) return false; state.fraudContracts.push(addr);
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
saved = true; }
}); await saveState();
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
merged = filtered.transactions; merged = filtered.transactions;
@@ -255,13 +234,6 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message); log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list"); const list = $("home-tx-list");
if (list) { if (list) {
list.innerHTML = list.innerHTML =
+3 -30
View File
@@ -50,11 +50,6 @@ let versionClickTimer = null;
// not the one in `state`. // not the one in `state`.
let shownNetwork = null; let shownNetwork = null;
// Each endpoint "Save" counts its taps here, under the field that it stores.
// Once the user taps the same "Save" again, the earlier tap shows no flash
// message. The earlier tap stores its URL only if its write had already begun.
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
// One row per site origin, however many addresses it appears under, each with // One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove. // an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) { function renderSiteList(containerId, origins, onRemove) {
@@ -352,16 +347,10 @@ function showNewSettings() {
// shows `url`, nothing is saved. The flash message then says that the URL was // shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in // changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`. // `state` in one step. So a redraw after the comparison shows `url`.
// async function saveEndpoint(field, input, url, networkId) {
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
// the same "Save" again before this save holds the lock, nothing is stored. If
// the user taps the same "Save" again before the write ends, no flash message
// is shown.
async function saveEndpoint(field, input, url, networkId, tap) {
let refused = false; let refused = false;
let inputChanged = false; let inputChanged = false;
await saveState((stored) => { await saveState((stored) => {
if (saveTaps[field] !== tap) return false;
refused = refused =
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url; inputChanged = $(input).value.trim() !== url;
@@ -369,7 +358,6 @@ async function saveEndpoint(field, input, url, networkId, tap) {
state[field] = url; state[field] = url;
stored[field] = url; stored[field] = url;
}); });
if (saveTaps[field] !== tap) return;
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) { } else if (inputChanged) {
@@ -387,8 +375,6 @@ function init(_ctx) {
showPhrase.init(); showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
saveTaps.rpcUrl++;
const tap = saveTaps.rpcUrl;
const url = $("settings-rpc").value.trim(); const url = $("settings-rpc").value.trim();
if (!url) { if (!url) {
showFlash("Please enter an RPC URL."); showFlash("Please enter an RPC URL.");
@@ -409,8 +395,6 @@ function init(_ctx) {
}), }),
}); });
const json = await resp.json(); const json = await resp.json();
// The user has tapped this "Save" again since the check began.
if (tap !== saveTaps.rpcUrl) return;
if (json.error) { if (json.error) {
log.errorf("RPC validation error:", json.error); log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error."); showFlash("Endpoint returned an error.");
@@ -424,7 +408,6 @@ function init(_ctx) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.rpcUrl) return;
// Not the error's message: fetch puts the whole URL, password and // Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL // key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse. // with a user name and password or one it cannot parse.
@@ -432,12 +415,10 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap); await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
saveTaps.blockscoutUrl++;
const tap = saveTaps.blockscoutUrl;
const url = $("settings-blockscout").value.trim(); const url = $("settings-blockscout").value.trim();
if (!url) { if (!url) {
showFlash("Please enter a Blockscout API URL."); showFlash("Please enter a Blockscout API URL.");
@@ -447,26 +428,18 @@ function init(_ctx) {
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url + "/stats"); const resp = await debugFetch(url + "/stats");
if (tap !== saveTaps.blockscoutUrl) return;
if (!resp.ok) { if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + "."); showFlash("Endpoint returned HTTP " + resp.status + ".");
return; return;
} }
} catch { } catch {
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.blockscoutUrl) return;
// Not the error's message, as for the RPC check above. // Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url)); log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint( await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
"blockscoutUrl",
"settings-blockscout",
url,
net.id,
tap,
);
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
-11
View File
@@ -3,7 +3,6 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
let ctx; let ctx;
@@ -156,16 +155,6 @@ function init(_ctx) {
); );
return; return;
} }
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked. The rule never refuses
// a bundled token, so the "Top tokens:" buttons and the top 100
// dropdown need no such check.
if (isSpoofedSymbol(info.symbol, addr)) {
showFlash("Not added: this token fakes a known symbol.");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ state.trackedTokens.push({
address: addr, address: addr,
+3 -19
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network // Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// from the stored profile. // profile.
// //
// Before each request that sends the user's data to Blockscout or Etherscan, the // Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting // wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,11 +7,6 @@
// made, and an answer that lands after the setting went off is dropped. A page's // made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just // own copy of the setting is not used, because another page may have just
// changed it. // changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi"); const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState"); const { normalizePersisted } = require("./persistedState");
@@ -29,15 +24,4 @@ async function storedSettingIsOn(name) {
} }
} }
// Whether `networkId` is the active network in the stored profile. A failed module.exports = { storedSettingIsOn };
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+1 -23
View File
@@ -75,7 +75,7 @@ test("an answer after the user opened Settings adds the token and Settings stays
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]); expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
}); });
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => { test("Back onto AddToken after that answer shows no lookup status", async () => {
const boot = await openAddToken(); const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot); const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token..."); expect(boot.text("add-token-info")).toBe("Looking up token...");
@@ -104,28 +104,6 @@ test("an answer after AddToken was opened again adds the token and leaves AddTok
expect(boot.storage.read("autistmask").trackedTokens).toEqual([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 () => { test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken(); const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot); const answerLookup = addWithPendingLookup(boot);
+1 -6
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
} }
// ------------------------------------------------------------ harness // ------------------------------------------------------------ harness
@@ -188,11 +188,7 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have. // handler to the buttons it drew, as the browser's fresh list would have.
let plusButton; 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(() => { beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null; approvalDetails = null;
sent = []; sent = [];
mockNode.broadcast = []; mockNode.broadcast = [];
@@ -243,7 +239,6 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
+12 -11
View File
@@ -537,18 +537,7 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve)); 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
@@ -1894,6 +1883,18 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1]; 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 () => { test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground(); const bg = loadBackground();
const pending = bg.requestTx(); const pending = bg.requestTx();
-11
View File
@@ -103,18 +103,7 @@ function deferred() {
return { promise, resolve }; 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
-11
View File
@@ -56,18 +56,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
-11
View File
@@ -64,18 +64,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
-11
View File
@@ -76,18 +76,7 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve(); 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
}); });
-11
View File
@@ -57,18 +57,7 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); 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(() => { afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
}); });
-11
View File
@@ -102,17 +102,6 @@ function makeDocument() {
// ------------------------------------------------------------ harness // ------------------------------------------------------------ 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) { function wallet(name, secret, addresses) {
return { return {
type: "hd", type: "hd",
-498
View File
@@ -1,498 +0,0 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
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"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check after the name lookup can drop the
// answer.
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () =>
history().slice(0, 1),
);
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+1 -9
View File
@@ -131,14 +131,10 @@ async function review(amount, token) {
async function confirm(amount, token) { async function confirm(amount, token) {
const txInfo = await review("0.5", token); const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount }); confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 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(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
state.wallets = [ state.wallets = [
{ {
@@ -171,10 +167,6 @@ beforeEach(() => {
state.currentView = null; state.currentView = null;
}); });
afterEach(() => {
jest.useRealTimers();
});
describe.each([ describe.each([
["1e-3", "ETH"], ["1e-3", "ETH"],
["1e-3", "TOK"], ["1e-3", "TOK"],
+1 -9
View File
@@ -277,18 +277,14 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle. // show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() { async function settle() {
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
} }
function canSend() { function canSend() {
return !el("btn-confirm-send").disabled; 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(() => { beforeEach(() => {
jest.useFakeTimers();
elements.clear(); elements.clear();
log.errorf.mockClear(); log.errorf.mockClear();
pageClosed = new AbortController(); pageClosed = new AbortController();
@@ -301,10 +297,6 @@ beforeEach(() => {
mockNode.broadcast = []; mockNode.broadcast = [];
}); });
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => { describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => { test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI); await refreshWith(BALANCE_WEI);
-269
View File
@@ -167,272 +167,3 @@ test.each([
); );
}, },
); );
// This answer passes both endpoint checks on Ethereum Mainnet.
const PASSING_ANSWER = {
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
};
// Both endpoint checks read this answer as an error. The RPC check reads the
// answer's `error`. The Blockscout check reads the answer's `ok`.
const ERROR_ANSWER = {
ok: false,
status: 500,
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
};
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user types a second URL and taps "Save" again. The second check passes
// first. The second URL is stored, and the flash message reads "Saved.". The
// first check then ends in one of three ways:
//
// - It passes.
// - It fails, because fetch cannot reach the endpoint.
// - It gets an answer that holds an error.
//
// None of the three changes the stored URL or the flash message
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "passes",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "fails",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "answers with an error",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "passes",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "fails",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "answers with an error",
},
])(
"$button keeps the second tap's result when the first check $first last",
async ({ button, input, field, first }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const secondUrl = "https://second.example.invalid";
page.node(input).value = "https://first.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = secondUrl;
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
checks[1].resolve(PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Saved.");
if (first === "passes") {
checks[0].resolve(PASSING_ANSWER);
} else if (first === "answers with an error") {
checks[0].resolve(ERROR_ANSWER);
} else {
// fetch rejects when it cannot reach the endpoint.
checks[0].reject(new TypeError("Failed to fetch"));
}
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user empties the input and taps "Save" again. The flash message asks for
// a URL. The first check then passes. The flash message still asks for a URL,
// and the stored URL is unchanged.
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
message: "Please enter an RPC URL.",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
message: "Please enter a Blockscout API URL.",
},
])(
"$button keeps the empty-input message when the first check passes last",
async ({ button, input, field, message }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = "";
await page.click(button);
expect(page.text("flash-msg")).toBe(message);
answer(PASSING_ANSWER);
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(message);
},
);
// The user types a URL in Settings and taps "Save". Another write holds the
// lock. So the save waits for the lock once the check passes. The user then
// taps "Save" again on the same URL. When the lock is free, the first save
// stores nothing and shows no flash message. The second check then passes, and
// the second save stores the URL.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing for an earlier tap still waiting for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve) => {
checks.push(resolve);
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
checks[0](PASSING_ANSWER);
await page.settle();
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseLock();
await lockHeld;
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe("Testing endpoint...");
checks[1](PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user taps "Save", and the check passes. The save then begins its storage
// write, and this test keeps the write waiting. The user taps "Save" again, and
// the second check cannot reach the endpoint. When the write ends, the flash
// message still reads "Could not reach endpoint.". The write stores the URL
// anyway, because the write began before the second tap.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button keeps the second tap's message when tapped again during the write",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
const { set } = page.storage.local;
const write = set.getMockImplementation();
let writeBegan = false;
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
set.mockImplementationOnce(async (items) => {
writeBegan = true;
await writeHeld;
await write(items);
});
checks[0].resolve(PASSING_ANSWER);
await page.settle();
expect(writeBegan).toBe(true);
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// fetch rejects when it cannot reach the endpoint.
checks[1].reject(new TypeError("Failed to fetch"));
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
releaseWrite();
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
},
);
-91
View File
@@ -1,91 +0,0 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
});
-6
View File
@@ -357,11 +357,6 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
beforeEach(() => { 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(); const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand // A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check. // in for the defect check.
@@ -403,7 +398,6 @@ describe("every way to a signature or the private key refuses a defective wallet
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers();
state.wallets = []; state.wallets = [];
state.activeAddress = null; state.activeAddress = null;
state.selectedWallet = null; state.selectedWallet = null;