diff --git a/src/shared/chainSwitch.js b/src/shared/chainSwitch.js index f709670..e58337e 100644 --- a/src/shared/chainSwitch.js +++ b/src/shared/chainSwitch.js @@ -53,18 +53,31 @@ function clearPopupCaches() { // change. So the switch also resets these fields in the stored record: // // - every address's balance and token balances; -// - the time of the last balance refresh. +// - the time of the last balance refresh; +// - the fraud contracts. // -// A balance that the background or another page stored since this page's last -// save is then not kept under the new network -// (https://git.eeqj.de/sneak/AutistMask/issues/653). +// A balance or a fraud contract that the background or another page stored +// since this page's last save is then not kept under the new network +// (https://git.eeqj.de/sneak/AutistMask/issues/653, +// https://git.eeqj.de/sneak/AutistMask/issues/692). +// +// Before the switch, `state` takes the tracked tokens that another page stored +// since this page's last save (takeStoredTrackedTokens() in state.js). The +// switch then puts them away under the previous network, the network they were +// added on. When the stored record is on another network, a switch saved +// elsewhere has not reached this page yet. The stored record's tokens are then +// that network's, and none are taken. async function onChainSwitch(newNetworkId) { - const { state, saveState } = require("./state"); + const { state, saveState, takeStoredTrackedTokens } = require("./state"); let net = null; await saveState((stored) => { if (state.networkId === newNetworkId) return false; + if (stored.networkId === state.networkId) { + takeStoredTrackedTokens(stored); + } net = applyChainSwitchFields(state, newNetworkId); stored.lastBalanceRefresh = 0; + stored.fraudContracts = []; for (const wallet of stored.wallets) { for (const addr of wallet.addresses) { addr.balance = "0"; diff --git a/src/shared/state.js b/src/shared/state.js index 2dbbe3a..36ba606 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -126,10 +126,10 @@ function currentNetwork() { } // The persisted fields as this page held them when its last loadState() -// finished, or when its last successful saveState() began, with any network -// switch saved elsewhere that this page has taken since (takeStoredNetwork()). -// saveState() diffs the live state against this to find only the fields THIS -// page changed since. +// finished, or when its last successful saveState() began, with what this page +// has taken from the stored record since (takeStoredNetwork() and +// takeStoredTrackedTokens()). saveState() diffs the live state against this to +// find only the fields THIS page changed since. // // Deep-cloned, not a reference: callers mutate persisted objects and arrays // in place (state.wallets.push(...)), and a reference baseline would mutate @@ -615,6 +615,28 @@ function takeStoredNetwork(stored) { copyChainSwitchFields(baseline, structuredClone(stored)); } +// Give this page the tracked tokens of `stored`, a record on this page's +// network that a save of this page has just read (onChainSwitch() in +// src/shared/chainSwitch.js calls this from `beforeWrite`): +// +// - A token that another page tracked or removed since this page's last save +// is tracked or removed in `state` too. +// - A token that this page tracked or removed since then stays as this page +// has it. +// +// `baseline` takes the stored list, so the save stores the list in `state` +// whenever the two differ. +function takeStoredTrackedTokens(stored) { + rawState.trackedTokens = mergeListByIdentity( + baseline.trackedTokens, + rawState.trackedTokens, + stored.trackedTokens, + (token) => token.address.toLowerCase(), + (base, ours) => ours, + ); + baseline.trackedTokens = structuredClone(stored.trackedTokens); +} + // Rejects with StateUnusableError for a stored record this build cannot make // sense of. Nothing is assigned and `loaded` stays false in that case, so a // caller that ignores the rejection gets StateNotLoadedError on the first @@ -661,6 +683,7 @@ module.exports = { saveState, onSaveFailure, takeStoredNetwork, + takeStoredTrackedTokens, loadState, currentAddress, currentNetwork, diff --git a/tests/trackedTokensPerNetwork.test.js b/tests/trackedTokensPerNetwork.test.js index d766a11..8aa258b 100644 --- a/tests/trackedTokensPerNetwork.test.js +++ b/tests/trackedTokensPerNetwork.test.js @@ -5,6 +5,9 @@ // 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, @@ -178,9 +181,8 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only", }); // Another popup page tracks DAI on Ethereum Mainnet after this page's last -// save. The switch's save writes only the token lists that this page changed. -// So DAI stays in `trackedTokens`, the active network's list. -test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => { +// 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", { @@ -193,10 +195,51 @@ test("a token another page tracked before a switch in Settings stays in trackedT const stored = boot.storage.read("autistmask"); expect(stored.networkId).toBe("sepolia"); - expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]); + 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 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.