Compare commits

..
1 Commits
Author SHA1 Message Date
sneak d9eec62994 fix: two pages switching networks no longer lose tracked tokens (closes #701)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
A page that had not yet taken another page's network switch could lose
tracked tokens in two ways.

- Its save of an added or removed token wrote its own network's list as
  the new network's list. saveState() now keeps the stored list when the
  merged record names a network other than this page's.
- Its own switch in Settings wrote its old lists of tracked tokens per
  network over the stored ones. onChainSwitch() now first takes the
  stored switch with takeStoredNetwork(), as followStoredSettings() does,
  and then switches from there.

Model: opus-5-5
2026-10-27 19:27:55 +00:00
26 changed files with 136 additions and 473 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 . .
+24 -36
View File
@@ -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,10 +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
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
answers → nothing is tracked; "The active network changed before the answers → nothing is tracked; "The active network changed before the
lookup finished, so the token was not added." flash message, no screen lookup finished, so the token was not added." flash message, no screen
@@ -2548,9 +2539,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");
-28
View File
@@ -3,15 +3,9 @@ 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
// AddToken was last opened changes neither the screen nor the status line.
let addTokenScreenOpenings = 0;
function show() { function show() {
addTokenScreenOpenings++;
$("add-token-address").value = ""; $("add-token-address").value = "";
$("add-token-info").textContent = ""; $("add-token-info").textContent = "";
$("add-token-info").style.visibility = "hidden"; $("add-token-info").style.visibility = "hidden";
@@ -61,7 +55,6 @@ function init(ctx) {
// The network the lookup asks, which Settings may switch away from // The network the lookup asks, which Settings may switch away from
// before it answers, as for a transaction in confirmTx.js. // before it answers, as for a transaction in confirmTx.js.
const chainId = currentNetwork().chainId; const chainId = currentNetwork().chainId;
const opening = addTokenScreenOpenings;
try { try {
const info = await lookupTokenInfo( const info = await lookupTokenInfo(
contractAddr, contractAddr,
@@ -78,15 +71,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,
@@ -96,17 +80,6 @@ function init(ctx) {
}); });
await saveState(); await saveState();
ctx.doRefreshAndRender(); 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 // Pop the stack (back to address detail) and re-render it
// so the newly added token is visible immediately. // so the newly added token is visible immediately.
if (state.viewStack.length > 0) { if (state.viewStack.length > 0) {
@@ -127,7 +100,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";
} }
-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,
-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,
+17 -6
View File
@@ -61,20 +61,31 @@ function clearPopupCaches() {
// (https://git.eeqj.de/sneak/AutistMask/issues/653, // (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692). // https://git.eeqj.de/sneak/AutistMask/issues/692).
// //
// When the stored record is on another network, a switch saved elsewhere has
// not reached this page yet. This page first takes that switch with
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
// `newNetworkId` is then made from the stored record's network. So every
// network's list of tracked tokens in the stored record is kept
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
// already on `newNetworkId`, the switch made from there only resets the
// balances and caches.
//
// Before the switch, `state` takes the tracked tokens that another page stored // Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The // since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were // switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved // added on.
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState, takeStoredTrackedTokens } = require("./state"); const {
state,
saveState,
takeStoredNetwork,
takeStoredTrackedTokens,
} = require("./state");
let net = null; let net = null;
await saveState((stored) => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) { if (stored.networkId !== state.networkId) takeStoredNetwork(stored);
takeStoredTrackedTokens(stored); takeStoredTrackedTokens(stored);
}
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0; stored.lastBalanceRefresh = 0;
stored.fraudContracts = []; stored.fraudContracts = [];
+14 -5
View File
@@ -527,6 +527,14 @@ async function saveStateOnce(beforeWrite) {
merged[key] = current[key]; merged[key] = current[key];
} }
} }
// When the merged record names a network other than this page's, this page
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
// is then the list of the network that this page still has. So the merged
// record keeps the stored list
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
if (merged.networkId !== current.networkId) {
merged.trackedTokens = fresh.trackedTokens;
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0); merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
// Stamped on every write, never merged or diffed: the record that goes to // Stamped on every write, never merged or diffed: the record that goes to
// storage is in THIS build's shape whatever shape it was read in, which is // storage is in THIS build's shape whatever shape it was read in, which is
@@ -608,11 +616,12 @@ function saveState(beforeWrite) {
} }
// Give this page the network switch held by `stored`, a record a save of this // Give this page the network switch held by `stored`, a record a save of this
// page has just read (followStoredSettings() in src/popup/index.js calls this // page has just read (followStoredSettings() in src/popup/index.js and
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does // onChainSwitch() in src/shared/chainSwitch.js call this from `beforeWrite`),
// with a whole record. The switch is then not this page's own change, even when // in `state` and in `baseline` alike, as loadState() does with a whole
// that save fails to write: a later switch back to the previous network still // record. The switch is then not this page's own change, even when that save
// differs from `baseline`, and is stored. // fails to write: a later switch back to the previous network still differs
// from `baseline`, and is stored.
function takeStoredNetwork(stored) { function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored); copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
-137
View File
@@ -1,137 +0,0 @@
// 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]);
});
+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",
+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);
-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([]);
});
});
+57
View File
@@ -257,6 +257,7 @@ test("a token tracked on Sepolia when another page switched there is not tracked
...boot.storage.read("autistmask"), ...boot.storage.read("autistmask"),
networkId: "sepolia", networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN], trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: { mainnet: [] },
}); });
await chooseNetwork(boot, "sepolia"); await chooseNetwork(boot, "sepolia");
@@ -267,6 +268,62 @@ test("a token tracked on Sepolia when another page switched there is not tracked
expect(stored.networkTrackedTokens.mainnet).toEqual([]); expect(stored.networkTrackedTokens.mainnet).toEqual([]);
}); });
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page adds a token on Ethereum Mainnet. The add's
// save does not write this page's Ethereum Mainnet list as Sepolia's list.
test("a token added before this page takes another page's switch leaves Sepolia's tokens tracked", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. That switch puts DAI away under Ethereum Mainnet. This page has not
// taken the switch yet. A switch to Sepolia in this page's Settings keeps DAI
// on Ethereum Mainnet's list.
test("a token another page tracked before its own switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this // Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the // page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own. // switch drops this page's own.
-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;