Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ blocTest<KycCubit, KycState>(
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [const KycLoading(), const KycCompleted()],
Expand Down
33 changes: 20 additions & 13 deletions lib/screens/kyc/cubits/kyc/kyc_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ class KycCubit extends Cubit<KycState> {
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
Expand Down Expand Up @@ -101,17 +104,17 @@ class KycCubit extends Cubit<KycState> {
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;
}
Expand Down Expand Up @@ -190,8 +193,12 @@ class KycCubit extends Cubit<KycState> {
_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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
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,
Expand All @@ -24,28 +42,66 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
super(const KycEmailVerificationInitial());

Future<void> 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<void> _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<bool> _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;
}
}

Expand Down
9 changes: 9 additions & 0 deletions lib/screens/kyc/steps/email/kyc_email_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ class _KycEmailFormState extends State<KycEmailForm> {
),
);
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<KycCubit>().markRegistrationSignProduced();
context.read<KycCubit>().checkKyc();
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/screens/kyc/steps/registration/kyc_registration_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ class _KycRegistrationViewState extends State<KycRegistrationView> {
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<KycCubit>().markBitboxConfirmed();
// completeRegistration already produced the EIP-712 registration
// signature, so the sign gate is satisfied for this session.
context.read<KycCubit>().markRegistrationSignProduced();
context.read<KycCubit>().checkKyc();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
),
),
],
Expand Down
38 changes: 19 additions & 19 deletions test/screens/kyc/cubits/kyc/kyc_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ void main() {
);

blocTest<KycCubit, KycState>(
'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),
Expand Down Expand Up @@ -165,7 +165,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [
Expand All @@ -190,7 +190,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [
Expand All @@ -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)],
Expand All @@ -240,7 +240,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [
Expand Down Expand Up @@ -268,7 +268,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [
Expand All @@ -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<KycCubit, KycState>(
'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),
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -462,7 +462,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
verify: (cubit) {
Expand All @@ -487,7 +487,7 @@ void main() {
build: buildCubit,
act: (cubit) async {
cubit.markLegalDisclaimerAccepted();
cubit.markBitboxConfirmed();
cubit.markRegistrationSignProduced();
await cubit.checkKyc();
},
expect: () => [
Expand All @@ -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)],
Expand All @@ -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)],
Expand Down Expand Up @@ -559,7 +559,7 @@ void main() {

final cubit = KycCubit(kycService, registrationService)
..markLegalDisclaimerAccepted()
..markBitboxConfirmed();
..markRegistrationSignProduced();

final states = <KycState>[];
final sub = cubit.stream.listen(states.add);
Expand Down Expand Up @@ -610,9 +610,9 @@ void main() {
);
});

group('$KycCubit markLegalDisclaimerAccepted / markBitboxConfirmed', () {
group('$KycCubit markLegalDisclaimerAccepted / markRegistrationSignProduced', () {
blocTest<KycCubit, KycState>(
'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),
Expand All @@ -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: () => [
Expand Down
Loading
Loading