From 31b2aa2d8ac8bf79c8db78bc0f32a4b4e6a2cafa Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 20 Aug 2026 14:00:00 +0000 Subject: [PATCH] fix: make saveState() a read-modify-write merge instead of a full-blob overwrite (closes #304) Every extension page (the toolbar popup, a dApp approval window, the background's backgroundRefresh()) holds its own in-memory `state`, loaded once, and showView() saves on every navigation. saveState() wrote the entire state blob, so any second page that saved overwrote whatever another page had written since -- a whole wallet, name, addresses and encrypted secret included, with no attacker and no unusual input. saveState() now re-reads storage, diffs the persisted fields against a deep-cloned baseline snapshot taken at this page's last loadState()/saveState(), and writes only the fields that differ. Every other field is carried forward from storage in its loaded-and-normalized shape (normalizePersisted(), shared with loadState()), so a legacy or malformed record a load has always self-healed in memory keeps getting written back even on a save that touched something unrelated. showView() fires saveState() without awaiting it, so two saves from the SAME page can be in flight at once; a FIFO queue serializes them. Deliberately not done, a documented deviation from the plan on the issue: the live `state` of a field this page does not own is not rehydrated from what another page wrote, only the persisted record is. Adopting a concurrently-written value into `state` reintroduced the same clobber one page later, under the fire-and-forget saveState() calling convention every view uses -- caught red by tests/txStatus.test.js. Two writers of the same field still resolve last-writer-wins, documented at the merge point. tests/stateMerge.test.js covers both required cases against the real state.js and showView(): a save from a page loaded before a wallet was added elsewhere, and the approval-window reproduction from the issue. Both were confirmed failing against the prior full-blob write before this fix landed. --- TODO.md | 26 +++ src/shared/state.js | 350 +++++++++++++++++++++++++-------------- tests/stateMerge.test.js | 214 ++++++++++++++++++++++++ 3 files changed, 467 insertions(+), 123 deletions(-) create mode 100644 tests/stateMerge.test.js diff --git a/TODO.md b/TODO.md index 15419fd..5398877 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,32 @@ but the review is broader than any of them. # Completed Steps +- 2026-08-20: A second extension page can no longer silently delete a wallet + ([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote + the entire state blob, and every extension page — the toolbar popup, a dApp + approval window, `backgroundRefresh()` — holds its own in-memory `state`, + loaded once, with `showView()` saving on every navigation; a second page that + saved after a first had written something new overwrote it, no attacker or + unusual input required. `saveState()` is now a read-modify-write: it re-reads + storage, diffs the persisted fields against a deep-cloned `baseline` snapshot + taken at the last `loadState()`/`saveState()` on that page, and writes only + the fields that actually changed — everything else is carried forward from + storage in its loaded-and-normalized shape (`normalizePersisted()`, shared + with `loadState()`), so a legacy or malformed record a load has always + self-healed in memory keeps getting written back even on a save that touched + something else entirely. `showView()` fires `saveState()` on every navigation + without awaiting it, so two saves from the same page can be in flight at once; + a FIFO queue serializes them rather than letting a slow one finish after a + later one and re-derive a stale answer. Deliberately not done: the live + `state` of a field this page does not own is not rehydrated from what another + page wrote, only the persisted record is — adopting a concurrently-written + value into `state` reintroduced the same clobber one page later, caught by + `tests/txStatus.test.js` red. Two writers of the same field still resolve + last-writer-wins, documented at the merge point. `tests/stateMerge.test.js` + covers the two-page save and the approval-window reproduction from the issue — + add a wallet in one page, force a save from a second page loaded before it, + both wallets survive — each demonstrated failing against the unfixed full-blob + write. - 2026-08-20: A forgotten password no longer wedges the wallet ([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet was password-gated and importing its recovery phrase again was refused as a diff --git a/src/shared/state.js b/src/shared/state.js index a774e6b..34b9e7d 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -88,135 +88,239 @@ function currentNetwork() { return networkById(state.networkId); } -async function saveState() { - const persisted = { - hasWallet: state.hasWallet, - wallets: state.wallets, - trackedTokens: state.trackedTokens, - networkId: state.networkId, - rpcUrl: state.rpcUrl, - blockscoutUrl: state.blockscoutUrl, - networkEndpoints: state.networkEndpoints, - lastBalanceRefresh: state.lastBalanceRefresh, - activeAddress: state.activeAddress, - allowedSites: state.allowedSites, - deniedSites: state.deniedSites, - rememberSiteChoice: state.rememberSiteChoice, - showZeroBalanceTokens: state.showZeroBalanceTokens, - hideSpoofedSymbols: state.hideSpoofedSymbols, - hideLowHolderTokens: state.hideLowHolderTokens, - hideFraudContracts: state.hideFraudContracts, - hideDustTransactions: state.hideDustTransactions, - dustThresholdGwei: state.dustThresholdGwei, - utcTimestamps: state.utcTimestamps, - fraudContracts: state.fraudContracts, - tokenHolderCache: state.tokenHolderCache, - theme: state.theme, - debugMode: state.debugMode, - currentView: state.currentView, - selectedWallet: state.selectedWallet, - selectedAddress: state.selectedAddress, - selectedToken: state.selectedToken, - viewData: state.viewData, - viewStack: state.viewStack, - }; - await storageSet({ autistmask: persisted }); +// Every field written to and read from the single "autistmask" storage key. +// hasWallet is deliberately excluded from the diffing/merge logic below — +// like loadState() does, it is always derived from `wallets`, never carried +// as an independent value. +const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE) + .filter((key) => key !== "hasWallet") + .concat([ + "currentView", + "selectedWallet", + "selectedAddress", + "selectedToken", + "viewData", + "viewStack", + ]); + +// Turn a raw stored (or missing) record into the full, defaulted shape +// loadState() used to assign directly onto `state`. Pulled out as a pure +// function so saveState() can apply it too: the fields THIS page did not +// change still have to come from storage in their loaded-and-normalized +// form, not as the raw bytes another page (or an old release) left there — +// otherwise a legacy shape a load has always self-healed in memory (a +// missing networkEndpoints map, an out-of-range flag) is dropped right back +// into storage unfixed every time the page that DID normalize it saves +// something unrelated, because that field's value never "changed" for that +// page to notice. +function normalizePersisted(saved) { + saved = saved || {}; + const out = {}; + out.wallets = saved.wallets || []; + // Derived, never trusted verbatim off storage — see loadState(). + out.hasWallet = out.wallets.length > 0; + out.trackedTokens = saved.trackedTokens || []; + out.networkId = saved.networkId || DEFAULT_STATE.networkId; + out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; + out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; + // An actual object is required, not merely a truthy non-array: the code + // below and onChainSwitch() index and ASSIGN INTO this value, and + // assigning a property to a string or a number is a silent no-op in + // sloppy mode. Copied rather than referenced, nested pairs included, so + // normalizing never mutates the object a caller handed in. + const rawEndpoints = + typeof saved.networkEndpoints === "object" && + saved.networkEndpoints !== null && + !Array.isArray(saved.networkEndpoints) + ? saved.networkEndpoints + : {}; + out.networkEndpoints = {}; + for (const netId of Object.keys(rawEndpoints)) { + out.networkEndpoints[netId] = { ...rawEndpoints[netId] }; + } + // A profile written before this map existed carries exactly one pair of + // endpoints, belonging to whatever network it was last on. Adopt it as + // that network's remembered pair, so a custom endpoint set on the old + // build is not lost by the first switch away and back. + if (!out.networkEndpoints[out.networkId]) { + out.networkEndpoints[out.networkId] = { + rpcUrl: out.rpcUrl, + blockscoutUrl: out.blockscoutUrl, + }; + } + out.lastBalanceRefresh = saved.lastBalanceRefresh || 0; + out.activeAddress = saved.activeAddress || null; + out.allowedSites = + saved.allowedSites && !Array.isArray(saved.allowedSites) + ? saved.allowedSites + : {}; + out.deniedSites = + saved.deniedSites && !Array.isArray(saved.deniedSites) + ? saved.deniedSites + : {}; + out.rememberSiteChoice = + saved.rememberSiteChoice !== undefined + ? saved.rememberSiteChoice + : true; + out.showZeroBalanceTokens = + saved.showZeroBalanceTokens !== undefined + ? saved.showZeroBalanceTokens + : true; + // A profile written before this setting existed has no key for it. It + // is a safety filter, so absent must load as on, not as undefined. + out.hideSpoofedSymbols = + saved.hideSpoofedSymbols !== undefined + ? saved.hideSpoofedSymbols + : true; + out.hideLowHolderTokens = + saved.hideLowHolderTokens !== undefined + ? saved.hideLowHolderTokens + : true; + out.hideFraudContracts = + saved.hideFraudContracts !== undefined + ? saved.hideFraudContracts + : true; + out.hideDustTransactions = + saved.hideDustTransactions !== undefined + ? saved.hideDustTransactions + : true; + out.dustThresholdGwei = + saved.dustThresholdGwei !== undefined + ? saved.dustThresholdGwei + : 100000; + out.utcTimestamps = + saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; + out.fraudContracts = saved.fraudContracts || []; + out.tokenHolderCache = saved.tokenHolderCache || {}; + out.theme = saved.theme || "system"; + out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false; + out.currentView = saved.currentView || null; + out.selectedWallet = + saved.selectedWallet !== undefined ? saved.selectedWallet : null; + out.selectedAddress = + saved.selectedAddress !== undefined ? saved.selectedAddress : null; + out.selectedToken = saved.selectedToken || null; + out.viewData = saved.viewData || {}; + out.viewStack = restorableStack(saved.viewStack, out.currentView); + return out; +} + +// The persisted fields as they stood at the end of this page's last +// loadState() or saveState(). saveState() diffs the live state against this +// to find only the fields THIS page actually changed. +// +// Deep-cloned, not a reference: callers mutate persisted objects and arrays +// in place (state.wallets.push(...)), and a reference baseline would mutate +// right along with `state`, so the diff would always come out empty. +let baseline = null; + +function snapshotPersisted() { + const out = {}; + for (const key of PERSISTED_FIELDS) out[key] = state[key]; + return out; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (typeof a !== "object" || typeof b !== "object") return false; + if (a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!deepEqual(a[key], b[key])) return false; + } + return true; +} + +// Read-modify-write, merged per field, rather than one full-blob write. +// +// Every extension page (the toolbar popup, a dApp approval window, the +// background's backgroundRefresh()) holds its own in-memory `state`, loaded +// once, and showView() saves on every navigation. A full-blob write here +// clobbers whatever a second page had written since — including, in the +// worst case, an entire wallet and its encrypted secret with no attacker +// and no unusual input (see the issue this fixes). +// +// Only the fields this page actually changed — those that differ from +// `baseline`, captured at the last loadState()/saveState() on this page — +// are written; every other field is carried forward from whatever is in +// storage right now, which may already be a value another page wrote. +// +// This does not make two pages that both change the SAME field concurrently +// safe: last write wins on that one field, same as before. What it removes +// is the cross-field clobber — a page that only navigated overwriting a +// wallet list it never touched. +// +// This page's own live `state` is deliberately NOT rehydrated from a field +// another page changed — only the record written to storage is merged. +// showView() fires saveState() on every navigation without awaiting it, +// which is what makes the queue above necessary in the first place, and a +// save that is slow to come back has no way to tell whether the field it +// is about to hand back is still the current answer or has since been +// overtaken by something this very page did in the meantime; writing it +// into `state` regardless reintroduced exactly the clobber this function +// exists to remove, just delayed and confined to one page instead of two +// (caught by tests/txStatus.test.js). A page's live picture of a field it +// does not own goes on being whatever its last loadState() saw, same as +// before this fix; only the persisted record is guaranteed current. +async function saveStateOnce() { + const current = snapshotPersisted(); + const result = await storageGet("autistmask"); + // Normalized, not raw: a field this page did not change still has to + // come from storage in its loaded (self-healed) shape. See + // normalizePersisted() above. + const fresh = normalizePersisted(result.autistmask); + + const merged = { ...fresh }; + for (const key of PERSISTED_FIELDS) { + if (baseline === null || !deepEqual(current[key], baseline[key])) { + merged[key] = current[key]; + } + } + merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0); + + await storageSet({ autistmask: merged }); + + // Derived from this page's own wallets, never adopted off the wire — + // see loadState(). Everything else this page did not change is left + // exactly as it stood; see the note above. + state.hasWallet = state.wallets.length > 0; + + baseline = structuredClone(snapshotPersisted()); +} + +// showView() calls saveState() on every navigation without awaiting it, so +// two saves from the SAME page can be in flight at once — e.g. a screen +// shown, then immediately replaced before the first save's storageGet() +// round trip has come back. Left concurrent, the first save's turn would +// finish after the second's live-state mutation and then re-hydrate `state` +// from what IT read, stomping the second, later change back to a stale +// value — the same clobber this function exists to prevent, just between +// two saves on one page instead of two pages. Queuing makes every save's +// snapshot-diff-write-rehydrate run start to finish before the next one +// begins, so each one only ever sees the true live state at its turn. +let saveQueue = Promise.resolve(); + +function saveState() { + const turn = saveQueue.then(saveStateOnce); + // The queue must advance even when a save rejects, or every save after + // it queues behind a promise that never settles. + saveQueue = turn.catch(() => {}); + return turn; } async function loadState() { const result = await storageGet("autistmask"); if (result.autistmask) { - const saved = result.autistmask; - state.wallets = saved.wallets || []; - // Derived, never read from storage: a profile persisted with the flag - // out of step with the wallet list would otherwise stay broken on - // every load. Nothing depends on the two disagreeing. - state.hasWallet = state.wallets.length > 0; - state.trackedTokens = saved.trackedTokens || []; - state.networkId = saved.networkId || DEFAULT_STATE.networkId; - state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl; - state.blockscoutUrl = - saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl; - // An actual object is required, not merely a truthy non-array: the - // code below and onChainSwitch() index and ASSIGN INTO this value, - // and assigning a property to a string or a number is a silent no-op - // in sloppy mode. A stored primitive would therefore be re-persisted - // unchanged forever, and every switch would fall back to the network - // default — the endpoint loss this map exists to prevent, with no - // self-healing. The allowedSites/deniedSites guards below are only - // read from, which is why they can be looser. - state.networkEndpoints = - typeof saved.networkEndpoints === "object" && - saved.networkEndpoints !== null && - !Array.isArray(saved.networkEndpoints) - ? saved.networkEndpoints - : {}; - // A profile written before this map existed carries exactly one pair - // of endpoints, belonging to whatever network it was last on. Adopt - // it as that network's remembered pair, so a custom endpoint set on - // the old build is not lost by the first switch away and back. - if (!state.networkEndpoints[state.networkId]) { - state.networkEndpoints[state.networkId] = { - rpcUrl: state.rpcUrl, - blockscoutUrl: state.blockscoutUrl, - }; - } - state.lastBalanceRefresh = saved.lastBalanceRefresh || 0; - state.activeAddress = saved.activeAddress || null; - state.allowedSites = - saved.allowedSites && !Array.isArray(saved.allowedSites) - ? saved.allowedSites - : {}; - state.deniedSites = - saved.deniedSites && !Array.isArray(saved.deniedSites) - ? saved.deniedSites - : {}; - state.rememberSiteChoice = - saved.rememberSiteChoice !== undefined - ? saved.rememberSiteChoice - : true; - state.showZeroBalanceTokens = - saved.showZeroBalanceTokens !== undefined - ? saved.showZeroBalanceTokens - : true; - // A profile written before this setting existed has no key for it. - // It is a safety filter, so absent must load as on, not as undefined. - state.hideSpoofedSymbols = - saved.hideSpoofedSymbols !== undefined - ? saved.hideSpoofedSymbols - : true; - state.hideLowHolderTokens = - saved.hideLowHolderTokens !== undefined - ? saved.hideLowHolderTokens - : true; - state.hideFraudContracts = - saved.hideFraudContracts !== undefined - ? saved.hideFraudContracts - : true; - state.hideDustTransactions = - saved.hideDustTransactions !== undefined - ? saved.hideDustTransactions - : true; - state.dustThresholdGwei = - saved.dustThresholdGwei !== undefined - ? saved.dustThresholdGwei - : 100000; - state.utcTimestamps = - saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; - state.fraudContracts = saved.fraudContracts || []; - state.tokenHolderCache = saved.tokenHolderCache || {}; - state.theme = saved.theme || "system"; - state.debugMode = - saved.debugMode !== undefined ? saved.debugMode : false; - state.currentView = saved.currentView || null; - state.selectedWallet = - saved.selectedWallet !== undefined ? saved.selectedWallet : null; - state.selectedAddress = - saved.selectedAddress !== undefined ? saved.selectedAddress : null; - state.selectedToken = saved.selectedToken || null; - state.viewData = saved.viewData || {}; - state.viewStack = restorableStack(saved.viewStack, state.currentView); + Object.assign(state, normalizePersisted(result.autistmask)); } + // The point of comparison every saveState() on this page diffs against, + // whether storage had a profile or was empty. See PERSISTED_FIELDS above + // saveState() for why a reference here would be wrong. + baseline = structuredClone(snapshotPersisted()); } function currentAddress() { diff --git a/tests/stateMerge.test.js b/tests/stateMerge.test.js new file mode 100644 index 0000000..066833d --- /dev/null +++ b/tests/stateMerge.test.js @@ -0,0 +1,214 @@ +// saveState() used to write the entire state blob every time +// (src/shared/state.js). Every extension page — the toolbar popup, a dApp +// approval window opened by the background, backgroundRefresh() in +// src/background/index.js — holds its own in-memory `state`, loaded once, +// and src/popup/views/helpers.js showView() saves on EVERY navigation. So +// any second page that saved after a first page had written something new +// overwrote it, with no attacker and no unusual input: a whole wallet, name, +// addresses and encrypted secret included, silently gone +// (https://git.eeqj.de/sneak/AutistMask/issues/304). +// +// Both cases below drive the real state.js module through two independent +// module registries sharing one storage backend, the way two real extension +// pages share one chrome.storage.local. The storage stub structured-clones +// on both get and set — a stub that hands back the object it was given +// aliases the caller's own mutation and would make this entire defect class +// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324). + +function makeStorage() { + let store = {}; + return { + get: async (keys) => { + const wanted = + keys === undefined || keys === null + ? Object.keys(store) + : [].concat(keys); + const out = {}; + for (const key of wanted) { + if (key in store) out[key] = structuredClone(store[key]); + } + return out; + }, + set: async (items) => { + for (const [key, value] of Object.entries(items)) { + store[key] = structuredClone(value); + } + }, + }; +} + +// One extension page: a fresh module registry over the shared storage. +// state.js resolves the storage API at require time, so the stub has to be +// installed before the module is loaded, and `state` is a module-level +// singleton, so each page needs its own registry to hold its own copy. +function loadPage(storage) { + jest.resetModules(); + globalThis.chrome = { storage: { local: storage } }; + return { + state: require("../src/shared/state"), + helpers: require("../src/popup/views/helpers"), + }; +} + +function wallet(name, secret, address) { + return { + type: "hd", + name, + xpub: "xpub-" + name, + encryptedSecret: secret, + nextIndex: 1, + addresses: [{ address, balance: "0", tokenBalances: [] }], + }; +} + +const W1 = wallet( + "Wallet 1", + "secret-one", + "0x66133E8ea0f5D1d612D2502a968757D1048c214a", +); +const W2 = wallet( + "Wallet 2", + "secret-two", + "0xdAC17F958D2ee523a2206206994597C13D831ec7", +); + +// Minimal DOM: showView() toggles view elements, clears the flash line and +// creates/removes the debug banner. Nothing here is asserted; it only has to +// answer without throwing, the way the popup's own index.html would. +function makeElement(id) { + const classes = new Set(); + return { + id, + textContent: "", + style: {}, + classList: { + add: (...n) => n.forEach((c) => classes.add(c)), + remove: (...n) => n.forEach((c) => classes.delete(c)), + toggle: (c, force) => { + const on = force === undefined ? !classes.has(c) : force; + if (on) classes.add(c); + else classes.delete(c); + return on; + }, + }, + remove: () => {}, + }; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +afterEach(() => { + delete globalThis.chrome; + delete globalThis.document; +}); + +describe("a save from a page that never saw a wallet another page added", () => { + // The first DoD case on the issue: add a wallet in one page, then force + // a save from a second page loaded before that wallet existed. Both + // wallets must survive. + test("both wallets are in storage afterwards", async () => { + const storage = makeStorage(); + await storage.set({ autistmask: { wallets: [W1] } }); + + // Loaded while storage held only Wallet 1, and never reloads — + // the approval window in the reproduction, or a second popup that + // has been open for a while. + const stale = loadPage(storage); + await stale.state.loadState(); + expect(stale.state.state.wallets).toHaveLength(1); + + // A second page, loaded after, adds a wallet — the exact sequence + // src/popup/views/addWallet.js uses. + const fresh = loadPage(storage); + await fresh.state.loadState(); + fresh.state.state.wallets.push(W2); + fresh.state.state.hasWallet = true; + await fresh.state.saveState(); + + expect( + (await storage.get("autistmask")).autistmask.wallets, + ).toHaveLength(2); + + // The stale page saves something that has nothing to do with + // wallets — exactly what showView() does on every navigation, and + // what backgroundRefresh() does after a balance poll. + stale.state.state.currentView = "settings"; + await stale.state.saveState(); + + const persisted = (await storage.get("autistmask")).autistmask; + expect(persisted.wallets.map((w) => w.name)).toEqual([ + "Wallet 1", + "Wallet 2", + ]); + expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([ + "secret-one", + "secret-two", + ]); + }); +}); + +describe("the approval-window reproduction", () => { + // approval window open, add a wallet in the popup, confirm the approval + // — the exact sequence from the issue. The approval window and the + // popup are the same popup code with a different starting view, so + // showView() is the real save path in both: src/popup/views/approval.js + // showTxApproval() calls showView("approve-tx") when the window opens, + // and a successful confirm calls + // src/popup/views/txStatus.js showWait() -> startWait(), which calls + // showView("wait-tx") — the save that clobbered the second wallet in + // the reproduction on the issue. + test("the wallet added in the popup survives confirming the approval", async () => { + globalThis.document = makeDocument(); + + const storage = makeStorage(); + await storage.set({ autistmask: { wallets: [W1] } }); + + // The background opens the approval window on the approve-tx + // screen; nothing else has happened yet. + const approvalWindow = loadPage(storage); + await approvalWindow.state.loadState(); + approvalWindow.helpers.showView("approve-tx"); + // showView() does not await its own saveState(); an extra save + // joins the same queue and only resolves once that one has too, + // which is the black-box way to know it landed. + await approvalWindow.state.saveState(); + + // The user adds a wallet in the popup — a separate page, loaded + // after the approval window. + const popup = loadPage(storage); + await popup.state.loadState(); + popup.state.state.wallets.push(W2); + popup.state.state.hasWallet = true; + await popup.state.saveState(); + expect( + (await storage.get("autistmask")).autistmask.wallets, + ).toHaveLength(2); + + // The user confirms the approval. The approval window navigates + // approve-tx -> wait-tx, saving again from state it loaded before + // Wallet 2 ever existed. + approvalWindow.helpers.showView("wait-tx"); + await approvalWindow.state.saveState(); + + const persisted = (await storage.get("autistmask")).autistmask; + expect(persisted.wallets.map((w) => w.name)).toEqual([ + "Wallet 1", + "Wallet 2", + ]); + expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([ + "secret-one", + "secret-two", + ]); + }); +});