Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cold-wallet-app/lib/components/call_detail_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>((ref, address) async {
return HumanReadableChecksumService().getHumanReadableName(address);
return (await HumanReadableChecksumService().getHumanReadableName(address)) ?? '';
});

/// Renders every parameter of a decoded call, recursing into nested calls.
Expand Down
3 changes: 2 additions & 1 deletion cold-wallet-app/lib/providers/wallet_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,5 +114,6 @@ final addressProvider = Provider<String?>((ref) => ref.watch(keypairProvider)?.s
final checkphraseProvider = FutureProvider<String>((ref) async {
final address = ref.watch(addressProvider);
if (address == null) return '';
return HumanReadableChecksumService().getHumanReadableName(address);
final name = await HumanReadableChecksumService().getHumanReadableName(address);
return name ?? '';
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,7 +28,7 @@ class SharedAddressActionSheet extends StatefulWidget {

class _SharedAddressActionSheetState extends State<SharedAddressActionSheet> {
String? _checksum;
Future<String>? _checksumFuture;
Future<String?>? _checksumFuture;
List<String>? _splittedAddress;

final HumanReadableChecksumService _checksumService = HumanReadableChecksumService();
Expand Down Expand Up @@ -61,7 +64,14 @@ class _SharedAddressActionSheetState extends State<SharedAddressActionSheet> {
}

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,
Expand Down
5 changes: 5 additions & 0 deletions mobile-app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions mobile-app/lib/l10n/app_id.arb
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"addHardwareAccountNameRequired": "Nama wajib diisi",
"addHardwareAccountInvalidAddress": "Alamat tidak valid",

"invalidAddress": "Alamat tidak valid",

"sendTitle": "Kirim",
"sendPayTitle": "Bayar",
"sendEnterAddress": "Masukkan Alamat",
Expand Down
6 changes: 6 additions & 0 deletions mobile-app/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions mobile-app/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 3 additions & 0 deletions mobile-app/lib/l10n/app_localizations_id.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
5 changes: 3 additions & 2 deletions mobile-app/lib/providers/wallet_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ final humanReadableChecksumServiceProvider = Provider<HumanReadableChecksumServi
return HumanReadableChecksumService();
});

final checksumNameProvider = FutureProvider.family<String, String>((ref, address) {
return ref.watch(humanReadableChecksumServiceProvider).getHumanReadableName(address);
final checksumNameProvider = FutureProvider.family<String, String>((ref, address) async {
final name = await ref.watch(humanReadableChecksumServiceProvider).getHumanReadableName(address);
return name ?? '';
});

final reversibleTransfersServiceProvider = Provider<ReversibleTransfersService>((ref) {
Expand Down
15 changes: 10 additions & 5 deletions mobile-app/lib/services/deep_link_service.dart
Original file line number Diff line number Diff line change
@@ -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<DeepLinkService>((ref) {
Expand All @@ -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;

Expand All @@ -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');
}
}

Expand Down
2 changes: 1 addition & 1 deletion mobile-app/lib/services/referral_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class ReferralService {
final account = await getMainAccount();
final referralCode = await _checksumService.getHumanReadableName(account.accountId);

return referralCode;
return referralCode ?? '';
}

Future<ShareParams> getShareLinkParameters(Rect? positionOrigin) async {
Expand Down
4 changes: 3 additions & 1 deletion mobile-app/lib/v2/screens/receive/receive_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ class _ReceiveScreenState extends ConsumerState<ReceiveScreen> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class _SelectRecipientScreenState extends ConsumerState<SelectRecipientScreen> {
});
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) {
Expand Down
53 changes: 53 additions & 0 deletions mobile-app/test/unit/deep_link_service_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
35 changes: 35 additions & 0 deletions mobile-app/test/unit/human_readable_checksum_service_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
});
}
46 changes: 46 additions & 0 deletions mobile-app/test/unit/payment_intent_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
47 changes: 47 additions & 0 deletions mobile-app/test/unit/shared_address_action_sheet_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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));
});
}
Loading
Loading