Skip to content

fix(bitbox): guard + self-heal empty wallet address (grey screen)#710

Merged
TaprootFreak merged 1 commit into
stagingfrom
fix/bitbox-empty-address-self-heal
Jun 9, 2026
Merged

fix(bitbox): guard + self-heal empty wallet address (grey screen)#710
TaprootFreak merged 1 commit into
stagingfrom
fix/bitbox-empty-address-self-heal

Conversation

@TaprootFreak

Copy link
Copy Markdown
Contributor

Problem

A BitBox (hardware) wallet could be persisted with an empty on-chain address. On the next app launch — after PIN entry — the dashboard build reads that address through EthereumAddress.fromHex(""), which throws. In release this is uncaught in the build phase and surfaces as a bare grey screen (the default ErrorWidget). Software wallets are unaffected because they always derive a real address.

Root cause

bitbox_flutter's getETHAddress coerces a native null into "" at the transport boundary (bitbox_usb_method_channel.dartreturn result ?? '';; the iOS/Android handlers return it unvalidated). When the device isn't fully ready (e.g. a transient BLE stall right after channel-hash verify), the address comes back empty, and createBitboxWallet persisted it with no validation — this gap has existed since BitBox support was first added. The pairing ceremony also fetched the address with no device-ready re-check or retry (unlike the existing channel-hash retry loop).

What changed

  • Central retrying boundaryBitboxService.getEthAddress never returns empty: a transient empty read self-recovers across bounded retries; a persistent one throws the new typed BitboxAddressUnavailableException.
  • Validate before persistcreateBitboxWallet (and the heal path) route through that boundary and keep a format guard, so an empty/invalid address can never land on disk again. A failed fetch falls back to the pairing flow's existing retry path.
  • Self-heal for already-corrupted wallets — at load, a BitBox row with an empty/invalid address is detected (non-throwing) and the app diverts to a re-pairing recovery page that re-derives and backfills the address onto the existing row, then continues to the dashboard. This is local key derivation (no API state). Cancelling removes the unusable view-wallet (keys live on the device; re-pairing re-derives the same address) so the user is never stranded.
  • Defense-in-depth — a custom ErrorWidget.builder replaces the silent grey box with a logged, on-brand surface, and routes uncaught build errors through FlutterError.onError.

Test plan

  • flutter analyze clean
  • flutter test --exclude-tags golden — full suite green (2334 tests)
  • Unit: createBitboxWallet rejects empty/invalid without persisting; getEthAddress retry (first-ok / empty-then-ok via fakeAsync / persistent-empty throws); currentWalletNeedsAddressRecovery matrix; healCurrentBitboxAddress happy + throw
  • Bloc: HomeBloc diverts to recovery and clears the flag after a clean load
  • Widget: recovery onCancel does not throw on a single-entry stack (+ regression guard)
  • On-device: re-pair an empty-address BitBox wallet → lands on dashboard; cancel → onboarding

A BitBox wallet could be persisted with an empty on-chain address: the
bitbox_flutter transport coerces a native null into "" (result ?? '') when
the device isn't fully ready right after channel-hash verify, and
createBitboxWallet stored it without validation. On the next launch the
dashboard build read that address through EthereumAddress.fromHex("") which
throws, surfacing in release as a bare grey ErrorWidget. Software wallets are
unaffected because they always derive a real address.

- BitboxService.getEthAddress: single retrying boundary that never returns
  empty — a transient empty read self-recovers across attempts, a persistent
  one throws BitboxAddressUnavailableException.
- createBitboxWallet / healCurrentBitboxAddress route through it and keep a
  format guard before persisting.
- Self-heal: detect a BitBox row with an empty/invalid address at load and
  divert to a re-pairing recovery page that re-derives and backfills the
  address (local key derivation, no API state), then continues to the
  dashboard. Cancel removes the unusable view-wallet so the user is never
  stranded.
- Defense-in-depth: a custom ErrorWidget.builder replaces the silent grey box
  with a logged, on-brand surface.
@TaprootFreak TaprootFreak marked this pull request as ready for review June 9, 2026 11:57
@TaprootFreak TaprootFreak merged commit b529dcc into staging Jun 9, 2026
12 checks passed
@TaprootFreak TaprootFreak deleted the fix/bitbox-empty-address-self-heal branch June 9, 2026 12:56
TaprootFreak added a commit that referenced this pull request Jun 9, 2026
## Problem

Connecting a **brand-new BitBox that has no wallet set up** (no seed)
left the user stuck. Pairing succeeds, but the device has no seed to
derive an ETH address from, so `getETHAddress` comes back empty. That
empty read failed as a generic error → `BitboxNotConnected` → a SnackBar
"something went wrong" → and because the re-scan timer is re-armed, the
device is immediately found again and the user is walked through the
pairing code → fail → SnackBar **loop**, with no hint that the real
problem is simply an un-set-up device.

(The earlier fix #710 stopped the empty address from being *persisted* —
no more grey screen — but did not distinguish "no seed" from a transient
empty read.)

## What changed

After channel-hash verify, read the device's firmware status via the new
`bitbox_flutter` `getDeviceStatus()` (cached read, no device
round-trip). When it reports `uninitialized`, emit a dedicated
**`BitboxNotInitialized`** state that explains the user must set up /
restore a wallet on the device first.

- The state offers a **retry** (`recheckDeviceStatus`) that re-reads the
status — if the user has since set up a wallet, the connection continues
without re-pairing.
- It deliberately **does not arm the re-scan timer**, so the silent
re-pair loop is gone.
- Only `uninitialized` is treated as "no wallet". Other non-ready
statuses (e.g. firmware-upgrade-required) intentionally keep the
existing failure path rather than being mislabelled.

The address-derivation/observe/sign tail of `confirmPairing` was
extracted into `_acquireWalletAndConnect()` so the initial flow and the
retry share one path.

## Dependency

Requires `bitbox_flutter` `getDeviceStatus()` from
**[DFXswiss/bitbox_flutter#29](DFXswiss/bitbox_flutter#29.
This PR temporarily pins the plugin to that fix branch; it will be moved
to the **`v0.0.9`** tag once #29 is merged and tagged. **Kept as Draft
until then.**

## Test plan

- [x] `flutter analyze` — clean (only the pre-existing
generated-`i18n.dart` warning)
- [x] `flutter test` — bitbox cubit / service / view suites all green
- [x] Cubit: unseeded → `BitboxNotInitialized`, no wallet created, no
re-scan loop (state stays stable); retry continues once seeded; retry
stays while still unseeded; no-op off-state
- [x] Service: `getDeviceStatus` pass-through via the simulator
- [x] Widget: `BitboxNotInitialized` renders retry + cancel; retry calls
`recheckDeviceStatus`
- [x] i18n: new keys in both `de`/`en` ARBs (case-sensitive ASCII
order), regenerated
- [ ] On-device: pair an un-set-up BitBox → lands on the explanatory
screen; set up a wallet + retry → continues to the dashboard
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