Skip to content
12 changes: 8 additions & 4 deletions lib/packages/service/dfx/dfx_kyc_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ class DfxKycService extends DFXAuthService {
return UserDto.fromJson(json);
}

Future<KycLevelDto> getKycStatus() async {
Future<KycLevelDto> getKycStatus({String? context}) async {
final user = await getUser();

final uri = buildUri(host, _kycPath);
final queryParams = <String, String>{};
if (context != null) queryParams['context'] = context;
final uri = buildUri(host, _kycPath, queryParams.isNotEmpty ? queryParams : null);
final response = await authenticatedGet(
uri,
headers: {
Expand All @@ -55,10 +57,12 @@ class DfxKycService extends DFXAuthService {
return KycLevelDto.fromJson(json);
}

Future<KycSessionDto> continueKyc() async {
Future<KycSessionDto> continueKyc({String? context}) async {
final user = await getUser();

final uri = buildUri(host, _kycPath);
final queryParams = <String, String>{};
if (context != null) queryParams['context'] = context;
final uri = buildUri(host, _kycPath, queryParams.isNotEmpty ? queryParams : null);
final response = await authenticatedPut(
uri,
headers: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart';

class RegistrationRequiredException extends ApiException {
final String? context;

const RegistrationRequiredException({
super.statusCode,
required super.code,
required super.message,
this.context,
});

factory RegistrationRequiredException.fromJson(Map<String, dynamic> json, {int? httpStatusCode}) {
return RegistrationRequiredException(
statusCode: json['statusCode'] as int? ?? httpStatusCode,
code: json['code'] as String,
message: json['message'] as String,
context: json['context'] as String?,
);
}

Expand All @@ -22,13 +26,15 @@ class RegistrationRequiredException extends ApiException {
class KycLevelRequiredException extends ApiException {
final int requiredLevel;
final int currentLevel;
final String? context;

const KycLevelRequiredException({
super.statusCode,
required super.code,
required super.message,
required this.requiredLevel,
required this.currentLevel,
this.context,
});

factory KycLevelRequiredException.fromJson(Map<String, dynamic> json, {int? httpStatusCode}) {
Expand All @@ -38,6 +44,7 @@ class KycLevelRequiredException extends ApiException {
message: json['message'] as String,
requiredLevel: json['requiredLevel'] as int,
currentLevel: json['currentLevel'] as int,
context: json['context'] as String?,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ class BuyPaymentInfoCubit extends Cubit<BuyPaymentInfoState> {
return BuyPaymentInfoFailure(
PaymentInfoError.kycRequired,
requiredLevel: e.requiredLevel,
context: e.context,
);
} on RegistrationRequiredException catch (e) {
return BuyPaymentInfoFailure(
PaymentInfoError.registrationRequired,
context: e.context,
);
} on RegistrationRequiredException {
return const BuyPaymentInfoFailure(PaymentInfoError.registrationRequired);
} on BitboxNotConnectedException {
return const BuyPaymentInfoFailure(PaymentInfoError.bitboxDisconnected);
} catch (e) {
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? context;

const BuyPaymentInfoFailure(this.error, {this.requiredLevel});
const BuyPaymentInfoFailure(this.error, {this.requiredLevel, this.context});

@override
List<Object?> get props => [error, requiredLevel];
List<Object?> get props => [error, requiredLevel, context];
}

class BuyPaymentInfoMinAmountNotMetFailure extends BuyPaymentInfoFailure {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class PaymentAdditionalActionNeededButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 20),
child: AppFilledButton(
onPressed: () async {
await context.pushNamed(AppRoutes.kyc);
await context.pushNamed(AppRoutes.kyc, extra: paymentState.context);
if (context.mounted) {
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
amount: amountController.text,
Expand All @@ -72,7 +72,7 @@ class PaymentAdditionalActionNeededButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 20),
child: AppFilledButton(
onPressed: () async {
await context.pushNamed(AppRoutes.kyc);
await context.pushNamed(AppRoutes.kyc, extra: paymentState.context);
if (context.mounted) {
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
amount: amountController.text,
Expand Down
8 changes: 5 additions & 3 deletions lib/screens/kyc/cubits/kyc/kyc_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class KycCubit extends Cubit<KycState> {

bool _legalDisclaimerAccepted = false;
bool _emailRegistrationAttempted = false;
String? _kycContext;

// `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 All @@ -42,7 +43,8 @@ class KycCubit extends Cubit<KycState> {
_appStore = appStore,
super(const KycInitial());

Future<void> checkKyc() async {
Future<void> checkKyc({String? context}) async {
_kycContext = context ?? _kycContext;
final generation = ++_runGeneration;
try {
await _runCheckKyc(generation).timeout(_checkKycTimeout);
Expand All @@ -61,7 +63,7 @@ class KycCubit extends Cubit<KycState> {
emit(const KycLoading());

final results = await Future.wait([
_kycService.getKycStatus(),
_kycService.getKycStatus(context: _kycContext),
_kycService.getUser(),
]);

Expand Down Expand Up @@ -227,7 +229,7 @@ class KycCubit extends Cubit<KycState> {

/// should only be called after realunit registration was completed
Future<void> _continueKyc(int generation) async {
final kycStatus = await _kycService.continueKyc();
final kycStatus = await _kycService.continueKyc(context: _kycContext);
if (isClosed || generation != _runGeneration) return;

// `KycSessionDto.currentStep` is the authoritative source — see
Expand Down
8 changes: 5 additions & 3 deletions lib/screens/kyc/kyc_page_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,18 @@ import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart';
import 'package:realunit_wallet/setup/di.dart';

class KycPageManager extends StatelessWidget {
const KycPageManager({super.key});
final String? kycContext;

const KycPageManager({super.key, this.kycContext});

@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => KycCubit(
create: (_) => KycCubit(
getIt<DfxKycService>(),
getIt<RealUnitRegistrationService>(),
getIt<AppStore>(),
)..checkKyc(),
)..checkKyc(context: kycContext),
child: const KycViewManager(),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class SellPaymentInfoCubit extends Cubit<SellPaymentInfoState> {
PaymentInfoError.kycRequired,
message: e.toString(),
requiredLevel: e.requiredLevel,
context: e.context,
),
);
} on RegistrationRequiredException catch (e) {
Expand All @@ -85,6 +86,7 @@ class SellPaymentInfoCubit extends Cubit<SellPaymentInfoState> {
SellPaymentInfoFailure(
PaymentInfoError.registrationRequired,
message: e.toString(),
context: e.context,
),
);
} on BitboxNotConnectedException catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ class SellPaymentInfoFailure extends SellPaymentInfoState {
final PaymentInfoError error;
final String message;
final int? requiredLevel;
final String? context;

const SellPaymentInfoFailure(this.error, {this.message = '', this.requiredLevel});
const SellPaymentInfoFailure(this.error, {this.message = '', this.requiredLevel, this.context});

@override
List<Object?> get props => [error, message, requiredLevel];
List<Object?> get props => [error, message, requiredLevel, context];
}

class SellPaymentInfoMinAmountNotMet extends SellPaymentInfoState {
Expand Down
4 changes: 2 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,11 @@ class SellButton extends StatelessWidget {
listener: (context, state) async {
if (state is SellPaymentInfoFailure) {
if (state.error == .kycRequired) {
await context.pushNamed(AppRoutes.kyc);
await context.pushNamed(AppRoutes.kyc, extra: state.context);
return;
}
if (state.error == .registrationRequired) {
await context.pushNamed(AppRoutes.kyc);
await context.pushNamed(AppRoutes.kyc, extra: state.context);
return;
}
if (state.error == .bitboxDisconnected) {
Expand Down
2 changes: 1 addition & 1 deletion lib/setup/routing/router_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ final GoRouter routerConfig = GoRouter(
GoRoute(
name: AppRoutes.kyc,
path: '/kyc',
builder: (_, _) => const KycPageManager(),
builder: (_, state) => KycPageManager(kycContext: state.extra as String?),
),

GoRoute(
Expand Down
64 changes: 64 additions & 0 deletions test/packages/service/dfx/dfx_kyc_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,38 @@ void main() {
);
});

test('appends ?context= query param when context is provided', () async {
Uri? capturedUri;
final client = MockClient((request) async {
if (request.url.path == '/v2/user') {
return http.Response(jsonEncode(_userJson), 200);
}
capturedUri = request.url;
return http.Response(jsonEncode(_kycLevelJson), 200);
});

await build(client).getKycStatus(context: 'RealunitBuy');

expect(capturedUri!.path, '/v2/kyc');
expect(capturedUri!.queryParameters['context'], 'RealunitBuy');
});

test('omits context query param when context is null', () async {
Uri? capturedUri;
final client = MockClient((request) async {
if (request.url.path == '/v2/user') {
return http.Response(jsonEncode(_userJson), 200);
}
capturedUri = request.url;
return http.Response(jsonEncode(_kycLevelJson), 200);
});

await build(client).getKycStatus();

expect(capturedUri!.path, '/v2/kyc');
expect(capturedUri!.queryParameters, isNot(contains('context')));
});

// Failure on the user-fetch must short-circuit — the kyc-code is
// unobtainable, so calling /v2/kyc would just produce another error.
// Uses a 4xx that does NOT trigger the auth-service's 401 token refresh
Expand Down Expand Up @@ -403,6 +435,38 @@ void main() {
expect(dto.currentStep!.session.type, UrlType.browser);
});

test('appends ?context= query param when context is provided', () async {
Uri? capturedUri;
final client = MockClient((request) async {
if (request.url.path == '/v2/user') {
return http.Response(jsonEncode(_userJson), 200);
}
capturedUri = request.url;
return http.Response(jsonEncode(_kycSessionJson), 200);
});

await build(client).continueKyc(context: 'RealunitSell');

expect(capturedUri!.path, '/v2/kyc');
expect(capturedUri!.queryParameters['context'], 'RealunitSell');
});

test('omits context query param when context is null', () async {
Uri? capturedUri;
final client = MockClient((request) async {
if (request.url.path == '/v2/user') {
return http.Response(jsonEncode(_userJson), 200);
}
capturedUri = request.url;
return http.Response(jsonEncode(_kycSessionJson), 200);
});

await build(client).continueKyc();

expect(capturedUri!.path, '/v2/kyc');
expect(capturedUri!.queryParameters, isNot(contains('context')));
});

test('throws ApiException on a 409 conflict from /v2/kyc', () async {
final client = MockClient((request) async {
if (request.url.path == '/v2/user') {
Expand Down
34 changes: 34 additions & 0 deletions test/packages/service/dfx/exceptions/api_exception_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ void main() {
final kyc = exception as KycLevelRequiredException;
expect(kyc.requiredLevel, 30);
expect(kyc.currentLevel, 20);
expect(kyc.context, isNull);
});

test('creates KycLevelRequiredException with context from JSON', () {
final exception = ApiException.fromJson(
{
'code': 'KYC_LEVEL_REQUIRED',
'message': 'KYC level too low',
'requiredLevel': 30,
'currentLevel': 20,
'context': 'RealunitBuy',
},
httpStatusCode: 403,
);

expect(exception, isA<KycLevelRequiredException>());
final kyc = exception as KycLevelRequiredException;
expect(kyc.context, 'RealunitBuy');
});

test('creates RegistrationRequiredException with httpStatusCode', () {
Expand All @@ -73,6 +91,22 @@ void main() {
expect(exception, isA<RegistrationRequiredException>());
expect(exception.statusCode, 403);
expect(exception.message, 'Please register first');
expect((exception as RegistrationRequiredException).context, isNull);
});

test('creates RegistrationRequiredException with context from JSON', () {
final exception = ApiException.fromJson(
{
'code': 'REGISTRATION_REQUIRED',
'message': 'Please register first',
'context': 'RealunitSell',
},
httpStatusCode: 403,
);

expect(exception, isA<RegistrationRequiredException>());
final reg = exception as RegistrationRequiredException;
expect(reg.context, 'RealunitSell');
});
});
});
Expand Down
Loading
Loading