Skip to content

Commit c189bfd

Browse files
committed
Merge #17824: wallet: Prefer full destination groups in coin selection
a2324e4 test: Improve naming and logging of avoid_reuse tests (Fabian Jahr) 1abbdac wallet: Prefer full destination groups in coin selection (Fabian Jahr) Pull request description: Fixes #17603 (together with #17843) In the case of destination groups of >10 outputs existing in a wallet with `avoid_reuse` enabled, the grouping algorithm is adding left-over outputs as an "incomplete" group to the list of groups even when a full group has already been added. This leads to the strange behavior that if there are >10 outputs for a destination the transaction spending from that will effectively use `len(outputs) % 10` as inputs for that transaction. From the original PR and the code comment I understand the correct behavior should be the usage of 10 outputs. I opted for minimal changes in the current code although there maybe optimizations possible for cases with >20 outputs on a destination this sounds like too much of an edge case right now. ACKs for top commit: jonatack: Re-ACK a2324e4 achow101: ACK a2324e4 kallewoof: ACK a2324e4 meshcollider: Tested ACK a2324e4 (verified the new test fails on master without this change) Tree-SHA512: 4743779c5d469fcd16df5baf166024b1d3c8eaca151df1e8281b71df62b29541cf7bfee3f8ab48d83e3b34c9256e53fd38a7b146a54c79f9caa44cce3636971a
2 parents 4a71c46 + a2324e4 commit c189bfd

File tree

3 files changed

+111
-29
lines changed

3 files changed

+111
-29
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: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,19 @@ def run_test(self):
8585
self.sync_all()
8686
self.test_change_remains_change(self.nodes[1])
8787
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
88-
self.test_fund_send_fund_senddirty()
88+
self.test_sending_from_reused_address_without_avoid_reuse()
8989
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
90-
self.test_fund_send_fund_send("legacy")
90+
self.test_sending_from_reused_address_fails("legacy")
9191
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
92-
self.test_fund_send_fund_send("p2sh-segwit")
92+
self.test_sending_from_reused_address_fails("p2sh-segwit")
9393
reset_balance(self.nodes[1], self.nodes[0].getnewaddress())
94-
self.test_fund_send_fund_send("bech32")
94+
self.test_sending_from_reused_address_fails("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.'''
@@ -162,13 +166,13 @@ def test_change_remains_change(self, node):
162166
for logical_tx in node.listtransactions():
163167
assert logical_tx.get('address') != changeaddr
164168

165-
def test_fund_send_fund_senddirty(self):
169+
def test_sending_from_reused_address_without_avoid_reuse(self):
166170
'''
167-
Test the same as test_fund_send_fund_send, except send the 10 BTC with
171+
Test the same as test_sending_from_reused_address_fails, except send the 10 BTC with
168172
the avoid_reuse flag set to false. This means the 10 BTC send should succeed,
169-
where it fails in test_fund_send_fund_send.
173+
where it fails in test_sending_from_reused_address_fails.
170174
'''
171-
self.log.info("Test fund send fund send dirty")
175+
self.log.info("Test sending from reused address with avoid_reuse=false")
172176

173177
fundaddr = self.nodes[1].getnewaddress()
174178
retaddr = self.nodes[0].getnewaddress()
@@ -213,7 +217,7 @@ def test_fund_send_fund_senddirty(self):
213217
assert_approx(self.nodes[1].getbalance(), 5, 0.001)
214218
assert_approx(self.nodes[1].getbalance(avoid_reuse=False), 5, 0.001)
215219

216-
def test_fund_send_fund_send(self, second_addr_type):
220+
def test_sending_from_reused_address_fails(self, second_addr_type):
217221
'''
218222
Test the simple case where [1] generates a new address A, then
219223
[0] sends 10 BTC to A.
@@ -222,7 +226,7 @@ def test_fund_send_fund_send(self, second_addr_type):
222226
[1] tries to spend 10 BTC (fails; dirty).
223227
[1] tries to spend 4 BTC (succeeds; change address sufficient)
224228
'''
225-
self.log.info("Test fund send fund send")
229+
self.log.info("Test sending from reused {} address fails".format(second_addr_type))
226230

227231
fundaddr = self.nodes[1].getnewaddress(label="", address_type="legacy")
228232
retaddr = self.nodes[0].getnewaddress()
@@ -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)