fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

Out of the box the wallet sent the user's addresses to Blockscout for history
and token lookups, and each recipient to Etherscan for its label. Two settings,
"Use Blockscout" and "Check recipients on Etherscan", now gate those requests
where they are made. Both are off in a new profile and offered unticked on
Welcome and in Settings. While one is off, the screens that relied on it say so
in a plain line naming the setting; after either changes in Settings, Back
draws the screen it lands on again. README and the user guide say what is
contacted by default and what each setting adds.

Model: opus-5-5
This commit is contained in:
2026-10-21 14:29:38 +00:00
parent c316d9ee78
commit bd2b5ce96a
49 changed files with 1342 additions and 139 deletions
+8 -1
View File
@@ -410,7 +410,14 @@ describe("a balance refresh under a network switch", () => {
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
refreshBalances: async (
wallets,
rpcUrl,
explorer,
useBlockscout,
tokens,
id,
) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
+1
View File
@@ -54,6 +54,7 @@ function refresh(signal) {
wallets,
RPC_URL,
EXPLORER_URL,
true,
[TRACKED],
"mainnet",
signal,
+1 -1
View File
@@ -91,7 +91,7 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
+467
View File
@@ -0,0 +1,467 @@
// Blockscout and Etherscan are asked nothing until the user turns them on
// (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use
// Blockscout" and "Check recipients on Etherscan" both off. Each request to
// either service is skipped where it is made while its setting is off, and
// made while it is on; the popup and the background hand the stored setting
// to the code that makes the request.
//
// The functions that make the requests are driven directly, against a fetch()
// that records every URL it is asked for. The screens are driven through the
// real popup over a stored profile (tests/support/popupBoot.js), whose fetch()
// stub records the same way.
"use strict";
const { FetchRequest } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
DEFAULT_STATE,
normalizePersisted,
} = require("../src/shared/persistedState");
const {
fetchTokenBalances,
refreshBalances,
} = require("../src/shared/balances");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { getFullWarnings } = require("../src/shared/addressWarnings");
const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers");
const RPC_URL = "https://rpc.example.invalid";
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
const TX_HASH = "0x" + "ab".repeat(32);
const ZERO_WORD = "0x" + "0".repeat(64);
// USDC, from the bundled list, as Blockscout reports a holding of it.
const USDC_ROW = {
value: "5000000",
token: {
type: "ERC-20",
address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
symbol: "USDC",
name: "USD Coin",
decimals: "6",
holders_count: "50000",
},
};
const realFetch = globalThis.fetch;
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
describe("a new profile", () => {
test("has both settings off", () => {
expect(DEFAULT_STATE.useBlockscout).toBe(false);
expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false);
const loaded = normalizePersisted(undefined);
expect(loaded.useBlockscout).toBe(false);
expect(loaded.checkRecipientsOnEtherscan).toBe(false);
});
test("keeps either setting the user turned on", () => {
const loaded = normalizePersisted({
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
expect(loaded.useBlockscout).toBe(true);
expect(loaded.checkRecipientsOnEtherscan).toBe(true);
});
});
describe("where each request is made", () => {
// Every URL fetch() was asked for, in order.
let asked;
beforeEach(() => {
asked = [];
globalThis.fetch = jest.fn(async (url) => {
asked.push(String(url));
return {
ok: true,
status: 200,
json: async () =>
String(url).endsWith("/token-balances")
? [USDC_ROW]
: { items: [] },
text: async () => "<html><body></body></html>",
};
});
});
afterEach(() => {
globalThis.fetch = realFetch;
});
test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => {
expect(
await fetchTokenBalances(ADDRESS, BLOCKSCOUT, false, []),
).toEqual([]);
expect(asked).toEqual([]);
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, true, []);
expect(rows.map((row) => row.symbol)).toEqual(["USDC"]);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
});
test("the transaction history is asked for only while Use Blockscout is on", async () => {
expect(
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, false, "0x1"),
).toEqual([]);
expect(asked).toEqual([]);
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, true, "0x1");
expect(asked.sort()).toEqual([
BLOCKSCOUT +
"/addresses/" +
ADDRESS +
"/token-transfers?type=ERC-20",
BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions",
]);
});
test("the Etherscan label check is made only while Check recipients on Etherscan is on", async () => {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" });
await getFullWarnings(RECIPIENT, provider, {
chainId: "0x1",
checkRecipientsOnEtherscan: false,
});
expect(asked).toEqual([]);
await getFullWarnings(RECIPIENT, provider, {
chainId: "0x1",
checkRecipientsOnEtherscan: true,
});
expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]);
});
describe("a balance refresh", () => {
// The RPC endpoint answers every balance with zero, and every other
// call, the ENS reverse lookup's, with no resolver.
beforeEach(() => {
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const reply = (r) => ({
jsonrpc: "2.0",
id: r.id,
result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD,
});
const answer = Array.isArray(body)
? body.map(reply)
: reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
});
// An address that was refreshed while Use Blockscout was on, and holds
// the USDC Blockscout reported then.
function addressWithUsdc() {
return {
address: ADDRESS,
tokenBalances: [
{
address: USDC_ROW.token.address_hash,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
],
};
}
async function refresh(addr, useBlockscout) {
await refreshBalances(
[{ addresses: [addr] }],
RPC_URL,
BLOCKSCOUT,
useBlockscout,
[],
"mainnet",
);
}
test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => {
const addr = addressWithUsdc();
await refresh(addr, false);
expect(asked).toEqual([]);
expect(addr.tokenBalances).toEqual([]);
});
test("asks Blockscout for the token balances while Use Blockscout is on", async () => {
const addr = addressWithUsdc();
await refresh(addr, true);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([
"USDC",
]);
});
});
});
describe("the popup", () => {
// Every URL the booted page handed to fetch().
function fetched() {
return globalThis.fetch.mock.calls.map((call) => String(call[0]));
}
function blockscoutOrEtherscan(url) {
return url.includes("blockscout") || url.includes("etherscan");
}
beforeEach(() => {
// The screens below log their failed RPC reads; the stub node in
// popupBoot.js answers none.
jest.spyOn(console, "error").mockImplementation(() => {});
});
test("hands its balance refresh and Home's history the stored Use Blockscout setting", async () => {
await bootPopup(unversionedValidProfile());
const {
refreshBalances: popupRefresh,
} = require("../src/shared/balances");
const {
fetchRecentTransactions: popupHistory,
} = require("../src/shared/transactions");
expect(popupRefresh.mock.calls[0][3]).toBe(false);
expect(popupHistory.mock.calls[0][2]).toBe(false);
cleanupPopup();
await bootPopup(unversionedValidProfile({ useBlockscout: true }));
const {
refreshBalances: onRefresh,
} = require("../src/shared/balances");
const {
fetchRecentTransactions: onHistory,
} = require("../src/shared/transactions");
expect(onRefresh.mock.calls[0][3]).toBe(true);
expect(onHistory.mock.calls[0][2]).toBe(true);
});
test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.node("home-tx-list").innerHTML).toContain(
escapeHtml(HISTORY_OFF_TEXT),
);
expect(page.hidden("home-tokens-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
expect(on.node("home-tx-list").innerHTML).toContain(
"No transactions found.",
);
expect(on.hidden("home-tokens-note")).toBe(true);
});
// The transaction screen, as a reopened popup draws it.
function onTransactionScreen(extra) {
return unversionedValidProfile({
currentView: "transaction",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
tx: {
hash: TX_HASH,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId: "0x1",
},
},
...extra,
});
}
test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => {
const page = await bootPopup(onTransactionScreen());
expect(page.visibleViews()).toEqual(["transaction"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
onTransactionScreen({ useBlockscout: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
BLOCKSCOUT + "/transactions/" + TX_HASH,
]);
expect(on.hidden("tx-detail-blockscout-note")).toBe(true);
});
// The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a
// reopened popup draws it.
function onConfirmScreen(extra) {
return unversionedValidProfile({
currentView: "confirm-tx",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: RECIPIENT,
amount: "0.1",
chainId: "0x1",
},
},
...extra,
});
}
test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => {
const page = await bootPopup(onConfirmScreen());
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("confirm-etherscan-note")).toBe(false);
expect(page.hidden("confirm-etherscan-warning")).toBe(true);
cleanupPopup();
const on = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
"https://etherscan.io/address/" + RECIPIENT,
]);
expect(on.hidden("confirm-etherscan-note")).toBe(true);
expect(on.hidden("confirm-etherscan-warning")).toBe(false);
});
// Ticks a checkbox on the page, as the user does.
async function tick(page, id) {
const box = page.node(id);
box.checked = true;
for (const fn of box.listeners.change) await fn();
await page.settle();
}
test("Welcome offers both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(undefined);
expect(page.visibleViews()).toEqual(["welcome"]);
expect(page.node("welcome-use-blockscout").checked).toBe(false);
expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe(
false,
);
await tick(page, "welcome-use-blockscout");
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "welcome-check-recipients-on-etherscan");
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
test("Settings shows both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
expect(page.node("settings-use-blockscout").checked).toBe(false);
expect(
page.node("settings-check-recipients-on-etherscan").checked,
).toBe(false);
await tick(page, "settings-use-blockscout");
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "settings-check-recipients-on-etherscan");
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
});
describe("the background", () => {
afterEach(() => {
delete globalThis.chrome;
jest.dontMock("../src/shared/balances");
});
// Starts the background over `profile`, fires its balance alarm, and
// answers with the Use Blockscout setting its refresh was handed.
async function settingTheRefreshIsHanded(profile) {
jest.resetModules();
let handed;
const called = new Promise((resolve) => {
handed = resolve;
});
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: async (...args) => handed(args[3]),
}));
let onAlarm = null;
globalThis.chrome = {
storage: makeStorageStub({ autistmask: profile }),
alarms: {
create: () => {},
get: async () => undefined,
clear: async () => false,
onAlarm: {
addListener: (fn) => {
onAlarm = fn;
},
},
},
runtime: {
onMessage: { addListener: () => {} },
onConnect: { addListener: () => {} },
onInstalled: { addListener: () => {} },
onStartup: { addListener: () => {} },
getURL: (p) => "chrome-extension://test/" + p,
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
tabs: { query: () => {}, sendMessage: () => {} },
action: { setPopup: () => {} },
};
require("../src/background/index");
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
onAlarm({ name: BALANCE_REFRESH_ALARM });
return called;
}
test("hands its balance refresh the stored Use Blockscout setting", async () => {
const profile = { ...unversionedValidProfile(), lastBalanceRefresh: 0 };
expect(await settingTheRefreshIsHanded(profile)).toBe(false);
expect(
await settingTheRefreshIsHanded({
...profile,
useBlockscout: true,
}),
).toBe(true);
});
});
+1
View File
@@ -247,6 +247,7 @@ async function openSend(token, tokenUnits = null) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+7 -1
View File
@@ -50,8 +50,14 @@ function explorerPage(title) {
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
// The Etherscan label check is made, so its box can be shown.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
+8 -2
View File
@@ -142,13 +142,19 @@ jest.mock("../src/popup/views/txStatus", () => ({
}));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
// asks for are recorded, and nothing is fetched. The stored profile has the
// check turned on.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
+2
View File
@@ -297,6 +297,8 @@ describe.each([
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
+23 -2
View File
@@ -653,6 +653,12 @@ function traceEnabled(raw) {
* confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @param {string} [opts.etherscanLabel] the label Etherscan's page gives the
* address asked about, such as "Fake_Phishing1"; none when unset. Read at
* request time.
* @param {object[]} [opts.requests] every request this handler sees, from the
* popup's pages and the background worker alike, appended in order as
* { method, url, fromWorker }.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
@@ -691,6 +697,13 @@ async function installNetworkStubs(ctx, opts) {
const origin = fromWorker ? "[sw] " : "[page] ";
console.log("# routed " + origin + req.method() + " " + req.url());
}
if (Array.isArray(opts.requests)) {
opts.requests.push({
method: req.method(),
url: req.url(),
fromWorker,
});
}
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") {
@@ -762,12 +775,20 @@ async function installNetworkStubs(ctx, opts) {
return route.fulfill({ status: 204, body: "" });
}
// Best-effort Etherscan address labels: served as an empty page.
// Best-effort Etherscan address labels: served as an empty page, or
// as a page labelling the address with opts.etherscanLabel.
if (url.hostname.endsWith("etherscan.io")) {
const title = opts.etherscanLabel
? "<title>" +
opts.etherscanLabel +
" | Address: " +
p.split("/").pop() +
" | Etherscan</title>"
: "";
return route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body></body></html>",
body: "<html><head>" + title + "</head><body></body></html>",
});
}
+255
View File
@@ -269,6 +269,58 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
}
});
// ------------------ Blockscout and Etherscan are asked nothing (#668)
//
// A fresh install asks neither Blockscout nor Etherscan anything until the
// user turns on "Use Blockscout" or "Check recipients on Etherscan". The route
// handler in tests/e2e/network.js records every request the extension makes,
// from its pages and its background worker, in env.routeOpts.requests.
// Recording starts when launch() in tests/e2e/harness.js installs that
// handler, before the first popup page opens; a request the background worker
// makes while it starts up, before then, is not recorded. These tests drive
// every screen that would ask either service, and the background's own
// balance refresh, before either setting is changed, then turn each on and
// watch its requests start.
// What the three transaction lists say while Use Blockscout is off. Written
// out here rather than imported, so the assertion does not compare the popup
// with itself.
const HISTORY_OFF =
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
// The requests made so far to Blockscout or Etherscan.
function explorerRequests(env) {
return env.routeOpts.requests.filter(({ url }) => {
const host = new URL(url).hostname;
return host.endsWith("blockscout.com") || host.endsWith("etherscan.io");
});
}
// Waits until the list `listId` says what Use Blockscout being off leaves out.
async function waitForHistoryOff(page, listId) {
await page.waitForFunction(
([id, want]) => document.getElementById(id).textContent.trim() === want,
[listId, HISTORY_OFF],
{ timeout: 15000 },
);
}
test("Welcome offers Use Blockscout and Check recipients on Etherscan, both unticked (#668)", async (env) => {
await visible(env.page, "#view-welcome");
for (const id of [
"welcome-use-blockscout",
"welcome-check-recipients-on-etherscan",
]) {
const st = await controlState(env.page, id);
assert(st !== null, "Welcome has no #" + id);
assert(
st.tag === "input" && st.type === "checkbox",
"#" + id + " is a <" + st.tag + " type=" + st.type + ">",
);
assert(st.checked === false, "#" + id + " is ticked on a new install");
}
});
// The address scan that follows creating the wallet is held, so the popup
// reloads while it is in flight. As for the reloads mid-refresh further down,
// the assertion for that is the harness's own: a console.error from the page
@@ -296,6 +348,202 @@ test("wallet creation through the UI reaches the main view, and reloading mid-sc
assert(addrCount > 0, "no addresses rendered in the wallet list");
});
test("with neither setting changed, Home, AddressDetail, AddressToken and ConfirmTx say what is off (#668)", async (env) => {
// Funded, so Send leads to the confirmation screen.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
try {
await visible(env.page, "#view-main");
await waitForHistoryOff(env.page, "home-tx-list");
await visible(env.page, "#home-tokens-note");
await env.page.waitForFunction(
(want) =>
document
.getElementById("wallet-list")
.textContent.includes(want),
quantity(FUNDED_ETH_WEI),
{ timeout: 60000 },
);
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await visible(env.page, "#address-tokens-note");
await env.page.locator('#address-balances [data-token="ETH"]').click();
await visible(env.page, "#view-address-token");
await waitForHistoryOff(env.page, "address-token-tx-list");
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
await visible(env.page, "#confirm-etherscan-note");
assert(
!(await env.page.isVisible("#confirm-etherscan-warning")),
"ConfirmTx keeps the Etherscan warning's place while no Etherscan check is made",
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
}
});
// The background refreshes balances on its alarm, once a minute, and skips a
// refresh when the stored record says one ran in the last 30 seconds, which
// the open popup's own refresh does every 10. So the popup is closed, and that
// record is written back to say no refresh has run, from the background
// worker, as another extension page saving it would.
test("with neither setting changed, nothing has reached Blockscout or Etherscan, the background's refresh included (#668)", async (env) => {
await waitForPersisted(
env.page,
"currentView",
"address",
"before the popup is closed",
);
await env.page.close();
const [worker] = env.ctx.serviceWorkers();
await worker.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = r.autistmask;
record.lastBalanceRefresh = 0;
chrome.storage.local.set({ autistmask: record }, resolve);
});
}),
);
const before = env.routeOpts.requests.length;
const deadline = Date.now() + 75000;
while (
!env.routeOpts.requests
.slice(before)
.some((r) => r.fromWorker && r.method === "POST")
) {
assert(
Date.now() < deadline,
"the background's balance refresh never asked the RPC endpoint",
);
await sleep(250);
}
// Its RPC reads and any Blockscout request start together.
await sleep(3000);
const seen = explorerRequests(env);
assert(
seen.length === 0,
"a fresh install asked Blockscout or Etherscan: " +
JSON.stringify(seen),
);
// The watch saw the popup's own traffic as well as the worker's.
assert(
env.routeOpts.requests.some((r) => !r.fromWorker),
"no request from the popup was recorded",
);
// It reopens on the address screen the test above left it on.
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-address");
});
// Ticked from AddressDetail's gear, so Back lands on the screen that said the
// setting is off.
test("turning on Use Blockscout from AddressDetail's gear asks Blockscout for that screen's history on Back, then for the tokens (#668)", async (env) => {
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "once ticked");
const historyAsked = env.page.waitForRequest(
(r) =>
r.url().includes("blockscout.com") &&
r.url().endsWith("/transactions"),
);
const tokensAsked = env.page.waitForRequest(
(r) => r.url().endsWith("/token-balances"),
{ timeout: 30000 },
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await historyAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#address-tokens-note")),
"AddressDetail still says only tracked tokens are listed",
);
await goHome(env.page);
await tokensAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("home-tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#home-tokens-note")),
"Home still says only tracked tokens are listed",
);
});
// Ticked from ConfirmTx's gear, so Back lands on the screen that said no
// Etherscan check was made. Left on for the rest of the run, as the check was
// always made before #668.
test("turning on Check recipients on Etherscan from ConfirmTx's gear has ConfirmTx ask Etherscan on Back and show its label (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.etherscanLabel = "Fake_Phishing668";
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI });
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await visible(env.page, "#confirm-etherscan-note");
await openSettings(env.page);
await env.page.check("#settings-check-recipients-on-etherscan");
await waitForPersisted(
env.page,
"checkRecipientsOnEtherscan",
true,
"once ticked",
);
const before = env.routeOpts.requests.length;
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-confirm-tx");
await visible(env.page, "#confirm-etherscan-warning", 30000);
assert(
!(await env.page.isVisible("#confirm-etherscan-note")),
"ConfirmTx says no Etherscan check was made, with the check on",
);
const asked = env.routeOpts.requests
.slice(before)
.map((r) => r.url.toLowerCase())
.filter((url) => url.includes("etherscan.io"));
assert(
asked.length === 1 &&
asked[0] ===
"https://etherscan.io/address/" + STUB_COUNTERPARTY,
"ConfirmTx asked Etherscan for " + JSON.stringify(asked),
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
env.routeOpts.etherscanLabel = null;
}
});
test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
@@ -5258,10 +5506,17 @@ async function main() {
seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false,
// The label Etherscan's page gives an address, when it gives one
// (#668).
etherscanLabel: null,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
broadcastTransactions: [],
// Every request the extension makes, in order, from when launch()
// installs the route handler, before the first popup page opens
// (#668).
requests: [],
};
let session;
+1
View File
@@ -114,6 +114,7 @@ async function fetchOnto(items, trackedTokens = []) {
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
true,
trackedTokens,
);
state.trackedTokens = trackedTokens;
+9 -5
View File
@@ -150,19 +150,23 @@ describe("the balance-list spam gate", () => {
test("a token with plenty of reported holders is listed", async () => {
respondWith([item()]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBe(50000);
});
test("a token reporting zero holders is still excluded", async () => {
respondWith([item({ token: { holders_count: "0" } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("an unknown holder count does not admit an unvouched token", async () => {
respondWith([item({ token: { holders_count: null } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The path that reaches the send selector and the history filter: a token
@@ -171,7 +175,7 @@ describe("the balance-list spam gate", () => {
// zero that would then hide it downstream.
test("a tracked token with an unknown count is listed with holders null", async () => {
respondWith([item({ token: { holders_count: undefined } })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [
{ address: NOVEL_TOKEN.toUpperCase() },
]);
expect(balances).toHaveLength(1);
@@ -188,7 +192,7 @@ describe("the balance-list spam gate", () => {
},
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
+2
View File
@@ -436,6 +436,8 @@ describe.each([
});
test("the transaction detail screen's type line and fee", async () => {
// The fee is Blockscout's.
state.useBlockscout = true;
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
+5 -2
View File
@@ -618,6 +618,8 @@ const CONTRACT = [
"hideFraudContracts",
"hideDustTransactions",
"utcTimestamps",
"useBlockscout",
"checkRecipientsOnEtherscan",
"debugMode",
].map((field) => ({
field,
@@ -625,8 +627,9 @@ const CONTRACT = [
// A flag: only ever tested for truthiness, and written back verbatim.
hostile: [42, "notabool", { a: 1 }],
// Both answers to that truthiness test have to be driven, and 0 is a
// value src/ never writes for a flag. For utcTimestamps and debugMode
// the falsy answer is also the DEFAULT_STATE default.
// value src/ never writes for a flag. For utcTimestamps,
// useBlockscout, checkRecipientsOnEtherscan and debugMode the falsy
// answer is also the DEFAULT_STATE default.
falsy: [0],
})),
];
+4 -1
View File
@@ -44,7 +44,8 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
// (https://git.eeqj.de/sneak/AutistMask/issues/559). Both requests are made
// only while the user has the setting for them on.
function restoringOnto(view, viewData) {
return unversionedValidProfile({
currentView: view,
@@ -52,6 +53,8 @@ function restoringOnto(view, viewData) {
selectedAddress: 0,
viewStack: ["main"],
viewData,
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
}
+1 -1
View File
@@ -87,7 +87,7 @@ test("the popup open on AddressDetail shows the new network and its balances", a
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
+1
View File
@@ -88,6 +88,7 @@ test("the balance refresh", async () => {
wallets,
RPC_URL,
"https://explorer.example.invalid/api/v2",
true,
[],
"mainnet",
);
+1
View File
@@ -224,6 +224,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+1
View File
@@ -217,6 +217,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+23 -10
View File
@@ -582,12 +582,14 @@ describe("surface 3: the balance list", () => {
// 1,000-holder floor and was listed as a holding named ETH.
test("a fake ETH token clearing the holder floor is filtered", async () => {
respondWith([fakeEthItem()]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("tracking the fake token manually does not admit it either", async () => {
respondWith([fakeEthItem({ holders_count: "0" })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [
{ address: FAKE_ETH_CONTRACT },
]);
expect(balances).toEqual([]);
@@ -597,12 +599,16 @@ describe("surface 3: the balance list", () => {
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The false-positive direction on the surface that matters most: a real
@@ -617,7 +623,7 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
@@ -631,7 +637,7 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
@@ -642,7 +648,9 @@ describe("surface 3: the balance list", () => {
// before the symbol rule is consulted.
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
respondWith([fakeEthItem({ type: "ERC-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The adjacent finding from the same review as issue #260: the type gate
@@ -660,16 +668,20 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The money test: the user holds real ETH and has been airdropped a fake
@@ -682,6 +694,7 @@ describe("surface 3: the balance list", () => {
[{ addresses: [addr] }],
"https://rpc.example.invalid",
BLOCKSCOUT,
true,
[],
"mainnet",
);
+2
View File
@@ -134,6 +134,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
describe.each([
+5
View File
@@ -158,6 +158,7 @@ beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.useBlockscout = true;
state.networkEndpoints = {};
state.wallets = [
{
@@ -177,6 +178,7 @@ test("a token transfer's decoded details name the token contract and the amount
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.mainnet.chainId,
);
expect(row.to).toBe(RECIPIENT);
@@ -209,6 +211,7 @@ test("with mainnet active, a Sepolia transaction is read from Sepolia's Blocksco
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
@@ -237,6 +240,7 @@ test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia tr
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
@@ -258,6 +262,7 @@ async function showSepoliaTransferWithMainnetActive() {
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
+1
View File
@@ -132,6 +132,7 @@ async function refresh(addresses, trackedTokens) {
[{ addresses }],
RPC_URL,
EXPLORER_URL,
true,
trackedTokens,
"mainnet",
new AbortController().signal,
+1
View File
@@ -287,6 +287,7 @@ describe("Back after a switch from the Settings gear", () => {
currentView: "confirm-tx",
selectedWallet: 0,
selectedAddress: 0,
checkRecipientsOnEtherscan: true,
viewData: {
pendingTx: {
token: "ETH",
+2
View File
@@ -125,6 +125,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
afterEach(() => {
+51 -9
View File
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1");
expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain(
@@ -1283,7 +1283,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
const merged = txs[0];
// The received leg (the swap output) supplies the display amount.
@@ -1320,7 +1325,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000");
@@ -1346,7 +1356,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0");
@@ -1386,7 +1401,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
@@ -1427,7 +1447,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
@@ -1453,7 +1478,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2);
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
2,
);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
});
@@ -1479,6 +1510,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0xaa36a7",
);
expect(sepolia[0].symbol).toBe("SepoliaETH");
@@ -1487,6 +1519,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(mainnet[0].symbol).toBe("ETH");
@@ -1513,7 +1546,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0);
@@ -1555,6 +1593,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBeNull();
@@ -1565,6 +1604,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBeNull();
@@ -1575,6 +1615,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(filterTransactions(txs, filters()).transactions).toEqual(
@@ -1591,6 +1632,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBe(0);
@@ -1608,7 +1650,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
},
}));
await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1"),
).resolves.toEqual([]);
});
+1
View File
@@ -3456,6 +3456,7 @@ async function showDetail(data) {
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
+2
View File
@@ -208,6 +208,7 @@ async function showDetail(row, activeNetworkId) {
globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -287,6 +288,7 @@ async function historyRow(method, sentTo, chainId) {
const rows = await fetchRecentTransactions(
RECIPIENT,
"https://explorer.example/api/v2",
true,
chainId,
);
return rows[0];
+3 -3
View File
@@ -172,7 +172,7 @@ async function fetchOnto(items) {
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
state.wallets = [
{
name: "Wallet 1",
@@ -197,14 +197,14 @@ async function fetchOntoBoth(itemsA, itemsB) {
statusText: "OK",
json: async () => itemsA,
}));
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsB,
}));
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, true, []);
state.wallets = [
{
name: "Wallet 1",