fix: answer eth_chainId and net_version from loaded state (closes #317) #319
Reference in New Issue
Block a user
Delete Branch "issue-317-cold-worker-chainid"
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 #317.
What changed
eth_chainIdandnet_versionanswered fromcurrentNetwork(), which reads the module-levelstatesingleton that nothing populates at module scope. A service worker revived by the page's own message heldDEFAULT_STATEand reported mainnet0x1/1to a page whose user was on Sepolia. Neither method is gated on a connection, so any page got the stale answer.Both now answer from
getState():getState()is the per-call storage read that returns a detached object, and is what every other read handler in this file already uses (eth_accounts,wallet_getPermissions,getRpcUrl).networkById(undefined)falls back to mainnet, which is the answer a profile with no storednetworkIdalready got.networkByIdadded to the../shared/networksimport.Why not
await loadState()(the previous revision of this PR)Loading the singleton fixes the stale answer and introduces a worse defect on the same path, which the review caught.
loadState()replaces every field of the singleton,state.walletsincluded, with freshly deserialized objects.backgroundRefresh()handsstate.walletstorefreshBalances(), which mutates those address objects in place across a network round trip (src/shared/balances.js:121-:143), and only then setsstate.lastBalanceRefresh = nowand saves. AloadState()landing inside that round trip detaches the objects being mutated, sosaveState()persists the pre-refresh balances while still stampinglastBalanceRefresh— and theRECENT_BALANCE_REFRESH_MSguard then suppresses the redo for half the alarm period.These two methods are reachable by any page, and
src/content/inpage.js:244sendseth_chainIdon provider init for every page load, so an ordinary page load overlapping the refresh window would drop that refresh and a polling page could keep any refresh from ever persisting.getState()reads storage once per call and mutates nothing shared, so there is no re-entrancy to have.Read-side audit of the background
Every background read of
currentNetwork()or thestatesingleton, at the line numbers of this revision:eth_chainId/net_version(:686)wallet_switchEthereumChain(:694, reads at:724):721(#316):1318currentNetwork(),:1391state.rpcUrl):1317)backgroundRefresh(:1082, readsstateat:1086-:1092):1083):593) / denial (:607):592/:606)eth_accounts,wallet_getPermissions,handleConnectionRequest,handleSendTransaction's gate,getRpcUrlgetState()reads storage on every callOne stale read found and not fixed here, because it is a different handler rather than the same one-line shape:
handleSendTransactioncallsgetProvider(await getRpcUrl())(src/background/index.js:954) with no network name, andsrc/shared/balances.js:26then falls back tocurrentNetwork().idon the same unloaded singleton for ethers' static network hint. On a cold worker whose user is on Sepolia the rpc url is the stored (Sepolia) one but the static hint is mainnet, sopopulateTransaction()fixeschainIdat0x1and the approval is prepared for the wrong chain. It does not send on the wrong chain: the signed artifact is verified againstcurrentNetwork().chainIdafterloadState()at:1317, so the send is refused as "for a different network". Naming it rather than filing it, per the issue's instruction.Test
tests/coldWorkerChainId.test.js: the real state module behind a write-retaining storage stub, and noloadState()from the test — the handler has to answer from storage on its own.The stub now structured-clones in both directions, as the real
chrome.storage.localdoes. The previous revision'sgethanded back the live stored object, which aliases it into whatever read it — that makes an in-place mutation of a detached copy look as though it reached storage, and hides this whole class of defect. Measured, not asserted: with the aliasinggetrestored, the full suite passes 794/794 against theloadState()version, mid-refresh case included. It also madeexpect(bg.persisted()).toEqual(storedProfile("sepolia"))compare an aliased object with itself; over the cloning stub that assertion now genuinely checks the store was not mutated in place.Cases:
eth_chainIdandnet_versionon a profile stored on Sepolia; an origin that never connected; a mainnet-stored profile as the control; that answering a read persists nothing; and new — a chain read arriving during an in-flightbackgroundRefresh(), where the refresh's stub parks on a gate, the unconnected origin'seth_chainIdis answered, the gate releases, and the refreshed balance is asserted to be what gets persisted.Verification
Failing first — two mutations, each with the rest of the tree untouched, both reverted with the editor afterwards:
next's behaviour): 3 failed / 791 passed — exactlyeth_chainId answers the stored chain,net_version answers the stored chainandanswers the stored chain to an origin that never connected, each answering0x1/1instead of0xaa36a7/11155111. The mid-refresh case passes under this one, correctly: nothing clobbers the singleton.getState()/networkByIdreplaced withawait loadState()+currentNetwork(), i.e. the revision the review failed: 1 failed / 793 passed — onlya chain read arriving mid-refresh does not discard the refresh, reportingExpected: "1.5"/Received: "0". The five older cases all pass under it, which is why they alone would have shipped this.make checkgreen on the committed tree, rebased onnextat50078b3:Lint ran in the pinned container and executed rather than hitting cache —
#11 [lint 1/1] RUN make lintrunningeslint . && prettier --check .,All matched files use Prettier code style!,DONE 5.0s, notCACHED.make fmtrun and its result is in the commit.docker ps -aempty; no image tag produced and no prune of any kind.Not in scope
The systemic version of this — the background abandoning the singleton entirely — is #324 and is deliberately untouched here. This PR only stops these two handlers from being the ones that trip it.
FAIL — needs-rework. One finding.
src/background/index.js:674—await loadState()on a page-callable path clobbers the singleton under an in-flightbackgroundRefresh(), discarding the balance refresh while stamping it as done.loadState()does not merely read; it overwrites every field of the module-level singleton, includingstate.wallets, with freshly deserialized objects from storage.backgroundRefresh()(:1071) loads, handsstate.walletstorefreshBalances(), which mutates those wallet/address objects in place across a long network round trip (src/shared/balances.js:121-:143), and only then doesstate.lastBalanceRefresh = now; await saveState(). AloadState()that lands inside that round trip replacesstate.walletswith new objects, so the refreshed balances are written to detached ones and the followingsaveState()persists the pre-refresh values — while still stampinglastBalanceRefresh, so theRECENT_BALANCE_REFRESH_MSguard (:1074) suppresses a redo for half the alarm period.Before this commit nothing a page could send reached
loadState(); the only callers were the user-driven chain switch and the transaction broadcast. This change makes it reachable frometh_chainId/net_version, which are not gated on a connection and whichsrc/content/inpage.js:244sends on provider init for every page that loads. So an ordinary page load overlapping the refresh window silently drops that refresh, and a page pollingeth_chainIdcan keep every background refresh from ever persisting.Reproduced, not inferred. Scratch jest probe (deleted, tree left clean): alarm handler fires
backgroundRefresh,refreshBalancessets the address balance to1.5and parks on a gate, an unconnected origin sendseth_chainId, gate releases.726b692: persisted balance"0",lastBalanceRefreshstamped.src/background/index.jsreverted tonext: persisted balance"1.5".Note the storage stub must structured-clone on
get, as the realchrome.storage.localdoes.tests/coldWorkerChainId.test.js's stub returns the live object (get: jest.fn(async () => ({ autistmask: store.autistmask }))), which aliases the stored objects into the singleton and hides this class of defect entirely — with the aliasing stub the probe reports"1.5"even at726b692. That also weakens theexpect(bg.persisted()).toEqual(storedProfile("sepolia"))assertion in the persists-nothing case, which is comparing an aliased object; thestorageSetassertion beside it is what actually carries that test.Acceptable: answer these two from
getState()rather than the singleton — the per-call storage read every other read handler in this file already uses, and which the PR body's own audit names as the idiom (eth_accounts,wallet_getPermissions,getRpcUrl).const s = await getState(); const net = networkById(s.networkId);thennet.chainId/net.networkVersion. Same one storage read per call, no mutation of shared state, no re-entrancy;networkById(undefined)already falls back to mainnet for a profile with no storednetworkId, matching today's default.networkByIdneeds adding to the../shared/networksimport at:6. Keep the existing three cases and add one that a chain read arriving during an in-flightbackgroundRefresh()does not discard it — over a cloning storage stub.Everything else verified and passing: definition of done, both answer shapes (
0xaa36a7hex quantity /"11155111"decimal string), no path around the load, the audit enumeration re-derived independently (including transitively —src/shared/prices.jsandsrc/shared/etherscanLabels.jssingleton reads are popup-only,src/shared/chainSwitch.js:17is under the switch handler's load), failing-first reproduced exactly (3 failed / 775 passed, the three named cases), dropping only theawaitstill fails those same three so the test is not timing-dependent, the persists-nothing control is non-vacuous (addingawait saveState()to the handler fails exactly it), CI green 3/3 on726b692, fast-forward onto currentnext, single commit ending(closes #317)withTODO.mdin it, authoredclawbot, no attribution trailers,make checkre-run here with lint executing uncached in the pinned container (#11 [lint 1/1] RUN make lint ... DONE 4.5s, eslint + prettier clean) and 778/778 tests.726b69216ato5c4a671d4aReworked at
5c4a671, rebased ontonextat50078b3. Both halves of the finding accepted; nothing rebutted.1.
await loadState()on a page-callable path clobbers the singleton under an in-flightbackgroundRefresh(). Fixed as specified: both methods answer fromgetState(),networkByIdadded to the../shared/networksimport at:6.Not defended. The finding was right about the mechanism and right that this was newly reachable — before the commit nothing a page sent reached
loadState(), andsrc/content/inpage.js:244makeseth_chainIdtraffic every page generates, so this was not a narrow race.2. The aliasing storage stub. Fixed, and it was load-bearing exactly as stated.
getandsetbothstructuredClonenow. Measured rather than assumed: restoring only the aliasinggetwhile leaving theloadState()version of the handler in place gives 794/794 passing, mid-refresh case included — the stub alone was enough to hide the defect. Over the cloning stub,expect(bg.persisted()).toEqual(storedProfile("sepolia"))in the persists-nothing case is no longer comparing an object with itself.3. New case.
a chain read arriving mid-refresh does not discard the refresh: the alarm handler is captured fromregisterAlarmHandlersand fired, therefreshBalancesstub parks on a gate and then mutateswallets[0].addresses[0].balancein place on release (the orderingsrc/shared/balances.jshas — the assignment happens in the round trip's.then()), an unconnected origin'seth_chainIdis answered while the gate is held, and the persisted balance is asserted to be the refreshed one.Failing-first, two mutations, each with the rest of the tree untouched and each reverted with the editor:
next's behaviour)0x1/1for0xaa36a7/11155111. The mid-refresh case passes here, correctly: nothing clobbers the singleton.getState()/networkByIdreplaced byawait loadState()+currentNetwork(), i.e. the failed revisionExpected: "1.5"/Received: "0", reproducing the numbers in the finding.The second row is the point: the five pre-existing cases all pass against the defective handler, so they alone would have shipped it.
Preserved: both answer shapes (
eth_chainIda hex quantity,net_versiona decimal string), the read-side audit in the PR body — re-derived against this revision's line numbers, since the import change shifted them — and all five existing cases including the mainnet control and the persists-nothing control.Not touched: the systemic fix. #324 is the right home for it and this PR does not anticipate it.
make checkgreen on the committed tree: 37 suites / 794 tests,test-verify-build18 cases,check-censored145 files. Lint ran in the pinned container and executed rather than hitting cache —#11 [lint 1/1] RUN make lint,eslint . && prettier --check .,All matched files use Prettier code style!,DONE 5.0s. Still one commit, title unchanged,docker ps -aempty, no prune.PASS.
Independently re-verified at
5c4a671in a fresh clone: all three failing-first claims reproduce exactly — singleton read 3 failed / 791 passed (the three named cases);await loadState()+currentNetwork()1 failed / 793 passed withExpected: "1.5"/Received: "0"; and restoring only the aliasinggetover that defective handler gives 794/794, so the stub alone was indeed sufficient to hide it. Additional probe: movingsaveState()ahead ofrefreshBalances()inbackgroundRefresh()fails the mid-refresh case and nothing else, so its assertion is genuinely pinned to the persist ordering rather than to a scripted sequence.make checkgreen here with lint executing uncached in the pinned container (#11 [lint 1/1] RUN make lint...DONE 4.7s, prettier clean), 37 suites / 794 tests; CI 3/3 green on the head; fast-forwards ontonextat50078b3; single commit ending(closes #317), authored and committedclawbot, no attribution trailers; both TODO entries intact after the rebase; answer shapes preserved.Judgement call, disclosed rather than filed: the commit subject says "from loaded state" while the fix deliberately does not load the singleton. Left alone — it is the issue's own definition-of-done wording and the body states the distinction explicitly.