diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6a596d22..a9f18de2 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 52406e86..aae7ff63 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 1e5caa6c..0a6e9a6a 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 96da7838..0774d112 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,19 @@ 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'; + + // 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, @@ -155,15 +168,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 _isDeviceUninitialized()) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + await _acquireWalletAndConnect(); } catch (e) { developer.log(e.toString(), name: '$ConnectBitboxCubit'); _pendingInit = null; @@ -173,6 +189,63 @@ 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 { + final status = await _service.getDeviceStatus().timeout(_deviceStatusTimeout); + return status == _statusUninitialized; + } catch (e) { + developer.log( + 'device status read failed/timed out, 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. + 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 _isDeviceUninitialized()) { + 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 8384e478..7e679463 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 ee72a39f..7f673133 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 d8b9567b..795c516b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.9" - resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1" + ref: "v0.0.10" + resolved-ref: "cd99ce656410e8df6c6585076d6ee0205a67b34c" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index fce1f4d8..6f5b347e 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.9 + ref: v0.0.10 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 43ee21c8..b9107444 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 11b764c5..2d2b0a98 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,121 @@ 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()); + }); + + // 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); + }); }); } 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 478ad0d0..096c0ea0 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(