The stored profile carried no version, so nothing could tell a record this build wrote from one a later build did, and loadState() coerced scalars while trusting the structure. A wallets that was a string, an array of nulls, or a later schema's wallet records reached the popup and threw on the first dereference: no view, no message, no control, and every dApp call answering a generic -32603 because getActiveAddress() dereferenced the same record. There was no reset or wipe control anywhere in the product, so the only escape was clearing extension storage through browser internals.
saveState() and updateState() now both stamp STATE_SCHEMA_VERSION, and every read goes through assertStateUsable() on the raw bytes before normalization can paper over them. Version 1 is the shape that shipped unversioned, so the profile every existing install holds loads normally and is migrated in place by being stamped on the first write; an upgrade shows nobody a wipe prompt for a wallet that is fine. A record this build cannot vouch for is refused instead, and refused all the way: not normalized, not written back, not half-loaded, and not overwritten by a save either.
The gate covers what nothing downstream can floor. Everything else is normalizePersisted()'s job, and three separate gaps there let a gate-accepted record reach a dereference and blank the popup anyway. trackedTokens and activeAddress were floored on truthiness rather than on type: trackedTokens: "nope" rendered nothing with "Cannot read properties of undefined (reading 'toLowerCase')", activeAddress: 42 rendered nothing with "address.slice is not a function". A container check is not enough either, because [1, 2] IS a list and the dereference is t.address.toLowerCase() one level below the Array.isArray(): [1,2], [null], [{}], [{address:42}] and ["0xAA..."] each still rendered nothing. And each address's tokenBalances had no floor at all — which matters most, since refreshBalances() writes that field wholesale and the partial write the issue names as the live cause of a corrupt record lands exactly there — so "x", 42, [null] and [42] rendered nothing too. All of them are type-checked now, container AND entries: an entry that is not a record with a text address is dropped, the well-formed entries beside it survive, and an empty list is still a legitimate value. activeAddress's empty string now floors to null rather than surviving, because init() auto-selects the first address only on a strict null, so a kept "" would leave the popup with no address ever selected; that restores what the || null this check replaced already did.
The header of stateSchema.js claimed every non-gated field's floor was a type check. It is not, and now it says so field by field: rpcUrl, blockscoutUrl, lastBalanceRefresh, fraudContracts, tokenHolderCache, theme, currentView, selectedToken and viewData are saved.x || default; every boolean flag, dustThresholdGwei, selectedWallet and selectedAddress are taken verbatim when present; allowedSites and deniedSites are checked as containers only, never per entry. The header and the README now list which field is in which category rather than asserting a rule the module does not follow.
The popup shows a new StateRecovery screen. It names the problem in a sentence, exports the stored record into a text box on the page with no normalization or repair on it (and downloads it where the browser allows), and offers an erase behind a typed ERASE MY WALLET. Both controls are required: an export with no reset leaves the user stuck, and a reset with no export destroys the only copy of possibly recoverable key material. The Settings gear is hidden while it is up, and showView() is not used to raise it, because both read the state singleton that by then refuses to be read. The export is JSON.stringify of the deserialized record, so what JSON cannot carry is stated where the export is written: a cycle or a BigInt throws and fails the export entirely, and a Date, a Map, a Set, an undefined property or a NaN is mangled silently instead, which is the worse residual because the box then looks complete.
The background refuses the same record and answers dApps -32007 with a message saying the saved data cannot be read and that nothing was signed or sent, rather than the -32603 it also answers when a signing attempt breaks. EIP-1474 sets aside -32000..-32099 for implementation-defined server errors but assigns meanings to -32000 through -32006, including -32001 "Resource not found" and the -32002 "Resource unavailable" this wallet already uses for a pending approval; -32007..-32099 are the unassigned ones, and a test pins the code against that table.
networkById() now throws on an id it does not know instead of quietly answering mainnet, which also stops NETWORKS["constructor"] resolving off the prototype chain. Every key test in the gate is an own-property test, because networkId is an object key into networkEndpoints and an unvalidated "__proto__" set that map's prototype instead of an own key, dropping the user's endpoint silently; normalizePersisted() copies endpoint entries with defineProperty for the same reason. That own-property discipline is the gate's alone — normalizePersisted() reads the same fields plainly, and the two agree only because a record from storage has been through structuredClone and carries Object.prototype.
The three corrupt blobs from the issue drive the real popup entry point and the real worker in tests; each rendered nothing at all and answered -32603 before this, and the unversioned-but-valid case is tested too. Every corrupt-field shape that has ever been observed to blank the popup is a row in tests/stateRecovery.test.js, measured through the same entry point; none has been removed. Three test files used fixture wallets the product cannot produce (a bare address string where an address record belongs, a wallet with no address list) and now use whole records. src/popup/restorableViews.js moved to src/shared/restorableViews.js, since persistedState.js requires it and that module is in the background bundle.
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
|
|
// Address RECORDS, not bare address strings. A stored profile is validated
|
|
// against the schema on every read now (src/shared/stateSchema.js), and a bare
|
|
// string where an address record belongs is one of the shapes that refuses to
|
|
// load — as it should, since every screen dereferences addr.address.
|
|
function oneWallet() {
|
|
return [
|
|
{
|
|
name: "Wallet 1",
|
|
type: "hd",
|
|
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
|
},
|
|
];
|
|
}
|
|
|
|
const { makeStorageStub } = require("./support/storageStub");
|
|
|
|
// state.js resolves the storage API at require time, so the stub has to exist
|
|
// before the module is loaded, and the module registry has to be reset between
|
|
// cases because `state` is a module-level singleton.
|
|
//
|
|
// The stub clones in both directions, as the real chrome.storage.local does —
|
|
// see tests/support/storageStub.js for why an aliasing one made this file
|
|
// assert less than it appears to.
|
|
function loadModuleWith(persisted) {
|
|
jest.resetModules();
|
|
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
|
global.chrome = { storage };
|
|
return {
|
|
mod: require("../src/shared/state"),
|
|
set: storage.set,
|
|
stored: () => storage.read("autistmask"),
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete global.chrome;
|
|
});
|
|
|
|
describe("loadState hasWallet reconciliation", () => {
|
|
// A profile that deleted its last wallet on a build predating the write
|
|
// path fix keeps hasWallet: true forever. It must load as no wallet, which
|
|
// is what sends the popup to the welcome view.
|
|
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
|
|
const { mod } = loadModuleWith({ hasWallet: true });
|
|
await mod.loadState();
|
|
expect(mod.state.wallets).toEqual([]);
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("absent hasWallet with wallets present loads as having a wallet", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(true);
|
|
});
|
|
|
|
test("consistent stored states are preserved", async () => {
|
|
const withWallet = loadModuleWith({
|
|
hasWallet: true,
|
|
wallets: oneWallet(),
|
|
});
|
|
await withWallet.mod.loadState();
|
|
expect(withWallet.mod.state.hasWallet).toBe(true);
|
|
|
|
const without = loadModuleWith({ hasWallet: false, wallets: [] });
|
|
await without.mod.loadState();
|
|
expect(without.mod.state.hasWallet).toBe(false);
|
|
});
|
|
|
|
test("empty storage leaves the default no-wallet state", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hasWallet).toBe(false);
|
|
expect(mod.state.wallets).toEqual([]);
|
|
});
|
|
|
|
// The correction is derived on every load rather than written back, so a
|
|
// load never has a storage side effect.
|
|
test("loadState does not write to storage", async () => {
|
|
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
|
|
await mod.loadState();
|
|
expect(set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Deriving must not disturb the rest of the load.
|
|
test("other persisted fields still load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
hasWallet: false,
|
|
wallets: oneWallet(),
|
|
networkId: "sepolia",
|
|
theme: "dark",
|
|
activeAddress: ADDRESS,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.networkId).toBe("sepolia");
|
|
expect(mod.state.theme).toBe("dark");
|
|
expect(mod.state.activeAddress).toBe(ADDRESS);
|
|
});
|
|
});
|
|
|
|
// The known-symbol spoof filter is a safety filter, so an existing profile
|
|
// stored before the setting existed must load with it on rather than with
|
|
// undefined, which would read as off.
|
|
describe("hideSpoofedSymbols persistence", () => {
|
|
test("defaults to on with empty storage", async () => {
|
|
const { mod } = loadModuleWith(null);
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("a profile stored without the key loads with it on", async () => {
|
|
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
|
|
test("an explicit false survives the load", async () => {
|
|
const { mod } = loadModuleWith({
|
|
wallets: oneWallet(),
|
|
hideSpoofedSymbols: false,
|
|
});
|
|
await mod.loadState();
|
|
expect(mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("saveState persists the flag", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.hideSpoofedSymbols = false;
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
|
|
});
|
|
});
|
|
|
|
test("the flag round-trips off through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = false;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
|
|
});
|
|
|
|
test("the flag round-trips back on through save and load", async () => {
|
|
const first = loadModuleWith(null);
|
|
first.mod.state.hideSpoofedSymbols = true;
|
|
await first.mod.saveState();
|
|
const persisted = first.set.mock.calls[0][0].autistmask;
|
|
|
|
const second = loadModuleWith(persisted);
|
|
await second.mod.loadState();
|
|
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
|
});
|
|
});
|
|
|
|
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
|
// behind it was restored verbatim, so Back could still walk onto a screen
|
|
// whose content is deliberately never re-rendered — and "show-phrase" has no
|
|
// Back control of its own to leave by. The stack is filtered on load, at the
|
|
// first entry the popup would not render, and everything above it goes too:
|
|
// those entries were reached THROUGH the dropped one.
|
|
describe("restored viewStack is filtered against RESTORABLE_VIEWS", () => {
|
|
const NON_RESTORABLE = ["export-privkey", "show-phrase"];
|
|
|
|
function restoredStack(viewStack, currentView = "settings") {
|
|
return loadModuleWith({
|
|
wallets: oneWallet(),
|
|
currentView,
|
|
viewStack,
|
|
});
|
|
}
|
|
|
|
test("a non-restorable view at the top of the stack is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "address", "export-privkey"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main", "address"]);
|
|
});
|
|
|
|
test("a non-restorable view in the middle truncates the stack there", async () => {
|
|
const { mod } = restoredStack(["main", "show-phrase", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Truncating a stack rooted at a non-restorable view leaves nothing, and
|
|
// the restored view still needs somewhere for Back to go.
|
|
test("a non-restorable view at the bottom leaves main to go back to", async () => {
|
|
const { mod } = restoredStack(["export-privkey", "address", "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("no restored stack retains a secret-bearing view", async () => {
|
|
for (const view of NON_RESTORABLE) {
|
|
const { mod } = restoredStack(["main", "address", view, "receive"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).not.toContain(view);
|
|
}
|
|
});
|
|
|
|
// The rule is "views the popup will render", not a blocklist of the two
|
|
// secret screens: a name no longer in the set (or never a view at all)
|
|
// has to go the same way.
|
|
test("a name that is not a restorable view at all is dropped", async () => {
|
|
const { mod } = restoredStack(["main", "welcome", "address"]);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
// Restorable entries are kept verbatim. That they are then unhidden
|
|
// without being re-rendered is a separate defect, tracked in #268; this
|
|
// filter is only about views the popup declined to restore.
|
|
test("an ordinary restorable stack is restored unchanged", async () => {
|
|
const stack = ["main", "address", "address-token"];
|
|
const { mod } = restoredStack(stack);
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(stack);
|
|
});
|
|
|
|
test("restoring onto main keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["show-phrase"], "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// main is not the only view that gets no ["main"] beneath it: restoreView()
|
|
// will not reopen onto a non-restorable view either, so nothing is left for
|
|
// Back to sit under and the stack stays empty.
|
|
test("restoring onto a view the popup will not reopen keeps the stack empty", async () => {
|
|
const { mod } = restoredStack(["export-privkey"], "show-phrase");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Not an array means nothing survives, but the never-empty rule still
|
|
// applies: a corrupt stack must not leave a restored view with no Back
|
|
// target of its own.
|
|
test("a stack that is not an array still gets main beneath a restored view", async () => {
|
|
const { mod } = restoredStack("main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual(["main"]);
|
|
});
|
|
|
|
test("a stack that is not an array loads as empty under main", async () => {
|
|
const { mod } = restoredStack({ 0: "main" }, "main");
|
|
await mod.loadState();
|
|
expect(mod.state.viewStack).toEqual([]);
|
|
});
|
|
|
|
// Filtering belongs on load, not on save: the live in-session stack is
|
|
// legitimate — the user really is one Back away from a screen that is
|
|
// rendered right now — and only a load-side filter also cleans the
|
|
// stacks already sitting in storage.
|
|
test("saveState persists the live stack verbatim", async () => {
|
|
const { mod, set } = loadModuleWith(null);
|
|
mod.state.viewStack = ["main", "address", "export-privkey"];
|
|
await mod.saveState();
|
|
expect(set).toHaveBeenCalledWith({
|
|
autistmask: expect.objectContaining({
|
|
viewStack: ["main", "address", "export-privkey"],
|
|
}),
|
|
});
|
|
});
|
|
});
|