From 79782027aae2a9c55df33907edd2c20387cdc003 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:06:54 -0500 Subject: [PATCH 1/2] fix: let callers dismiss the building transaction dialog The dialog popped itself on Cancel, but twelve of its thirteen call sites already popped in their own onCancel handler, so on mobile Cancel tore down the dialog and the page behind it. Dismissal now belongs to the caller, and the exchange step 4 send flow gains the pop it had been relying on the dialog for. --- .../exchange_step_views/step_4_view.dart | 1 + .../building_transaction_dialog.dart | 2 - .../building_transaction_dialog_test.dart | 118 ++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/pages/send_view/building_transaction_dialog_test.dart diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index a48b85b23c..6df00faecf 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -270,6 +270,7 @@ class _Step4ViewState extends ConsumerState { isSpark: wallet is FiroWallet && !firoPublicSend, onCancel: () { wasCancelled = true; + Navigator.of(context).pop(); }, ); }, diff --git a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart index 0d1e9ef344..4cba928080 100644 --- a/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart +++ b/lib/pages/send_view/sub_widgets/building_transaction_dialog.dart @@ -115,7 +115,6 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); onCancel.call(); }, ), @@ -140,7 +139,6 @@ class _RestoringDialogState extends ConsumerState { style: STextStyles.itemSubtitle12(context), ), onPressed: () { - Navigator.of(context).pop(); onCancel.call(); }, ), diff --git a/test/pages/send_view/building_transaction_dialog_test.dart b/test/pages/send_view/building_transaction_dialog_test.dart new file mode 100644 index 0000000000..56b2dcb963 --- /dev/null +++ b/test/pages/send_view/building_transaction_dialog_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/send_view/sub_widgets/building_transaction_dialog.dart'; +import 'package:stackwallet/themes/coin_image_provider.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog.dart'; + +import '../../sample_data/theme_json.dart'; + +void main() { + for (final isDesktop in [false, true]) { + testWidgets( + 'cancel dismisses only the ${isDesktop ? 'desktop' : 'mobile'} dialog', + (tester) async { + Util.screenWidth = isDesktop ? null : 400; + addTearDown(() => Util.screenWidth = null); + + var cancelCount = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + coinImageSecondaryProvider.overrideWithProvider( + (_) => Provider((_) => 'coin.svg'), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: _RootPage( + isDesktop: isDesktop, + onCancel: () => cancelCount++, + ), + ), + ), + ); + + await tester.tap(find.text('Open caller')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Build transaction')); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(cancelCount, 1); + expect(find.byKey(const Key('caller page')), findsOneWidget); + expect(find.text('Generating transaction'), findsNothing); + expect( + tester.state(find.byType(Navigator)).canPop(), + isTrue, + ); + }, + ); + } +} + +class _RootPage extends StatelessWidget { + const _RootPage({required this.isDesktop, required this.onCancel}); + + final bool isDesktop; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + _CallerPage(isDesktop: isDesktop, onCancel: onCancel), + ), + ), + child: const Text('Open caller'), + ), + ); + } +} + +class _CallerPage extends StatelessWidget { + const _CallerPage({required this.isDesktop, required this.onCancel}); + + final bool isDesktop; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('caller page'), + body: TextButton( + onPressed: () => showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final child = BuildingTransactionDialog( + coin: Bitcoin(CryptoCurrencyNetwork.main), + isSpark: false, + onCancel: () { + onCancel(); + Navigator.of(dialogContext).pop(); + }, + ); + + return isDesktop ? DesktopDialog(child: child) : child; + }, + ), + child: const Text('Build transaction'), + ), + ); + } +} From 43528a266877182ac95376a9ff31b03c92d074cd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 24 Aug 2026 16:07:05 -0500 Subject: [PATCH 2/2] feat: coin control for the exchange send flows Adds a "Coin control" row to the mobile exchange step 4 send button and to each wallet card in the send-from view, backed by a shared module: a visibility predicate, an amount-plus-fee funding estimate computed at the same rate the send will use, and a validator that re-reads every stored output from the database before opening the picker and again before sending. Outputs that went missing, changed wallet, were frozen, spent or lost their confirmations clear the selection; a merely underfunded one is kept so the user can add more. Firo is excluded. --- .../exchange_step_views/step_4_view.dart | 61 ++++ lib/pages/exchange_view/send_from_view.dart | 148 ++++---- .../sub_widgets/exchange_coin_control.dart | 322 ++++++++++++++++++ .../exchange_coin_control_test.dart | 308 +++++++++++++++++ 4 files changed, 782 insertions(+), 57 deletions(-) create mode 100644 lib/pages/exchange_view/sub_widgets/exchange_coin_control.dart create mode 100644 test/pages/exchange_view/exchange_coin_control_test.dart diff --git a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart index 6df00faecf..ab8ee60de9 100644 --- a/lib/pages/exchange_view/exchange_step_views/step_4_view.dart +++ b/lib/pages/exchange_view/exchange_step_views/step_4_view.dart @@ -17,6 +17,7 @@ import 'package:tuple/tuple.dart'; import '../../../app_config.dart'; import '../../../models/exchange/incomplete_exchange.dart'; +import '../../../models/isar/models/isar_models.dart'; import '../../../providers/providers.dart'; import '../../../route_generator.dart'; import '../../../themes/stack_colors.dart'; @@ -46,6 +47,7 @@ import '../../../widgets/stack_dialog.dart'; import '../../home_view/home_view.dart'; import '../../send_view/sub_widgets/building_transaction_dialog.dart'; import '../../wallet_view/wallet_view.dart'; +import '../sub_widgets/exchange_coin_control.dart'; import '../confirm_change_now_send.dart'; import '../send_from_view.dart'; import '../sub_widgets/step_row.dart'; @@ -71,6 +73,12 @@ class _Step4ViewState extends ConsumerState { late final IncompleteExchangeModel model; late final ClipboardInterface clipboard; + Set _selectedUTXOs = {}; + String? _selectedUtxoWalletId; + + Set _selectionFor(String walletId) => + _selectedUtxoWalletId == walletId ? _selectedUTXOs : const {}; + String _statusString = "New"; Timer? _statusTimer; @@ -288,6 +296,17 @@ class _Step4ViewState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(address)!, ); + final selected = _selectionFor(tuple.item1); + final selectedInputs = selected.isEmpty + ? null + : (await prepareExchangeCoinSelection( + walletId: tuple.item1, + wallet: wallet, + currentChainHeight: ref.read(pWalletChainHeight(tuple.item1)), + amount: amount, + selected: selected, + )).inputs; + if (wallet is FiroWallet && !firoPublicSend) { throw Exception( "Sending private Firo funds to an exchange address is temporarily " @@ -304,6 +323,7 @@ class _Step4ViewState extends ConsumerState { recipients: [recipient], memo: memo, feeRateType: FeeRateType.average, + utxos: selectedInputs, note: "${model.trade!.payInCurrency.toUpperCase()}/" "${model.trade!.payOutCurrency.toUpperCase()} exchange", @@ -344,6 +364,12 @@ class _Step4ViewState extends ConsumerState { } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (e is ExchangeCoinSelectionException && e.clearSelection && mounted) { + setState(() { + _selectedUtxoWalletId = null; + _selectedUTXOs = {}; + }); + } if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context).pop(); @@ -522,6 +548,41 @@ class _Step4ViewState extends ConsumerState { ), if (isWalletCoinAndCanSend) const SizedBox(height: 12), + if (isWalletCoinAndCanSend) + Builder( + builder: (context) { + final tuple = ref + .watch( + exchangeSendFromWalletIdStateProvider + .state, + ) + .state; + if (tuple == null || + model.sendTicker.toLowerCase() != + tuple.item2.ticker.toLowerCase()) { + return const SizedBox.shrink(); + } + return ExchangeCoinControlSelector( + key: ValueKey(tuple.item1), + walletId: tuple.item1, + amount: model.sendAmount.toAmount( + fractionDigits: + tuple.item2.fractionDigits, + ), + selected: _selectionFor(tuple.item1), + onChanged: (selected) { + setState(() { + _selectedUtxoWalletId = tuple.item1; + _selectedUTXOs = selected; + }); + }, + padding: const EdgeInsets.only( + bottom: 12, + ), + rounded: true, + ); + }, + ), if (isWalletCoinAndCanSend) _SendFromButton( model: model, diff --git a/lib/pages/exchange_view/send_from_view.dart b/lib/pages/exchange_view/send_from_view.dart index 1e3555c655..04dda9ae4a 100644 --- a/lib/pages/exchange_view/send_from_view.dart +++ b/lib/pages/exchange_view/send_from_view.dart @@ -17,6 +17,7 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../models/exchange/response_objects/trade.dart'; +import '../../models/isar/models/isar_models.dart'; import '../../pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; @@ -48,6 +49,7 @@ import '../../widgets/stack_dialog.dart'; import '../home_view/home_view.dart'; import '../send_view/sub_widgets/building_transaction_dialog.dart'; import 'confirm_change_now_send.dart'; +import 'sub_widgets/exchange_coin_control.dart'; class SendFromView extends ConsumerStatefulWidget { const SendFromView({ @@ -222,12 +224,13 @@ class _SendFromCardState extends ConsumerState { late final String address; late final Trade trade; + Set _selectedUTXOs = {}; + Future _send({bool? shouldSendPublicFiroFunds}) async { final coin = ref.read(pWalletCoin(walletId)); + var wasCancelled = false; try { - bool wasCancelled = false; - final wallet = ref.read(pWallets).getWallet(walletId); unawaited( @@ -278,6 +281,16 @@ class _SendFromCardState extends ConsumerState { addressType: wallet.cryptoCurrency.getAddressType(address)!, ); + final selectedInputs = _selectedUTXOs.isEmpty + ? null + : (await prepareExchangeCoinSelection( + walletId: walletId, + wallet: wallet, + currentChainHeight: ref.read(pWalletChainHeight(walletId)), + amount: amount, + selected: _selectedUTXOs, + )).inputs; + // if not firo then do normal send if (shouldSendPublicFiroFunds == null) { final memo = coin is Stellar || coin is Solana @@ -290,6 +303,7 @@ class _SendFromCardState extends ConsumerState { recipients: [recipient], memo: memo, feeRateType: FeeRateType.average, + utxos: selectedInputs, ), ); } else { @@ -299,6 +313,7 @@ class _SendFromCardState extends ConsumerState { txData: TxData( recipients: [recipient], feeRateType: FeeRateType.average, + utxos: selectedInputs, ), ); } else { @@ -349,7 +364,10 @@ class _SendFromCardState extends ConsumerState { } } catch (e, s) { Logging.instance.e("$e\n$s", error: e, stackTrace: s); - if (mounted) { + if (e is ExchangeCoinSelectionException && e.clearSelection && mounted) { + setState(() => _selectedUTXOs = {}); + } + if (mounted && !wasCancelled) { // pop building dialog Navigator.of(context, rootNavigator: Util.isDesktop).pop(); @@ -543,68 +561,84 @@ class _SendFromCardState extends ConsumerState { ], ), ), - child: ConditionalParent( - condition: !isFiro, - builder: (child) => MaterialButton( - splashColor: Theme.of(context).extension()!.highlight, - key: Key("walletsSheetItemButtonKey_$walletId"), - padding: const EdgeInsets.all(8), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - onPressed: () async { - if (mounted) { - unawaited(_send()); - } - }, - child: child, - ), - child: Row( - children: [ - Container( - decoration: BoxDecoration( - color: ref.watch(pCoinColor(coin)).withOpacity(0.5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConditionalParent( + condition: !isFiro, + builder: (child) => MaterialButton( + splashColor: Theme.of( + context, + ).extension()!.highlight, + key: Key("walletsSheetItemButtonKey_$walletId"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), ), - child: Padding( - padding: const EdgeInsets.all(6), - child: SvgPicture.file( - File(ref.watch(coinIconProvider(coin))), - width: 24, - height: 24, - ), - ), + onPressed: () async { + if (mounted) { + unawaited(_send()); + } + }, + child: child, ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - ref.watch(pWalletName(walletId)), - style: STextStyles.titleBold12(context), + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: ref.watch(pCoinColor(coin)).withOpacity(0.5), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), ), - if (!isFiro) const SizedBox(height: 2), - if (!isFiro) - Text( - ref - .watch(pAmountFormatter(coin)) - .format( - ref.watch(pWalletBalance(walletId)).spendable, - ), - style: STextStyles.itemSubtitle(context), + child: Padding( + padding: const EdgeInsets.all(6), + child: SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, ), - ], - ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12(context), + ), + if (!isFiro) const SizedBox(height: 2), + if (!isFiro) + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref.watch(pWalletBalance(walletId)).spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + ], ), - ], - ), + ), + ExchangeCoinControlSelector( + walletId: walletId, + amount: amount, + selected: _selectedUTXOs, + onChanged: (selected) { + setState(() => _selectedUTXOs = selected); + }, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ), + ], ), ), ); diff --git a/lib/pages/exchange_view/sub_widgets/exchange_coin_control.dart b/lib/pages/exchange_view/sub_widgets/exchange_coin_control.dart new file mode 100644 index 0000000000..af8e6ca79f --- /dev/null +++ b/lib/pages/exchange_view/sub_widgets/exchange_coin_control.dart @@ -0,0 +1,322 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + */ + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; +import 'package:tuple/tuple.dart'; + +import '../../../db/isar/main_db.dart'; +import '../../../models/input.dart'; +import '../../../models/isar/models/isar_models.dart'; +import '../../../providers/providers.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/crypto_currency/coins/firo.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/impl/namecoin_wallet.dart'; +import '../../../wallets/wallet/wallet.dart'; +import '../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; +import '../../../widgets/custom_buttons/blue_text_button.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../coin_control/coin_control_view.dart'; + +typedef ExchangeUtxoLookup = UTXO? Function(UTXO selected); +typedef ExchangeUtxoConfirmation = bool Function(UTXO utxo); + +class ExchangeCoinSelectionException implements Exception { + const ExchangeCoinSelectionException( + this.message, { + required this.clearSelection, + }); + + final String message; + final bool clearSelection; + + @override + String toString() => message; +} + +bool shouldShowExchangeCoinControl({ + required bool preferenceEnabled, + required bool walletSupportsCoinControl, + required bool isFiro, +}) => preferenceEnabled && walletSupportsCoinControl && !isFiro; + +Future estimateExchangeFundingTotal({ + required Amount amount, + required Future Function(Amount amount) estimateFee, +}) async => amount + await estimateFee(amount); + +Set? validateExchangeCoinSelection({ + required String walletId, + required Set selected, + required Amount requiredTotal, + required ExchangeUtxoLookup lookup, + required ExchangeUtxoConfirmation isConfirmed, + bool requireSufficientValue = true, +}) { + if (selected.isEmpty) { + return null; + } + + final current = {}; + for (final prior in selected) { + final utxo = prior.walletId == walletId ? lookup(prior) : null; + if (utxo == null || + utxo.walletId != walletId || + utxo.isBlocked || + utxo.used == true || + !isConfirmed(utxo)) { + throw const ExchangeCoinSelectionException( + "Selected outputs changed. Please select them again.", + clearSelection: true, + ); + } + current.add(utxo); + } + + final selectedValue = current.fold( + BigInt.zero, + (sum, utxo) => sum + BigInt.from(utxo.value), + ); + if (requireSufficientValue && selectedValue < requiredTotal.raw) { + throw const ExchangeCoinSelectionException( + "Selected outputs do not cover the exchange amount and network fee. " + "Please select more outputs.", + clearSelection: false, + ); + } + + return current.map(StandardInput.new).toSet(); +} + +Future estimateExchangeFundingTotalForWallet({ + required Wallet wallet, + required Amount amount, +}) async { + final fees = await wallet.fees; + return estimateExchangeFundingTotal( + amount: amount, + estimateFee: (amount) => wallet.estimateFeeFor(amount, fees.medium), + ); +} + +Future<({Amount requiredTotal, Set? inputs})> +prepareExchangeCoinSelection({ + required String walletId, + required Wallet wallet, + required int currentChainHeight, + required Amount amount, + required Set selected, + bool requireSufficientValue = true, +}) async { + final requiredTotal = await estimateExchangeFundingTotalForWallet( + wallet: wallet, + amount: amount, + ); + + final inputs = validateExchangeCoinSelection( + walletId: walletId, + selected: selected, + requiredTotal: requiredTotal, + lookup: (prior) => MainDB.instance.isar.utxos + .where() + .txidWalletIdVoutEqualTo(prior.txid, walletId, prior.vout) + .findFirstSync(), + isConfirmed: (utxo) => wallet is NamecoinWallet + ? wallet.checkUtxoConfirmed(utxo, currentChainHeight) + : utxo.isConfirmed( + currentChainHeight, + wallet.cryptoCurrency.minConfirms, + wallet.cryptoCurrency.minCoinbaseConfirms, + ), + requireSufficientValue: requireSufficientValue, + ); + return (requiredTotal: requiredTotal, inputs: inputs); +} + +class ExchangeCoinControlRow extends StatelessWidget { + const ExchangeCoinControlRow({ + super.key, + required this.selectedCount, + required this.onPressed, + required this.loading, + required this.padding, + required this.rounded, + }); + + final int selectedCount; + final VoidCallback onPressed; + final bool loading; + final EdgeInsetsGeometry padding; + final bool rounded; + + @override + Widget build(BuildContext context) { + final row = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Coin control", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of(context).extension()!.textSubtitle1, + ), + ), + CustomTextButton( + enabled: !loading, + text: loading + ? "Calculating fee..." + : selectedCount == 0 + ? "Select coins" + : "Selected coins ($selectedCount)", + onTap: loading ? null : onPressed, + ), + ], + ); + + return Padding( + padding: padding, + child: rounded ? RoundedWhiteContainer(child: row) : row, + ); + } +} + +class ExchangeCoinControlSelector extends ConsumerStatefulWidget { + const ExchangeCoinControlSelector({ + super.key, + required this.walletId, + required this.amount, + required this.selected, + required this.onChanged, + required this.padding, + this.rounded = false, + }); + + final String walletId; + final Amount amount; + final Set selected; + final ValueChanged> onChanged; + final EdgeInsetsGeometry padding; + final bool rounded; + + @override + ConsumerState createState() => + _ExchangeCoinControlSelectorState(); +} + +class _ExchangeCoinControlSelectorState + extends ConsumerState { + bool _opening = false; + + Future _open() async { + if (_opening) { + return; + } + setState(() => _opening = true); + + try { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 100)); + } + + final wallet = ref.read(pWallets).getWallet(widget.walletId); + final preparation = await prepareExchangeCoinSelection( + walletId: widget.walletId, + wallet: wallet, + currentChainHeight: ref.read(pWalletChainHeight(widget.walletId)), + amount: widget.amount, + selected: widget.selected, + requireSufficientValue: false, + ); + + if (!mounted) { + return; + } + final result = await Navigator.of(context).pushNamed( + CoinControlView.routeName, + arguments: Tuple4( + widget.walletId, + CoinControlViewType.use, + preparation.requiredTotal, + preparation.inputs?.map((input) => input.utxo).toSet() ?? const {}, + ), + ); + if (mounted && result is Set) { + widget.onChanged(Set.unmodifiable(result)); + } + } on ExchangeCoinSelectionException catch (e, s) { + Logging.instance.w( + "Exchange coin selection changed", + error: e, + stackTrace: s, + ); + if (!mounted) { + return; + } + if (e.clearSelection) { + widget.onChanged(const {}); + } + await showDialog( + context: context, + builder: (_) => + StackOkDialog(title: "Selection changed", message: e.message), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to open exchange coin control", + error: e, + stackTrace: s, + ); + if (mounted) { + await showDialog( + context: context, + builder: (_) => const StackOkDialog( + title: "Coin control unavailable", + message: + "Unable to estimate the network fee. Check your " + "connection and try again.", + ), + ); + } + } finally { + if (mounted) { + setState(() => _opening = false); + } + } + } + + @override + Widget build(BuildContext context) { + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + final preferenceEnabled = ref.watch( + prefsChangeNotifierProvider.select((value) => value.enableCoinControl), + ); + if (!shouldShowExchangeCoinControl( + preferenceEnabled: preferenceEnabled, + walletSupportsCoinControl: wallet is CoinControlInterface, + isFiro: wallet.info.coin is Firo, + )) { + return const SizedBox.shrink(); + } + + return ExchangeCoinControlRow( + selectedCount: widget.selected.length, + onPressed: _open, + loading: _opening, + padding: widget.padding, + rounded: widget.rounded, + ); + } +} diff --git a/test/pages/exchange_view/exchange_coin_control_test.dart b/test/pages/exchange_view/exchange_coin_control_test.dart new file mode 100644 index 0000000000..0ec7b32640 --- /dev/null +++ b/test/pages/exchange_view/exchange_coin_control_test.dart @@ -0,0 +1,308 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/pages/exchange_view/sub_widgets/exchange_coin_control.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; + +void main() { + const walletId = "wallet-1"; + + UTXO utxo({ + String id = "tx-1", + String wallet = walletId, + int value = 1500, + bool blocked = false, + bool? used, + String name = "", + }) => UTXO( + walletId: wallet, + txid: id, + vout: 0, + value: value, + name: name, + isBlocked: blocked, + blockedReason: null, + isCoinbase: false, + blockHash: "block", + blockHeight: 1, + blockTime: 1, + used: used, + ); + + Amount amount(int raw) => + Amount(rawValue: BigInt.from(raw), fractionDigits: 8); + + group("visibility", () { + test("requires preference and coin-control wallet", () { + expect( + shouldShowExchangeCoinControl( + preferenceEnabled: true, + walletSupportsCoinControl: true, + isFiro: false, + ), + isTrue, + ); + expect( + shouldShowExchangeCoinControl( + preferenceEnabled: false, + walletSupportsCoinControl: true, + isFiro: false, + ), + isFalse, + ); + expect( + shouldShowExchangeCoinControl( + preferenceEnabled: true, + walletSupportsCoinControl: false, + isFiro: false, + ), + isFalse, + ); + }); + + test("excludes Firo exchange sends", () { + expect( + shouldShowExchangeCoinControl( + preferenceEnabled: true, + walletSupportsCoinControl: true, + isFiro: true, + ), + isFalse, + ); + }); + }); + + test("funding total includes the estimated network fee", () async { + final total = await estimateExchangeFundingTotal( + amount: amount(1000), + estimateFee: (requested) async { + expect(requested.raw, BigInt.from(1000)); + return amount(250); + }, + ); + + expect(total.raw, BigInt.from(1250)); + }); + + group("selection validation", () { + test("uses fresh database outputs", () { + final prior = utxo(name: "old"); + final current = utxo(name: "current"); + + final result = validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => current, + isConfirmed: (_) => true, + ); + + expect(result, hasLength(1)); + expect(result!.single.utxo.name, "current"); + }); + + test("accepts an empty optional selection", () { + final result = validateExchangeCoinSelection( + walletId: walletId, + selected: const {}, + requiredTotal: amount(1200), + lookup: (_) => fail("lookup should not run"), + isConfirmed: (_) => fail("confirmation should not run"), + ); + + expect(result, isNull); + }); + + for (final invalidCase in { + "missing": () => null, + "wrong wallet": () => utxo(wallet: "wallet-2"), + "blocked": () => utxo(blocked: true), + "spent": () => utxo(used: true), + }.entries) { + test("rejects ${invalidCase.key} outputs", () { + final prior = utxo(); + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => invalidCase.value(), + isConfirmed: (_) => true, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + true, + ), + ), + ); + }); + } + + test("rejects an unconfirmed output", () { + final prior = utxo(); + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => prior, + isConfirmed: (_) => false, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + true, + ), + ), + ); + }); + + test("rejects a total that omits the fee without clearing", () { + final prior = utxo(value: 1000); + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => prior, + isConfirmed: (_) => true, + ), + throwsA( + isA() + .having((error) => error.clearSelection, "clearSelection", false) + .having( + (error) => error.message, + "message", + contains("network fee"), + ), + ), + ); + }); + + test("allows an underfunded selection while adding more outputs", () { + final prior = utxo(value: 1000); + + final result = validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => prior, + isConfirmed: (_) => true, + requireSufficientValue: false, + ); + + expect(result, hasLength(1)); + }); + + test("still rejects stale outputs when sufficiency is not required", () { + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {utxo()}, + requiredTotal: amount(1200), + lookup: (_) => null, + isConfirmed: (_) => true, + requireSufficientValue: false, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + true, + ), + ), + ); + }); + + test("rejects a prior from another wallet without consulting the " + "database", () { + // The lookup is keyed on (txid, walletId, vout), so a stale prior from + // another wallet would silently resolve to this wallet's output at the + // same outpoint if it were not rejected before the query. + var lookups = 0; + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {utxo(wallet: "wallet-2")}, + requiredTotal: amount(1200), + lookup: (_) { + lookups++; + return utxo(); + }, + isConfirmed: (_) => true, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + true, + ), + ), + ); + expect(lookups, 0); + }); + + test("validates every selected output, not only the first", () { + final fresh = utxo(id: "tx-fresh", value: 5000); + final stale = utxo(id: "tx-stale", value: 5000); + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {fresh, stale}, + requiredTotal: amount(1200), + lookup: (prior) => prior.txid == "tx-stale" ? null : prior, + isConfirmed: (_) => true, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + true, + ), + ), + ); + }); + + test("accepts a selection worth exactly the required total", () { + final prior = utxo(value: 1200); + + final result = validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => prior, + isConfirmed: (_) => true, + ); + + expect(result, hasLength(1)); + }); + + test("rejects a selection one atomic unit short", () { + final prior = utxo(value: 1199); + + expect( + () => validateExchangeCoinSelection( + walletId: walletId, + selected: {prior}, + requiredTotal: amount(1200), + lookup: (_) => prior, + isConfirmed: (_) => true, + ), + throwsA( + isA().having( + (error) => error.clearSelection, + "clearSelection", + false, + ), + ), + ); + }); + }); +}