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. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve. README and the user guide say what is contacted by default and what each setting adds. Model: opus-5-5
471 lines
17 KiB
JavaScript
471 lines
17 KiB
JavaScript
// Blockscout and Etherscan are sent none of the user's data 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 one exception is the
|
|
// endpoint check Settings makes when the user presses "Save" beside the
|
|
// Blockscout URL. It asks only for `/stats` and carries no user data; it is
|
|
// made whether or not the setting is on.
|
|
//
|
|
// 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);
|
|
});
|
|
});
|