From b39110544d7644db8e148e6c7c883300cbdf9ee7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 05:04:57 +0000 Subject: [PATCH] fix: a chain switch gives trackedTokens its own copy of the network's list (closes #719) After a switch, `trackedTokens` and that network's list in `networkTrackedTokens` were one array. So a page's next add or removal also changed `networkTrackedTokens`, and the save wrote that page's older copy over the stored one. Another page's add on the previous network was lost, and its removal was undone. `applyChainSwitchFields()` now copies the incoming network's list. An add or removal on the active network changes only `trackedTokens`. Model: opus-5-5 --- src/shared/chainSwitchFields.js | 10 ++++-- tests/trackedTokensPerNetwork.test.js | 50 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/shared/chainSwitchFields.js b/src/shared/chainSwitchFields.js index cea6ef2..356f039 100644 --- a/src/shared/chainSwitchFields.js +++ b/src/shared/chainSwitchFields.js @@ -42,11 +42,15 @@ function applyChainSwitchFields(s, newNetworkId) { // Tracked tokens are kept per network the same way, because a contract // address names a token on one network only. s.trackedTokens stays the // active network's list, so nothing that reads it changes: the outgoing - // list is put away under its network, and the incoming network's list, or - // an empty one, takes its place. + // list is put away under its network, and a copy of the incoming network's + // list, or an empty list, takes its place. + // + // It is a copy because a save writes a changed s.networkTrackedTokens whole, + // over any list another page stored. An add or removal on the active network + // must change only s.trackedTokens. if (!s.networkTrackedTokens) s.networkTrackedTokens = {}; s.networkTrackedTokens[s.networkId] = s.trackedTokens; - s.trackedTokens = s.networkTrackedTokens[net.id] || []; + s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []); s.networkId = net.id; s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl; diff --git a/tests/trackedTokensPerNetwork.test.js b/tests/trackedTokensPerNetwork.test.js index e77089c..77c6877 100644 --- a/tests/trackedTokensPerNetwork.test.js +++ b/tests/trackedTokensPerNetwork.test.js @@ -466,6 +466,56 @@ test("a token another page removed on Ethereum Mainnet stays removed when this p 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, 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, 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. -- 2.54.0