Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions lib/packages/hardware_wallet/bitbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> getDeviceStatus() => bitboxManager.getDeviceStatus();

/// Derives the wallet's ETH address from the device, retrying transient empty
/// reads before giving up.
///
Expand Down
91 changes: 82 additions & 9 deletions lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
// 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,
Expand Down Expand Up @@ -155,15 +168,18 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
'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;
Expand All @@ -173,6 +189,63 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
}
}

/// 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<bool> _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<void> _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<void> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
14 changes: 14 additions & 0 deletions lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectBitboxCubit>().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',
Expand Down
4 changes: 2 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v0.0.9"
resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1"
ref: "fix/bitbox-uninitialized-device-status"
resolved-ref: "1cfab9d982f5a822dfbaf2d1ec961c8fdd7e9e30"
url: "https://github.com/DFXswiss/bitbox_flutter.git"
source: git
version: "0.0.1"
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ dependencies:
bitbox_flutter:
git:
url: https://github.com/DFXswiss/bitbox_flutter.git
ref: v0.0.9
ref: fix/bitbox-uninitialized-device-status

dev_dependencies:
flutter_test:
Expand Down
20 changes: 20 additions & 0 deletions test/packages/hardware_wallet/bitbox_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {});
Expand Down Expand Up @@ -453,5 +457,121 @@ void main() {
expect(cubit.state, isA<BitboxConnected>());
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<BitboxCheckHash>(cubit);
await cubit.confirmPairing();

expect(cubit.state, isA<BitboxNotInitialized>());
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<void>.delayed(const Duration(milliseconds: 50));
expect(cubit.state, isA<BitboxNotInitialized>());
});

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<BitboxCheckHash>(cubit);
await cubit.confirmPairing();
expect(cubit.state, isA<BitboxNotInitialized>());

await cubit.recheckDeviceStatus();
expect(cubit.state, isA<BitboxConnected>());
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<BitboxCheckHash>(cubit);
await cubit.confirmPairing();
expect(cubit.state, isA<BitboxNotInitialized>());

await cubit.recheckDeviceStatus();
expect(cubit.state, isA<BitboxNotInitialized>());
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<BitboxNotConnected>());
});

// 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<BitboxCheckHash>(cubit);
await cubit.confirmPairing();

expect(cubit.state, isA<BitboxConnected>());
verify(() => walletService.createBitboxWallet(any())).called(1);
});
});
}
20 changes: 20 additions & 0 deletions test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +21,8 @@ class _MockConnectBitboxCubit extends MockCubit<BitboxConnectionState>

class _MockBitboxWallet extends Mock implements BitboxWallet {}

class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {}

void main() {
late _MockConnectBitboxCubit cubit;
late _MockBitboxWallet wallet;
Expand Down Expand Up @@ -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<AppFilledButton>(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(
Expand Down