From 2d52d4585f87601977d9de979d4286d8a05a65f3 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 5 Sep 2025 05:26:53 +0300 Subject: [PATCH] CW-1200-Dust-amount-error-when-sending-All (#2495) * Add isSendAll parameter to exchange provider trade creation * Improve send-all transaction handling and fee calculation change. --- cw_bitcoin/lib/electrum_wallet.dart | 100 ++++++++++++++++++ .../letsexchange_exchange_provider.dart | 1 + .../stealth_ex_exchange_provider.dart | 1 + .../provider/xoswap_exchange_provider.dart | 1 + lib/exchange/trade.dart | 2 +- 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index c539eebcd..5427e824f 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -851,6 +851,39 @@ abstract class ElectrumWalletBase throw BitcoinTransactionNoDustException(); } + + // If there is only one output, and the amount to send is more than the max spendable amount + // then it is actually a send all transaction + + if (outputs.length == 1) { + final maxSpendable = await _maxSpendableNoChangeAmount( + initialOutput: outputs.first, + feeRate: feeRate, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + if (credentialsAmount > maxSpendable) { + throw BitcoinTransactionWrongBalanceException(); + } + if (credentialsAmount >= maxSpendable) { + final estimateOutput = [ + BitcoinOutput( + address: outputs.first.address, + value: BigInt.zero, + isSilentPayment: outputs.first.isSilentPayment, + ) + ]; + return estimateSendAllTx( + estimateOutput, + feeRate, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + } + final utxoDetails = _createUTXOS( sendAll: false, credentialsAmount: credentialsAmount, @@ -952,7 +985,38 @@ abstract class ElectrumWalletBase updatedOutputs.removeLast(); outputs.removeLast(); + // If the computed change is negative or below dust: + // - negative: try a no-change tx (recalculate fee without change) + // - non-negative but dust: drop change and add remainder to fee if (amountLeftForChange < 0) { + final tempNoChange = outputs.map((o) => o).toList(); + final feeNoChange = await calcFee( + utxos: utxoDetails.utxos, + outputs: tempNoChange, + network: network, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, + ); + final leftover = utxoDetails.allInputsAmount - credentialsAmount - feeNoChange; + + if (leftover >= 0) { + final finalFee = feeNoChange + leftover; // absorb tiny remainder + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: finalFee, + amount: amount, + hasChange: false, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } + if (!spendingAllCoins) { return estimateTxForAmount( credentialsAmount, @@ -1016,6 +1080,42 @@ abstract class ElectrumWalletBase } } + Future _maxSpendableNoChangeAmount({ + required BitcoinOutput initialOutput, + required int feeRate, + String? memo, + bool hasSilentPayment = false, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) async { + + final utxoDetailsAll = _createUTXOS( + sendAll: true, + paysToSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + + final output = [ + BitcoinOutput( + address: initialOutput.address, + value: BigInt.zero, + isSilentPayment: initialOutput.isSilentPayment, + ) + ]; + + final feeNoChange = await calcFee( + utxos: utxoDetailsAll.utxos, + outputs: output, + network: network, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetailsAll.inputPrivKeyInfos, + vinOutpoints: utxoDetailsAll.vinOutpoints, + ); + + final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange; + return maxSpendable > 0 ? maxSpendable : 0; + } + Future calcFee({ required List utxos, required List outputs, diff --git a/lib/exchange/provider/letsexchange_exchange_provider.dart b/lib/exchange/provider/letsexchange_exchange_provider.dart index 7b6665303..ee54c6430 100644 --- a/lib/exchange/provider/letsexchange_exchange_provider.dart +++ b/lib/exchange/provider/letsexchange_exchange_provider.dart @@ -213,6 +213,7 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { extraId: extraId, userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', + isSendAll: isSendAll, ); } catch (e) { log(e.toString()); diff --git a/lib/exchange/provider/stealth_ex_exchange_provider.dart b/lib/exchange/provider/stealth_ex_exchange_provider.dart index 47f09ba6b..980f4b217 100644 --- a/lib/exchange/provider/stealth_ex_exchange_provider.dart +++ b/lib/exchange/provider/stealth_ex_exchange_provider.dart @@ -200,6 +200,7 @@ class StealthExExchangeProvider extends ExchangeProvider { extraId: extraId, userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', + isSendAll: isSendAll, ); } catch (e) { log(e.toString()); diff --git a/lib/exchange/provider/xoswap_exchange_provider.dart b/lib/exchange/provider/xoswap_exchange_provider.dart index e4c775334..3ef36996f 100644 --- a/lib/exchange/provider/xoswap_exchange_provider.dart +++ b/lib/exchange/provider/xoswap_exchange_provider.dart @@ -260,6 +260,7 @@ class XOSwapExchangeProvider extends ExchangeProvider { extraId: extraId, userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', + isSendAll: isSendAll, ); } catch (e) { printV(e.toString()); diff --git a/lib/exchange/trade.dart b/lib/exchange/trade.dart index 70cf39eb7..b22a64f8a 100644 --- a/lib/exchange/trade.dart +++ b/lib/exchange/trade.dart @@ -119,7 +119,7 @@ class Trade extends HiveObject { bool? isRefund; @HiveField(21) - bool? isSendAll; + bool? isSendAll; /// Must be set on createTrade; @HiveField(22) String? router;