From 80dc7199433f81f4c87faa1e4fff5fe80a2e5141 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:54:48 +0200 Subject: [PATCH 1/5] feat(bitbox): surface a dedicated state for an unseeded device A BitBox paired without a wallet set up (no seed) cannot derive an ETH address. The empty address read failed as a generic error and bounced the user into the silent re-scan loop with no explanation, so a brand-new device was effectively unusable with no hint why. After pairing, read the device's firmware status via the new bitbox_flutter getDeviceStatus and, when it reports `uninitialized`, emit a dedicated BitboxNotInitialized state that tells the user to set up or restore a wallet on the device first. The state offers a retry that re-checks the status without re-pairing and, crucially, does not arm the re-scan timer, so the device is no longer picked up and re-paired in a loop. Only `uninitialized` is treated as "no wallet"; other non-ready statuses keep the existing path. Temporarily pins bitbox_flutter to the fix branch; moves to the v0.0.9 tag once the plugin PR is merged and tagged. --- assets/languages/strings_de.arb | 2 + assets/languages/strings_en.arb | 2 + lib/packages/hardware_wallet/bitbox.dart | 7 ++ .../bloc/connect_bitbox_cubit.dart | 67 +++++++++++-- .../bloc/connect_bitbox_state.dart | 9 ++ .../connect_bitbox_view.dart | 14 +++ pubspec.lock | 4 +- pubspec.yaml | 2 +- .../hardware_wallet/bitbox_service_test.dart | 20 ++++ .../bloc/connect_bitbox_cubit_test.dart | 95 +++++++++++++++++++ .../connect_bitbox_view_test.dart | 20 ++++ 11 files changed, 230 insertions(+), 12 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6a596d22c..a9f18de2f 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.", "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", "connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "connectBitboxNotInitialized": "Auf dieser BitBox ist noch keine Wallet eingerichtet. Bitte richten Sie über die BitBox-App eine Wallet auf dem Gerät ein oder stellen Sie eine wieder her und versuchen Sie es erneut.", + "connectBitboxNotInitializedTitle": "BitBox noch nicht eingerichtet", "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.", "connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 52406e865..aae7ff636 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Please connect your BitBox with your Smartphone.", "connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.", "connectBitboxFailed": "Something went wrong. Please try to connect again.", + "connectBitboxNotInitialized": "This BitBox has no wallet set up yet. Set up or restore a wallet on the device using the BitBox app, then try again.", + "connectBitboxNotInitializedTitle": "BitBox not set up yet", "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.", "connectBitboxSignatureCapturingTitle": "Confirm sign-in", diff --git a/lib/packages/hardware_wallet/bitbox.dart b/lib/packages/hardware_wallet/bitbox.dart index 1e5caa6cd..0a6e9a6a9 100644 --- a/lib/packages/hardware_wallet/bitbox.dart +++ b/lib/packages/hardware_wallet/bitbox.dart @@ -129,6 +129,13 @@ class BitboxService { if (!didVerify) throw Exception('Failed to verify'); } + /// The paired device's firmware status (`uninitialized` / `seeded` / + /// `initialized`). Read after pairing to tell a device with no wallet set up + /// (`uninitialized` — cannot derive an address) apart from a ready device. + /// Delegates to the plugin's cached-status read, so there is no device + /// round-trip and it cannot block. + Future getDeviceStatus() => bitboxManager.getDeviceStatus(); + /// Derives the wallet's ETH address from the device, retrying transient empty /// reads before giving up. /// diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 96da7838a..12d307f72 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -24,6 +24,13 @@ class ConnectBitboxCubit extends Cubit { // walked away and the device-side ephemeral noise channel has died. static const Duration _defaultPairingPinTimeout = Duration(seconds: 120); + // The bitbox02 firmware status for a device that has no wallet set up — it + // cannot derive an address. Mirrors the SDK's `StatusUninitialized`. Only this + // value is treated as "not set up": other non-ready statuses (e.g. a firmware + // upgrade requirement) are intentionally left on the existing failure path + // rather than mislabelled as unseeded. + static const _statusUninitialized = 'uninitialized'; + ConnectBitboxCubit( this._service, this._walletService, @@ -155,15 +162,18 @@ class ConnectBitboxCubit extends Cubit { 'Disconnect the device, restart the app, and re-pair.', ), ); - final wallet = await _acquireWalletOrDefault().timeout( - _createWalletTimeout, - onTimeout: () => throw TimeoutException( - 'BitBox did not return an ETH address within ' - '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', - ), - ); - _service.startConnectionStatusObserver(); - await _captureAuthSignature(wallet); + // A device paired without a wallet set up has no seed, so the address read + // below would come back empty and fail as a generic error — bouncing the + // user into the silent re-scan loop with no idea why. Detect it up front + // and surface a dedicated state. Returning here (instead of throwing) means + // no re-scan timer is armed, so the device isn't picked up and re-paired in + // an endless loop. + if (await _service.getDeviceStatus() == _statusUninitialized) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + await _acquireWalletAndConnect(); } catch (e) { developer.log(e.toString(), name: '$ConnectBitboxCubit'); _pendingInit = null; @@ -173,6 +183,45 @@ class ConnectBitboxCubit extends Cubit { } } + /// Acquires the wallet from the device and finishes the connection. Shared by + /// the initial pairing flow and the [recheckDeviceStatus] retry so both run + /// the same create/observe/sign sequence. + Future _acquireWalletAndConnect() async { + final wallet = await _acquireWalletOrDefault().timeout( + _createWalletTimeout, + onTimeout: () => throw TimeoutException( + 'BitBox did not return an ETH address within ' + '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', + ), + ); + _service.startConnectionStatusObserver(); + await _captureAuthSignature(wallet); + } + + /// Re-reads the device status after a [BitboxNotInitialized], for when the user + /// has set up / restored a wallet on the device and wants to continue without + /// re-pairing. If the device now reports a wallet, the connection proceeds; if + /// it is still unseeded, the state is re-emitted so the user can try again. + Future recheckDeviceStatus() async { + final currentState = state; + if (currentState is! BitboxNotInitialized) return; + try { + if (await _service.getDeviceStatus() == _statusUninitialized) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + if (isClosed) return; + emit(BitboxPairing(currentState.device)); + await _acquireWalletAndConnect(); + } catch (e) { + developer.log(e.toString(), name: '$ConnectBitboxCubit'); + if (isClosed) return; + emit(BitboxNotConnected()); + _checkForTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => checkForBitbox()); + } + } + /// Captures and caches the auth signature as an awaited, user-guided step of /// the pairing flow. The BitBox is guaranteed connected here, so every later /// buy / KYC / user-data call can run off the cached signature without diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart index 8384e478d..7e679463b 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart @@ -24,6 +24,15 @@ class BitboxPairing extends BitboxFound { BitboxPairing(super.device); } +/// The paired BitBox has no wallet set up yet (firmware status `uninitialized`), +/// so no address can be derived. A dedicated state — not the generic failure — +/// so the UI can tell the user to set up / restore a wallet on the device first, +/// instead of bouncing through the silent re-scan loop. Carries the device so a +/// re-check can continue the connection without re-pairing. +class BitboxNotInitialized extends BitboxFound { + BitboxNotInitialized(super.device); +} + class BitboxCapturingSignature extends BitboxConnectionState { final BitboxWallet wallet; diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart index ee72a39fb..7f6731330 100644 --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart @@ -121,6 +121,20 @@ class ConnectBitboxView extends StatelessWidget { ], ), ), + BitboxNotInitialized() => ConnectContent( + title: S.of(context).connectBitboxNotInitializedTitle, + imagePath: 'assets/images/illustrations/bitbox_connect.svg', + onConfirm: () => context.read().recheckDeviceStatus(), + onCancel: onCancel ?? context.pop, + confirmLabel: S.of(context).retry, + child: Text( + S.of(context).connectBitboxNotInitialized, + textAlign: .center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), BitboxCapturingSignature() => ConnectContent( title: S.of(context).connectBitboxSignatureCapturingTitle, imagePath: 'assets/images/illustrations/bitbox_connected.svg', diff --git a/pubspec.lock b/pubspec.lock index cf5905a02..f7a276c15 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.8" - resolved-ref: "7786f6e70b716287f08f4b7082762e6d7d0546bf" + ref: "fix/bitbox-uninitialized-device-status" + resolved-ref: "237ceee851cd654ccfd4bbcdee094fd796032453" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index a277d4e39..bb35a2626 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,7 @@ dependencies: bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git - ref: v0.0.8 + ref: fix/bitbox-uninitialized-device-status dev_dependencies: flutter_test: diff --git a/test/packages/hardware_wallet/bitbox_service_test.dart b/test/packages/hardware_wallet/bitbox_service_test.dart index 43ee21c83..b91074448 100644 --- a/test/packages/hardware_wallet/bitbox_service_test.dart +++ b/test/packages/hardware_wallet/bitbox_service_test.dart @@ -382,6 +382,26 @@ void main() { }); }); + test('getDeviceStatus returns the firmware status the device reports', () { + // Thin pass-through to the plugin's cached-status read. The cubit branches + // on this string after pairing to detect an unseeded device, so a + // method-name flip would silently break that gate on real hardware. + fakeAsync((async) { + final service = pairedServiceSync(async); + platform.when( + SimulatedBitboxMethod.getDeviceStatus, + (_) async => 'uninitialized', + ); + + String? status; + service.getDeviceStatus().then((value) => status = value); + async.flushMicrotasks(); + + expect(status, 'uninitialized'); + expect(platform.count(SimulatedBitboxMethod.getDeviceStatus), 1); + }); + }); + test('confirmPairing returns normally on a verified channel', () { // Happy path: user pressed the on-device button. fakeAsync((async) { diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index 11b764c55..ea8c13a39 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -50,6 +50,10 @@ void main() { when(() => service.startScan()).thenAnswer((_) async => true); when(() => service.getAllUsbDevices()).thenAnswer((_) async => []); when(() => service.startConnectionStatusObserver()).thenReturn(null); + // Default to a device that has a wallet set up so the existing pairing + // tests reach the address-derivation path. The unseeded path is exercised + // explicitly below. + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'initialized'); when(() => walletService.createBitboxWallet(any())).thenAnswer((_) async => wallet); when(() => wallet.currentAccount).thenReturn(_FakeBitboxWalletAccount()); when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); @@ -453,5 +457,96 @@ void main() { expect(cubit.state, isA()); verify(() => walletService.createBitboxWallet('Luke-Skywallet')).called(1); }); + + // A brand-new device with no wallet (firmware status `uninitialized`) cannot + // derive an address. It must surface BitboxNotInitialized — not the generic + // failure — and must NOT try to create a wallet or arm the re-scan timer that + // would re-pair the device in an endless loop. + test('emits BitboxNotInitialized when the device has no wallet set up', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + + // The state must be stable: no re-scan timer is armed, so the cubit does + // not bounce back through the connection flow on its own. + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state, isA()); + }); + + test('recheckDeviceStatus continues to BitboxConnected once a wallet is set up', () async { + var pollCount = 0; + var statusCalls = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async { + statusCalls++; + return statusCalls == 1 ? 'uninitialized' : 'initialized'; + }); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); + + test( + 'recheckDeviceStatus stays in BitboxNotInitialized while the device is still unseeded', + () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + }, + ); + + test('recheckDeviceStatus is a no-op when not in BitboxNotInitialized', () async { + final cubit = makeCubit(); + addTearDown(cubit.close); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + }); }); } diff --git a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart index 478ad0d0c..096c0ea0b 100644 --- a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart +++ b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart @@ -1,3 +1,4 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,8 @@ class _MockConnectBitboxCubit extends MockCubit class _MockBitboxWallet extends Mock implements BitboxWallet {} +class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {} + void main() { late _MockConnectBitboxCubit cubit; late _MockBitboxWallet wallet; @@ -105,6 +108,23 @@ void main() { verify(() => cubit.continueWithoutSignature()).called(1); }); + testWidgets('BitboxNotInitialized shows retry and cancel buttons', (tester) async { + final device = _FakeBitboxDevice(); + await pumpView(tester, BitboxNotInitialized(device)); + + expect(find.byType(AppFilledButton), findsNWidgets(2)); + }); + + testWidgets('BitboxNotInitialized retry button calls recheckDeviceStatus', (tester) async { + final device = _FakeBitboxDevice(); + when(() => cubit.recheckDeviceStatus()).thenAnswer((_) async {}); + await pumpView(tester, BitboxNotInitialized(device)); + + final buttons = tester.widgetList(find.byType(AppFilledButton)).toList(); + buttons[0].onPressed?.call(); + verify(() => cubit.recheckDeviceStatus()).called(1); + }); + testWidgets('ConnectContent honors confirmLabel and cancelLabel overrides', (tester) async { await tester.pumpApp( ConnectContent( From 98dcfb6608ec30c548d40f37895c034e049a04e0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:28:37 +0200 Subject: [PATCH 2/5] fix(bitbox): make the device-status gate fail-open Harden the new unseeded-device check so it can only ever ADD the dedicated "not set up" path, never break a device that would otherwise pair. The status read is wrapped so any failure or unexpected value falls through to the normal acquire path (the exact pre-change behaviour); only a clean, explicit `uninitialized` read diverts to BitboxNotInitialized. Adds a regression test asserting a throwing status read still reaches BitboxConnected. --- .../bloc/connect_bitbox_cubit.dart | 21 ++++++++++++++-- .../bloc/connect_bitbox_cubit_test.dart | 25 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 12d307f72..9095045a4 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -168,7 +168,7 @@ class ConnectBitboxCubit extends Cubit { // and surface a dedicated state. Returning here (instead of throwing) means // no re-scan timer is armed, so the device isn't picked up and re-paired in // an endless loop. - if (await _service.getDeviceStatus() == _statusUninitialized) { + if (await _isDeviceUninitialized()) { if (isClosed) return; emit(BitboxNotInitialized(currentState.device)); return; @@ -183,6 +183,23 @@ class ConnectBitboxCubit extends Cubit { } } + /// Reads the device status, treating ONLY a clean, explicit `uninitialized` + /// read as "no wallet set up". Any failure or unexpected value returns false + /// (fail-open), so a status read can never block a device that would otherwise + /// pair successfully — it only ever adds the dedicated unseeded path on top of + /// the existing behaviour, never removes the working one. + Future _isDeviceUninitialized() async { + try { + return await _service.getDeviceStatus() == _statusUninitialized; + } catch (e) { + developer.log( + 'device status read failed, treating device as ready: $e', + name: '$ConnectBitboxCubit', + ); + return false; + } + } + /// Acquires the wallet from the device and finishes the connection. Shared by /// the initial pairing flow and the [recheckDeviceStatus] retry so both run /// the same create/observe/sign sequence. @@ -206,7 +223,7 @@ class ConnectBitboxCubit extends Cubit { final currentState = state; if (currentState is! BitboxNotInitialized) return; try { - if (await _service.getDeviceStatus() == _statusUninitialized) { + if (await _isDeviceUninitialized()) { if (isClosed) return; emit(BitboxNotInitialized(currentState.device)); return; diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index ea8c13a39..2d2b0a981 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -548,5 +548,30 @@ void main() { await cubit.recheckDeviceStatus(); expect(cubit.state, isA()); }); + + // Fail-open guarantee: a failing status read must NOT block a device that + // would otherwise pair. If getDeviceStatus throws, the flow falls through to + // the normal acquire path and still reaches BitboxConnected — the new gate + // can only ever ADD the unseeded path, never break the working one. + test('continues to BitboxConnected when the status read throws', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenThrow(Exception('status boom')); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); }); } From 5459f2f513c6a0c380cd3e0145f12e9056ae0f58 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:56:59 +0200 Subject: [PATCH 3/5] fix(bitbox): bound the device-status read with a fail-open timeout The status read is a local cached lookup, but it was the only device-facing call in the pairing path without a timeout. Cap it at 5s; on timeout the read is treated as "not uninitialized" (fail-open via the existing catch), so a hypothetical stall can never hang an otherwise-working pairing. --- .../bloc/connect_bitbox_cubit.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 9095045a4..0774d1120 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -31,6 +31,12 @@ class ConnectBitboxCubit extends Cubit { // rather than mislabelled as unseeded. static const _statusUninitialized = 'uninitialized'; + // The status read is a local cached lookup (no device round-trip), so it + // returns in milliseconds. This cap only exists so a hypothetical stall can + // never hang the pairing flow — on timeout the read is treated as "not + // uninitialized" (fail-open) and the normal acquire path proceeds. + static const Duration _deviceStatusTimeout = Duration(seconds: 5); + ConnectBitboxCubit( this._service, this._walletService, @@ -190,10 +196,11 @@ class ConnectBitboxCubit extends Cubit { /// the existing behaviour, never removes the working one. Future _isDeviceUninitialized() async { try { - return await _service.getDeviceStatus() == _statusUninitialized; + final status = await _service.getDeviceStatus().timeout(_deviceStatusTimeout); + return status == _statusUninitialized; } catch (e) { developer.log( - 'device status read failed, treating device as ready: $e', + 'device status read failed/timed out, treating device as ready: $e', name: '$ConnectBitboxCubit', ); return false; From 725498e02fcea5f8d06ce0ed7b7853fa04baedd2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:22:11 +0200 Subject: [PATCH 4/5] chore(deps): pin bitbox_flutter to v0.0.10 The plugin PR landed and tagged v0.0.10 (carries getDeviceStatus + the 16 KB alignment). Replaces the temporary fix-branch pin with the released tag. --- pubspec.lock | 28 ++++++++++++++-------------- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9abade98f..9f08a9153 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "fix/bitbox-uninitialized-device-status" - resolved-ref: "1cfab9d982f5a822dfbaf2d1ec961c8fdd7e9e30" + ref: "v0.0.10" + resolved-ref: "cd99ce656410e8df6c6585076d6ee0205a67b34c" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" @@ -174,10 +174,10 @@ packages: dependency: "direct main" description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -923,18 +923,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1416,26 +1416,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bb35a2626..6f5b347e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,7 @@ dependencies: bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git - ref: fix/bitbox-uninitialized-device-status + ref: v0.0.10 dev_dependencies: flutter_test: From 8b53e77a43cf8b3e03635424504cce7560eda003 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:24:06 +0200 Subject: [PATCH 5/5] chore(deps): restore lockfile transitives, keep only the v0.0.10 bump A local pub get had downgraded SDK-pinned transitives (matcher, test, ...). Reset the lockfile to the staging baseline so the only delta is the bitbox_flutter v0.0.9 -> v0.0.10 bump. --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9f08a9153..795c516be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -174,10 +174,10 @@ packages: dependency: "direct main" description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -923,18 +923,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -1416,26 +1416,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" typed_data: dependency: transitive description: