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..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; @@ -270,6 +278,7 @@ class _Step4ViewState extends ConsumerState { isSpark: wallet is FiroWallet && !firoPublicSend, onCancel: () { wasCancelled = true; + Navigator.of(context).pop(); }, ); }, @@ -287,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 " @@ -303,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", @@ -343,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(); @@ -521,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/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/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, + ), + ), + ); + }); + }); +} 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'), + ), + ); + } +}