Skip to content

fix: BitBox pairing flow shows code in app and survives Home auth#301

Merged
TaprootFreak merged 1 commit into
RealUnitCH:developfrom
joshuakrueger-dfx:fix/bitbox-pairing-code-sync
May 8, 2026
Merged

fix: BitBox pairing flow shows code in app and survives Home auth#301
TaprootFreak merged 1 commit into
RealUnitCH:developfrom
joshuakrueger-dfx:fix/bitbox-pairing-code-sync

Conversation

@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator

@joshuakrueger-dfx joshuakrueger-dfx commented May 7, 2026

Summary

  • Run BitboxService.init() in the background and poll getChannelHash() in parallel so the app shows the pairing code at the same time as the BitBox does
  • confirmPairing() awaits the in-flight init() future before calling channelHashVerify(), so the host-side verify (and createBitboxWallet after it) cannot run before the device-side verify has landed
  • Guard every async step on isClosed and clear _pendingInit in close() — dismissing the modal during pairing no longer leaks the re-scan timer or emits on a disposed cubit (incorporates the dispose-leak hardening from fix: prevent orphaned timer leak on BitBox modal dismiss #303)
  • Skip the automatic DFX auth call in HomeBloc._setupFiatService for BitBox-backed wallets — bitbox_flutter's ETHSignMessage panics on a NACK and crashes the engine, which previously killed the app right after pairing
  • DE/EN copy on the connecting screen reworded to make the compare-step expectation clearer

Why

Pairing code never shown in app. bitbox02-api-go's pair() sets device.channelHash before issuing opICanHasPairinVerificashun, which blocks until the user confirms the pairing on the device. The previous flow await init() first, so the BitBox displayed the pairing code while the app stayed on its spinner indefinitely — there was no way to compare the codes. Calling channelHashVerify() before init() returned also crashed createBitboxWallet because the noise channel was not yet considered verified on the device side.

bitbox.ChannelHash() is a plain field read on the Go-side Device, so polling it concurrently with the still-running init() is safe.

Modal-dismiss timer leak. The cubit lives inside a showModalBottomSheet. Dismissing the sheet during the 90 s polling loop ran close() while connectToBitbox() was still in flight; the catch block then armed a fresh Timer.periodic on the disposed cubit and emitted onto a closed bloc. The isClosed guards stop the loop on disposal, prevent the catch path from arming a new timer, and let close() drop the _pendingInit reference cleanly.

Engine crash on first Home load. Right after a fresh pairing, HomeBloc._setupFiatService asks the wallet to sign a challenge so the DFX backend can verify ownership. For BitBox-backed wallets this goes through bitbox_flutter, and the SDK's ETHSignMessage panics with unexpected NACK response when the device rejects the request. Go panics in gomobile bindings cannot be caught from Dart, so the engine dies. Skipping the automatic call for BitBox wallets keeps the app alive — signing is then triggered explicitly by user-initiated flows that can present a clearer error.

Changes

  • lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart: parallel-poll for the channel hash, gate confirmPairing() on the init future, isClosed guards on every async path, drop _pendingInit in close()
  • lib/screens/home/bloc/home_bloc.dart: skip auto-auth when the active credentials are BitboxCredentials
  • assets/languages/strings_de.arb, assets/languages/strings_en.arb: reworded connectBitboxConnecting

Test plan

Devices: iPhone with USB-C cable, BitBox02 Plus, debug-mode build, console open for developer.log output.

1. Happy path — fresh first pairing

  • Hot-restart, Welcome → BitBox flow, plug BitBox in
  • Within 1–2 s the same pairing code appears on BitBox and in the app
  • Codes match
  • Tap confirm in app → spinner appears
  • Confirm on BitBox → app moves to Connected, wallet is created, onboarding continues

2. Cancel from BitboxCheckHash

  • Steps 1–4 of test 1, then tap cancel in the app instead of confirm
  • Sheet closes, no crash, no stuck spinner
  • Restarting the BitBox flow immediately can re-pair

3. Modal-dismiss during BitboxConnecting

  • Start BitBox flow, plug in, swipe sheet down while still on the spinner
  • Sheet closes, no Cannot emit new states after calling close and no orphan timer in DevTools

4. Decline / timeout on the device

  • Steps 1–5 of test 1, then either reject the pairing on the BitBox or do nothing
  • Within ≤ 120 s the app shows the failure snackbar and returns to Welcome
  • Re-pair works

5. Reach Home with a BitBox wallet

  • Complete the pairing flow, set PIN and biometric on the device
  • App lands on Home without crashing
  • Fiat-service banner is hidden / disabled (the auth call is intentionally skipped)

@joshuakrueger-dfx joshuakrueger-dfx changed the title fix: clarify BitBox pairing-code wait UX fix: BitBox pairing code shown in app + clearer wait UX May 7, 2026
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the fix/bitbox-pairing-code-sync branch from 07a9609 to 1be44cf Compare May 7, 2026 16:30
@joshuakrueger-dfx joshuakrueger-dfx changed the title fix: BitBox pairing code shown in app + clearer wait UX fix: show BitBox pairing code while SDK init waits for device confirm May 7, 2026
@TaprootFreak
Copy link
Copy Markdown
Contributor

Funktional ist der PR eine deutliche Verbesserung — das eigentliche UX-Problem (Pairing-Code auf BitBox sichtbar, App spinnt) ist sauber gelöst, und die Timeouts sind insgesamt besser als das vorherige unbegrenzte await init().

Zwei Punkte, die ich vor dem Merge testen würde:

1. Stale-Channel-Hash beim Re-Pair (real, neu durch diesen PR)

Original: await init() → dann getChannelHash() — garantiert frischer Hash.
Neu: Polling parallel zu connect().

bitboxManager ist ein Instanz-Feld in BitboxService (bitbox.dart:7). Falls eine BitboxService-Instanz über mehrere Pair-Versuche in derselben App-Session wiederverwendet wird, könnte bitboxManager.getChannelHash() initial den Hash der vorherigen Session zurückgeben, bevor connect() den neuen Noise-Channel etabliert hat. Das Polling akzeptiert dann fälschlich den alten Hash, weil isNotEmpty zutrifft.

Test-Szenario das im Plan fehlt:

  • Erfolgreich pairen → Wallet erstellt
  • Innerhalb derselben App-Session BitBox trennen + neu anstecken
  • Erneut pairen — wird derselbe oder ein neuer Hash angezeigt?

Falls das reproduzierbar ist, wäre die saubere Lösung: vor dem Polling-Start den initialen Hash-Wert merken und warten, bis er sich ändert (statt nur isNotEmpty).

2. Future.timeout cancelt _runInit nicht (neu, kosmetisch)

Wenn _pendingInit.timeout(120s) in confirmPairing feuert, läuft _runInit im Hintergrund weiter und kann später BitboxService._isConnected = true setzen (bitbox.dart:29) — auf einer Cubit, die längst in BitboxNotConnected ist. In allen Codepfaden, die ich gesehen habe, ist das idempotent (nächster Pair-Versuch ruft connect() neu auf), also kein User-sichtbarer Bug. Aber sauberer wäre, BitboxService ein cancel() zu geben, das _failAndRescan aufruft.

Kleinere Nits (kein Blocker):

  • _pendingInit!-Bang in confirmPairing (:67): heute durch State-Gate safe, aber zerbrechlich.
  • await Future.delayed vor dem ersten Versuch in _waitForChannelHash: 500 ms unnötiger Delay auf dem Happy Path.
  • catch (_) in der Polling-Schleife schluckt alle Fehler 90 s lang — mindestens loggen wäre hilfreich für künftiges Debugging.
  • Test-Plan deckt Android-USB-Pfad nicht ab (kein startScan()).

@TaprootFreak
Copy link
Copy Markdown
Contributor

Habe einen Folge-Commit auf den Branch gepusht (e9630f3), der die zwei realen Punkte aus dem Review adressiert:

1. Stale-Channel-Hash beim Re-Pair
Vor dem Start von init() wird einmal der aktuelle channelHash gesnapshottet (priorHash). Polling akzeptiert nur einen Hash, der nicht-leer UND ≠ priorHash ist. Damit kann ein zweiter Pair-Versuch in derselben App-Session nicht versehentlich den Hash der vorherigen Session anzeigen.

2. _pendingInit!-Bang
Ersetzt durch null-check + StateError. Heute durch State-Gate safe, aber explizit ist sicherer.

Polish nebenbei:

  • _waitForChannelHash auf do/while umgestellt — erster Versuch sofort statt nach 500 ms.
  • catch (_) im Polling loggt jetzt den Fehler, statt ihn zu schlucken.

Den Punkt „Future.timeout cancelt _runInit nicht" habe ich gelassen — der entstehende Stale-State auf BitboxService._isConnected ist in allen sichtbaren Codepfaden idempotent (nächster Pair-Versuch ruft connect() neu auf). Saubere Lösung wäre ein BitboxService.disconnect() aus _failAndRescan, aber das ist ein eigener Refactor.

@TaprootFreak
Copy link
Copy Markdown
Contributor

Aufgeräumt: zwei Folge-Commits gepusht, die meine erste Runde an Polish + den Original-Diff zurückbauen.

90f6001 — Anpassung an Projekt-Konventionen:

  • 5 named Konstanten entfernt → inline const Duration(...), wie überall sonst im Projekt (sell_bitbox_cubit, verify_pin_page, etc.)
  • _readChannelHashOrEmpty Helper inlined (war 1×-verwendet)
  • priorHash ist jetzt String? (null = unlesbar/keine Vorsession), semantisch korrekter als ''-Fallback
  • Defensive if (pending == null) throw zurückgebaut auf bang — State-Guard macht ihn safe, Projekt nutzt bangs

e65f5c1 — Stil-Diff minimiert:

  • Felder-Reihenfolge zurück auf Original (unter _startScanning)
  • Leerzeile in confirmPairing zwischen State-Guard und try wieder da

Ergebnis: Cubit-Diff jetzt 69 Zeilen statt vorher 78 (Joshuas Original) bzw. 92 (mit meinem ersten Bloat). Stale-Hash-Schutz drin, sonst keine unnötigen Änderungen vs. Original.

CI-Run für e9630f3 ist grün durch (4m28s), die zwei Cleanup-Commits warten noch auf Workflow-Approval (Fork-PR-Standard).

Copy link
Copy Markdown
Contributor

@davidleomay davidleomay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close() doesn't clean up _pendingInit
If the user navigates away while init() is still in flight, the future keeps running and could emit on a closed cubit. _waitForChannelHash would also keep polling. Worth guarding against — e.g. checking isClosed in the polling loop and nulling out _pendingInit in close().

@TaprootFreak
Copy link
Copy Markdown
Contributor

@davidleomay Guter Fang — habe den close()-Pfad im Commit f2dfa54 gefixt:

  • close() setzt jetzt _pendingInit = null
  • _waitForChannelHash checkt isClosed am Loop-Anfang und wirft StateError, sobald die Cubit zu ist
  • _failAndRescan early-returnt wenn isClosed, damit der StateError-Catch keine doppelte Emit-Crash auslöst

init() selbst läuft im Hintergrund weiter (Dart-Futures lassen sich nicht canceln), aber das Result wird nicht mehr verwendet und keine Emits passieren mehr auf der toten Cubit.

@TaprootFreak
Copy link
Copy Markdown
Contributor

Test-Plan vor Merge

Geräte: iOS Device + BitBox02 Plus mit USB-C Adapter. App im Debug-Build laufen lassen, Konsole offen für developer.log-Output.


1. Happy Path — frische erste Kopplung

  1. App frisch starten (Hot-Restart), Welcome-Screen → BitBox-Flow auswählen
  2. BitBox per USB einstecken
  3. Erwartet: Innerhalb 1–2 s erscheint gleichzeitig der Kopplungscode auf BitBox UND in der App
  4. Codes vergleichen — müssen identisch sein
  5. In App Bestätigen tippen → in App erscheint Spinner
  6. Auf BitBox bestätigen
  7. Erwartet: App wechselt auf "Verbunden", Wallet wird erstellt, Onboarding läuft weiter

Failure-Mode wenn Bug: Code erscheint nur auf BitBox, App zeigt unendlich Spinner.


2. Cancel im BitboxCheckHash-State

  1. Schritte 1–4 von Test 1 wiederholen
  2. Statt Bestätigen in App Abbrechen tippen
  3. Erwartet: Sheet schließt, Welcome-Screen zurück, kein Crash, kein hängender Spinner
  4. Sofort wieder BitBox-Flow starten → muss neu kuppeln können

3. Wegnavigieren während BitboxConnecting (vor Code-Anzeige)

  1. BitBox-Flow starten, BitBox einstecken
  2. Sofort während Spinner (vor Code-Anzeige) Sheet runterziehen / Back-Button
  3. Erwartet: Sheet schließt, kein Crash, keine StateError: Cannot emit new states after calling close in der Konsole

Hintergrund: Dies testet Davids Review-Punkt — close() während laufendem init().


4. Wegnavigieren während BitboxPairing (nach Bestätigen, vor Device-Confirm)

  1. Schritte 1–5 von Test 1
  2. Vor dem Bestätigen auf der BitBox: App-Sheet zuziehen / Back-Button
  3. Erwartet: Sheet schließt sauber, keine Konsolen-Errors
  4. BitBox-Flow erneut starten → Re-Pair muss funktionieren (siehe Test 6)

5. Decline auf BitBox (User confirmt nicht auf Device)

  1. Schritte 1–5 von Test 1
  2. Auf BitBox Pairing ablehnen (oder einfach lange nichts tun, BitBox-Timeout abwarten)
  3. Erwartet: Nach max. 120 s wechselt App auf "BitBox nicht verbunden"-Snackbar, zurück zu Welcome
  4. Re-Pair muss funktionieren

6. Re-Pair in derselben App-Session (priorHash-Schutz)

Das ist der kritische Test für meinen Stale-Hash-Fix.

  1. Test 1 erfolgreich durchlaufen → wallet ist verbunden
  2. Auf einen Screen navigieren, von dem aus BitBox nochmal gekoppelt werden kann (z. B. Wallet entfernen + neu hinzufügen, oder Bitbox-Flow erneut anstoßen — abhängig vom App-Flow)
  3. BitBox physisch trennen + neu einstecken
  4. Zweite Kopplung starten
  5. Erwartet: Neuer Code auf BitBox UND App, Codes matchen
  6. Failure-Mode wenn Bug: App zeigt den ALTEN Code aus Test 1, BitBox zeigt einen NEUEN Code → Codes matchen nicht

Wenn das nicht reproduzierbar ist (weil BitboxService State zwischen Pairings doch gecleart wird), den Test trotzdem mit App-Restart wiederholen.


7. USB-Disconnect mitten im Pairing

A) Während BitboxConnecting (vor Code):

  1. BitBox-Flow starten, BitBox einstecken
  2. Sofort BitBox abziehen, bevor Code angezeigt wird
  3. Erwartet: App fällt auf "Suchen"-State zurück, Re-Connect funktioniert

B) Während BitboxPairing:

  1. Schritte 1–5 von Test 1 durchlaufen, in App Bestätigen
  2. Vor Device-Confirm BitBox abziehen
  3. Erwartet: App fällt auf "BitBox nicht verbunden" zurück

8. App-Background während Pairing

  1. Schritte 1–4 von Test 1
  2. App in Hintergrund (Home-Button), 30 s warten, Foreground
  3. Erwartet: App-State noch sinnvoll (entweder noch im CheckHash-State mit Code oder zurück zu Suchen)
  4. Pairing kann fortgesetzt oder neu gestartet werden

9. Konsolen-Check

Während aller Tests in der Konsole achten auf:

  • Cannot emit new states after calling close → Bug, sollte nicht auftreten
  • init error: ... → erwartet bei USB-Disconnect-Tests, sonst nicht
  • channel hash poll error: ... → akzeptabel, solange Pairing trotzdem durchläuft

Smoke-Test nach Merge auf develop

  • iOS Beta-Build ziehen
  • Test 1 (Happy Path) auf 1× iPhone

Wenn 1, 2, 3, 4 und 6 grün sind, ist der PR aus meiner Sicht prod-ready. 5, 7, 8 sind nice-to-have-Robustheit-Checks.

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the fix/bitbox-pairing-code-sync branch 5 times, most recently from 493fafa to 51ba50c Compare May 8, 2026 07:36
@joshuakrueger-dfx joshuakrueger-dfx changed the title fix: show BitBox pairing code while SDK init waits for device confirm fix: BitBox pairing flow shows code in app and survives Home auth May 8, 2026
Two related problems are addressed:

1. The connect screen never displayed the pairing code. bitbox02-api-go's
   pair() sets device.channelHash before issuing
   opICanHasPairinVerificashun, which blocks until the user confirms
   the pairing on the device. The cubit awaited init() so the BitBox
   displayed the code while the app stayed on its spinner.

   Run init() in the background and poll ChannelHash() in parallel so
   the same code shows in the app for visual comparison.
   confirmPairing() awaits the same future before calling
   channelHashVerify(), preventing the host-side verify (and the
   createBitboxWallet that follows) from running before the
   device-side verify has landed.

2. Once paired, HomeBloc._setupFiatService asks the wallet to sign a
   challenge for the DFX auth endpoint. For BitBox-backed wallets this
   goes through bitbox_flutter, whose ETHSignMessage panics on a NACK
   from the device — Go panics in gomobile bindings cannot be caught
   from Dart, so the engine dies. Skip the automatic auth call for
   BitBox wallets until the SDK returns the error gracefully.

DE/EN copy on the connecting screen reworded to set the right
expectation.
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the fix/bitbox-pairing-code-sync branch from 51ba50c to a524de1 Compare May 8, 2026 07:42
@TaprootFreak TaprootFreak merged commit 5d55c4a into RealUnitCH:develop May 8, 2026
1 check passed
TaprootFreak added a commit that referenced this pull request May 8, 2026
## Summary
- Add `isClosed` guards to all async methods in `ConnectBitboxCubit` to
prevent resource leaks on disposal
- Align `close()` with the project convention (`return super.close()`
instead of `async` + bare `super.close()`)

## Problem
`ConnectBitboxCubit` lives inside a `showModalBottomSheet` without
`isDismissible: false`. During the `BitboxConnecting` state there is no
cancel button — the only way to abort is swiping the modal down.

When the user swipes during the 90-second polling loop introduced in
#301:

1. `BlocProvider` removes the widget → `close()` runs → `_checkForTimer`
is cancelled
2. But `connectToBitbox()` is an in-flight async method — it keeps
running
3. When the loop eventually times out or `init` fails, the `catch` block
creates a **new `Timer.periodic`** that is never cancelled (because
`close()` already ran)
4. This timer holds a reference to the cubit, preventing garbage
collection, and calls `checkForBitbox()` every 500ms indefinitely

The same issue exists in the `confirmPairing()` catch block.

## Fix
- `checkForBitbox()`: `if (isClosed) return` after `await
getAllUsbDevices()` — prevents starting a connection flow on a disposed
cubit
- `connectToBitbox()` `while` condition: `&& !isClosed` — stops polling
immediately on dispose
- `connectToBitbox()` after `await Future.delayed`: `if (isClosed)
return` — catches the edge case where close happens during the delay
- `connectToBitbox()` after loop exit: `if (isClosed) return` — prevents
`emit` on a closed cubit
- Both `catch` blocks: `if (isClosed) return` — prevents creating
orphaned timers
- `close()`: `_pendingInit = null` — drops the future reference
- `close()`: aligned with project convention — non-async with `return
super.close()`

## Test plan
- [x] `flutter analyze` — 0 errors
- [x] `flutter test` — 182/182 passed
- [ ] Manual: open BitBox modal → swipe down during "connecting" spinner
→ verify no timer leak in DevTools
TaprootFreak pushed a commit that referenced this pull request May 8, 2026
## Summary
- Move the BitBox short-circuit out of `HomeBloc._setupFiatService` and
into `DFXAuthService.getAuthToken()` so every caller is covered
- `getAuthToken()` returns `null` for `BitboxCredentials` wallets, which
the existing callers already treat as \"not authenticated\"

## Why
`bitbox_flutter`'s `ETHSignMessage` panics on a NACK from the device and
crashes the Flutter engine (panics in gomobile bindings cannot be caught
from Dart). #301 added a guard inside `HomeBloc._setupFiatService` so
the auto-auth on Home would not crash a BitBox-backed wallet, but the
same `getAuthToken()` path is reachable from `DfxKycService`,
`DfxSupportService`, `DfxWidgetService`, and every future
`DFXAuthService` consumer — each one would still crash the app the
moment the user touched that feature.

Putting the check at the source means the workaround can stay in one
place until the SDK returns the error gracefully (tracked in
DFXswiss/bitbox_flutter).

## Changes
- `lib/packages/service/dfx/dfx_auth_service.dart`: `getAuthToken()`
short-circuits to `null` when the active credentials are
`BitboxCredentials`
- `lib/screens/home/bloc/home_bloc.dart`: drop the now-redundant guard
and the `bitbox_credentials` import

## Test plan
- [ ] Pair a BitBox, land on Home — no crash, fiat banner stays disabled
(same as #301)
- [ ] Open Settings → Contact / KYC entry while BitBox-paired — no
crash, the affected screens treat the user as unauthenticated
- [ ] Software wallet on Home still authenticates against DFX
(regression check)
TaprootFreak added a commit that referenced this pull request May 8, 2026
## Summary
- Snapshot `getChannelHash()` before starting `init()` and require the
polled hash to differ from it
- Drop the `_pendingInit ?? Future.value(true)` fallback in
`confirmPairing` and use the bang assertion the state guard already
guarantees

## Why

### Stale hash on re-pair
`BitboxService` is registered as a DI singleton
(`lib/setup/di.dart:121`), and the `BitboxManager` instance inside it
persists across pairings. The connect modal is reachable from at least
two entry points (`welcome_page.dart`, `kyc_registration_page.dart`),
and any second pairing attempt within the same app session goes through
that same SDK instance.

Before this PR, the polling loop accepted any non-empty hash. If
`bitboxManager.getChannelHash()` still holds the previous session's hash
when polling starts, the app shows the *old* hash while the BitBox
displays the *new* one — codes don't match, user is stuck.

The 500 ms first-iteration delay introduced in #305 (`...so the SDK can
finish setting up its Go-side device pointer...`) confirms there is a
real timing gap during which the SDK is not yet up-to-date for the new
session, which is precisely the window where stale hashes surface.

The fix records the hash once before `init()` runs and ignores any
polled hash that matches the snapshot.

### `??` fallback in confirmPairing
`_pendingInit ?? Future.value(true)` silently substitutes "init
succeeded" if the future is missing. The `is! BitboxCheckHash` state
guard at the top of `confirmPairing` already guarantees that
`connectToBitbox` set `_pendingInit`. The fallback only triggers in an
impossible state, and if that state ever became reachable, returning
`true` would let `channelHashVerify()` run on an unverified channel —
exactly the crash #301 set out to prevent.

Replacing it with the bang assertion makes the precondition explicit and
matches the project's bang usage in similar state-guarded code paths.

## Changes
- `lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart`:
add `priorHash` snapshot + `hash != priorHash` check in polling; replace
`??` fallback with bang on `_pendingInit`

## Test plan
- [ ] Happy path on iOS BitBox02 Plus: open Welcome → pair → wallet
creates (regression check, no behavioural change expected on first pair)
- [ ] **Re-pair in same session:** pair successfully → trigger a second
pairing without restarting the app (e.g. via KYC retry flow, or by
opening the connect modal again). Verify the code shown in app matches
the code on the BitBox — not the previous session's code
- [ ] Cancel during `BitboxConnecting` (swipe modal down before code
appears): no `StateError`, no orphaned timer (regression check for #303)
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.

3 participants