A new address, a new wallet and a network switch each leave tracked tokens with no balance entry, so their lines read 0.0000 before any refresh had read them. Each of these now sets `balanceNotFetched` on the tracked tokens, and a refresh already clears it. Their lines read `quantity unknown` until then. Addresses that the wallet scan finds get the same. A refresh that did not read every address the page now holds leaves the field set. Model: opus-5-5
1069 lines
40 KiB
JavaScript
1069 lines
40 KiB
JavaScript
// Tracked tokens are kept per network, and the two add-token screens offer the
|
|
// bundled token list, which holds Ethereum Mainnet contracts, on Ethereum
|
|
// Mainnet only (https://git.eeqj.de/sneak/AutistMask/issues/523), including on
|
|
// the screen "Back" lands on after a switch made from the Settings gear and for
|
|
// a contract lookup that answers after one. Driven through the real popup over
|
|
// a stored profile; the same switch made by the background is in
|
|
// tests/coldWorkerChainSwitch.test.js.
|
|
//
|
|
// The tests of what another page stored before a switch in Settings cover a
|
|
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
|
|
|
|
const {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
} = require("./support/popupBoot");
|
|
const { NETWORKS } = require("../src/shared/networks");
|
|
|
|
const SEPOLIA_TOKEN = {
|
|
address: TOKEN_ADDRESS,
|
|
symbol: "TST",
|
|
decimals: 18,
|
|
name: "Test Token",
|
|
};
|
|
const MAINNET_TOKEN = {
|
|
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
|
|
symbol: "DAI",
|
|
decimals: 18,
|
|
name: "Dai Stablecoin",
|
|
};
|
|
// Tracked on neither network.
|
|
const NEW_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
|
const NETWORK_CHANGED_FLASH =
|
|
"The active network changed before the lookup finished, so the token was" +
|
|
" not added.";
|
|
|
|
afterEach(() => {
|
|
cleanupPopup();
|
|
});
|
|
|
|
// The popup reopened onto SettingsAddToken, on `networkId`.
|
|
function bootOnAddToken(networkId, extra) {
|
|
return bootPopup(
|
|
unversionedValidProfile({
|
|
networkId,
|
|
currentView: "settings-addtoken",
|
|
...extra,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// The popup reopened onto AddressDetail on Ethereum Mainnet, with DAI tracked
|
|
// there and TST tracked on Sepolia.
|
|
function bootOnAddress() {
|
|
return bootPopup(
|
|
unversionedValidProfile({
|
|
currentView: "address",
|
|
selectedWallet: 0,
|
|
selectedAddress: 0,
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// The popup reopened onto AddressToken for `token`, with AddressDetail beneath
|
|
// it, over the same tracked tokens as bootOnAddress().
|
|
function bootOnToken(token) {
|
|
return bootPopup(
|
|
unversionedValidProfile({
|
|
currentView: "address-token",
|
|
viewStack: ["main", "address"],
|
|
selectedWallet: 0,
|
|
selectedAddress: 0,
|
|
selectedToken: token,
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// AddressToken for `token` opened from AddressDetail, as tapping the token's
|
|
// row there does.
|
|
async function openToken(boot, token) {
|
|
const { state } = require("../src/shared/state");
|
|
state.selectedToken = token;
|
|
require("../src/popup/views/helpers").pushCurrentView();
|
|
require("../src/popup/views/addressToken").show();
|
|
await boot.settle();
|
|
}
|
|
|
|
// Presses `addButton` with a contract lookup that answers only after the
|
|
// Settings gear and a switch to `networkId`.
|
|
async function addAcrossSwitch(boot, addButton, networkId) {
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
let answer;
|
|
lookupTokenInfo.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
answer = resolve;
|
|
}),
|
|
);
|
|
const adding = boot.click(addButton);
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, networkId);
|
|
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
|
await adding;
|
|
}
|
|
|
|
// Picks `networkId` in the Settings network selector, as the user does.
|
|
async function chooseNetwork(boot, networkId) {
|
|
const select = boot.node("settings-network");
|
|
select.value = networkId;
|
|
for (const fn of select.listeners.change) await fn();
|
|
await boot.settle();
|
|
}
|
|
|
|
// Adds NEW_TOKEN_ADDRESS by contract address on SettingsAddToken, as the user
|
|
// does. Returns the record the add tracks.
|
|
async function addNewToken(boot) {
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
const info = { symbol: "NEW", decimals: 18, name: "New Token" };
|
|
lookupTokenInfo.mockResolvedValueOnce(info);
|
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
|
await boot.click("btn-settings-addtoken-manual");
|
|
return { address: NEW_TOKEN_ADDRESS, ...info, balanceNotFetched: true };
|
|
}
|
|
|
|
// The Settings gear, a switch to Sepolia, and Back.
|
|
async function gearSwitchBack(boot) {
|
|
await boot.click("btn-settings");
|
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
|
await chooseNetwork(boot, "sepolia");
|
|
await boot.click("btn-settings-back");
|
|
}
|
|
|
|
describe.each([
|
|
["mainnet", false],
|
|
["sepolia", true],
|
|
])("on %s", (networkId, hidden) => {
|
|
test(`the common tokens are hidden: ${hidden}`, async () => {
|
|
const boot = await bootOnAddToken(networkId);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
|
expect(boot.hidden("settings-addtoken-common")).toBe(hidden);
|
|
|
|
require("../src/popup/views/addToken").show();
|
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
|
expect(boot.hidden("add-token-common")).toBe(hidden);
|
|
});
|
|
});
|
|
|
|
test("a token added by contract address on Sepolia is tracked on Sepolia only", async () => {
|
|
const boot = await bootOnAddToken("sepolia", {
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
|
});
|
|
const stored = () => boot.storage.read("autistmask");
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
lookupTokenInfo.mockResolvedValueOnce({
|
|
symbol: SEPOLIA_TOKEN.symbol,
|
|
decimals: SEPOLIA_TOKEN.decimals,
|
|
name: SEPOLIA_TOKEN.name,
|
|
});
|
|
|
|
boot.node("settings-addtoken-address").value = TOKEN_ADDRESS;
|
|
await boot.click("btn-settings-addtoken-manual");
|
|
|
|
expect(lookupTokenInfo).toHaveBeenCalledWith(
|
|
TOKEN_ADDRESS,
|
|
expect.any(String),
|
|
"sepolia",
|
|
expect.anything(),
|
|
);
|
|
// No refresh has read the new token's balance yet.
|
|
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
|
|
expect(stored().trackedTokens).toEqual([added]);
|
|
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "mainnet");
|
|
expect(stored().networkId).toBe("mainnet");
|
|
// No refresh has read a balance on this network since the switch.
|
|
expect(stored().trackedTokens).toEqual([
|
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
|
]);
|
|
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
|
expect(onMainnet).toContain("DAI");
|
|
expect(onMainnet).not.toContain("TST");
|
|
|
|
await chooseNetwork(boot, "sepolia");
|
|
expect(stored().networkId).toBe("sepolia");
|
|
expect(stored().trackedTokens).toEqual([added]);
|
|
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
|
|
expect(onSepolia).toContain("TST");
|
|
expect(onSepolia).not.toContain("DAI");
|
|
});
|
|
|
|
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
|
|
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
|
|
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
|
|
const boot = await bootPopup(unversionedValidProfile());
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
});
|
|
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
|
});
|
|
|
|
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
|
|
// last save. This page still holds DAI. The switch to Sepolia does not store
|
|
// DAI under Ethereum Mainnet again.
|
|
test("a token another page removed before a switch in Settings stays removed", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [],
|
|
});
|
|
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
|
});
|
|
|
|
// Another popup page removes DAI after this page's last save. That page then
|
|
// adds DAI again, typing the contract address in lower case. The two pages'
|
|
// entries for DAI match, because contract addresses are compared ignoring
|
|
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
|
|
// Mainnet.
|
|
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [
|
|
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
|
|
],
|
|
});
|
|
|
|
await boot.click("btn-settings");
|
|
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 saves a switch to Sepolia after this page's last save.
|
|
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
|
|
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
|
|
test("a token tracked on Sepolia when another page switched there is not 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: [SEPOLIA_TOKEN],
|
|
});
|
|
|
|
await chooseNetwork(boot, "sepolia");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([
|
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
|
]);
|
|
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 adds the token to Ethereum Mainnet's list and leaves Sepolia's list as
|
|
// it was.
|
|
test("a token added before this page takes another page's switch is tracked on Ethereum Mainnet", 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,
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
balanceNotFetched: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
|
|
// Sepolia. This page does not track DAI. Before taking that switch, this page
|
|
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
|
|
// token on Ethereum Mainnet's list.
|
|
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkId: "sepolia",
|
|
trackedTokens: [],
|
|
networkTrackedTokens: { mainnet: [MAINNET_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([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
|
MAINNET_TOKEN,
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
balanceNotFetched: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
|
|
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
|
|
// on a tracked token in Settings does. The removal's save drops DAI from
|
|
// Ethereum Mainnet's list and leaves Sepolia's list as it was.
|
|
test("a token removed before this page takes another page's switch is gone from Ethereum Mainnet", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({
|
|
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 { state, saveState } = require("../src/shared/state");
|
|
state.trackedTokens.splice(0, 1);
|
|
await saveState();
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
|
});
|
|
|
|
// 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]);
|
|
});
|
|
|
|
// This page switches to Sepolia. Another popup page has not taken that switch,
|
|
// and its save adds a token to Ethereum Mainnet's list. This page switches back
|
|
// and tracks that token.
|
|
test("a token another page added on Ethereum Mainnet is tracked when this page switches back there", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
const newToken = {
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
};
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN, newToken] },
|
|
});
|
|
|
|
await chooseNetwork(boot, "mainnet");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("mainnet");
|
|
expect(stored.trackedTokens).toEqual([
|
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
|
{ ...newToken, balanceNotFetched: true },
|
|
]);
|
|
});
|
|
|
|
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
|
|
// popup page has not taken that switch, and its save removes DAI from Ethereum
|
|
// Mainnet's list. This page switches back and does not track DAI again.
|
|
test("a token another page removed on Ethereum Mainnet stays removed when this page switches back there", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkTrackedTokens: { mainnet: [] },
|
|
});
|
|
|
|
await chooseNetwork(boot, "mainnet");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("mainnet");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
});
|
|
|
|
// This page switches to Sepolia, where TST is tracked. Another popup page has
|
|
// not taken that switch, and its save tracks DAI on Ethereum Mainnet. This page
|
|
// then adds a token on Sepolia. Its save changes only `trackedTokens`, so DAI
|
|
// stays on Ethereum Mainnet's list.
|
|
test("a token another page added on Ethereum Mainnet stays when this page adds a token on Sepolia", async () => {
|
|
const boot = await bootOnAddToken("mainnet", {
|
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
|
});
|
|
expect(boot.pageErrors).toEqual([]);
|
|
await gearSwitchBack(boot);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkTrackedTokens: {
|
|
mainnet: [MAINNET_TOKEN],
|
|
sepolia: [SEPOLIA_TOKEN],
|
|
},
|
|
});
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
|
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
|
expect(stored.trackedTokens).toEqual([
|
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
|
added,
|
|
]);
|
|
});
|
|
|
|
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
|
|
// tracked. Another popup page has not taken that switch, and its save removes
|
|
// DAI from Ethereum Mainnet's list. This page then adds a token on Sepolia. Its
|
|
// save changes only `trackedTokens`, so DAI does not come back.
|
|
test("a token another page removed on Ethereum Mainnet stays removed when this page adds a token on Sepolia", async () => {
|
|
const boot = await bootOnAddToken("mainnet", {
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
|
});
|
|
expect(boot.pageErrors).toEqual([]);
|
|
await gearSwitchBack(boot);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkTrackedTokens: { mainnet: [], sepolia: [SEPOLIA_TOKEN] },
|
|
});
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
|
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
|
expect(stored.trackedTokens).toEqual([
|
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
|
added,
|
|
]);
|
|
});
|
|
|
|
// 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
|
|
// switch drops this page's own.
|
|
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
|
|
const boot = await bootPopup(unversionedValidProfile());
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
fraudContracts: [NEW_TOKEN_ADDRESS],
|
|
});
|
|
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.fraudContracts).toEqual([]);
|
|
});
|
|
|
|
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
|
|
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
|
|
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
|
|
test("a token added while a switch in Settings waits for the lock stays tracked on Ethereum Mainnet", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
let answer;
|
|
lookupTokenInfo.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
answer = resolve;
|
|
}),
|
|
);
|
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
|
const adding = boot.click("btn-settings-addtoken-manual");
|
|
await boot.click("btn-settings");
|
|
|
|
// Another write holds the lock, so the switch's save waits for it.
|
|
let releaseLock;
|
|
const lockHeld = navigator.locks.request(
|
|
"autistmask",
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseLock = resolve;
|
|
}),
|
|
);
|
|
const select = boot.node("settings-network");
|
|
select.value = "sepolia";
|
|
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
|
await boot.settle();
|
|
|
|
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
|
await boot.settle();
|
|
releaseLock();
|
|
await lockHeld;
|
|
await switching;
|
|
await adding;
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
balanceNotFetched: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
|
|
// this page's switch to Sepolia waits for the lock. Another popup page holds
|
|
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
|
|
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
|
|
// list.
|
|
test("a token added while a switch in Settings waits for another page's switch stays tracked on Ethereum Mainnet", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
let answer;
|
|
lookupTokenInfo.mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
answer = resolve;
|
|
}),
|
|
);
|
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
|
const adding = boot.click("btn-settings-addtoken-manual");
|
|
await boot.click("btn-settings");
|
|
|
|
let releaseLock;
|
|
const lockHeld = navigator.locks.request(
|
|
"autistmask",
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseLock = resolve;
|
|
}),
|
|
);
|
|
const select = boot.node("settings-network");
|
|
select.value = "sepolia";
|
|
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
|
await boot.settle();
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
networkId: "sepolia",
|
|
trackedTokens: [],
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
|
});
|
|
|
|
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
|
|
await boot.settle();
|
|
releaseLock();
|
|
await lockHeld;
|
|
await switching;
|
|
await adding;
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
|
MAINNET_TOKEN,
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
balanceNotFetched: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
|
|
// tracked on Ethereum Mainnet. This page waits for the lock to take that
|
|
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
|
|
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
|
|
// stay on Ethereum Mainnet's list.
|
|
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
let releaseLock;
|
|
const lockHeld = navigator.locks.request(
|
|
"autistmask",
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseLock = resolve;
|
|
}),
|
|
);
|
|
await boot.storage.local.set({
|
|
autistmask: {
|
|
...boot.storage.read("autistmask"),
|
|
networkId: "sepolia",
|
|
trackedTokens: [],
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
|
},
|
|
});
|
|
await boot.settle();
|
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
|
lookupTokenInfo.mockResolvedValueOnce({
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
});
|
|
|
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
|
const adding = boot.click("btn-settings-addtoken-manual");
|
|
await boot.settle();
|
|
releaseLock();
|
|
await lockHeld;
|
|
await adding;
|
|
await boot.settle();
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([
|
|
MAINNET_TOKEN,
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
balanceNotFetched: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// This page tracks DAI on Ethereum Mainnet. Another popup page holds the lock
|
|
// and stores its switch to Sepolia, with DAI on Ethereum Mainnet's list. This
|
|
// page waits for the lock to take that switch. Meanwhile, the user removes DAI,
|
|
// as the `[x]` on a tracked token in Settings does. When this page takes that
|
|
// switch, DAI is gone from Ethereum Mainnet's list.
|
|
test("a token removed while this page waits to take another page's switch is gone from Ethereum Mainnet", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
let releaseLock;
|
|
const lockHeld = navigator.locks.request(
|
|
"autistmask",
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseLock = resolve;
|
|
}),
|
|
);
|
|
await boot.storage.local.set({
|
|
autistmask: {
|
|
...boot.storage.read("autistmask"),
|
|
networkId: "sepolia",
|
|
trackedTokens: [],
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
|
},
|
|
});
|
|
await boot.settle();
|
|
|
|
const { state, saveState } = require("../src/shared/state");
|
|
state.trackedTokens.splice(0, 1);
|
|
const removing = saveState();
|
|
releaseLock();
|
|
await lockHeld;
|
|
await removing;
|
|
await boot.settle();
|
|
|
|
const stored = boot.storage.read("autistmask");
|
|
expect(stored.networkId).toBe("sepolia");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
|
});
|
|
|
|
// Another popup page tracks DAI after this page's last save. This page then
|
|
// adds a token. Its save keeps DAI.
|
|
test("a token another page added stays when this page adds a token", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
});
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
|
MAINNET_TOKEN,
|
|
added,
|
|
]);
|
|
});
|
|
|
|
// Another popup page tracks a new token after this page's last save. This page
|
|
// then removes DAI, as the `[x]` on a tracked token in Settings does. Its save
|
|
// keeps the new token.
|
|
test("a token another page added stays when this page removes a token", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
|
);
|
|
expect(boot.pageErrors).toEqual([]);
|
|
const otherPageToken = {
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
};
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [MAINNET_TOKEN, otherPageToken],
|
|
});
|
|
|
|
const { state, saveState } = require("../src/shared/state");
|
|
state.trackedTokens.splice(0, 1);
|
|
await saveState();
|
|
|
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
|
otherPageToken,
|
|
]);
|
|
});
|
|
|
|
// Another popup page stops tracking DAI after this page's last save. This page
|
|
// still holds DAI and adds a token. Its save does not store DAI again.
|
|
test("a token another page removed stays removed when this page adds a token", async () => {
|
|
const boot = await bootOnAddToken("mainnet", {
|
|
trackedTokens: [MAINNET_TOKEN],
|
|
});
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [],
|
|
});
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([added]);
|
|
});
|
|
|
|
// Another popup page tracks DAI and the new token after this page's last save,
|
|
// and its refresh has read the new token's balance. This page adds the new token
|
|
// too, so the two pages hold different records for it. The save stores one
|
|
// record for the new token, this page's, and keeps DAI.
|
|
test("a token both pages added is stored once, with this page's record", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.pageErrors).toEqual([]);
|
|
boot.storage.write("autistmask", {
|
|
...boot.storage.read("autistmask"),
|
|
trackedTokens: [
|
|
MAINNET_TOKEN,
|
|
{
|
|
address: NEW_TOKEN_ADDRESS,
|
|
symbol: "NEW",
|
|
decimals: 18,
|
|
name: "New Token",
|
|
},
|
|
],
|
|
});
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
|
MAINNET_TOKEN,
|
|
added,
|
|
]);
|
|
});
|
|
|
|
// This page and the stored list each hold DAI twice. This page then adds a
|
|
// token. Its save stores DAI once.
|
|
test("a token held twice is stored once when this page adds a token", async () => {
|
|
const boot = await bootOnAddToken("mainnet", {
|
|
trackedTokens: [MAINNET_TOKEN, MAINNET_TOKEN],
|
|
});
|
|
expect(boot.pageErrors).toEqual([]);
|
|
|
|
const added = await addNewToken(boot);
|
|
|
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
|
MAINNET_TOKEN,
|
|
added,
|
|
]);
|
|
});
|
|
|
|
describe("Back after a switch from the Settings gear", () => {
|
|
test("SettingsAddToken no longer offers the common tokens", async () => {
|
|
const boot = await bootOnAddToken("mainnet");
|
|
expect(boot.hidden("settings-addtoken-common")).toBe(false);
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
|
expect(boot.hidden("settings-addtoken-common")).toBe(true);
|
|
});
|
|
|
|
test("AddressDetail lists the new network's tracked tokens", async () => {
|
|
const boot = await bootOnAddress();
|
|
expect(boot.node("address-balances").innerHTML).toContain("DAI");
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["address"]);
|
|
const balances = boot.node("address-balances").innerHTML;
|
|
expect(balances).toContain("TST");
|
|
expect(balances).not.toContain("DAI");
|
|
});
|
|
|
|
// Neither can be rendered from saved state, so neither is landed on.
|
|
test.each([
|
|
["btn-add-token", "add-token"],
|
|
["btn-send", "send"],
|
|
])("from over %s, it lands on AddressDetail", async (button, view) => {
|
|
const boot = await bootOnAddress();
|
|
await boot.click(button);
|
|
expect(boot.visibleViews()).toEqual([view]);
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["address"]);
|
|
expect(boot.node("address-balances").innerHTML).not.toContain("DAI");
|
|
});
|
|
|
|
// DAI was picked on Ethereum Mainnet, where alone it is tracked.
|
|
test.each([
|
|
[
|
|
"opened from AddressDetail",
|
|
async () => {
|
|
const boot = await bootOnAddress();
|
|
await openToken(boot, MAINNET_TOKEN.address);
|
|
return boot;
|
|
},
|
|
],
|
|
["in a reopened popup", () => bootOnToken(MAINNET_TOKEN.address)],
|
|
])(
|
|
"from AddressToken for DAI %s, it lands on AddressDetail",
|
|
async (_how, open) => {
|
|
const boot = await open();
|
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
|
expect(boot.text("address-token-title")).toContain("DAI");
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["address"]);
|
|
const balances = boot.node("address-balances").innerHTML;
|
|
expect(balances).toContain("TST");
|
|
expect(balances).not.toContain("DAI");
|
|
},
|
|
);
|
|
|
|
// The stack is saved cut with the switch, so a popup closed straight after
|
|
// it and reopened onto Settings lands where this one would. Loading a
|
|
// saved stack cuts it at AddToken and Send as well, but not at
|
|
// AddressToken.
|
|
test.each([
|
|
[
|
|
"AddressToken for DAI",
|
|
(boot) => openToken(boot, MAINNET_TOKEN.address),
|
|
],
|
|
["AddToken", (boot) => boot.click("btn-add-token")],
|
|
["Send", (boot) => boot.click("btn-send")],
|
|
])(
|
|
"from %s, a popup reopened after the switch lands on AddressDetail",
|
|
async (_view, open) => {
|
|
const boot = await bootOnAddress();
|
|
await open(boot);
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
|
|
const reopened = await bootPopup(boot.storage.read("autistmask"));
|
|
expect(reopened.visibleViews()).toEqual(["settings"]);
|
|
await reopened.click("btn-settings-back");
|
|
expect(reopened.visibleViews()).toEqual(["address"]);
|
|
const balances = reopened.node("address-balances").innerHTML;
|
|
expect(balances).toContain("TST");
|
|
expect(balances).not.toContain("DAI");
|
|
},
|
|
);
|
|
|
|
test("AddressToken for the native token is drawn again for the new network", async () => {
|
|
const boot = await bootOnToken("ETH");
|
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["address-token"]);
|
|
expect(boot.text("address-token-title")).toContain("SepoliaETH");
|
|
});
|
|
|
|
// A popup reopened onto ConfirmTx has no Send beneath it, so "Back" draws
|
|
// ConfirmTx again from the saved transaction. It is drawn for the network
|
|
// the transaction was reviewed on, and Sign & Send refuses it on the new
|
|
// one.
|
|
test("ConfirmTx reviewed on Sepolia is not sent on Ethereum Mainnet", async () => {
|
|
const boot = await bootPopup(
|
|
unversionedValidProfile({
|
|
networkId: "sepolia",
|
|
currentView: "confirm-tx",
|
|
selectedWallet: 0,
|
|
selectedAddress: 0,
|
|
checkRecipientsOnEtherscan: true,
|
|
viewData: {
|
|
pendingTx: {
|
|
token: "ETH",
|
|
from: ADDRESS,
|
|
to: ADDRESS,
|
|
amount: "1",
|
|
chainId: NETWORKS.sepolia.chainId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
|
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "mainnet");
|
|
globalThis.fetch.mockClear();
|
|
await boot.click("btn-settings-back");
|
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
|
// Drawn again: its Etherscan label check asks Sepolia's explorer.
|
|
expect(globalThis.fetch.mock.calls.map((call) => call[0])).toEqual([
|
|
NETWORKS.sepolia.explorerUrl + "/address/" + ADDRESS,
|
|
]);
|
|
expect(boot.text("confirm-type")).toBe("Native SepoliaETH transfer");
|
|
|
|
boot.node("confirm-tx-password").value = "any password";
|
|
await boot.click("btn-confirm-send");
|
|
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
|
|
expect(
|
|
boot.node("confirm-network-changed-error").style.visibility,
|
|
).toBe("visible");
|
|
// Refused before the password is even tried.
|
|
expect(boot.text("confirm-tx-password-error")).toBe("");
|
|
});
|
|
|
|
test("without a wallet, it lands on Welcome", async () => {
|
|
const boot = await bootPopup(undefined);
|
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
|
|
|
await gearSwitchBack(boot);
|
|
expect(boot.visibleViews()).toEqual(["welcome"]);
|
|
});
|
|
|
|
test("a later Back onto Settings keeps what was typed there", async () => {
|
|
const boot = await bootOnAddress();
|
|
await boot.click("btn-settings");
|
|
await chooseNetwork(boot, "sepolia");
|
|
boot.node("settings-rpc").value = "https://typed.example";
|
|
|
|
await boot.click("btn-settings-add-token");
|
|
expect(boot.visibleViews()).toEqual(["settings-addtoken"]);
|
|
await boot.click("btn-settings-addtoken-back");
|
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
|
expect(boot.value("settings-rpc")).toBe("https://typed.example");
|
|
});
|
|
});
|
|
|
|
describe("a contract lookup that answers after a switch adds nothing", () => {
|
|
test("from SettingsAddToken", async () => {
|
|
const boot = await bootOnAddToken("sepolia", {
|
|
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
|
|
});
|
|
const stored = () => boot.storage.read("autistmask");
|
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
|
|
|
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
|
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
|
expect(stored().trackedTokens).toEqual([
|
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
|
]);
|
|
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
|
});
|
|
|
|
test("from AddToken", async () => {
|
|
const boot = await bootOnAddress();
|
|
const stored = () => boot.storage.read("autistmask");
|
|
await boot.click("btn-add-token");
|
|
boot.node("add-token-address").value = NEW_TOKEN_ADDRESS;
|
|
|
|
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
|
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
|
expect(stored().trackedTokens).toEqual([
|
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
|
]);
|
|
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
|
});
|
|
});
|