Skip to content
Open
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
3 changes: 3 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
14 changes: 14 additions & 0 deletions lib/packages/service/dfx/models/user/dto/user_dto.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> addresses;

const UserDto({
this.mail,
required this.kyc,
this.capabilities = const UserCapabilitiesDto(),
this.addresses = const [],
});

factory UserDto.fromJson(Map<String, dynamic> json) {
Expand All @@ -18,6 +25,13 @@ class UserDto {
capabilities: json['capabilities'] != null
? UserCapabilitiesDto.fromJson(json['capabilities'] as Map<String, dynamic>)
: const UserCapabilitiesDto(),
addresses:
(json['addresses'] as List<dynamic>?)
?.map((a) => a is Map<String, dynamic> ? a['address'] : null)
.whereType<String>()
.map((address) => address.toLowerCase())
.toList() ??
const [],
);
}
}
Expand Down
7 changes: 6 additions & 1 deletion lib/screens/buy/buy_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ class BuyView extends StatefulWidget {
}

class _BuyViewState extends State<BuyView> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,34 @@ class BuyPaymentInfoCubit extends Cubit<BuyPaymentInfoState> {
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(),
);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object?> get props => [error, requiredLevel];
List<Object?> get props => [error, requiredLevel, message];
}

class BuyPaymentInfoMinAmountNotMetFailure extends BuyPaymentInfoFailure {
Expand Down
3 changes: 3 additions & 0 deletions lib/screens/kyc/cubits/kyc/kyc_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions lib/screens/kyc/kyc_page_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class KycViewManager extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<KycCubit, KycState>(
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(
Expand Down Expand Up @@ -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}',
),
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -12,6 +13,11 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
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
Expand All @@ -25,16 +31,38 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
// 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<void> checkEmailVerification() async {
Expand Down Expand Up @@ -63,36 +91,70 @@ class KycEmailVerificationCubit extends Cubit<KycEmailVerificationState> {
// 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<bool> _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<void>.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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
13 changes: 12 additions & 1 deletion lib/screens/kyc/steps/email/kyc_email_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,21 @@ class _KycEmailFormState extends State<KycEmailForm> {
context.read<KycCubit>().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<KycCubit> not found` crash. `.value` does not own the
// cubit, so popping the route never closes it.
final kycCubit = context.read<KycCubit>();
final isConfirmed = await Navigator.push<bool>(
context,
MaterialPageRoute<bool>(
builder: (BuildContext context) => const KycEmailVerificationPage(),
builder: (_) => BlocProvider<KycCubit>.value(
value: kycCubit,
child: const KycEmailVerificationPage(),
),
),
);
if (isConfirmed == true && context.mounted) {
Expand Down
Loading
Loading