refactor electrumx based coin transaction building and fee calculation
What changed, and why it matters
This commit refactors how Stack Wallet builds Bitcoin-like transactions and calculates fees. It introduces a new fee planner that handles three modes: normal fixed-amount sends, 'subtract fee from amount' (the recipient gets slightly less so the sender doesn't need extra funds for the fee), and sweep/all sends. The change consolidates previously scattered fee logic into one place and adds unit tests. It is a code-quality and feature refactor; there is no direct evidence in the commit that it fixes a known security vulnerability, but any change to transaction-fee logic can affect whether users accidentally overpay, underpay, or create invalid transactions.
Treat this as a high-risk refactor of financial logic. Review the new fee planner against edge cases: empty wallets, exact-dust amounts, multi-input size growth, MWEB peg-in/peg-out, and coin-control sends. Run the new unit tests and add integration tests for each supported coin. Monitor for user reports of unexpected fees, failed sends, or incorrect change outputs. If this commit is being backported, verify it does not change behavior for existing transaction types beyond the intended refactor.
Security signals we found
Refactor of transaction-fee calculation logic for ElectrumX-based coins
New 'subtract fee from amount' mode that reduces the recipient's output to cover fees
Iterative fee planner that re-measures vSize after building the transaction
Dust-limit checks before building outputs
Minimum fee floor logic to prevent underpaying
Insufficient-funds exception with required-fee hint for retry logic
Evidence from the diff
The patch adds electrum_fee_planner.dart, which implements planElectrumFee<T>() with modes fixedAmount, subtractFeeFromAmount, and sweep. It iteratively builds a transaction, measures vSize, and ensures the paid fee is at least the greater of the rate-based fee, 1 sat/vByte, and an optional minimumFeeAmount. The electrumx_interface.dart coin-selection code is heavily refactored to use this planner, replacing duplicated vSize/fee estimation paths. A new subtractFeeFromAmount flag is added to TxData. Unit tests cover fee floors, per-kB rounding, dust handling, and insufficient-funds exceptions.
Changed components
lib/wallets/models/tx_data.dartlib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dartlib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.darttest/wallets/electrum_fee_planner_test.darttest/wallets/tx_data_test.dartInspect captured patch +465 / −372
diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart
index 744d848..1c8f81c 100644
--- a/lib/wallets/models/tx_data.dart
+++ b/lib/wallets/models/tx_data.dart
@@ -105,6 +105,7 @@ class TxData {
final TransactionV2? tempTx;
final bool ignoreCachedBalanceChecks;
+ final bool subtractFeeFromAmount;
// Namecoin Name related
final NameOpState? opNameState;
@@ -150,6 +151,7 @@ class TxData {
this.usedSparkCoins,
this.tempTx,
this.ignoreCachedBalanceChecks = false,
+ this.subtractFeeFromAmount = false,
this.opNameState,
this.sparkNameInfo,
this.vExtraData,
@@ -298,6 +300,7 @@ class TxData {
List<SparkCoin>? usedSparkCoins,
TransactionV2? tempTx,
bool? ignoreCachedBalanceChecks,
+ bool? subtractFeeFromAmount,
NameOpState? opNameState,
({
String additionalInfo,
@@ -346,6 +349,8 @@ class TxData {
tempTx: tempTx ?? this.tempTx,
ignoreCachedBalanceChecks:
ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks,
+ subtractFeeFromAmount:
+ subtractFeeFromAmount ?? this.subtractFeeFromAmount,
opNameState: opNameState ?? this.opNameState,
sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo,
vExtraData: vExtraData ?? this.vExtraData,
@@ -390,6 +395,7 @@ class TxData {
'otherData: $otherData, '
'tempTx: $tempTx, '
'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, '
+ 'subtractFeeFromAmount: $subtractFeeFromAmount, '
'opNameState: $opNameState, '
'sparkNameInfo: $sparkNameInfo, '
'vExtraData: ${vExtraData?.toHex}, '
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart
new file mode 100644
index 0000000..55f38da
--- /dev/null
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart
@@ -0,0 +1,100 @@
+enum ElectrumFeeMode { fixedAmount, subtractFeeFromAmount, sweep }
+
+final class ElectrumFeeInsufficientFunds implements Exception {
+ /// The fee the rejected transaction needed to pay. Callers retrying with
+ /// more funds should select inputs covering at least the recipient amount
+ /// plus this fee.
+ final BigInt requiredFee;
+
+ const ElectrumFeeInsufficientFunds({required this.requiredFee});
+}
+
+typedef ElectrumFeeTransactionBuilder<T> =
+ Future<({T transaction, int vSize})> Function({
+ required BigInt recipientAmount,
+ BigInt? changeAmount,
+ });
+
+final class ElectrumFeeResult<T> {
+ final T transaction;
+ final BigInt fee;
+
+ const ElectrumFeeResult({required this.transaction, required this.fee});
+}
+
+/// The fee paid is always at least [minimumFeeAmount] (when non-null), the
+/// rate-based fee for the measured vSize, and one sat per vByte, whichever is
+/// greatest. [minimumFeeAmount] is a floor, not an exact override.
+Future<ElectrumFeeResult<T>> planElectrumFee<T>({
+ required ElectrumFeeMode mode,
+ required BigInt inputTotal,
+ required BigInt recipientAmount,
+ required BigInt dustLimit,
+ required int? satsPerVByte,
+ required BigInt feeRatePerKB,
+ required BigInt? minimumFeeAmount,
+ required ElectrumFeeTransactionBuilder<T> build,
+}) async {
+ if (mode != ElectrumFeeMode.sweep && recipientAmount < dustLimit) {
+ throw Exception(
+ "Recipient amount ($recipientAmount) is below dust limit ($dustLimit)",
+ );
+ }
+
+ BigInt requiredFeeFor(int vSize) {
+ final BigInt rateFee;
+ if (satsPerVByte != null) {
+ rateFee = BigInt.from(satsPerVByte * vSize);
+ } else {
+ final kb = BigInt.from(1000);
+ rateFee = (feeRatePerKB * BigInt.from(vSize) + kb - BigInt.one) ~/ kb;
+ }
+
+ final vSizeFloor = BigInt.from(vSize);
+ final minimumFloor = minimumFeeAmount ?? BigInt.zero;
+ final feeFloor = rateFee > minimumFloor ? rateFee : minimumFloor;
+ return feeFloor > vSizeFloor ? feeFloor : vSizeFloor;
+ }
+
+ final selectedSurplus = inputTotal - recipientAmount;
+ final subDustSurplus =
+ mode == .subtractFeeFromAmount &&
+ selectedSurplus > BigInt.zero &&
+ selectedSurplus < dustLimit
+ ? selectedSurplus
+ : BigInt.zero;
+ BigInt fee = -subDustSurplus;
+ while (true) {
+ final amountToSend = switch (mode) {
+ .fixedAmount => recipientAmount,
+ .subtractFeeFromAmount => recipientAmount - fee,
+ .sweep => inputTotal - fee,
+ };
+ if (amountToSend < dustLimit) {
+ throw Exception("Estimated fee ($fee sats) leaves no spendable amount!");
+ }
+
+ final possibleChange = switch (mode) {
+ .fixedAmount => inputTotal - recipientAmount - fee,
+ .subtractFeeFromAmount => inputTotal - recipientAmount,
+ .sweep => null,
+ };
+ final changeAmount = possibleChange != null && possibleChange >= dustLimit
+ ? possibleChange
+ : null;
+
+ final built = await build(
+ recipientAmount: amountToSend,
+ changeAmount: changeAmount,
+ );
+ final feePaid = inputTotal - amountToSend - (changeAmount ?? BigInt.zero);
+ final requiredFee = requiredFeeFor(built.vSize);
+ if (feePaid >= requiredFee) {
+ return ElectrumFeeResult(transaction: built.transaction, fee: feePaid);
+ }
+ if (mode == ElectrumFeeMode.fixedAmount && possibleChange! < dustLimit) {
+ throw ElectrumFeeInsufficientFunds(requiredFee: requiredFee);
+ }
+ fee += requiredFee - feePaid;
+ }
+}
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
index 100aa9f..9a2f2f3 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -34,6 +34,7 @@ import '../impl/firo_wallet.dart';
import '../impl/peercoin_wallet.dart';
import '../intermediate/bip39_hd_wallet.dart';
import 'cpfp_interface.dart';
+import 'electrum_fee_planner.dart';
import 'mweb_interface.dart';
import 'paynym_interface.dart';
import 'rbf_interface.dart';
@@ -123,14 +124,18 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
required bool coinControl,
required bool isSendAll,
required bool isSendAllCoinControlUtxos,
- int additionalOutputs = 0,
List<BaseInput>? utxos,
- BigInt? overrideFeeAmount,
+ BigInt? minimumFeeAmount,
}) async {
Logging.instance.d("Starting coinSelection ----------");
// TODO: multiple recipients one day
assert(txData.recipients!.length == 1);
+ if (txData.recipients!.length != 1) {
+ throw Exception(
+ "Transactions with more than one recipient are not supported",
+ );
+ }
if (coinControl && utxos == null) {
throw Exception("Coin control used where utxos is null!");
@@ -199,7 +204,8 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
throw Exception("Insufficient balance");
} else if (spendableSatoshiValue == satoshiAmountToSend &&
!isSendAll &&
- !isSendAllCoinControlUtxos) {
+ !isSendAllCoinControlUtxos &&
+ !txData.subtractFeeFromAmount) {
throw Exception("Insufficient balance to pay transaction fee");
}
@@ -224,411 +230,193 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend");
// Use coinlib CoinSelection algorithms except for
- // "coinControl", "SendAll", "MWEB", "overrideFeeAmount",
+ // "coinControl", "SendAll", "MWEB", "minimumFeeAmount",
+ // and "subtractFeeFromAmount"
// because they do not need a selection or
// do not meet the requirements for the algorithms
final bool useOptimalSelection =
!coinControl &&
!isSendAll &&
!isSendAllCoinControlUtxos &&
- overrideFeeAmount == null &&
+ !txData.subtractFeeFromAmount &&
+ minimumFeeAmount == null &&
txData.type != TxType.mweb &&
txData.type != TxType.mwebPegOut &&
txData.type != TxType.mwebPegIn;
if (useOptimalSelection) {
- return await _optimalCoinSelection(
- txData: txData,
- spendableOutputs: spendableOutputs.whereType<StandardInput>().toList(),
- recipientAddress: recipientAddress,
- satoshiAmountToSend: satoshiAmountToSend,
- satsPerVByte: satsPerVByte,
- feeRatePerKB: selectedTxFeeRate,
- changeAddress: await changeAddress(),
- );
+ try {
+ return await _optimalCoinSelection(
+ txData: txData,
+ spendableOutputs: spendableOutputs
+ .whereType<StandardInput>()
+ .toList(),
+ recipientAddress: recipientAddress,
+ satoshiAmountToSend: satoshiAmountToSend,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: selectedTxFeeRate,
+ changeAddress: await changeAddress(),
+ );
+ } on ElectrumFeeInsufficientFunds catch (e) {
+ Logging.instance.w(
+ "Optimal coin selection could not cover the measured transaction "
+ "fee (${e.requiredFee} sats). Falling back to previous/old input "
+ "selection.",
+ );
+ }
}
BigInt satoshisBeingUsed = BigInt.zero;
int inputsBeingConsumed = 0;
final List<BaseInput> utxoObjectsToUse = [];
+ final List<BaseInput> inputsWithKeys = [];
- if (!coinControl) {
- for (
- var i = 0;
- satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length;
- i++
- ) {
- utxoObjectsToUse.add(spendableOutputs[i]);
- satoshisBeingUsed += spendableOutputs[i].value;
- inputsBeingConsumed += 1;
- }
- for (
- int i = 0;
- i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length;
- i++
- ) {
- utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]);
- satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value;
- inputsBeingConsumed += 1;
+ /// Consume spendable outputs until [target] is covered (all of them when
+ /// using coin control), gathering signing data for newly added inputs.
+ Future<void> consumeInputsFor(BigInt target) async {
+ final start = inputsBeingConsumed;
+ if (coinControl) {
+ satoshisBeingUsed = spendableSatoshiValue;
+ utxoObjectsToUse.addAll(spendableOutputs);
+ inputsBeingConsumed = spendableOutputs.length;
+ } else {
+ while (satoshisBeingUsed < target &&
+ inputsBeingConsumed < spendableOutputs.length) {
+ utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]);
+ satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value;
+ inputsBeingConsumed += 1;
+ }
}
- } else {
- satoshisBeingUsed = spendableSatoshiValue;
- utxoObjectsToUse.addAll(spendableOutputs);
- inputsBeingConsumed = spendableOutputs.length;
- }
-
- Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
- Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed");
- Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse');
+ inputsWithKeys.addAll(
+ await addSigningKeys(utxoObjectsToUse.sublist(start)),
+ );
- // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray
- final List<String> recipientsArray = [recipientAddress];
- final List<BigInt> recipientsAmtArray = [satoshiAmountToSend];
+ Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
+ Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed");
+ Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse');
+ }
- // gather required signing data
- final inputsWithKeys = await addSigningKeys(utxoObjectsToUse);
+ await consumeInputsFor(satoshiAmountToSend);
if (isSendAll || isSendAllCoinControlUtxos) {
- if ((overrideFeeAmount ?? BigInt.zero) + satoshiAmountToSend !=
+ if ((minimumFeeAmount ?? BigInt.zero) + satoshiAmountToSend !=
satoshisBeingUsed) {
Logging.instance.d("txData.type: ${txData.type}");
Logging.instance.d("isSendAll: $isSendAll");
Logging.instance.d(
"isSendAllCoinControlUtxos: $isSendAllCoinControlUtxos",
);
- Logging.instance.d("overrideFeeAmount: $overrideFeeAmount");
+ Logging.instance.d("minimumFeeAmount: $minimumFeeAmount");
Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend");
Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
// hack check
if (!(txData.type == TxType.mwebPegIn ||
- (txData.type.isMweb() && overrideFeeAmount != null))) {
+ (txData.type.isMweb() && minimumFeeAmount != null))) {
throw Exception(
"Something happened that should never actually happen. "
"Please report this error to the developers.",
);
}
}
- return await _sendAllBuilder(
+ return await _buildTransactionPayingFee(
txData: txData,
- recipientAddress: recipientAddress,
- satoshisBeingUsed: satoshisBeingUsed,
inputsWithKeys: inputsWithKeys,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: satoshisBeingUsed,
satsPerVByte: satsPerVByte,
feeRatePerKB: selectedTxFeeRate,
- overrideFeeAmount: overrideFeeAmount,
+ minimumFeeAmount: minimumFeeAmount,
+ isSweep: true,
+ nextChangeAddress: () async => (await changeAddress()).value,
);
}
- final int vSizeForOneOutput;
- try {
- vSizeForOneOutput = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshisBeingUsed - BigInt.one],
- ),
- ),
- )).vSize!;
- } catch (e, s) {
- Logging.instance.e("vSizeForOneOutput: $e", error: e, stackTrace: s);
- rethrow;
- }
-
- final int vSizeForTwoOutPuts;
-
- BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b;
-
- try {
- vSizeForTwoOutPuts = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress, (await changeAddress()).value],
- [
- satoshiAmountToSend,
- maxBI(
- BigInt.zero,
- satoshisBeingUsed - (satoshiAmountToSend + BigInt.one),
- ),
- ],
- ),
- ),
- )).vSize!;
- } catch (e, s) {
- Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s);
- rethrow;
- }
-
- // Assume 1 output, only for recipient and no change
- final feeForOneOutput =
- overrideFeeAmount ??
- BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForOneOutput)
- : estimateTxFee(
- vSize: vSizeForOneOutput,
- feeRatePerKB: selectedTxFeeRate,
- ),
- );
- // Assume 2 outputs, one for recipient and one for change
- final feeForTwoOutputs =
- overrideFeeAmount ??
- BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForTwoOutPuts)
- : estimateTxFee(
- vSize: vSizeForTwoOutPuts,
- feeRatePerKB: selectedTxFeeRate,
- ),
- );
-
- Logging.instance.d("feeForTwoOutputs: $feeForTwoOutputs");
- Logging.instance.d("feeForOneOutput: $feeForOneOutput");
-
- final difference = satoshisBeingUsed - satoshiAmountToSend;
-
- Future<TxData> singleOutputTxn() async {
- Logging.instance.d('Input size: $satoshisBeingUsed');
- Logging.instance.d('Recipient output size: $satoshiAmountToSend');
- Logging.instance.d('Fee being paid: $difference sats');
- Logging.instance.d('Estimated fee: $feeForOneOutput');
- final txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- ),
- );
- return txnData.copyWith(
- fee: Amount(
- rawValue: feeForOneOutput,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: inputsWithKeys,
- );
- }
-
- // no change output required
- if (difference == feeForOneOutput) {
- Logging.instance.d('1 output in tx');
- return await singleOutputTxn();
- } else if (difference < feeForOneOutput) {
- Logging.instance.w(
- 'Cannot pay tx fee - checking for more outputs and trying again',
- );
- // try adding more outputs
- if (spendableOutputs.length > inputsBeingConsumed) {
- return coinSelection(
+ while (true) {
+ try {
+ return await _buildTransactionPayingFee(
txData: txData,
- isSendAll: isSendAll,
- additionalOutputs: additionalOutputs + 1,
- utxos: utxos,
- coinControl: coinControl,
- isSendAllCoinControlUtxos: isSendAllCoinControlUtxos,
- overrideFeeAmount: overrideFeeAmount,
+ inputsWithKeys: inputsWithKeys,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: satoshisBeingUsed,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: selectedTxFeeRate,
+ minimumFeeAmount: minimumFeeAmount,
+ isSweep: false,
+ nextChangeAddress: () async {
+ if (!(txData.type == TxType.mweb ||
+ txData.type == TxType.mwebPegOut)) {
+ await checkChangeAddressForTransactions();
+ }
+ return (await changeAddress()).value;
+ },
);
- }
- throw Exception("Insufficient balance to pay transaction fee");
- } else {
- if (difference > (feeForOneOutput + cryptoCurrency.dustLimit.raw)) {
- final changeOutputSize = difference - feeForTwoOutputs;
- // check if possible to add the change output
- if (changeOutputSize > cryptoCurrency.dustLimit.raw &&
- difference - changeOutputSize == feeForTwoOutputs) {
- if (!(txData.type == TxType.mweb ||
- txData.type == TxType.mwebPegOut)) {
- // generate new change address if current change address has been used
- await checkChangeAddressForTransactions();
- }
- final newChangeAddress = await changeAddress();
-
- BigInt feeBeingPaid = difference - changeOutputSize;
-
- // add change output
- recipientsArray.add(newChangeAddress.value);
- recipientsAmtArray.add(changeOutputSize);
-
- Logging.instance.d('2 outputs in tx');
- Logging.instance.d('Input size: $satoshisBeingUsed');
- Logging.instance.d('Recipient output size: $satoshiAmountToSend');
- Logging.instance.d('Change Output Size: $changeOutputSize');
- Logging.instance.d('Difference (fee being paid): $feeBeingPaid sats');
- Logging.instance.d('Estimated fee: $feeForTwoOutputs');
-
- TxData txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: inputsWithKeys,
- ),
- );
-
- // make sure minimum fee is accurate if that is being used
- if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) {
- final changeOutputSize = difference - BigInt.from(txnData.vSize!);
- feeBeingPaid = difference - changeOutputSize;
- recipientsAmtArray.removeLast();
- recipientsAmtArray.add(changeOutputSize);
-
- Logging.instance.d('Adjusted Input size: $satoshisBeingUsed');
- Logging.instance.d(
- 'Adjusted Recipient output size: $satoshiAmountToSend',
- );
- Logging.instance.d(
- 'Adjusted Change Output Size: $changeOutputSize',
- );
- Logging.instance.d(
- 'Adjusted Difference (fee being paid): $feeBeingPaid sats',
- );
- Logging.instance.d('Adjusted Estimated fee: $feeForTwoOutputs');
-
- txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: inputsWithKeys,
- ),
- );
- }
-
- return txnData.copyWith(
- fee: Amount(
- rawValue: feeBeingPaid,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: inputsWithKeys,
- );
- } else {
- // Something went wrong here. It either overshot or undershot the estimated fee amount or the changeOutputSize
- // is smaller than or equal to cryptoCurrency.dustLimit. Revert to single output transaction.
- Logging.instance.d('Reverting to 1 output in tx');
-
- return await singleOutputTxn();
+ } on ElectrumFeeInsufficientFunds catch (e) {
+ if (coinControl || inputsBeingConsumed >= spendableOutputs.length) {
+ throw Exception("Insufficient balance to pay transaction fee");
}
+ Logging.instance.w(
+ "Cannot pay tx fee (${e.requiredFee} sats) -"
+ " selecting more inputs and trying again",
+ );
+ // Select enough to also cover the fee the last attempt needed. The
+ // added inputs grow the transaction, so the next attempt may still
+ // fall short and raise the target again until it converges.
+ await consumeInputsFor(satoshiAmountToSend + e.requiredFee);
}
}
-
- return txData;
}
- Future<TxData> _sendAllBuilder({
+ Future<TxData> _buildTransactionPayingFee({
required TxData txData,
- required String recipientAddress,
- required BigInt satoshisBeingUsed,
required List<BaseInput> inputsWithKeys,
+ required String recipientAddress,
+ required BigInt recipientAmount,
+ required BigInt inputTotal,
required int? satsPerVByte,
required BigInt feeRatePerKB,
- BigInt? overrideFeeAmount,
+ required BigInt? minimumFeeAmount,
+ required bool isSweep,
+ required Future<String> Function() nextChangeAddress,
}) async {
- Logging.instance.d("Attempting to send all $cryptoCurrency");
- if (txData.recipients!.length != 1) {
- throw Exception("Send all to more than one recipient not yet supported");
- }
-
- BigInt feeForOneOutput;
- if (overrideFeeAmount == null) {
- final int vSizeForOneOutput = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshisBeingUsed - BigInt.one],
- ),
- ),
- )).vSize!;
- feeForOneOutput = BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForOneOutput)
- : estimateTxFee(
- vSize: vSizeForOneOutput,
- feeRatePerKB: feeRatePerKB,
- ),
- );
-
- if (satsPerVByte == null) {
- final roughEstimate = roughFeeEstimate(
- inputsWithKeys.length,
- 1,
- feeRatePerKB,
- ).raw;
- if (feeForOneOutput < roughEstimate) {
- feeForOneOutput = roughEstimate;
+ final BigInt dustLimit = cryptoCurrency.dustLimit.raw;
+ String? changeAddress;
+ final result = await planElectrumFee<TxData>(
+ mode: isSweep
+ ? ElectrumFeeMode.sweep
+ : txData.subtractFeeFromAmount
+ ? ElectrumFeeMode.subtractFeeFromAmount
+ : ElectrumFeeMode.fixedAmount,
+ inputTotal: inputTotal,
+ recipientAmount: recipientAmount,
+ dustLimit: dustLimit,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: feeRatePerKB,
+ minimumFeeAmount: minimumFeeAmount,
+ build: ({required recipientAmount, changeAmount}) async {
+ final addresses = [recipientAddress];
+ final amounts = [recipientAmount];
+ if (changeAmount != null) {
+ final address = changeAddress ??= await nextChangeAddress();
+ addresses.add(address);
+ amounts.add(changeAmount);
}
- }
- } else {
- feeForOneOutput = overrideFeeAmount;
- }
-
- late TxData data;
- if (txData.type == TxType.mwebPegIn) {
- while (true) {
- final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput;
- if (satoshiAmountToSend.isNegative) {
- throw Exception(
- "Estimated fee ($feeForOneOutput sats) is greater than balance!",
- );
- }
-
- data = await buildTransaction(
+ final transaction = await buildTransaction(
+ inputsWithKeys: inputsWithKeys,
txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshiAmountToSend],
- ),
+ recipients: await helperRecipientsConvert(addresses, amounts),
+ usedUTXOs: inputsWithKeys,
),
- inputsWithKeys: inputsWithKeys,
- );
-
- if (overrideFeeAmount != null) {
- break;
- }
-
- // Signing can change vSize, so calculate the fee from the final tx.
- final vSize = BigInt.from(data.vSize!);
- final feeForFinalVSize = BigInt.from(
- satsPerVByte != null
- ? satsPerVByte * data.vSize!
- : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB),
);
- final requiredFee = feeForFinalVSize > vSize ? feeForFinalVSize : vSize;
- if (feeForOneOutput >= requiredFee) {
- break;
- }
- feeForOneOutput = requiredFee;
- }
- } else {
- final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput;
-
- if (satoshiAmountToSend.isNegative) {
- throw Exception(
- "Estimated fee ($feeForOneOutput sats) is greater than balance!",
- );
- }
-
- data = await buildTransaction(
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshiAmountToSend],
- ),
- ),
- inputsWithKeys: inputsWithKeys,
- );
- }
+ return (transaction: transaction, vSize: transaction.vSize!);
+ },
+ );
- return data.copyWith(
+ return result.transaction.copyWith(
fee: Amount(
- rawValue: feeForOneOutput,
+ rawValue: result.fee,
fractionDigits: cryptoCurrency.fractionDigits,
),
usedUTXOs: inputsWithKeys,
@@ -794,33 +582,25 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
" signedSize=${selection.signedSize}",
);
- /// Add the change if there is one
- final List<String> recipientsArray = [recipientAddress];
- final List<BigInt> recipientsAmtArray = [satoshiAmountToSend];
- if (!selection.changeless) {
- await checkChangeAddressForTransactions();
- final freshChange = (await getCurrentChangeAddress())!;
- recipientsArray.add(freshChange.value);
- recipientsAmtArray.add(selection.changeValue);
- }
-
- final TxData txBuilt = await buildTransaction(
- inputsWithKeys: selectedBaseInputs,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: selectedBaseInputs,
- ),
+ final BigInt inputTotal = selectedBaseInputs.fold(
+ BigInt.zero,
+ (sum, input) => sum + input.value,
);
- return txBuilt.copyWith(
- fee: Amount(
- rawValue: selection.fee,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: selectedBaseInputs,
+ return _buildTransactionPayingFee(
+ txData: txData,
+ inputsWithKeys: selectedBaseInputs,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: inputTotal,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: feeRatePerKB,
+ minimumFeeAmount: null,
+ isSweep: false,
+ nextChangeAddress: () async {
+ await checkChangeAddressForTransactions();
+ return (await getCurrentChangeAddress())!.value;
+ },
);
}
@@ -2193,13 +1973,15 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
TxData mwebData = await coinSelection(
txData: result.copyWith(
- recipients: result.recipients!.where((e) => !(e.isChange)).toList(),
+ recipients: txData.subtractFeeFromAmount
+ ? txData.recipients
+ : result.recipients!.where((e) => !(e.isChange)).toList(),
),
utxos: utxos?.toList(),
coinControl: coinControl,
isSendAll: isSendAll,
isSendAllCoinControlUtxos: isSendAllCoinControlUtxos,
- overrideFeeAmount: fee.raw,
+ minimumFeeAmount: fee.raw,
);
if (mwebData.type == TxType.mwebPegIn) {
@@ -2212,7 +1994,7 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
mwebData,
);
Logging.instance.d("prepare MWEB send: $data");
- return data.copyWith(fee: fee);
+ return data;
}
Logging.instance.d("prepare send: $result");
diff --git a/test/wallets/electrum_fee_planner_test.dart b/test/wallets/electrum_fee_planner_test.dart
new file mode 100644
index 0000000..5a541f5
--- /dev/null
+++ b/test/wallets/electrum_fee_planner_test.dart
@@ -0,0 +1,187 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart';
+
+typedef _Payment = ({BigInt recipientAmount, BigInt? changeAmount});
+
+Future<({ElectrumFeeResult<_Payment> result, List<_Payment> builds})> _plan({
+ required ElectrumFeeMode mode,
+ required int inputTotal,
+ required int recipientAmount,
+ required int dustLimit,
+ required List<int> vSizes,
+ int? satsPerVByte = 1,
+ int feeRatePerKB = 1000,
+ int? minimumFeeAmount,
+}) async {
+ final builds = <_Payment>[];
+ var buildIndex = 0;
+ final result = await planElectrumFee<_Payment>(
+ mode: mode,
+ inputTotal: BigInt.from(inputTotal),
+ recipientAmount: BigInt.from(recipientAmount),
+ dustLimit: BigInt.from(dustLimit),
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: BigInt.from(feeRatePerKB),
+ minimumFeeAmount: minimumFeeAmount == null
+ ? null
+ : BigInt.from(minimumFeeAmount),
+ build: ({required recipientAmount, changeAmount}) async {
+ final payment = (
+ recipientAmount: recipientAmount,
+ changeAmount: changeAmount,
+ );
+ builds.add(payment);
+ final vSize =
+ vSizes[buildIndex < vSizes.length ? buildIndex++ : vSizes.length - 1];
+ return (transaction: payment, vSize: vSize);
+ },
+ );
+ return (result: result, builds: builds);
+}
+
+void main() {
+ test('keeps the larger fee when measured vsize shrinks', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.sweep,
+ inputTotal: 10000,
+ recipientAmount: 10000,
+ dustLimit: 546,
+ vSizes: [192, 191],
+ );
+
+ expect(plan.result.fee, BigInt.from(192));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(9808));
+ expect(plan.builds.length, 2);
+ });
+
+ test('rounds per-kilobyte fees up', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.sweep,
+ inputTotal: 10000,
+ recipientAmount: 10000,
+ dustLimit: 546,
+ vSizes: [191],
+ satsPerVByte: null,
+ feeRatePerKB: 1001,
+ );
+
+ expect(plan.result.fee, BigInt.from(192));
+ });
+
+ test('does not let a minimum fee underpay the measured vsize', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.sweep,
+ inputTotal: 10000,
+ recipientAmount: 10000,
+ dustLimit: 546,
+ vSizes: [225],
+ satsPerVByte: null,
+ feeRatePerKB: 0,
+ minimumFeeAmount: 100,
+ );
+
+ expect(plan.result.fee, BigInt.from(225));
+ });
+
+ test('keeps exact-dust fixed change after vsize shrinks', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.fixedAmount,
+ inputTotal: 1319,
+ recipientAmount: 547,
+ dustLimit: 546,
+ vSizes: [226, 225],
+ );
+
+ expect(plan.result.fee, BigInt.from(226));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(547));
+ expect(plan.result.transaction.changeAmount, BigInt.from(546));
+ });
+
+ test('accepts an exact-dust fixed recipient', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.fixedAmount,
+ inputTotal: 772,
+ recipientAmount: 546,
+ dustLimit: 546,
+ vSizes: [226],
+ );
+
+ expect(plan.result.fee, BigInt.from(226));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(546));
+ });
+
+ test('subtracts the fee and preserves change', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.subtractFeeFromAmount,
+ inputTotal: 10000,
+ recipientAmount: 6000,
+ dustLimit: 546,
+ vSizes: [225],
+ );
+
+ expect(plan.result.fee, BigInt.from(225));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(5775));
+ expect(plan.result.transaction.changeAmount, BigInt.from(4000));
+ });
+
+ test('uses a sub-dust surplus toward the subtracted fee', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.subtractFeeFromAmount,
+ inputTotal: 10000,
+ recipientAmount: 9900,
+ dustLimit: 546,
+ vSizes: [225],
+ );
+
+ expect(plan.result.fee, BigInt.from(225));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(9775));
+ expect(plan.result.transaction.changeAmount, isNull);
+ });
+
+ test('uses an equal sub-dust surplus as the fee', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.subtractFeeFromAmount,
+ inputTotal: 10000,
+ recipientAmount: 9775,
+ dustLimit: 546,
+ vSizes: [225],
+ );
+
+ expect(plan.result.fee, BigInt.from(225));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(9775));
+ expect(plan.result.transaction.changeAmount, isNull);
+ });
+
+ test('returns excess sub-dust surplus to the recipient', () async {
+ final plan = await _plan(
+ mode: ElectrumFeeMode.subtractFeeFromAmount,
+ inputTotal: 10000,
+ recipientAmount: 9700,
+ dustLimit: 546,
+ vSizes: [225],
+ );
+
+ expect(plan.result.fee, BigInt.from(225));
+ expect(plan.result.transaction.recipientAmount, BigInt.from(9775));
+ expect(plan.result.transaction.changeAmount, isNull);
+ });
+
+ test('fixed mode requests another input when the fee is short', () {
+ expect(
+ _plan(
+ mode: ElectrumFeeMode.fixedAmount,
+ inputTotal: 10000,
+ recipientAmount: 9900,
+ dustLimit: 546,
+ vSizes: [225],
+ ),
+ throwsA(
+ isA<ElectrumFeeInsufficientFunds>().having(
+ (e) => e.requiredFee,
+ 'requiredFee',
+ BigInt.from(225),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/wallets/tx_data_test.dart b/test/wallets/tx_data_test.dart
new file mode 100644
index 0000000..b07748a
--- /dev/null
+++ b/test/wallets/tx_data_test.dart
@@ -0,0 +1,18 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/wallets/models/tx_data.dart';
+
+void main() {
+ test('subtractFeeFromAmount defaults and copies', () {
+ final txData = TxData();
+
+ expect(txData.subtractFeeFromAmount, false);
+
+ final enabled = txData.copyWith(subtractFeeFromAmount: true);
+ expect(enabled.subtractFeeFromAmount, true);
+ expect(enabled.copyWith().subtractFeeFromAmount, true);
+ expect(
+ enabled.copyWith(subtractFeeFromAmount: false).subtractFeeFromAmount,
+ false,
+ );
+ });
+}
Why this scored 33/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.