include unconfirmed wallet descendant fees when increasing a transaction fee, advise the replaced fees plus relay cost on rejection, and allow a cpfp child to be replaced
What changed, and why it matters
This commit fixes how Sparrow Wallet calculates fees when you speed up or replace a Bitcoin transaction. Previously, if your wallet had unconfirmed follow-up transactions (like a CPFP child) that depended on the transaction being replaced, the wallet did not count their fees toward the replacement cost. That could cause the replacement to be rejected by the network for paying too little. The fix also improves the error message so users are told the correct minimum fee to use next time, and it allows a CPFP child that pays to a change address to be treated as a consolidation output when bumping fees.
Users who perform RBF fee bumps on transactions with unconfirmed child transactions should upgrade. The change is defensive and improves reliability rather than fixing a remote exploit, but it prevents stuck or rejected replacements and misleading fee guidance.
Security signals we found
RBF fee-bumping calculation omitted unconfirmed descendant fees, risking insufficient-fee rejection
User-facing error message previously could understate the fee required to successfully replace a transaction
CPFP child output to change address was not treated as consolidation, potentially affecting input selection during fee bump
Fee parsing changed from truncation to rounding, reducing small under-advisement errors
Evidence from the diff
The patch modifies fee-bumping logic in EntryCell.java and broadcast error handling in HeadersController.java. It introduces getUnconfirmedDescendantFees(), which recursively sums fees of unconfirmed wallet transactions spending outputs of the transaction being replaced. These descendant fees are subtracted from changeTotal when evaluating the effective fee rate and added to the base fee before applying the RBF relay-cost increment. The consolidation-output filter now accepts a single output regardless of whether it is a receive or change output, enabling CPFP children sweeping to a change address to be recognized. HeadersController.java changes the fee parsing from cast-to-long truncation to Math.round and adds a branch for the ‘less fees than conflicting txs’ rejection message, advising requiredAdditionalFee plus the replacement’s own relay cost.
Changed components
src/main/java/com/sparrowwallet/sparrow/control/EntryCell.javasrc/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.javaInspect captured patch +33 / −4
### src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
@@ -241,7 +241,8 @@ private static void increaseFee(TransactionEntry transactionEntry, boolean cance
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
- .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
+ //A single output back to the wallet is a consolidation on either chain, a CPFP child sweeping to a change address included
+ .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && (e.getKeyPurpose() == KeyPurpose.RECEIVE || blockTransaction.getTransaction().getOutputs().size() == 1))
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
@@ -261,7 +262,10 @@ private static void increaseFee(TransactionEntry transactionEntry, boolean cance
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups, SECURE_RANDOM);
- while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) {
+
+ //Replacement tx fees must also cover the fees of the unconfirmed wallet transactions spending its outputs, which are replaced along with it
+ long descendantFees = getUnconfirmedDescendantFees(transactionEntry.getWallet(), walletTxos.keySet(), blockTransaction.getHash(), new HashSet<>());
+ while((double)(changeTotal - descendantFees) / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) {
//If there is insufficient change output, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
@@ -273,6 +277,8 @@ private static void increaseFee(TransactionEntry transactionEntry, boolean cance
Long fee = blockTransaction.getFee();
if(fee != null) {
+ fee += descendantFees;
+
//Replacement tx fees must be greater than the original tx fees by its minimum relay cost
fee += (long)Math.ceil(vSize * AppServices.getMinimumRelayFeeRate());
}
@@ -358,6 +364,25 @@ private static Double getMaxFeeRate() {
return AppServices.getTargetBlockFeeRates().values().iterator().next();
}
+ private static long getUnconfirmedDescendantFees(Wallet wallet, Collection<BlockTransactionHashIndex> walletTxos, Sha256Hash txid, Set<Sha256Hash> visited) {
+ long fees = 0;
+ for(BlockTransactionHashIndex txo : walletTxos) {
+ if(txo.getHash().equals(txid) && txo.getSpentBy() != null && visited.add(txo.getSpentBy().getHash())) {
+ BlockTransaction child = wallet.getWalletTransaction(txo.getSpentBy().getHash());
+ if(child == null || child.getHeight() <= 0) {
+ //A descendant whose fee is unknown adds nothing, so the total is a lower bound, but the descendants spending it are replaced all the same
+ if(child != null && child.getFee() != null) {
+ fees += child.getFee();
+ }
+
+ fees += getUnconfirmedDescendantFees(wallet, walletTxos, txo.getSpentBy().getHash(), visited);
+ }
+ }
+ }
+
+ return fees;
+ }
+
private static void createCpfp(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
### src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -1515,9 +1515,13 @@ public void broadcastTransaction(ActionEvent event) {
Matcher feeMatcher = RBF_INSUFFICIENT_FEE.matcher(failMessage);
Matcher feeRateMatcher = RBF_INSUFFICIENT_FEE_RATE.matcher(failMessage);
if(feeMatcher.matches() && fee.getValue() > 0) {
- long currentAdditionalFee = (long)(Double.parseDouble(feeMatcher.group(1)) * Transaction.SATOSHIS_PER_BITCOIN);
- long requiredAdditionalFee = (long)(Double.parseDouble(feeMatcher.group(2)) * Transaction.SATOSHIS_PER_BITCOIN);
+ long currentAdditionalFee = Math.round(Double.parseDouble(feeMatcher.group(1)) * Transaction.SATOSHIS_PER_BITCOIN);
+ long requiredAdditionalFee = Math.round(Double.parseDouble(feeMatcher.group(2)) * Transaction.SATOSHIS_PER_BITCOIN);
long requiredFee = fee.getValue() - currentAdditionalFee + requiredAdditionalFee;
+ if(failMessage.contains("less fees than conflicting txs")) {
+ //Reported against the fees of the replaced transactions alone, which the replacement must also exceed by its own relay cost
+ requiredFee = requiredAdditionalFee + (long)Math.ceil(getVirtualSize() * AppServices.getMinimumRelayFeeRate());
+ }
AppServices.showErrorDialog("Error broadcasting transaction", "The fee for the replacement transaction was insufficient. Increase the fee to at least " + requiredFee + " sats to try again.");
} else if(feeRateMatcher.matches()) {
double requiredFeeRate = Double.parseDouble(feeRateMatcher.group(2)) * Transaction.SATOSHIS_PER_BITCOIN / 1000;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.