From 8f3ef700b20df3f6f9251e4753411747c2750529 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 12 May 2026 23:05:07 +0200 Subject: [PATCH 1/2] refactor(wallet): typed SigningCancelledException with i18n message Replace generic Exception at both empty-signature guard sites with a typed SigningCancelledException so the KYC registration page can show a localised user message instead of a raw English exception string. Closes #316 --- assets/languages/strings_de.arb | 1 + assets/languages/strings_en.arb | 1 + .../service/dfx/dfx_auth_service.dart | 3 +- lib/packages/wallet/eip712_signer.dart | 3 +- .../signing_cancelled_exception.dart | 6 ++ .../kyc_registration_submit_cubit.dart | 4 +- .../kyc_registration_submit_state.dart | 5 +- .../registration/kyc_registration_page.dart | 7 ++- test/packages/wallet/eip712_signer_test.dart | 59 +++++++++++++++++++ 9 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 lib/packages/wallet/exceptions/signing_cancelled_exception.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6244e58d..90d6ed47 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -254,6 +254,7 @@ "settingsWalletBackupSubtitle2": "Dies ist die einzige Möglichkeit, Ihre Wallet wiederherzustellen.", "showSeed": "Seed anzeigen", "signature": "Signatur", + "signingCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen", "signMessage": "Signierte Nachricht", "signMessageGet": "Signierte Nachricht abrufen", "skip": "Überspringen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index f0f12ad2..e2d7c0bd 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -254,6 +254,7 @@ "settingsWalletBackupSubtitle2": "This is the only way to recover your wallet.", "showSeed": "Show Seed", "signature": "Signature", + "signingCancelled": "Signature cancelled — please confirm on the BitBox again", "signMessage": "Sign Message", "signMessageGet": "Get Sign Message", "skip": "Skip", diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 4c6f8924..00be0445 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; abstract class DFXAuthService { @@ -48,7 +49,7 @@ abstract class DFXAuthService { final signature = await wallet.signMessage(message).timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { - throw Exception('Wallet returned an empty signature'); + throw const SigningCancelledException(); } await appStore.sessionCache.saveSignature(walletAddress, signature); diff --git a/lib/packages/wallet/eip712_signer.dart b/lib/packages/wallet/eip712_signer.dart index 944317c1..f19f0e78 100644 --- a/lib/packages/wallet/eip712_signer.dart +++ b/lib/packages/wallet/eip712_signer.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; @@ -126,7 +127,7 @@ class Eip712Signer { // guard the empty sig would be sent to the backend and the abort would // be misread as a successful sign. if (signature.isEmpty || signature == '0x') { - throw Exception('Signature was empty — wallet may have been cancelled or disconnected'); + throw const SigningCancelledException(); } return signature; } diff --git a/lib/packages/wallet/exceptions/signing_cancelled_exception.dart b/lib/packages/wallet/exceptions/signing_cancelled_exception.dart new file mode 100644 index 00000000..4d2c5b00 --- /dev/null +++ b/lib/packages/wallet/exceptions/signing_cancelled_exception.dart @@ -0,0 +1,6 @@ +class SigningCancelledException implements Exception { + const SigningCancelledException(); + + @override + String toString() => 'SigningCancelledException'; +} diff --git a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart index 1beaec85..00fbbbe2 100644 --- a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart +++ b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit.dart @@ -66,7 +66,7 @@ class KycRegistrationSubmitCubit extends Cubit { } } catch (e) { developer.log(e.toString()); - emit(KycRegistrationSubmitFailure(e.toString())); + emit(KycRegistrationSubmitFailure(e.toString(), cause: e)); return; } } @@ -92,7 +92,7 @@ class KycRegistrationSubmitCubit extends Cubit { emit(const KycRegistrationSubmitSuccess(RegistrationStatus.completed)); } catch (e) { developer.log(e.toString()); - emit(KycRegistrationSubmitFailure(e.toString())); + emit(KycRegistrationSubmitFailure(e.toString(), cause: e)); } } diff --git a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_state.dart b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_state.dart index 07abe338..4cfa1a44 100644 --- a/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_state.dart +++ b/lib/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_state.dart @@ -22,11 +22,12 @@ class KycRegistrationSubmitSuccess extends KycRegistrationSubmitState { class KycRegistrationSubmitFailure extends KycRegistrationSubmitState { final String message; + final Object? cause; - const KycRegistrationSubmitFailure(this.message); + const KycRegistrationSubmitFailure(this.message, {this.cause}); @override - List get props => [message]; + List get props => [message, cause]; } class KycRegistrationSubmitBitboxRequired extends KycRegistrationSubmitState { diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index c9a2e627..2fe905bb 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -4,11 +4,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; @@ -109,9 +111,12 @@ class _KycRegistrationViewState extends State { } } if (state is KycRegistrationSubmitFailure) { + final message = state.cause is SigningCancelledException + ? S.of(context).signingCancelled + : 'Registration failed:\n${state.message}'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Registration failed:\n${state.message}'), + content: Text(message), backgroundColor: RealUnitColors.status.red600, ), ); diff --git a/test/packages/wallet/eip712_signer_test.dart b/test/packages/wallet/eip712_signer_test.dart index ae968cb9..0b1ee869 100644 --- a/test/packages/wallet/eip712_signer_test.dart +++ b/test/packages/wallet/eip712_signer_test.dart @@ -1,10 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:web3dart/web3dart.dart'; +class _MockBitboxCredentials extends Mock implements BitboxCredentials {} + void main() { late String privateKeyHex; late RegistrationUserType type; @@ -79,5 +84,59 @@ void main() { '0xa11cb57186b9c9f9a09fafa7a3aa256ab14ca030d7eba89f35026b64925d617b3e2cb15349ca561fae5e431deed3f1aa69c7d391cfba80aa6111e753fa782ea21c', ); }); + + test('throws SigningCancelledException when BitBox returns empty signature', () async { + final credentials = _MockBitboxCredentials(); + when(() => credentials.address) + .thenReturn(EthereumAddress.fromHex('0x0000000000000000000000000000000000000001')); + when(() => credentials.signTypedDataV4(any(), any())).thenAnswer((_) async => ''); + + expect( + () => Eip712Signer.signRegistration( + credentials: credentials, + chainId: 1, + type: RegistrationUserType.human.jsonName, + email: 'cancel@dfx.swiss', + name: 'Cancel User', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-12', + ), + throwsA(isA()), + ); + }); + + test('throws SigningCancelledException when BitBox returns 0x signature', () async { + final credentials = _MockBitboxCredentials(); + when(() => credentials.address) + .thenReturn(EthereumAddress.fromHex('0x0000000000000000000000000000000000000001')); + when(() => credentials.signTypedDataV4(any(), any())).thenAnswer((_) async => '0x'); + + expect( + () => Eip712Signer.signRegistration( + credentials: credentials, + chainId: 1, + type: RegistrationUserType.human.jsonName, + email: 'cancel@dfx.swiss', + name: 'Cancel User', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-12', + ), + throwsA(isA()), + ); + }); }); } From 7599a745c8110edce9ace248f54855d17be6310c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 12 May 2026 23:13:26 +0200 Subject: [PATCH 2/2] refactor(kyc): localise registration failure fallback and dedupe test - Add `registrationFailed` i18n key (DE/EN) for the non-cancellation failure path so the snackbar is fully localised, not partially - Collapse the two empty-signature tests into a single parametrised loop over `''` and `'0x'` --- assets/languages/strings_de.arb | 1 + assets/languages/strings_en.arb | 1 + .../registration/kyc_registration_page.dart | 2 +- test/packages/wallet/eip712_signer_test.dart | 81 +++++++------------ 4 files changed, 32 insertions(+), 53 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 90d6ed47..9e05b096 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -204,6 +204,7 @@ "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registerPhoneNumberTooShort": "Telefonnummer ist zu kurz", + "registrationFailed": "Registrierung fehlgeschlagen:\n${message}", "registrationRequired": "Registrierung erforderlich", "registrationRequiredDescription": "Um RealUnit Token kaufen zu können, müssen Sie sich einmalig registrieren.", "reset": "Zurücksetzen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index e2d7c0bd..13c645b1 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -204,6 +204,7 @@ "registerPhoneNumberInvalid": "Phone number is required", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registerPhoneNumberTooShort": "Phone number is too short", + "registrationFailed": "Registration failed:\n${message}", "registrationRequired": "Registration required", "registrationRequiredDescription": "To purchase RealUnit tokens, you must register once.", "reset": "Reset", diff --git a/lib/screens/kyc/steps/registration/kyc_registration_page.dart b/lib/screens/kyc/steps/registration/kyc_registration_page.dart index 2fe905bb..8036a5c1 100644 --- a/lib/screens/kyc/steps/registration/kyc_registration_page.dart +++ b/lib/screens/kyc/steps/registration/kyc_registration_page.dart @@ -113,7 +113,7 @@ class _KycRegistrationViewState extends State { if (state is KycRegistrationSubmitFailure) { final message = state.cause is SigningCancelledException ? S.of(context).signingCancelled - : 'Registration failed:\n${state.message}'; + : S.of(context).registrationFailed(state.message); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), diff --git a/test/packages/wallet/eip712_signer_test.dart b/test/packages/wallet/eip712_signer_test.dart index 0b1ee869..45e8f9da 100644 --- a/test/packages/wallet/eip712_signer_test.dart +++ b/test/packages/wallet/eip712_signer_test.dart @@ -10,6 +10,23 @@ import 'package:web3dart/web3dart.dart'; class _MockBitboxCredentials extends Mock implements BitboxCredentials {} +Future _signWith(BitboxCredentials credentials) => Eip712Signer.signRegistration( + credentials: credentials, + chainId: 1, + type: RegistrationUserType.human.jsonName, + email: 'cancel@dfx.swiss', + name: 'Cancel User', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Teststrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-05-12', +); + void main() { late String privateKeyHex; late RegistrationUserType type; @@ -85,58 +102,18 @@ void main() { ); }); - test('throws SigningCancelledException when BitBox returns empty signature', () async { - final credentials = _MockBitboxCredentials(); - when(() => credentials.address) - .thenReturn(EthereumAddress.fromHex('0x0000000000000000000000000000000000000001')); - when(() => credentials.signTypedDataV4(any(), any())).thenAnswer((_) async => ''); - - expect( - () => Eip712Signer.signRegistration( - credentials: credentials, - chainId: 1, - type: RegistrationUserType.human.jsonName, - email: 'cancel@dfx.swiss', - name: 'Cancel User', - phoneNumber: '+41790000000', - birthday: '1990-01-01', - nationality: 'CH', - addressStreet: 'Teststrasse 1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: 'CH', - swissTaxResidence: true, - registrationDate: '2026-05-12', - ), - throwsA(isA()), - ); - }); + for (final emptySignature in const ['', '0x']) { + test('throws SigningCancelledException when BitBox returns "$emptySignature"', () async { + final credentials = _MockBitboxCredentials(); + when( + () => credentials.address, + ).thenReturn(EthereumAddress.fromHex('0x0000000000000000000000000000000000000001')); + when( + () => credentials.signTypedDataV4(any(), any()), + ).thenAnswer((_) async => emptySignature); - test('throws SigningCancelledException when BitBox returns 0x signature', () async { - final credentials = _MockBitboxCredentials(); - when(() => credentials.address) - .thenReturn(EthereumAddress.fromHex('0x0000000000000000000000000000000000000001')); - when(() => credentials.signTypedDataV4(any(), any())).thenAnswer((_) async => '0x'); - - expect( - () => Eip712Signer.signRegistration( - credentials: credentials, - chainId: 1, - type: RegistrationUserType.human.jsonName, - email: 'cancel@dfx.swiss', - name: 'Cancel User', - phoneNumber: '+41790000000', - birthday: '1990-01-01', - nationality: 'CH', - addressStreet: 'Teststrasse 1', - addressPostalCode: '8000', - addressCity: 'Zurich', - addressCountry: 'CH', - swissTaxResidence: true, - registrationDate: '2026-05-12', - ), - throwsA(isA()), - ); - }); + expect(() => _signWith(credentials), throwsA(isA())); + }); + } }); }