diff --git a/cold-wallet-app/lib/components/call_detail_view.dart b/cold-wallet-app/lib/components/call_detail_view.dart index 79eb7bdab..66e231517 100644 --- a/cold-wallet-app/lib/components/call_detail_view.dart +++ b/cold-wallet-app/lib/components/call_detail_view.dart @@ -9,7 +9,7 @@ import 'package:quantus_cold_wallet/theme/app_text_styles.dart'; /// destination — an approval or a governance call can name several accounts, and /// each one needs to be verifiable by eye. final _checkphraseProvider = FutureProvider.family((ref, address) async { - return HumanReadableChecksumService().getHumanReadableName(address); + return (await HumanReadableChecksumService().getHumanReadableName(address)) ?? ''; }); /// Renders every parameter of a decoded call, recursing into nested calls. diff --git a/cold-wallet-app/lib/providers/wallet_providers.dart b/cold-wallet-app/lib/providers/wallet_providers.dart index a41d0512a..fa75c9cfd 100644 --- a/cold-wallet-app/lib/providers/wallet_providers.dart +++ b/cold-wallet-app/lib/providers/wallet_providers.dart @@ -114,5 +114,6 @@ final addressProvider = Provider((ref) => ref.watch(keypairProvider)?.s final checkphraseProvider = FutureProvider((ref) async { final address = ref.watch(addressProvider); if (address == null) return ''; - return HumanReadableChecksumService().getHumanReadableName(address); + final name = await HumanReadableChecksumService().getHumanReadableName(address); + return name ?? ''; }); diff --git a/mobile-app/lib/features/components/shared_address_action_sheet.dart b/mobile-app/lib/features/components/shared_address_action_sheet.dart index e3d8c87d4..9460bcda6 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -5,10 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/styles/app_colors_theme.dart'; import 'package:resonance_network_wallet/features/styles/app_text_theme.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/routes.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.dart'; import 'package:resonance_network_wallet/shared/extensions/media_query_data_extension.dart'; +import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; @@ -25,7 +28,7 @@ class SharedAddressActionSheet extends StatefulWidget { class _SharedAddressActionSheetState extends State { String? _checksum; - Future? _checksumFuture; + Future? _checksumFuture; List? _splittedAddress; final HumanReadableChecksumService _checksumService = HumanReadableChecksumService(); @@ -61,7 +64,14 @@ class _SharedAddressActionSheetState extends State { } void _sendToAddress() { - ProviderScope.containerOf(context).read(keystoneSignCacheProvider.notifier).startNewSendSession(); + final container = ProviderScope.containerOf(context); + // Fail closed: never pre-fill the send flow with an invalid address, + // same as address entry in the send flow itself. + if (!container.read(substrateServiceProvider).isValidSS58Address(widget.address)) { + context.showErrorToaster(message: container.read(l10nProvider).invalidAddress); + return; + } + container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); Navigator.of(context).pop(); Navigator.push( context, diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 8f22a1f7d..6a482e696 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -1290,6 +1290,11 @@ "description": "Validation when address is invalid" }, + "invalidAddress": "Invalid address", + "@invalidAddress": { + "description": "Shown when an address fails SS58 validation" + }, + "sendTitle": "Send", "@sendTitle": { "description": "Send flow app bar title" diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 82f587c57..e33f7cd4d 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -294,6 +294,8 @@ "addHardwareAccountNameRequired": "Nama wajib diisi", "addHardwareAccountInvalidAddress": "Alamat tidak valid", + "invalidAddress": "Alamat tidak valid", + "sendTitle": "Kirim", "sendPayTitle": "Bayar", "sendEnterAddress": "Masukkan Alamat", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index c7f7f674d..b4ce482f6 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -1748,6 +1748,12 @@ abstract class AppLocalizations { /// **'Invalid address'** String get addHardwareAccountInvalidAddress; + /// Shown when an address fails SS58 validation + /// + /// In en, this message translates to: + /// **'Invalid address'** + String get invalidAddress; + /// Send flow app bar title /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 299b4f85b..68cc009cc 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -931,6 +931,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addHardwareAccountInvalidAddress => 'Invalid address'; + @override + String get invalidAddress => 'Invalid address'; + @override String get sendTitle => 'Send'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index d657f2a99..300572efc 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -928,6 +928,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get addHardwareAccountInvalidAddress => 'Alamat tidak valid'; + @override + String get invalidAddress => 'Alamat tidak valid'; + @override String get sendTitle => 'Kirim'; diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index ba31c6dd8..1df44ecd7 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -46,8 +46,9 @@ final humanReadableChecksumServiceProvider = Provider((ref, address) { - return ref.watch(humanReadableChecksumServiceProvider).getHumanReadableName(address); +final checksumNameProvider = FutureProvider.family((ref, address) async { + final name = await ref.watch(humanReadableChecksumServiceProvider).getHumanReadableName(address); + return name ?? ''; }); final reversibleTransfersServiceProvider = Provider((ref) { diff --git a/mobile-app/lib/services/deep_link_service.dart b/mobile-app/lib/services/deep_link_service.dart index 718474c51..339b7dba8 100644 --- a/mobile-app/lib/services/deep_link_service.dart +++ b/mobile-app/lib/services/deep_link_service.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/providers/account_associations_providers.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; final deepLinkServiceProvider = Provider((ref) { @@ -23,18 +25,19 @@ class DeepLinkService { // Handle links when the app is already open (warm state) _linkSubscription = _appLinks.uriLinkStream.listen((uri) { quantusPrint('Received link while app is open: $uri'); - _handleLink(uri); + handleLink(uri); }); // Handle the link that opened the app (cold state) final initialUri = await _appLinks.getInitialLink(); if (initialUri != null) { quantusPrint('Received initial link: $initialUri'); - _handleLink(initialUri); + handleLink(initialUri); } } - void _handleLink(Uri uri) { + @visibleForTesting + void handleLink(Uri uri) { if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'account') { String? accountId; @@ -56,10 +59,12 @@ class DeepLinkService { if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'pay') { final payment = PaymentIntent.tryParseUrl(uri.toString()); - if (payment != null) { + // Fail closed: a /pay link with an invalid recipient must not pre-fill + // the send flow, same as address entry in the send flow itself. + if (payment != null && _ref.read(substrateServiceProvider).isValidSS58Address(payment.to)) { _ref.read(paymentIntentProvider.notifier).state = payment; } else { - quantusPrint('Missing payment parameters'); + quantusPrint('Missing payment parameters or invalid recipient address'); } } diff --git a/mobile-app/lib/services/referral_service.dart b/mobile-app/lib/services/referral_service.dart index 6ff255154..31ff207b3 100644 --- a/mobile-app/lib/services/referral_service.dart +++ b/mobile-app/lib/services/referral_service.dart @@ -144,7 +144,7 @@ class ReferralService { final account = await getMainAccount(); final referralCode = await _checksumService.getHumanReadableName(account.accountId); - return referralCode; + return referralCode ?? ''; } Future getShareLinkParameters(Rect? positionOrigin) async { diff --git a/mobile-app/lib/v2/screens/receive/receive_screen.dart b/mobile-app/lib/v2/screens/receive/receive_screen.dart index 048aa7ea1..d8acd7bed 100644 --- a/mobile-app/lib/v2/screens/receive/receive_screen.dart +++ b/mobile-app/lib/v2/screens/receive/receive_screen.dart @@ -56,7 +56,9 @@ class _ReceiveScreenState extends ConsumerState { final service = ref.read(encryptedAccountServiceProvider((base as Account).walletIndex)); accountId = (await service.receiveKeyPair()).address; } - final checksum = await checksumService.getHumanReadableName(accountId); + // Degrade to a blank checkphrase on lookup failure so the address/QR + // still renders instead of an unbounded loader. + final checksum = await checksumService.getHumanReadableName(accountId) ?? ''; if (!mounted) return; setState(() { _accountId = accountId; diff --git a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart index f96f9333c..8a54e3e77 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -80,7 +80,7 @@ class _SelectRecipientScreenState extends ConsumerState { }); for (final addr in addresses) { checksumService.getHumanReadableName(addr).then((name) { - if (mounted) setState(() => _checksums[addr] = name); + if (mounted && name != null) setState(() => _checksums[addr] = name); }); } } catch (e) { diff --git a/mobile-app/test/unit/deep_link_service_test.dart b/mobile-app/test/unit/deep_link_service_test.dart new file mode 100644 index 000000000..25a2f2826 --- /dev/null +++ b/mobile-app/test/unit/deep_link_service_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; +import 'package:resonance_network_wallet/services/deep_link_service.dart'; + +void main() { + group('DeepLinkService /pay handling', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + void handle(String url) { + container.read(deepLinkServiceProvider).handleLink(Uri.parse(url)); + } + + test('drops a /pay link with a malformed recipient', () { + handle('https://www.quantus.com/pay?to=not-a-valid-address&amount=1.5'); + + expect(container.read(paymentIntentProvider), isNull); + }); + + test('drops a /pay link with an invalid-checksum recipient', () { + // One character off from a valid address — must fail SS58 validation. + handle('https://www.quantus.com/pay?to=qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEdX&amount=1.5'); + + expect(container.read(paymentIntentProvider), isNull); + }); + + test('drops a /pay link with a missing recipient', () { + handle('https://www.quantus.com/pay?amount=1.5'); + + expect(container.read(paymentIntentProvider), isNull); + }); + + test('drops a /pay link with a missing amount', () { + handle('https://www.quantus.com/pay?to=qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda'); + + expect(container.read(paymentIntentProvider), isNull); + }); + + test('ignores unrelated links', () { + handle('https://www.quantus.com/unknown?to=whatever&amount=1'); + + expect(container.read(paymentIntentProvider), isNull); + }); + }); +} diff --git a/mobile-app/test/unit/human_readable_checksum_service_test.dart b/mobile-app/test/unit/human_readable_checksum_service_test.dart new file mode 100644 index 000000000..fed032076 --- /dev/null +++ b/mobile-app/test/unit/human_readable_checksum_service_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const validAddress = 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda'; + + group('HumanReadableChecksumService.getHumanReadableName', () { + test('returns a checkphrase for a valid address', () async { + final name = await HumanReadableChecksumService().getHumanReadableName(validAddress); + + expect(name, isNotNull); + expect(name, isNotEmpty); + expect(name, contains('-')); + }); + + test('serves repeat lookups from cache', () async { + final first = await HumanReadableChecksumService().getHumanReadableName(validAddress); + final second = await HumanReadableChecksumService().getHumanReadableName(validAddress); + + expect(second, first); + }); + + test('is deterministic across addresses', () async { + final other = await HumanReadableChecksumService().getHumanReadableName( + 'qzjij4Tiow9jtse9d7L1T3NEZuxgFW8JdUbaTLsfgubF7ZQAC', + ); + final original = await HumanReadableChecksumService().getHumanReadableName(validAddress); + + expect(other, isNotNull); + expect(other, isNot(original)); + }); + }); +} diff --git a/mobile-app/test/unit/payment_intent_test.dart b/mobile-app/test/unit/payment_intent_test.dart new file mode 100644 index 000000000..6f9417b37 --- /dev/null +++ b/mobile-app/test/unit/payment_intent_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; + +void main() { + group('PaymentIntent.tryParseUrl', () { + test('parses a valid /pay link', () { + final intent = PaymentIntent.tryParseUrl('https://www.quantus.com/pay?to=recipient&amount=1.5&ref=order-1'); + + expect(intent, isNotNull); + expect(intent!.to, 'recipient'); + expect(intent.amount, '1.5'); + expect(intent.ref, 'order-1'); + }); + + test('ref is optional', () { + final intent = PaymentIntent.tryParseUrl('https://www.quantus.com/pay?to=recipient&amount=1.5'); + + expect(intent, isNotNull); + expect(intent!.ref, isNull); + }); + + test('returns null when to is missing', () { + expect(PaymentIntent.tryParseUrl('https://www.quantus.com/pay?amount=1.5'), isNull); + }); + + test('returns null when to is empty', () { + expect(PaymentIntent.tryParseUrl('https://www.quantus.com/pay?to=&amount=1.5'), isNull); + }); + + test('returns null when amount is missing', () { + expect(PaymentIntent.tryParseUrl('https://www.quantus.com/pay?to=recipient'), isNull); + }); + + test('returns null when amount is empty', () { + expect(PaymentIntent.tryParseUrl('https://www.quantus.com/pay?to=recipient&amount='), isNull); + }); + + test('returns null for a non-pay path', () { + expect(PaymentIntent.tryParseUrl('https://www.quantus.com/account?id=abc'), isNull); + }); + + test('returns null for a malformed url', () { + expect(PaymentIntent.tryParseUrl(':::'), isNull); + }); + }); +} diff --git a/mobile-app/test/unit/shared_address_action_sheet_test.dart b/mobile-app/test/unit/shared_address_action_sheet_test.dart new file mode 100644 index 000000000..848fa42ad --- /dev/null +++ b/mobile-app/test/unit/shared_address_action_sheet_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_theme.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SettingsService().initialize(); + }); + + Future pumpSheet(WidgetTester tester, String address) async { + await tester.pumpWidget( + ProviderScope( + child: MediaQuery( + data: const MediaQueryData(size: Size(800, 600)), + child: Builder( + builder: (context) => MaterialApp( + theme: AppTheme.darkTheme(context), + home: Scaffold(body: SharedAddressActionSheet(address: address)), + ), + ), + ), + ), + ); + await tester.pump(); + } + + testWidgets('Send To This Account does not navigate for an invalid address', (tester) async { + await pumpSheet(tester, 'not-a-valid-address'); + + await tester.tap(find.text('Send To This Account')); + await tester.pump(); + + expect(find.byType(InputAmountScreen), findsNothing); + expect(find.text('Invalid address'), findsOneWidget); + + // Let the error toast (10s duration) dismiss so no ticker leaks. + await tester.pump(const Duration(seconds: 11)); + }); +} diff --git a/quantus_sdk/lib/src/services/human_readable_checksum_service.dart b/quantus_sdk/lib/src/services/human_readable_checksum_service.dart index bb2be0f58..a27ba80e8 100644 --- a/quantus_sdk/lib/src/services/human_readable_checksum_service.dart +++ b/quantus_sdk/lib/src/services/human_readable_checksum_service.dart @@ -50,6 +50,10 @@ class HumanReadableChecksumService { quantusPrint('Initialization error stack: $s'); if (!(_isolateReadyCompleter?.isCompleted ?? false)) { _isolateReadyCompleter!.completeError(e); + // The error is already logged and rethrown; keep the completer's + // future from surfacing as an unhandled async error when no + // concurrent caller is awaiting it. + _isolateReadyCompleter!.future.ignore(); } _isolate?.kill(); _isolate = null; @@ -59,9 +63,9 @@ class HumanReadableChecksumService { } } - Future getHumanReadableName(String address, {upperCase = true}) async { + Future getHumanReadableName(String address, {upperCase = true}) async { + final key = address + (upperCase ? '#U' : ''); try { - final key = address + (upperCase ? '#U' : ''); if (_checkPhraseCache.containsKey(key)) { return _checkPhraseCache[key]!; } @@ -72,7 +76,7 @@ class HumanReadableChecksumService { if (_isolateSendPort == null) { quantusPrint('Error: _isolateSendPort is null after successful initialization wait.'); - return ''; + return null; } final responsePort = ReceivePort(); @@ -80,7 +84,9 @@ class HumanReadableChecksumService { final result = await responsePort.first as String?; responsePort.close(); - var finalResult = result ?? ''; + if (result == null || result.isEmpty) return null; + + var finalResult = result; if (upperCase) { finalResult = finalResult @@ -94,8 +100,8 @@ class HumanReadableChecksumService { } catch (e, s) { quantusPrint('Error in getHumanReadableName for address $address: $e'); quantusPrint('Lookup error stack: $s'); - _checkPhraseCache.remove(address); - return ''; + _checkPhraseCache.remove(key); + return null; } } diff --git a/quantus_sdk/test/human_readable_checksum_service_test.dart b/quantus_sdk/test/human_readable_checksum_service_test.dart new file mode 100644 index 000000000..964243bef --- /dev/null +++ b/quantus_sdk/test/human_readable_checksum_service_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +// The checkphrase wordlist asset is bundled with the apps, not with +// quantus_sdk, so loading it here fails — which exercises the failure path. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('HumanReadableChecksumService.getHumanReadableName failure handling', () { + test('returns null instead of throwing or an empty string when initialization fails', () async { + final name = await HumanReadableChecksumService().getHumanReadableName( + 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEda', + ); + + expect(name, isNull); + }); + }); +}