diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 2d3de5d5..6a596d22 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -221,7 +221,6 @@ "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.", "registerEmailVerificationFailed": "Sie haben Ihre E-Mail noch nicht bestätigt.", - "registerEmailVerificationRegistrationFailed": "Die Wallet-Registrierung wurde noch nicht abgeschlossen. Bitte versuchen Sie es in wenigen Sekunden erneut. Falls das Problem weiterhin besteht, kontaktieren Sie den Support.", "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 84509699..52406e86 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -221,7 +221,6 @@ "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.", "registerEmailVerificationFailed": "You have not yet confirmed your email address.", - "registerEmailVerificationRegistrationFailed": "Wallet registration is not yet complete. Please try again in a few seconds. If the issue persists, contact support.", "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", 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..8afc8445 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 @@ -1,16 +1,12 @@ -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/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/utils/jwt_decoder.dart'; part 'kyc_email_verification_state.dart'; class KycEmailVerificationCubit extends Cubit { final DFXAuthService _dfxService; - final RealUnitRegistrationService _registrationService; // `Future.timeout` does not cancel the underlying work, so a late HTTP // response from an earlier call can still resume after a retry. Each @@ -21,84 +17,37 @@ class KycEmailVerificationCubit extends Cubit { // sequences. Pattern mirrors `KycCubit._runGeneration`. int _runGeneration = 0; - // Once the JWT account-id change has confirmed the merge, the auth side - // 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; - - KycEmailVerificationCubit({ - required DFXAuthService dfxService, - required RealUnitRegistrationService registrationService, - }) : _dfxService = dfxService, - _registrationService = registrationService, - super(const KycEmailVerificationInitial()); + KycEmailVerificationCubit({required DFXAuthService dfxService}) + : _dfxService = dfxService, + super(const KycEmailVerificationInitial()); + /// Confirms the account merge by detecting that the backend re-issued the JWT + /// for the merged (master) account. Registration is intentionally NOT done + /// here: once the merge is confirmed, the KYC flow (`KycCubit`) is the single + /// source of truth for what this wallet needs next — add wallet vs. full + /// registration — see CONTRIBUTING.md "API as Decision Authority". The page + /// pops on [KycEmailVerificationSuccess] and `KycEmailPage` re-runs `checkKyc`. Future checkEmailVerification() async { final generation = ++_runGeneration; if (isClosed) return; emit(const KycEmailVerificationLoading()); - if (!_mergeDetected) { - final currentAccountId = await getAccountId(); - if (isClosed || generation != _runGeneration) return; - _dfxService.invalidateAuthToken(); - final newAccountId = await getAccountId(); - if (isClosed || generation != _runGeneration) return; - - if (currentAccountId == newAccountId) { - // Email link not yet clicked, or token still cached. The user can - // retry by tapping again once the link in the confirmation mail has - // actually been visited. - emit(const KycEmailVerificationFailure()); - return; - } - _mergeDetected = true; + final currentAccountId = await getAccountId(); + if (isClosed || generation != _runGeneration) return; + _dfxService.invalidateAuthToken(); + final newAccountId = await getAccountId(); + if (isClosed || generation != _runGeneration) return; + + if (currentAccountId == newAccountId) { + // Email link not yet clicked, or token still cached. The user can retry + // by tapping again once the link in the confirmation mail has been visited. + emit(const KycEmailVerificationFailure()); + return; } - // JWT account changed → backend recognised the merge. Now associate the - // new wallet with the merged user via the EIP-712 registration signature. - if (await _completeRegistration(generation)) { - if (isClosed || generation != _runGeneration) return; - 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. - } - - /// 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 - /// error to the user. - 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`. - developer.log( - 'getRegistrationInfo returned null realUnitUserDataDto after merge', - ); - emit(const KycEmailVerificationRegistrationFailure()); - return false; - } - await _registrationService.registerWallet(info.realUnitUserDataDto!); - if (isClosed || generation != _runGeneration) return false; - return true; - } catch (e) { - if (isClosed || generation != _runGeneration) return false; - developer.log('registerWallet failed: $e'); - emit(const KycEmailVerificationRegistrationFailure()); - return false; - } + // JWT account changed → the backend recognised the merge. Hand back to the + // KYC flow, which routes the wallet to add-wallet or full registration. + emit(const KycEmailVerificationSuccess()); } Future getAccountId() async { 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..81ec9325 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 @@ -22,8 +22,3 @@ class KycEmailVerificationSuccess extends KycEmailVerificationState { class KycEmailVerificationFailure extends KycEmailVerificationState { const KycEmailVerificationFailure(); } - -class KycEmailVerificationRegistrationFailure - extends KycEmailVerificationState { - const KycEmailVerificationRegistrationFailure(); -} diff --git a/lib/screens/kyc/steps/email/kyc_email_page.dart b/lib/screens/kyc/steps/email/kyc_email_page.dart index 9710e06c..528753ca 100644 --- a/lib/screens/kyc/steps/email/kyc_email_page.dart +++ b/lib/screens/kyc/steps/email/kyc_email_page.dart @@ -80,12 +80,12 @@ class _KycEmailFormState extends State { ), ); if (isConfirmed == true && context.mounted) { - // A successful merge confirmation already registered this - // wallet via `KycEmailVerificationCubit._completeRegistration` - // → `RealUnitRegistrationService.registerWallet`. The next - // `checkKyc()` re-fetches `getRegistrationInfo`, sees - // `AlreadyRegistered`, and routes forward — no local sign-gate - // flag needed. + // Merge confirmed. `checkKyc()` re-fetches `getRegistrationInfo` + // and routes this wallet by the API `state`: AddWallet → link + // wallet, NewRegistration → full registration form, + // AlreadyRegistered → forward. The KYC flow is the single source + // of registration routing — see CONTRIBUTING.md "API as Decision + // Authority". context.read().checkKyc(); } } 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..5a335ec2 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 @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; 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/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -20,7 +19,6 @@ class KycEmailVerificationPage extends StatelessWidget { return BlocProvider( create: (context) => KycEmailVerificationCubit( dfxService: getIt(), - registrationService: getIt(), ), child: const KycEmailVerificationView(), ); @@ -42,16 +40,6 @@ class KycEmailVerificationView extends StatelessWidget { ), ); } - if (state is KycEmailVerificationRegistrationFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - S.of(context).registerEmailVerificationRegistrationFailed, - ), - backgroundColor: RealUnitColors.status.red600, - ), - ); - } if (state is KycEmailVerificationSuccess) { context.pop(true); } 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..9e31acd1 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,18 +4,10 @@ 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/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'; -import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; -import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/screens/kyc/steps/email/cubits/email_verification/kyc_email_verification_cubit.dart'; class _MockAuthService extends Mock implements DFXAuthService {} -class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} - String _fakeJwt(int accountId) { final header = base64Url .encode(utf8.encode('{"alg":"HS256"}')) @@ -26,53 +18,16 @@ String _fakeJwt(int accountId) { return '$header.$payload.signature'; } -const _kycData = KycPersonalData( - accountType: KycAccountType.personal, - firstName: 'A', - lastName: 'B', - phone: '+41', - address: KycAddress( - street: 'S', - zip: '8000', - city: 'Zurich', - country: 41, - ), -); - -const _userData = RealUnitUserDataDto( - email: 'a@b.com', - name: 'A B', - type: 'HUMAN', - phoneNumber: '+41', - birthday: '2000-01-01', - nationality: 'CH', - addressStreet: 'S', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: 'CH', - swissTaxResidence: true, - lang: 'de', - kycData: _kycData, -); - void main() { late _MockAuthService auth; - late _MockRegistrationService registrationService; - - setUpAll(() { - registerFallbackValue(_userData); - }); setUp(() { auth = _MockAuthService(); - registrationService = _MockRegistrationService(); when(() => auth.invalidateAuthToken()).thenReturn(null); }); - KycEmailVerificationCubit build() => KycEmailVerificationCubit( - dfxService: auth, - registrationService: registrationService, - ); + KycEmailVerificationCubit build() => + KycEmailVerificationCubit(dfxService: auth); group('initial state', () { test('emits $KycEmailVerificationInitial', () { @@ -82,9 +37,10 @@ void main() { group('checkEmailVerification', () { blocTest( - 'same account id before + after invalidation → Failure', + 'same account id before + after invalidation → Failure ' + '(confirmation link not visited yet)', setUp: () { - // Both calls return the same token, so the same account id is parsed. + // Both reads return the same token, so the same account id is parsed. when(() => auth.getAuthToken()).thenAnswer((_) async => _fakeJwt(1)); }, build: build, @@ -93,26 +49,16 @@ void main() { isA(), isA(), ], - verify: (_) { - verify(() => auth.invalidateAuthToken()).called(1); - verifyNever(() => registrationService.registerWallet(any())); - }, + verify: (_) => verify(() => auth.invalidateAuthToken()).called(1), ); blocTest( - 'changed account id + existing user data → registerWallet + Success', + 'changed account id → Success (merge confirmed; the KYC flow handles ' + 'registration — nothing is registered here)', setUp: () { final tokens = [_fakeJwt(1), _fakeJwt(2)]; var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ), - ); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) => c.checkEmailVerification(), @@ -120,87 +66,17 @@ void main() { isA(), isA(), ], - verify: (_) => 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)', + 'retry: first tap (link not visited) → Failure, second tap (account now ' + 'changed) → Success', setUp: () { - final tokens = [_fakeJwt(1), _fakeJwt(2)]; + // tap 1: both reads = account 1 → Failure. + // tap 2: account 1 → 2 → Success. + final tokens = [_fakeJwt(1), _fakeJwt(1), _fakeJwt(1), _fakeJwt(2)]; var i = 0; when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.newRegistration, - realUnitUserDataDto: null, - ), - ); - }, - build: build, - act: (c) => c.checkEmailVerification(), - expect: () => [ - isA(), - isA(), - ], - verify: (_) { - verifyNever(() => registrationService.registerWallet(any())); - }, - ); - - blocTest( - 'registerWallet throws → RegistrationFailure, no Success ' - '(failure is surfaced so the user can retry instead of proceeding ' - 'with a wallet that is not actually registered)', - setUp: () { - final tokens = [_fakeJwt(1), _fakeJwt(2)]; - var i = 0; - when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - when(() => registrationService.getRegistrationInfo()).thenAnswer( - (_) async => RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ), - ); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => throw Exception('boom')); - }, - build: build, - act: (c) => c.checkEmailVerification(), - expect: () => [ - isA(), - isA(), - ], - verify: (_) => verify(() => registrationService.registerWallet(_userData)).called(1), - ); - - blocTest( - 'retry after null-userData race: second call skips account-id check ' - '(propagation completed → registerWallet succeeds → Success)', - setUp: () { - // First call: account-id changes, userData not yet propagated. - // Second call: same account-id (already merged), userData now present. - // Without the `_mergeDetected` short-circuit the second call would - // hit the same-account-id guard and emit Failure ("email not yet - // confirmed") — verifying the retry path works. - final tokens = [_fakeJwt(1), _fakeJwt(2), _fakeJwt(2), _fakeJwt(2)]; - var i = 0; - when(() => auth.getAuthToken()).thenAnswer((_) async => tokens[i++]); - var walletStatusCallCount = 0; - when(() => registrationService.getRegistrationInfo()).thenAnswer((_) async { - walletStatusCallCount++; - return walletStatusCallCount == 1 - ? RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.newRegistration, - realUnitUserDataDto: null, - ) - : RealUnitRegistrationInfoDto( - state: RealUnitRegistrationState.addWallet, - realUnitUserDataDto: _userData, - ); - }); - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) async { @@ -209,13 +85,10 @@ void main() { }, expect: () => [ isA(), - isA(), + isA(), isA(), isA(), ], - verify: (_) { - verify(() => registrationService.registerWallet(_userData)).called(1); - }, ); }); diff --git a/test/screens/kyc/steps/kyc_step_states_test.dart b/test/screens/kyc/steps/kyc_step_states_test.dart index 6f445c04..58281037 100644 --- a/test/screens/kyc/steps/kyc_step_states_test.dart +++ b/test/screens/kyc/steps/kyc_step_states_test.dart @@ -27,14 +27,12 @@ void main() { }); group('$KycEmailVerificationState', () { - test('all four states are distinct singletons by type', () { + test('all states are distinct singletons by type', () { expect(const KycEmailVerificationInitial(), const KycEmailVerificationInitial()); expect(const KycEmailVerificationInitial(), isNot(const KycEmailVerificationLoading())); expect(const KycEmailVerificationSuccess(), isNot(const KycEmailVerificationFailure())); - expect(const KycEmailVerificationFailure(), - isNot(const KycEmailVerificationRegistrationFailure())); }); });