From c1a19b8ecb16f79ecc965425a6952a5b31bd2b9b Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 23 Jul 2026 17:31:25 +0700 Subject: [PATCH 1/5] fix: queue intents until unlocked and bind send to source account (M10, L10) M10: deep-link and notification intents were consumed by HomeScreen's listeners even while the lock overlay was up, pushing attacker-crafted screens underneath it. Intent handlers now require localAuthProvider.isAuthenticated; intents arriving while locked stay queued in their providers and are drained on unlock. L10: RegularSendStrategy re-resolved the source account at submit time, so an account switch between review and confirm signed from the new account. The strategy now captures the source account at flow start and uses it for fee estimation, balance validation and submission. Intents that navigate or switch the active account are also deferred while a send flow is on the stack, so a notification tap can't hijack an in-flight send. --- .../shared_address_action_sheet.dart | 12 +++++- .../lib/v2/screens/home/home_screen.dart | 43 ++++++++++++++++--- .../v2/screens/send/input_amount_screen.dart | 8 ++++ .../screens/send/regular_send_strategy.dart | 23 +++++----- .../screens/send/select_recipient_screen.dart | 8 ++++ .../lib/v2/screens/send/send_providers.dart | 15 +++++-- 6 files changed, 87 insertions(+), 22 deletions(-) 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 c9d583af7..66c6b44a9 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -5,6 +5,7 @@ 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/account_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'; @@ -60,12 +61,19 @@ class _SharedAddressActionSheetState extends State { } void _sendToAddress() { - ProviderScope.containerOf(context).read(keystoneSignCacheProvider.notifier).startNewSendSession(); + final container = ProviderScope.containerOf(context); + final active = container.read(activeAccountProvider).value; + if (active is! RegularAccount) { + debugPrint('shared address send: active account cannot send regular transfers'); + return; + } + container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); Navigator.of(context).pop(); Navigator.push( context, MaterialPageRoute( - builder: (_) => InputAmountScreen(strategy: const RegularSendStrategy(), recipientAddress: widget.address), + builder: (_) => + InputAmountScreen(strategy: RegularSendStrategy(account: active.account), recipientAddress: widget.address), ), ); } diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 970e86f12..cb4b52d85 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -38,11 +38,13 @@ import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/local_auth_provider.dart'; import 'package:resonance_network_wallet/providers/multisig_providers.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; import 'package:resonance_network_wallet/v2/components/global_toast_listener.dart'; @@ -67,7 +69,9 @@ class _HomeScreenState extends ConsumerState { ref.listenManual(proposalIntentProvider, _onProposalIntent); ref.listenManual>(activeAccountProvider, (_, async) { if (async.value == null) return; - _onTransactionIntent(null, ref.read(transactionIntentProvider)); + // Accounts may still be loading when an intent arrives on a cold start; + // retry once they are available. + _drainPendingIntents(); }); // Multisig accounts may still be loading when a proposal intent arrives on a // cold start; retry once they are available. @@ -75,10 +79,28 @@ class _HomeScreenState extends ConsumerState { if (async.value == null) return; _onProposalIntent(null, ref.read(proposalIntentProvider)); }); + // Intents are only consumed while unlocked: anything arriving beneath the + // lock overlay stays queued in its provider and is drained after unlock. + ref.listenManual(localAuthProvider, (prev, next) { + if (next.isAuthenticated && !(prev?.isAuthenticated ?? false)) _drainPendingIntents(); + }); + // Intents deferred while a send flow was in flight become consumable again + // once the last send screen leaves the navigation stack. + ref.listenManual(sendFlowActiveProvider, (prev, next) { + if ((prev ?? 0) > 0 && next == 0) _drainPendingIntents(); + }); Future.microtask(_drainPendingIntents); } + /// Deep-link and notification intents must never be acted on while the lock + /// overlay is up; the Navigator underneath it is still alive. + bool get _isUnlocked => ref.read(localAuthProvider).isAuthenticated; + + /// While a send flow is on the stack, intents that navigate or switch the + /// active account are deferred so they can't hijack the in-flight send. + bool get _sendInFlight => ref.read(sendFlowActiveProvider) > 0; + void _drainPendingIntents() { if (!mounted) return; _onTransactionIntent(null, ref.read(transactionIntentProvider)); @@ -88,7 +110,7 @@ class _HomeScreenState extends ConsumerState { } void _onTransactionIntent(TransactionEvent? _, TransactionEvent? transaction) { - if (transaction == null || !mounted) return; + if (transaction == null || !mounted || !_isUnlocked) return; final active = ref.read(activeAccountProvider).value; if (active == null) return; ref.read(transactionIntentProvider.notifier).state = null; @@ -97,13 +119,20 @@ class _HomeScreenState extends ConsumerState { } void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) { - if (payment == null || !mounted) return; + if (payment == null || !mounted || !_isUnlocked || _sendInFlight) return; + final active = ref.read(activeAccountProvider).value; + // Still loading — the activeAccountProvider listener will retry. + if (active == null) return; ref.read(paymentIntentProvider.notifier).state = null; + if (active is! RegularAccount) { + quantusDebugPrint('payment intent: active account cannot send regular transfers'); + return; + } ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); final pageRoute = MaterialPageRoute( builder: (_) => InputAmountScreen( - strategy: const RegularSendStrategy(), + strategy: RegularSendStrategy(account: active.account), recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true, @@ -119,7 +148,7 @@ class _HomeScreenState extends ConsumerState { } void _onSharedIntent(String? _, String? shared) { - if (shared == null || !mounted) return; + if (shared == null || !mounted || !_isUnlocked || _sendInFlight) return; ref.read(sharedAccountIntentProvider.notifier).state = null; showSharedAddressActionSheet(context, shared); @@ -129,7 +158,7 @@ class _HomeScreenState extends ConsumerState { /// the active account, then opens the detail sheet immediately. The sheet /// shows a loader while it resolves the proposal by id. Future _onProposalIntent(ProposalIntent? _, ProposalIntent? intent) async { - if (intent == null || !mounted) return; + if (intent == null || !mounted || !_isUnlocked || _sendInFlight) return; final multisigAccounts = ref.read(multisigAccountsProvider).value; // Still loading — the multisigAccountsProvider listener will retry. @@ -341,7 +370,7 @@ class _HomeScreenState extends ConsumerState { const enableSwap = true; // ref.watch(remoteConfigProvider).enableSwap; Override enable swap config for now final SendStrategy sendStrategy = isEncrypted ? EncryptedSendStrategy(account: account) - : const RegularSendStrategy(); + : RegularSendStrategy(account: account); final receiveCard = _actionCard( iconAsset: 'assets/v2/action_receive.svg', diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index a2f42a3ed..c4acf5a52 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -13,6 +13,7 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/review_send_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_screen_logic.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; @@ -62,6 +63,9 @@ class _InputAmountScreenState extends ConsumerState { // Each request has a counter value, so old responses can be ignored int _fetchFeeCounter = 0; + // Cached in initState so dispose can decrement without touching ref. + late final _sendFlowActive = ref.read(sendFlowActiveProvider.notifier); + AmountInputLogic get _amountInputLogic => AmountInputLogic( exchangeRateService: ref.read(exchangeRateServiceProvider), selectedFiat: ref.read(selectedFiatCurrencyProvider), @@ -73,6 +77,9 @@ class _InputAmountScreenState extends ConsumerState { void initState() { super.initState(); assert(widget.recipientAddress.trim().isNotEmpty, 'InputAmountScreen requires a recipient'); + // Mark a send flow as in flight so incoming intents can't interrupt it; + // decremented symmetrically in dispose. + _sendFlowActive.state++; _amountFocus.addListener(_onAmountFocusChanged); if (widget.initialAmount != null && widget.initialAmount!.isNotEmpty) { final formattingService = ref.read(numberFormattingServiceProvider); @@ -97,6 +104,7 @@ class _InputAmountScreenState extends ConsumerState { @override void dispose() { + _sendFlowActive.state--; _feeDebouncer.cancel(); _amountController.dispose(); _amountFocus.removeListener(_onAmountFocusChanged); diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 1b0613b03..dfd8dae79 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/local_auth_service.dart'; @@ -20,13 +19,19 @@ import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; /// Standard single-signer transfer from the active account. Signs locally, or /// hands off to the Keystone QR flow for hardware accounts. +/// +/// The source [account] is captured when the flow starts and used for the whole +/// flow (fee estimation, balance validation, submission), so a mid-flow account +/// switch can never change the account being signed from. class RegularSendStrategy extends SendStrategy { - const RegularSendStrategy(); + final Account account; + + const RegularSendStrategy({required this.account}); static final BigInt _estimateFeeAmount = BigInt.from(1000) * NumberFormattingService.scaleFactorBigInt; @override - String? sourceAccountId(WidgetRef ref) => ref.read(activeAccountProvider).value?.account.accountId; + String? sourceAccountId(WidgetRef ref) => account.accountId; @override SendStrings strings(AppLocalizations l10n) => SendStrings( @@ -41,7 +46,8 @@ class RegularSendStrategy extends SendStrategy { ); @override - ProviderListenable> get spendableBalanceProvider => effectiveMaxBalanceProvider; + ProviderListenable> get spendableBalanceProvider => + effectiveMaxBalanceProviderFamily(account.accountId); @override bool extraBalancesLoading(WidgetRef ref) => false; @@ -51,11 +57,6 @@ class RegularSendStrategy extends SendStrategy { @override Future estimateFee(WidgetRef ref, {required String recipient, required BigInt amount}) async { - final displayAccount = ref.read(activeAccountProvider).value; - if (displayAccount is! RegularAccount) { - throw StateError('Regular send requires an active regular account'); - } - final account = displayAccount.account; final useReal = amount > BigInt.zero && ref.read(substrateServiceProvider).isValidSS58Address(recipient); final feeAmount = useReal ? amount : _estimateFeeAmount; final toAddress = useReal ? recipient : account.accountId; @@ -109,7 +110,9 @@ class RegularSendStrategy extends SendStrategy { final fmt = ref.read(numberFormattingServiceProvider); final regularFee = fee as RegularFee; final recipient = recipientAddress.trim(); - final account = (await SettingsService().getActiveRegularAccount())!; + // Sign from the account captured when the flow started, not whichever + // account happens to be active at submit time. + final account = this.account; final terminal = buildSentTerminalContent( l10n, fmt, 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 98bff444d..613c5e358 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -21,6 +21,7 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -48,15 +49,22 @@ class _SelectRecipientScreenState extends ConsumerState { bool _isSelfSend = false; String? _recipientChecksum; + // Cached in initState so dispose can decrement without touching ref. + late final _sendFlowActive = ref.read(sendFlowActiveProvider.notifier); + @override void initState() { super.initState(); + // Mark a send flow as in flight so incoming intents can't interrupt it; + // decremented symmetrically in dispose. + _sendFlowActive.state++; _recipientController.addListener(_onRecipientChanged); _loadRecents(); } @override void dispose() { + _sendFlowActive.state--; _recipientController.removeListener(_onRecipientChanged); _recipientController.dispose(); _amountController.dispose(); diff --git a/mobile-app/lib/v2/screens/send/send_providers.dart b/mobile-app/lib/v2/screens/send/send_providers.dart index 1b28e0da7..40de866f7 100644 --- a/mobile-app/lib/v2/screens/send/send_providers.dart +++ b/mobile-app/lib/v2/screens/send/send_providers.dart @@ -6,10 +6,19 @@ import 'package:resonance_network_wallet/providers/wallet_providers.dart'; // Local provider for existential deposit toggle in send screen final existentialDepositToggleProvider = StateProvider((ref) => true); -// Provider that combines balance with existential deposit toggle -final effectiveMaxBalanceProvider = Provider>((ref) { +/// Number of send-flow root screens (select recipient / input amount) currently +/// on the navigation stack. While this is positive a send is in flight and +/// incoming intents must not interrupt it (e.g. a proposal-notification tap +/// switching the active account mid-send). +final sendFlowActiveProvider = StateProvider((_) => 0); + +/// Max sendable balance for [accountId]: the effective balance (chain balance +/// minus pending outgoing) with the existential deposit toggle applied. Send +/// strategies bind to the source account captured at flow start so a mid-flow +/// account switch cannot change the balance being validated against. +final effectiveMaxBalanceProviderFamily = Provider.family, String>((ref, accountId) { final existentialDeposit = balances.Constants().existentialDeposit; - final balanceAsyncValue = ref.watch(balanceProvider); + final balanceAsyncValue = ref.watch(effectiveBalanceProviderFamily(accountId)); final includeExistentialDeposit = ref.watch(existentialDepositToggleProvider); return balanceAsyncValue.when( From c26906ba63bbdfa8d23d184998e7b5e15aceafa4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 23 Jul 2026 17:59:48 +0700 Subject: [PATCH 2/5] style: apply dart format --- .../features/components/shared_address_action_sheet.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 66c6b44a9..3656a6ef9 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -72,8 +72,10 @@ class _SharedAddressActionSheetState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => - InputAmountScreen(strategy: RegularSendStrategy(account: active.account), recipientAddress: widget.address), + builder: (_) => InputAmountScreen( + strategy: RegularSendStrategy(account: active.account), + recipientAddress: widget.address, + ), ), ); } From b4964a1fee7d142d2c841b232f270d7867049928 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 29 Jul 2026 13:33:22 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20send=20flow=20state=20via=20startSendFlow,=20balanc?= =?UTF-8?q?e=20fallback,=20send=20rejection=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single startSendFlow entry point owns sendFlowActiveProvider (now a bool) and the Keystone signing session; screens no longer mutate providers in initState/dispose. Only one send flow can be active; intents arriving mid-send are dropped, not queued. - effectiveBalanceProviderFamily keeps the last fetched balance on refresh errors instead of zeroing out spendable checks (finding 1). - Non-regular active account send attempts now surface a warning toast (finding 2) and log via quantusDebugPrint (finding 5). - Document the deliberate isAuthenticated-only lock gate (finding 3). - Add widget/unit tests for intent gating, send rejection, balance fallback (finding 4). --- .../shared_address_action_sheet.dart | 18 ++- mobile-app/lib/l10n/app_en.arb | 4 + mobile-app/lib/l10n/app_id.arb | 1 + mobile-app/lib/l10n/app_localizations.dart | 6 + mobile-app/lib/l10n/app_localizations_en.dart | 3 + mobile-app/lib/l10n/app_localizations_id.dart | 3 + .../lib/providers/wallet_providers.dart | 32 ++--- .../lib/v2/screens/home/home_screen.dart | 62 ++++---- .../v2/screens/send/input_amount_screen.dart | 7 - .../screens/send/select_recipient_screen.dart | 7 - .../lib/v2/screens/send/send_providers.dart | 32 ++++- mobile-app/test/extensions.dart | 4 +- mobile-app/test/fakes.dart | 93 ++++++++++++ .../test/screens/home_intent_gating_test.dart | 135 ++++++++++++++++++ .../shared_address_action_sheet_test.dart | 34 +++++ .../unit/effective_balance_provider_test.dart | 46 ++++++ .../test/unit/keystone_sign_cache_test.dart | 36 ++--- .../test/unit/regular_send_strategy_test.dart | 104 ++++++++++++++ 18 files changed, 517 insertions(+), 110 deletions(-) create mode 100644 mobile-app/test/fakes.dart create mode 100644 mobile-app/test/screens/home_intent_gating_test.dart create mode 100644 mobile-app/test/screens/shared_address_action_sheet_test.dart create mode 100644 mobile-app/test/unit/effective_balance_provider_test.dart create mode 100644 mobile-app/test/unit/regular_send_strategy_test.dart 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 3656a6ef9..786647b10 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -6,14 +6,17 @@ 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/account_providers.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.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'; -import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; class SharedAddressActionSheet extends StatefulWidget { final String address; @@ -64,19 +67,14 @@ class _SharedAddressActionSheetState extends State { final container = ProviderScope.containerOf(context); final active = container.read(activeAccountProvider).value; if (active is! RegularAccount) { - debugPrint('shared address send: active account cannot send regular transfers'); + quantusDebugPrint('shared address send: active account cannot send regular transfers'); + context.showWarningToaster(message: container.read(l10nProvider).sendRegularAccountRequired); return; } - container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); Navigator.of(context).pop(); - Navigator.push( + startSendFlow( context, - MaterialPageRoute( - builder: (_) => InputAmountScreen( - strategy: RegularSendStrategy(account: active.account), - recipientAddress: widget.address, - ), - ), + screen: InputAmountScreen(strategy: RegularSendStrategy(account: active.account), recipientAddress: widget.address), ); } diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 619d0e0aa..822d0c1e0 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -1391,6 +1391,10 @@ "@sendReviewSubmitFailed": { "description": "Error when transaction submission fails" }, + "sendRegularAccountRequired": "Switch to a regular account to send", + "@sendRegularAccountRequired": { + "description": "Toast when a send is requested while the active account cannot make regular transfers" + }, "sendTxSubmittedHeadlinePaid": "{amount} {symbol} paid", "@sendTxSubmittedHeadlinePaid": { diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index b7f26eabe..d7cfa8c95 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -319,6 +319,7 @@ "sendReviewAuthReason": "Autentikasi untuk mengonfirmasi transaksi", "sendReviewAuthRequired": "Autentikasi diperlukan untuk mengirim", "sendReviewSubmitFailed": "Gagal mengirim transaksi", + "sendRegularAccountRequired": "Beralih ke akun reguler untuk mengirim", "sendTxSubmittedHeadlinePaid": "{amount} {symbol} dibayar", "sendTxSubmittedHeadlineSent": "{amount} {symbol} terkirim", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 9ad32e748..54677b52b 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -1880,6 +1880,12 @@ abstract class AppLocalizations { /// **'Failed submitting transaction'** String get sendReviewSubmitFailed; + /// Toast when a send is requested while the active account cannot make regular transfers + /// + /// In en, this message translates to: + /// **'Switch to a regular account to send'** + String get sendRegularAccountRequired; + /// Success headline when payment completed /// /// 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 a76e12d00..8e2ab3a7d 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1000,6 +1000,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sendReviewSubmitFailed => 'Failed submitting transaction'; + @override + String get sendRegularAccountRequired => 'Switch to a regular account to send'; + @override String sendTxSubmittedHeadlinePaid(String amount, String symbol) { return '$amount $symbol paid'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 72cb6060f..e35374e61 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -997,6 +997,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get sendReviewSubmitFailed => 'Gagal mengirim transaksi'; + @override + String get sendRegularAccountRequired => 'Beralih ke akun reguler untuk mengirim'; + @override String sendTxSubmittedHeadlinePaid(String amount, String symbol) { return '$amount $symbol dibayar'; diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index c2ebab44b..90a6fea86 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -136,23 +136,23 @@ final effectiveBalanceProviderFamily = Provider.family, Strin final pendingMultisigCancellations = ref.watch(pendingMultisigCancellationsProvider); final pendingMultisigCreations = ref.watch(pendingMultisigCreationsProvider); - return balanceAsync.when( - data: (blockchainBalance) { - final pendingOutgoing = _calculatePendingOutgoing( - pendingTransactions, - pendingMultisigProposals, - pendingMultisigExecutions, - pendingMultisigCancellations, - pendingMultisigCreations, - accountId, - ); - final effectiveBalance = blockchainBalance - pendingOutgoing; - final result = effectiveBalance >= BigInt.zero ? effectiveBalance : BigInt.zero; - return AsyncValue.data(result); - }, - loading: () => const AsyncValue.loading(), - error: (err, stack) => AsyncValue.error(err, stack), + // AsyncError/AsyncLoading preserve the previously fetched balance; keep + // using it so a transient refresh error can't zero out spendable-balance + // checks mid-send. Errors before any successful fetch still propagate. + final blockchainBalance = balanceAsync.value; + if (blockchainBalance == null) { + return balanceAsync.isLoading ? const AsyncValue.loading() : AsyncValue.error(balanceAsync.error!, balanceAsync.stackTrace!); + } + final pendingOutgoing = _calculatePendingOutgoing( + pendingTransactions, + pendingMultisigProposals, + pendingMultisigExecutions, + pendingMultisigCancellations, + pendingMultisigCreations, + accountId, ); + final effectiveBalance = blockchainBalance - pendingOutgoing; + return AsyncValue.data(effectiveBalance >= BigInt.zero ? effectiveBalance : BigInt.zero); }); // Raw blockchain balance (without pending transaction adjustments) diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index cb4b52d85..5b0d086c7 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/dotted_border.dart'; @@ -11,6 +12,7 @@ import 'package:resonance_network_wallet/services/global_history_polling_service import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.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/shared/utils/url_utils.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; @@ -26,7 +28,6 @@ import 'package:resonance_network_wallet/v2/screens/multisig/multisig_activity_s import 'package:resonance_network_wallet/v2/screens/multisig/multisig_proposal_detail_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/send/encrypted_send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; import 'package:resonance_network_wallet/v2/screens/send/multisig_propose_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; import 'package:resonance_network_wallet/v2/screens/send/select_recipient_screen.dart'; @@ -84,22 +85,24 @@ class _HomeScreenState extends ConsumerState { ref.listenManual(localAuthProvider, (prev, next) { if (next.isAuthenticated && !(prev?.isAuthenticated ?? false)) _drainPendingIntents(); }); - // Intents deferred while a send flow was in flight become consumable again - // once the last send screen leaves the navigation stack. - ref.listenManual(sendFlowActiveProvider, (prev, next) { - if ((prev ?? 0) > 0 && next == 0) _drainPendingIntents(); - }); Future.microtask(_drainPendingIntents); } /// Deep-link and notification intents must never be acted on while the lock - /// overlay is up; the Navigator underneath it is still alive. + /// overlay is up; the Navigator underneath it is still alive. Deliberately + /// gates on authentication only, not the visual privacy overlay: resume + /// either clears that overlay or forces re-auth before anything is tappable. bool get _isUnlocked => ref.read(localAuthProvider).isAuthenticated; - /// While a send flow is on the stack, intents that navigate or switch the - /// active account are deferred so they can't hijack the in-flight send. - bool get _sendInFlight => ref.read(sendFlowActiveProvider) > 0; + /// A send in flight must never be interrupted: intents that would start + /// another flow or switch the active account are dropped, not queued. + bool _consumeIfSendInFlight(StateProvider provider, String label) { + if (!ref.read(sendFlowActiveProvider)) return false; + ref.read(provider.notifier).state = null; + quantusDebugPrint('$label intent ignored: send flow active'); + return true; + } void _drainPendingIntents() { if (!mounted) return; @@ -119,36 +122,32 @@ class _HomeScreenState extends ConsumerState { } void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) { - if (payment == null || !mounted || !_isUnlocked || _sendInFlight) return; + if (payment == null || !mounted || !_isUnlocked) return; + if (_consumeIfSendInFlight(paymentIntentProvider, 'payment')) return; final active = ref.read(activeAccountProvider).value; // Still loading — the activeAccountProvider listener will retry. if (active == null) return; ref.read(paymentIntentProvider.notifier).state = null; if (active is! RegularAccount) { quantusDebugPrint('payment intent: active account cannot send regular transfers'); + context.showWarningToaster(message: ref.read(l10nProvider).sendRegularAccountRequired); return; } - ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); - final pageRoute = MaterialPageRoute( - builder: (_) => InputAmountScreen( + startSendFlow( + context, + screen: InputAmountScreen( strategy: RegularSendStrategy(account: active.account), recipientAddress: payment.to, initialAmount: payment.amount, isPayMode: true, ), - settings: inputAmountScreenRouteSettings, ); - - if (context.peekTopRouteName == inputAmountScreenRouteSettings.name) { - Navigator.pushReplacement(context, pageRoute); - } else { - Navigator.push(context, pageRoute); - } } void _onSharedIntent(String? _, String? shared) { - if (shared == null || !mounted || !_isUnlocked || _sendInFlight) return; + if (shared == null || !mounted || !_isUnlocked) return; + if (_consumeIfSendInFlight(sharedAccountIntentProvider, 'shared account')) return; ref.read(sharedAccountIntentProvider.notifier).state = null; showSharedAddressActionSheet(context, shared); @@ -158,7 +157,8 @@ class _HomeScreenState extends ConsumerState { /// the active account, then opens the detail sheet immediately. The sheet /// shows a loader while it resolves the proposal by id. Future _onProposalIntent(ProposalIntent? _, ProposalIntent? intent) async { - if (intent == null || !mounted || !_isUnlocked || _sendInFlight) return; + if (intent == null || !mounted || !_isUnlocked) return; + if (_consumeIfSendInFlight(proposalIntentProvider, 'proposal')) return; final multisigAccounts = ref.read(multisigAccountsProvider).value; // Still loading — the multisigAccountsProvider listener will retry. @@ -382,10 +382,7 @@ class _HomeScreenState extends ConsumerState { key: const Key(E2EKeys.homeSendButton), iconAsset: 'assets/v2/action_send.svg', label: l10n.homeSend, - onTap: () { - ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); - Navigator.push(context, MaterialPageRoute(builder: (_) => SelectRecipientScreen(strategy: sendStrategy))); - }, + onTap: () => startSendFlow(context, screen: SelectRecipientScreen(strategy: sendStrategy)), ); final swapCard = _actionCard( @@ -423,15 +420,8 @@ class _HomeScreenState extends ConsumerState { _actionCard( iconAsset: 'assets/v2/action_send.svg', label: l10n.multisigProposeTitle, - onTap: () { - ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig)), - ), - ); - }, + onTap: () => + startSendFlow(context, screen: SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig))), ), ], ); diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index c4acf5a52..bbb3dddee 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -63,9 +63,6 @@ class _InputAmountScreenState extends ConsumerState { // Each request has a counter value, so old responses can be ignored int _fetchFeeCounter = 0; - // Cached in initState so dispose can decrement without touching ref. - late final _sendFlowActive = ref.read(sendFlowActiveProvider.notifier); - AmountInputLogic get _amountInputLogic => AmountInputLogic( exchangeRateService: ref.read(exchangeRateServiceProvider), selectedFiat: ref.read(selectedFiatCurrencyProvider), @@ -77,9 +74,6 @@ class _InputAmountScreenState extends ConsumerState { void initState() { super.initState(); assert(widget.recipientAddress.trim().isNotEmpty, 'InputAmountScreen requires a recipient'); - // Mark a send flow as in flight so incoming intents can't interrupt it; - // decremented symmetrically in dispose. - _sendFlowActive.state++; _amountFocus.addListener(_onAmountFocusChanged); if (widget.initialAmount != null && widget.initialAmount!.isNotEmpty) { final formattingService = ref.read(numberFormattingServiceProvider); @@ -104,7 +98,6 @@ class _InputAmountScreenState extends ConsumerState { @override void dispose() { - _sendFlowActive.state--; _feeDebouncer.cancel(); _amountController.dispose(); _amountFocus.removeListener(_onAmountFocusChanged); 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 613c5e358..9bc76708d 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -49,22 +49,15 @@ class _SelectRecipientScreenState extends ConsumerState { bool _isSelfSend = false; String? _recipientChecksum; - // Cached in initState so dispose can decrement without touching ref. - late final _sendFlowActive = ref.read(sendFlowActiveProvider.notifier); - @override void initState() { super.initState(); - // Mark a send flow as in flight so incoming intents can't interrupt it; - // decremented symmetrically in dispose. - _sendFlowActive.state++; _recipientController.addListener(_onRecipientChanged); _loadRecents(); } @override void dispose() { - _sendFlowActive.state--; _recipientController.removeListener(_onRecipientChanged); _recipientController.dispose(); _amountController.dispose(); diff --git a/mobile-app/lib/v2/screens/send/send_providers.dart b/mobile-app/lib/v2/screens/send/send_providers.dart index 40de866f7..19d4abf25 100644 --- a/mobile-app/lib/v2/screens/send/send_providers.dart +++ b/mobile-app/lib/v2/screens/send/send_providers.dart @@ -1,16 +1,38 @@ +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; // Local provider for existential deposit toggle in send screen final existentialDepositToggleProvider = StateProvider((ref) => true); -/// Number of send-flow root screens (select recipient / input amount) currently -/// on the navigation stack. While this is positive a send is in flight and -/// incoming intents must not interrupt it (e.g. a proposal-notification tap -/// switching the active account mid-send). -final sendFlowActiveProvider = StateProvider((_) => 0); +/// True while a send flow is on the navigation stack. Only one send flow can +/// be active at a time; incoming intents that would start or interrupt a send +/// are ignored while this is set. Only mutated by [startSendFlow]. +final sendFlowActiveProvider = StateProvider((_) => false); + +/// Single entry point for send flows: refuses to start a second flow, starts a +/// fresh Keystone signing session (a QR cached by an earlier flow may carry a +/// stale nonce), and clears the in-flight flag when [screen]'s route leaves +/// the stack — by pop, replacement, or removal. +Future startSendFlow(BuildContext context, {required Widget screen}) async { + final container = ProviderScope.containerOf(context); + final sendFlow = container.read(sendFlowActiveProvider.notifier); + if (sendFlow.state) { + quantusDebugPrint('startSendFlow ignored: a send flow is already active'); + return; + } + container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); + sendFlow.state = true; + try { + await Navigator.push(context, MaterialPageRoute(builder: (_) => screen)); + } finally { + sendFlow.state = false; + } +} /// Max sendable balance for [accountId]: the effective balance (chain balance /// minus pending outgoing) with the existential deposit toggle applied. Send diff --git a/mobile-app/test/extensions.dart b/mobile-app/test/extensions.dart index fe33c7265..d1a7b9608 100644 --- a/mobile-app/test/extensions.dart +++ b/mobile-app/test/extensions.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:resonance_network_wallet/features/styles/app_theme.dart'; +import 'package:resonance_network_wallet/v2/theme/app_theme.dart'; extension Device on WidgetTester { Size get devicePixel { @@ -21,7 +21,7 @@ extension Device on WidgetTester { child: Builder( builder: (context) { return MaterialApp( - theme: AppTheme.lightTheme(context), + theme: AppTheme.darkTheme(context), darkTheme: AppTheme.darkTheme(context), themeMode: ThemeMode.dark, navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart new file mode 100644 index 000000000..4c40c122d --- /dev/null +++ b/mobile-app/test/fakes.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/local_auth_provider.dart'; +import 'package:resonance_network_wallet/services/local_auth_service.dart'; + +class FakeSettingsService extends Fake implements SettingsService { + DisplayAccount? activeAccount; + + FakeSettingsService({this.activeAccount}); + + @override + Future getActiveAccount() async => activeAccount; + + @override + Future setActiveAccount(DisplayAccount account) async => activeAccount = account; + + @override + Future> getMultisigAccounts() async => []; + + @override + String? getSelectedAppLocale() => 'en'; + + @override + String? getSelectedFiatCurrency() => null; + + @override + bool isBalanceHidden() => false; + + @override + bool isCurrencyFlipped() => false; + + @override + bool isPosModeEnabled() => false; +} + +/// Drives [LocalAuthState] directly so tests can lock/unlock without the +/// platform auth dialog. +class TestLocalAuthController extends LocalAuthController { + TestLocalAuthController({required bool authenticated}) : super(LocalAuthService()) { + setAuthenticated(authenticated); + } + + void setAuthenticated(bool value) { + state = state.copyWith(isAuthenticated: value); + } +} + +class FakeBalancesService extends Fake implements BalancesService { + Account? lastFeeAccount; + + @override + Future getBalanceTransferFee(Account account, String targetAddress, BigInt amount) async { + lastFeeAccount = account; + return ExtrinsicFeeData(fee: BigInt.from(1000000000), blockHash: '0x00', blockNumber: 1); + } +} + +Account makeAccount(int index, {AccountType accountType = AccountType.local}) => Account( + walletIndex: 0, + index: index, + name: 'Account $index', + accountId: 'qzaccount$index${'x' * 40}', + accountType: accountType, +); + +MultisigAccount makeMultisigAccount() => MultisigAccount( + name: 'Msig', + accountId: 'qzmsig${'x' * 40}', + signers: [makeAccount(1).accountId], + threshold: 1, + nonce: BigInt.zero, + myMemberAccountId: makeAccount(1).accountId, +); + +UnsignedTransactionData makeUnsignedTransactionData() { + return UnsignedTransactionData( + payloadToSign: QuantusSigningPayload( + method: Uint8List(0), + specVersion: 1, + transactionVersion: 1, + genesisHash: '0x00', + blockHash: '0x00', + blockNumber: 42, + eraPeriod: 64, + nonce: 0, + tip: 0, + ), + signer: Uint8List(32), + registry: Object(), + ); +} diff --git a/mobile-app/test/screens/home_intent_gating_test.dart b/mobile-app/test/screens/home_intent_gating_test.dart new file mode 100644 index 000000000..de0808ee9 --- /dev/null +++ b/mobile-app/test/screens/home_intent_gating_test.dart @@ -0,0 +1,135 @@ +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/models/combined_transactions_list.dart'; +import 'package:resonance_network_wallet/models/fiat_currency.dart'; +import 'package:resonance_network_wallet/providers/active_account_transactions_provider.dart'; +import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; +import 'package:resonance_network_wallet/providers/local_auth_provider.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/services/exchange_rate_service.dart'; +import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; + +import '../extensions.dart'; +import '../fakes.dart'; + +void main() { + Future pumpHome( + WidgetTester tester, { + required FakeSettingsService settings, + required TestLocalAuthController auth, + }) async { + await tester.pumpApp( + const HomeScreen(), + overrides: [ + settingsServiceProvider.overrideWithValue(settings), + localAuthProvider.overrideWith((ref) => auth), + activeAccountTransactionsProvider.overrideWith((ref, filter) => AsyncValue.data(CombinedTransactionsList.empty)), + balanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(10).pow(15))), + balanceProviderFamily.overrideWith((ref, accountId) async => BigInt.from(10).pow(15)), + balanceDisplayProvider.overrideWithValue( + const AsyncValue.data( + CurrencyDisplayState(primaryAmount: '10', secondaryAmount: '10', isFlipped: false, selectedFiat: FiatCurrency.usd), + ), + ), + backupReminderWalletIndexProvider.overrideWithValue(null), + exchangeRateServiceProvider.overrideWithValue(ExchangeRateService(rates: {})), + balancesServiceProvider.overrideWithValue(FakeBalancesService()), + ], + ); + // Let the active account and multisig list finish their async load. + await tester.pump(); + return ProviderScope.containerOf(tester.element(find.byType(HomeScreen))); + } + + testWidgets('intent arriving while locked stays queued and drains on unlock', (tester) async { + final settings = FakeSettingsService(activeAccount: RegularAccount(makeAccount(1))); + final auth = TestLocalAuthController(authenticated: false); + final container = await pumpHome(tester, settings: settings, auth: auth); + + container.read(proposalIntentProvider.notifier).state = const ProposalIntent( + multisigAddress: 'unknown-msig', + proposalId: 1, + ); + await tester.pump(); + expect(container.read(proposalIntentProvider), isNotNull); + + auth.setAuthenticated(true); + await tester.pump(); + expect(container.read(proposalIntentProvider), isNull); + }); + + testWidgets('payment intent queued while locked opens the send flow bound to the active account on unlock', ( + tester, + ) async { + final active = makeAccount(1); + final settings = FakeSettingsService(activeAccount: RegularAccount(active)); + final auth = TestLocalAuthController(authenticated: false); + final container = await pumpHome(tester, settings: settings, auth: auth); + + // A cached QR from an earlier flow must not survive into the new session. + container.read(keystoneSignCacheProvider.notifier).store( + key: KeystoneSignCacheKey.fromSendParams( + accountId: active.accountId, + recipientAddress: makeAccount(9).accountId, + amount: BigInt.one, + ), + unsignedData: makeUnsignedTransactionData(), + urParts: const ['ur:part'], + ); + + container.read(paymentIntentProvider.notifier).state = PaymentIntent( + to: makeAccount(9).accountId, + amount: '1000000000000', + ); + await tester.pump(); + expect(find.byType(InputAmountScreen), findsNothing); + expect(container.read(paymentIntentProvider), isNotNull); + + auth.setAuthenticated(true); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(container.read(paymentIntentProvider), isNull); + expect(find.byType(InputAmountScreen), findsOneWidget); + final screen = tester.widget(find.byType(InputAmountScreen)); + expect((screen.strategy as RegularSendStrategy).account.accountId, active.accountId); + expect(container.read(sendFlowActiveProvider), isTrue); + expect(container.read(keystoneSignCacheProvider), isNull); + }); + + testWidgets('intent arriving during a send flow is dropped', (tester) async { + final settings = FakeSettingsService(activeAccount: RegularAccount(makeAccount(1))); + final auth = TestLocalAuthController(authenticated: true); + final container = await pumpHome(tester, settings: settings, auth: auth); + + container.read(paymentIntentProvider.notifier).state = PaymentIntent( + to: makeAccount(9).accountId, + amount: '1000000000000', + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(InputAmountScreen), findsOneWidget); + expect(container.read(sendFlowActiveProvider), isTrue); + + container.read(proposalIntentProvider.notifier).state = const ProposalIntent( + multisigAddress: 'unknown-msig', + proposalId: 7, + ); + await tester.pump(); + expect(container.read(proposalIntentProvider), isNull); + + tester.state(find.byType(Navigator).first).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(InputAmountScreen), findsNothing); + expect(container.read(sendFlowActiveProvider), isFalse); + }); +} diff --git a/mobile-app/test/screens/shared_address_action_sheet_test.dart b/mobile-app/test/screens/shared_address_action_sheet_test.dart new file mode 100644 index 000000000..b2eef4fd1 --- /dev/null +++ b/mobile-app/test/screens/shared_address_action_sheet_test.dart @@ -0,0 +1,34 @@ +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/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; + +import '../extensions.dart'; +import '../fakes.dart'; + +void main() { + testWidgets('send tap warns and keeps the sheet open when the active account cannot send', (tester) async { + final settings = FakeSettingsService(activeAccount: MultisigDisplayAccount(makeMultisigAccount())); + await tester.pumpApp( + SharedAddressActionSheet(address: makeAccount(9).accountId), + overrides: [settingsServiceProvider.overrideWithValue(settings)], + ); + final container = ProviderScope.containerOf(tester.element(find.byType(SharedAddressActionSheet))); + container.read(activeAccountProvider); + await tester.pump(); + + await tester.tap(find.text('Send To This Account')); + await tester.pump(); + + expect(find.byType(InputAmountScreen), findsNothing); + expect(find.byType(SharedAddressActionSheet), findsOneWidget); + expect(find.text('Switch to a regular account to send'), findsOneWidget); + + // Let the toast run out its display duration and dismiss. + await tester.pump(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 1)); + }); +} diff --git a/mobile-app/test/unit/effective_balance_provider_test.dart b/mobile-app/test/unit/effective_balance_provider_test.dart new file mode 100644 index 000000000..5a30e31a0 --- /dev/null +++ b/mobile-app/test/unit/effective_balance_provider_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + const accountId = 'account-a'; + + ProviderContainer makeContainer({required Future Function() fetch}) { + final container = ProviderContainer( + overrides: [balanceProviderFamily.overrideWith((ref, accountId) => fetch())], + ); + addTearDown(container.dispose); + // Riverpod pauses unlistened providers; keep the chain active. + container.listen(balanceProviderFamily(accountId), (_, _) {}); + container.listen(effectiveBalanceProviderFamily(accountId), (_, _) {}); + return container; + } + + test('keeps the last fetched balance when a refresh errors', () async { + var fail = false; + final container = makeContainer(fetch: () async => fail ? throw Exception('rpc down') : BigInt.from(100)); + + await container.read(balanceProviderFamily(accountId).future); + expect(container.read(effectiveBalanceProviderFamily(accountId)).value, BigInt.from(100)); + + fail = true; + container.invalidate(balanceProviderFamily(accountId)); + await expectLater(container.read(balanceProviderFamily(accountId).future), throwsA(anything)); + + expect(container.read(effectiveBalanceProviderFamily(accountId)).value, BigInt.from(100)); + }); + + test('propagates an error that happens before any successful fetch', () async { + final container = makeContainer(fetch: () async => throw Exception('rpc down')); + + await expectLater(container.read(balanceProviderFamily(accountId).future), throwsA(anything)); + + final result = container.read(effectiveBalanceProviderFamily(accountId)); + expect(result.hasError, isTrue); + expect(result.value, isNull); + }); +} diff --git a/mobile-app/test/unit/keystone_sign_cache_test.dart b/mobile-app/test/unit/keystone_sign_cache_test.dart index 8898bc50f..25db13aad 100644 --- a/mobile-app/test/unit/keystone_sign_cache_test.dart +++ b/mobile-app/test/unit/keystone_sign_cache_test.dart @@ -1,30 +1,12 @@ -import 'dart:typed_data'; - import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/v2/screens/send/keystone_sign_cache.dart'; +import '../fakes.dart'; + /// Mortal era validity for Keystone payloads (eraPeriod 64, ~12s blocks, 2-block margin). Duration _mortalEraMaxCacheAge(QuantusSigningPayload payload) => keystoneSignCacheMaxAge(payload); -UnsignedTransactionData _fakeUnsignedData() { - return UnsignedTransactionData( - payloadToSign: QuantusSigningPayload( - method: Uint8List(0), - specVersion: 1, - transactionVersion: 1, - genesisHash: '0x00', - blockHash: '0x00', - blockNumber: 42, - eraPeriod: 64, - nonce: 0, - tip: 0, - ), - signer: Uint8List(32), - registry: Object(), - ); -} - void main() { group('KeystoneSignCacheKey', () { test('trims recipient address in fromSendParams', () { @@ -82,7 +64,7 @@ void main() { }); test('store and lookup return entry for matching key', () { - final unsigned = _fakeUnsignedData(); + final unsigned = makeUnsignedTransactionData(); const urParts = ['ur:part1', 'ur:part2']; notifier.store(key: key, unsignedData: unsigned, urParts: urParts); @@ -95,13 +77,13 @@ void main() { }); test('lookup returns null for different key', () { - notifier.store(key: key, unsignedData: _fakeUnsignedData(), urParts: const ['ur:part1']); + notifier.store(key: key, unsignedData: makeUnsignedTransactionData(), urParts: const ['ur:part1']); expect(notifier.lookup(otherKey), isNull); }); test('startNewSendSession invalidates prior entry until re-stored', () { - notifier.store(key: key, unsignedData: _fakeUnsignedData(), urParts: const ['ur:part1']); + notifier.store(key: key, unsignedData: makeUnsignedTransactionData(), urParts: const ['ur:part1']); expect(notifier.lookup(key), isNotNull); notifier.startNewSendSession(); @@ -110,10 +92,10 @@ void main() { }); test('second startNewSendSession requires fresh store even when params unchanged', () { - notifier.store(key: key, unsignedData: _fakeUnsignedData(), urParts: const ['ur:first']); + notifier.store(key: key, unsignedData: makeUnsignedTransactionData(), urParts: const ['ur:first']); notifier.startNewSendSession(); - final unsigned = _fakeUnsignedData(); + final unsigned = makeUnsignedTransactionData(); notifier.store(key: key, unsignedData: unsigned, urParts: const ['ur:second']); final entry = notifier.lookup(key); @@ -132,7 +114,7 @@ void main() { final key = KeystoneSignCacheKey(accountId: 'account-a', recipientAddress: 'recipient', amount: BigInt.from(100)); test('lookup returns null when user returns after mortal era expires', () { - final unsigned = _fakeUnsignedData(); + final unsigned = makeUnsignedTransactionData(); final storedAt = DateTime(2026, 1, 1, 12, 0, 0); final now = storedAt.add(_mortalEraMaxCacheAge(unsigned.payloadToSign)); @@ -142,7 +124,7 @@ void main() { }); test('lookup still returns entry within mortal era window', () { - final unsigned = _fakeUnsignedData(); + final unsigned = makeUnsignedTransactionData(); final storedAt = DateTime(2026, 1, 1, 12, 0, 0); final now = storedAt.add(_mortalEraMaxCacheAge(unsigned.payloadToSign) - const Duration(seconds: 1)); diff --git a/mobile-app/test/unit/regular_send_strategy_test.dart b/mobile-app/test/unit/regular_send_strategy_test.dart new file mode 100644 index 000000000..4dc8348cd --- /dev/null +++ b/mobile-app/test/unit/regular_send_strategy_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; + +import '../fakes.dart'; + +void main() { + final captured = makeAccount(1); + final other = makeAccount(2); + + Future pumpRef(WidgetTester tester, {List overrides = const []}) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: Consumer( + builder: (context, ref, _) { + widgetRef = ref; + return const SizedBox(); + }, + ), + ), + ); + return widgetRef; + } + + testWidgets('stays bound to the captured account after the active account switches', (tester) async { + final settings = FakeSettingsService(activeAccount: RegularAccount(other)); + final ref = await pumpRef(tester, overrides: [settingsServiceProvider.overrideWithValue(settings)]); + final strategy = RegularSendStrategy(account: captured); + + ref.read(activeAccountProvider); + await tester.pump(); + expect(ref.read(activeAccountProvider).value?.account.accountId, other.accountId); + + expect(strategy.sourceAccountId(ref), captured.accountId); + expect(strategy.spendableBalanceProvider, effectiveMaxBalanceProviderFamily(captured.accountId)); + }); + + testWidgets('validates against the captured account balance, not the active one', (tester) async { + final existentialDeposit = balances.Constants().existentialDeposit; + final capturedBalance = existentialDeposit * BigInt.from(100); + final ref = await pumpRef( + tester, + overrides: [ + effectiveBalanceProviderFamily.overrideWith( + (r, accountId) => AsyncValue.data(accountId == captured.accountId ? capturedBalance : BigInt.one), + ), + ], + ); + final strategy = RegularSendStrategy(account: captured); + + expect(ref.read(strategy.spendableBalanceProvider).value, capturedBalance - existentialDeposit); + }); + + testWidgets('estimates fees from the captured account', (tester) async { + final balancesService = FakeBalancesService(); + final settings = FakeSettingsService(activeAccount: RegularAccount(other)); + final ref = await pumpRef( + tester, + overrides: [ + settingsServiceProvider.overrideWithValue(settings), + balancesServiceProvider.overrideWithValue(balancesService), + ], + ); + final strategy = RegularSendStrategy(account: captured); + + final fee = await strategy.estimateFee(ref, recipient: other.accountId, amount: BigInt.from(10)); + + expect(balancesService.lastFeeAccount?.accountId, captured.accountId); + expect((fee as RegularFee).networkFee, BigInt.from(1000000000)); + }); + + testWidgets('submit hands the captured keystone account to the signing session after a switch', (tester) async { + final keystone = makeAccount(3, accountType: AccountType.keystone); + final settings = FakeSettingsService(activeAccount: RegularAccount(other)); + final ref = await pumpRef(tester, overrides: [settingsServiceProvider.overrideWithValue(settings)]); + final strategy = RegularSendStrategy(account: keystone); + + ref.read(activeAccountProvider); + await tester.pump(); + expect(ref.read(activeAccountProvider).value?.account.accountId, other.accountId); + + final outcome = await strategy.submit( + ref, + recipientAddress: other.accountId, + recipientChecksum: 'checksum', + amount: BigInt.from(1000), + fee: RegularFee(networkFee: BigInt.from(10), blockHeight: 1), + isPayMode: false, + ); + + expect(outcome, isA()); + expect((outcome as SendNeedsHardwareSignature).session.account.accountId, keystone.accountId); + }); +} From 8d7b57c9f4009f42fe8f1aa0cd9734b1ef0b7bd4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 29 Jul 2026 13:46:59 +0800 Subject: [PATCH 4/5] test: stabilize new tests post-merge - Fake SubstrateService for SS58 validation in widget tests (Rust FFI is unavailable under flutter test). - Disable Riverpod auto-retry in the balance-fallback test so a failed fetch reaches its terminal error state. - Drop TestWidgetsFlutterBinding from the plain provider test (it stalls awaited provider futures); mock SharedPreferences directly. - Extra pump for route disposal after popping the send flow. --- mobile-app/lib/v2/screens/home/home_screen.dart | 2 -- mobile-app/test/fakes.dart | 5 +++++ .../test/screens/home_intent_gating_test.dart | 1 + .../test/unit/effective_balance_provider_test.dart | 11 ++++++----- .../test/unit/shared_address_action_sheet_test.dart | 13 +++++++++++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 3e944981b..7c64ace52 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -7,11 +7,9 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/features/components/dotted_border.dart'; import 'package:resonance_network_wallet/features/components/skeleton.dart'; import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; -import 'package:resonance_network_wallet/routes.dart'; import 'package:resonance_network_wallet/services/global_history_polling_service.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; -import 'package:resonance_network_wallet/shared/extensions/current_route_extensions.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/shared/utils/url_utils.dart'; diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 4c40c122d..e4855b5da 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -47,6 +47,11 @@ class TestLocalAuthController extends LocalAuthController { } } +class FakeSubstrateService extends Fake implements SubstrateService { + @override + bool isValidSS58Address(String address) => true; +} + class FakeBalancesService extends Fake implements BalancesService { Account? lastFeeAccount; diff --git a/mobile-app/test/screens/home_intent_gating_test.dart b/mobile-app/test/screens/home_intent_gating_test.dart index de0808ee9..45a583edb 100644 --- a/mobile-app/test/screens/home_intent_gating_test.dart +++ b/mobile-app/test/screens/home_intent_gating_test.dart @@ -128,6 +128,7 @@ void main() { tester.state(find.byType(Navigator).first).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); expect(find.byType(InputAmountScreen), findsNothing); expect(container.read(sendFlowActiveProvider), isFalse); diff --git a/mobile-app/test/unit/effective_balance_provider_test.dart b/mobile-app/test/unit/effective_balance_provider_test.dart index 5a30e31a0..6232f8a0a 100644 --- a/mobile-app/test/unit/effective_balance_provider_test.dart +++ b/mobile-app/test/unit/effective_balance_provider_test.dart @@ -4,18 +4,18 @@ import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => SharedPreferences.setMockInitialValues({})); const accountId = 'account-a'; ProviderContainer makeContainer({required Future Function() fetch}) { final container = ProviderContainer( + // No auto-retry: let a failed fetch reach its terminal error state. + retry: (retryCount, error) => null, overrides: [balanceProviderFamily.overrideWith((ref, accountId) => fetch())], ); addTearDown(container.dispose); // Riverpod pauses unlistened providers; keep the chain active. - container.listen(balanceProviderFamily(accountId), (_, _) {}); container.listen(effectiveBalanceProviderFamily(accountId), (_, _) {}); return container; } @@ -24,20 +24,21 @@ void main() { var fail = false; final container = makeContainer(fetch: () async => fail ? throw Exception('rpc down') : BigInt.from(100)); - await container.read(balanceProviderFamily(accountId).future); + await pumpEventQueue(); expect(container.read(effectiveBalanceProviderFamily(accountId)).value, BigInt.from(100)); fail = true; container.invalidate(balanceProviderFamily(accountId)); - await expectLater(container.read(balanceProviderFamily(accountId).future), throwsA(anything)); + await pumpEventQueue(); + expect(container.read(balanceProviderFamily(accountId)).hasError, isTrue); expect(container.read(effectiveBalanceProviderFamily(accountId)).value, BigInt.from(100)); }); test('propagates an error that happens before any successful fetch', () async { final container = makeContainer(fetch: () async => throw Exception('rpc down')); - await expectLater(container.read(balanceProviderFamily(accountId).future), throwsA(anything)); + await pumpEventQueue(); final result = container.read(effectiveBalanceProviderFamily(accountId)); expect(result.hasError, isTrue); diff --git a/mobile-app/test/unit/shared_address_action_sheet_test.dart b/mobile-app/test/unit/shared_address_action_sheet_test.dart index 827f3079c..4a936e479 100644 --- a/mobile-app/test/unit/shared_address_action_sheet_test.dart +++ b/mobile-app/test/unit/shared_address_action_sheet_test.dart @@ -2,11 +2,15 @@ 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:flutter_riverpod/misc.dart'; import 'package:resonance_network_wallet/features/components/shared_address_action_sheet.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.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'; +import '../fakes.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -15,9 +19,10 @@ void main() { await SettingsService().initialize(); }); - Future pumpSheet(WidgetTester tester, String address) async { + Future pumpSheet(WidgetTester tester, String address, {List overrides = const []}) async { await tester.pumpWidget( ProviderScope( + overrides: overrides, child: MediaQuery( data: const MediaQueryData(size: Size(800, 600)), child: Builder( @@ -46,7 +51,11 @@ void main() { }); testWidgets('Send To This Account warns and keeps the sheet open without a regular account', (tester) async { - await pumpSheet(tester, 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEdX'); + await pumpSheet( + tester, + 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEdX', + overrides: [substrateServiceProvider.overrideWithValue(FakeSubstrateService())], + ); await tester.tap(find.text('Send To This Account')); await tester.pump(); From d563d1be42637380981d42be27347d66c8ee16b2 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 29 Jul 2026 16:21:50 +0800 Subject: [PATCH 5/5] fix: gate intents through visual lock and restore CI --- .../shared_address_action_sheet.dart | 5 +- .../lib/providers/wallet_providers.dart | 4 +- .../lib/v2/screens/home/home_screen.dart | 18 +++--- .../v2/screens/send/input_amount_screen.dart | 1 - .../screens/send/select_recipient_screen.dart | 1 - mobile-app/test/fakes.dart | 4 ++ .../test/screens/home_intent_gating_test.dart | 55 +++++++++++++++---- 7 files changed, 65 insertions(+), 23 deletions(-) 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 720dd10a2..381f9f08d 100644 --- a/mobile-app/lib/features/components/shared_address_action_sheet.dart +++ b/mobile-app/lib/features/components/shared_address_action_sheet.dart @@ -81,7 +81,10 @@ class _SharedAddressActionSheetState extends State { Navigator.of(context).pop(); startSendFlow( context, - screen: InputAmountScreen(strategy: RegularSendStrategy(account: active.account), recipientAddress: widget.address), + screen: InputAmountScreen( + strategy: RegularSendStrategy(account: active.account), + recipientAddress: widget.address, + ), ); } diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index e2be6360b..768f0ced2 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -143,7 +143,9 @@ final effectiveBalanceProviderFamily = Provider.family, Strin // checks mid-send. Errors before any successful fetch still propagate. final blockchainBalance = balanceAsync.value; if (blockchainBalance == null) { - return balanceAsync.isLoading ? const AsyncValue.loading() : AsyncValue.error(balanceAsync.error!, balanceAsync.stackTrace!); + return balanceAsync.isLoading + ? const AsyncValue.loading() + : AsyncValue.error(balanceAsync.error!, balanceAsync.stackTrace!); } final pendingOutgoing = _calculatePendingOutgoing( pendingTransactions, diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 7c64ace52..82d19d889 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -81,17 +81,17 @@ class _HomeScreenState extends ConsumerState { // Intents are only consumed while unlocked: anything arriving beneath the // lock overlay stays queued in its provider and is drained after unlock. ref.listenManual(localAuthProvider, (prev, next) { - if (next.isAuthenticated && !(prev?.isAuthenticated ?? false)) _drainPendingIntents(); + if (_isAuthStateUnlocked(next) && (prev == null || !_isAuthStateUnlocked(prev))) { + _drainPendingIntents(); + } }); Future.microtask(_drainPendingIntents); } - /// Deep-link and notification intents must never be acted on while the lock - /// overlay is up; the Navigator underneath it is still alive. Deliberately - /// gates on authentication only, not the visual privacy overlay: resume - /// either clears that overlay or forces re-auth before anything is tappable. - bool get _isUnlocked => ref.read(localAuthProvider).isAuthenticated; + bool _isAuthStateUnlocked(LocalAuthState auth) => auth.isAuthenticated && !auth.isVisuallyLocked; + + bool get _isUnlocked => _isAuthStateUnlocked(ref.read(localAuthProvider)); /// A send in flight must never be interrupted: intents that would start /// another flow or switch the active account are dropped, not queued. @@ -418,8 +418,10 @@ class _HomeScreenState extends ConsumerState { _actionCard( iconAsset: 'assets/v2/action_send.svg', label: l10n.multisigProposeTitle, - onTap: () => - startSendFlow(context, screen: SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig))), + onTap: () => startSendFlow( + context, + screen: SelectRecipientScreen(strategy: MultisigProposeStrategy(msig: msig)), + ), ), ], ); diff --git a/mobile-app/lib/v2/screens/send/input_amount_screen.dart b/mobile-app/lib/v2/screens/send/input_amount_screen.dart index 81c5ddad6..311ad20e1 100644 --- a/mobile-app/lib/v2/screens/send/input_amount_screen.dart +++ b/mobile-app/lib/v2/screens/send/input_amount_screen.dart @@ -13,7 +13,6 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/review_send_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_screen_logic.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; 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 2f43f3035..8a54e3e77 100644 --- a/mobile-app/lib/v2/screens/send/select_recipient_screen.dart +++ b/mobile-app/lib/v2/screens/send/select_recipient_screen.dart @@ -22,7 +22,6 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; import 'package:resonance_network_wallet/v2/screens/send/input_amount_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; import 'package:resonance_network_wallet/v2/screens/send/send_strategy.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index e4855b5da..a91fa0d47 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -45,6 +45,10 @@ class TestLocalAuthController extends LocalAuthController { void setAuthenticated(bool value) { state = state.copyWith(isAuthenticated: value); } + + void setVisuallyLocked(bool value) { + state = state.copyWith(isVisuallyLocked: value); + } } class FakeSubstrateService extends Fake implements SubstrateService { diff --git a/mobile-app/test/screens/home_intent_gating_test.dart b/mobile-app/test/screens/home_intent_gating_test.dart index 45a583edb..75100926b 100644 --- a/mobile-app/test/screens/home_intent_gating_test.dart +++ b/mobile-app/test/screens/home_intent_gating_test.dart @@ -30,12 +30,19 @@ void main() { overrides: [ settingsServiceProvider.overrideWithValue(settings), localAuthProvider.overrideWith((ref) => auth), - activeAccountTransactionsProvider.overrideWith((ref, filter) => AsyncValue.data(CombinedTransactionsList.empty)), + activeAccountTransactionsProvider.overrideWith( + (ref, filter) => AsyncValue.data(CombinedTransactionsList.empty), + ), balanceProvider.overrideWithValue(AsyncValue.data(BigInt.from(10).pow(15))), balanceProviderFamily.overrideWith((ref, accountId) async => BigInt.from(10).pow(15)), balanceDisplayProvider.overrideWithValue( const AsyncValue.data( - CurrencyDisplayState(primaryAmount: '10', secondaryAmount: '10', isFlipped: false, selectedFiat: FiatCurrency.usd), + CurrencyDisplayState( + primaryAmount: '10', + secondaryAmount: '10', + isFlipped: false, + selectedFiat: FiatCurrency.usd, + ), ), ), backupReminderWalletIndexProvider.overrideWithValue(null), @@ -74,15 +81,17 @@ void main() { final container = await pumpHome(tester, settings: settings, auth: auth); // A cached QR from an earlier flow must not survive into the new session. - container.read(keystoneSignCacheProvider.notifier).store( - key: KeystoneSignCacheKey.fromSendParams( - accountId: active.accountId, - recipientAddress: makeAccount(9).accountId, - amount: BigInt.one, - ), - unsignedData: makeUnsignedTransactionData(), - urParts: const ['ur:part'], - ); + container + .read(keystoneSignCacheProvider.notifier) + .store( + key: KeystoneSignCacheKey.fromSendParams( + accountId: active.accountId, + recipientAddress: makeAccount(9).accountId, + amount: BigInt.one, + ), + unsignedData: makeUnsignedTransactionData(), + urParts: const ['ur:part'], + ); container.read(paymentIntentProvider.notifier).state = PaymentIntent( to: makeAccount(9).accountId, @@ -104,6 +113,30 @@ void main() { expect(container.read(keystoneSignCacheProvider), isNull); }); + testWidgets('payment intent stays queued until the visual lock clears', (tester) async { + final active = makeAccount(1); + final settings = FakeSettingsService(activeAccount: RegularAccount(active)); + final auth = TestLocalAuthController(authenticated: true)..setVisuallyLocked(true); + final container = await pumpHome(tester, settings: settings, auth: auth); + + container.read(paymentIntentProvider.notifier).state = PaymentIntent( + to: makeAccount(9).accountId, + amount: '1000000000000', + ); + await tester.pump(); + + expect(container.read(paymentIntentProvider), isNotNull); + expect(find.byType(InputAmountScreen), findsNothing); + + auth.setVisuallyLocked(false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(container.read(paymentIntentProvider), isNull); + expect(find.byType(InputAmountScreen), findsOneWidget); + expect(container.read(sendFlowActiveProvider), isTrue); + }); + testWidgets('intent arriving during a send flow is dropped', (tester) async { final settings = FakeSettingsService(activeAccount: RegularAccount(makeAccount(1))); final auth = TestLocalAuthController(authenticated: true);