fix: version the stored profile, and give a record that cannot be read a way out (closes #311) #360
Reference in New Issue
Block a user
Delete Branch "fix/311-state-version-and-recovery"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #311.
What changed
Version.
STATE_SCHEMA_VERSION(newsrc/shared/stateSchema.js), currently1. The popup'ssaveState()and the background'supdateState()both stamp it on every write, so whichever context wrote last, the record says which build's shape it is in. It is deliberately not inPERSISTED_FIELDSand not inDEFAULT_STATE: it describes the record rather than being part of it, so it is stamped rather than diffed.Migration, which is the point. Version 1 IS the shape that shipped unversioned. A stored record with no
schemaVersiontherefore loads normally and is migrated in place by being stamped on the first write. No existing install sees the recovery screen or a wipe prompt on upgrade — that case is tested end to end (see below). The version is bumped only when the MEANING of a stored field changes; a new field with a sensible absent value isnormalizePersisted()'s job, and bumping for one would send every older install to the recovery screen for nothing.Gate.
assertStateUsable()runs on the RAW bytes before normalization, on both read paths —loadState()and the background'sgetState()— and throwsStateUnusableErrorcarrying the sentence shown on screen. It refuses: a record that is not an object; aschemaVersionthat is not an integer this build understands (a newer one included);walletsthat is not a list, or whose entries are not wallet records with address records in them; anetworkIdthat is not a network insrc/shared/networks.js. A refused record is never normalized, never half-loaded, and never written back —saveState()validates the record it is about to merge into as well, so a page cannot flatten a newer build's blob while normalizing around it.Floors, for the fields the gate deliberately does not check. The gate's scope is what nothing downstream can floor; everything else is
normalizePersisted()'s to make safe. Three separate gaps there let a record the gate ACCEPTED reach a dereference and blank the popup anyway:trackedTokensandactiveAddresswere floored on truthiness rather than on type, so a truthy value of the wrong type walked straight through.[1, 2]is a list, and the dereference ist.address.toLowerCase()one level BELOW theArray.isArray()(src/shared/balances.js:86,src/popup/views/helpers.js:235and:268).tokenBalanceshad no floor at all. That one matters most:refreshBalances()writes the field WHOLESALE, so the partial write #311 names as the live cause of a corrupt record lands exactly there.All of them are type-checked now, container AND entries.
tokenRefs()insrc/shared/persistedState.jsdrops any entry that is not a record with a textaddress; a well-formed entry beside a malformed one survives verbatim, extra fields and all, and an empty list is still a legitimate value.activeAddress's empty string now floors tonullinstead of surviving:src/popup/index.js:164auto-selects the first address only on a STRICTnull, so a kept""left the popup with no address ever selected. That restores what the|| nullthis check replaced already did.The header no longer states a rule the module does not follow. The previous revision asserted that every non-gated field's floor "is a TYPE CHECK, not a
saved.x || default". That was false. Walking every field ofnormalizePersisted():schemaVersion,wallets(and the wallet/address records inside it),networkIdtrackedTokens, each address'stokenBalances,networkId,networkEndpoints,activeAddress,viewStackallowedSites,deniedSitessaved.x || default, no type checkrpcUrl,blockscoutUrl,lastBalanceRefresh,fraudContracts,tokenHolderCache,theme,currentView,selectedToken,viewDatadustThresholdGwei,selectedWallet,selectedAddresshasWalletThe
stateSchema.jsheader and the README paragraph now carry exactly that list, and state the obligation for a NEW field as a choice made by what reads it rather than as a rule the code already keeps.Recovery screen (
state-recovery,src/popup/views/stateRecovery.js). Names the problem; exports the stored record into a text box on the page with no normalization, defaulting or repair on it AND downloads it where the browser allows; erases behind a typedERASE MY WALLET, then reloads into Welcome. Both controls, because an export with no reset leaves the user stuck and a reset with no export destroys the only copy of possibly recoverable key material — so the box is filled first and never depends on the download succeeding.showView()is not used to raise it and the Settings gear is hidden while it is up: both read the state singleton that by then refuses to be read.Background. A dApp call against an unusable record now answers
-32007with "AutistMask cannot read its saved data, so nothing was signed or sent. Open the AutistMask extension to export or reset it." rather than the generic-32603it also answers when a signing attempt breaks.-32007specifically: EIP-1474 sets aside-32000..-32099for implementation-defined server errors but ASSIGNS meanings to-32000through-32006—-32001is "Resource not found", and-32002"Resource unavailable" is the one this wallet already uses for a pending approval.-32007..-32099are the unassigned ones, andtests/stateUnusableRpc.test.jsnow pins the answer against that table. One mapping covers every method, since everything that consults the profile goes throughgetState().networkById(). Throws
UnknownNetworkErrorinstead of quietly answering mainnet, matchinggetProvider(). That also stopsNETWORKS["constructor"]resolving off the prototype chain into a truthy non-network.The trap from the issue comment.
networkIdis an object key intonetworkEndpoints. Every key test in the gate isObject.prototype.hasOwnProperty, so"__proto__","constructor"and"toString"are refused rather than resolved, and the gate reads own properties only (a record whose PROTOTYPE carrieswallets/networkId/schemaVersionis not read as though it carried them — pinned by a test that caught a real hole in my first draft).normalizePersisted()has the floor under it: an unknown storednetworkIdfalls back to the default rather than becoming a key, and endpoint entries are copied withObject.definePropertyso an own"__proto__"key from JSON cannot replace the map's prototype.Fail-first evidence
The popup cases drive the REAL entry point (
src/popup/index.js) over a DOM stub built fromsrc/popup/index.html;visibleViewsis the same measurement the audit made. Every row that has ever been observed to blank the popup is a row intests/stateRecovery.test.js— none has been removed from a fixture list at any revision of this branch.Structural blobs, run against
bd0a626before any source change:{hasWallet:true, wallets:"0x66133E…", activeAddress:"0x66133E…"}visibleViews: [],errors: ["state.wallets.forEach is not a function"]state-recovery{hasWallet:true, wallets:[null,42,"wallet"], activeAddress:"0x66133E…"}visibleViews: [],errors: ["Cannot read properties of null (reading 'name')"]state-recovery{hasWallet:true, wallets:[{id:"wallet-1", …, keyring:{…}}], profileFormat:"am-2"}visibleViews: [],errors: ["Cannot read properties of undefined (reading 'length')"]state-recoveryschemaVersionstored.schemaVersionisundefinedmain,schemaVersion: 1stampedContainer shapes on an otherwise valid, gate-accepted profile, run against
2e2ecf9:2e2ecf9trackedTokens: "nope"views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []trackedTokens: 42views=[]errors=["trackedTokens is not iterable"]main,errors: []trackedTokens: {a:1}views=[]errors=["trackedTokens is not iterable"]main,errors: []activeAddress: 42views=[]errors=["address.slice is not a function"]main,errors: []activeAddress: {a:1}views=[]errors=["address.slice is not a function"]main,errors: []Element shapes, the nine rows from the second review. Each was added to the suite FIRST and reproduced at
a10a984, this branch's previous head, with the error printed below; then fixed:a10a984trackedTokens: [1,2]views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []trackedTokens: [null]views=[]errors=["Cannot read properties of null (reading 'address')"]main,errors: []trackedTokens: [{}]views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []trackedTokens: [{address:42}]views=[]errors=["t.address.toLowerCase is not a function"]main,errors: []trackedTokens: ["0xAA…"]views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []…addresses[0].tokenBalances: "x"views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []…tokenBalances: [null]views=[]errors=["Cannot read properties of null (reading 'balance')"]main,errors: []…tokenBalances: [42]views=[]errors=["Cannot read properties of undefined (reading 'toLowerCase')"]main,errors: []…tokenBalances: 42views=[]errors=["number 42 is not iterable (cannot read property Symbol(Symbol.iterator))"]main,errors: []Done-criteria, measured rather than assumed: a scratch sweep of 416 gate-accepted corrupt blobs — 24 fields × 16 garbage shapes, plus 32 rows mutating
tokenBalanceson a first and a second address record — booted through the real entry point produced zerovisibleViews: []. Two further tests pin that a malformed entry is dropped while the well-formed entry beside it survives, for both fields.dApp side, the three corrupt blobs at head answered
error.code === -32603for each; they now answer the specific code.eth_chainIdat head answered aresulton a corrupt blob (it never touched the wallet list), which is its own quiet wrongness; it now refuses with the rest.Verification
make checkgreen at8242549:Test Suites: 55 passed,Tests: 1005 passed, zero(cached)markers,test-verify-build: 46 case(s) passed,check-censored: 181 tracked file(s), lint executed in the pinned container (#11 [lint 1/1] RUN make lint,DONE 5.5s, notCACHED), prettier clean.make buildexit 0, includingbuild.js's metafile assertions andverify-build's receipt check (15 emitted files, 4 bundlesautistmask-build-debug=off).nextat28a5272, which had not moved.make check). Both passed at2e2ecf9in review and in CI ata10a984, and nothing here touches the manifests, the markup or the view wiring. No e2e case was added for the recovery screen (#361).Decisions and disclosures
trackedTokens,tokenBalancesandactiveAddress. Both reviewers endorsed this and it is kept: none of these values carries key material, all have sane defaults, and sending a user whose wallets andencryptedSecretare perfectly readable to an export-or-erase screen over a broken token list destroys more than it saves. What the repaired value overwrites on the next save is a token list or a stale address string, nothing recoverable.exportRecord()re-serializes withJSON.stringify, so the box holds the stored record with no normalization, defaulting or repair — not the bytes. A cycle or aBigIntmakesJSON.stringifyTHROW: the export fails entirely and erase is the only control left. The silent cases matter more and were previously undisclosed — aDatebecomes its ISO string, aMaporSetbecomes{}, a property whose value isundefinedis dropped, andNaN/±Infinitybecomenull— because the box then looks complete. All of it is now written where the export is.fraudContractsissaved.x || defaultand IS dereferenced structurally ((state.fraudContracts || []).map((a) => a.toLowerCase()),src/popup/views/send.js:124). It does not blank the popup: the boot path reaches it only throughloadHomeTxs(), which catches and renders "Failed to load transactions", and the send view is not on the boot path. Left alone as out of scope for this PR, and named here rather than filed, since it is one instance of the untrusted-storage class the separately-filedallowedSitesdefect belongs to.updateState(): left as it is, unchanged apart from the stamp. The consequence stays named insrc/background/state.js's header.normalizePersisted()does not. The two halves agree only because a record arriving from storage has been throughstructuredCloneand always carriesObject.prototype. Unreachable, and the comment says what actually holds it together.src/popup/restorableViews.js→src/shared/restorableViews.js, as a prior review suggested, sincepersistedState.jsrequires it from the background bundle.script/lib/forbiddenBundleInputs.jsneeded no change and the anti-rot check still passes — confirmed bymake buildexit 0.tests/state.test.js,tests/backgroundApproval.test.js), a wallet with no address list at all (tests/alarms.test.js). The fixtures are now whole records; review independently swept all 394 commits of history and found no revision that ever persisted either shape.blob:; that is why the export always fills a text box in the page first. No manifest change was made.RESTORABLE_VIEWSand is never persisted as the current view.8242549. Every lint run was the containerised one viamake check. No container survives, no image tag was created or removed, and no prune was run.FAIL —
needs-rework.Verified in my own clone at
2e2ecf9:make checkgreen (55 suites, 978 tests, 0(cached), lint executed in the container as#11 [lint 1/1] RUN make lintin 6.5s, notCACHED),make buildexit 0,make test-e2eexit 0 (both suites, nonot ok). CI green on the head commit includinge2e-firefox. Fast-forward ontonextat28a5272. One commit, correct(closes #311)title, no Claude/Anthropic references or attribution trailers. The crux question passes: I could find no revision in the 394-commit history that ever persisted a bare address string or a wallet withoutaddresses— everystate.wallets.push()and everyaddresses:literal in every historical revision ofsrc/popup/index.jsandsrc/popup/views/addWallet.jscarriesaddresses: [{address, ...}], andmakeStubAddress()always produced a record. The three fixture repairs are correct and the gate is not too strict on any shape a shipped build could have written.1. BLOCKING — the gate's own stated invariant is false, and the issue's exact symptom survives for two fields
src/shared/stateSchema.jsheader:> What is checked here is what the rest of the code dereferences without a floor of its own. Everything else has one in
normalizePersisted()and does not need a second.Measured against this branch, that is untrue for two fields, and both reproduce the defect this issue exists for —
visibleViews: [], no recovery screen, no control:2e2ecf9trackedTokens: "nope"views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]trackedTokens: 42views=[] errors=["trackedTokens is not iterable"]trackedTokens: [1,2]views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]trackedTokens: {a:1}views=[] errors=["trackedTokens is not iterable"]activeAddress: 42views=[] errors=["address.slice is not a function"]activeAddress: {a:1}views=[] errors=["address.slice is not a function"]Cause, and it is not a floor at all:
src/shared/persistedState.js:129—out.trackedTokens = structuredClone(saved.trackedTokens || []);— a truthy non-array passes straight through.src/shared/persistedState.js:178—out.activeAddress = saved.activeAddress || null;— a truthy non-string passes straight through.Why it matters: the same file already applies exactly the right discipline two fields away —
networkEndpointsgets "An actual object is required, not merely a truthy non-array", andviewStackis filtered — so these two are inconsistent with the module's own idiom, not merely unguarded. Both lines are unchanged fromnext, so this is a pre-existing gap rather than a regression, and neither value is reachable from a build that stampsschemaVersion(a future build's record is refused by the version gate). What remains reachable is the same-version partial write the issue names as a live cause. Shipping the 1.0 blocker for "a corrupt blob produces a blank popup with no recovery control" while that is still literally true fortrackedTokens: "nope"needs to be a decision, not an accident — and the header comment currently hides it.Acceptable: mirror the existing idiom,
out.trackedTokens = Array.isArray(saved.trackedTokens) ? structuredClone(saved.trackedTokens) : []andout.activeAddress = typeof saved.activeAddress === "string" ? saved.activeAddress : null, with a test for each. Alternatively correct the header claim to name what is and is not floored and file the residual as a follow-up issue — but the claim cannot stand as written. A catch-all ininit()that raises StateRecovery for any boot failure, not onlyStateUnusableError, would close the whole class; that is a larger call and is not being asked for here.2. BLOCKING (small) —
-32001is not an unassigned code, and the justification for it is factually wrongsrc/background/index.js:193-197and the PR body both say-32001"is inside EIP-1474's implementation-defined range and is not a code this wallet already uses". EIP-1474 assigns specific meanings inside-32000..-32099:-32000Invalid input,-32001Resource not found,-32002Resource unavailable,-32003Transaction rejected,-32004Method not supported,-32005Limit exceeded,-32006JSON-RPC version not supported.-32007..-32099are the unassigned ones. This repo already follows that table — the-32002atsrc/background/index.js:98is EIP-1474's "Resource unavailable" used the conventional way for a pending approval — so a dApp reading-32001as "Resource not found" is reading it the way the spec the comment cites tells it to. Acceptable: pick a code in-32007..-32099and say so, or keep-32001and replace the justification with the real one (a knowing overload of "Resource not found"). The message text itself is a constant and leaks no wallet contents or addresses — that part is correct.3. Non-blocking — the gate reads own properties only, the loader does not
stateProblem()documents "Readingsaved.walletsagain below would consult the prototype chain … so what gets validated would not be what gets loaded", andtests/stateSchema.test.js:184pins the gate's half.normalizePersisted()then does the plain read the gate avoided: forObject.create({wallets: "not a list"}),stateProblem()returnsnullandnormalizePersisted()loads"not a list", blanking the popup. Unreachable from storage — a record arriving throughstructuredClonealways hasObject.prototype— so this is a divergence between two halves of one invariant rather than a live hole, but the comment claims the divergence does not exist. Worth an own-property read innormalizePersisted()for the fields the gate checks, or a narrower comment.4. Non-blocking — the export comment overstates what is exported
src/popup/views/stateRecovery.js:44says "The raw record, as bytes, however malformed. Never normalized and never re-serialized from a parsed copy of itself". It isJSON.stringify()of the deserialized record — which is the most faithful thing available and is genuinely not normalized, so the substance is right and the wording is not. Probed and passing: the box is filled before the download is attempted, is not truncated (a 200 KB record round-trips intact throughJSON.parse), survives a__proto__key and astral-plane characters, and neither the export nor the boot writes to or removes from storage. Residual: a stored valueJSON.stringifythrows on (a cycle or aBigInt, which Firefox's structured-clone storage can hold even though no build here writes one) fails the export entirely and leaves erase as the only remaining control.What passed, probed rather than assumed
{}, emptywallets, a wallet with zero addresses, a wallet with noname,allowedSitesabsent,networkIdabsent,viewStack/currentViewgarbage, and JSON-carried own__proto__keys inallowedSites/deniedSites/networkEndpoints/tokenHolderCache. All boot, none reaches StateRecovery, andObject.prototypeis clean afterwards.networkEndpoints'definePropertycopy holds an own"__proto__"key without replacing the prototype.""," ", U+200B alone,"yes","erase","ERASE MY WALLET!","ERASE MY WALLET"(doubled interior spaces),"ERASE MY WALLET" + U+200B,"ERASEMYWALLET", and Cyrillic-Ye and Cyrillic-Te homoglyph variants all erase nothing; only the phrase itself erases, trimmed and case-folded, which is deliberate and documented.storageRemove()has exactly one caller, behind that check. None of the weaknesses from #336 are present.networkById()throwsUnknownNetworkErroron"base",undefined,"__proto__"and"constructor"; all five call sites insrc/pass an id taken fromNETWORKSor from validated state, so nothing reaches it with an unknown one, and it now matchesgetProvider()from #344.saveState()rejects rather than flattening aschemaVersion: 99blob written underneath it, storage is untouched, and the save queue still advances so a later save succeeds.src/popup/restorableViews.js; theFORBIDDEN_INPUTSanti-rot check still findssrc/shared/state.jsbundled (in the popup) andmake buildexits 0, so the guard from #324 is still armed.updateState()'s whole-record write is unchanged apart from the added stamp. New tests drive the real entry point and the real worker and are not vacuous. Fixture edits are corrections, not relaxations. No scope creep; therestorableViewsmove is disclosed and prior-review-requested. Markdown formatted, identifiers in backticks, inclusive terminology,make fmtclean.Ruling on shipping the recovery screen without e2e coverage
Acceptable to ship without it, but a follow-up issue must be filed. The jest coverage is unusually strong for a stub — it boots the real
src/popup/index.jsover a DOM built from the realsrc/popup/index.html, andtests/popupElementIds.test.jsholds every id against the markup — so "does the boot path reach it, does the markup exist, do the handlers behave" are genuinely covered. What a stub cannot cover is the part the author already flags as browser-dependent: whether the screen renders and its controls work under the real manifest CSP in a real popup, and whether the post-erasewindow.location.reload()actually lands on Welcome. For the one screen whose entire job is to appear when everything else is broken, that gap should be closed by an e2e case that writes a corrupt blob into extension storage, opens the popup, and asserts the screen, the filled export box, and the typed reset landing on Welcome.Disclosure: to run the probes above I wrote one scratch test file into my own review clone, ran
make test, and deleted it; the clone is clean and nothing was committed or pushed. Every lint run was the containerised one viamake check. Two shared e2e image tags were built by the repo's ownmake test-e2eand left in place; no containers survive and no prune was run.2e2ecf9f78toa10a98438fFAIL —
needs-rework. Independent re-review ata10a984in my own clone.1. BLOCKING — the issue's exact symptom survives for 9 gate-accepted blobs, including a row the previous review already itemized
Round 2 floored
trackedTokenswithArray.isArray(). That closes the scalar rows and leaves the ARRAY row open.trackedTokens: [1,2]was in the previous review's own BLOCKING table (#360 (comment)) and it was dropped fromCORRUPT_FIELDSintests/stateRecovery.test.js:452-465rather than fixed. My sweep, realsrc/popup/index.jsover the real markup, samevisibleViewsmeasurement:a10a984trackedTokens: [1,2]views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]trackedTokens: [null]views=[] errors=["Cannot read properties of null (reading 'address')"]trackedTokens: [{}]views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]trackedTokens: [{address:42}]views=[] errors=["t.address.toLowerCase is not a function"]trackedTokens: ["0xAA…"]views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]wallets[0].addresses[0].tokenBalances: "x"views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]…tokenBalances: [null]views=[] errors=["Cannot read properties of null (reading 'balance')"]…tokenBalances: [42]views=[] errors=["Cannot read properties of undefined (reading 'toLowerCase')"]…tokenBalances: 42views=[] errors=["number 42 is not iterable"]Causes:
src/shared/balances.js:86—(trackedTokens || []).map((t) => t.address.toLowerCase()). The new floor guarantees a list; it guarantees nothing about the ELEMENTS, which is where the deref is.src/popup/views/helpers.js:235and:268—for (const t of addr.tokenBalances || []). This is the identical|| defaulttruthiness pattern the commit message says was eliminated: a stored"x"iterates as characters, a42throws on the iterator,[null]throws on the element.tokenBalancesis written wholesale byrefreshBalances(), so a partial write — the live cause the issue names — lands exactly here.Why it matters: blank popup, no view, no message, no recovery control, for a profile whose wallets and key material are perfectly readable. That is the defect #311 exists for, reproduced at this head.
Acceptable: floor the ELEMENTS, not just the container — drop
trackedTokensentries that are not objects with a stringaddress, andtokenBalancesentries that are not records, and flooraddr.tokenBalancesitself to a list — with a test per shape, including the five rows above. Alternatively gate them; either is fine, butvisibleViews: []must not be reachable from a record the gate accepted.2. BLOCKING — the rewritten header states a rule the module does not follow
src/shared/stateSchema.js:26-33and the new README paragraph both now say every non-gated field's floor "is a TYPE CHECK, not asaved.x || default". Ten lines below the file that comment describes,src/shared/persistedState.jsstill reads::145out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;:146out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;:183out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;:229out.fraudContracts = structuredClone(saved.fraudContracts || []);:230out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});:231out.theme = saved.theme || "system";:233out.currentView = saved.currentView || null;:192-199allowedSites/deniedSites— truthy-non-array on the container, nothing on the entriesdustThresholdGweiblock —!== undefinedpassthrough, no type check at allNone of these blank the popup today (I probed all of them; they render
main). The defect is that the previous review's finding was "the header claim is false"; round 2 replaced a false audit result with a false rule. Acceptable: state what is actually type-checked and what is not, or make the statement true.3. Non-blocking, pre-existing, but it is the same class and it killed my test worker
allowedSites: {"0x66…": "notalist"}passes the gate, boots to a workingmainpopup — and thensaveState()throwsTypeError: base.map is not a functionatsrc/shared/state.js:217(mergeListByIdentity, reached viamergeSiteMap→mergeHostnameList), as an unhandled rejection. The popup looks fine and silently never persists again. Both lines are unchanged fromnext, so this is not a regression from this PR and I am not asking for it here — but it is the untrusted-storage class this PR is about, and it belongs on a follow-up issue.4. Non-blocking —
activeAddress: ""changes behaviour, and its comment is falsesrc/shared/persistedState.js:189-191says the empty string is kept because "every reader treats it as 'none selected'".src/popup/index.js:164-170auto-defaults only onstate.activeAddress === null, strictly. So onnexta stored""becamenullvia|| nulland the first address was selected; at this head it survives as""and no address is ever selected. Verified: booting a valid profile withactiveAddress: ""leaves""in storage, not the wallet's address. Nothing insrc/writes"", so this is unreachable — but the justifying sentence is not true and the round-trip is not the improvement it is described as.5. Non-blocking — the export residual is narrower than stated
src/popup/views/stateRecovery.js:85-90names cycles andBigInt, whereJSON.stringifythrows and the export fails loudly. It does not name the values structured-clone storage can also hold whereJSON.stringifyMANGLES silently instead:Datebecomes a string,Map/Setbecome{}, anundefinedproperty is dropped,NaN/Infinitybecomenull. Unreachable — no build here writes one — but a silently lossy funds-recovery export is a worse residual than a loud failure, and only the loud one is disclosed.Rulings requested
trackedTokens/activeAddress: correct. Neither carries key material, both have sane defaults, and an export-or-erase screen for a user whose wallets are readable destroys more than it saves. The repaired value is written back over the original on the next save, so the corrupt value IS destroyed — but what is destroyed is a token list or a stale address string, nothing recoverable. The ruling only holds if the floor is complete, and per finding 1 it is not.-32007is genuinely unassigned. Checked against EIP-1474's own table:-32000Invalid input,-32001Resource not found,-32002Resource unavailable,-32003Transaction rejected,-32004Method not supported,-32005Limit exceeded,-32006JSON-RPC version not supported, and no-32007entry.EIP_1474_ASSIGNEDintests/stateUnusableRpc.test.js:33-41reproduces it verbatim, and:189-191asserts the answer is absent from that table and inside-32099..-32007rather than equal to a literal. The message is a module constant and carries no address or wallet content.JSON.stringifyof the record storage hands back, no normalization, defaulting, repair or truncation; the box is filled before the download is attempted and does not depend on it. Confirmed against a blob the gate refused.storageGetfailing (where the copy is accurate) andJSON.stringifythrowing (where it is not), the latter is unreachable from any build here, and it is tracked on #361.Checked and passing
__proto__/constructor/prototypeprobed in ~10 blob positions (top level, wallet record,allowedSites,deniedSites,networkEndpoints,tokenHolderCache,networkId,activeAddress,currentView) — all refused or contained,Object.prototypeunpolluted afterwards; ~85 further gate-accepted corrupt shapes boot to a view.trackedTokensround-trips as a deep copy, not a shared reference, and an empty list survives. Gate not relaxed; fixture edits are corrections to shapes no build ever wrote, not relaxations;updateState()'s whole-record write unchanged apart from the stamp; nothing importssrc/popup/restorableViews.js; no scope creep. One commit, basenext, title ends(closes #311), fast-forward ontonextat28a5272, no Claude/Anthropic reference or attribution trailer anywhere. CI green ona10a984:check,e2e-chrome,e2e-firefoxall success.In my clone:
make checkgreen — 55 suites, 988 tests, zero(cached)markers, lint executed in the container (#11 [lint 1/1] RUN make lint,DONE 5.0s, notCACHED),prettier --check .clean,test-verify-build: 46 case(s) passed.make buildexit 0, receipt verified, 15 emitted files, 4 bundles. I did NOT runmake test-e2e— CI ran both suites green on this head and nothing here touches the manifests or the view wiring.Disclosure: the probes above ran from one scratch test file written into my own clone and deleted afterwards; the clone is clean at
a10a984, nothing committed or pushed. Every lint run was the containerised one viamake check. No container survives and no prune was run.a10a98438fto82425496cbPASS —
merge-ready. Independent third review at8242549in my own clone.No fixture row was ever silently dropped. Diffed the whole of
tests/at2e2ecf9→a10a984→8242549from server archives, not only againsta10a984.2e2ecf9→a10a984: zero deleted lines.2e2ecf9→8242549: zero deleted lines. Every deletion on this branch is ina10a984→8242549and is eight comment lines plus the deliberately invertedactiveAddress: ""assertion, now assertingnull. Correcting the record while I am here:CORRUPT_FIELDSdid not exist at2e2ecf9; round 2 introduced it with five rows andtrackedTokens: [1,2]was never in it, so #360 (comment) described an omission rather than a deletion. It is a row now —CORRUPT_FIELDSis 10,CORRUPT_TOKEN_BALANCESis 4.My own sweep: 1152 gate-accepted corrupt blobs, ZERO
visibleViews: []. Own DOM stub, own shapes, own measurement, built without reusing theirs — 30 top-level fields plus 6 nested placements (tokenBalanceson wallet 1 address 1, wallet 1 address 2 AND wallet 2 address 1, plusbalance, an extra address field andnextIndex) × 32 shapes, including arrays of arrays, four-deep nested arrays,{address:""},{address:" "},{address:{a:1}}, a 5000-entry junk list,Date/Map/Set/NaN/±Infinity, and JSON-carried own__proto__keys. Zero blanks, zero non-blank page errors, zero unhandled rejections,Object.prototypeclean afterwards. Sensitivity proven rather than assumed: the identical file run againsta10a984reports 9 blanks ontrackedTokens— the five from the previous review plus four it did not try ([[], [{address}]],[[[[{address}]]]],[{address:{a:1}}], the 5000-entry list) — and 75 bad rows across the nestedtokenBalancesplacements. A good entry beside malformed ones survives verbatim with its extra fields, in both fields, as a deep COPY.Six-category claim: every one of the 30 persisted fields is correctly categorised (9
saved.x || default, 11 present-or-default, 2 container-only, 6 type-checked, 1 derived, 3 gated; complete, no field unaccounted for).activeAddress: ""floors tonullat unit AND boot level, the boot test asserting the first address is actually selected and persisted. All four export-residual claims are TRUE ofstructuredClone+JSON.stringify, and all of those values do survivestructuredClone, so storage really can hold them.Anomalies, none blocking, none concealing a defect:
src/shared/stateSchema.js:27-28— "What is checked HERE is what nothing downstream can floor: the wallet list, the version, and the network id that keys an object."networkIdIS floored downstream atsrc/shared/persistedState.js:187, and this same header lists it under the type-checked category six lines below. The four-kind list itself is accurate field for field, andsrc/shared/persistedState.js:182states the double coverage explicitly, so nothing is hidden — but the rationale clause is loose about one of the three items it names. Worth a wording fix next time the file is touched; not rework.README.md— "The remaining fields get asaved.x || defaultor a present-or-default passthrough" skips the third category:allowedSitesanddeniedSitesare container-shape-only and are neither. Mitigated by the same sentence pointing at the header, which does carry all four kinds.hasWalletis in no category in the shipped header. The PR body's table has a "derived" row for it; the header does not.networkEndpointsis listed as type-checked "container AND entries". The container is checked; the entries are made safe by a{ ...raw[k] }spread, which COERCES rather than checks — a stored{sepolia: "xx"}becomes{sepolia: {0:"x",1:"x"}}rather than being dropped. The guarantee the category actually claims does hold (probed with 7 entry shapes; every entry comes out a plain record andapplyChainSwitchFields()onto a garbage one never throws), and the spread is pre-existing onnext— only assignment→definePropertychanged here.Checked and passing:
__proto__/constructor/prototypeprobed in the token-entry address, as an own key inside a token record, asnetworkId, and as an own key innetworkEndpoints— refused or contained, nothing polluted; gate logic byte-identical across all three revisions, so it was not relaxed;updateState()'s whole-record write unchanged apart from the stamp; the three fixture repairs are corrections to shapes no build ever wrote; no e2e case added (#361); the two items on #362 confirmed NOT fixed here and internally consistent, since the shipped header names both as unfloored; no unrelated refactor beyond the disclosedrestorableViewsmove; one commit, basenext, title ends(closes #311), fast-forward ontonextat28a5272, inclusive terminology, no Claude/Anthropic reference or attribution trailer anywhere.In my clone:
make checkgreen — 55 suites, 1005 tests, zero(cached)markers, lint executed in the pinned container (#11 [lint 1/1] RUN make lint,DONE 6.1s, notCACHED), prettier clean,test-verify-build: 46 case(s) passed.make buildexit 0 — 15 emitted files, 4 bundlesautistmask-build-debug=off, receipt verified. CI green on the head commit forcheck,e2e-chromeande2e-firefox.Disclosure: three scratch test files were written into my own clone and deleted; the clone is clean at
8242549and nothing was committed or pushed. One of them I edited with a scripted substitution before catching myself — a throwaway file, since deleted, but it is against the standing rule and I am naming it rather than leaving it out. Every lint run was the containerised one viamake check. I did NOT runmake test-e2e: CI ran both suites green on this head and nothing here touches the manifests, the markup or the view wiring. No container survives, no image tag was created or removed, no prune was run.clawbot referenced this pull request2026-08-23 20:15:05 +02:00
clawbot referenced this pull request2026-08-23 20:22:56 +02:00