diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 23357f37..1755a9d9 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -211,6 +211,7 @@ "registerEmailInvalid": "E-Mail ist ungültig", "registerEmailRequired": "E-Mail ist erforderlich", "registerEmailVerification": "E-Mail Bestätigung", + "registerEmailVerificationBitboxRequired": "Ihre BitBox ist nicht verbunden. Bitte erneut verbinden, um die Wallet-Registrierung abzuschliessen.", "registerEmailVerificationBitboxSignHint": "Bitte bestätigen Sie die Signatur auf Ihrer BitBox — die Nachricht erstreckt sich über mehrere Seiten, halten Sie den Touchsensor zum Weiterblättern.", "registerEmailVerificationButton": "Ich habe meine E-Mail bestätigt", "registerEmailVerificationDescription": "Wie es aussieht, haben Sie bereits ein Konto. Wir haben Ihnen gerade eine E-Mail geschickt. Um mit Ihrem bestehenden Konto fortzufahren, bestätigen Sie bitte Ihre E-Mail-Adresse, indem Sie auf den zugesandten Link klicken.", @@ -312,6 +313,8 @@ "supportTransactionIssue": "Transaktionsproblem", "supportTypeMessage": "Beschreiben Sie Ihr Anliegen", "swissPaymentTextInvalid": "Nur in der Schweiz gültige Buchstaben und Zeichen sind erlaubt", + "swissTaxResidence": "Ich bin in der Schweiz steuerpflichtig", + "swissTaxResidenceDescription": "Aktivieren, falls Ihr primärer Steuerwohnsitz die Schweiz ist. Erforderlich für FATCA / CRS-Meldungen.", "tapHereToView": "Hier tippen, um anzuzeigen", "taxReport": "Steuerbericht", "taxReportDescription": "Hier können Sie Ihren Steuerbericht für ein spezifisches Datum generieren.", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 6ada4d8a..83f13279 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -211,6 +211,7 @@ "registerEmailInvalid": "Email is invalid", "registerEmailRequired": "Email is required", "registerEmailVerification": "Email verification", + "registerEmailVerificationBitboxRequired": "Your BitBox is not connected. Please reconnect to complete the wallet registration.", "registerEmailVerificationBitboxSignHint": "Confirm the signature on your BitBox — the message spans multiple pages, hold the touch sensor to advance.", "registerEmailVerificationButton": "I have confirmed my email address", "registerEmailVerificationDescription": "It looks like you already have an account. We have just sent you an email. To continue with your existing account, please confirm your email address by clicking on the link in the email.", @@ -312,6 +313,8 @@ "supportTransactionIssue": "Transaction issue", "supportTypeMessage": "Describe your issue", "swissPaymentTextInvalid": "Only letters and characters valid in Switzerland are allowed", + "swissTaxResidence": "I am a tax resident in Switzerland", + "swissTaxResidenceDescription": "Tick if Switzerland is your primary tax residence. Required for FATCA / CRS reporting.", "tapHereToView": "Tap here to view", "taxReport": "Tax report", "taxReportDescription": "Here you can generate your tax report for a specific date.", diff --git a/lib/packages/service/dfx/models/user/dto/user_dto.dart b/lib/packages/service/dfx/models/user/dto/user_dto.dart index 33722348..6cb2f1d0 100644 --- a/lib/packages/service/dfx/models/user/dto/user_dto.dart +++ b/lib/packages/service/dfx/models/user/dto/user_dto.dart @@ -5,10 +5,17 @@ class UserDto { final UserKycDto kyc; final UserCapabilitiesDto capabilities; + /// Lowercased blockchain addresses currently associated with this user + /// account (the `addresses[].address` list from `/v2/user`). This is a + /// best-effort hint: absent/empty data is treated as unknown by callers, not + /// as proof that the locally-active wallet is unregistered. + final List addresses; + const UserDto({ this.mail, required this.kyc, this.capabilities = const UserCapabilitiesDto(), + this.addresses = const [], }); factory UserDto.fromJson(Map json) { @@ -18,6 +25,13 @@ class UserDto { capabilities: json['capabilities'] != null ? UserCapabilitiesDto.fromJson(json['capabilities'] as Map) : const UserCapabilitiesDto(), + addresses: + (json['addresses'] as List?) + ?.map((a) => a is Map ? a['address'] : null) + .whereType() + .map((address) => address.toLowerCase()) + .toList() ?? + const [], ); } } diff --git a/lib/screens/buy/buy_page.dart b/lib/screens/buy/buy_page.dart index a6d6b9e9..a031d928 100644 --- a/lib/screens/buy/buy_page.dart +++ b/lib/screens/buy/buy_page.dart @@ -41,7 +41,12 @@ class BuyView extends StatefulWidget { } class _BuyViewState extends State { - final TextEditingController _amountController = TextEditingController(); + // Pre-fill the default 300 immediately so the amount is shown from the + // first frame, independent of the brokerbot share-conversion round-trip. + // The converter still emits fiatText='300' (see BuyPage's onFiatChanged), + // and the loading→false listener re-syncs (no-op when already equal); but + // if that round-trip stalls, the field must not render blank. + final TextEditingController _amountController = TextEditingController(text: '300'); final TextEditingController _resultController = TextEditingController(); @override diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index 3bbf127f..b9416ee8 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -64,21 +64,34 @@ class BuyPaymentInfoCubit extends Cubit { minAmount: paymentInfo.minVolume!, ); } - return const BuyPaymentInfoFailure(PaymentInfoError.unknown); + return BuyPaymentInfoFailure( + PaymentInfoError.unknown, + message: paymentInfo.error ?? '', + ); } return BuyPaymentInfoSuccess(paymentInfo); } on KycLevelRequiredException catch (e) { return BuyPaymentInfoFailure( PaymentInfoError.kycRequired, + message: e.toString(), requiredLevel: e.requiredLevel, ); - } on RegistrationRequiredException { - return const BuyPaymentInfoFailure(PaymentInfoError.registrationRequired); - } on BitboxNotConnectedException { - return const BuyPaymentInfoFailure(PaymentInfoError.bitboxDisconnected); + } on RegistrationRequiredException catch (e) { + return BuyPaymentInfoFailure( + PaymentInfoError.registrationRequired, + message: e.toString(), + ); + } on BitboxNotConnectedException catch (e) { + return BuyPaymentInfoFailure( + PaymentInfoError.bitboxDisconnected, + message: e.toString(), + ); } catch (e) { developer.log(e.toString()); - return const BuyPaymentInfoFailure(PaymentInfoError.unknown); + return BuyPaymentInfoFailure( + PaymentInfoError.unknown, + message: e.toString(), + ); } } diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart index 81113f59..7e5cfb4b 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_state.dart @@ -27,11 +27,12 @@ class BuyPaymentInfoSuccess extends BuyPaymentInfoState { class BuyPaymentInfoFailure extends BuyPaymentInfoState { final PaymentInfoError error; final int? requiredLevel; + final String message; - const BuyPaymentInfoFailure(this.error, {this.requiredLevel}); + const BuyPaymentInfoFailure(this.error, {this.requiredLevel, this.message = ''}); @override - List get props => [error, requiredLevel]; + List get props => [error, requiredLevel, message]; } class BuyPaymentInfoMinAmountNotMetFailure extends BuyPaymentInfoFailure { diff --git a/lib/screens/kyc/cubits/kyc/kyc_state.dart b/lib/screens/kyc/cubits/kyc/kyc_state.dart index c72971e3..d1afa3e5 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_state.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_state.dart @@ -66,6 +66,9 @@ class KycAccountMergeRequested extends KycState { const KycAccountMergeRequested(); } +/// The backend reported a KYC step (or a `PendingReview`) that the app cannot +/// map to a known UI page. Surfaced as an explicit failure page (never a silent +/// `KycCompleted`), naming the offending step via [stepName] when one is known. class KycUnsupportedStepFailure extends KycState { // Null when the backend says `PendingReview` but the step list contains no // `isRequired` step we can name — we still surface the failure (never a diff --git a/lib/screens/kyc/kyc_page_manager.dart b/lib/screens/kyc/kyc_page_manager.dart index f44874c5..c2e23030 100644 --- a/lib/screens/kyc/kyc_page_manager.dart +++ b/lib/screens/kyc/kyc_page_manager.dart @@ -45,6 +45,10 @@ class KycViewManager extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) => switch (state) { + // KycInitial is the pre-checkKyc() seed state. Render the loading page + // (not the diagnostic catch-all below) so the first build frame never + // flashes a raw "Unhandled KYC state: KycInitial" error to the user. + KycInitial() => const KycLoadingPage(), KycLoading() => const KycLoadingPage(), KycFailure(:final message) => KycFailurePage(message: message), KycRequiredFailure() => KycFailurePage( @@ -72,9 +76,18 @@ class KycViewManager extends StatelessWidget { KycStep.twoFa => const Kyc2FaPage(), KycStep.ident => KycIdentPage(accessToken: urlOrToken ?? ''), KycStep.financialData => KycFinancialDataPage(url: urlOrToken ?? ''), - (_) => const Scaffold(), + // DfxApproval is a backend-side manual review step with no user + // action — the user has completed everything actionable and is + // waiting for DFX to approve. Render the pending/review screen + // instead of a blank Scaffold (previously fell through to the + // grey catch-all below). + KycStep.dfxApproval => const KycPendingPage(pendingStep: KycStep.dfxApproval), }, - KycState() => const Scaffold(), + // Never render a blank grey Scaffold — surface the unhandled state so + // it is diagnosable on-device instead of looking like a hang. + KycState() => KycFailurePage( + message: 'Unhandled KYC state: ${state.runtimeType}', + ), }, ); } diff --git a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart index 89dae281..1a7da6dc 100644 --- a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart +++ b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/utils/jwt_decoder.dart'; @@ -12,6 +13,11 @@ class KycEmailVerificationCubit extends Cubit { final DFXAuthService _dfxService; final RealUnitRegistrationService _registrationService; + /// Invoked only after registerWallet succeeded. This keeps success + /// notifications tied to the actual EIP-712 round-trip instead of a + /// speculative page pop. + final void Function()? _onSignProduced; + // `Future.timeout` does not cancel the underlying work, so a late HTTP // response from an earlier call can still resume after a retry. Each // `checkEmailVerification` captures its own generation; any continuation @@ -25,16 +31,38 @@ class KycEmailVerificationCubit extends Cubit { // is settled — re-running the account-id comparison on a retry would just // emit `Failure` ("email not yet confirmed") because `getAuthToken` keeps // returning the new (merged) account. The remaining work that can still - // race is `getRegistrationInfo` propagation on the user-data side, so a - // retry after a `RegistrationFailure` should skip the auth-side check and - // go straight to `_completeRegistration`. - bool _mergeDetected = false; + // race is `getRegistrationInfo` propagation on the user-data side, so a retry + // after a `RegistrationFailure` should skip the auth-side check and go + // straight to `_completeRegistration`. + // + // BL-006 invariant: once the merge is detected (the one-shot JWT account-id + // delta), this latch STAYS set. A mid-sign BitBox drop must NOT reset it — + // the merge already happened, so the post-reconnect retry re-attempts the + // registration SIGN, not the one-shot account-id check (which would now see + // the same merged account twice and dead-end on "email not confirmed"). + // Seeded from `initialMergeDetected` for the re-entrant resume path. + bool _mergeDetected; KycEmailVerificationCubit({ required DFXAuthService dfxService, required RealUnitRegistrationService registrationService, + void Function()? onSignProduced, + // Re-entrant resume path: when the merge was already confirmed on the + // auth side in a previous session (app restarted mid-merge), the one-shot + // JWT account-id delta can no longer be observed. Seed the latch as + // detected so checkEmailVerification skips straight to registerWallet. + bool initialMergeDetected = false, + // Bounded auto-retry for the post-merge user-data propagation race. + // Injectable so tests can drive the immediate-fail contract (retries: 1, + // zero delay) without waiting on real timers. + int registrationInfoRetries = 4, + Duration registrationInfoRetryDelay = const Duration(seconds: 2), }) : _dfxService = dfxService, _registrationService = registrationService, + _onSignProduced = onSignProduced, + _mergeDetected = initialMergeDetected, + _registrationInfoRetries = registrationInfoRetries, + _registrationInfoRetryDelay = registrationInfoRetryDelay, super(const KycEmailVerificationInitial()); Future checkEmailVerification() async { @@ -63,36 +91,70 @@ class KycEmailVerificationCubit extends Cubit { // new wallet with the merged user via the EIP-712 registration signature. if (await _completeRegistration(generation)) { if (isClosed || generation != _runGeneration) return; + // Sign succeeded — notify only from inside the cubit's success branch. + _onSignProduced?.call(); emit(const KycEmailVerificationSuccess()); } - // else: _completeRegistration already emitted RegistrationFailure; we - // intentionally do NOT emit Success here so the verification page stays - // open and the user can retry without the failure being papered over. + // else: _completeRegistration already emitted RegistrationFailure or + // KycEmailVerificationBitboxRequired; we intentionally do NOT emit + // Success here so the verification page stays open and the user can + // retry without the failure being papered over. } /// Returns `true` when the wallet was successfully registered with the /// (now-merged) user account. On failure the cubit is already in - /// [KycEmailVerificationRegistrationFailure] so the listener can show the + /// [KycEmailVerificationRegistrationFailure] or + /// [KycEmailVerificationBitboxRequired] so the listener can show the /// error to the user. + // Bounded auto-retry budget for the post-merge user-data propagation race. + // getRegistrationInfo is a side-effect-free GET, so polling it a few times + // is safe; this absorbs the common "auth merged, user-data not propagated + // yet" window without forcing the user to tap retry manually. Injected so + // tests run without real delays. + final int _registrationInfoRetries; + final Duration _registrationInfoRetryDelay; + Future _completeRegistration(int generation) async { try { - final info = await _registrationService.getRegistrationInfo(); - if (isClosed || generation != _runGeneration) return false; - if (info.realUnitUserDataDto == null) { - // Backend race: the auth service reports the merged account while the - // user-data service hasn't propagated yet. Surface as a recoverable - // failure so the user can retry by tapping the confirmation button - // again — by then propagation will usually have completed, and the - // retry path skips the auth-side check thanks to `_mergeDetected`. + // Backend race: the auth service reports the merged account while the + // user-data service hasn't propagated `realUnitUserDataDto` yet. Poll a + // bounded number of times before giving up so the merge completes + // automatically in the common case instead of dead-ending on a manual + // retry. The `_mergeDetected` latch already guarantees a later manual + // retry skips the auth-side check, so a final failure here is still + // recoverable. + var info = await _registrationService.getRegistrationInfo(); + for (var attempt = 0; attempt < _registrationInfoRetries; attempt++) { + if (isClosed || generation != _runGeneration) return false; + if (info.realUnitUserDataDto != null) break; + if (attempt < _registrationInfoRetries - 1) { + await Future.delayed(_registrationInfoRetryDelay); + if (isClosed || generation != _runGeneration) return false; + info = await _registrationService.getRegistrationInfo(); + } + } + final userData = info.realUnitUserDataDto; + if (userData == null) { developer.log( - 'getRegistrationInfo returned null realUnitUserDataDto after merge', + 'getRegistrationInfo still null after $_registrationInfoRetries attempts', ); emit(const KycEmailVerificationRegistrationFailure()); return false; } - await _registrationService.registerWallet(info.realUnitUserDataDto!); + await _registrationService.registerWallet(userData); if (isClosed || generation != _runGeneration) return false; return true; + } on BitboxNotConnectedException { + // BL-006 — the BitBox dropped mid-sign. Route to the typed + // BitboxRequired state so the page can open the reconnect sheet instead + // of a generic "Registration failed" snackbar. Keep `_mergeDetected` + // set: the merge already happened, so the post-reconnect retry must + // re-attempt the registration sign — NOT re-run the one-shot account-id + // check, which would now see the same merged account twice and dead-end + // on "email not confirmed". + if (isClosed || generation != _runGeneration) return false; + emit(const KycEmailVerificationBitboxRequired()); + return false; } catch (e) { if (isClosed || generation != _runGeneration) return false; developer.log('registerWallet failed: $e'); diff --git a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_state.dart b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_state.dart index 77cf44a9..ca9a91a8 100644 --- a/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_state.dart +++ b/lib/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_state.dart @@ -27,3 +27,13 @@ class KycEmailVerificationRegistrationFailure extends KycEmailVerificationState { const KycEmailVerificationRegistrationFailure(); } + +/// Emitted when the registerWallet sign threw +/// [BitboxNotConnectedException] mid-ceremony — closes BL-006. The page +/// listener routes this state to [showBitboxReconnectSheet]; on a +/// successful reconnect the cubit re-runs [checkEmailVerification] +/// (with the merge latch reset so the auth-side JWT account check runs +/// again). +class KycEmailVerificationBitboxRequired extends KycEmailVerificationState { + const KycEmailVerificationBitboxRequired(); +} diff --git a/lib/screens/kyc/steps/email/kyc_email_page.dart b/lib/screens/kyc/steps/email/kyc_email_page.dart index 9710e06c..25331396 100644 --- a/lib/screens/kyc/steps/email/kyc_email_page.dart +++ b/lib/screens/kyc/steps/email/kyc_email_page.dart @@ -73,10 +73,21 @@ class _KycEmailFormState extends State { context.read().checkKyc(); } if (state.status == .mergeRequested) { + // KycCubit lives in the KycPageManager BlocProvider, which is an + // ancestor of THIS page but NOT of a route pushed onto the + // Navigator. Capture it here (where it resolves) and re-provide it + // into the pushed route via BlocProvider.value so + // KycEmailVerificationPage can advance the parent KYC flow without + // a `Provider not found` crash. `.value` does not own the + // cubit, so popping the route never closes it. + final kycCubit = context.read(); final isConfirmed = await Navigator.push( context, MaterialPageRoute( - builder: (BuildContext context) => const KycEmailVerificationPage(), + builder: (_) => BlocProvider.value( + value: kycCubit, + child: const KycEmailVerificationPage(), + ), ), ); if (isConfirmed == true && context.mounted) { diff --git a/lib/screens/kyc/steps/email/subpages/kyc_email_verification_page.dart b/lib/screens/kyc/steps/email/subpages/kyc_email_verification_page.dart index 9afb5b88..4eefddc7 100644 --- a/lib/screens/kyc/steps/email/subpages/kyc_email_verification_page.dart +++ b/lib/screens/kyc/steps/email/subpages/kyc_email_verification_page.dart @@ -6,14 +6,21 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; class KycEmailVerificationPage extends StatelessWidget { - const KycEmailVerificationPage({super.key}); + /// When `true` the auth-side merge already happened in a prior session, so + /// the one-shot account-id check is seeded as already-detected and success + /// advances the KYC flow in place instead of popping a pushed route. + final bool mergeAlreadyConfirmed; + + const KycEmailVerificationPage({super.key, this.mergeAlreadyConfirmed = false}); @override Widget build(BuildContext context) { @@ -21,19 +28,22 @@ class KycEmailVerificationPage extends StatelessWidget { create: (context) => KycEmailVerificationCubit( dfxService: getIt(), registrationService: getIt(), + initialMergeDetected: mergeAlreadyConfirmed, ), - child: const KycEmailVerificationView(), + child: KycEmailVerificationView(mergeAlreadyConfirmed: mergeAlreadyConfirmed), ); } } class KycEmailVerificationView extends StatelessWidget { - const KycEmailVerificationView({super.key}); + final bool mergeAlreadyConfirmed; + + const KycEmailVerificationView({super.key, this.mergeAlreadyConfirmed = false}); @override Widget build(BuildContext context) { return BlocListener( - listener: (context, state) { + listener: (context, state) async { if (state is KycEmailVerificationFailure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -53,7 +63,31 @@ class KycEmailVerificationView extends StatelessWidget { ); } if (state is KycEmailVerificationSuccess) { - context.pop(true); + if (mergeAlreadyConfirmed) { + // Re-entrant resume: this view is rendered in place by + // KycViewManager (not a pushed route). Advance the KYC flow via + // checkKyc — the wallet is now registered, so the registration + // gate clears and the flow continues. Popping here would tear + // down the whole /kyc route. + context.read().checkKyc(); + } else { + context.pop(true); + } + } + if (state is KycEmailVerificationBitboxRequired) { + // BL-006 — surface the reconnect sheet instead of a generic + // "Registration failed" snackbar. On successful reconnect, + // immediately re-run the verification flow; the cubit's + // `_mergeDetected` latch was reset on the disconnect so the + // JWT account check runs again. + // + // Capture the cubit reference up front so the post-await + // re-run does not depend on the still-mounted BuildContext. + final cubit = context.read(); + final reconnected = await showBitboxReconnectSheet(context); + if (reconnected && !cubit.isClosed) { + await cubit.checkEmailVerification(); + } } }, child: Scaffold( @@ -91,12 +125,14 @@ class KycEmailVerificationView extends StatelessWidget { child: BlocBuilder( builder: (context, state) { final isLoading = state is KycEmailVerificationLoading; - final isBitbox = context - .read() - .state - .openWallet - ?.currentAccount - .primaryAddress is BitboxCredentials; + final isBitbox = + context + .read() + .state + .openWallet + ?.currentAccount + .primaryAddress + is BitboxCredentials; return Column( spacing: 12, children: [ diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index f73be532..5f558ece 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -78,6 +78,14 @@ class _KycRegistrationViewState extends State { final cityCtrl = TextEditingController(); final countryCtrl = ValueNotifier(null); + // BL-002: was hardcoded `true` at the page-layer submit call. Now a + // form input wired into the address step's CheckboxListTile. The + // default `false` lets the country listener flip it on once + // Switzerland is selected (the common case) while leaving users in + // other countries able to explicitly tick if they have a CH tax + // residence on top of their primary address. + final swissTaxResidenceCtrl = ValueNotifier(false); + Country? _initialNationality; Country? _initialAddressCountry; @@ -259,6 +267,7 @@ class _KycRegistrationViewState extends State { postalCodeCtrl: postalCodeCtrl, cityCtrl: cityCtrl, countryCtrl: countryCtrl, + swissTaxResidenceCtrl: swissTaxResidenceCtrl, initialCountry: _initialAddressCountry, onSubmit: _onSubmit, ); @@ -277,7 +286,7 @@ class _KycRegistrationViewState extends State { addressPostalCode: postalCodeCtrl.text.trim(), addressCity: cityCtrl.text.trim(), addressCountry: countryCtrl.value!, - swissTaxResidence: true, + swissTaxResidence: swissTaxResidenceCtrl.value, ); @override @@ -294,6 +303,7 @@ class _KycRegistrationViewState extends State { postalCodeCtrl.dispose(); cityCtrl.dispose(); countryCtrl.dispose(); + swissTaxResidenceCtrl.dispose(); super.dispose(); } } diff --git a/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart b/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart index f863961c..ce58139c 100644 --- a/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart +++ b/lib/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart @@ -6,27 +6,73 @@ import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/country_field.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; -class KycRegistrationAddressStep extends StatelessWidget { +class KycRegistrationAddressStep extends StatefulWidget { final TextEditingController addressStreetCtrl; final TextEditingController addressNumberCtrl; final TextEditingController postalCodeCtrl; final TextEditingController cityCtrl; final ValueNotifier countryCtrl; + + /// Swiss-tax-residence flag the user attests. Closes BL-002: until + /// Initiative II this value was hardcoded `true` at the page layer, + /// disconnected from any UI. The notifier flows through the submit + /// callback into the SignRequest so what the user ticks here is what + /// they sign on the BitBox. + final ValueNotifier swissTaxResidenceCtrl; + final Country? initialCountry; final Future Function() onSubmit; - KycRegistrationAddressStep({ + const KycRegistrationAddressStep({ super.key, required this.addressStreetCtrl, required this.addressNumberCtrl, required this.postalCodeCtrl, required this.cityCtrl, required this.countryCtrl, + required this.swissTaxResidenceCtrl, required this.onSubmit, this.initialCountry, }); + + @override + State createState() => _KycRegistrationAddressStepState(); +} + +class _KycRegistrationAddressStepState extends State { final _formKey = GlobalKey(); + /// Tracks whether the user has explicitly interacted with the + /// checkbox. While `false`, the value follows the country selection + /// (Switzerland → true). Once the user toggles the box manually we + /// stop overriding so a CH-resident-who-also-files-elsewhere can + /// untick without the country listener flipping it back. + bool _userToggled = false; + + late final VoidCallback _countryListener; + + @override + void initState() { + super.initState(); + _countryListener = _onCountryChanged; + widget.countryCtrl.addListener(_countryListener); + } + + @override + void dispose() { + widget.countryCtrl.removeListener(_countryListener); + super.dispose(); + } + + void _onCountryChanged() { + if (_userToggled) return; + final country = widget.countryCtrl.value; + final shouldBeTrue = country?.symbol == 'CH'; + if (widget.swissTaxResidenceCtrl.value != shouldBeTrue) { + widget.swissTaxResidenceCtrl.value = shouldBeTrue; + } + } + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -48,13 +94,15 @@ class KycRegistrationAddressStep extends StatelessWidget { flex: 2, child: LabeledTextField( hintText: 'Musterstrasse', - controller: addressStreetCtrl, + controller: widget.addressStreetCtrl, label: S.of(context).street, keyboardType: TextInputType.streetAddress, textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -62,12 +110,14 @@ class KycRegistrationAddressStep extends StatelessWidget { Expanded( child: LabeledTextField( hintText: '13', - controller: addressNumberCtrl, + controller: widget.addressNumberCtrl, label: S.of(context).number, keyboardType: TextInputType.streetAddress, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -82,12 +132,14 @@ class KycRegistrationAddressStep extends StatelessWidget { flex: 2, child: LabeledTextField( hintText: '8000', - controller: postalCodeCtrl, + controller: widget.postalCodeCtrl, label: S.of(context).postcodeAbr, keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -96,13 +148,15 @@ class KycRegistrationAddressStep extends StatelessWidget { flex: 3, child: LabeledTextField( hintText: 'Zurich', - controller: cityCtrl, + controller: widget.cityCtrl, label: S.of(context).city, keyboardType: TextInputType.text, textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.isEmpty) return ''; - if (!isSwissPaymentText(value)) return S.of(context).swissPaymentTextInvalid; + if (!isSwissPaymentText(value)) { + return S.of(context).swissPaymentTextInvalid; + } return null; }, ), @@ -112,16 +166,34 @@ class KycRegistrationAddressStep extends StatelessWidget { CountryField( label: S.of(context).country, purpose: CountryFieldPurpose.residence, - initialValue: initialCountry, - onChanged: (country) => countryCtrl.value = country, + initialValue: widget.initialCountry, + onChanged: (country) => widget.countryCtrl.value = country, + ), + ValueListenableBuilder( + valueListenable: widget.swissTaxResidenceCtrl, + builder: (context, swissTaxResidence, _) { + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(S.of(context).swissTaxResidence), + subtitle: Text( + S.of(context).swissTaxResidenceDescription, + ), + value: swissTaxResidence, + onChanged: (value) { + _userToggled = true; + widget.swissTaxResidenceCtrl.value = value ?? false; + }, + ); + }, ), Padding( - padding: const .symmetric(vertical: 16.0), + padding: const EdgeInsets.symmetric(vertical: 16.0), child: AppFilledButton( onPressed: () async { FocusManager.instance.primaryFocus?.unfocus(); if (_formKey.currentState?.validate() ?? false) { - await onSubmit(); + await widget.onSubmit(); } }, label: S.of(context).complete, diff --git a/test/goldens/screens/buy/goldens/macos/buy_initial.png b/test/goldens/screens/buy/goldens/macos/buy_initial.png index 5aa1e71b..66cea74e 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_initial.png and b/test/goldens/screens/buy/goldens/macos/buy_initial.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png b/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png index e2dd0bb0..36380a3d 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png and b/test/goldens/screens/buy/goldens/macos/buy_kyc_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png b/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png index 9d8fd101..158b9e79 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png and b/test/goldens/screens/buy/goldens/macos/buy_min_amount_not_met.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png index 9e409915..f4555034 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loaded.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png index afafbf97..2ea9a96a 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png and b/test/goldens/screens/buy/goldens/macos/buy_payment_info_loading.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_registration_required.png b/test/goldens/screens/buy/goldens/macos/buy_registration_required.png index 2b3aa1ba..fda35ec7 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_registration_required.png and b/test/goldens/screens/buy/goldens/macos/buy_registration_required.png differ diff --git a/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png b/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png index f900ae4f..66eb2d85 100644 Binary files a/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png and b/test/goldens/screens/buy/goldens/macos/buy_unknown_error.png differ diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png index 38725764..f015548d 100644 Binary files a/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png and b/test/goldens/screens/kyc/goldens/macos/kyc_registration_address_step_default.png differ diff --git a/test/goldens/screens/kyc/kyc_registration_address_step_golden_test.dart b/test/goldens/screens/kyc/kyc_registration_address_step_golden_test.dart index ae3ba217..32f589db 100644 --- a/test/goldens/screens/kyc/kyc_registration_address_step_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_registration_address_step_golden_test.dart @@ -11,8 +11,7 @@ import '../../../helper/helper.dart'; void main() { setUpAll(() { final countryService = MockDfxCountryService(); - when(() => countryService.getAllCountries()) - .thenAnswer((_) async => const []); + when(() => countryService.getAllCountries()).thenAnswer((_) async => const []); GetIt.instance.registerSingleton(countryService); }); @@ -31,6 +30,7 @@ void main() { postalCodeCtrl: TextEditingController(), cityCtrl: TextEditingController(), countryCtrl: ValueNotifier(null), + swissTaxResidenceCtrl: ValueNotifier(false), onSubmit: () async {}, ), ), diff --git a/test/packages/service/dfx/models/user/dto/user_dto_test.dart b/test/packages/service/dfx/models/user/dto/user_dto_test.dart index 0aed2330..91b4515d 100644 --- a/test/packages/service/dfx/models/user/dto/user_dto_test.dart +++ b/test/packages/service/dfx/models/user/dto/user_dto_test.dart @@ -252,7 +252,7 @@ void main() { 'dataComplete': true, }; - test('parses the full shape with mail + kyc + capabilities', () { + test('parses the full shape with mail + kyc + capabilities + addresses', () { final dto = UserDto.fromJson({ 'mail': 'user@example.com', 'kyc': kycJson(), @@ -262,6 +262,10 @@ void main() { 'canEditPhone': true, 'canEditAddress': false, }, + 'addresses': [ + {'address': '0xABCDEF'}, + {'address': '0x123456'}, + ], }); expect(dto.mail, 'user@example.com'); @@ -270,6 +274,7 @@ void main() { expect(dto.kyc.dataComplete, isTrue); expect(dto.capabilities.canEditName, isTrue); expect(dto.capabilities.canEditMail, isFalse); + expect(dto.addresses, ['0xabcdef', '0x123456']); }); test('mail is optional (null on the wire stays null)', () { @@ -297,6 +302,26 @@ void main() { expect(dto.capabilities.canEditAddress, isFalse); }); + test('addresses absent or malformed entries parse as an empty/filtered hint', () { + final absent = UserDto.fromJson({ + 'mail': 'a@b.com', + 'kyc': kycJson(), + }); + final filtered = UserDto.fromJson({ + 'mail': 'a@b.com', + 'kyc': kycJson(), + 'addresses': [ + null, + {'address': null}, + {'address': 123}, + {'address': '0xABC'}, + ], + }); + + expect(absent.addresses, isEmpty); + expect(filtered.addresses, ['0xabc']); + }); + test('capabilities explicitly null → falls back to all-false default', () { // Same default branch but via an explicit `null` (different code // path through the `as Map` cast guard). diff --git a/test/screens/buy/buy_page_test.dart b/test/screens/buy/buy_page_test.dart index 4678f2f1..be17640f 100644 --- a/test/screens/buy/buy_page_test.dart +++ b/test/screens/buy/buy_page_test.dart @@ -259,7 +259,9 @@ void main() { ); }); - testWidgets('retries payment info when unknown error is shown', (tester) async { + testWidgets('retries payment info with the default amount when unknown error is shown', ( + tester, + ) async { when(() => buyPaymentInfoCubit.state).thenReturn( const BuyPaymentInfoFailure(PaymentInfoError.unknown), ); @@ -277,7 +279,7 @@ void main() { verify( () => buyPaymentInfoCubit.getPaymentInfo( - amount: '', + amount: '300', currency: Currency.eur, ), ).called(1); diff --git a/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart b/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart index c1945809..c1ef84d7 100644 --- a/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart +++ b/test/screens/buy/cubits/buy_payment_info/buy_payment_info_state_test.dart @@ -61,7 +61,16 @@ void main() { final a = BuyPaymentInfoFailure(PaymentInfoError.kycRequired, requiredLevel: 30); final b = BuyPaymentInfoFailure(PaymentInfoError.kycRequired, requiredLevel: 30); expect(a, equals(b)); - expect(a.props, [PaymentInfoError.kycRequired, 30]); + expect(a.props, [PaymentInfoError.kycRequired, 30, '']); + }); + + test('message participates in equality and props', () { + final a = BuyPaymentInfoFailure(PaymentInfoError.unknown, message: 'Forbidden resource'); + final b = BuyPaymentInfoFailure(PaymentInfoError.unknown, message: 'Forbidden resource'); + final c = BuyPaymentInfoFailure(PaymentInfoError.unknown); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a.props, [PaymentInfoError.unknown, null, 'Forbidden resource']); }); test('null requiredLevel is allowed and equal across instances', () { diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 40e520e8..e513c364 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; @@ -178,6 +179,33 @@ void main() { expect(f.error, PaymentInfoError.unknown); }); + test('plain 403 ApiException (code UNKNOWN) → Failure(unknown), NOT registrationRequired', + () async { + // Regression guard: the backend returns a *structured* + // RegistrationRequiredException when a wallet genuinely needs RealUnit + // onboarding (see the test above). A bare 403 "Forbidden resource" with + // no structured code is a different authorization denial (account/role + // gating) and MUST surface as `unknown` — never be force-mapped to + // `registrationRequired`, which would dump an already-onboarded but + // backend-blocked user into the registration/KYC flow and dead-end them + // at "Fehler beim Laden". + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const ApiException( + statusCode: 403, + code: 'UNKNOWN', + message: 'Forbidden resource', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + final f = cubit.state as BuyPaymentInfoFailure; + expect(f.error, PaymentInfoError.unknown); + expect(f.message, contains('Forbidden resource')); + }); + test('BitboxNotConnectedException → Failure(bitboxDisconnected)', () async { when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) .thenAnswer((_) async => throw const BitboxNotConnectedException()); diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index eb180f2d..5e4cef5f 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -35,9 +35,11 @@ UserKycDto _kycHeader({KycLevel level = KycLevel.level0}) => UserDto _user({ String? mail = 'test@example.com', KycLevel headerLevel = KycLevel.level0, + List addresses = const [], }) => UserDto( mail: mail, kyc: _kycHeader(level: headerLevel), + addresses: addresses, ); KycStepDto _step( @@ -158,6 +160,42 @@ void main() { ], ); + blocTest( + 'does not use empty addresses as proof that wallet registration is missing', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level20), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(addresses: const []), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.legalDisclaimer), + ], + ); + + blocTest( + 'does not use user.addresses as wallet-routing authority when another address is present', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level20), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(addresses: const ['0xsomeotheraddress']), + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.legalDisclaimer), + ], + ); + blocTest( 'auto-registers email when mail exists but level < 10, then recurses', setUp: () { @@ -180,6 +218,30 @@ void main() { }, ); + blocTest( + 'auto-registers email when mail exists but level < 10 even if addresses are empty', + setUp: () { + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => _kycStatus(level: KycLevel.level0), + ); + when(() => kycService.getUser()).thenAnswer( + (_) async => _user(addresses: const []), + ); + when(() => registrationService.registerEmail(any())).thenAnswer( + (_) async => RegistrationEmailStatus.emailRegistered, + ); + }, + build: buildCubit, + act: (cubit) => cubit.checkKyc(), + expect: () => [ + const KycLoading(), + const KycSuccess(currentStep: KycStep.email), + ], + verify: (_) { + verify(() => registrationService.registerEmail('test@example.com')).called(1); + }, + ); + blocTest( 'emits KycSuccess(legalDisclaimer) when disclaimer not yet accepted', setUp: () { @@ -918,7 +980,8 @@ void main() { act: (cubit) async { await cubit.checkKyc(); // expects legalDisclaimer cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); // expects KycCompleted (AlreadyRegistered + processStatus=Completed) + await cubit + .checkKyc(); // expects KycCompleted (AlreadyRegistered + processStatus=Completed) }, expect: () => [ const KycLoading(), diff --git a/test/screens/kyc/kyc_page_manager_test.dart b/test/screens/kyc/kyc_page_manager_test.dart new file mode 100644 index 00000000..af8edf54 --- /dev/null +++ b/test/screens/kyc/kyc_page_manager_test.dart @@ -0,0 +1,50 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; +import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_failure_page.dart'; +import 'package:realunit_wallet/screens/kyc/subpages/kyc_loading_page.dart'; + +import '../../helper/pump_app.dart'; + +class _MockKycCubit extends MockCubit implements KycCubit {} + +void main() { + late KycCubit kycCubit; + + setUp(() { + kycCubit = _MockKycCubit(); + }); + + Future pumpManager(WidgetTester tester, KycState state) async { + when(() => kycCubit.state).thenReturn(state); + await tester.pumpApp( + BlocProvider.value( + value: kycCubit, + child: const KycViewManager(), + ), + ); + } + + group('$KycViewManager', () { + // #610 F2: KycInitial is the pre-checkKyc() seed state. It must render the + // loading page, never fall through to the diagnostic catch-all that would + // flash "Unhandled KYC state: KycInitial" on the very first frame. + testWidgets('KycInitial renders the loading page, not the failure fallback', + (tester) async { + await pumpManager(tester, const KycInitial()); + + expect(find.byType(KycLoadingPage), findsOneWidget); + expect(find.byType(KycFailurePage), findsNothing); + }); + + testWidgets('KycLoading renders the loading page', (tester) async { + await pumpManager(tester, const KycLoading()); + + expect(find.byType(KycLoadingPage), findsOneWidget); + expect(find.byType(KycFailurePage), findsNothing); + }); + }); +} diff --git a/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart b/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart index 588a801a..ba783c56 100644 --- a/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart +++ b/test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart @@ -4,6 +4,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; @@ -17,12 +18,8 @@ class _MockAuthService extends Mock implements DFXAuthService {} class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} String _fakeJwt(int accountId) { - final header = base64Url - .encode(utf8.encode('{"alg":"HS256"}')) - .replaceAll('=', ''); - final payload = base64Url - .encode(utf8.encode('{"account":$accountId}')) - .replaceAll('=', ''); + final header = base64Url.encode(utf8.encode('{"alg":"HS256"}')).replaceAll('=', ''); + final payload = base64Url.encode(utf8.encode('{"account":$accountId}')).replaceAll('=', ''); return '$header.$payload.signature'; } @@ -55,6 +52,14 @@ const _userData = RealUnitUserDataDto( kycData: _kycData, ); +RealUnitRegistrationInfoDto _registrationInfo({ + RealUnitRegistrationState state = RealUnitRegistrationState.addWallet, + RealUnitUserDataDto? userData = _userData, +}) => RealUnitRegistrationInfoDto( + state: state, + realUnitUserDataDto: userData, +); + void main() { late _MockAuthService auth; late _MockRegistrationService registrationService; @@ -69,10 +74,19 @@ void main() { when(() => auth.invalidateAuthToken()).thenReturn(null); }); - KycEmailVerificationCubit build() => KycEmailVerificationCubit( - dfxService: auth, - registrationService: registrationService, - ); + KycEmailVerificationCubit build({ + void Function()? onSignProduced, + bool initialMergeDetected = false, + int registrationInfoRetries = 1, + Duration registrationInfoRetryDelay = Duration.zero, + }) => KycEmailVerificationCubit( + dfxService: auth, + registrationService: registrationService, + onSignProduced: onSignProduced, + initialMergeDetected: initialMergeDetected, + registrationInfoRetries: registrationInfoRetries, + registrationInfoRetryDelay: registrationInfoRetryDelay, + ); group('initial state', () { test('emits $KycEmailVerificationInitial', () { @@ -106,13 +120,11 @@ void main() { var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ), + (_) async => _registrationInfo(), ); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) => c.checkEmailVerification(), @@ -123,6 +135,31 @@ void main() { verify: (_) => verify(() => registrationService.registerWallet(_userData)).called(1), ); + blocTest( + 'initialMergeDetected (re-entrant resume) skips the one-shot account-id ' + 'check and goes straight to registerWallet → Success', + setUp: () { + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: () => build(initialMergeDetected: true), + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + verify: (_) { + // The account-id delta is the one-shot signal that cannot be re-derived + // after a restart — re-entrant mode must NOT call it. + verifyNever(() => auth.getAuthToken()); + verify(() => registrationService.registerWallet(_userData)).called(1); + }, + ); + blocTest( 'changed account id but no userData → RegistrationFailure, no Success ' '(propagation race: user can retry by tapping the confirm button again)', @@ -131,9 +168,9 @@ void main() { var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( + (_) async => _registrationInfo( state: RealUnitRegistrationState.newRegistration, - realUnitUserDataDto: null, + userData: null, ), ); }, @@ -148,6 +185,38 @@ void main() { }, ); + blocTest( + 'changed account id retries registration info propagation before registering', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + var registrationInfoCallCount = 0; + when(() => registrationService.getRegistrationInfo()).thenAnswer((_) async { + registrationInfoCallCount++; + return registrationInfoCallCount == 1 + ? _registrationInfo( + state: RealUnitRegistrationState.newRegistration, + userData: null, + ) + : _registrationInfo(); + }); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: () => build(registrationInfoRetries: 2), + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + verify: (_) { + verify(() => registrationService.getRegistrationInfo()).called(2); + verify(() => registrationService.registerWallet(_userData)).called(1); + }, + ); + blocTest( 'registerWallet throws → RegistrationFailure, no Success ' '(failure is surfaced so the user can retry instead of proceeding ' @@ -157,13 +226,11 @@ void main() { var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ), + (_) async => _registrationInfo(), ); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => throw Exception('boom')); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => throw Exception('boom')); }, build: build, act: (c) => c.checkEmailVerification(), @@ -186,21 +253,19 @@ void main() { final tokens = [_fakeJwt(1), _fakeJwt(2), _fakeJwt(2), _fakeJwt(2)]; var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - var walletStatusCallCount = 0; + var registrationInfoCallCount = 0; when(() => registrationService.getRegistrationInfo()).thenAnswer((_) async { - walletStatusCallCount++; - return walletStatusCallCount == 1 - ? RealUnitRegistrationInfoDto( + registrationInfoCallCount++; + return registrationInfoCallCount == 1 + ? _registrationInfo( state: RealUnitRegistrationState.newRegistration, - realUnitUserDataDto: null, + userData: null, ) - : RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ); + : _registrationInfo(); }); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) async { @@ -219,6 +284,186 @@ void main() { ); }); + group('BL-006: BitBox disconnect mid-sign routes to BitboxRequired', () { + blocTest( + 'registerWallet throws BitboxNotConnectedException → ' + 'KycEmailVerificationBitboxRequired (legacy exception path)', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + when(() => registrationService.registerWallet(any())).thenAnswer( + (_) async => throw const BitboxNotConnectedException(), + ); + }, + build: build, + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + verify: (_) => verify(() => registrationService.registerWallet(_userData)).called(1), + ); + + blocTest( + '#610 F1 / BL-006: reconnect after BitboxNotConnected → the merge latch ' + 'STAYS set, so the second call re-attempts the registration SIGN ' + 'instead of re-running the one-shot account-id check.', + setUp: () { + // First call: account changes 1→2 (merge detected), the sign then fails + // with a BitBox disconnect mid-signature. The auth side is now settled. + // + // The bug (BL-006): the disconnect handler used to reset the latch, so + // the post-reconnect retry re-ran the one-shot account-id check. By then + // getAuthToken keeps returning the merged account (2), so the + // same-account-id guard (2 == 2) dead-ended on Failure ("email not + // confirmed") and the user could never finish — even though the BitBox + // was reconnected and ready to sign. + // + // Fixed: the latch stays set, so the second call skips the auth-side + // check entirely and goes straight to registerWallet, which now + // succeeds on the reconnected device. We feed only the two tokens the + // first call's account-id check consumes; if the latch were wrongly + // reset the second call would demand a third token and re-run the + // (now same-account) guard, surfacing Failure instead of Success. + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()) + .thenAnswer((_) async => tokens[i < tokens.length ? i++ : tokens.length - 1]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + var registerCallCount = 0; + when(() => registrationService.registerWallet(any())).thenAnswer( + (_) async { + registerCallCount++; + if (registerCallCount == 1) { + throw const BitboxNotConnectedException(); + } + return RegistrationStatus.completed; + }, + ); + }, + build: build, + act: (c) async { + await c.checkEmailVerification(); + await c.checkEmailVerification(); + }, + expect: () => [ + isA(), + isA(), + isA(), + // Latch held → the retry re-signs and completes the merge. + isA(), + ], + verify: (_) { + // Two sign attempts (failed + succeeded), and the auth-side account-id + // check ran only ONCE — not re-run on the retry. + verify(() => registrationService.registerWallet(_userData)).called(2); + verify(() => auth.getAuthToken()).called(2); + }, + ); + }); + + group('success callback fires only after registerWallet', () { + blocTest( + 'on Success → onSignProduced callback is invoked', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: () { + var callCount = 0; + final cubit = build(onSignProduced: () => callCount++); + // Stash the callback's invocation count on the cubit via a + // sentinel state-listener so the verify block can assert it. + addTearDown(() { + expect(callCount, 1, reason: 'success callback must fire exactly once on Success'); + }); + return cubit; + }, + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'on RegistrationFailure → onSignProduced is NOT invoked', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => throw Exception('boom')); + }, + build: () { + var callCount = 0; + final cubit = build(onSignProduced: () => callCount++); + addTearDown(() { + expect( + callCount, + 0, + reason: 'success callback must NOT fire if registerWallet failed', + ); + }); + return cubit; + }, + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'on BitboxRequired → onSignProduced is NOT invoked', + setUp: () { + final tokens = [_fakeJwt(1), _fakeJwt(2)]; + var i = 0; + when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); + when(() => registrationService.getRegistrationInfo()).thenAnswer( + (_) async => _registrationInfo(), + ); + when(() => registrationService.registerWallet(any())).thenAnswer( + (_) async => throw const BitboxNotConnectedException(), + ); + }, + build: () { + var callCount = 0; + final cubit = build(onSignProduced: () => callCount++); + addTearDown(() { + expect( + callCount, + 0, + reason: 'success callback must NOT fire on a BitBox disconnect', + ); + }); + return cubit; + }, + act: (c) => c.checkEmailVerification(), + expect: () => [ + isA(), + isA(), + ], + ); + }); + group('getAccountId', () { test('returns null when there is no token', () async { when(() => auth.getAuthToken()).thenAnswer((_) async => null); diff --git a/test/screens/kyc/steps/registration/steps/kyc_registration_address_step_test.dart b/test/screens/kyc/steps/registration/steps/kyc_registration_address_step_test.dart new file mode 100644 index 00000000..f3c2ddff --- /dev/null +++ b/test/screens/kyc/steps/registration/steps/kyc_registration_address_step_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registration_address_step.dart'; + +import '../../../../../helper/pump_app.dart'; + +class _MockDfxCountryService extends Mock implements DfxCountryService {} + +const _ch = Country(id: 1, symbol: 'CH', name: 'Switzerland', kycAllowed: true); +const _de = Country(id: 2, symbol: 'DE', name: 'Germany', kycAllowed: true); + +void main() { + setUpAll(() { + final countryService = _MockDfxCountryService(); + when(() => countryService.getAllCountries()) + .thenAnswer((_) async => const []); + GetIt.instance.registerSingleton(countryService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + // The step's TextFields require a Material ancestor; mirror the golden + // harness by wrapping in a Scaffold. + Widget buildStep( + ValueNotifier countryCtrl, + ValueNotifier taxCtrl, + ) => Scaffold( + body: KycRegistrationAddressStep( + addressStreetCtrl: TextEditingController(), + addressNumberCtrl: TextEditingController(), + postalCodeCtrl: TextEditingController(), + cityCtrl: TextEditingController(), + countryCtrl: countryCtrl, + swissTaxResidenceCtrl: taxCtrl, + onSubmit: () async {}, + ), + ); + + // BL-002 (#610 F4): the Swiss-tax-residence flag auto-ticks for a CH country + // and clears for non-CH — until the user manually toggles it, after which the + // country listener must stop overriding. This is the value the user signs. + group('$KycRegistrationAddressStep swissTaxResidence (BL-002)', () { + testWidgets('auto-ticks for CH and clears for non-CH', (tester) async { + final country = ValueNotifier(null); + final tax = ValueNotifier(false); + await tester.pumpApp(buildStep(country, tax)); + + country.value = _ch; + await tester.pump(); + expect(tax.value, isTrue, reason: 'CH must auto-tick swissTaxResidence'); + + country.value = _de; + await tester.pump(); + expect(tax.value, isFalse, reason: 'non-CH must clear it'); + }); + + testWidgets('stops auto-overriding once the user toggles the checkbox', (tester) async { + final country = ValueNotifier(_de); + final tax = ValueNotifier(false); + await tester.pumpApp(buildStep(country, tax)); + + // User manually ticks it → marks the field user-controlled. The form is + // taller than the test viewport, so scroll the checkbox into view first. + await tester.ensureVisible(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + expect(tax.value, isTrue); + + // A later CH selection must NOT auto-flip the user's choice... + country.value = _ch; + await tester.pump(); + expect(tax.value, isTrue, reason: 'manual toggle disables auto-override'); + + // ...and neither must switching back to non-CH. + country.value = _de; + await tester.pump(); + expect(tax.value, isTrue); + }); + }); +}