Home, AddressDetail and AddressToken remember which network is active before they ask Blockscout for the transaction history. Each screen drops the answer if another network is active on the page or in storage when the answer lands. AddressDetail and AddressToken also drop an answer that lands once the screen shows another address. AddressToken also drops an answer that lands once the screen shows another token. A dropped answer draws no rows and saves no fraud contracts. A failed request goes through the same checks. A dropped failure shows no message. Holding the lock, the save adds new fraud contracts only if the page and the stored record still name the request's network. storedNetworkIs() reads the stored network, in src/shared/storedSettings.js. Model: opus-5-5
494 lines
16 KiB
JavaScript
494 lines
16 KiB
JavaScript
// Home, AddressDetail and AddressToken drop a transaction history answer that
|
|
// lands after a network switch. A dropped answer draws no rows and saves no
|
|
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
|
|
//
|
|
// Most tests hold the history request open, switch the network or what the
|
|
// screen shows, and then let the answer land. The views run against a minimal
|
|
// DOM stub, as in tests/transactionListCancelled.test.js, and over
|
|
// tests/support/storageStub.js.
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
|
|
|
|
jest.mock("../src/shared/transactions", () => ({
|
|
...jest.requireActual("../src/shared/transactions"),
|
|
fetchRecentTransactions: jest.fn(async () => []),
|
|
}));
|
|
jest.mock("../src/shared/ens", () => ({
|
|
resolveEnsNames: jest.fn(async () => new Map()),
|
|
}));
|
|
|
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
|
const { resolveEnsNames } = require("../src/shared/ens");
|
|
const { state, loadState } = require("../src/shared/state");
|
|
const { onChainSwitch } = require("../src/shared/chainSwitch");
|
|
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
|
|
const { storedNetworkIs } = require("../src/shared/storedSettings");
|
|
const home = require("../src/popup/views/home");
|
|
const addressDetail = require("../src/popup/views/addressDetail");
|
|
const addressToken = require("../src/popup/views/addressToken");
|
|
|
|
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
|
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
|
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
|
|
// adds this contract to the fraud contracts.
|
|
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
|
|
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
|
|
|
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
|
|
// fraud contracts.
|
|
function profile() {
|
|
return {
|
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
hasWallet: true,
|
|
wallets: [
|
|
{
|
|
type: "hd",
|
|
name: "Main",
|
|
xpub: "xpub-main",
|
|
encryptedSecret: "encrypted-secret",
|
|
nextIndex: 2,
|
|
addresses: [
|
|
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
|
|
{
|
|
address: OTHER_ADDRESS,
|
|
balance: "0.0",
|
|
tokenBalances: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
activeAddress: ADDRESS,
|
|
networkId: "mainnet",
|
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
|
useBlockscout: true,
|
|
fraudContracts: [],
|
|
};
|
|
}
|
|
|
|
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
|
|
//
|
|
// - a transfer of ETH from ADDRESS;
|
|
// - a transfer of the fake USDT from SPOOF_CONTRACT.
|
|
function history() {
|
|
return [
|
|
{
|
|
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
|
|
blockNumber: 2,
|
|
from: ADDRESS,
|
|
to: RECIPIENT,
|
|
value: "1.0000",
|
|
exactValue: "1.0",
|
|
rawAmount: "1000000000000000000",
|
|
rawUnit: "wei",
|
|
symbol: "ETH",
|
|
timestamp: 1790000000,
|
|
isError: false,
|
|
directionLabel: "Sent",
|
|
direction: "sent",
|
|
contractAddress: null,
|
|
},
|
|
{
|
|
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
|
|
blockNumber: 1,
|
|
from: SPOOF_CONTRACT,
|
|
to: ADDRESS,
|
|
value: "100.0000",
|
|
exactValue: "100.0",
|
|
rawAmount: "100000000",
|
|
rawUnit: "base units",
|
|
symbol: "USDT",
|
|
timestamp: 1790000000,
|
|
isError: false,
|
|
directionLabel: "Received",
|
|
direction: "received",
|
|
contractAddress: SPOOF_CONTRACT,
|
|
},
|
|
];
|
|
}
|
|
|
|
// The next history request waits until the returned function is called, and
|
|
// then answers with `answer`.
|
|
function holdHistory(answer = history()) {
|
|
let land;
|
|
fetchRecentTransactions.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
land = () => resolve(answer);
|
|
}),
|
|
);
|
|
return () => land();
|
|
}
|
|
|
|
// The next history request waits until the returned function is called, and
|
|
// then fails.
|
|
function holdFailure() {
|
|
let fail;
|
|
fetchRecentTransactions.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve, reject) => {
|
|
fail = () => reject(new Error("Blockscout is down"));
|
|
}),
|
|
);
|
|
return () => fail();
|
|
}
|
|
|
|
// Another write holds the lock until the returned function is called. Every
|
|
// save waits until then.
|
|
function holdLock() {
|
|
let release;
|
|
const held = navigator.locks.request(
|
|
"autistmask",
|
|
() =>
|
|
new Promise((resolve) => {
|
|
release = resolve;
|
|
}),
|
|
);
|
|
return async () => {
|
|
release();
|
|
await held;
|
|
};
|
|
}
|
|
|
|
function makeElement(id) {
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
value: "",
|
|
innerHTML: "",
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
add: () => {},
|
|
remove: () => {},
|
|
contains: () => false,
|
|
toggle: () => false,
|
|
},
|
|
addEventListener: () => {},
|
|
querySelectorAll: () => [],
|
|
appendChild: () => {},
|
|
};
|
|
// Views reach for .parentElement to hide whole sections.
|
|
Object.defineProperty(el, "parentElement", {
|
|
get: () => node(id + "-parent"),
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function makeDocument() {
|
|
const els = new Map();
|
|
return {
|
|
getElementById(id) {
|
|
// The debug banner is created on demand by helpers.js; absent
|
|
// is the state a non-debug, non-testnet popup is in.
|
|
if (id === "debug-banner") return null;
|
|
if (!els.has(id)) els.set(id, makeElement(id));
|
|
return els.get(id);
|
|
},
|
|
createElement: () => makeElement("created"),
|
|
addEventListener: () => {},
|
|
body: { prepend: () => {} },
|
|
};
|
|
}
|
|
|
|
function node(id) {
|
|
return globalThis.document.getElementById(id);
|
|
}
|
|
|
|
// Lets every promise that is not held open settle.
|
|
function settle() {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
let storage;
|
|
|
|
beforeEach(async () => {
|
|
storage = makeStorageStub({ autistmask: profile() });
|
|
globalThis.chrome = {
|
|
storage: { local: storage.local, onChanged: storage.onChanged },
|
|
};
|
|
globalThis.document = makeDocument();
|
|
globalThis.window = { location: { search: "" } };
|
|
await loadState();
|
|
state.viewStack = [];
|
|
state.selectedWallet = 0;
|
|
state.selectedAddress = 0;
|
|
state.selectedToken = "ETH";
|
|
const ctx = { pageClosed: new AbortController().signal };
|
|
addressDetail.init(ctx);
|
|
addressToken.init(ctx);
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete globalThis.chrome;
|
|
delete globalThis.document;
|
|
delete globalThis.window;
|
|
});
|
|
|
|
const HOME = {
|
|
name: "Home",
|
|
listId: "home-tx-list",
|
|
open: () => home.render({ pageClosed: new AbortController().signal }),
|
|
};
|
|
const ADDRESS_DETAIL = {
|
|
name: "AddressDetail",
|
|
listId: "tx-list",
|
|
open: () => addressDetail.show(),
|
|
};
|
|
const ADDRESS_TOKEN = {
|
|
name: "AddressToken",
|
|
listId: "address-token-tx-list",
|
|
open: () => addressToken.show(),
|
|
};
|
|
|
|
// Three ways a test switches the network:
|
|
//
|
|
// - made in Settings: the page and storage have both switched;
|
|
// - saved elsewhere: storage has switched and the page has not;
|
|
// - not yet stored: the page has switched and storage has not.
|
|
const SWITCHES = [
|
|
{
|
|
how: "made in Settings",
|
|
to: (networkId) => onChainSwitch(networkId),
|
|
},
|
|
{
|
|
// Another page or the background stored the switch, and this page has
|
|
// not followed it yet.
|
|
how: "saved elsewhere",
|
|
to: async (networkId) => {
|
|
const record = storage.read("autistmask");
|
|
applyChainSwitchFields(record, networkId);
|
|
storage.write("autistmask", record);
|
|
},
|
|
},
|
|
{
|
|
// The switch's own save has changed the page and has not yet written
|
|
// storage.
|
|
how: "not yet stored",
|
|
to: async (networkId) => {
|
|
applyChainSwitchFields(state, networkId);
|
|
},
|
|
},
|
|
];
|
|
|
|
// The list still shows `before`, and neither the page nor storage holds a
|
|
// fraud contract.
|
|
function expectNothingAdded(screen, before) {
|
|
expect(node(screen.listId).innerHTML).toBe(before);
|
|
expect(state.fraudContracts).toEqual([]);
|
|
expect(storage.read("autistmask").fraudContracts).toEqual([]);
|
|
}
|
|
|
|
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
|
|
test("an answer that lands on the same network is drawn and saved", async () => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
land();
|
|
await settle();
|
|
|
|
expect(node(screen.listId).innerHTML).toContain("tx-row");
|
|
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
|
|
expect(storage.read("autistmask").fraudContracts).toEqual([
|
|
SPOOF_CONTRACT,
|
|
]);
|
|
});
|
|
|
|
test.each(SWITCHES)(
|
|
"an answer that lands after a switch $how adds nothing",
|
|
async ({ to }) => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
await to("sepolia");
|
|
land();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
},
|
|
);
|
|
|
|
// The answer is the transfer of ETH alone, which adds no fraud contract.
|
|
// Nothing is saved, so only the check before the save can drop the answer.
|
|
test.each(SWITCHES)(
|
|
"an answer without a fraud contract that lands after a switch $how adds nothing",
|
|
async ({ to }) => {
|
|
const land = holdHistory(history().slice(0, 1));
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
await to("sepolia");
|
|
land();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
},
|
|
);
|
|
|
|
test.each(SWITCHES)(
|
|
"a request that fails after a switch $how shows no message",
|
|
async ({ to }) => {
|
|
const fail = holdFailure();
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
await to("sepolia");
|
|
fail();
|
|
await settle();
|
|
|
|
expect(node(screen.listId).innerHTML).toBe(before);
|
|
},
|
|
);
|
|
|
|
// The user picks Sepolia in Settings while another write holds the lock.
|
|
// The answer lands, and its save waits behind the switch.
|
|
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
const before = node(screen.listId).innerHTML;
|
|
const releaseLock = holdLock();
|
|
const switching = onChainSwitch("sepolia");
|
|
land();
|
|
await settle();
|
|
await releaseLock();
|
|
await switching;
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
});
|
|
|
|
// The answer lands and its save waits for the lock. Another page or the
|
|
// background then stores a switch.
|
|
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
const before = node(screen.listId).innerHTML;
|
|
const releaseLock = holdLock();
|
|
land();
|
|
await settle();
|
|
const record = storage.read("autistmask");
|
|
applyChainSwitchFields(record, "sepolia");
|
|
storage.write("autistmask", record);
|
|
await releaseLock();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
});
|
|
|
|
// The answer lands and its save waits for the lock. The page then switches,
|
|
// and storage has not.
|
|
test("an answer whose save waits while the page switches adds nothing", async () => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
const before = node(screen.listId).innerHTML;
|
|
const releaseLock = holdLock();
|
|
land();
|
|
await settle();
|
|
applyChainSwitchFields(state, "sepolia");
|
|
await releaseLock();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
});
|
|
|
|
test("a request that fails after a switch leaves the new network's rows", async () => {
|
|
const fail = holdFailure();
|
|
screen.open();
|
|
await onChainSwitch("sepolia");
|
|
fetchRecentTransactions.mockImplementationOnce(async () => history());
|
|
screen.open();
|
|
await settle();
|
|
const drawn = node(screen.listId).innerHTML;
|
|
fail();
|
|
await settle();
|
|
|
|
expect(drawn).toContain("tx-row");
|
|
expect(node(screen.listId).innerHTML).toBe(drawn);
|
|
});
|
|
});
|
|
|
|
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
|
|
test("an answer that lands once the screen shows another address adds nothing", async () => {
|
|
const land = holdHistory();
|
|
screen.open();
|
|
state.selectedAddress = 1;
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
land();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
});
|
|
|
|
test("a request that fails once the screen shows another address shows no message", async () => {
|
|
const fail = holdFailure();
|
|
screen.open();
|
|
state.selectedAddress = 1;
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
fail();
|
|
await settle();
|
|
|
|
expect(node(screen.listId).innerHTML).toBe(before);
|
|
});
|
|
|
|
test("an answer whose name lookup lands after a switch adds nothing", async () => {
|
|
let landNames;
|
|
resolveEnsNames.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
landNames = () => resolve(new Map());
|
|
}),
|
|
);
|
|
fetchRecentTransactions.mockImplementationOnce(async () => history());
|
|
screen.open();
|
|
await settle();
|
|
const before = node(screen.listId).innerHTML;
|
|
await onChainSwitch("sepolia");
|
|
landNames();
|
|
await settle();
|
|
|
|
expectNothingAdded(screen, before);
|
|
});
|
|
});
|
|
|
|
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
|
|
const land = holdHistory();
|
|
addressToken.show();
|
|
state.selectedToken = TOKEN;
|
|
addressToken.show();
|
|
await settle();
|
|
const before = node(ADDRESS_TOKEN.listId).innerHTML;
|
|
land();
|
|
await settle();
|
|
|
|
expectNothingAdded(ADDRESS_TOKEN, before);
|
|
});
|
|
|
|
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
|
|
const fail = holdFailure();
|
|
addressToken.show();
|
|
state.selectedToken = TOKEN;
|
|
addressToken.show();
|
|
await settle();
|
|
const before = node(ADDRESS_TOKEN.listId).innerHTML;
|
|
fail();
|
|
await settle();
|
|
|
|
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
|
|
});
|
|
|
|
test("storedNetworkIs(): a failed read counts as no", async () => {
|
|
globalThis.chrome.storage.local = {
|
|
get: async () => {
|
|
throw new Error("storage is down");
|
|
},
|
|
};
|
|
|
|
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
|
|
});
|