Compare commits
1 Commits
82425496cb
...
a10a98438f
| Author | SHA1 | Date | |
|---|---|---|---|
| a10a98438f |
15
README.md
15
README.md
@@ -1015,17 +1015,10 @@ saved data cannot be read and that nothing was signed or sent, rather than the
|
|||||||
generic `-32603` every request used to answer.
|
generic `-32603` every request used to answer.
|
||||||
|
|
||||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||||
gated. That floor is a type check for the fields something dereferences
|
gated, and that floor is a type check: a truthy value of the wrong type walks
|
||||||
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`,
|
through a `saved.x || default` and throws on the first dereference, which is the
|
||||||
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
|
blank popup again by a longer route. Adding a field means giving it a floor
|
||||||
well as the container, because `[1, 2]` is a list and `t.address` is one level
|
there or a check in the gate.
|
||||||
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
|
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||||
|
|||||||
30
TODO.md
30
TODO.md
@@ -77,24 +77,18 @@ but the review is broader than any of them.
|
|||||||
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
|
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
|
||||||
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
|
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`
|
read and that nothing was signed or sent, rather than the generic `-32603`
|
||||||
that every request used to get. Fields the gate deliberately does not check
|
that every request used to get. Two fields the gate deliberately does not
|
||||||
produced the same blank popup on their own: `trackedTokens` and
|
check, `trackedTokens` and `activeAddress`, were floored on truthiness rather
|
||||||
`activeAddress` were floored on truthiness rather than on type, and
|
than on type and so produced the same blank popup for a truthy value of the
|
||||||
`trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
|
wrong type; both are type-checked now. `networkById()` now throws on an id it
|
||||||
at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
|
does not know instead of quietly answering mainnet, and the gate's key tests
|
||||||
one level below the container. All of them are type-checked now, entries
|
are all own-property tests: `networkId` is an object key into
|
||||||
included, and the header of `src/shared/stateSchema.js` lists which fields of
|
`networkEndpoints`, so an unvalidated `"__proto__"` used to set that map's
|
||||||
the record get a type check and which get a `saved.x || default` or a verbatim
|
prototype and drop the user's endpoint silently. The three corrupt blobs from
|
||||||
passthrough, rather than asserting a rule the module does not follow.
|
the issue drive the real popup entry point in `tests/stateRecovery.test.js`
|
||||||
`networkById()` now throws on an id it does not know instead of quietly
|
and the real worker in `tests/stateUnusableRpc.test.js`; each rendered nothing
|
||||||
answering mainnet, and the gate's key tests are all own-property tests:
|
at all and answered `-32603` before this. `src/popup/restorableViews.js` moved
|
||||||
`networkId` is an object key into `networkEndpoints`, so an unvalidated
|
to `src/shared/restorableViews.js`, since `persistedState.js` requires it and
|
||||||
`"__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.
|
that module is in the background bundle.
|
||||||
|
|
||||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
|
|||||||
@@ -85,19 +85,12 @@ function offerDownload(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Residual, stated rather than left to be discovered: structured-clone storage
|
// Residual, stated rather than left to be discovered: JSON.stringify THROWS on
|
||||||
// holds values JSON does not have, and no build here writes one, but the export
|
// a reference cycle or a BigInt, and Firefox's structured-clone storage can
|
||||||
// is a funds-recovery path and what it cannot carry has to be written down.
|
// 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
|
||||||
// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in
|
// the screen. Nothing here can serialize such a record; recovering one needs
|
||||||
// the catch below, so the export fails entirely and erase is the only control
|
// the browser's own storage inspector.
|
||||||
// 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() {
|
async function exportRecord() {
|
||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,31 +67,6 @@ const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
|||||||
"viewStack",
|
"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.
|
// 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
|
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
|
||||||
@@ -151,34 +126,14 @@ function normalizePersisted(saved) {
|
|||||||
out.wallets = structuredClone(saved.wallets || []);
|
out.wallets = structuredClone(saved.wallets || []);
|
||||||
// Derived, never trusted verbatim off storage — see loadState().
|
// Derived, never trusted verbatim off storage — see loadState().
|
||||||
out.hasWallet = out.wallets.length > 0;
|
out.hasWallet = out.wallets.length > 0;
|
||||||
// Each address's token holdings, floored to a list of token records on the
|
// An actual list is required, not merely a truthy value: everything
|
||||||
// detached copy above. Every reader iterates it behind a `|| []` that only
|
// downstream iterates this and dereferences `token.address`, so a stored
|
||||||
// covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
|
// string or object walks through a `|| []` and throws on the first read
|
||||||
// and `t.balance` — so a stored string iterates as characters, a number
|
// — the blank popup from the issue, for a profile whose wallets are
|
||||||
// throws on the iterator, and a null entry throws on the field.
|
// perfectly fine. An empty list is a legitimate value and survives.
|
||||||
//
|
out.trackedTokens = Array.isArray(saved.trackedTokens)
|
||||||
// This field specifically, because refreshBalances() writes it WHOLESALE
|
? structuredClone(saved.trackedTokens)
|
||||||
// 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
|
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
// 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
|
// so a value that is not a network in networks.js must never get that far
|
||||||
@@ -227,18 +182,13 @@ function normalizePersisted(saved) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||||
// A non-empty address, or null, never anything else: this is passed to
|
// Text or null, never anything else: this is passed to address.slice()
|
||||||
// address.slice() and compared against stored addresses, so a stored
|
// and compared against stored addresses, so a stored number or object
|
||||||
// number or object walks through a `|| null` and throws on the first
|
// walks through a `|| null` and throws on the first render. The empty
|
||||||
// render. The empty string is text but it is not an address, and it must
|
// string is text and is kept as stored; every reader treats it as "none
|
||||||
// become null rather than survive: init() auto-selects the first address
|
// selected", which is what it is.
|
||||||
// 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 =
|
out.activeAddress =
|
||||||
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
|
typeof saved.activeAddress === "string" ? saved.activeAddress : null;
|
||||||
? saved.activeAddress
|
|
||||||
: null;
|
|
||||||
out.allowedSites =
|
out.allowedSites =
|
||||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||||
? structuredClone(saved.allowedSites)
|
? structuredClone(saved.allowedSites)
|
||||||
|
|||||||
@@ -26,29 +26,11 @@
|
|||||||
//
|
//
|
||||||
// What is checked HERE is what nothing downstream can floor: the wallet list,
|
// 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
|
// the version, and the network id that keys an object. Every other field is
|
||||||
// normalizePersisted()'s to make safe, and what that function does today is
|
// normalizePersisted()'s to make safe, and that obligation is a TYPE CHECK,
|
||||||
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
|
// not a `saved.x || default` — a truthy value of the wrong type walks through
|
||||||
// which one a given field has without reading it off:
|
// 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
|
||||||
// Type-checked, container AND entries: trackedTokens, each address's
|
// it a floor there or a check here; do not assume a default covers it.
|
||||||
// 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");
|
const { isKnownNetworkId } = require("./networks");
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ const POPUP_HTML = fs.readFileSync(
|
|||||||
|
|
||||||
// Fixed address, never used for anything but these tests.
|
// Fixed address, never used for anything but these tests.
|
||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
// A fixed ERC-20 contract address, same rule.
|
|
||||||
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
||||||
|
|
||||||
// ------------------------------------------------------------- fixtures
|
// ------------------------------------------------------------- fixtures
|
||||||
|
|
||||||
@@ -449,28 +447,17 @@ 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
|
// 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
|
// 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
|
// threw on the first dereference — the same blank popup this issue is
|
||||||
// about, measured the same way. Every row below did, at the head named
|
// about, measured the same way. Both of these did, at 2e2ecf9:
|
||||||
// against it; none has ever been removed from this list.
|
|
||||||
//
|
//
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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
|
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
||||||
// undefined (reading 'toLowerCase')"
|
// undefined (reading 'toLowerCase')"
|
||||||
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
|
// activeAddress: 42 -> views=[] "address.slice is not a function"
|
||||||
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
|
//
|
||||||
// activeAddress: 42 -> views=[] "address.slice is not a
|
// These belong on the floor rather than in the gate: neither value carries
|
||||||
// function"
|
// key material, both have a sane default, and sending a user whose wallets
|
||||||
// activeAddress: {a:1} -> views=[] "address.slice is not a
|
// are perfectly readable to an export-or-erase screen over a broken token
|
||||||
// function"
|
// list would destroy more than it saves.
|
||||||
|
const CORRUPT_FIELDS = [
|
||||||
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
||||||
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
||||||
{
|
{
|
||||||
@@ -482,64 +469,8 @@ describe("a garbage value in a field the gate does not check", () => {
|
|||||||
name: "activeAddress is an object",
|
name: "activeAddress is an object",
|
||||||
patch: { activeAddress: { a: 1 } },
|
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) {
|
for (const { name, patch } of CORRUPT_FIELDS) {
|
||||||
test(`${name}: a working popup, not a blank one`, async () => {
|
test(`${name}: a working popup, not a blank one`, async () => {
|
||||||
const env = await bootPopup(
|
const env = await bootPopup(
|
||||||
@@ -553,17 +484,6 @@ 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 () => {
|
test("the wallet is intact afterwards, and the bad value is gone", async () => {
|
||||||
const env = await bootPopup(
|
const env = await bootPopup(
|
||||||
Object.assign(unversionedValidProfile(), {
|
Object.assign(unversionedValidProfile(), {
|
||||||
@@ -578,53 +498,4 @@ describe("a garbage value in a field the gate does not check", () => {
|
|||||||
// Floored to null, then filled in by init()'s auto-default.
|
// Floored to null, then filled in by init()'s auto-default.
|
||||||
expect(stored.activeAddress).toBe(ADDRESS);
|
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" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -225,12 +225,9 @@ 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
|
// The gate's scope is what nothing can floor. Everything it lets through
|
||||||
// is normalizePersisted()'s to make safe, and a floor written as
|
// 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
|
// `saved.x || default` is not one: a truthy value of the wrong type walks
|
||||||
// through it and throws on the first dereference, which produced the blank
|
// through it and throws on the first dereference. These two did, and
|
||||||
// popup from the issue. Nor is a container check on its own: [1, 2] is a
|
// produced the blank popup from the issue. Type checks, not truthiness —
|
||||||
// list, and the dereference is `t.address.toLowerCase()` one level below
|
// an empty list and an empty string are legitimate values and survive.
|
||||||
// it. Container AND entries, therefore — an empty list still survives.
|
|
||||||
const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
||||||
|
|
||||||
test("trackedTokens that is not a list becomes an empty list", () => {
|
test("trackedTokens that is not a list becomes an empty list", () => {
|
||||||
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
for (const bad of ["nope", 42, true, { a: 1 }]) {
|
||||||
expect(
|
expect(
|
||||||
@@ -239,14 +236,6 @@ 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", () => {
|
test("a real trackedTokens list survives, copied not shared", () => {
|
||||||
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
|
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
|
||||||
|
|
||||||
@@ -259,57 +248,6 @@ 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", () => {
|
test("activeAddress that is not text becomes null", () => {
|
||||||
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
|
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
|
||||||
expect(
|
expect(
|
||||||
@@ -318,16 +256,15 @@ describe("the floors under the gate, for fields the gate does not check", () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a real activeAddress survives; the empty string becomes null", () => {
|
test("a real activeAddress survives, including an empty string", () => {
|
||||||
expect(
|
expect(
|
||||||
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
|
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
|
||||||
).toBe(ADDRESS);
|
).toBe(ADDRESS);
|
||||||
// "" is text but it is not an address, and src/popup/index.js
|
// Not a useful address, but it is text and it is what was stored;
|
||||||
// auto-selects the first address only on a STRICT null — so keeping
|
// rewriting it to null would be normalization inventing a change.
|
||||||
// the empty string would leave the popup with none ever selected.
|
expect(normalizePersisted({ activeAddress: "" }).activeAddress).toBe(
|
||||||
expect(
|
"",
|
||||||
normalizePersisted({ activeAddress: "" }).activeAddress,
|
);
|
||||||
).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user