Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';

/// Reported — never thrown — when the API routes an account to a KYC step this
/// app has no screen for, or reports a pending review whose step list holds no
/// required step we can name.
///
/// The app maps a subset of [KycStepName] to screens; anything outside it lands
/// on the generic handoff page. Nothing else marks that: every request in the
/// flow returns 200, so without this event the only signal that the mapping
/// table has a gap is a user complaint.
///
/// [stepName] is the API's wire identifier for the step, or null when the API
/// named no step at all. It carries nothing about the user.
class KycUnsupportedStepException implements Exception {
final KycStepName? stepName;

const KycUnsupportedStepException(this.stepName);

@override
String toString() =>
'KycUnsupportedStepException: no screen for KYC step '
'${stepName?.value ?? '<none reported>'}';
}
15 changes: 12 additions & 3 deletions lib/screens/buy/widgets/payment_action_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ class PaymentActionButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 20),
child: AppFilledButton(
onPressed: () async {
await context.pushNamed(AppRoutes.kyc, extra: paymentState.context);
await context.pushNamed(
AppRoutes.kyc,
queryParameters: kycRouteQuery(paymentState.context),
);
if (context.mounted) {
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
amount: amountController.text,
Expand All @@ -83,7 +86,10 @@ class PaymentActionButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 20),
child: AppFilledButton(
onPressed: () async {
await context.pushNamed(AppRoutes.kyc, extra: paymentState.context);
await context.pushNamed(
AppRoutes.kyc,
queryParameters: kycRouteQuery(paymentState.context),
);
if (context.mounted) {
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
amount: amountController.text,
Expand Down Expand Up @@ -128,7 +134,10 @@ class PaymentActionButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 20),
child: AppFilledButton(
onPressed: () async {
await context.pushNamed(AppRoutes.kyc, extra: paymentState.context);
await context.pushNamed(
AppRoutes.kyc,
queryParameters: kycRouteQuery(paymentState.context),
);
if (context.mounted) {
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
amount: amountController.text,
Expand Down
43 changes: 32 additions & 11 deletions lib/screens/kyc/cubits/kyc/kyc_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/kyc_unsupported_step_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart';
import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
import 'package:realunit_wallet/packages/service/dfx/models/legal/real_unit_legal_agreement.dart';
Expand All @@ -15,6 +16,7 @@ import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_reg
import 'package:realunit_wallet/packages/service/dfx/real_unit_legal_service.dart';
import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart';
import 'package:realunit_wallet/packages/wallet/wallet.dart';
import 'package:realunit_wallet/setup/error_handling/crash_reporting.dart';

part 'kyc_state.dart';

Expand All @@ -26,6 +28,10 @@ class KycCubit extends Cubit<KycState> {
final RealUnitLegalService _legalService;
final AppStore _appStore;

/// Sink for the unmapped-step report — see [_emitUnsupportedStep]. Injectable
/// so tests can observe the report without the crash reporter running.
final NonFatalReporter _report;

/// Offline fallback ONLY. The legal disclaimer gate is server-driven via
/// `_legalService.getLegalInfo()`; this per-session flag is used solely when
/// that endpoint is unreachable (pre-rollout backend or outage) so the
Expand Down Expand Up @@ -53,15 +59,17 @@ class KycCubit extends Cubit<KycState> {
DfxKycService kycService,
RealUnitRegistrationService registrationService,
RealUnitLegalService legalService,
AppStore appStore,
) : _kycService = kycService,
_registrationService = registrationService,
_legalService = legalService,
_appStore = appStore,
super(const KycInitial());
AppStore appStore, {
NonFatalReporter report = reportNonFatal,
}) : _kycService = kycService,
_registrationService = registrationService,
_legalService = legalService,
_appStore = appStore,
_report = report,
super(const KycInitial());

Future<void> checkKyc({String? context}) async {
_kycContext = context ?? _kycContext;
_kycContext = (context == null || context.isEmpty) ? _kycContext : context;
final generation = ++_runGeneration;
try {
await _runCheckKyc(generation).timeout(_checkKycTimeout);
Expand Down Expand Up @@ -245,12 +253,12 @@ class KycCubit extends Cubit<KycState> {
(s) => s.isRequired && s.status != KycStepStatus.completed,
);
if (pending == null) {
emit(const KycUnsupportedStepFailure(null));
_emitUnsupportedStep(null);
return;
}
final step = _mapStepName(pending.name);
if (step == null) {
emit(KycUnsupportedStepFailure(pending.name));
_emitUnsupportedStep(pending.name);
return;
}
emit(KycPending(step));
Expand Down Expand Up @@ -324,13 +332,13 @@ class KycCubit extends Cubit<KycState> {
// in the i18n message).
final currentStep = kycStatus.currentStep;
if (currentStep == null) {
emit(const KycUnsupportedStepFailure(null));
_emitUnsupportedStep(null);
return;
}

final kycStep = _mapStepName(currentStep.name);
if (kycStep == null) {
emit(KycUnsupportedStepFailure(currentStep.name));
_emitUnsupportedStep(currentStep.name);
return;
}

Expand All @@ -343,6 +351,19 @@ class KycCubit extends Cubit<KycState> {
);
}

/// Routes an unmapped step to the generic handoff page and reports the
/// occurrence.
///
/// The report is the only trace this leaves: every call in the flow returned
/// 200 and the user sees a handoff screen, so a step name missing from
/// [_mapStepName] is otherwise invisible until someone writes in. Reporting
/// before the emit keeps the event even when the user leaves the flow on this
/// screen.
void _emitUnsupportedStep(KycStepName? stepName) {
_report(KycUnsupportedStepException(stepName));
emit(KycUnsupportedStepFailure(stepName));
}

KycStep? _mapStepName(KycStepName name) => switch (name) {
KycStepName.contactData => KycStep.registration,
KycStepName.personalData => KycStep.personalData,
Expand Down
10 changes: 8 additions & 2 deletions lib/screens/sell/widgets/sell_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ class SellButton extends StatelessWidget {
listener: (context, state) async {
if (state is SellPaymentInfoFailure) {
if (state.error == .kycRequired) {
await context.pushNamed(AppRoutes.kyc, extra: state.context);
await context.pushNamed(
AppRoutes.kyc,
queryParameters: kycRouteQuery(state.context),
);
return;
}
if (state.error == .registrationRequired) {
await context.pushNamed(AppRoutes.kyc, extra: state.context);
await context.pushNamed(
AppRoutes.kyc,
queryParameters: kycRouteQuery(state.context),
);
return;
}
if (state.error == .bitboxDisconnected) {
Expand Down
31 changes: 31 additions & 0 deletions lib/setup/error_handling/crash_reporting.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer' as developer;

import 'package:flutter/foundation.dart';
Expand Down Expand Up @@ -56,6 +57,36 @@ Future<void> initCrashReporting({
}
}

/// Sink for a condition the app caught and handled but that should not have
/// happened. Matches [reportNonFatal] so a caller can hold the seam as a field
/// and a test can record instead of report.
typedef NonFatalReporter = void Function(Object error);

/// Records [error] as a non-fatal event: a `developer.log` line for an attached
/// developer plus, when the crash reporter is running, an error event carrying
/// the same object.
///
/// Same channel and same pinned option surface as the uncaught-error path — no
/// PII, no attachments, no breadcrumb widening — so callers must pass an error
/// object that describes the condition in its own `toString()` and nothing
/// about the user.
///
/// Gated on the same [crashReportingDsn] that decides whether [initCrashReporting]
/// starts the SDK at all: without an injected DSN — every local and test build —
/// nothing was ever started, and the report is a pure log line.
void reportNonFatal(Object error) {
developer.log('non-fatal: $error', name: 'WalletApp', error: error);
if (crashReportingDsn.isEmpty) return;
try {
Sentry.captureException(error).ignore();
} catch (_) {
// A caller-visible throw here must never happen, since this runs before
// a required emit in KycCubit. `.ignore()` discards both the eventual
// value and any asynchronous error; the try/catch covers a synchronous
// throw from the call itself.
}
}

/// Applies the pinned option set. The guarantee is exactly this list — an
/// upstream default flip outside it is not caught here: no PII, no
/// screenshots, no performance tracing, no session telemetry. Native crash
Expand Down
3 changes: 2 additions & 1 deletion lib/setup/routing/router_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ final GoRouter routerConfig = GoRouter(
GoRoute(
name: AppRoutes.kyc,
path: '/kyc',
builder: (_, state) => KycPageManager(kycContext: state.extra as String?),
builder: (_, state) =>
KycPageManager(kycContext: state.uri.queryParameters['context']),
),

GoRoute(
Expand Down
17 changes: 17 additions & 0 deletions lib/setup/routing/routes/app_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,20 @@ abstract final class AppRoutes {

static const webView = 'webView';
}

/// Query parameters that scope the [AppRoutes.kyc] flow to the context the user
/// entered it from (`RealunitBuy`, `RealunitSell`, …), as the API reported it.
///
/// The context travels in the URL rather than in `extra` because the flow has a
/// second entry: after a background lock the boot ladder re-pushes the captured
/// location by bare path (`BootNavRestore` in `boot_navigation.dart`), which
/// carries no `extra`. Without the query the restored flow rebuilds unscoped,
/// and the API then computes `processStatus` over every globally required step
/// instead of only those the entered flow needs — reporting a user who has
/// finished `Ident` as still in progress for steps that do not gate buying.
///
/// A null or empty [kycContext] means the API attached none; the route is
/// then entered unscoped exactly as before, never with an invented context.
Map<String, String> kycRouteQuery(String? kycContext) => {
if (kycContext != null && kycContext.isNotEmpty) 'context': kycContext,
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_unavailable_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/kyc_unsupported_step_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/pay_exceptions.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/sell_exceptions.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/transfer_exceptions.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';
import 'package:realunit_wallet/packages/storage/secure_storage.dart';
import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart';

Expand Down Expand Up @@ -42,6 +44,7 @@ void main() {
const TransferGasFundingUnavailableException(),
const TransferConfirmMismatchException(),
const TransferAlreadyConfirmedException(code: 'TEST', message: 'test'),
const KycUnsupportedStepException(KycStepName.statutes),
];

for (final ex in exceptions) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/kyc_unsupported_step_exception.dart';
import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart';

// The rendered string is the whole payload of the report — the crash reporter
// receives nothing else about the occurrence. If it does not name the step, the
// event cannot say which entry the client's mapping table is missing.
void main() {
group('$KycUnsupportedStepException', () {
test('names the step by its API wire identifier', () {
const exception = KycUnsupportedStepException(KycStepName.residencePermit);

expect(exception.stepName, KycStepName.residencePermit);
expect(exception.toString(), contains(KycStepName.residencePermit.value));
});

test('renders readably when the API named no step at all', () {
const exception = KycUnsupportedStepException(null);

expect(exception.stepName, isNull);
expect(exception.toString(), isNot(contains('null')));
expect(exception.toString(), isNotEmpty);
});
});
}
8 changes: 4 additions & 4 deletions test/screens/buy/widgets/payment_action_button_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ void main() {
// value (mirrors the registration / KYC gates), so the re-fetch is
// asserted on both paths.
bool? emailCaptureResult;
String? kycExtra;
String? kycContext;

setUpAll(() {
registerFallbackValue(Currency.chf);
Expand All @@ -43,7 +43,7 @@ void main() {
amountController = TextEditingController(text: '250');
pushedRoutes = <String>[];
emailCaptureResult = true;
kycExtra = null;
kycContext = null;

when(() => converterCubit.state)
.thenReturn(const BuyConverterState(currency: Currency.eur));
Expand Down Expand Up @@ -92,7 +92,7 @@ void main() {
path: '/kyc',
builder: (_, state) {
pushedRoutes.add(AppRoutes.kyc);
kycExtra = state.extra as String?;
kycContext = state.uri.queryParameters['context'];
return _EmailCaptureStub(
onReady: (popContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Expand Down Expand Up @@ -199,7 +199,7 @@ void main() {

// Routed to KYC confirm-email, not to email capture.
expect(pushedRoutes, [AppRoutes.kyc]);
expect(kycExtra, 'RealunitBuy');
expect(kycContext, 'RealunitBuy');
// After the KYC flow returns, the quote is re-fetched with the
// current amount + currency so a now-confirmed email surfaces the CTA.
verify(
Expand Down
Loading