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 9460bcda..381f9f08 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/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/routes.dart'; @@ -15,8 +16,8 @@ import 'package:resonance_network_wallet/shared/extensions/toaster_extensions.da 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; @@ -71,12 +72,18 @@ class _SharedAddressActionSheetState extends State { context.showErrorToaster(message: container.read(l10nProvider).invalidAddress); return; } - container.read(keystoneSignCacheProvider.notifier).startNewSendSession(); + final active = container.read(activeAccountProvider).value; + if (active is! RegularAccount) { + quantusPrint('shared address send: active account cannot send regular transfers'); + context.showWarningToaster(message: container.read(l10nProvider).sendRegularAccountRequired); + return; + } Navigator.of(context).pop(); - Navigator.push( + startSendFlow( context, - MaterialPageRoute( - builder: (_) => InputAmountScreen(strategy: const RegularSendStrategy(), 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 6a482e69..6a1bc2ed 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -1404,6 +1404,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 e33f7cd4..c7d587d4 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -323,6 +323,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 b4ce482f..2efd167c 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -1898,6 +1898,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 68cc009c..0a70745e 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1010,6 +1010,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 300572ef..c11b66fb 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1007,6 +1007,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 1df44ecd..768f0ced 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -138,23 +138,25 @@ 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 2f119f1e..82d19d88 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -1,16 +1,16 @@ 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'; 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'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; @@ -26,7 +26,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'; @@ -38,11 +37,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 +68,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 +78,30 @@ 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 (_isAuthStateUnlocked(next) && (prev == null || !_isAuthStateUnlocked(prev))) { + _drainPendingIntents(); + } + }); Future.microtask(_drainPendingIntents); } + 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. + bool _consumeIfSendInFlight(StateProvider provider, String label) { + if (!ref.read(sendFlowActiveProvider)) return false; + ref.read(provider.notifier).state = null; + quantusPrint('$label intent ignored: send flow active'); + return true; + } + void _drainPendingIntents() { if (!mounted) return; _onTransactionIntent(null, ref.read(transactionIntentProvider)); @@ -88,7 +111,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,29 +120,32 @@ class _HomeScreenState extends ConsumerState { } void _onPaymentIntent(PaymentIntent? _, PaymentIntent? payment) { - if (payment == null || !mounted) 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; - ref.read(keystoneSignCacheProvider.notifier).startNewSendSession(); + if (active is! RegularAccount) { + quantusPrint('payment intent: active account cannot send regular transfers'); + context.showWarningToaster(message: ref.read(l10nProvider).sendRegularAccountRequired); + return; + } - final pageRoute = MaterialPageRoute( - builder: (_) => InputAmountScreen( - strategy: const RegularSendStrategy(), + 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) return; + if (shared == null || !mounted || !_isUnlocked) return; + if (_consumeIfSendInFlight(sharedAccountIntentProvider, 'shared account')) return; ref.read(sharedAccountIntentProvider.notifier).state = null; showSharedAddressActionSheet(context, shared); @@ -129,7 +155,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) 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. @@ -341,7 +368,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', @@ -353,10 +380,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( @@ -394,15 +418,10 @@ 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/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index f310e0bd..b18baddc 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'; @@ -21,13 +20,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( @@ -42,7 +47,8 @@ class RegularSendStrategy extends SendStrategy { ); @override - ProviderListenable> get spendableBalanceProvider => effectiveMaxBalanceProvider; + ProviderListenable> get spendableBalanceProvider => + effectiveMaxBalanceProviderFamily(account.accountId); @override bool extraBalancesLoading(WidgetRef ref) => false; @@ -52,11 +58,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; @@ -110,7 +111,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/send_providers.dart b/mobile-app/lib/v2/screens/send/send_providers.dart index 1b28e0da..d7868d36 100644 --- a/mobile-app/lib/v2/screens/send/send_providers.dart +++ b/mobile-app/lib/v2/screens/send/send_providers.dart @@ -1,15 +1,46 @@ +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); -// Provider that combines balance with existential deposit toggle -final effectiveMaxBalanceProvider = Provider>((ref) { +/// 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) { + quantusPrint('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 +/// 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( diff --git a/mobile-app/test/extensions.dart b/mobile-app/test/extensions.dart index fe33c726..d1a7b960 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 00000000..a91fa0d4 --- /dev/null +++ b/mobile-app/test/fakes.dart @@ -0,0 +1,102 @@ +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); + } + + void setVisuallyLocked(bool value) { + state = state.copyWith(isVisuallyLocked: value); + } +} + +class FakeSubstrateService extends Fake implements SubstrateService { + @override + bool isValidSS58Address(String address) => true; +} + +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 00000000..75100926 --- /dev/null +++ b/mobile-app/test/screens/home_intent_gating_test.dart @@ -0,0 +1,169 @@ +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('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); + 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)); + 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 new file mode 100644 index 00000000..6232f8a0 --- /dev/null +++ b/mobile-app/test/unit/effective_balance_provider_test.dart @@ -0,0 +1,47 @@ +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() { + 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(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 pumpEventQueue(); + expect(container.read(effectiveBalanceProviderFamily(accountId)).value, BigInt.from(100)); + + fail = true; + container.invalidate(balanceProviderFamily(accountId)); + 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 pumpEventQueue(); + + 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 8898bc50..25db13aa 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 00000000..4dc8348c --- /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); + }); +} 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 848fa42a..4a936e47 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( @@ -44,4 +49,21 @@ void main() { // Let the error toast (10s duration) dismiss so no ticker leaks. await tester.pump(const Duration(seconds: 11)); }); + + testWidgets('Send To This Account warns and keeps the sheet open without a regular account', (tester) async { + await pumpSheet( + tester, + 'qzpyxSr48YN9EQe2ito734iCReTXjnungmNCSY4Yph1YznEdX', + overrides: [substrateServiceProvider.overrideWithValue(FakeSubstrateService())], + ); + + 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); + + await tester.pump(const Duration(seconds: 11)); + }); }