From 3d98c8dfb8ccc91f714250ee9e135b52f836478b Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Thu, 21 May 2026 09:05:00 +0200 Subject: [PATCH 1/3] fix(kyc): close existing-customer merge misroute, rename sign gate flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing DFX customers who registered in realUnit with the same email as their DFX account were routed to the empty personal-data form after the merge had already succeeded server-side. The form's submit would have produced a fresh EIP-712 sign over user-typed data, attaching potentially-divergent values (diacritics, address abbreviations, phone format) to the existing DFX account — a data-integrity risk, plus a miserable onboarding UX on top. Root cause: `KycCubit` gates past the disclaimer step on `_bitboxConfirmed`, a flag tracking "EIP-712 registration sign produced this session". The new-registration path set the flag in `kyc_registration_page.dart` after a successful form submit. The merge path produces the same EIP-712 signature via `KycEmailVerificationCubit._completeRegistration` → `RealUnitRegistrationService.registerWallet`, but never marked the flag — so after merge confirm + disclaimer accept the gate fired and sent the user back to the form. Changes: - Rename `_bitboxConfirmed` → `_registrationSignProduced` and `markBitboxConfirmed()` → `markRegistrationSignProduced()` to match the wallet-agnostic semantics. The BitBox 13-step ceremony is one visible manifestation of the underlying EIP-712 sign; software wallets produce the same signature silently. The old name routinely misled readers. - Call `markRegistrationSignProduced()` from `kyc_email_page.dart` on a successful merge confirmation pop, before re-running `checkKyc`. - Update doc comments around the gate to focus on the per-session sign-gate semantics rather than the hardware-wallet UX surface. - Update `docs/testing.md` code snippet to use the new method name — the previous version would not compile if copied. - Update `kyc_cubit_test.dart` references and group name. - Add widget tests in `kyc_email_page_test.dart` that verify the setter is called when the merge-confirmation pop returns `true`, and not called on `false` / `null`. Without these a future refactor could remove the setter call again without any test failing. Closes #464. --- docs/testing.md | 2 +- lib/screens/kyc/cubits/kyc/kyc_cubit.dart | 33 ++++-- .../kyc/steps/email/kyc_email_page.dart | 9 ++ .../registration/kyc_registration_page.dart | 6 +- .../kyc/cubits/kyc/kyc_cubit_test.dart | 38 +++--- .../kyc/steps/kyc_email_page_test.dart | 112 ++++++++++++++++++ 6 files changed, 164 insertions(+), 36 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index c4a935c8..0f7845f8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -53,7 +53,7 @@ blocTest( build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycCompleted()], diff --git a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart index b2c7f94e..b589d129 100644 --- a/lib/screens/kyc/cubits/kyc/kyc_cubit.dart +++ b/lib/screens/kyc/cubits/kyc/kyc_cubit.dart @@ -31,10 +31,13 @@ class KycCubit extends Cubit { bool _legalDisclaimerAccepted = false; bool _emailRegistrationAttempted = false; - // Per-cubit-instance gate. Reset on every KYC entry because `KycPageManager` - // creates a fresh `KycCubit` via `BlocProvider(create:)`, so each entry - // forces a fresh hardware-wallet confirmation before sensitive steps. - bool _bitboxConfirmed = false; + // Tracks whether the EIP-712 registration signature has been produced in + // *this* KYC entry. Wallet-agnostic — for software wallets the sign is a + // silent local operation, for BitBox it is the visible 13-step ceremony. + // Per-cubit-instance by design: `KycPageManager` creates a fresh `KycCubit` + // via `BlocProvider(create:)`, so every `/kyc` entry forces a fresh sign + // before sensitive steps and stale sessions cannot be re-used. + bool _registrationSignProduced = false; // `Future.timeout` does not cancel the underlying work, so a late HTTP // response from an earlier call can still resume and emit state after a @@ -101,17 +104,17 @@ class KycCubit extends Cubit { return; } - // Disclaimer + form (name/address) + EIP-712 13-step sign always - // precede every state past the email step, even when the user is - // already at `>= requiredLevel`. The hardware-wallet ceremony is the - // security gate, not the backend KYC level — without the sign a - // returning user with a high level would otherwise be granted - // sensitive actions on this device without ever touching the BitBox. + // Disclaimer + EIP-712 registration sign always precede every state + // past the email step, even when the user is already at + // `>= requiredLevel`. The sign is the per-session security gate, not + // the backend KYC level — without it a returning user with a high + // level would otherwise be granted sensitive actions on this device + // without re-proving ownership of the wallet key. if (!_legalDisclaimerAccepted) { emit(const KycSuccess(currentStep: KycStep.legalDisclaimer)); return; } - if (!_bitboxConfirmed) { + if (!_registrationSignProduced) { emit(const KycSuccess(currentStep: KycStep.registration)); return; } @@ -190,8 +193,12 @@ class KycCubit extends Cubit { _legalDisclaimerAccepted = true; } - void markBitboxConfirmed() { - _bitboxConfirmed = true; + /// Records that the EIP-712 registration signature has been produced in + /// this session, so subsequent [checkKyc] calls pass the sign gate. + /// Callers: any code path that triggers the sign — currently the + /// new-registration form submit and the existing-customer merge confirm. + void markRegistrationSignProduced() { + _registrationSignProduced = true; } /// should only be called after realunit registration was completed diff --git a/lib/screens/kyc/steps/email/kyc_email_page.dart b/lib/screens/kyc/steps/email/kyc_email_page.dart index 2e7fe64a..9df3031f 100644 --- a/lib/screens/kyc/steps/email/kyc_email_page.dart +++ b/lib/screens/kyc/steps/email/kyc_email_page.dart @@ -80,6 +80,15 @@ class _KycEmailFormState extends State { ), ); if (isConfirmed == true && context.mounted) { + // A successful merge confirmation produced the EIP-712 + // registration signature via + // `KycEmailVerificationCubit._completeRegistration` → + // `RealUnitRegistrationService.registerWallet`. The verification + // page only pops with `true` when that succeeded, so the sign + // gate is safe to mark — without this, existing DFX customers + // would be misrouted back to the empty registration form after + // the disclaimer step. + context.read().markRegistrationSignProduced(); context.read().checkKyc(); } } diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index 8036a5c1..b12b614c 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -104,9 +104,9 @@ class _KycRegistrationViewState extends State { listener: (context, state) async { if (state is KycRegistrationSubmitSuccess) { if (state.status == RegistrationStatus.completed) { - // completeRegistration already produced the BitBox 13-step sign, - // so skip the security ceremony on the next checkKyc. - context.read().markBitboxConfirmed(); + // completeRegistration already produced the EIP-712 registration + // signature, so the sign gate is satisfied for this session. + context.read().markRegistrationSignProduced(); context.read().checkKyc(); } } diff --git a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart index a03dfbe0..4e5d6232 100644 --- a/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart +++ b/test/screens/kyc/cubits/kyc/kyc_cubit_test.dart @@ -128,7 +128,7 @@ void main() { ); blocTest( - 'emits KycSuccess(registration) when disclaimer accepted but BitBox not confirmed', + 'emits KycSuccess(registration) when disclaimer accepted but registration sign not yet produced', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus(level: KycLevel.level20), @@ -165,7 +165,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [ @@ -190,7 +190,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [ @@ -215,7 +215,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycPending(KycStep.dfxApproval)], @@ -240,7 +240,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [ @@ -268,7 +268,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [ @@ -288,14 +288,14 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycCompleted()], ); blocTest( - 'returning user at level >= required still routes through the BitBox gate first', + 'returning user at level >= required still routes through the sign gate first', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus(level: KycLevel.level50), @@ -386,7 +386,7 @@ void main() { build: () => buildCubit(requiredLevel: 10), act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycCompleted()], @@ -430,7 +430,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycCompleted()], @@ -462,7 +462,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, verify: (cubit) { @@ -487,7 +487,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [ @@ -510,7 +510,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycPending(KycStep.ident)], @@ -527,7 +527,7 @@ void main() { build: buildCubit, act: (cubit) async { cubit.markLegalDisclaimerAccepted(); - cubit.markBitboxConfirmed(); + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); }, expect: () => [const KycLoading(), const KycPending(KycStep.ident)], @@ -559,7 +559,7 @@ void main() { final cubit = KycCubit(kycService, registrationService) ..markLegalDisclaimerAccepted() - ..markBitboxConfirmed(); + ..markRegistrationSignProduced(); final states = []; final sub = cubit.stream.listen(states.add); @@ -610,9 +610,9 @@ void main() { ); }); - group('$KycCubit markLegalDisclaimerAccepted / markBitboxConfirmed', () { + group('$KycCubit markLegalDisclaimerAccepted / markRegistrationSignProduced', () { blocTest( - 'progresses past the BitBox gate once both marks are set', + 'progresses past the sign gate once both marks are set', setUp: () { when(() => kycService.getKycStatus()).thenAnswer( (_) async => _kycStatus(level: KycLevel.level30), @@ -623,8 +623,8 @@ void main() { act: (cubit) async { await cubit.checkKyc(); // expects legalDisclaimer cubit.markLegalDisclaimerAccepted(); - await cubit.checkKyc(); // expects registration (BitBox gate) - cubit.markBitboxConfirmed(); + await cubit.checkKyc(); // expects registration (sign gate) + cubit.markRegistrationSignProduced(); await cubit.checkKyc(); // expects KycCompleted }, expect: () => [ diff --git a/test/screens/kyc/steps/kyc_email_page_test.dart b/test/screens/kyc/steps/kyc_email_page_test.dart index 4e1ca3c2..984b187b 100644 --- a/test/screens/kyc/steps/kyc_email_page_test.dart +++ b/test/screens/kyc/steps/kyc_email_page_test.dart @@ -1,10 +1,16 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_widget_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_email_status.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_wallet_service.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_step/kyc_email_step_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/email/kyc_email_page.dart'; @@ -16,24 +22,63 @@ class MockKycEmailStepCubit extends MockCubit implements KycE class MockKycCubit extends MockCubit implements KycCubit {} +class MockHomeBloc extends MockBloc implements HomeBloc {} + class MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} +class MockRealUnitWalletService extends Mock implements RealUnitWalletService {} + +class MockDfxWidgetService extends Mock implements DfxWidgetService {} + +/// Pops the first pushed sub-route with the configured [value], so a test can +/// simulate the merge-confirmation page returning success without having to +/// fully mount [KycEmailVerificationPage] and drive its button. The first +/// `didPush` from `pumpWidget` itself (the test's own root route) is skipped. +class _AutoPopObserver extends NavigatorObserver { + _AutoPopObserver({this.value}); + + final Object? value; + bool _seenRoot = false; + bool _popped = false; + + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (!_seenRoot) { + _seenRoot = true; + return; + } + if (_popped) return; + _popped = true; + // Pop on the next microtask so the route gets fully mounted first — + // matches how a real user would tap "I've confirmed" after the page + // appears. + Future.microtask(() => route.navigator?.pop(value as bool?)); + } +} + void main() { late KycEmailStepCubit kycEmailStepCubit; late KycCubit kycCubit; + late HomeBloc homeBloc; setUp(() { kycEmailStepCubit = MockKycEmailStepCubit(); kycCubit = MockKycCubit(); + homeBloc = MockHomeBloc(); when(() => kycEmailStepCubit.state).thenReturn(const KycEmailStepInitial()); when(() => kycCubit.state).thenReturn(const KycInitial()); when(() => kycCubit.checkKyc()).thenAnswer((_) => Future.value()); + when(() => kycCubit.markRegistrationSignProduced()).thenAnswer((_) {}); + when(() => homeBloc.state).thenReturn(const HomeState()); }); void setupDependencyInjection() { final getIt = GetIt.instance; getIt.registerSingleton(MockRealUnitRegistrationService()); + getIt.registerSingleton(MockRealUnitWalletService()); + getIt.registerSingleton(MockDfxWidgetService()); } setUpAll(() { @@ -47,6 +92,7 @@ void main() { providers: [ BlocProvider.value(value: kycCubit), BlocProvider.value(value: kycEmailStepCubit), + BlocProvider.value(value: homeBloc), ], child: child, ); @@ -108,5 +154,71 @@ void main() { expect(find.byType(SnackBar), findsOne); }); + + testWidgets( + 'marks registration sign produced and re-runs checkKyc after merge confirm pops with true', + (tester) async { + whenListen( + kycEmailStepCubit, + Stream.fromIterable([ + const KycEmailStepSuccess(RegistrationEmailStatus.mergeRequested), + ]), + initialState: const KycEmailStepInitial(), + ); + + // Wrap directly so we can install a NavigatorObserver that auto-pops + // the verification page with `true` — simulating a successful merge + // confirmation. `pumpApp` doesn't expose observers. + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + navigatorObservers: [_AutoPopObserver(value: true)], + home: buildSubject(const KycEmailView()), + ), + ); + await tester.pumpAndSettle(); + + verify(() => kycCubit.markRegistrationSignProduced()).called(1); + verify(() => kycCubit.checkKyc()).called(1); + }, + ); + + testWidgets( + 'does NOT mark registration sign produced when merge confirm pops with false / null', + (tester) async { + whenListen( + kycEmailStepCubit, + Stream.fromIterable([ + const KycEmailStepSuccess(RegistrationEmailStatus.mergeRequested), + ]), + initialState: const KycEmailStepInitial(), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + // Pop with `null` (e.g. user backs out of the verification page). + navigatorObservers: [_AutoPopObserver(value: null)], + home: buildSubject(const KycEmailView()), + ), + ); + await tester.pumpAndSettle(); + + verifyNever(() => kycCubit.markRegistrationSignProduced()); + verifyNever(() => kycCubit.checkKyc()); + }, + ); }); } From e71f71e26a3b2fbaf34291c34f1841ec218653ac Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Thu, 21 May 2026 09:05:18 +0200 Subject: [PATCH 2/3] fix(kyc-email-verification): surface failures, add generation guard + retry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KycEmailVerificationCubit.checkEmailVerification` previously emitted `KycEmailVerificationSuccess` unconditionally whenever the JWT account-id changed, even if the subsequent `_completeRegistration` call failed. Two failure modes were silently swallowed: 1. `getWalletStatus` returning `null` `realUnitUserDataDto` (likely a propagation race between the auth service and the user-data service after merge) 2. `registerWallet` throwing for any reason Both emitted `RegistrationFailure` from inside the catch block, but the outer Success emit immediately overwrote it. The snackbar shown by the verification page flashed briefly and was then dismissed by the `context.pop(true)` that Success triggers. The user perceived a successful merge while the wallet was, in fact, not signed and registered. Tests asserted this overwrite behaviour as if it were intended; they were the smoking gun for the bug. Changes: - `_completeRegistration` now returns `Future`. `checkEmailVerification` only emits `Success` when registration genuinely succeeded — otherwise it leaves the cubit in `RegistrationFailure` so the verification page stays open and the user can retry. - Add `_runGeneration` cancellation-token (mirrors `KycCubit`): guards every emit against stale calls and against `isClosed`, prevents a late HTTP response from emitting after a retry, and lets a double-tap supersede the in-flight call instead of racing two emit sequences. - Add `_mergeDetected` short-circuit: once the JWT account-id change has confirmed the merge, the auth side is settled. A retry after a null-userData race should not re-run the account-id comparison (which would emit `Failure` because `getAuthToken` keeps returning the merged account). Subsequent retries skip the auth check and go straight to `_completeRegistration`, where the propagation race usually resolves. - Rewrite the misleading `registerEmailVerificationRegistrationFailed` i18n strings (DE + EN). The old text claimed the wallet had been assigned to the account, which is only true on the `registerWallet` throw path — for the null-userData branch nothing had been assigned. New text is neutral and actionable for both failure modes. - Update two existing tests that asserted the silent-Success behaviour to assert the new, correct behaviour: failures surface to the UI. - Add a new test for the retry path: two consecutive calls with a null-userData first response and a populated second response end in Success, exercising `_mergeDetected`. Refs #465. --- assets/languages/strings_de.arb | 2 +- assets/languages/strings_en.arb | 2 +- .../kyc_email_verification_cubit.dart | 76 ++++++++++++++++--- .../kyc_email_verification_cubit_test.dart | 66 ++++++++++++++-- 4 files changed, 128 insertions(+), 18 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7d2ebe87..aa16c271 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -202,7 +202,7 @@ "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": "Ihre neue Wallet konnte Ihrem Account zugeordnet, jedoch konnte die Wallet nicht registriert werden. Bitte melden Sie sich beim Support.", + "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 747bcb8f..966688b6 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -202,7 +202,7 @@ "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": "Your new wallet has been assigned to your account, but the wallet could not be registered. Please contact support.", + "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 6e96b3aa..1efc52c6 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 @@ -14,6 +14,24 @@ class KycEmailVerificationCubit extends Cubit { final RealUnitWalletService _walletService; 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 + // `checkEmailVerification` captures its own generation; any continuation + // bails when its generation no longer matches the current one. Acts as a + // cancellation token for non-cancellable HTTP work, and lets a fast + // double-tap supersede the in-flight call instead of racing two emit + // 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 `getWalletStatus` 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 RealUnitWalletService walletService, @@ -24,28 +42,66 @@ class KycEmailVerificationCubit extends Cubit { super(const KycEmailVerificationInitial()); Future checkEmailVerification() async { + final generation = ++_runGeneration; + if (isClosed) return; emit(const KycEmailVerificationLoading()); - final currentAccountId = await getAccountId(); - _dfxService.invalidateAuthToken(); - final newAccountId = await getAccountId(); + 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; + } - if (currentAccountId != newAccountId) { - await _completeRegistration(); + // 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 { - emit(const KycEmailVerificationFailure()); } + // 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. } - Future _completeRegistration() async { + /// 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 status = await _walletService.getWalletStatus(); - if (status.realUnitUserDataDto == null) throw Exception('No existing user data'); + if (isClosed || generation != _runGeneration) return false; + if (status.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( + 'getWalletStatus returned null realUnitUserDataDto after merge', + ); + emit(const KycEmailVerificationRegistrationFailure()); + return false; + } await _registrationService.registerWallet(status.realUnitUserDataDto!); + if (isClosed || generation != _runGeneration) return false; + return true; } catch (e) { - developer.log(e.toString()); + if (isClosed || generation != _runGeneration) return false; + developer.log('registerWallet failed: $e'); emit(const KycEmailVerificationRegistrationFailure()); + return false; } } 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 79d28161..407abe17 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 @@ -129,8 +129,8 @@ void main() { ); blocTest( - 'changed account id but no userData → cubit settles on Success (Registration' - 'Failure is intermediate, overwritten by the outer Success emit)', + 'changed account id but no userData → RegistrationFailure, no Success ' + '(propagation race: user can retry by tapping the confirm button again)', setUp: () { final tokens = [_fakeJwt(1), _fakeJwt(2)]; var i = 0; @@ -144,11 +144,19 @@ void main() { }, build: build, act: (c) => c.checkEmailVerification(), - verify: (c) => expect(c.state, isA()), + expect: () => [ + isA(), + isA(), + ], + verify: (_) { + verifyNever(() => registrationService.registerWallet(any())); + }, ); blocTest( - 'registerWallet throws: cubit still settles on Success (does not crash)', + '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; @@ -164,8 +172,54 @@ void main() { }, build: build, act: (c) => c.checkEmailVerification(), - verify: (c) { - expect(c.state, isA()); + 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(() => walletService.getWalletStatus()).thenAnswer((_) async { + walletStatusCallCount++; + return walletStatusCallCount == 1 + ? RealUnitWalletStatusDto( + isRegistered: false, + realUnitUserDataDto: null, + ) + : RealUnitWalletStatusDto( + isRegistered: true, + realUnitUserDataDto: _userData, + ); + }); + when(() => registrationService.registerWallet(any())) + .thenAnswer((_) async => RegistrationStatus.completed); + }, + build: build, + act: (c) async { + await c.checkEmailVerification(); + await c.checkEmailVerification(); + }, + expect: () => [ + isA(), + isA(), + isA(), + isA(), + ], + verify: (_) { + verify(() => registrationService.registerWallet(_userData)).called(1); }, ); }); From 4d44c21f2b47c56cd3b35d3f72f69b53815ef289 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Thu, 21 May 2026 09:05:26 +0200 Subject: [PATCH 3/3] fix(kyc): require street number in registration address form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `addressNumberCtrl` field in `KycRegistrationAddressStep` had no validator. An empty value would pass `_formKey.currentState?.validate()` and the registration submit would send a malformed address to the backend. Surfaced in the #466 review as a follow-up to the merge-misroute fix because the registration form is now the only path an existing-DFX customer never reaches — but for new users it is the primary entry, so the gap matters. Mirrors the validator pattern used by street, postal code, city, and country fields in the same step. --- .../registration/steps/kyc_registration_address_step.dart | 4 ++++ 1 file changed, 4 insertions(+) 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 60ad6c53..25142330 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 @@ -61,6 +61,10 @@ class KycRegistrationAddressStep extends StatelessWidget { controller: addressNumberCtrl, label: S.of(context).number, keyboardType: TextInputType.streetAddress, + validator: (value) { + if (value == null || value.isEmpty) return ''; + return null; + }, ), ), ],