diff --git a/.maestro/handbook/24-settings-delete-wallet.yaml b/.maestro/handbook/24-settings-delete-wallet.yaml
index 18c695bc9..df26d14d0 100644
--- a/.maestro/handbook/24-settings-delete-wallet.yaml
+++ b/.maestro/handbook/24-settings-delete-wallet.yaml
@@ -3,13 +3,13 @@
# this flow's diagnostic capture lands in build/handbook-captures/24-settings-delete-wallet.png.
# Continues from 23-settings-contact (no clearState). The contact page is
# pushed on top of the Settings menu, so we tap back to the menu, then open
-# "Geschäftsbeziehung beenden". That entry does not navigate to a new page —
+# "Abmelden". That entry does not navigate to a new page —
# it presents the SettingsConfirmLogoutWalletSheet modal, the confirmation
-# step for terminating the business relationship and signing out of the
-# wallet. This flow documents that sheet: it stops on the modal and does NOT
+# step for logging out, which removes the wallet from the device and returns
+# to onboarding. This flow documents that sheet: it stops on the modal and does NOT
# tick the confirmation checkbox or tap "Abmelden".
#
-# Both taps (back to the Settings menu, then opening the terminate entry) are
+# Both taps (back to the Settings menu, then opening the logout entry) are
# gated on their target not yet showing and re-tapped if one was silently
# dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26,
# mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the
@@ -36,7 +36,7 @@ appId: swiss.realunit.app
text: '.*Aus REALU Wallet abmelden.*'
commands:
- tapOn:
- text: '.*Geschäftsbeziehung beenden.*'
+ text: '.*Abmelden.*'
optional: true
- waitForAnimationToEnd
- extendedWaitUntil:
diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb
index 6a596d22c..5f9c8675e 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",
@@ -186,6 +188,7 @@
"pinVerify": "Geben Sie Ihre PIN ein",
"pinVerifyDescription": "Geben Sie Ihre PIN ein, um Ihre Wallet zu entsperren",
"pinVerifyFailed": "Die PIN ist falsch. Versuchen Sie es erneut.",
+ "pinVerifying": "Anmeldung…",
"pinVerifyLocked": "Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen.",
"pinVerifyLockedTemporarily": "Zu viele Fehlversuche. Versuchen Sie es in ${remaining} erneut.",
"pinVerifySeedDescription": "Geben Sie Ihre PIN ein, um Ihre Seed-Phrase anzuzeigen",
@@ -272,7 +275,7 @@
"settingsCurrency": "Währung",
"settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden",
"settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
- "settingsDeleteWallet": "Geschäftsbeziehung beenden",
+ "settingsDeleteWallet": "Abmelden",
"settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden",
"settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"settingsLanguages": "Sprachen",
diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb
index 52406e865..fdce5a692 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",
@@ -186,6 +188,7 @@
"pinVerify": "Enter your pin",
"pinVerifyDescription": "Enter your PIN to unlock your wallet",
"pinVerifyFailed": "PIN is wrong. Try again.",
+ "pinVerifying": "Signing in…",
"pinVerifyLocked": "Too many failed attempts. Use 'Forgot PIN?' to reset.",
"pinVerifyLockedTemporarily": "Too many failed attempts. Try again in ${remaining}.",
"pinVerifySeedDescription": "Enter your PIN to view your seed phrase",
@@ -272,7 +275,7 @@
"settingsCurrency": "Currency",
"settingsCurrencyLoadFailed": "Failed to load currencies",
"settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.",
- "settingsDeleteWallet": "Terminate business relationship",
+ "settingsDeleteWallet": "Logout",
"settingsLanguageLoadFailed": "Failed to load languages",
"settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.",
"settingsLanguages": "Languages",
diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html
index 85887157f..2c190fe32 100644
--- a/docs/handbook/de/index.html
+++ b/docs/handbook/de/index.html
@@ -1337,7 +1337,7 @@
06Einstellungen
Vollständige Settings-Liste: Sprachen, Währung, Netzwerk, Steuerbericht,
Nutzerdaten, Rechtsdokumente, Kontakt, Wallet-Adresse, Wallet-Sicherung,
- Geschäftsbeziehung beenden. Aktive Auswahlen (Sprache, Währung, Netzwerk)
+ Abmelden. Aktive Auswahlen (Sprache, Währung, Netzwerk)
werden inline als trailing Text rechts angezeigt.
Die Kachel Wallet-Sicherung erscheint nur bei einer Software-Wallet
(Re-Display der BIP-39-Recovery-Phrase) — bei einer BitBox ist sie
@@ -1503,12 +1503,12 @@
06Einstellungen
Das Bestätigungs-Sheet „Aus REALU Wallet abmelden", ausgelöst über
- den Einstellungs-Eintrag Geschäftsbeziehung beenden. Der User muss
+ den Einstellungs-Eintrag Abmelden. Der User muss
per Checkbox bestätigen, dass er seine Wiederherstellungsphrase gesichert
hat — erst dann wird der Abmelden-Button aktiv. Abmelden löscht die
Wallet vom Gerät und führt zurück ins Onboarding.
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..0774d1120 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 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/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart
index 1019dc79c..a6cd9161b 100644
--- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart
+++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart
@@ -15,10 +15,8 @@ part 'kyc_link_wallet_state.dart';
class KycLinkWalletCubit extends Cubit {
final RealUnitRegistrationService _registrationService;
- KycLinkWalletCubit(
- RealUnitRegistrationService registrationService,
- RealUnitUserDataDto userData,
- ) : _registrationService = registrationService,
+ KycLinkWalletCubit(RealUnitRegistrationService registrationService, RealUnitUserDataDto userData)
+ : _registrationService = registrationService,
super(KycLinkWalletReady(userData));
Future submit(RealUnitUserDataDto userData) async {
@@ -26,10 +24,14 @@ class KycLinkWalletCubit extends Cubit {
emit(KycLinkWalletSubmitting(userData));
await _registrationService.registerWallet(userData);
emit(const KycLinkWalletSuccess());
- } on BitboxNotConnectedException catch (e) {
- emit(KycLinkWalletFailure(e.toString(), cause: e));
+ } on BitboxNotConnectedException {
+ emit(KycLinkWalletBitboxRequired(userData));
} catch (e) {
emit(KycLinkWalletFailure(e.toString(), cause: e));
}
}
+
+ /// Re-runs registration after the BitBox connection was established via the
+ /// `ConnectBitboxPage` sheet. Mirror of `KycRegistrationSubmitCubit.retrySubmit`.
+ Future retrySubmit(RealUnitUserDataDto userData) => submit(userData);
}
diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart
index 848237276..b68a822a4 100644
--- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart
+++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart
@@ -38,3 +38,18 @@ class KycLinkWalletFailure extends KycLinkWalletState {
@override
List