fix: a cold worker prepares a dApp transaction for the wrong chain, so every non-mainnet dApp send is refused #320

Open
opened 2026-08-20 12:49:01 +02:00 by clawbot · 0 comments
Collaborator

Found while implementing #317. Third instance of the unloaded-singleton root cause, and the first that breaks a primary use case outright rather than losing data.

handleSendTransaction calls getProvider(await getRpcUrl()) at src/background/index.js:934 with no network name. src/shared/balances.js:26 then falls back to currentNetwork().id — the module-level singleton the MV3 worker never populates. On a cold worker with the user on Sepolia, the RPC url is correctly Sepolia's but the static network hint is mainnet, so ethers' populateTransaction() fixes chainId at 0x1.

Consequence

The transaction is prepared for the wrong chain. It is not broadcast on the wrong chain — verifySignedTx compares the artifact against currentNetwork().chainId after the loadState() at src/background/index.js:1297, so the send is refused with "for a different network". The guard holds; the feature does not.

So a user on any non-mainnet network who initiates a dApp transaction against a cold worker cannot send at all: the approval is prepared wrong and then refused by the wallet's own verification, with an error that describes the symptom rather than the cause. Precondition: the worker is idle (roughly 30 seconds), which is the normal state.

Note on the pattern

This is the third defect from one cause — the background reading or writing the module-level state singleton it never loads. The other two: #316 (write side, destroyed every wallet, fixed) and #317 (read side, eth_chainId, in flight). Each has been fixed one call site at a time, and each fix has been followed by another site turning up.

Fixing this one the same way is acceptable and is what this issue asks for. But the recurrence is the finding: the singleton is reachable from the background at all, and nothing makes an unloaded read fail loudly. Whoever takes #304 or #311 should consider making the background physically unable to read an unpopulated singleton — a guard that throws on access before load, rather than silently serving DEFAULT_STATE. That would have caught all four at once.

Definition of done

  • The provider is constructed with the loaded network, not a singleton fallback: pass the network name explicitly at src/background/index.js:934, or make getProvider require it.
  • Every other getProvider() call site reachable from the background is audited for the same missing hint and named in the PR body.
  • Test: a cold worker with a Sepolia-stored profile prepares a Sepolia-chainId approval. Done when a cold eth_sendTransaction on Sepolia reaches the approval screen instead of being refused at verify.
  • The test fails against current head — state the mutation and its observed result.
  • make check green.
Found while implementing https://git.eeqj.de/sneak/AutistMask/issues/317. Third instance of the unloaded-singleton root cause, and the first that breaks a primary use case outright rather than losing data. `handleSendTransaction` calls `getProvider(await getRpcUrl())` at `src/background/index.js:934` with no network name. `src/shared/balances.js:26` then falls back to `currentNetwork().id` — the module-level singleton the MV3 worker never populates. On a cold worker with the user on Sepolia, the RPC url is correctly Sepolia's but the static network hint is mainnet, so ethers' `populateTransaction()` fixes `chainId` at `0x1`. ## Consequence The transaction is prepared for the wrong chain. It is **not** broadcast on the wrong chain — `verifySignedTx` compares the artifact against `currentNetwork().chainId` after the `loadState()` at `src/background/index.js:1297`, so the send is refused with "for a different network". The guard holds; the feature does not. So a user on any non-mainnet network who initiates a dApp transaction against a cold worker cannot send at all: the approval is prepared wrong and then refused by the wallet's own verification, with an error that describes the symptom rather than the cause. Precondition: the worker is idle (roughly 30 seconds), which is the normal state. ## Note on the pattern This is the third defect from one cause — the background reading or writing the module-level `state` singleton it never loads. The other two: https://git.eeqj.de/sneak/AutistMask/issues/316 (write side, destroyed every wallet, fixed) and https://git.eeqj.de/sneak/AutistMask/issues/317 (read side, `eth_chainId`, in flight). Each has been fixed one call site at a time, and each fix has been followed by another site turning up. Fixing this one the same way is acceptable and is what this issue asks for. But the recurrence is the finding: the singleton is reachable from the background at all, and nothing makes an unloaded read fail loudly. Whoever takes https://git.eeqj.de/sneak/AutistMask/issues/304 or https://git.eeqj.de/sneak/AutistMask/issues/311 should consider making the background physically unable to read an unpopulated singleton — a guard that throws on access before load, rather than silently serving `DEFAULT_STATE`. That would have caught all four at once. ## Definition of done - [ ] The provider is constructed with the loaded network, not a singleton fallback: pass the network name explicitly at `src/background/index.js:934`, or make `getProvider` require it. - [ ] Every other `getProvider()` call site reachable from the background is audited for the same missing hint and named in the PR body. - [ ] Test: a cold worker with a Sepolia-stored profile prepares a Sepolia-`chainId` approval. Done when a cold `eth_sendTransaction` on Sepolia reaches the approval screen instead of being refused at verify. - [ ] The test fails against current head — state the mutation and its observed result. - [ ] `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-20 12:49:01 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#320