Skip to content

Commit f18f9ef

Browse files
committed
Amend bumpfee for inputs with overlapping ancestry
At the end of coin selection reduce the fees by the difference between the individual bump fee estimates and the collective bump fee estimate.
1 parent 2e35e94 commit f18f9ef

8 files changed

+88
-21
lines changed

src/bench/coin_selection.cpp

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ static void CoinSelection(benchmark::Bench& bench)
7979
};
8080
auto group = wallet::GroupOutputs(wallet, available_coins, coin_selection_params, {{filter_standard}})[filter_standard];
8181
bench.run([&] {
82-
auto result = AttemptSelection(1003 * COIN, group, coin_selection_params, /*allow_mixed_output_types=*/true);
82+
auto result = AttemptSelection(wallet.chain(), 1003 * COIN, group, coin_selection_params, /*allow_mixed_output_types=*/true);
8383
assert(result);
8484
assert(result->GetSelectedValue() == 1003 * COIN);
8585
assert(result->GetInputSet().size() == 2);

src/wallet/coinselection.cpp

+14-5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <common/system.h>
88
#include <consensus/amount.h>
99
#include <consensus/consensus.h>
10+
#include <interfaces/chain.h>
1011
#include <logging.h>
1112
#include <policy/feerate.h>
1213
#include <util/check.h>
@@ -456,12 +457,12 @@ CAmount SelectionResult::GetSelectionWaste(CAmount change_cost, CAmount target,
456457

457458
// Always consider the cost of spending an input now vs in the future.
458459
CAmount waste = 0;
459-
CAmount selected_effective_value = 0;
460460
for (const auto& coin_ptr : m_selected_inputs) {
461461
const COutput& coin = *coin_ptr;
462462
waste += coin.GetFee() - coin.long_term_fee;
463-
selected_effective_value += use_effective_value ? coin.GetEffectiveValue() : coin.txout.nValue;
464463
}
464+
// Bump fee of whole selection may diverge from sum of individual bump fees
465+
waste -= bump_fee_group_discount;
465466

466467
if (change_cost) {
467468
// Consider the cost of making change and spending it in the future
@@ -470,6 +471,7 @@ CAmount SelectionResult::GetSelectionWaste(CAmount change_cost, CAmount target,
470471
waste += change_cost;
471472
} else {
472473
// When we are not making change (change_cost == 0), consider the excess we are throwing away to fees
474+
CAmount selected_effective_value = use_effective_value ? GetSelectedEffectiveValue() : GetSelectedValue();
473475
assert(selected_effective_value >= target);
474476
waste += selected_effective_value - target;
475477
}
@@ -488,6 +490,14 @@ CAmount GenerateChangeTarget(const CAmount payment_value, const CAmount change_f
488490
}
489491
}
490492

493+
void SelectionResult::SetBumpFeeDiscount(const CAmount discount)
494+
{
495+
// Overlapping ancestry can only lower the fees, not increase them
496+
assert (discount >= 0);
497+
bump_fee_group_discount = discount;
498+
}
499+
500+
491501
void SelectionResult::ComputeAndSetWaste(const CAmount min_viable_change, const CAmount change_cost, const CAmount change_fee)
492502
{
493503
const CAmount change = GetChange(min_viable_change, change_fee);
@@ -511,13 +521,12 @@ CAmount SelectionResult::GetSelectedValue() const
511521

512522
CAmount SelectionResult::GetSelectedEffectiveValue() const
513523
{
514-
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->GetEffectiveValue(); });
524+
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->GetEffectiveValue(); }) + bump_fee_group_discount;
515525
}
516526

517-
518527
CAmount SelectionResult::GetTotalBumpFees() const
519528
{
520-
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->ancestor_bump_fees; });
529+
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->ancestor_bump_fees; }) - bump_fee_group_discount;
521530
}
522531

523532
void SelectionResult::Clear()

src/wallet/coinselection.h

+5
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ struct SelectionResult
331331
std::optional<CAmount> m_waste;
332332
/** Total weight of the selected inputs */
333333
int m_weight{0};
334+
/** How much individual inputs overestimated the bump fees for the shared ancestry */
335+
CAmount bump_fee_group_discount{0};
334336

335337
template<typename T>
336338
void InsertInputs(const T& inputs)
@@ -377,6 +379,9 @@ struct SelectionResult
377379
void AddInput(const OutputGroup& group);
378380
void AddInputs(const std::set<std::shared_ptr<COutput>>& inputs, bool subtract_fee_outputs);
379381

382+
/** How much individual inputs overestimated the bump fees for shared ancestries */
383+
void SetBumpFeeDiscount(const CAmount discount);
384+
380385
/** Calculates and stores the waste for this selection via GetSelectionWaste */
381386
void ComputeAndSetWaste(const CAmount min_viable_change, const CAmount change_cost, const CAmount change_fee);
382387
[[nodiscard]] CAmount GetWaste() const;

src/wallet/feebumper.cpp

+4-6
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,11 @@ static feebumper::Result CheckFeeRate(const CWallet& wallet, const CMutableTrans
8686
reused_inputs.push_back(txin.prevout);
8787
}
8888

89-
std::map<COutPoint, CAmount> bump_fees = wallet.chain().CalculateIndividualBumpFees(reused_inputs, newFeerate);
90-
CAmount total_bump_fees = 0;
91-
for (auto& [_, bump_fee] : bump_fees) {
92-
total_bump_fees += bump_fee;
89+
std::optional<CAmount> combined_bump_fee = wallet.chain().CalculateCombinedBumpFee(reused_inputs, newFeerate);
90+
if (!combined_bump_fee.has_value()) {
91+
errors.push_back(strprintf(Untranslated("Failed to calculate bump fees, because unconfirmed UTXOs depend on enormous cluster of unconfirmed transactions.")));
9392
}
94-
95-
CAmount new_total_fee = newFeerate.GetFee(maxTxSize) + total_bump_fees;
93+
CAmount new_total_fee = newFeerate.GetFee(maxTxSize) + combined_bump_fee.value();
9694

9795
CFeeRate incrementalRelayFee = std::max(wallet.chain().relayIncrementalFee(), CFeeRate(WALLET_INCREMENTAL_RELAY_FEE));
9896

src/wallet/spend.cpp

+26-7
Original file line numberDiff line numberDiff line change
@@ -544,13 +544,13 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
544544
// Returns true if the result contains an error and the message is not empty
545545
static bool HasErrorMsg(const util::Result<SelectionResult>& res) { return !util::ErrorString(res).empty(); }
546546

547-
util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, OutputGroupTypeMap& groups,
547+
util::Result<SelectionResult> AttemptSelection(interfaces::Chain& chain, const CAmount& nTargetValue, OutputGroupTypeMap& groups,
548548
const CoinSelectionParams& coin_selection_params, bool allow_mixed_output_types)
549549
{
550550
// Run coin selection on each OutputType and compute the Waste Metric
551551
std::vector<SelectionResult> results;
552552
for (auto& [type, group] : groups.groups_by_type) {
553-
auto result{ChooseSelectionResult(nTargetValue, group, coin_selection_params)};
553+
auto result{ChooseSelectionResult(chain, nTargetValue, group, coin_selection_params)};
554554
// If any specific error message appears here, then something particularly wrong happened.
555555
if (HasErrorMsg(result)) return result; // So let's return the specific error.
556556
// Append the favorable result.
@@ -564,14 +564,14 @@ util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, Outp
564564
// over all available coins, which would allow mixing.
565565
// If TypesCount() <= 1, there is nothing to mix.
566566
if (allow_mixed_output_types && groups.TypesCount() > 1) {
567-
return ChooseSelectionResult(nTargetValue, groups.all_groups, coin_selection_params);
567+
return ChooseSelectionResult(chain, nTargetValue, groups.all_groups, coin_selection_params);
568568
}
569569
// Either mixing is not allowed and we couldn't find a solution from any single OutputType, or mixing was allowed and we still couldn't
570570
// find a solution using all available coins
571571
return util::Error();
572572
};
573573

574-
util::Result<SelectionResult> ChooseSelectionResult(const CAmount& nTargetValue, Groups& groups, const CoinSelectionParams& coin_selection_params)
574+
util::Result<SelectionResult> ChooseSelectionResult(interfaces::Chain& chain, const CAmount& nTargetValue, Groups& groups, const CoinSelectionParams& coin_selection_params)
575575
{
576576
// Vector of results. We will choose the best one based on waste.
577577
std::vector<SelectionResult> results;
@@ -596,12 +596,10 @@ util::Result<SelectionResult> ChooseSelectionResult(const CAmount& nTargetValue,
596596

597597
// The knapsack solver has some legacy behavior where it will spend dust outputs. We retain this behavior, so don't filter for positive only here.
598598
if (auto knapsack_result{KnapsackSolver(groups.mixed_group, nTargetValue, coin_selection_params.m_min_change_target, coin_selection_params.rng_fast, max_inputs_weight)}) {
599-
knapsack_result->ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
600599
results.push_back(*knapsack_result);
601600
} else append_error(knapsack_result);
602601

603602
if (auto srd_result{SelectCoinsSRD(groups.positive_group, nTargetValue, coin_selection_params.m_change_fee, coin_selection_params.rng_fast, max_inputs_weight)}) {
604-
srd_result->ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
605603
results.push_back(*srd_result);
606604
} else append_error(srd_result);
607605

@@ -611,6 +609,27 @@ util::Result<SelectionResult> ChooseSelectionResult(const CAmount& nTargetValue,
611609
return errors.empty() ? util::Error() : errors.front();
612610
}
613611

612+
// If the chosen input set has unconfirmed inputs, check for synergies from overlapping ancestry
613+
for (auto& result : results) {
614+
std::vector<COutPoint> outpoints;
615+
std::set<std::shared_ptr<COutput>> coins = result.GetInputSet();
616+
CAmount summed_bump_fees = 0;
617+
for (auto& coin : coins) {
618+
if (coin->depth > 0) continue; // Bump fees only exist for unconfirmed inputs
619+
outpoints.push_back(coin->outpoint);
620+
summed_bump_fees += coin->ancestor_bump_fees;
621+
}
622+
std::optional<CAmount> combined_bump_fee = chain.CalculateCombinedBumpFee(outpoints, coin_selection_params.m_effective_feerate);
623+
if (!combined_bump_fee.has_value()) {
624+
return util::Error{_("Failed to calculate bump fees, because unconfirmed UTXOs depend on enormous cluster of unconfirmed transactions.")};
625+
}
626+
CAmount bump_fee_overestimate = summed_bump_fees - combined_bump_fee.value();
627+
if (bump_fee_overestimate) {
628+
result.SetBumpFeeDiscount(bump_fee_overestimate);
629+
}
630+
result.ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
631+
}
632+
614633
// Choose the result with the least waste
615634
// If the waste is the same, choose the one which spends more inputs.
616635
return *std::min_element(results.begin(), results.end());
@@ -740,7 +759,7 @@ util::Result<SelectionResult> AutomaticCoinSelection(const CWallet& wallet, Coin
740759
for (const auto& select_filter : ordered_filters) {
741760
auto it = filtered_groups.find(select_filter.filter);
742761
if (it == filtered_groups.end()) continue;
743-
if (auto res{AttemptSelection(value_to_select, it->second,
762+
if (auto res{AttemptSelection(wallet.chain(), value_to_select, it->second,
744763
coin_selection_params, select_filter.allow_mixed_output_types)}) {
745764
return res; // result found
746765
} else {

src/wallet/spend.h

+4-2
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
123123
* the solution (according to the waste metric) will be chosen. If a valid input cannot be found from any
124124
* single OutputType, fallback to running `ChooseSelectionResult()` over all available coins.
125125
*
126+
* param@[in] chain The chain interface to get information on unconfirmed UTXOs bump fees
126127
* param@[in] nTargetValue The target value
127128
* param@[in] groups The grouped outputs mapped by coin eligibility filters
128129
* param@[in] coin_selection_params Parameters for the coin selection
@@ -132,14 +133,15 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
132133
* or (2) an specific error message if there was something particularly wrong (e.g. a selection
133134
* result that surpassed the tx max weight size).
134135
*/
135-
util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, OutputGroupTypeMap& groups,
136+
util::Result<SelectionResult> AttemptSelection(interfaces::Chain& chain, const CAmount& nTargetValue, OutputGroupTypeMap& groups,
136137
const CoinSelectionParams& coin_selection_params, bool allow_mixed_output_types);
137138

138139
/**
139140
* Attempt to find a valid input set that meets the provided eligibility filter and target.
140141
* Multiple coin selection algorithms will be run and the input set that produces the least waste
141142
* (according to the waste metric) will be chosen.
142143
*
144+
* param@[in] chain The chain interface to get information on unconfirmed UTXOs bump fees
143145
* param@[in] nTargetValue The target value
144146
* param@[in] groups The struct containing the outputs grouped by script and divided by (1) positive only outputs and (2) all outputs (positive + negative).
145147
* param@[in] coin_selection_params Parameters for the coin selection
@@ -148,7 +150,7 @@ util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, Outp
148150
* or (2) an specific error message if there was something particularly wrong (e.g. a selection
149151
* result that surpassed the tx max weight size).
150152
*/
151-
util::Result<SelectionResult> ChooseSelectionResult(const CAmount& nTargetValue, Groups& groups, const CoinSelectionParams& coin_selection_params);
153+
util::Result<SelectionResult> ChooseSelectionResult(interfaces::Chain& chain, const CAmount& nTargetValue, Groups& groups, const CoinSelectionParams& coin_selection_params);
152154

153155
// User manually selected inputs that must be part of the transaction
154156
struct PreSelectedInputs

src/wallet/test/coinselector_tests.cpp

+10
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,11 @@ BOOST_AUTO_TEST_CASE(bump_fee_test)
977977
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
978978
CAmount expected_waste = fee_diff * -2 + change_cost + /*bump_fees=*/60;
979979
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
980+
981+
selection.SetBumpFeeDiscount(30);
982+
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
983+
expected_waste = fee_diff * -2 + change_cost + /*bump_fees=*/60 - /*group_discount=*/30;
984+
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
980985
}
981986

982987
{
@@ -998,6 +1003,11 @@ BOOST_AUTO_TEST_CASE(bump_fee_test)
9981003
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
9991004
CAmount expected_waste = fee_diff * -2 + /*bump_fees=*/60 + /*excess = 100 - bump_fees*/40;
10001005
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
1006+
1007+
selection.SetBumpFeeDiscount(30);
1008+
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
1009+
expected_waste = fee_diff * -2 + /*bump_fees=*/60 - /*group_discount=*/30 + /*excess = 100 - bump_fees + group_discount*/70;
1010+
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
10011011
}
10021012
}
10031013

test/functional/wallet_spend_unconfirmed.py

+24
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ def test_rbf_bumping(self):
337337

338338
wallet.unloadwallet()
339339

340+
# Test that transaction spending two UTXOs with overlapping ancestry does not bump shared ancestors twice
341+
def test_target_feerate_unconfirmed_low_overlapping_ancestry(self):
342+
self.log.info("Start test where two UTXOs have overlapping ancestry")
343+
wallet = self.setup_and_fund_wallet("overlapping_ancestry_wallet")
344+
345+
parent_txid = wallet.sendtoaddress(address=wallet.getnewaddress(), amount=1, fee_rate=1)
346+
two_output_parent_tx = wallet.gettransaction(txid=parent_txid, verbose=True)
347+
348+
self.assert_undershoots_target(two_output_parent_tx)
349+
350+
# spend both outputs from parent transaction
351+
ancestor_aware_txid = wallet.sendtoaddress(address=self.def_wallet.getnewaddress(), amount=1.5, fee_rate=self.target_fee_rate)
352+
ancestor_aware_tx = wallet.gettransaction(txid=ancestor_aware_txid, verbose=True)
353+
self.assert_spends_only_parents(ancestor_aware_tx, [parent_txid, parent_txid])
354+
355+
self.assert_beats_target(ancestor_aware_tx)
356+
resulting_ancestry_fee_rate = self.calc_set_fee_rate([two_output_parent_tx, ancestor_aware_tx])
357+
assert_greater_than_or_equal(resulting_ancestry_fee_rate, self.target_fee_rate)
358+
assert_greater_than_or_equal(self.target_fee_rate*1.01, resulting_ancestry_fee_rate)
359+
360+
wallet.unloadwallet()
361+
340362
# Test that new transaction ignores sibling transaction with low feerate
341363
def test_sibling_tx_gets_ignored(self):
342364
self.log.info("Start test where a low-fee sibling tx gets created and check that bumping ignores it")
@@ -472,6 +494,8 @@ def run_test(self):
472494

473495
self.test_rbf_bumping()
474496

497+
self.test_target_feerate_unconfirmed_low_overlapping_ancestry()
498+
475499
self.test_sibling_tx_gets_ignored()
476500

477501
self.test_sibling_tx_bumps_parent()

0 commit comments

Comments
 (0)