From bf8255fb8021b509bfa862b6f5bfd4e29383dd46 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 4 Jun 2026 09:18:48 +0200 Subject: [PATCH] fix(restore): surface a retryable error instead of an endless spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restoreWallet() had no try/catch: a persist/crypto failure escaped as an unhandled async error, the cubit stayed in isLoading and — because the button renders .loading whenever wallet==null — the user was stranded on a permanent spinner with no way out. End-to-end fix across both layers: - cubit: try/catch + isClosed guard, emits a terminal RestoreWalletState (hasError: true) and logs the failure - state: new hasError flag (in props) - button: on hasError renders a tappable "Restore failed – tap to retry" (idle + refresh icon; the error-state button is non-interactive) that re-invokes restoreWallet with the entered seed - l10n: new restoreWalletFailed string (EN/DE) Regressions: restore_wallet_cubit_test.dart (error state + retry recovery), restore_wallet_page_test.dart (tappable retry, no spinner). Issue #657 — Part 1, finding B1 (HIGH). --- assets/languages/strings_de.arb | 19 +++---- assets/languages/strings_en.arb | 19 +++---- .../restore_wallet/restore_wallet_cubit.dart | 52 ++++++++++++------- .../restore_wallet/restore_wallet_state.dart | 7 ++- .../widgets/restore_wallet_button.dart | 11 ++++ .../restore_wallet_cubit_test.dart | 38 ++++++++++++++ .../restore_wallet_page_test.dart | 23 ++++++++ 7 files changed, 132 insertions(+), 37 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index e3f21595..9bbbe460 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "Es gibt ein technisches Problem. Bitte überprüfen Sie Ihr E-Mail-Postfach, möglicherweise fehlt noch eine Bestätigung Ihrer Blockchain-Adresse. Andernfalls versuchen Sie es später erneut. Falls der Fehler weiterhin besteht, kontaktieren Sie unseren Support.", "buyPaymentInformation": "Zahlungsinformationen", "buyPaymentInformationDescription": "Bitte überweisen Sie den Kaufbetrag mit diesen Angaben über Ihre Bankanwendung. Der Verwendungszweck ist wichtig!", - "buyRealUnit": "RealUnit kaufen", "buyRealu": "RealUnit Token kaufen", + "buyRealUnit": "RealUnit kaufen", "cancel": "Abbrechen", "changeAddress": "Adresse ändern", "changeInReview": "Änderung in Prüfung", @@ -53,11 +53,11 @@ "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.", - "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", "connectBitboxSignatureFailed": "Ihre Anmeldesignatur konnte nicht erfasst werden. Sie können es erneut versuchen oder trotzdem fortfahren – Ihre BitBox wird dann möglicherweise für Ihren ersten Kauf erneut benötigt.", "connectBitboxSignatureFailedTitle": "Anmeldung nicht abgeschlossen", + "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxTitle": "BitBox verbinden", "connected": "Verbunden", "connectedBitboxContent": "Bitte bestätigen Sie und folgen nun den letzten Anweisungen auf Ihrer BitBox.", @@ -197,8 +197,8 @@ "proofDocument": "Nachweis-Dokument", "purposeOfPayment": "Verwendungszweck", "qrCode": "QR-Code", - "realunitStockToken": "RealUnit Aktientoken", "realunitStockprice": "RealUnit Aktienkurs", + "realunitStockToken": "RealUnit Aktientoken", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Aus REALU Wallet abmelden", "realunitWalletLogoutCheck": "Ich habe meine Wiederherstellungsphrase gesichert.", @@ -232,6 +232,7 @@ "reset": "Zurücksetzen", "residence": "Residenz", "restoreWallet": "Wallet wiederherstellen", + "restoreWalletFailed": "Wiederherstellung fehlgeschlagen – erneut versuchen", "restoreWalletFromSeedDescription": "Bitte geben Sie Ihre 12 Wiederherstellungs-Wörter in der korrekten Reihenfolge ein, um wieder Zugriff auf Ihre Wallet zu erhalten.", "restoreWalletSubtitle": "Ich habe bereits eine Wallet (z.B. Aktionariat) und möchte diese wiederherstellen.", "restoreWalletSuccessful": "Wallet wiederhergestellt", @@ -246,18 +247,18 @@ "sellBitboxCheckingEth": "Wallet-Guthaben wird geprüft", "sellBitboxDepositDescription": "Bestätigen Sie auf der BitBox, um ZCHF an die DFX-Einzahlungsadresse zu überweisen.", "sellBitboxDepositFrom": "Sie senden", + "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxDepositRetryDescription": "Der Tausch wurde abgeschlossen, aber die ZCHF-Einzahlung konnte nicht gesendet werden. Ihre Mittel sind sicher. Tippen Sie auf Wiederholen.", "sellBitboxDepositRetryTitle": "Einzahlung fehlgeschlagen", "sellBitboxDepositTitle": "ZCHF an DFX senden", "sellBitboxDepositTo": "DFX-Einzahlung", - "sellBitboxDepositing": "ZCHF wird gesendet. Bestätigen Sie auf der Bitbox", "sellBitboxEthReady": "Wallet bereit", "sellBitboxEthReadyDescription": "Ihr Wallet hat genug ETH, um mit dem Verkauf fortzufahren.", "sellBitboxSwapDescription": "Bestätigen Sie auf Ihrem BitBox, um REALU über den BrokerBot in ZCHF zu tauschen.", "sellBitboxSwapFrom": "Sie senden", + "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxSwapTitle": "REALU → ZCHF tauschen", "sellBitboxSwapTo": "Sie erhalten", - "sellBitboxSwapping": "Tausch on-chain. Bestätigen Sie auf der Bitbox.", "sellBitboxWaitingForEth": "Gasgebühren werden angefordert", "sellBitboxWaitingForEthDescription": "Ein kleiner ETH-Betrag wird an Ihr Wallet gesendet, um die Transaktionsgebühren zu decken. Dies kann einige Minuten dauern.", "sellMinAmount": "Mindestbetrag: ${amount} ${currency}", @@ -282,10 +283,10 @@ "settingsWalletBackupSubtitle1": "Bitte notieren Sie Ihre 12 Wiederherstellungs-Wörter in der exakten Reihenfolge auf einem Blatt Papier und bewahren Sie sie absolut sicher auf.", "settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.", "showSeed": "Seed anzeigen", - "signMessage": "Signierte Nachricht", - "signMessageGet": "Signierte Nachricht abrufen", "signature": "Signatur", "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", + "signMessage": "Signierte Nachricht", + "signMessageGet": "Signierte Nachricht abrufen", "skip": "Überspringen", "softwareTermsText": "Mit der Nutzung dieser App akzeptieren Sie die Nutzungsbedingungen dieser Software.", "softwareTermsTextHighlighted": "Nutzungsbedingungen", @@ -329,9 +330,9 @@ "transactionBuy": "Kauf", "transactionHistory": "Transaktionshistorie", "transactionPending": "In Bearbeitung", + "transactions": "Transaktionen", "transactionSell": "Verkauf", "transactionWaitingForPayment": "Warte auf Zahlung", - "transactions": "Transaktionen", "twoFa": "2-Faktor Authentifizierung", "twoFaCodeRequired": "Code ist erforderlich", "twoFaCodeTooShort": "Der Code sollte 6 Ziffern lang sein", @@ -356,4 +357,4 @@ "youPay": "Sie bezahlen", "youReceive": "Sie erhalten", "youSell": "Sie verkaufen" -} +} \ No newline at end of file diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 56e4db7f..8c7e643e 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -33,8 +33,8 @@ "buyPaymentConfirmFailedAktionariat": "There is a technical problem. Please check your email inbox — you may still need to confirm your blockchain address. Otherwise, please try again later. If the error persists, contact our support team.", "buyPaymentInformation": "Payment information", "buyPaymentInformationDescription": "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!", - "buyRealUnit": "Buy RealUnit", "buyRealu": "Buy RealUnit Token", + "buyRealUnit": "Buy RealUnit", "cancel": "Cancel", "changeAddress": "Change address", "changeInReview": "Change in review", @@ -53,11 +53,11 @@ "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.", - "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", "connectBitboxSignatureFailed": "We couldn't capture your sign-in signature. You can retry, or continue anyway – your BitBox may then be needed again for your first purchase.", "connectBitboxSignatureFailedTitle": "Sign-in not completed", + "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxTitle": "Connect BitBox", "connected": "Connected", "connectedBitboxContent": "Please confirm and follow the last steps on your BitBox.", @@ -197,8 +197,8 @@ "proofDocument": "Proof document", "purposeOfPayment": "Purpose of payment", "qrCode": "QR code", - "realunitStockToken": "RealUnit Stock Token", "realunitStockprice": "RealUnit Stockprice", + "realunitStockToken": "RealUnit Stock Token", "realunitWallet": "RealUnit Wallet", "realunitWalletLogout": "Log out of REALU Wallet", "realunitWalletLogoutCheck": "I have backed up my recovery phrase.", @@ -232,6 +232,7 @@ "reset": "Reset", "residence": "Residence", "restoreWallet": "Restore wallet", + "restoreWalletFailed": "Restore failed – tap to retry", "restoreWalletFromSeedDescription": "Please enter your 12 recovery words in the correct order to regain access to your wallet.", "restoreWalletSubtitle": "I already have a wallet (e.g., Aktionariat) and would like to restore it.", "restoreWalletSuccessful": "Wallet restored", @@ -246,18 +247,18 @@ "sellBitboxCheckingEth": "Checking your wallet balance", "sellBitboxDepositDescription": "Confirm on your BitBox to transfer ZCHF to the DFX deposit address.", "sellBitboxDepositFrom": "You send", + "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxDepositRetryDescription": "The swap was completed but the ZCHF deposit could not be sent. Your funds are safe. Tap retry to try again.", "sellBitboxDepositRetryTitle": "Deposit failed", "sellBitboxDepositTitle": "Send ZCHF to DFX", "sellBitboxDepositTo": "DFX deposit", - "sellBitboxDepositing": "Sending ZCHF. Please confirm on the Bitbox.", "sellBitboxEthReady": "Wallet ready", "sellBitboxEthReadyDescription": "Your wallet has enough ETH to proceed with the sale.", "sellBitboxSwapDescription": "Confirm on your BitBox to swap REALU for ZCHF via the BrokerBot.", "sellBitboxSwapFrom": "You send", + "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxSwapTitle": "Swap REALU → ZCHF", "sellBitboxSwapTo": "You receive", - "sellBitboxSwapping": "Swapping on-chain. Please confirm on the Bitbox.", "sellBitboxWaitingForEth": "Requesting gas funds", "sellBitboxWaitingForEthDescription": "A small amount of ETH is being sent to your wallet to cover transaction fees. This may take a few minutes.", "sellMinAmount": "Minimum amount: ${amount} ${currency}", @@ -282,10 +283,10 @@ "settingsWalletBackupSubtitle1": "Please write down your 12 recovery words in the exact order on a piece of paper and keep them in a completely safe place.", "settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.", "showSeed": "Show Seed", - "signMessage": "Sign Message", - "signMessageGet": "Get Sign Message", "signature": "Signature", "signingCancelled": "Signature cancelled — please confirm on the BitBox again", + "signMessage": "Sign Message", + "signMessageGet": "Get Sign Message", "skip": "Skip", "softwareTermsText": "By using this app, you accept the terms of use of this software.", "softwareTermsTextHighlighted": "terms of use", @@ -329,9 +330,9 @@ "transactionBuy": "Buy", "transactionHistory": "Transaction history", "transactionPending": "Processing", + "transactions": "Transactions", "transactionSell": "Sell", "transactionWaitingForPayment": "Waiting for payment", - "transactions": "Transactions", "twoFa": "Two-factor authentication", "twoFaCodeRequired": "Code is required", "twoFaCodeTooShort": "Code should be 6 digits", @@ -356,4 +357,4 @@ "youPay": "You pay", "youReceive": "You receive", "youSell": "You sell" -} +} \ No newline at end of file diff --git a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart index 8b945bde..47f3c369 100644 --- a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart +++ b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -20,23 +21,38 @@ class RestoreWalletCubit extends Cubit { final normalizedSeed = seed.split(' ').where((element) => element.isNotEmpty).join(' '); - final wallet = await _walletService.restoreWallet('Obi-Wallet-Kenobi', normalizedSeed); - // Fire-and-forget the auth-signature capture so a 20 s HTTP timeout doesn't - // block the wallet-restore UI. The lazy path in DFXAuthService.getSignature - // is the safety net. - unawaited( - warmAuthSignature( - _authService, - wallet.currentAccount, - loggerName: '$RestoreWalletCubit', - ), - ); - - emit( - RestoreWalletState( - isLoading: false, - wallet: wallet, - ), - ); + try { + final wallet = await _walletService.restoreWallet('Obi-Wallet-Kenobi', normalizedSeed); + // Fire-and-forget the auth-signature capture so a 20 s HTTP timeout doesn't + // block the wallet-restore UI. The lazy path in DFXAuthService.getSignature + // is the safety net. + unawaited( + warmAuthSignature( + _authService, + wallet.currentAccount, + loggerName: '$RestoreWalletCubit', + ), + ); + + if (isClosed) return; + emit( + RestoreWalletState( + isLoading: false, + wallet: wallet, + ), + ); + } catch (e, stackTrace) { + // A persist/crypto/storage failure used to escape as an unhandled async + // error, stranding the UI on a permanent spinner with no retry + // (issue #657 P1 B1). Surface a terminal, retryable error state instead. + developer.log( + 'restoreWallet failed', + name: '$RestoreWalletCubit', + error: e, + stackTrace: stackTrace, + ); + if (isClosed) return; + emit(const RestoreWalletState(isLoading: false, hasError: true)); + } } } diff --git a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_state.dart b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_state.dart index 704eab3f..08b326b8 100644 --- a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_state.dart +++ b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_state.dart @@ -2,13 +2,18 @@ part of 'restore_wallet_cubit.dart'; class RestoreWalletState extends Equatable { final bool isLoading; + + /// True when the restore attempt threw — the UI leaves the loading state and + /// offers a retry instead of spinning forever (issue #657 P1 B1). + final bool hasError; final SoftwareWallet? wallet; const RestoreWalletState({ this.isLoading = false, + this.hasError = false, this.wallet, }); @override - List get props => [isLoading, wallet]; + List get props => [isLoading, hasError, wallet]; } diff --git a/lib/screens/restore_wallet/widgets/restore_wallet_button.dart b/lib/screens/restore_wallet/widgets/restore_wallet_button.dart index 851689d3..6477ac3c 100644 --- a/lib/screens/restore_wallet/widgets/restore_wallet_button.dart +++ b/lib/screens/restore_wallet/widgets/restore_wallet_button.dart @@ -35,6 +35,17 @@ class RestoreWalletButton extends StatelessWidget { state: .success, ); } + // Restore threw: leave the spinner and offer a tappable retry — the + // error-state button is non-interactive, so render an idle button + // with the failure label instead (issue #657 P1 B1). + if (restoreWalletState.hasError) { + return AppFilledButton( + icon: Icons.refresh, + label: S.of(context).restoreWalletFailed, + onPressed: () => + context.read().restoreWallet(_controllers.seed), + ); + } return AppFilledButton( label: S.of(context).next, state: .loading, diff --git a/test/screens/restore_wallet/restore_wallet_cubit_test.dart b/test/screens/restore_wallet/restore_wallet_cubit_test.dart index 5d107e61..0b8d6729 100644 --- a/test/screens/restore_wallet/restore_wallet_cubit_test.dart +++ b/test/screens/restore_wallet/restore_wallet_cubit_test.dart @@ -53,6 +53,44 @@ void main() { expect(cubit.state.isLoading, isFalse); }); + test('restoreWallet emits a terminal hasError state (not endless loading) when the ' + 'service throws (issue #657 P1 B1)', () async { + when(() => service.restoreWallet(any(), any())) + .thenAnswer((_) async => throw Exception('persist failed')); + final cubit = RestoreWalletCubit(service, authService); + + cubit.restoreWallet(_testMnemonic); + final errorState = await cubit.stream + .firstWhere((s) => s.hasError) + .timeout(const Duration(seconds: 1)); + + // No permanent spinner: loading cleared, error surfaced, no wallet. + expect(errorState.hasError, isTrue); + expect(errorState.isLoading, isFalse); + expect(errorState.wallet, isNull); + }); + + test('restoreWallet recovers on retry after a failure', () async { + final restored = SoftwareWallet(1, 'W', _testMnemonic); + var attempts = 0; + when(() => service.restoreWallet(any(), any())).thenAnswer((_) async { + attempts++; + if (attempts == 1) throw Exception('transient'); + return restored; + }); + final cubit = RestoreWalletCubit(service, authService); + + cubit.restoreWallet(_testMnemonic); + await cubit.stream.firstWhere((s) => s.hasError); + + // Retry: the same entry point is called again and now succeeds. + cubit.restoreWallet(_testMnemonic); + await cubit.stream.firstWhere((s) => s.wallet != null); + + expect(cubit.state.wallet, same(restored)); + expect(cubit.state.hasError, isFalse); + }); + test('restoreWallet emits an interim isLoading=true state', () async { final restored = SoftwareWallet(1, 'W', _testMnemonic); when(() => service.restoreWallet(any(), any())).thenAnswer((_) async => restored); diff --git a/test/screens/restore_wallet/restore_wallet_page_test.dart b/test/screens/restore_wallet/restore_wallet_page_test.dart index b50f35d4..1aaac283 100644 --- a/test/screens/restore_wallet/restore_wallet_page_test.dart +++ b/test/screens/restore_wallet/restore_wallet_page_test.dart @@ -182,6 +182,29 @@ void main() { findsOne, ); }); + + testWidgets( + 'offers a tappable retry (not a dead spinner) when restore failed ' + '(issue #657 P1 B1)', (tester) async { + when(() => validateSeedCubit.state).thenReturn(ValidateSeedState.valid); + when(() => restoreWalletCubit.state) + .thenReturn(const RestoreWalletState(hasError: true)); + + await tester.pumpApp(buildSubject(const RestoreWalletView())); + + final button = find.descendant( + of: find.byType(RestoreWalletButton), + matching: find.byType(FilledButton), + ); + // Not stuck on the loading spinner; the button is interactive. + expect(find.byType(CupertinoActivityIndicator), findsNothing); + expect(tester.widget(button).enabled, isTrue); + + // Tapping it retries the restore. + await tester.tap(button); + await tester.pump(); + verify(() => restoreWalletCubit.restoreWallet(any())).called(1); + }); }); group('$RestoreWalletInputField', () {