Fix Spark mint fee subtraction edge case
What changed, and why it matters
This commit fixes how transaction fees are subtracted from Spark mint outputs. The old code could mis-handle cases where an output was too small to cover its share of the fee, potentially leaving leftover fees unassigned or removing outputs incorrectly. The new code walks through outputs one by one, recalculating each remaining output's fair share of the fee, and cleanly removes outputs that cannot cover their portion. It also adds a guard to drop the whole input set if every output gets removed because fees exceed the mint amount.
Review the new fee-subtraction loop against formal invariants (total fee deducted equals nFeeRet, no output goes negative, no infinite loop) and add unit tests covering edge cases such as: one output smaller than fee share, all outputs smaller than fee share, fee exactly equal to total mint value, and fee not evenly divisible by output count. Consider whether the removed-output value should be subtracted from remainingFee in full or only up to the fee share.
Security signals we found
Fee-allocation logic bug in privacy-focused Spark minting
Potential for transaction construction to produce invalid or uneconomical outputs
Edge case where outputs smaller than fee share are removed without redistributing fee
Added defensive check to discard a coin selection candidate when all outputs are consumed by fees
Evidence from the diff
The patch refactors fee subtraction in Spark mint transaction construction. Previously, the code computed a single equal fee share and remainder once, then iterated outputs. That approach could skip outputs whose value was less than or equal to the fee, but did not recompute the per-output share for the remaining outputs, and the remainder logic was fragile. The new implementation uses a while-loop that recomputes feeShare = remainingFee ~/ outputsLeft (rounding up when not divisible) for each output, removes outputs that cannot cover their share while subtracting their full value from the remaining fee, and advances only when an output survives. After the loop, if no outputs remain, the entire UTXO entry is removed and coin selection continues. This is a correctness fix for fee allocation edge cases.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartSpark mint transaction fee subtractionCoin selection / UTXO handling for Spark mintsInspect captured patch +27 / −23
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
index e3c02ee..96a63e0 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
@@ -1664,11 +1664,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
if (autoMintAll) {
singleTxOutputs.add(
- MutableSparkRecipient(
- autoMintSparkAddress!,
- mintedValue,
- "",
- ),
+ MutableSparkRecipient(autoMintSparkAddress!, mintedValue, ""),
);
} else {
BigInt remainingMintValue = BigInt.parse(mintedValue.toString());
@@ -1696,26 +1692,34 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
}
- if (subtractFeeFromAmount) {
- final BigInt singleFee =
- nFeeRet ~/ BigInt.from(singleTxOutputs.length);
- BigInt remainder = nFeeRet % BigInt.from(singleTxOutputs.length);
-
- for (int i = 0; i < singleTxOutputs.length; ++i) {
- if (singleTxOutputs[i].value <= singleFee) {
- final removed = singleTxOutputs.removeAt(i);
- remainder += removed.value - singleFee;
- --i;
- continue;
+ if (subtractFeeFromAmount && nFeeRet > BigInt.zero) {
+ var remainingFee = nFeeRet;
+ var outputIndex = 0;
+ while (outputIndex < singleTxOutputs.length &&
+ remainingFee > BigInt.zero) {
+ final outputsLeft = BigInt.from(
+ singleTxOutputs.length - outputIndex,
+ );
+ var feeShare = remainingFee ~/ outputsLeft;
+ if (remainingFee % outputsLeft != BigInt.zero) {
+ feeShare += BigInt.one;
}
- singleTxOutputs[i].value -= singleFee;
- if (remainder > BigInt.zero &&
- singleTxOutputs[i].value >
- nFeeRet % BigInt.from(singleTxOutputs.length)) {
- // first receiver pays the remainder not divisible by output count
- singleTxOutputs[i].value -= remainder;
- remainder = BigInt.zero;
+
+ if (singleTxOutputs[outputIndex].value <= feeShare) {
+ remainingFee -= singleTxOutputs[outputIndex].value;
+ singleTxOutputs.removeAt(outputIndex);
+ continue;
}
+
+ singleTxOutputs[outputIndex].value -= feeShare;
+ remainingFee -= feeShare;
+ ++outputIndex;
+ }
+
+ if (singleTxOutputs.isEmpty) {
+ valueAndUTXOs.remove(itr);
+ skipCoin = true;
+ break;
}
}
Why this scored 42/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.