Skip to content

fix(wallet): purge stale account managers when the device seed changes underneath them#239

Merged
BitHighlander merged 2 commits into
developfrom
fix/stale-wallet-seed-purge
Jun 11, 2026
Merged

fix(wallet): purge stale account managers when the device seed changes underneath them#239
BitHighlander merged 2 commits into
developfrom
fix/stale-wallet-seed-purge

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

Summary

Recurring customer bug: the UI shows one wallet's addresses/balances while the device OLED shows another's (field report: UI 0x4C1922…, device 0x27de622c…; BTC balance missing after removing passphrase + reconnect).

The in-memory account managers (btcAccounts / evmAddresses) are deliberately kept across disconnect for watch-only and only re-derive when empty. The resets that should empty them infer seed changes from events, and every trigger has a blind spot:

Trigger Blind spot
needs_passphrase reset Skipped on reconnect with a pre-cached passphrase (device goes straight to ready)
Device-switch reset Skipped when deviceId is unchanged
seed-changed Skipped on hidden→standard transitions — hidden wallets never persist seed_eth_<id> (privacy), so the stored identity is the standard wallet's and matches once the device returns to it
Passphrase toggle applySettings+clearSession resets the engine's fingerprint/identity but never the managers

Fix: check the result, not the cause

The seed-identity address (ETH m/44'/60'/0'/0/0) is bit-for-bit evmAddressPath(0), so the EVM manager's index-0 address must equal the device-derived identity. One invariant covers every cause, including future ones. Two legs:

  • getBalances derives the identity fresh from the device (new RAM-only engine.deriveSeedIdentity()) on every fetch and reconciles before the init guards — every balance fetch is now self-verifying against the device.
  • state-change(ready) reconciles against engine.currentSeedEthAddress once the session is classified (the reconnect probe's confirmed-hidden path now re-emits state-change so the guard runs there too).

On purge: reset both managers (init guards re-derive), push empty account sets, wipe the deviceId-scoped DB cache (standard sessions only — a stale fetch classified as standard may have already persisted the wrong wallet's rows; hidden-session rows are never touched), and notify the frontend via new wallet-data-purged message. Dashboard clears displayed balances immediately and force-refreshes — "removing passphrase now triggers a rescan".

Never purges on uncertainty: unknown identity or uninitialized managers no-op, so cold start and watch-only keep their data.

evmAddressPath moved to shared/chains.ts (pure, test-importable — src/bun pulls ./db → electrobun import side effects that break bun test) and re-exported from evm-addresses.ts for existing importers.

Test plan

  • __tests__/seed-reconcile.test.ts pins: the path equivalence the invariant rests on, never-purge-on-uncertainty, both transition directions, the exact field-report scenario, and watch-only continuity on same-seed reconnect
  • bun test: 365 pass / 2 fail — both failures pre-existing & environmental (live vault at :1646, live Solana RPC), identical on baseline
  • Differential tsc: no new genuine errors (only the same rpc.send false-positive class every existing call produces outside the real build)
  • bun build (bun side) + vite build (frontend) both clean
  • Live device: enable passphrase → enter hidden wallet → disable passphrase → verify purge log, addresses match OLED, balances rescan
  • Live device: unplug/replug with cached passphrase → verify no wrongful purge (watch-only data retained until identity known)

…s underneath them

Recurring customer bug: the UI showed one wallet's addresses/balances while
the device OLED showed another's (field report: UI 0x4C1922..., device
0x27de622c...). The in-memory managers (btcAccounts/evmAddresses) are kept
across disconnect for watch-only and only re-derive when empty; the resets
that should empty them are event-INFERRED and every trigger has a blind spot:

- needs_passphrase reset: skipped on reconnect with a pre-cached passphrase
  (device goes straight to ready)
- device-switch reset: skipped when deviceId is unchanged
- seed-changed: skipped on hidden->standard transitions — hidden wallets never
  persist seed_eth_<id> (privacy), so the stored identity is the standard
  wallet's and MATCHES once the device returns to it; no event fires
- passphrase toggle: applySettings+clearSession resets the engine's
  fingerprint/identity but never the managers

Fix checks the RESULT instead of the cause. The seed-identity address (ETH
m/44'/60'/0'/0/0) is bit-for-bit evmAddressPath(0), so the EVM manager's
index-0 address must equal the device-derived identity. One invariant covers
every cause, including future ones. Two legs:

- getBalances: derive the identity FRESH from the device (new RAM-only
  engine.deriveSeedIdentity) on every fetch and reconcile before the init
  guards — every balance fetch is now self-verifying against the device
- state-change(ready): reconcile against engine.currentSeedEthAddress once
  checkSeedIdentity / hidden-scope derive / reconnect probe classify the
  session (probe's confirmed-hidden path now re-emits state-change so the
  guard actually runs there)

On purge: reset both managers (init guards re-derive), push empty account
sets, wipe the deviceId-scoped DB cache (standard sessions only — a stale
fetch classified as standard may have already persisted the WRONG wallet's
rows; hidden sessions never persist and their rows must not be touched), and
notify the frontend via new 'wallet-data-purged' message. Dashboard clears
the displayed balances immediately and force-refreshes (in-flight fetches
self-heal — the purge runs at their start).

Never purges on uncertainty: unknown identity or uninitialized managers no-op,
so cold start and watch-only keep their data.

evmAddressPath moved to shared/chains.ts (pure, test-importable — src/bun
pulls ./db -> electrobun import side effects that break bun test) and
re-exported from evm-addresses for existing importers.

Tests: __tests__/seed-reconcile.test.ts pins the path equivalence the
invariant rests on, the never-purge-on-uncertainty rule, both transition
directions, and the exact field-report scenario. Full suite 365 pass /
2 pre-existing environmental failures (live :1646 vault, live Solana RPC) —
identical on baseline.
…sumers

Review follow-up to the stale-wallet purge. Three gaps where stale account
managers could still be read before a full getBalances ran:

1. getBalance (single-chain) and buildTx read btcAccounts/evmAddresses with no
   reconciliation. getBalance backs AssetPage / tx-push / post-send refreshes;
   buildTx is the SIGNING path — a stale xpub/evmAddressIndex would build a tx
   against the wrong wallet's context. Added a shared ensureManagersForSeed()
   boundary (derive seed identity fresh from device → reconcile → purge if
   stale) and call it at every consumer: getBalances, getBalance, getBtcAccounts,
   getEvmAddresses, and buildTx. buildTx THROWS on purge so the half-prepared
   send is abandoned and the user re-initiates against refreshed balances.

2. The DB-cleanup on purge was gated on !isPassphraseWallet, so during the
   conservative cached-passphrase reconnect window it was skipped — and when the
   probe later reclassified to standard the managers already matched, so no
   second purge ever cleared the stale rows. Clearing only REMOVES rows (never
   writes hidden data), so it's privacy-safe: run it unconditionally.

3. reconcileSeedManagers only compared the EVM index-0 address, so a BTC-only
   initialized stale state (getBtcAccounts initializes BTC independently) had no
   proof to compare and survived. Added a seed-owner STAMP (managersSeedOwner):
   the seed the managers were derived under, set after every init and checked
   alongside the EVM index-0 proof. Either mismatch purges. All reset paths now
   go through resetSeedManagers() so the stamp stays in lock-step.

The engine reconnect probe's confirmed-hidden branch already re-emits
state-change (prior commit) so the ready-handler leg re-runs there too.

Tests: seed-reconcile.test.ts adds the seed-owner-stamp (BTC-only) cases.
Project suite 344 pass / 2 pre-existing env failures (live :1646, live Solana
RPC). Differential tsc clean; bun + vite build clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant