Skip to content

fix: detect KeepKey hot-swap + drop stale cache on device change#49

Merged
BitHighlander merged 3 commits intodevelopfrom
fix/device-change-detection
Apr 24, 2026
Merged

fix: detect KeepKey hot-swap + drop stale cache on device change#49
BitHighlander merged 3 commits intodevelopfrom
fix/device-change-detection

Conversation

@BitHighlander
Copy link
Copy Markdown
Collaborator

Summary

The extension treated any reachable vault as a reachable device, with no validation that the device currently paired with the vault is the same one whose pubkeys we cached. Three failure modes became silent:

  1. Hot-swap on same vault — `checkKeepKey` kept `state.deviceConnected = true` and never re-probed, so addresses/balances pinned to the old device indefinitely.
  2. Extension reload with a new device — `init()` loaded the old pubkey cache without comparing deviceId against the freshly-probed one, so cached addresses survived even a full BEX reload.
  3. Vault auth rejection (stale apiKey after re-pair) — `fetchPubkeys` caught the error and silently fell back to cached pubkeys from the previous device, masking the re-pair requirement.

Fix (four narrow changes)

  • `wallet.init()` compares cached `deviceInfo.deviceId` to the freshly-probed one when a device is reachable; mismatch → clear storage before `fetchPubkeys` runs. (Case 2.)
  • `wallet.handleDeviceSwitch()` primitive clears in-memory pubkeys/paths, resets `deviceConnected`, and wipes `pubkeyStorage`. Keeps the SDK alive — only device-specific state is dropped.
  • `background/index.ts` `checkKeepKey` now runs a periodic (30s, throttled) `getFeatures` re-probe while `deviceConnected`. Compares before/after deviceId; on mismatch calls `handleDeviceSwitch` which clears balance cache, per-chain address caches (Solana/Tron/TON), and triggers a fresh pubkey + balance refresh. (Case 1.)
  • `fetchPubkeys` no longer silently falls back to cache on auth errors. Detects 401/"unauthorized"/"not paired" shapes, clears the stored apiKey, and throws a user-facing message for the sidebar to surface. Non-auth failures (network blip, device busy) still fall back to cache as view-only — that path was always correct. (Case 3.)

Recovery from device swap is now automatic within ~30s on steady state, immediate on extension reload.

Test plan

  • Plug device A, open side panel, note address. Without closing anything, physically swap to device B. Within 30s: `[Device swap detected. Purging caches and re-fetching.]` in service-worker console + side panel addresses update to B.
  • Plug device A, close everything, swap cable so B is plugged. Reload extension: `[Device changed: cache= → probed=. Invalidating cache.]` + side panel shows B's addresses immediately, not A's.
  • Re-pair the extension's apiKey on the vault side while the extension is running: signing action → `"Vault auth failed. Your KeepKey needs to be re-paired..."` toast, not silent stale cache.

Dependencies

None — sits cleanly on develop. Independent of PR #46, #47, #48.

🤖 Generated with Claude Code

BitHighlander and others added 3 commits April 23, 2026 18:31
The extension treated any reachable vault as a reachable device, with no
validation that the device currently paired with the vault is the same
one whose pubkeys we cached. That made three failure modes silent:

  1. Hot-swap on same vault — checkKeepKey kept state.deviceConnected=true
     and never re-probed, so addresses/balances pinned to the old device
     indefinitely.
  2. Extension reload with a new device — init() loaded the old pubkey
     cache without comparing deviceId against the freshly-probed one, so
     cached addresses survived even a full BEX reload.
  3. Vault auth rejection (stale apiKey after re-pair) — fetchPubkeys
     caught the error and silently fell back to cached pubkeys from the
     previous device, masking the re-pair requirement.

Four narrow changes:

- wallet.init() now compares cached deviceInfo.deviceId to the freshly-
  probed deviceId when a device is reachable. Mismatch → clear storage
  before fetchPubkeys runs. (Covers case 2.)

- wallet.handleDeviceSwitch() primitive clears in-memory pubkeys/paths,
  resets the deviceConnected flag, and wipes pubkeyStorage so the next
  fetch rebuilds from the new device. Keeps SDK alive — only the
  device-specific state is dropped.

- background/index.ts checkKeepKey now runs a periodic (30s, throttled)
  getFeatures re-probe while deviceConnected. Compares before/after
  deviceId; on mismatch calls handleDeviceSwitch which clears balance
  cache, per-chain address caches (Solana/Tron/TON), and triggers a
  fresh pubkey+balance refresh. (Covers case 1.)

- fetchPubkeys no longer silently falls back to cache on auth errors.
  Detects 401 / "unauthorized" / "not paired" shapes, clears the stored
  apiKey, and throws a user-facing message for the sidebar to surface.
  Non-auth failures (network blip, device busy) still fall back to cache
  as view-only — that path is correct. (Covers case 3.)

No user-visible UX change on the happy path. Recovery from device swap
is now automatic within ~30s on steady state, immediate on extension
reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…atch

handleDeviceSwitch cleared state.paths to [], but the caller
(background checkKeepKey) then calls wallet.refreshPubkeys() which
only probes + fetches — it does not repopulate paths. fetchPubkeys
maps state.paths into the batch sent to the device, so an empty
paths array meant an empty batch, meaning the hot-swap recovery
finished with zero pubkeys and zero balances instead of the new
device's view.

Reset to getDefaultPaths() on switch so refreshPubkeys has something
to query against.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
refreshPubkeys only re-runs the default-path batch via
wallet.getPublicKeys — that batch covers BTC / LTC / ETH / Cosmos /
etc., but *not* SOL / TRX / TON. Those three are derived dynamically
at onStart via prefetchSolanaPubkey / prefetchTronPubkey /
prefetchTonAddress (each calls its chain's `*GetAddress` method and
stashes the result in a per-chain cache + adds a pubkey to state).

After a hot-swap:
  - handleDeviceSwitch clears the per-chain caches (resetXState)
  - refreshPubkeys repopulates the default batch
  - but nothing re-runs the three prefetches
  → SOL/TRX/TON vanish from the network dropdown / asset list / balance
    fetch until the user reloads the extension or manually navigates to
    those chains, which racy-triggers the lazy getAddress in each handler.

Fan out all three prefetches in parallel via Promise.allSettled after
refreshPubkeys. Non-throwing by design, so if one chain's derivation
fails (minFirmware mismatch, etc.) the other two still recover.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@BitHighlander BitHighlander merged commit c08ec7b into develop Apr 24, 2026
3 of 4 checks passed
@BitHighlander BitHighlander deleted the fix/device-change-detection branch April 24, 2026 21:22
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