Skip to content

Commit 1abbdac

Browse files
committed
wallet: Prefer full destination groups in coin selection
When a wallet uses avoid_reuse and has a large number of outputs in a single destination, it groups these outputs in OutputGroups that are no larger than OUTPUT_GROUP_MAX_ENTRIES. The goal is to spend as many outputs as possible from the destination while not breaking consensus due to a huge number of inputs and also not surprise the use with high fees. If there are n outputs in a destination and n > OUTPUT_GROUP_MAX_ENTRIES then this results in one or many groups of size OUTPUT_GROUP_MAX_ENTRIES and possibly one group of size < OUTPUT_GROUP_MAX_ENTRIES. Prior to this commit the coin selection in the case where n > OUTPUT_GROUP_MAX_ENTRIES was skewed towards the one group of size < OUTPUT_GROUP_MAX_ENTRIES if it exists and the amount to be spent by the transaction is smaller than the aggregate of those of the group size < OUTPUT_GROUP_MAX_ENTRIES. The reason is that the coin selection decides between the different groups based on fees and mostly the smaller group will cause smaller fees. The behavior that users of the avoid_reuse flag seek is that the full groups of size OUTPUT_GROUP_MAX_ENTRIES get used first. This commit implements this by pretending that the small group has a large number of ancestors (one smallet than the maximum allowed for this wallet). This dumps the small group to the bottom of the list of priorities in the coin selection algorithm.
1 parent 4702cad commit 1abbdac

File tree

3 files changed

+101
-19
lines changed

3 files changed

+101
-19
lines changed

src/wallet/wallet.cpp

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2372,6 +2372,13 @@ bool CWallet::SelectCoins(const std::vector<COutput>& vAvailableCoins, const CAm
23722372
++it;
23732373
}
23742374

2375+
unsigned int limit_ancestor_count = 0;
2376+
unsigned int limit_descendant_count = 0;
2377+
chain().getPackageLimits(limit_ancestor_count, limit_descendant_count);
2378+
size_t max_ancestors = (size_t)std::max<int64_t>(1, limit_ancestor_count);
2379+
size_t max_descendants = (size_t)std::max<int64_t>(1, limit_descendant_count);
2380+
bool fRejectLongChains = gArgs.GetBoolArg("-walletrejectlongchains", DEFAULT_WALLET_REJECT_LONG_CHAINS);
2381+
23752382
// form groups from remaining coins; note that preset coins will not
23762383
// automatically have their associated (same address) coins included
23772384
if (coin_control.m_avoid_partial_spends && vCoins.size() > OUTPUT_GROUP_MAX_ENTRIES) {
@@ -2380,14 +2387,7 @@ bool CWallet::SelectCoins(const std::vector<COutput>& vAvailableCoins, const CAm
23802387
// explicitly shuffling the outputs before processing
23812388
Shuffle(vCoins.begin(), vCoins.end(), FastRandomContext());
23822389
}
2383-
std::vector<OutputGroup> groups = GroupOutputs(vCoins, !coin_control.m_avoid_partial_spends);
2384-
2385-
unsigned int limit_ancestor_count;
2386-
unsigned int limit_descendant_count;
2387-
chain().getPackageLimits(limit_ancestor_count, limit_descendant_count);
2388-
size_t max_ancestors = (size_t)std::max<int64_t>(1, limit_ancestor_count);
2389-
size_t max_descendants = (size_t)std::max<int64_t>(1, limit_descendant_count);
2390-
bool fRejectLongChains = gArgs.GetBoolArg("-walletrejectlongchains", DEFAULT_WALLET_REJECT_LONG_CHAINS);
2390+
std::vector<OutputGroup> groups = GroupOutputs(vCoins, !coin_control.m_avoid_partial_spends, max_ancestors);
23912391

23922392
bool res = value_to_select <= 0 ||
23932393
SelectCoinsMinConf(value_to_select, CoinEligibilityFilter(1, 6, 0), groups, setCoinsRet, nValueRet, coin_selection_params, bnb_used) ||
@@ -4184,32 +4184,49 @@ bool CWalletTx::IsImmatureCoinBase() const
41844184
return GetBlocksToMaturity() > 0;
41854185
}
41864186

4187-
std::vector<OutputGroup> CWallet::GroupOutputs(const std::vector<COutput>& outputs, bool single_coin) const {
4187+
std::vector<OutputGroup> CWallet::GroupOutputs(const std::vector<COutput>& outputs, bool single_coin, const size_t max_ancestors) const {
41884188
std::vector<OutputGroup> groups;
41894189
std::map<CTxDestination, OutputGroup> gmap;
4190-
CTxDestination dst;
4190+
std::set<CTxDestination> full_groups;
4191+
41914192
for (const auto& output : outputs) {
41924193
if (output.fSpendable) {
4194+
CTxDestination dst;
41934195
CInputCoin input_coin = output.GetInputCoin();
41944196

41954197
size_t ancestors, descendants;
41964198
chain().getTransactionAncestry(output.tx->GetHash(), ancestors, descendants);
41974199
if (!single_coin && ExtractDestination(output.tx->tx->vout[output.i].scriptPubKey, dst)) {
4198-
// Limit output groups to no more than 10 entries, to protect
4199-
// against inadvertently creating a too-large transaction
4200-
// when using -avoidpartialspends
4201-
if (gmap[dst].m_outputs.size() >= OUTPUT_GROUP_MAX_ENTRIES) {
4202-
groups.push_back(gmap[dst]);
4203-
gmap.erase(dst);
4200+
auto it = gmap.find(dst);
4201+
if (it != gmap.end()) {
4202+
// Limit output groups to no more than OUTPUT_GROUP_MAX_ENTRIES
4203+
// number of entries, to protect against inadvertently creating
4204+
// a too-large transaction when using -avoidpartialspends to
4205+
// prevent breaking consensus or surprising users with a very
4206+
// high amount of fees.
4207+
if (it->second.m_outputs.size() >= OUTPUT_GROUP_MAX_ENTRIES) {
4208+
groups.push_back(it->second);
4209+
it->second = OutputGroup{};
4210+
full_groups.insert(dst);
4211+
}
4212+
it->second.Insert(input_coin, output.nDepth, output.tx->IsFromMe(ISMINE_ALL), ancestors, descendants);
4213+
} else {
4214+
gmap[dst].Insert(input_coin, output.nDepth, output.tx->IsFromMe(ISMINE_ALL), ancestors, descendants);
42044215
}
4205-
gmap[dst].Insert(input_coin, output.nDepth, output.tx->IsFromMe(ISMINE_ALL), ancestors, descendants);
42064216
} else {
42074217
groups.emplace_back(input_coin, output.nDepth, output.tx->IsFromMe(ISMINE_ALL), ancestors, descendants);
42084218
}
42094219
}
42104220
}
42114221
if (!single_coin) {
4212-
for (const auto& it : gmap) groups.push_back(it.second);
4222+
for (auto& it : gmap) {
4223+
auto& group = it.second;
4224+
if (full_groups.count(it.first) > 0) {
4225+
// Make this unattractive as we want coin selection to avoid it if possible
4226+
group.m_ancestors = max_ancestors - 1;
4227+
}
4228+
groups.push_back(group);
4229+
}
42134230
}
42144231
return groups;
42154232
}

src/wallet/wallet.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
830830
bool IsSpentKey(const uint256& hash, unsigned int n) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
831831
void SetSpentKeyState(WalletBatch& batch, const uint256& hash, unsigned int n, bool used, std::set<CTxDestination>& tx_destinations) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
832832

833-
std::vector<OutputGroup> GroupOutputs(const std::vector<COutput>& outputs, bool single_coin) const;
833+
std::vector<OutputGroup> GroupOutputs(const std::vector<COutput>& outputs, bool single_coin, const size_t max_ancestors) const;
834834

835835
bool IsLockedCoin(uint256 hash, unsigned int n) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);
836836
void LockCoin(const COutPoint& output) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet);

test/functional/wallet_avoidreuse.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ def run_test(self):
9494
self.test_fund_send_fund_send("bech32")
9595
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
9696
self.test_getbalances_used()
97+
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
98+
self.test_full_destination_group_is_preferred()
99+
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
100+
self.test_all_destination_groups_are_used()
97101

98102
def test_persistence(self):
99103
'''Test that wallet files persist the avoid_reuse flag.'''
@@ -313,5 +317,66 @@ def test_getbalances_used(self):
313317
assert_unspent(self.nodes[1], total_count=2, total_sum=6, reused_count=1, reused_sum=1)
314318
assert_balances(self.nodes[1], mine={"used": 1, "trusted": 5})
315319

320+
def test_full_destination_group_is_preferred(self):
321+
'''
322+
Test the case where [1] only has 11 outputs of 1 BTC in the same reused
323+
address and tries to send a small payment of 0.5 BTC. The wallet
324+
should use 10 outputs from the reused address as inputs and not a
325+
single 1 BTC input, in order to join several outputs from the reused
326+
address.
327+
'''
328+
self.log.info("Test that full destination groups are preferred in coin selection")
329+
330+
# Node under test should be empty
331+
assert_equal(self.nodes[1].getbalance(avoid_reuse=False), 0)
332+
333+
new_addr = self.nodes[1].getnewaddress()
334+
ret_addr = self.nodes[0].getnewaddress()
335+
336+
# Send 11 outputs of 1 BTC to the same, reused address in the wallet
337+
for _ in range(11):
338+
self.nodes[0].sendtoaddress(new_addr, 1)
339+
340+
self.nodes[0].generate(1)
341+
self.sync_all()
342+
343+
# Sending a transaction that is smaller than each one of the
344+
# available outputs
345+
txid = self.nodes[1].sendtoaddress(address=ret_addr, amount=0.5)
346+
inputs = self.nodes[1].getrawtransaction(txid, 1)["vin"]
347+
348+
# The transaction should use 10 inputs exactly
349+
assert_equal(len(inputs), 10)
350+
351+
def test_all_destination_groups_are_used(self):
352+
'''
353+
Test the case where [1] only has 22 outputs of 1 BTC in the same reused
354+
address and tries to send a payment of 20.5 BTC. The wallet
355+
should use all 22 outputs from the reused address as inputs.
356+
'''
357+
self.log.info("Test that all destination groups are used")
358+
359+
# Node under test should be empty
360+
assert_equal(self.nodes[1].getbalance(avoid_reuse=False), 0)
361+
362+
new_addr = self.nodes[1].getnewaddress()
363+
ret_addr = self.nodes[0].getnewaddress()
364+
365+
# Send 22 outputs of 1 BTC to the same, reused address in the wallet
366+
for _ in range(22):
367+
self.nodes[0].sendtoaddress(new_addr, 1)
368+
369+
self.nodes[0].generate(1)
370+
self.sync_all()
371+
372+
# Sending a transaction that needs to use the full groups
373+
# of 10 inputs but also the incomplete group of 2 inputs.
374+
txid = self.nodes[1].sendtoaddress(address=ret_addr, amount=20.5)
375+
inputs = self.nodes[1].getrawtransaction(txid, 1)["vin"]
376+
377+
# The transaction should use 22 inputs exactly
378+
assert_equal(len(inputs), 22)
379+
380+
316381
if __name__ == '__main__':
317382
AvoidReuseTest().main()

0 commit comments

Comments
 (0)