From 10354b22efaf80af5211768100eaff2ed0142a18 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 28 Aug 2024 10:18:26 -0600 Subject: [PATCH] add ability to "send all" selected UTXOs when using coin control --- lib/pages/send_view/send_view.dart | 98 +++++++++---------- .../wallet_view/sub_widgets/desktop_send.dart | 49 +++++++--- .../electrumx_interface.dart | 27 +++-- .../spark_interface.dart | 69 +++++++------ 4 files changed, 140 insertions(+), 103 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 004b02af1..cd7d5f01a 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -881,6 +881,48 @@ class _SendViewState extends ConsumerState { } } + String _getSendAllTitle(bool showCoinControl, Set selectedUTXOs) { + if (showCoinControl && selectedUTXOs.isNotEmpty) { + return "Send all selected"; + } + + return "Send all ${coin.ticker}"; + } + + Amount _selectedUtxosAmount(Set utxos) => Amount( + rawValue: + utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e), + fractionDigits: ref.read(pWalletCoin(walletId)).fractionDigits, + ); + + Future _sendAllTapped(bool showCoinControl) async { + final Amount amount; + + if (showCoinControl && selectedUTXOs.isNotEmpty) { + amount = _selectedUtxosAmount(selectedUTXOs); + } else if (isFiro) { + switch (ref.read(publicPrivateBalanceStateProvider.state).state) { + case FiroType.public: + amount = ref.read(pWalletBalance(walletId)).spendable; + break; + case FiroType.lelantus: + amount = ref.read(pWalletBalanceSecondary(walletId)).spendable; + break; + case FiroType.spark: + amount = ref.read(pWalletBalanceTertiary(walletId)).spendable; + break; + } + } else { + amount = ref.read(pWalletBalance(walletId)).spendable; + } + + cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( + amount, + withUnitName: false, + ); + _cryptoAmountChanged(); + } + bool get isPaynymSend => widget.accountLite != null; bool isCustomFee = false; @@ -1772,59 +1814,9 @@ class _SendViewState extends ConsumerState { ), if (coin is! Ethereum && coin is! Tezos) CustomTextButton( - text: "Send all ${coin.ticker}", - onTap: () async { - if (isFiro) { - final Amount amount; - switch (ref - .read( - publicPrivateBalanceStateProvider - .state, - ) - .state) { - case FiroType.public: - amount = ref - .read(pWalletBalance(walletId)) - .spendable; - break; - case FiroType.lelantus: - amount = ref - .read( - pWalletBalanceSecondary( - walletId, - ), - ) - .spendable; - break; - case FiroType.spark: - amount = ref - .read( - pWalletBalanceTertiary( - walletId, - ), - ) - .spendable; - break; - } - - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format( - amount, - withUnitName: false, - ); - } else { - cryptoAmountController.text = ref - .read(pAmountFormatter(coin)) - .format( - ref - .read(pWalletBalance(walletId)) - .spendable, - withUnitName: false, - ); - } - _cryptoAmountChanged(); - }, + text: _getSendAllTitle( + showCoinControl, selectedUTXOs), + onTap: () => _sendAllTapped(showCoinControl), ), ], ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index b09b57ef0..ce3ab2b1f 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -18,6 +18,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../../models/isar/models/contact_entry.dart'; import '../../../../models/paynym/paynym_account_lite.dart'; import '../../../../models/send_view_auto_fill_data.dart'; @@ -932,30 +933,45 @@ class _DesktopSendState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } - Future sendAllTapped() async { - final info = ref.read(pWalletInfo(walletId)); + String _getSendAllTitle(bool showCoinControl, Set selectedUTXOs) { + if (showCoinControl && selectedUTXOs.isNotEmpty) { + return "Send all selected"; + } + + return "Send all ${coin.ticker}"; + } + + Amount _selectedUtxosAmount(Set utxos) => Amount( + rawValue: + utxos.map((e) => BigInt.from(e.value)).reduce((v, e) => v += e), + fractionDigits: ref.read(pWalletCoin(walletId)).fractionDigits, + ); - if (coin is Firo) { + Future _sendAllTapped(bool showCoinControl) async { + final Amount amount; + + if (showCoinControl && ref.read(desktopUseUTXOs).isNotEmpty) { + amount = _selectedUtxosAmount(ref.read(desktopUseUTXOs)); + } else if (coin is Firo) { switch (ref.read(publicPrivateBalanceStateProvider.state).state) { case FiroType.public: - cryptoAmountController.text = info.cachedBalance.spendable.decimal - .toStringAsFixed(coin.fractionDigits); + amount = ref.read(pWalletBalance(walletId)).spendable; break; case FiroType.lelantus: - cryptoAmountController.text = info - .cachedBalanceSecondary.spendable.decimal - .toStringAsFixed(coin.fractionDigits); + amount = ref.read(pWalletBalanceSecondary(walletId)).spendable; break; case FiroType.spark: - cryptoAmountController.text = info - .cachedBalanceTertiary.spendable.decimal - .toStringAsFixed(coin.fractionDigits); + amount = ref.read(pWalletBalanceTertiary(walletId)).spendable; break; } } else { - cryptoAmountController.text = info.cachedBalance.spendable.decimal - .toStringAsFixed(coin.fractionDigits); + amount = ref.read(pWalletBalance(walletId)).spendable; } + + cryptoAmountController.text = ref.read(pAmountFormatter(coin)).format( + amount, + withUnitName: false, + ); } void _showDesktopCoinControl() async { @@ -1280,8 +1296,11 @@ class _DesktopSendState extends ConsumerState { ), if (coin is! Ethereum && coin is! Tezos) CustomTextButton( - text: "Send all ${coin.ticker}", - onTap: sendAllTapped, + text: _getSendAllTitle( + showCoinControl, + ref.watch(desktopUseUTXOs), + ), + onTap: () => _sendAllTapped(showCoinControl), ), ], ), diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index c31f7cd4a..eda5b0baa 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -105,6 +105,7 @@ mixin ElectrumXInterface required TxData txData, required bool coinControl, required bool isSendAll, + required bool isSendAllCoinControlUtxos, int additionalOutputs = 0, List? utxos, }) async { @@ -144,7 +145,9 @@ mixin ElectrumXInterface if (spendableSatoshiValue < satoshiAmountToSend) { throw Exception("Insufficient balance"); - } else if (spendableSatoshiValue == satoshiAmountToSend && !isSendAll) { + } else if (spendableSatoshiValue == satoshiAmountToSend && + !isSendAll && + !isSendAllCoinControlUtxos) { throw Exception("Insufficient balance to pay transaction fee"); } @@ -220,7 +223,8 @@ mixin ElectrumXInterface // gather required signing data final utxoSigningData = await fetchBuildTxData(utxoObjectsToUse); - if (isSendAll) { + if (isSendAll || isSendAllCoinControlUtxos) { + assert(satoshiAmountToSend == satoshisBeingUsed); return await _sendAllBuilder( txData: txData, recipientAddress: recipientAddress, @@ -357,6 +361,7 @@ mixin ElectrumXInterface additionalOutputs: additionalOutputs + 1, utxos: utxos, coinControl: coinControl, + isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, ); } throw Exception("Insufficient balance to pay transaction fee"); @@ -1681,11 +1686,23 @@ mixin ElectrumXInterface @override Future prepareSend({required TxData txData}) async { try { + if (txData.amount == null) { + throw Exception("No recipients in attempted transaction!"); + } + final feeRateType = txData.feeRateType; final customSatsPerVByte = txData.satsPerVByte; final feeRateAmount = txData.feeRateAmount; final utxos = txData.utxos; + final bool coinControl = utxos != null; + + final isSendAllCoinControlUtxos = coinControl && + txData.amount!.raw == + utxos + .map((e) => e.value) + .fold(BigInt.zero, (p, e) => p + BigInt.from(e)); + if (customSatsPerVByte != null) { // check for send all bool isSendAll = false; @@ -1694,8 +1711,6 @@ mixin ElectrumXInterface isSendAll = true; } - final bool coinControl = utxos != null; - if (coinControl && this is CpfpInterface && txData.amount == @@ -1709,6 +1724,7 @@ mixin ElectrumXInterface isSendAll: isSendAll, utxos: utxos?.toList(), coinControl: coinControl, + isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, ); Logging.instance @@ -1750,8 +1766,6 @@ mixin ElectrumXInterface isSendAll = true; } - final bool coinControl = utxos != null; - final result = await coinSelection( txData: txData.copyWith( feeRateAmount: rate, @@ -1759,6 +1773,7 @@ mixin ElectrumXInterface isSendAll: isSendAll, utxos: utxos?.toList(), coinControl: coinControl, + isSendAllCoinControlUtxos: isSendAllCoinControlUtxos, ); Logging.instance.log("prepare send: $result", level: LogLevel.Info); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 30b753be2..c0843246c 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -23,6 +23,7 @@ import '../../isar/models/spark_coin.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_hd_wallet.dart'; +import 'cpfp_interface.dart'; import 'electrumx_interface.dart'; const kDefaultSparkIndex = 1; @@ -1809,36 +1810,44 @@ mixin SparkInterface throw Exception("Attempted send of zero amount"); } - final currentHeight = await chainHeight; + final utxos = txData.utxos; + final bool coinControl = utxos != null; - // coin control not enabled for firo currently so we can ignore this - // final utxosToUse = txData.utxos?.toList() ?? await mainDB.isar.utxos - // .where() - // .walletIdEqualTo(walletId) - // .filter() - // .isBlockedEqualTo(false) - // .and() - // .group((q) => q.usedEqualTo(false).or().usedIsNull()) - // .and() - // .valueGreaterThan(0) - // .findAll(); - final spendableUtxos = await mainDB.isar.utxos - .where() - .walletIdEqualTo(walletId) - .filter() - .isBlockedEqualTo(false) - .and() - .group((q) => q.usedEqualTo(false).or().usedIsNull()) - .and() - .valueGreaterThan(0) - .findAll(); + final utxosTotal = coinControl + ? utxos + .map((e) => e.value) + .fold(BigInt.zero, (p, e) => p + BigInt.from(e)) + : null; - spendableUtxos.removeWhere( - (e) => !e.isConfirmed( - currentHeight, - cryptoCurrency.minConfirms, - ), - ); + if (coinControl && utxosTotal! < total) { + throw Exception("Insufficient selected UTXOs!"); + } + + final isSendAllCoinControlUtxos = coinControl && total == utxosTotal; + + final currentHeight = await chainHeight; + + final availableOutputs = utxos?.toList() ?? + await mainDB.isar.utxos + .where() + .walletIdEqualTo(walletId) + .filter() + .isBlockedEqualTo(false) + .and() + .group((q) => q.usedEqualTo(false).or().usedIsNull()) + .and() + .valueGreaterThan(0) + .findAll(); + + final canCPFP = this is CpfpInterface && coinControl; + + final spendableUtxos = availableOutputs + .where( + (e) => + canCPFP || + e.isConfirmed(currentHeight, cryptoCurrency.minConfirms), + ) + .toList(); if (spendableUtxos.isEmpty) { throw Exception("No available UTXOs found to anonymize"); @@ -1849,7 +1858,9 @@ mixin SparkInterface .reduce((value, element) => value += element); final bool subtractFeeFromAmount; - if (available < total) { + if (isSendAllCoinControlUtxos) { + subtractFeeFromAmount = true; + } else if (available < total) { throw Exception("Insufficient balance"); } else if (available == total) { subtractFeeFromAmount = true;