Skip to content

Commit

Permalink
Amend bumpfee for inputs with overlapping ancestry
Browse files Browse the repository at this point in the history
At the end of coin selection reduce the fees by the difference between
the individual bump fee estimates and the collective bump fee estimate.
  • Loading branch information
murchandamus committed Sep 13, 2023
1 parent 0c271b2 commit 42a9935
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 21 deletions.
2 changes: 1 addition & 1 deletion src/bench/coin_selection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ static void CoinSelection(benchmark::Bench& bench)
};
auto group = wallet::GroupOutputs(wallet, available_coins, coin_selection_params, {{filter_standard}})[filter_standard];
bench.run([&] {
auto result = AttemptSelection(1003 * COIN, group, coin_selection_params, /*allow_mixed_output_types=*/true);
auto result = AttemptSelection(wallet, 1003 * COIN, group, coin_selection_params, /*allow_mixed_output_types=*/true);
assert(result);
assert(result->GetSelectedValue() == 1003 * COIN);
assert(result->GetInputSet().size() == 2);
Expand Down
25 changes: 20 additions & 5 deletions src/wallet/coinselection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <common/system.h>
#include <consensus/amount.h>
#include <consensus/consensus.h>
#include <interfaces/chain.h>
#include <logging.h>
#include <policy/feerate.h>
#include <util/check.h>
Expand Down Expand Up @@ -456,12 +457,12 @@ CAmount SelectionResult::GetSelectionWaste(CAmount change_cost, CAmount target,

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

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

void SelectionResult::SetBumpFeeDiscount(const CAmount discount)
{
// Overlapping ancestry can only lower the fees, not increase them
assert (discount >= 0);
bump_fee_group_discount = discount;
}


void SelectionResult::ComputeAndSetWaste(const CAmount min_viable_change, const CAmount change_cost, const CAmount change_fee)
{
const CAmount change = GetChange(min_viable_change, change_fee);
Expand All @@ -511,13 +521,18 @@ CAmount SelectionResult::GetSelectedValue() const

CAmount SelectionResult::GetSelectedEffectiveValue() const
{
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->GetEffectiveValue(); });
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;
}

CAmount SelectionResult::GetBumpFeeDiscount() const
{
return bump_fee_group_discount;
}


CAmount SelectionResult::GetTotalBumpFees() const
{
return std::accumulate(m_selected_inputs.cbegin(), m_selected_inputs.cend(), CAmount{0}, [](CAmount sum, const auto& coin) { return sum + coin->ancestor_bump_fees; });
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;
}

void SelectionResult::Clear()
Expand Down
7 changes: 7 additions & 0 deletions src/wallet/coinselection.h
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ struct SelectionResult
std::optional<CAmount> m_waste;
/** Total weight of the selected inputs */
int m_weight{0};
/** How much individual inputs overestimated the bump fees for the shared ancestry */
CAmount bump_fee_group_discount{0};

template<typename T>
void InsertInputs(const T& inputs)
Expand Down Expand Up @@ -372,11 +374,16 @@ struct SelectionResult

[[nodiscard]] CAmount GetTotalBumpFees() const;

[[nodiscard]] CAmount GetBumpFeeDiscount() const;

void Clear();

void AddInput(const OutputGroup& group);
void AddInputs(const std::set<std::shared_ptr<COutput>>& inputs, bool subtract_fee_outputs);

/** How much individual inputs overestimated the bump fees for shared ancestries */
void SetBumpFeeDiscount(const CAmount discount);

/** Calculates and stores the waste for this selection via GetSelectionWaste */
void ComputeAndSetWaste(const CAmount min_viable_change, const CAmount change_cost, const CAmount change_fee);
[[nodiscard]] CAmount GetWaste() const;
Expand Down
10 changes: 4 additions & 6 deletions src/wallet/feebumper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,11 @@ static feebumper::Result CheckFeeRate(const CWallet& wallet, const CMutableTrans
reused_inputs.push_back(txin.prevout);
}

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

CAmount new_total_fee = newFeerate.GetFee(maxTxSize) + total_bump_fees;
CAmount new_total_fee = newFeerate.GetFee(maxTxSize) + combined_bump_fee.value();

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

Expand Down
33 changes: 26 additions & 7 deletions src/wallet/spend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -544,13 +544,13 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
// Returns true if the result contains an error and the message is not empty
static bool HasErrorMsg(const util::Result<SelectionResult>& res) { return !util::ErrorString(res).empty(); }

util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, OutputGroupTypeMap& groups,
util::Result<SelectionResult> AttemptSelection(interfaces::Chain& chain, const CAmount& nTargetValue, OutputGroupTypeMap& groups,
const CoinSelectionParams& coin_selection_params, bool allow_mixed_output_types)
{
// Run coin selection on each OutputType and compute the Waste Metric
std::vector<SelectionResult> results;
for (auto& [type, group] : groups.groups_by_type) {
auto result{ChooseSelectionResult(nTargetValue, group, coin_selection_params)};
auto result{ChooseSelectionResult(chain, nTargetValue, group, coin_selection_params)};
// If any specific error message appears here, then something particularly wrong happened.
if (HasErrorMsg(result)) return result; // So let's return the specific error.
// Append the favorable result.
Expand All @@ -564,14 +564,14 @@ util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, Outp
// over all available coins, which would allow mixing.
// If TypesCount() <= 1, there is nothing to mix.
if (allow_mixed_output_types && groups.TypesCount() > 1) {
return ChooseSelectionResult(nTargetValue, groups.all_groups, coin_selection_params);
return ChooseSelectionResult(chain, nTargetValue, groups.all_groups, coin_selection_params);
}
// 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
// find a solution using all available coins
return util::Error();
};

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

// 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.
if (auto knapsack_result{KnapsackSolver(groups.mixed_group, nTargetValue, coin_selection_params.m_min_change_target, coin_selection_params.rng_fast, max_inputs_weight)}) {
knapsack_result->ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
results.push_back(*knapsack_result);
} else append_error(knapsack_result);

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

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

// If the chosen input set has unconfirmed inputs, check for synergies from overlapping ancestry
for (auto& result : results) {
std::vector<COutPoint> outpoints;
std::set<std::shared_ptr<COutput>> coins = result.GetInputSet();
CAmount summed_bump_fees = 0;
for (auto& coin : coins) {
if (coin->depth > 0) continue; // Bump fees only exist for unconfirmed inputs
outpoints.push_back(coin->outpoint);
summed_bump_fees += coin->ancestor_bump_fees;
}
std::optional<CAmount> combined_bump_fee = chain.CalculateCombinedBumpFee(outpoints, coin_selection_params.m_effective_feerate);
if (!combined_bump_fee.has_value()) {
return util::Error{_("Failed to calculate bump fees, because unconfirmed UTXOs depend on enormous cluster of unconfirmed transactions.")};
}
CAmount bump_fee_overestimate = summed_bump_fees - combined_bump_fee.value();
if (bump_fee_overestimate) {
result.SetBumpFeeDiscount(bump_fee_overestimate);
}
result.ComputeAndSetWaste(coin_selection_params.min_viable_change, coin_selection_params.m_cost_of_change, coin_selection_params.m_change_fee);
}

// Choose the result with the least waste
// If the waste is the same, choose the one which spends more inputs.
return *std::min_element(results.begin(), results.end());
Expand Down Expand Up @@ -740,7 +759,7 @@ util::Result<SelectionResult> AutomaticCoinSelection(const CWallet& wallet, Coin
for (const auto& select_filter : ordered_filters) {
auto it = filtered_groups.find(select_filter.filter);
if (it == filtered_groups.end()) continue;
if (auto res{AttemptSelection(value_to_select, it->second,
if (auto res{AttemptSelection(wallet.chain(), value_to_select, it->second,
coin_selection_params, select_filter.allow_mixed_output_types)}) {
return res; // result found
} else {
Expand Down
6 changes: 4 additions & 2 deletions src/wallet/spend.h
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
* the solution (according to the waste metric) will be chosen. If a valid input cannot be found from any
* single OutputType, fallback to running `ChooseSelectionResult()` over all available coins.
*
* param@[in] chain The chain interface to get information on unconfirmed UTXOs bump fees
* param@[in] nTargetValue The target value
* param@[in] groups The grouped outputs mapped by coin eligibility filters
* param@[in] coin_selection_params Parameters for the coin selection
Expand All @@ -132,14 +133,15 @@ FilteredOutputGroups GroupOutputs(const CWallet& wallet,
* or (2) an specific error message if there was something particularly wrong (e.g. a selection
* result that surpassed the tx max weight size).
*/
util::Result<SelectionResult> AttemptSelection(const CAmount& nTargetValue, OutputGroupTypeMap& groups,
util::Result<SelectionResult> AttemptSelection(interfaces::Chain& chain, const CAmount& nTargetValue, OutputGroupTypeMap& groups,
const CoinSelectionParams& coin_selection_params, bool allow_mixed_output_types);

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

// User manually selected inputs that must be part of the transaction
struct PreSelectedInputs
Expand Down
57 changes: 57 additions & 0 deletions src/wallet/test/coinselector_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -954,6 +954,63 @@ BOOST_AUTO_TEST_CASE(waste_test)
}
}


BOOST_AUTO_TEST_CASE(bump_fee_test)
{
const CAmount fee{100};
const CAmount min_viable_change{200};
const CAmount change_cost{125};
const CAmount change_fee{35};
const CAmount fee_diff{40};
const CAmount target{2 * COIN};

{
SelectionResult selection{target, SelectionAlgorithm::MANUAL};
add_coin(1 * COIN, 1, selection, /*fee=*/fee, /*long_term_fee=*/fee + fee_diff);
add_coin(2 * COIN, 2, selection, fee, fee + fee_diff);
const std::vector<std::shared_ptr<COutput>> inputs = selection.GetShuffledInputVector();

for (size_t i = 0; i < inputs.size(); ++i) {
inputs[i]->ApplyBumpFee(20*(i+1));
}

selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
CAmount expected_waste = fee_diff * -2 + change_cost + /*bump_fees=*/60;
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());

selection.SetBumpFeeDiscount(30);
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
expected_waste = fee_diff * -2 + change_cost + /*bump_fees=*/60 - /*group_discount=*/30;
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
}

{
// Test with changeless transaction
//
// Bump fees and excess both contribute fully to the waste score,
// therefore, a bump fee group discount will not change the waste
// score as long as we do not create change in both instances.
CAmount changeless_target = 3 * COIN - 2 * fee - 100;
SelectionResult selection{changeless_target, SelectionAlgorithm::MANUAL};
add_coin(1 * COIN, 1, selection, /*fee=*/fee, /*long_term_fee=*/fee + fee_diff);
add_coin(2 * COIN, 2, selection, fee, fee + fee_diff);
const std::vector<std::shared_ptr<COutput>> inputs = selection.GetShuffledInputVector();

for (size_t i = 0; i < inputs.size(); ++i) {
inputs[i]->ApplyBumpFee(20*(i+1));
}

selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
CAmount expected_waste = fee_diff * -2 + /*bump_fees=*/60 + /*excess = 100 - bump_fees*/40;
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());

selection.SetBumpFeeDiscount(30);
selection.ComputeAndSetWaste(min_viable_change, change_cost, change_fee);
expected_waste = fee_diff * -2 + /*bump_fees=*/60 - /*group_discount=*/30 + /*excess = 100 - bump_fees + group_discount*/70;
BOOST_CHECK_EQUAL(expected_waste, selection.GetWaste());
}
}

BOOST_AUTO_TEST_CASE(effective_value_test)
{
const int input_bytes = 148;
Expand Down
24 changes: 24 additions & 0 deletions test/functional/wallet_spend_unconfirmed.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,28 @@ def test_rbf_bumping(self):

wallet.unloadwallet()

# Test that transaction spending two UTXOs with overlapping ancestry does not bump shared ancestors twice
def test_target_feerate_unconfirmed_low_overlapping_ancestry(self):
self.log.info("Start test where two UTXOs have overlapping ancestry")
wallet = self.setup_and_fund_wallet("overlapping_ancestry_wallet")

parent_txid = wallet.sendtoaddress(address=wallet.getnewaddress(), amount=1, fee_rate=1)
two_output_parent_tx = wallet.gettransaction(txid=parent_txid, verbose=True)

self.assert_undershoots_target(two_output_parent_tx)

# spend both outputs from parent transaction
ancestor_aware_txid = wallet.sendtoaddress(address=self.def_wallet.getnewaddress(), amount=1.5, fee_rate=self.target_fee_rate)
ancestor_aware_tx = wallet.gettransaction(txid=ancestor_aware_txid, verbose=True)
self.assert_spends_only_parents(ancestor_aware_tx, [parent_txid, parent_txid])

self.assert_beats_target(ancestor_aware_tx)
resulting_ancestry_fee_rate = self.calc_set_fee_rate([two_output_parent_tx, ancestor_aware_tx])
assert_greater_than_or_equal(resulting_ancestry_fee_rate, self.target_fee_rate)
assert_greater_than_or_equal(self.target_fee_rate*1.01, resulting_ancestry_fee_rate)

wallet.unloadwallet()

# Test that new transaction ignores sibling transaction with low feerate
def test_sibling_tx_gets_ignored(self):
self.log.info("Start test where a low-fee sibling tx gets created and check that bumping ignores it")
Expand Down Expand Up @@ -472,6 +494,8 @@ def run_test(self):

self.test_rbf_bumping()

self.test_target_feerate_unconfirmed_low_overlapping_ancestry()

self.test_sibling_tx_gets_ignored()

self.test_sibling_tx_bumps_parent()
Expand Down

0 comments on commit 42a9935

Please sign in to comment.