Compare commits

..

1 Commits

Author SHA1 Message Date
82425496cb fix: version the stored profile, and give a record that cannot be read a way out (closes #311)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m46s
e2e / e2e-firefox (push) Successful in 33s
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.
2026-08-23 17:18:29 +00:00
7 changed files with 338 additions and 58 deletions

View File

@@ -1015,10 +1015,17 @@ saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and that floor is a type check: a truthy value of the wrong type walks
through a `saved.x || default` and throws on the first dereference, which is the
blank popup again by a longer route. Adding a field means giving it a floor
there or a check in the gate.
gated. That floor is a type check for the fields something dereferences
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`,
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
well as the container, because `[1, 2]` is a list and `t.address` is one level
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a
present-or-default passthrough that takes the stored value verbatim, with no
type check at all; which field is in which category is listed in the header of
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
dereferenced walks through truthiness and throws on the first read, which is the
blank popup again by a longer route — so adding a field means choosing between
the two by what reads it.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's

30
TODO.md
View File

@@ -77,18 +77,24 @@ but the review is broader than any of them.
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
read and that nothing was signed or sent, rather than the generic `-32603`
that every request used to get. Two fields the gate deliberately does not
check, `trackedTokens` and `activeAddress`, were floored on truthiness rather
than on type and so produced the same blank popup for a truthy value of the
wrong type; both are type-checked now. `networkById()` now throws on an id it
does not know instead of quietly answering mainnet, and the gate's key tests
are all own-property tests: `networkId` is an object key into
`networkEndpoints`, so an unvalidated `"__proto__"` used to set that map's
prototype and drop the user's endpoint silently. The three corrupt blobs from
the issue drive the real popup entry point in `tests/stateRecovery.test.js`
and the real worker in `tests/stateUnusableRpc.test.js`; each rendered nothing
at all and answered `-32603` before this. `src/popup/restorableViews.js` moved
to `src/shared/restorableViews.js`, since `persistedState.js` requires it and
that every request used to get. Fields the gate deliberately does not check
produced the same blank popup on their own: `trackedTokens` and
`activeAddress` were floored on truthiness rather than on type, and
`trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
one level below the container. All of them are type-checked now, entries
included, and the header of `src/shared/stateSchema.js` lists which fields of
the record get a type check and which get a `saved.x || default` or a verbatim
passthrough, rather than asserting a rule the module does not follow.
`networkById()` now throws on an id it does not know instead of quietly
answering mainnet, and the gate's key tests are all own-property tests:
`networkId` is an object key into `networkEndpoints`, so an unvalidated
`"__proto__"` used to set that map's prototype and drop the user's endpoint
silently. The three corrupt blobs from the issue drive the real popup entry
point in `tests/stateRecovery.test.js` and the real worker in
`tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered
`-32603` before this. `src/popup/restorableViews.js` moved to
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
that module is in the background bundle.
- 2026-08-23: The background no longer reads or writes the shared `state`

View File

@@ -85,12 +85,19 @@ function offerDownload(text) {
}
}
// Residual, stated rather than left to be discovered: JSON.stringify THROWS on
// a reference cycle or a BigInt, and Firefox's structured-clone storage can
// hold both even though no build here writes one. That lands in the catch
// below, so the export fails entirely and erase is the only control left on
// the screen. Nothing here can serialize such a record; recovering one needs
// the browser's own storage inspector.
// Residual, stated rather than left to be discovered: structured-clone storage
// holds values JSON does not have, and no build here writes one, but the export
// is a funds-recovery path and what it cannot carry has to be written down.
//
// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in
// the catch below, so the export fails entirely and erase is the only control
// left on the screen.
//
// Silent, and the worse of the two, because the box then looks complete:
// a Date becomes its ISO string, a Map or a Set becomes {}, a property whose
// value is undefined is dropped from the output entirely, and NaN and
// ±Infinity become null. Nothing here can serialize any of it faithfully;
// recovering such a record needs the browser's own storage inspector.
async function exportRecord() {
let text;
try {

View File

@@ -67,6 +67,31 @@ const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
"viewStack",
]);
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// A list of token references, as everything downstream dereferences them:
// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js,
// src/popup/views/helpers.js, and every view that shows a balance line).
//
// Both the container AND the entries, because they are separate defects. A
// container check alone leaves a well-formed list of malformed entries walking
// through to a dereference one level below the check, which is the same blank
// popup: `[1, 2]` and `[{}]` are lists.
//
// A malformed entry is DROPPED rather than repaired: a token reference with no
// address identifies nothing, so there is no value to repair it to, and the
// alternative — refusing the whole record — sends a user whose wallets are
// perfectly readable to an export-or-erase screen over a token list. An entry
// that is a record with a text address is kept verbatim, extra fields and all.
function tokenRefs(value) {
if (!Array.isArray(value)) return [];
return value.filter(
(entry) => isRecord(entry) && typeof entry.address === "string",
);
}
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
@@ -126,14 +151,34 @@ function normalizePersisted(saved) {
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
// An actual list is required, not merely a truthy value: everything
// downstream iterates this and dereferences `token.address`, so a stored
// string or object walks through a `|| []` and throws on the first read
// — the blank popup from the issue, for a profile whose wallets are
// perfectly fine. An empty list is a legitimate value and survives.
out.trackedTokens = Array.isArray(saved.trackedTokens)
? structuredClone(saved.trackedTokens)
: [];
// Each address's token holdings, floored to a list of token records on the
// detached copy above. Every reader iterates it behind a `|| []` that only
// covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
// and `t.balance` — so a stored string iterates as characters, a number
// throws on the iterator, and a null entry throws on the field.
//
// This field specifically, because refreshBalances() writes it WHOLESALE
// rather than merging into it: a write that only partly lands is the live
// cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is
// where it lands. The wallet list itself is the gate's (stateSchema.js);
// what is below an address record is not, and gets floored here.
if (Array.isArray(out.wallets)) {
for (const wallet of out.wallets) {
if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue;
for (const addr of wallet.addresses) {
if (!isRecord(addr)) continue;
addr.tokenBalances = tokenRefs(addr.tokenBalances);
}
}
}
// An actual list of token records is required, not merely a truthy value
// and not merely a list: everything downstream iterates this and
// dereferences `token.address`, so a stored string or object walks through
// a `|| []`, and a list of numbers walks through an Array.isArray(), and
// both throw on the first read — the blank popup from the issue, for a
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
@@ -182,13 +227,18 @@ function normalizePersisted(saved) {
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
// Text or null, never anything else: this is passed to address.slice()
// and compared against stored addresses, so a stored number or object
// walks through a `|| null` and throws on the first render. The empty
// string is text and is kept as stored; every reader treats it as "none
// selected", which is what it is.
// A non-empty address, or null, never anything else: this is passed to
// address.slice() and compared against stored addresses, so a stored
// number or object walks through a `|| null` and throws on the first
// render. The empty string is text but it is not an address, and it must
// become null rather than survive: init() auto-selects the first address
// only on a STRICT null, so a stored "" would leave the popup with no
// address ever selected. Nothing in src/ writes one, and this keeps the
// behaviour the `|| null` this check replaced already had.
out.activeAddress =
typeof saved.activeAddress === "string" ? saved.activeAddress : null;
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress
: null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? structuredClone(saved.allowedSites)

View File

@@ -26,11 +26,29 @@
//
// What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and that obligation is a TYPE CHECK,
// not a `saved.x || default` — a truthy value of the wrong type walks through
// truthiness and throws on the first dereference, which is the same blank
// popup, reached the long way round. Adding a field to the record means giving
// it a floor there or a check here; do not assume a default covers it.
// normalizePersisted()'s to make safe, and what that function does today is
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
// which one a given field has without reading it off:
//
// Type-checked, container AND entries: trackedTokens, each address's
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
// These are the fields something dereferences structurally — iterated,
// indexed, assigned into, or .toLowerCase()'d — where a truthy value of
// the wrong type throws on the first read. The entries matter as much as
// the container: [1, 2] IS a list, and `t.address` is one level below the
// Array.isArray().
// Container shape only: allowedSites, deniedSites. A falsy value or a list
// becomes {}; anything else is taken as stored and the entries are not
// checked.
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
// currentView, selectedToken, viewData.
// Present-or-default, value taken verbatim: every boolean flag,
// dustThresholdGwei, selectedWallet, selectedAddress.
//
// A field added to the record needs a check here or a floor there, chosen by
// what reads it: anything dereferenced structurally needs the type check, and
// neither of the last two kinds is one.
const { isKnownNetworkId } = require("./networks");

View File

@@ -35,6 +35,8 @@ const POPUP_HTML = fs.readFileSync(
// Fixed address, never used for anything but these tests.
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A fixed ERC-20 contract address, same rule.
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// ------------------------------------------------------------- fixtures
@@ -447,17 +449,28 @@ describe("a garbage value in a field the gate does not check", () => {
// and where that job was written as `saved.x || default` rather than a
// type check, a TRUTHY value of the wrong type walked straight through and
// threw on the first dereference — the same blank popup this issue is
// about, measured the same way. Both of these did, at 2e2ecf9:
// about, measured the same way. Every row below did, at the head named
// against it; none has ever been removed from this list.
//
// trackedTokens: "nope" -> views=[] "Cannot read properties of
// undefined (reading 'toLowerCase')"
// activeAddress: 42 -> views=[] "address.slice is not a function"
// These belong on the floor rather than in the gate: none of these values
// carries key material, all have a sane default, and sending a user whose
// wallets are perfectly readable to an export-or-erase screen over a
// broken token list would destroy more than it saves.
//
// These belong on the floor rather than in the gate: neither value carries
// key material, both have a sane default, and sending a user whose wallets
// are perfectly readable to an export-or-erase screen over a broken token
// list would destroy more than it saves.
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
// containers with Array.isArray(), which left every row whose container is
// a well-formed list of malformed entries still blanking the popup: the
// dereference is `t.address.toLowerCase()`, one level below the check.
const CORRUPT_FIELDS = [
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
// trackedTokens: "nope" -> views=[] "Cannot read properties of
// undefined (reading 'toLowerCase')"
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
// activeAddress: 42 -> views=[] "address.slice is not a
// function"
// activeAddress: {a:1} -> views=[] "address.slice is not a
// function"
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
{
@@ -469,8 +482,64 @@ describe("a garbage value in a field the gate does not check", () => {
name: "activeAddress is an object",
patch: { activeAddress: { a: 1 } },
},
// Element shapes: a list, holding entries that are not token records.
// Observed at a10a984, AFTER the container floor:
// [1,2] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [null] -> views=[] "Cannot read properties of null
// (reading 'address')"
// [{}] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
// function"
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
{
name: "trackedTokens holds numbers",
patch: { trackedTokens: [1, 2] },
},
{
name: "trackedTokens holds null",
patch: { trackedTokens: [null] },
},
{
name: "trackedTokens holds a record with no address",
patch: { trackedTokens: [{}] },
},
{
name: "trackedTokens holds a record whose address is a number",
patch: { trackedTokens: [{ address: 42 }] },
},
{
name: "trackedTokens holds bare address strings",
patch: { trackedTokens: [TOKEN_ADDRESS] },
},
];
// The same defect one level deeper, inside a wallet the gate accepted.
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
// cause of a corrupt record lands exactly here. Observed at a10a984:
// "x" -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')" (a string iterates as characters)
// [null] -> views=[] "Cannot read properties of null (reading
// 'balance')"
// [42] -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')"
// 42 -> views=[] "number 42 is not iterable"
const CORRUPT_TOKEN_BALANCES = [
{ name: "a string", value: "x" },
{ name: "a list holding null", value: [null] },
{ name: "a list of numbers", value: [42] },
{ name: "a number", value: 42 },
];
function profileWithTokenBalances(value) {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].tokenBalances = value;
return profile;
}
for (const { name, patch } of CORRUPT_FIELDS) {
test(`${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(
@@ -484,6 +553,17 @@ describe("a garbage value in a field the gate does not check", () => {
});
}
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(profileWithTokenBalances(value));
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
}
test("the wallet is intact afterwards, and the bad value is gone", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
@@ -498,4 +578,53 @@ describe("a garbage value in a field the gate does not check", () => {
// Floored to null, then filled in by init()'s auto-default.
expect(stored.activeAddress).toBe(ADDRESS);
});
test("an empty activeAddress does not leave the popup with none selected", async () => {
// "" is text, so a type check alone lets it through — and init()
// auto-selects the first address only on a STRICT null, so it has to
// be floored to null rather than kept.
const env = await bootPopup(
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
);
expect(env.visibleViews()).toEqual(["main"]);
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
});
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
trackedTokens: [
1,
null,
{},
{ address: 42 },
TOKEN_ADDRESS,
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
],
}),
);
const stored = env.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
]);
});
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
const env = await bootPopup(
profileWithTokenBalances([
null,
42,
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]),
);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]);
});
});

View File

@@ -225,9 +225,12 @@ describe("the floors under the gate, for fields the gate does not check", () =>
// The gate's scope is what nothing can floor. Everything it lets through
// is normalizePersisted()'s to make safe, and a floor written as
// `saved.x || default` is not one: a truthy value of the wrong type walks
// through it and throws on the first dereference. These two did, and
// produced the blank popup from the issue. Type checks, not truthiness
// an empty list and an empty string are legitimate values and survive.
// through it and throws on the first dereference, which produced the blank
// popup from the issue. Nor is a container check on its own: [1, 2] is a
// list, and the dereference is `t.address.toLowerCase()` one level below
// it. Container AND entries, therefore — an empty list still survives.
const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
test("trackedTokens that is not a list becomes an empty list", () => {
for (const bad of ["nope", 42, true, { a: 1 }]) {
expect(
@@ -236,6 +239,14 @@ describe("the floors under the gate, for fields the gate does not check", () =>
}
});
test("a trackedTokens entry that is not a token record is dropped", () => {
for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) {
expect(
normalizePersisted({ trackedTokens: [bad] }).trackedTokens,
).toEqual([]);
}
});
test("a real trackedTokens list survives, copied not shared", () => {
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
@@ -248,6 +259,57 @@ describe("the floors under the gate, for fields the gate does not check", () =>
);
});
test("a good trackedTokens entry beside a malformed one survives", () => {
const good = { address: TOKEN, symbol: "AM", decimals: 18 };
expect(
normalizePersisted({ trackedTokens: [1, null, good, {}] })
.trackedTokens,
).toEqual([good]);
});
// Below an address record, which the gate walks but does not descend into.
// refreshBalances() writes tokenBalances WHOLESALE, so a write that only
// partly lands leaves exactly this field malformed.
function walletWith(tokenBalances) {
return {
wallets: [
{
name: "Wallet 1",
addresses: [{ address: ADDRESS, tokenBalances }],
},
],
};
}
function balancesOf(out) {
return out.wallets[0].addresses[0].tokenBalances;
}
test("an address's tokenBalances that is not a list becomes an empty list", () => {
for (const bad of ["x", 42, true, { a: 1 }, undefined]) {
expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]);
}
});
test("a tokenBalances entry that is not a token record is dropped", () => {
for (const bad of [null, 42, "x", {}, { address: 42 }]) {
expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual(
[],
);
}
});
test("a real tokenBalances entry survives, copied not shared", () => {
const held = { address: TOKEN, symbol: "AM", balance: "2.0" };
const saved = walletWith([held]);
const out = normalizePersisted(saved);
expect(balancesOf(out)).toEqual([held]);
expect(balancesOf(out)[0]).not.toBe(held);
});
test("activeAddress that is not text becomes null", () => {
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
expect(
@@ -256,15 +318,16 @@ describe("the floors under the gate, for fields the gate does not check", () =>
}
});
test("a real activeAddress survives, including an empty string", () => {
test("a real activeAddress survives; the empty string becomes null", () => {
expect(
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
).toBe(ADDRESS);
// Not a useful address, but it is text and it is what was stored;
// rewriting it to null would be normalization inventing a change.
expect(normalizePersisted({ activeAddress: "" }).activeAddress).toBe(
"",
);
// "" is text but it is not an address, and src/popup/index.js
// auto-selects the first address only on a STRICT null — so keeping
// the empty string would leave the popup with none ever selected.
expect(
normalizePersisted({ activeAddress: "" }).activeAddress,
).toBeNull();
});
});