fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362) #366
Reference in New Issue
Block a user
Delete Branch "fix/362-floor-site-and-fraud-entries"
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 #362.
Round 5, against the review at #366 (comment) and the scope ruling at #366 (comment). Head
2b78755, onnextat45500e6.make checkandmake buildboth exit 0.This round does the three things the ruling names and nothing else. The harness is NOT widened: no new hostile slots, no new polarity coverage, no new sweep dimensions. Test count is unchanged at 1475.
1. The assertion — an unhealthy combined boot now fails unconditionally
tests/persistedFieldContract.test.jsobserved a dead popup and threw the evidence away. When the combined boot came back unhealthy it discardedtogether, re-booted each of the twelveUNROUTEDfields alone, and asserted only on the resultingnamedlist. If no single field reproduced the failure,namedwas[]andexpect({view, fields: []}).toEqual({view, fields: []})passed.Reproduced first at
19a84a5with the reviewer's statement at the top ofshow()insrc/popup/views/receive.js:make test: 61 suites / 1475 tests, all green, exit 0 — with the slot-4receiveboot broken.The per-field loop now runs only when the combined boot is unhealthy, and only to DECORATE the message. The assertion is
{view, together, fields: named}against{view, together: RESTORED, fields: []}. When the loop finds no single culprit it says so and prints the record, so the message still points somewhere:One failure,
receive: hostile value 4 in all 12 of them, naming the throw.The four established mutations, re-measured at this head, all still RED
void state.theme.toLowerCase();inreceive.show()receiveslots 1 / 3 / 4; slot 2 ("chartreuse") correctly greenselectedTokenfloor reverted tosaved.selectedToken || null, rowSCALARtoLOOSEwithfalsy: [""]address-tokenslots here, plus 2 inpersistedEntryFloors.test.jsvoid state.dustThresholdGwei.toFixed(2);atoptransactionDetail.render()transactionslotif (!state.utcTimestamps) { void state.theme.toLowerCase(); }insettings.show()settingsslot 4One further two-field-interaction mutation was measured as temporary verification and reverted, adding no permanent coverage:
if (!state.hideLowHolderTokens) { void state.dustThresholdGwei.toFixed(2); }first inshow()ofsrc/popup/views/addressToken.js. Both fields default TRUTHY (trueand100000), so neither reproduces it alone — the swallowed shape. Caught, one failure,address-token: hostile value 4,"state.dustThresholdGwei.toFixed is not a function", with the "no single field reproduces it" decoration.Every mutation was reverted;
git statusis clean at2b78755.2. The sentence, narrowed in all six artifacts
tests/persistedFieldContract.test.js,README.md,src/shared/stateSchema.js,TODO.md, the commit message and this body now all state the same narrow claim, in the reviewer's own honest form:Undriven, said plainly in each artifact:
Within that boundary the verdict is unconditional: if one of these boots leaves the popup unhealthy or off the view it stored, the suite goes red, including when it takes two corrupted fields at once. Also red: a field that gains a floor while its row still claims it has none, and a field added to
PERSISTED_FIELDSwith no row. The headers say outright that widening further is out of scope, since proving no field is dereferenced on any reachable render path is exhaustive verification of the popup rather than a floor under a stored record.The sentences were written last, after the assertion fix was in and after all five mutations had been re-measured against the tree as built.
3. Not done, deliberately
The round-4 secondary item —
hostileRestoreentries carryingviews: [...]counting toward a row's polarity — is left untouched per the ruling, for the follow-up issue. No current row is affected.Verification
make check— exit 0. 61 suites / 1475 tests, Jest 12.8s against the 30sscript/testcap, 25.6s wall for the target.test-verify-build: 46 case(s) passed,check-censored: 192 tracked file(s) inspected, prettier clean.#11 [lint 1/1] RUN make lint…DONE 5.1s, notCACHED.make build— exit 0.verify-build: 23 emitted file(s) verified against the receipt, 4 bundle(s) autistmask-build-debug=off.45500e6; already up to date. Onlymakeentrypoints were used; every edit, including each mutation, was made by hand. No container was created and none survives; nothing was pruned.Round four: both polarities of every swept field are driven
Every hostile value the sweep carried was TRUTHY — a value nothing in
src/writes is a wrong-typed one, and wrong-typed values are objects, non-empty strings and non-zero numbers. So on the one boot that corrupted a field, noif (!state.x)branch was entered; and forutcTimestamps,debugModeandlastBalanceRefreshthe falsy answer is theDEFAULT_STATEdefault, the branch every ordinary install takes.HOSTILE_SLOTSdrives a fourth, FALSY slot. Which row gained what, and which cannot have one:rememberSiteChoice,showZeroBalanceTokens,hideSpoofedSymbols,hideLowHolderTokens,hideFraudContracts,hideDustTransactions,utcTimestamps,debugMode)0!== undefined ? saved : default, so0survives verbatim, and0is not a booleandustThresholdGwei""lastBalanceRefresh0|| 0collapses every falsy stored value to0, so0IS this field's whole falsy polarity — and its defaultcurrentView""|| null; the boot lands on Home, and the eleven per-view profiles deduplicate to onetheme,tokenHolderCache,viewDataneverFalsy|| "system"and|| {}make the field TRUTHY in state whatever was stored, so no!state.xbranch is reachable from a stored record at allselectedWallet,selectedAddresshostilevalue floors tonull, andhostileRestore: { value: 5 }is truthyThe last two rows are not prose.
both polarities of every swept field are driventakes what the file ACTUALLY drives for each swept row, floors each value the way a renderer sees it, and requires at least one truthy and at least one falsy; aneverFalsyrow instead has to PROVE the field comes back truthy for each of0,"",falseandnull.The falsy slot is deliberately kept OUT of the
is genuinely unflooredassertion:saved.x || defaultis a floor on falsy values and on nothing else, so verbatim survival is the wrong question to ask of them. What they have to carry through is being falsy, and that is what the polarity test asserts.Round three: the sweep enters the restore path
profileWith()built a profile with nocurrentView, so every row boot landed on Home and never enteredrestoreView()— the path this entire defect class lives on. Only two hand-written blocks drove the restore path, coveringviewDataand the two indices; every otherLOOSErow was proven against a profile shape that cannot reach a renderer.tests/persistedFieldContract.test.jsnow drives the restore path for every unfloored field, and both hand-written blocks are folded into the table as row data (hostileRestore).routes:currentView,viewData,selectedWallet,selectedAddress) gets its own boot per hostile value per restorable view. It is held to "healthy", not to "landed on the view", because a hostile value in one of these legitimately changes which view renders.routesand take the individual sweep.LOOSE, plus the two index rows that opt in withalsoSweepbecause the restore path is why they gained a floor in the first place.Secondary finding:
sometoeveryis genuinely unflooredassertedvalues.some(...)survived verbatim, so a PARTIAL floor kept a staleLOOSErow. It now asserts every value, one at a time, so the failure names which value got floored.What the corrected harness exposed
success-txandtransactioncould not be booted onto at all.renderSuccess()andtransactionDetail.render()reachparentElementto hide a field's wrapper, andtests/support/popupBoot.jshad noparentElement, so both threw on the first line that touches one. Every earlier boot aimed at those two views fell back to Home for that reason rather than for the reason the test was about.Fixed by giving each stub element a wrapper, made on demand. And so it cannot go unnoticed again, the base profile the sweep starts from is now asserted to RENDER each of the eleven restorable views rather than fall back — that assertion is what caught this.
With the wrapper in place the
viewDataguards were re-proven for the right reason: dropping thesuccess-txto/decodedchecks and thetransactionisRenderableTx()check fails 8 rows withaddress.toLowerCase is not a functionandaddress.slice is not a function, which is the dereference the guards exist for.The original change
src/shared/stateSchema.js's header carried a hand-written justification per persisted field. It shipped a false claim in three consecutive changes, a different field each time, each caught only by a reviewer re-deriving thirty fields by hand. The artifact was the problem, so the artifact is gone.tests/persistedFieldContract.test.jsreplaces it: one row per persisted field, declaring the property that field's floor is claimed to have, and proving it by driving the real code with hostile values for that field.REFUSEDassertStateUsable()refuses the recordstateProblem()must name a problem for every hostile valueENTRIESholds()predicate overnormalizePersisted()'s outputSCALARLOOSEA field added to
PERSISTED_FIELDSwith no row fails the exhaustiveness test, which compares the row set againstPERSISTED_FIELDSitself.This is the same lesson as #324: a convention nobody can violate beats a convention everyone restates.
A.
rpcUrl— claim was false, and there was a real defect behind itgetProvider()doesnew JsonRpcProvider(rpcUrl, …), which throws SYNCHRONOUSLY (url.clone is not a function) for42,true,{},[]— andsrc/popup/views/txStatus.js:116is reachable from a storedcurrentView: "wait-tx"through the unguardedrestoreView().rpcUrlis never concatenated either; onlyblockscoutUrlis.rpcUrlandblockscoutUrlare floored to non-empty text. Also floored: eachnetworkEndpointspair'srpcUrl/blockscoutUrl, sinceapplyChainSwitchFields()assignsremembered.rpcUrlstraight ontos.rpcUrlon the next chain switch. A field that is not text is deleted from the pair, so the switch falls through its own|| net.defaultRpcUrl; everything else in the pair is kept.The row proves it against the real constructor via
jest.requireActual, sincebootPopup()mocks that module out.B.
viewData— the ENTRIES are dereferencedrenderView()gated each branch on one truthy field and handed the rest to a renderer callingaddress.toLowerCase().restoreWait()has type-checked its own branch's fields since it was written; the other four had nothing.src/popup/viewRouter.jsnow checks, per branch, exactly what its renderer dereferences — and only that, sinceescapeHtml()anddisplaySymbol()bothString()their argument:success-tx:tomust be text, anddecodedmust be absent or a record whosedetailsis a list of records whoseaddressis text.error-tx:tomust be text.transaction:from,to, andcontractAddress-if-present must be text.confirm-tx:token,from,tomust be text.C.
selectedWallet/selectedAddress— the clearance was backwardsA stale INTEGER is falsy-or-out-of-range and safe, while
wallets["map"]is TRUTHY, sohasValidAddress()'s&&does not short-circuit and.addresses[…]throws."length","constructor"and"__proto__"answer the same way.Both fields are floored to a non-negative integer or
null. Andconfirm-txjoinsADDRESS_VIEWS:src/popup/views/confirmTx.js:430dereferencesstate.wallets[state.selectedWallet].encryptedSecretbehind no gate at all.__proto__A site-map key is a wallet address;
__proto__can never be one, so it grants and denies nothing, and it is now dropped.networkEndpointsdeliberately keeps keys that are not known network ids, so the fix there is the write rather than the key:mergeMapByKey()insrc/shared/state.jswrites every key withdefineProperty. Reverting onlysrc/shared/state.jsfails exactly one test.Smaller findings from the first review
tokenHolderCache"nothing insrc/reads it" was false. The row now claims only that nothing DEREFERENCES it structurally, and names the field-agnostic readers.viewStackas a second exception to "a malformed entry is dropped" — its row says so, and theREADME.mdsentence names both exceptions.theme/currentViewsub-claims — gone with the rest of the prose; both areLOOSErows proven by boot.tests/persistedEntryFloors.test.js:109expect({}.length).toBeUndefined()— deleted.Test-harness changes, all of them
Against
ad6aa7b:tests/stateRecovery.test.js, stated intests/support/popupBoot.js:body.prepend()registers the node by id.text()returnsnullfor a missing node where the old harness threw.debug-bannerstub went from an unconditionalreturn nulltoRUNTIME_IDS.has(id) && !els.has(id).makeElement'sremove: () => {}never unregisters.getContext("2d")on every element, for the Receive view's QR.parentElementon every element, without whichsuccess-txandtransactioncannot be rendered at all.bootPopup(stored, {storage})still ignoresstoredwhenstorageis supplied; unchanged, and unchanged in being misleading.allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function". All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing. A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission. The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.FAIL —
needs-rework(a rebase is also required).1. Conflicts with
next. Gitea reportsmergeable: false;git merge-tree origin/next 44b0a15confirms one content conflict inTODO.md— the adjacent Completed Steps entry, againstc9ebac8(#357), which landed ten seconds after this PR opened. Rebase and re-runmake check.2.
src/shared/stateSchema.js:52-54— the newrpcUrljustification is false. It claims the two URLs "are only concatenated into a fetch URL and handed to ethers, where a bad value fails the request on a path that already catches".getProvider()doesnew JsonRpcProvider(rpcUrl, …)(src/shared/balances.js:37-39), which throws SYNCHRONOUSLY for a non-string — verified by execution:12345,true,{},[]all throw, a string does not. Two call sites are outside anytry:src/popup/views/txStatus.js:116(reachable from a storedcurrentView: "wait-tx"through the unguardedrestoreView()atsrc/popup/index.js:245) andsrc/popup/views/addWallet.js:182,301(awaited by a handler with nocatch). Separately,rpcUrlis never concatenated — it is passed whole; onlyblockscoutUrlis, and that half of the claim does hold. This is new text added by this PR, in the header that has shipped a false claim twice. Acceptable: wrap those call sites so the claim becomes true, or state the truth.3.
src/shared/stateSchema.js:49-51— "None of these is dereferenced structurally" is false forviewData. True of the container, false of the entries, which is precisely the distinction this PR exists to draw.renderView()gates each branch on one truthy field and hands the rest to a renderer that calls.toLowerCase(). A stored{"currentView":"success-tx","viewData":{"hash":"0x1"}}passes thedata.hashgate (src/popup/viewRouter.js:122), reachestoAddressHtml(d.to)(src/popup/views/txStatus.js:288), and throws onaddress.toLowerCase()withundefined(src/popup/views/helpers.js:356), out of the unguardedrestoreView(). Same shape onerror-tx(txStatus.js:328),transaction(transactionDetail.js:96) andconfirm-tx(confirmTx.js:111,154).restoreWait()(txStatus.js:185-214) already type-checks exactly these fields for thewait-txbranch, with a comment stating the throw skips the rest of popup init — the identical argument applies to the other four and none got it.4. The
selectedWallet/selectedAddressclearance is wrong:hasValidAddress()is NOT safe for any value.src/popup/viewRouter.js:68-75evaluatesstate.wallets[state.selectedWallet] && state.wallets[state.selectedWallet].addresses[state.selectedAddress].walletsis a real Array, so aselectedWalletnaming anArray.prototype/Object.prototypemember —"map","length","constructor","toString","__proto__"— is TRUTHY, the&&does not short-circuit,.addressesisundefined, and the index access throwsTypeError. Verified by execution; a stale integer (5) or an ordinary string ("abc") IS falsy and safe. So the PR body's "a type check would not help anyway — a valid-typed but stale index fails identically" is backwards: the stale index is the case that works, the prototype-member string is the case that does not.normalizePersisted()takes both verbatim (src/shared/persistedState.js:337-339), so{"currentView":"address","selectedWallet":"map","selectedAddress":0}throws out ofrestoreView()beforedoRefreshAndRender()and before the 10s interval — the dead popup this class is about.confirm-txis inRESTORABLE_VIEWSbut notADDRESS_VIEWS, sosrc/popup/views/confirmTx.js:430dereferences it behind no gate at all. The code defect is pre-existing; the justification is this PR's. Acceptable: floor both to a non-negative integer ornull, asselectedTokenandactiveAddressnow are, or file it and correct the claim. Note this also falsifies the header's closing sentence atstateSchema.js:65-66("neither of the last two kinds is one").5. Smaller header/README inaccuracies.
stateSchema.js:59-60"nothing insrc/readstokenHolderCacheat all" is literally false:snapshotPersisted()reads it viaPERSISTED_FIELDS(src/shared/state.js:136) anddeepEqual(current[key], baseline[key])(:492) reachesObject.keys(). Both are field-agnostic and safe, so the conclusion stands; the wording should be "nothing dereferences it structurally".stateSchema.js:40-41"A malformed entry is dropped.networkEndpointsis the one whose entries are coerced rather than dropped" —viewStackis a second exception:restorableStack()truncates at the first non-restorable entry and may substitute["main"].README.md:1024states "A malformed entry is dropped." with neither exception;README.md:1020-1024foldsnetworkId/activeAddress/selectedTokeninto the sentence that then claims entry-as-well-as-container checking, which none of the three has.theme"only compared and concatenated" — nothing concatenatestheme; the other read is.value =(src/popup/views/settings.js:301).currentView"gated byRESTORABLE_VIEWS.has()before anything renders from it" —src/popup/views/settings.js:415passes it raw toupdateDebugBanner(), concatenated into on-screentextContent(helpers.js:122-128) with no gate. Both harmless, both inaccurate.6.
tests/persistedEntryFloors.test.js:109is vacuous.expect({}.length).toBeUndefined();cannot fail under any implementation, and the pollution it reaches for would move the prototype ofout[field], notObject.prototype. Lines 107-108 do the real work. Delete it.7. Question, not filed as a defect. The new
definePropertyguard insiteMap()is undone one layer downstream:mergeMapByKey()(src/shared/state.js:325,327,336) writesresult[key] = …by plain assignment, so an own__proto__key — whichsiteMap()deliberately PRESERVES, astests/persistedEntryFloors.test.js:99-109asserts — is handed to the prototype setter on the next save, recorded nowhere and moving that object's prototype. Verified by execution. Pre-existing and identical fornetworkEndpoints, with no global pollution and nothing persisted, hence a question rather than a finding: is preserving a__proto__key intended, given it can never be a wallet address key?8. Non-blocking, test scaffolding.
tests/support/popupBoot.jscarries three changes beyond the two disclosed:text()returnsnullfor a missing node where the old harness threw (:278-281vsad6aa7b:tests/stateRecovery.test.js:304); thedebug-bannerstub went from an unconditionalreturn nulltoRUNTIME_IDS.has(id) && !els.has(id)(:142); andmakeElement'sremove: () => {}(:96) never unregisters, so once a runtime node is registered "the banner is absent" can never be false again —persistedEntryFloors.test.js:375passes only because no banner is raised on that boot. AlsobootPopup(stored, {storage})silently ignoresstoredwhenstorageis supplied (:218-220), already misleading atpersistedEntryFloors.test.js:359. All inert today.Drop-vs-empty-list ruling (the question raised in the PR): dropping is correct, and the choice is security-neutral. Every consumer reads
s.<map>[address] || [](src/background/index.js:622,623,702,735,810,839,888,948,1089), so an absent key and[]are indistinguishable; the only presence test ismergeMapByKey(), wherebase,oursandtheirsare all normalized identically so presence stays consistent.src/popup/views/settings.js:59-62already deletes a key whose list empties, so the floor matches the invariant the revoke button maintains. No site can be silently un-denied into allowed: the two maps are independent and the gate checks denied, then allowed, then prompts, so a droppeddeniedSiteskey yields a re-prompt, never an allow.textList()keeps surviving hostname strings, so a denial is lost only when the stored value is not an array at all — where keeping[]would lose it identically.Verified passing: the DoD for the three named fields is met; #311 coverage is preserved (test bodies from the
// ---- testsmarker to EOF are byte-for-byte identical, and the pass/fail profile matches the old file under two independent source mutations); fail-first reproduces exactly (26 failures with onlysrc/shared/persistedState.jsreverted, includingbase.map is not a function,Received number of calls: 0,a.toLowerCase is not a functionandvisibleViews: []withtokenId.toLowerCase is not a function; andJest worker encountered 4 child process exceptionswith the save-failure half reverted); the banner usestextContentand obeys #307;onSaveFailureis registered ahead of every save path including the approval window and its reporter is itself try/caught; thedoRefreshAndRender()catch swallows nothing that previously reached a handler; all 30 persisted fields are accounted for in the categorisation and the removed "Container shape only" kind is genuinely empty; CI green 3/3 on44b0a15; one commit, title ends(closes #362), basenext, no attribution trailers and no Claude/Anthropic references;make checkandmake buildgreen in an independent clone with lint executed in the pinned container (#11 [lint 1/1] RUN make lint…DONE 5.9s, notCACHED), 56 suites / 1039 tests, prettier clean.Disclosure: findings 2, 4 and 7 and the drop-vs-empty ruling were confirmed by temporary scratch tests and temporary source reverts in a private clone; that clone was restored to
44b0a15clean and the scratch files deleted. No labels or assignee changed.44b0a153f0to78a5573d86fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)to fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362)FAIL —
needs-rework. One finding, in the harness this PR rests on.1. The
LOOSEproof does not prove what the commit,stateSchema.js,README.mdand the PR body all claim it proves — and a falseLOOSErow keepsmake checkgreen.tests/persistedFieldContract.test.js:320-322:profileWith()isunversionedValidProfile({[field]: value}), which carries nocurrentView. So every one of the ~150 row boots lands on Home. The restore path — the entire defect class this PR is about, and the path all three earlier false claims lived on — is never entered by a row's own boot. The only things that drive it are the two HAND-WRITTEN blocks at:490and:544, which coverviewDataand the two indices and nothing else. The other elevenLOOSErows are proven against one profile shape that cannot reach a renderer.Demonstrated twice in a private clone at
78a5573,make checkonly:void state.theme.toLowerCase();as the first line ofshow()insrc/popup/views/receive.js, changing nothing else.themestaysLOOSE, whose row claims "no structural dereference of it is reachable from a stored record". Result: 58 suites / 1253 tests, all green. A stored{"theme":42,"currentView":"receive"}then boots topageErrors: ["state.theme.toLowerCase is not a function"]andvisibleViews: []— the dead popup, out of the unguardedrestoreView(), exactly #362's shape. Measured with a scratch test, since nothing in the suite asks.out.selectedToken's floor tosaved.selectedToken || nulland changed its row fromSCALARtoLOOSE.persistedFieldContract.test.jsPASSED; the run went red only ontests/persistedEntryFloors.test.js. The contract accepted a false row about a field it exists to describe.That is the one failure mode the whole structural change was for: "a row claiming
LOOSEfor a field that IS dereferenced structurally: the boot throws out ofrestoreView()or leaves no view on screen" (PR body), "so does a row whose claim is false" (README.md:1030,stateSchema.js:35, commit message). All four statements are false as written. This is the same class of unexecutable claim the PR removes fromstateSchema.js, relocated into the thing that replaced it, which is worse: the header was at least visibly prose.Acceptable: drive each row's hostile values across the restore path, not only Home — every
RESTORABLE_VIEWSentry, or at minimum the union ofDATA_VIEWSandINDEX_VIEWS— so aLOOSErow is falsified by any renderer reachable from a stored record.HOSTILE_VIEW_DATA/HOSTILE_INDEXalready do exactly this for three fields and can fold into the row loop. If the boot cost is the obstacle, restrict the cross-view sweep toLOOSErows (eleven rows, three values each) and say in the header thatENTRIES/SCALARrows are proven by theirholds()instead. Failing that, the three artifacts must stop claiming the boot falsifies aLOOSErow.2.
tests/persistedFieldContract.test.js:356-367, same file, smaller. "is genuinely unfloored" assertsvalues.some(...)survives verbatim, so a field that gains a PARTIAL floor keeps its staleLOOSErow. Verified: flooringthemetotypeof saved.theme === "string" ? saved.theme : "system"leaves"chartreuse"surviving and the suite green (a total floor to the three valid values IS caught).everyis what the comment above it describes.Mutations that WERE caught, each confirmed red: a row deleted for a field still in
PERSISTED_FIELDS(exhaustiveness test);rpcUrldeclaredREFUSEDwhenstateProblem()accepts it; therpcUrlSCALARfloor reverted tosaved.rpcUrl || DEFAULT(bothholds()and thejest.requireActualproof against the realJsonRpcProvider); thesiteMap()ENTRIESfloor weakened to a truthiness check; a total floor added toLOOSEtheme; andmergeMapByKey()'sdefinePropertyreverted in isolation, which fails exactly one test as claimed.Hostile corpus: adequate. Not the thin
42/"x"/nullshape — it carries prototype-member strings, a JSON-parsed own__proto__key, arrays where records are expected, nested malformed entries,-1,1.5,"0"and the empty string. The corpus is not the weakness; the driver in finding 1 is.Verified passing: A, B and C are all closed. A —
applyChainSwitchFields()does launder (s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl,src/shared/chainSwitchFields.js:42), andendpointPair()deleting a non-text field makes it fall through tonet.defaultRpcUrl; the row proves the floor against the real constructor. B — five views readviewData(confirm-tx,transaction,wait-tx,success-tx,error-tx);wait-txIS the fifth andrestoreWait()already checked it, so there is no missed branch, and each new guard matches what its renderer dereferences. C —confirm-txreachesADDRESS_VIEWSwithout touching any other view (the approval window returns beforerestoreView(), and the onlyconfirmTx.show()caller always writes texttoken/from/to), and thewallets["map"]/"constructor"/"__proto__"truthiness hole is closed bylistIndex(). Both halves of the__proto__answer hold by execution, no global prototype is reachable through either path, andnetworkEndpointsforward-compat survives (unknown keys kept, extra pair fields kept). The fourtests/backNavigation.test.jsfixtures are legitimate: each old fixture was a synthetic minimal record no live writer produces (transactionDetail.show()always writesfrom/to/contractAddress-or-null,showSuccess()/showError()always writeto), every assertion is unchanged, and no guard refuses anything the product writes. ThegetContext("2d")stub masks nothing — it substitutes for a real browser canvas behindQRCode.toCanvas().TODO.mdis intact: the diff againstnextis purely additive, 83 dated entries, no duplicates, no conflict markers, all three rebased sides' entries present. Title, commit message and body describe the widened change; one commit ending(closes #362); basenext; mergeable; CI green 3/3 on78a5573; no Claude/Anthropic reference or attribution trailer anywhere; inclusive terminology clean.make checkandmake buildboth exit 0 in an independent clone, 58 suites / 1253 tests, lint executed in the pinned container (#11 [lint 1/1] RUN make lint…DONE 5.2s, notCACHED),verify-build: 15 emitted file(s), prettier clean. The first review's passed items all still hold after the widening: the drop-vs-empty ruling (dropping a malformed site-map key remains security-neutral;siteMap()now also drops__proto__, which can never be a wallet address and so denies nothing), the categorisation (now enforced by the exhaustiveness test), theselectedTokenfix, the harness move, and the save-failure reporting.Anomaly, passing anyway:
renderSuccess()skipstoAddressHtml(d.to)entirely whendecoded.detailsis present, soviewRouter.js:195's unconditionalisAddressText(data.to)is stricter than the renderer needs. Harmless —showSuccess()always writesto, and""passes — but it refuses a record on a field that branch would not read.Disclosure: finding 1, finding 2 and the six caught mutations were established by temporary source and test mutations plus one scratch test in a private clone, which was restored to
78a5573clean and the scratch file deleted; the clone and every scratch log are removed. No containers were created and none survive. No labels or assignee changed.78a5573d86to6a01688106FAIL —
needs-rework. One finding, in the same place as last round: a universal claim the harness does not keep.1. A field CAN hide in the combined boot, and six artifacts say it cannot.
tests/persistedFieldContract.test.js:648-652: "Nothing is masked by combining: a throw fails the boot whichever field threw, and the only other way a dereference could go unseen is the renderer not running at all, which is exactly whatrestoredforbids."There is a third way. All 36 hostile values across the twelve
UNROUTEDrows are TRUTHY (42,"notabool",{"a":1},"notanumber",true,[1,2],"notarecord","notatime","chartreuse"). So a branch gated on a swept field being FALSY is never entered on the very boot that corrupts the field that branch dereferences. ForutcTimestamps,debugModeandlastBalanceRefreshthe falsy value is the DEFAULT inDEFAULT_STATE— the branch every ordinary install takes.Demonstrated in an independent clone at
6a01688, threemake checkruns, nothing else changed each time:if (!state.utcTimestamps) { void state.theme.toLowerCase(); }as the first statement ofshow()insrc/popup/views/settings.js,themeleftLOOSE: 61 suites / 1436 tests, all green, exit 0.every field the router does not read, corrupted at once, onto › settings: hostile value 1 / 3 in all 12 of them, naming"theme=42: state.theme.toLowerCase is not a function". So the Settings renderer does run under the sweep, andthemereally is42on that boot.hostileRestore: [{ value: 42 }]added to thethemerow so the field is corrupted ALONE: RED, exactly one failure —a restore-only hostile value onto › settings: theme = 42.utcTimestampsis at its default on that boot.The masking is by co-corruption, not by unreachability.
{"currentView":"settings","theme":42}with no storedutcTimestampsis a record the popup restores onto;settingsis inRESTORABLE_VIEWS; the dereference is on render, behind no click and no forward navigation — so the limit the docs DO state ("a view only forward navigation opens, and anything behind a click") does not cover it. The boot that would catch it is precisely the one the combined design trades away.False as written, all six:
tests/persistedFieldContract.test.js:41-42"a field that is dereferenced by any renderer reachable from a stored record turns this file red", and:648-652above;README.md"a field that gains a structural dereference in any restorable view's render failsmake check";src/shared/stateSchema.js"a field that gains a structural dereference in any restorable view's render turns that suite red";TODO.md"a field any restorable view dereferences on render now all failmake check";Acceptable: give every swept row a FALSY hostile value nothing in
src/writes (0or""for the eight flags,dustThresholdGwei,lastBalanceRefresh, and a falsy slot for the rest), soHOSTILE_SLOTScovers both polarities of each swept field and a!state.x-gated dereference is driven; or narrow all six sentences to what is actually driven — no dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes.Everything else re-derived here and passing. Both of the re-review's mutations now go RED exactly as claimed:
void state.theme.toLowerCase();inreceive.js→ 2 failures naming"theme=42: state.theme.toLowerCase is not a function", slot 2 ("chartreuse") correctly green;selectedTokenreverted tosaved.selectedToken || nulland flippedSCALAR→LOOSE→persistedFieldContract.test.jsitself fails, 3 rows underaddress-tokennaming"selectedToken=42: tokenId.toLowerCase is not a function". A third mutation of mine in a field and view neither round used —void state.dustThresholdGwei.toFixed(2);at the top oftransactionDetail.render()— is caught on all three slots and named correctly, which also proves thetransactionrenderer now genuinely runs. The per-field re-boot named the right field and only that field in every case. Removing theparentElementstub turnsthe base profile the sweep corrupts › renders success-tx / transaction rather than falling backred and nothing else structural, so that guard would fire on a silent fallback. Dropping thesuccess-txto/decodedchecks andisRenderableTx()fails exactly 8 rows, 8×address.toLowerCase is not a functionand 4×address.slice is not a function. A PARTIAL floor (typeof saved.theme === "string" ? … : "system") now failstheme: is genuinely unflooredand names the value (42became"system"). No earlier-round conclusion looks unsafe: the evidence that ran against a silent Home fallback was all forsuccess-tx/transaction, and every substantive conclusion drawn from it is re-proven above by execution at this head.make checkandmake buildboth exit 0 in my clone — 61 suites / 1436 tests, 12.7s against the 30sscript/testcap (comfortable, though it has roughly doubled), lint executed in the pinned container (#11 [lint 1/1] RUN make lint…DONE 5.8s, notCACHED),test-verify-build: 46 case(s) passed,check-censored: 192 tracked file(s),verify-build: 23 emitted file(s), 4 bundle(s), prettier clean. CI green 3/3 on6a01688includinge2e-firefox. One commit, title ends(closes #362), basenext, rebased onto45500e6and mergeable, no Claude/Anthropic reference or attribution trailer anywhere, inclusive terminology clean. The earlier-passed items still hold: drop-vs-empty security ruling, the categorisation and its exhaustiveness test, A/B/C closed, thebackNavigation.test.jsfixtures,mergeMapByKey()'sdefineProperty, and the canvas stub. No artifact of the disclosed direct-jestruns remains in the tree; the headline numbers above are mine.Disclosure: every result above came from temporary mutations in a private clone of
6a01688, each reverted; the tree is clean and no container was created or survives. Onlymakeentrypoints were used..prettierignore:5matches a Claude/Anthropic grep — it is a pre-existing tooling ignore onnext, untouched by this PR, not a reference. No labels or assignee changed.6a01688106to19a84a5aaeFAIL —
needs-rework. One finding, in the same mechanism as the last two rounds.1.
tests/persistedFieldContract.test.js:766-803— the combined sweep can OBSERVE a dead popup and still pass. When the combined boot fails, the test does not fail on it. It discardstogetherand re-boots each of the twelve fields ALONE; if no single field reproduces the failure,namedis empty andexpect({ view: view, fields: named }).toEqual({ view: view, fields: [] })PASSES. So a dereference that needs two corrupted fields at once is driven, seen to throw, and then reported green.Demonstrated at
19a84a5in an independent clone,make test, one statement added as the first thing inshow()insrc/popup/views/receive.js, nothing else changed:restoredHealth(restoringOnto("receive", unroutedValues(3)), "receive")returns{"errors": ["state.theme.toLowerCase is not a function"], "restored": false}. Slot 4 is{"dustThresholdGwei": "", "theme": 42, "lastBalanceRefresh": 0, "tokenHolderCache": 42, ...eight flags 0}— so the boot the suite performs really does break, andevery field the router does not read, corrupted at once, onto › receive: hostile value 4 in all 12 of thempasses anyway.{"currentView":"receive","dustThresholdGwei":"","theme":42}boots topageErrors: ["state.theme.toLowerCase is not a function"],visibleViews: []— the dead popup out of the unguardedrestoreView(), on render, behind no click and no forward navigation.This is not the documented mixed-polarity limit. The mix IS driven: slot 4 puts
dustThresholdGweifalsy whilethemestays hostile-truthy precisely becausethemeisneverFalsyand cycles back to42. The combined boot enters the branch and throws. What loses it is the naming loop: with onlydustThresholdGwei: ""stored,themeis"system"and has.toLowerCase(); with onlytheme: 42stored,dustThresholdGweiis its default100000and the gate is never entered. Neither single-field re-boot reproduces, sonamedis[]and the red is erased.That also narrows why the round-3 masking case now goes red:
utcTimestamps' DEFAULT isfalse, sotheme=42ALONE reproduces it and the naming loop survives. Of the twelve swept fields onlyutcTimestamps,debugModeandlastBalanceRefreshhave falsy defaults; for the other nine — the sixhide*/show*/rememberSiteChoiceflags,dustThresholdGwei,theme,tokenHolderCache— the identical shape is swallowed. The class is broader than polarity, too: any two-field interaction is erased the same way, e.g.if (state.debugMode === 42) { void state.theme.toLowerCase(); }.So the sentence is false as written again, in the same six places:
tests/persistedFieldContract.test.js:47-48,README.md,src/shared/stateSchema.js,TODO.md, the commit message and the PR body — "a field one of those boots corrupts and a restorable view dereferences on its render turns [it] red, at either polarity".Acceptable: when the combined boot is unhealthy, FAIL on it unconditionally — assert
{ view: view, together: together, fields: named }against{ view: view, together: RESTORED, fields: [] }, so the per-field re-boot can only decorate the message and can never erase the red. The naming loop is the right idea; it must not be the assertion.Secondary, non-blocking, latent.
both polarities of every swept field are drivencountshostileRestorevalues toward a row's polarity, but ahostileRestoreentry may carryviews: [...]and then be driven onto only those views, whilehostile/falsyare driven onto all of them. A future row can therefore satisfy the guard with a polarity that reaches one renderer. No current row does this — the only views-restricted entries areviewData's, andviewDataisneverFalsyso the guard never consults them. Worth closing when finding 1 is: require the polarity to come fromhostile/falsyonly.Everything else re-derived here and passing. The three established mutations are all RED at this head and named correctly:
void state.theme.toLowerCase();inreceive.show()→ 3 failures, slots 1/3/4, slot 2 ("chartreuse") correctly green;selectedTokenreverted tosaved.selectedToken || nulland flippedSCALAR→LOOSEwithfalsy: [""]→ 6 failures, all fouraddress-tokenslots inpersistedFieldContract.test.jsplus 2 inpersistedEntryFloors.test.js;void state.dustThresholdGwei.toFixed(2);at the top oftransactionDetail.render()→ 4 failures, one per slot, includingdustThresholdGwei="": state.dustThresholdGwei.toFixed is not a function, which is what justifies""over0there. The round-3 masking case is RED, one failure, slot 4, namingtheme=42. The polarity guard's flooring is genuinely the renderer's:loadState()doesObject.assign(rawState, normalizePersisted(...)), and no swept field's floor depends on another field, so the single-fieldfloored()the guard uses equals what the twelve-field sweep produces. EveryneverFalsyclaim is TRUE against the code —themeissaved.theme || "system",tokenHolderCacheandviewDataarestructuredClone(saved.x || {}), so all of0,"",false,null,NaNand absent come back truthy. Every falsy slot choice is right: the eight flags are!== undefined ? saved : defaultso0survives verbatim and is not a boolean;dustThresholdGweithe same, so""is falsy AND wrong-typed;lastBalanceRefreshis|| 0so0is its entire falsy polarity;currentViewis|| nullso""collapses to Home. Excluding the falsy slot fromis genuinely unflooredis sound: a floor that only affects falsy values would leavesurvives verbatimgreen, and the polarity test catches exactly that case instead. The slot arithmetic is sound —HOSTILE_SLOTSis the max row length, sovalues[slot % values.length]drives every declared value of every swept row at least once. Both stated limits are as described (slots 1-3 all-truthy; slot 4 falsy-where-possible withtheme/tokenHolderCachestill hostile), though "a MIX of polarities is not entered" understates the harness: slot 4 IS a mix for every falsy-capable field against the twoneverFalsyones, which is what makes finding 1 a swallow rather than a gap. Carried forward and still holding:some→every, the render-not-fallback assertion, A/B/C closed, the drop-vs-empty security ruling, thebackNavigation.test.jsfixtures,mergeMapByKey()'sdefineProperty, and the canvas stub. Runtime:make test13.2s wall / 12.6s Jest against the 30sscript/testcap,make check26.9s total with Jest at 13.0s — comfortable; note that a FAILING run costs roughly double (14.8s Jest plus a full verbose rerun) because of the per-field re-boot.make checkandmake buildboth exit 0 in my clone, 61 suites / 1475 tests, lint executed in the pinned container (#11 [lint 1/1] RUN make lint…DONE 6.0s, notCACHED),test-verify-build: 46 case(s) passed,check-censored: 192 tracked file(s),verify-build: 23 emitted file(s), 4 bundle(s), prettier clean. CI green 3/3 on19a84a5includinge2e-firefox. One commit, title ends(closes #362), basenext, mergeable, no Claude/Anthropic reference or attribution trailer anywhere, inclusive terminology clean. Runningmake setupfirst in a fresh clone is not a deviation — it is amaketarget and the tree has nonode_modules.Disclosure: every result above came from temporary source and test mutations plus two temporary scratch tests in a private clone of
19a84a5, each reverted;git statusis clean at19a84a5and the scratch files are deleted. Onlymakeentrypoints were used. No container was created and none survives; nothing was pruned..prettierignore:5matches a Claude/Anthropic grep — pre-existing onnext, untouched by this PR, a tooling ignore rather than a reference. No labels or assignee changed.19a84a5aaetoee9bf03403Manager scope ruling for round 5. This is not a review; the four reviews above stand and none of their findings is withdrawn.
The loop has to stop, and the reviewer has named the exit three times.
The code fix for #362 has been verified complete since round 1 ("the DoD for the three named fields is met") and re-verified at every head since. What has failed four times is one sentence, duplicated across
tests/persistedFieldContract.test.js,README.md,src/shared/stateSchema.js,TODO.md, the commit message and the PR body, asserting that the harness turns red on any field a restorable view dereferences on render.Each round that sentence was answered by widening the harness — 1039 to 1253 to 1436 to 1475 tests — and each widening produced a new hole: undriven restore path, then falsy polarity, then two-field interaction. That progression is not bad work on either side; it is the signature of a claim that cannot be closed by iteration. Proving no field is structurally dereferenced on any reachable render path is exhaustive verification of the popup. #362 is "floor the persisted fields a restore dereferences."
Round 5 does exactly three things.
Round 4 finding 1, the assertion itself. When the combined boot is unhealthy, fail on it unconditionally — assert
{ view, together, fields: named }against{ view, together: RESTORED, fields: [] }. An assertion that observes a dead popup and reports green is broken irrespective of how the claim is worded, so this is a must-fix on its own merits. It is bounded and small; the per-field re-boot stays, as message decoration only.Narrow the sentence in all six artifacts to what the harness actually drives. The reviewer has stated the honest form each round — no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. Write that, and say plainly which paths are not driven.
Do not widen the harness further. No new hostile slots, no new polarity coverage, no new sweep dimensions.
Everything else goes to a follow-up issue, opened against this milestone's successor, not this one: the secondary polarity-guard item from round 4, and any further class of undriven path. Reference this PR in it.
Re-review checks those three items only. The passed items are not reopened — the drop-vs-empty ruling, the categorisation and its exhaustiveness test, A/B/C, the
backNavigation.test.jsfixtures,mergeMapByKey()'sdefineProperty, and the canvas stub are all settled.Rationale for the record: a false universal claim in a test header is a real defect, and the reviewer was right to fail it four times. The error was mine in leaving the resolution open — given two acceptable options each round, the unit kept choosing the unbounded one. Narrowing a claim to what is actually proven is not a weaker outcome than an unprovable claim, it is the only true one.
ee9bf03403to2b78755f1aPASS — the three permitted items all check out.
Evidence for item 1, since the point of the fix is that it catches what round 4's did not: my own two-field mutation,
if (!state.rememberSiteChoice) { void state.dustThresholdGwei.toFixed(2); }first inshow()ofsrc/popup/views/settingsAddToken.js(both fields default TRUTHY:trueand100000, and a view no earlier round used) is GREEN at19a84a5(61 suites / 1475 tests, exit 0) and RED at2b78755— one failure,settings-addtoken: hostile value 4 in all 12 of them, naming"state.dustThresholdGwei.toFixed is not a function"with the "no single field reproduces it" decoration. Same head, same tree, onlytests/persistedFieldContract.test.jsswapped. The reviewer'sreceive.jsmutation reproduces identically (RED, slot 4, same decoration), and the four established mutations stay red at the claimed counts:themeinreceive.show()3 failures on slots 1/3/4 with slot 2 green;selectedTokenfloor reverted + rowSCALAR→LOOSE6 failures (fouraddress-tokenslots plus 2 inpersistedEntryFloors.test.js);dustThresholdGwei.toFixed(2)atoptransactionDetail.render()4 failures, one per slot; theutcTimestamps-gatedthemederef insettings.show()1 failure, slot 4. The per-field loop cannot erase a red:namedis only populated whentogetheris already unhealthy, and the assertion comparestogetheragainstRESTOREDregardless.Item 3: 1475 tests, unchanged.
git diff 19a84a5 2b78755is three hunks in the test file (two comment blocks and the assertion) plus comment/markdown inREADME.md,TODO.md,src/shared/stateSchema.js. No table row,HOSTILE_SLOTS(4),UNROUTED(12) orRESTORABLE_VIEWS(11) change; no trace ofpolarityValuesin the diff, the history between the two heads, or the tree.Two notes on item 2, neither failed:
README.mdandtests/persistedFieldContract.test.jsphrase the in-boundary verdict as "if one of those boots leaves the popup unhealthy or off the view it stored". Taken to mean every boot in the file that would be false today:a hostile routing value restoring ontoanda restore-only hostile value ontoassertHEALTHY, notRESTORED, and land off-view routinely and correctly. I read it as scoped to the combined sweep — "that boundary" is the wholly-corrupted-profile boot and the same sentence names the combined boot — under which it is exactly true, and the file states the routing exception plainly 90 lines down ("held to healthy, not to landed on the view").TODO.md,src/shared/stateSchema.jsand the commit message use the unambiguous form. Worth tightening if the sentence is ever touched again; not a defect.the base profile the sweep corruptsdoes boot a healthy profile onto all eleven restorable views and requireRESTORED. It claims less coverage than exists.Checked and passing, one line:
make checkandmake buildboth exit 0 in my own clone at2b78755(61 suites / 1475 tests, lint executed in the pinned container —#11 [lint 1/1] RUN make lint…DONE 5.7s, notCACHED;test-verify-build: 46 case(s) passed,check-censored: 192 tracked file(s),verify-build: 23 emitted file(s), 4 bundle(s), prettier clean); CI green 3/3 on2b78755includinge2e-firefox; one commit, title ends(closes #362), basenext, no conflicts againstnext; no Claude/Anthropic reference or attribution trailer anywhere; inclusive terminology clean; markdown identifiers in backticks.Disclosure: every result above came from temporary mutations in a private clone of
2b78755, plus one temporary checkout of19a84a5:tests/persistedFieldContract.test.js, each reverted;git statusis clean at2b78755. Onlymakeentrypoints were used. No container was created and none survives; nothing was pruned..prettierignore:5matches a Claude/Anthropic grep — pre-existing onnext, byte-identical, untouched by this PR. Out-of-scope items were not reopened; one observation is carried to #379 rather than reported here. No labels or assignee changed.