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.

Serhii committed Sep 5, 2025 at 05:26 UTC 2d52d4585f87601977d9de979d4286d8a05a65f3
5 files changed +104 -1
cw_bitcoin/lib/electrum_wallet.dart
+100
@@ -851,6 +851,39 @@ abstract class ElectrumWalletBase
851 throw BitcoinTransactionNoDustException();
852 }
853
854 +
855 + // If there is only one output, and the amount to send is more than the max spendable amount
856 + // then it is actually a send all transaction
857 +
858 + if (outputs.length == 1) {
859 + final maxSpendable = await _maxSpendableNoChangeAmount(
860 + initialOutput: outputs.first,
861 + feeRate: feeRate,
862 + memo: memo,
863 + hasSilentPayment: hasSilentPayment,
864 + coinTypeToSpendFrom: coinTypeToSpendFrom,
865 + );
866 + if (credentialsAmount > maxSpendable) {
867 + throw BitcoinTransactionWrongBalanceException();
868 + }
869 + if (credentialsAmount >= maxSpendable) {
870 + final estimateOutput = [
871 + BitcoinOutput(
872 + address: outputs.first.address,
873 + value: BigInt.zero,
874 + isSilentPayment: outputs.first.isSilentPayment,
875 + )
876 + ];
877 + return estimateSendAllTx(
878 + estimateOutput,
879 + feeRate,
880 + memo: memo,
881 + hasSilentPayment: hasSilentPayment,
882 + coinTypeToSpendFrom: coinTypeToSpendFrom,
883 + );
884 + }
885 + }
886 +
887 final utxoDetails = _createUTXOS(
888 sendAll: false,
889 credentialsAmount: credentialsAmount,
@@ -952,7 +985,38 @@ abstract class ElectrumWalletBase
985 updatedOutputs.removeLast();
986 outputs.removeLast();
987
988 + // If the computed change is negative or below dust:
989 + // - negative: try a no-change tx (recalculate fee without change)
990 + // - non-negative but dust: drop change and add remainder to fee
991 if (amountLeftForChange < 0) {
992 + final tempNoChange = outputs.map((o) => o).toList();
993 + final feeNoChange = await calcFee(
994 + utxos: utxoDetails.utxos,
995 + outputs: tempNoChange,
996 + network: network,
997 + memo: memo,
998 + feeRate: feeRate,
999 + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1000 + vinOutpoints: utxoDetails.vinOutpoints,
1001 + );
1002 + final leftover = utxoDetails.allInputsAmount - credentialsAmount - feeNoChange;
1003 +
1004 + if (leftover >= 0) {
1005 + final finalFee = feeNoChange + leftover; // absorb tiny remainder
1006 + return EstimatedTxResult(
1007 + utxos: utxoDetails.utxos,
1008 + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
1009 + publicKeys: utxoDetails.publicKeys,
1010 + fee: finalFee,
1011 + amount: amount,
1012 + hasChange: false,
1013 + isSendAll: spendingAllCoins,
1014 + memo: memo,
1015 + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
1016 + spendsSilentPayment: utxoDetails.spendsSilentPayment,
1017 + );
1018 + }
1019 +
1020 if (!spendingAllCoins) {
1021 return estimateTxForAmount(
1022 credentialsAmount,
@@ -1016,6 +1080,42 @@ abstract class ElectrumWalletBase
1080 }
1081 }
1082
1083 + Future<int> _maxSpendableNoChangeAmount({
1084 + required BitcoinOutput initialOutput,
1085 + required int feeRate,
1086 + String? memo,
1087 + bool hasSilentPayment = false,
1088 + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
1089 + }) async {
1090 +
1091 + final utxoDetailsAll = _createUTXOS(
1092 + sendAll: true,
1093 + paysToSilentPayment: hasSilentPayment,
1094 + coinTypeToSpendFrom: coinTypeToSpendFrom,
1095 + );
1096 +
1097 + final output = [
1098 + BitcoinOutput(
1099 + address: initialOutput.address,
1100 + value: BigInt.zero,
1101 + isSilentPayment: initialOutput.isSilentPayment,
1102 + )
1103 + ];
1104 +
1105 + final feeNoChange = await calcFee(
1106 + utxos: utxoDetailsAll.utxos,
1107 + outputs: output,
1108 + network: network,
1109 + memo: memo,
1110 + feeRate: feeRate,
1111 + inputPrivKeyInfos: utxoDetailsAll.inputPrivKeyInfos,
1112 + vinOutpoints: utxoDetailsAll.vinOutpoints,
1113 + );
1114 +
1115 + final maxSpendable = utxoDetailsAll.allInputsAmount - feeNoChange;
1116 + return maxSpendable > 0 ? maxSpendable : 0;
1117 + }
1118 +
1119 Future<int> calcFee({
1120 required List<UtxoWithAddress> utxos,
1121 required List<BitcoinBaseOutput> outputs,
lib/exchange/provider/letsexchange_exchange_provider.dart
+1
@@ -213,6 +213,7 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
213 extraId: extraId,
214 userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}',
215 userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}',
216 + isSendAll: isSendAll,
217 );
218 } catch (e) {
219 log(e.toString());
lib/exchange/provider/stealth_ex_exchange_provider.dart
+1
@@ -200,6 +200,7 @@ class StealthExExchangeProvider extends ExchangeProvider {
200 extraId: extraId,
201 userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}',
202 userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}',
203 + isSendAll: isSendAll,
204 );
205 } catch (e) {
206 log(e.toString());
lib/exchange/provider/xoswap_exchange_provider.dart
+1
@@ -260,6 +260,7 @@ class XOSwapExchangeProvider extends ExchangeProvider {
260 extraId: extraId,
261 userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}',
262 userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}',
263 + isSendAll: isSendAll,
264 );
265 } catch (e) {
266 printV(e.toString());
lib/exchange/trade.dart
+1 -1
@@ -119,7 +119,7 @@ class Trade extends HiveObject {
119 bool? isRefund;
120
121 @HiveField(21)
122 - bool? isSendAll;
122 + bool? isSendAll; /// Must be set on createTrade;
123
124 @HiveField(22)
125 String? router;