Coverage Report

Created: 2026-04-29 19:21

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/tmp/bitcoin/src/wallet/test/group_outputs_tests.cpp
Line
Count
Source
1
// Copyright (c) 2022-present The Bitcoin Core developers
2
// Distributed under the MIT software license, see the accompanying
3
// file COPYING or https://www.opensource.org/licenses/mit-license.php.
4
5
#include <test/util/setup_common.h>
6
7
#include <wallet/coinselection.h>
8
#include <wallet/spend.h>
9
#include <wallet/test/util.h>
10
#include <wallet/wallet.h>
11
12
#include <boost/test/unit_test.hpp>
13
14
namespace wallet {
15
BOOST_FIXTURE_TEST_SUITE(group_outputs_tests, TestingSetup)
16
17
static int nextLockTime = 0;
18
19
static std::shared_ptr<CWallet> NewWallet(const node::NodeContext& m_node)
20
1
{
21
1
    std::unique_ptr<CWallet> wallet = std::make_unique<CWallet>(m_node.chain.get(), "", CreateMockableWalletDatabase());
22
1
    LOCK(wallet->cs_wallet);
23
1
    wallet->SetWalletFlag(WALLET_FLAG_DESCRIPTORS);
24
1
    wallet->SetupDescriptorScriptPubKeyMans();
25
1
    return wallet;
26
1
}
27
28
static void addCoin(CoinsResult& coins,
29
                     CWallet& wallet,
30
                     const CTxDestination& dest,
31
                     const CAmount& nValue,
32
                     bool is_from_me,
33
                     CFeeRate fee_rate = CFeeRate(0),
34
                     int depth = 6)
35
124
{
36
124
    CMutableTransaction tx;
37
124
    tx.nLockTime = nextLockTime++;        // so all transactions get different hashes
38
124
    tx.vout.resize(1);
39
124
    tx.vout[0].nValue = nValue;
40
124
    tx.vout[0].scriptPubKey = GetScriptForDestination(dest);
41
42
124
    const auto txid{tx.GetHash()};
43
124
    LOCK(wallet.cs_wallet);
44
124
    auto ret = wallet.mapWallet.emplace(std::piecewise_construct, std::forward_as_tuple(txid), std::forward_as_tuple(MakeTransactionRef(std::move(tx)), TxStateInactive{}));
45
124
    assert(ret.second);
46
124
    CWalletTx& wtx = (*ret.first).second;
47
124
    const auto& txout = wtx.tx->vout.at(0);
48
124
    coins.Add(*Assert(OutputTypeFromDestination(dest)),
49
124
              {COutPoint(wtx.GetHash(), 0),
50
124
                   txout,
51
124
                   depth,
52
124
                   CalculateMaximumSignedInputSize(txout, &wallet, /*coin_control=*/nullptr),
53
124
                   /*solvable=*/ true,
54
124
                   /*safe=*/ true,
55
124
                   wtx.GetTxTime(),
56
124
                   is_from_me,
57
124
                   fee_rate});
58
124
}
59
60
 CoinSelectionParams makeSelectionParams(FastRandomContext& rand, bool avoid_partial_spends)
61
16
{
62
16
    return CoinSelectionParams{
63
16
            rand,
64
16
            /*change_output_size=*/ 0,
65
16
            /*change_spend_size=*/ 0,
66
16
            /*min_change_target=*/ CENT,
67
16
            /*effective_feerate=*/ CFeeRate(0),
68
16
            /*long_term_feerate=*/ CFeeRate(0),
69
16
            /*discard_feerate=*/ CFeeRate(0),
70
16
            /*tx_noinputs_size=*/ 0,
71
16
            /*avoid_partial=*/ avoid_partial_spends,
72
16
    };
73
16
}
74
75
class GroupVerifier
76
{
77
public:
78
    std::shared_ptr<CWallet> wallet{nullptr};
79
    CoinsResult coins_pool;
80
    FastRandomContext rand;
81
82
    void GroupVerify(const OutputType type,
83
                     const CoinEligibilityFilter& filter,
84
                     bool avoid_partial_spends,
85
                     bool positive_only,
86
                     int expected_size)
87
16
    {
88
16
        OutputGroupTypeMap groups = GroupOutputs(*wallet, coins_pool, makeSelectionParams(rand, avoid_partial_spends), {{filter}})[filter];
89
16
        std::vector<OutputGroup>& groups_out = positive_only ? groups.groups_by_type[type].positive_group :
90
16
                                               groups.groups_by_type[type].mixed_group;
91
16
        BOOST_CHECK_EQUAL(groups_out.size(), expected_size);
92
16
    }
93
94
    void GroupAndVerify(const OutputType type,
95
                        const CoinEligibilityFilter& filter,
96
                        int expected_with_partial_spends_size,
97
                        int expected_without_partial_spends_size,
98
                        bool positive_only)
99
8
    {
100
        // First avoid partial spends
101
8
        GroupVerify(type, filter, /*avoid_partial_spends=*/false, positive_only,  expected_with_partial_spends_size);
102
        // Second don't avoid partial spends
103
8
        GroupVerify(type, filter, /*avoid_partial_spends=*/true, positive_only, expected_without_partial_spends_size);
104
8
    }
105
};
106
107
BOOST_AUTO_TEST_CASE(outputs_grouping_tests)
108
1
{
109
1
    const auto& wallet = NewWallet(m_node);
110
1
    GroupVerifier group_verifier;
111
1
    group_verifier.wallet = wallet;
112
113
1
    const CoinEligibilityFilter& BASIC_FILTER{1, 6, 0};
114
115
    // #################################################################################
116
    // 10 outputs from different txs going to the same script
117
    // 1) if partial spends is enabled --> must not be grouped
118
    // 2) if partial spends is not enabled --> must be grouped into a single OutputGroup
119
    // #################################################################################
120
121
1
    unsigned long GROUP_SIZE = 10;
122
1
    const CTxDestination dest = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
123
11
    for (unsigned long i = 0; i < GROUP_SIZE; i++) {
124
10
        addCoin(group_verifier.coins_pool, *wallet, dest, 10 * COIN, /*is_from_me=*/true);
125
10
    }
126
127
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
128
1
                                  BASIC_FILTER,
129
1
                                  /*expected_with_partial_spends_size=*/ GROUP_SIZE,
130
1
                                  /*expected_without_partial_spends_size=*/ 1,
131
1
                                  /*positive_only=*/ true);
132
133
    // ####################################################################################
134
    // 3) 10 more UTXO are added with a different script --> must be grouped into a single
135
    //    group for avoid partial spends and 10 different output groups for partial spends
136
    // ####################################################################################
137
138
1
    const CTxDestination dest2 = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
139
11
    for (unsigned long i = 0; i < GROUP_SIZE; i++) {
140
10
        addCoin(group_verifier.coins_pool, *wallet, dest2, 5 * COIN, /*is_from_me=*/true);
141
10
    }
142
143
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
144
1
            BASIC_FILTER,
145
1
            /*expected_with_partial_spends_size=*/ GROUP_SIZE * 2,
146
1
            /*expected_without_partial_spends_size=*/ 2,
147
1
            /*positive_only=*/ true);
148
149
    // ################################################################################
150
    // 4) Now add a negative output --> which will be skipped if "positive_only" is set
151
    // ################################################################################
152
153
1
    const CTxDestination dest3 = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
154
1
    addCoin(group_verifier.coins_pool, *wallet, dest3, 1, true, CFeeRate(100));
155
1
    BOOST_CHECK(group_verifier.coins_pool.coins[OutputType::BECH32].back().GetEffectiveValue() <= 0);
156
157
    // First expect no changes with "positive_only" enabled
158
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
159
1
            BASIC_FILTER,
160
1
            /*expected_with_partial_spends_size=*/ GROUP_SIZE * 2,
161
1
            /*expected_without_partial_spends_size=*/ 2,
162
1
            /*positive_only=*/ true);
163
164
    // Then expect changes with "positive_only" disabled
165
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
166
1
            BASIC_FILTER,
167
1
            /*expected_with_partial_spends_size=*/ GROUP_SIZE * 2 + 1,
168
1
            /*expected_without_partial_spends_size=*/ 3,
169
1
            /*positive_only=*/ false);
170
171
172
    // ##############################################################################
173
    // 5) Try to add a non-eligible UTXO (due not fulfilling the min depth target for
174
    //    "not mine" UTXOs) --> it must not be added to any group
175
    // ##############################################################################
176
177
1
    const CTxDestination dest4 = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
178
1
    addCoin(group_verifier.coins_pool, *wallet, dest4, 6 * COIN,
179
1
            /*is_from_me=*/false, CFeeRate(0), /*depth=*/5);
180
181
    // Expect no changes from this round and the previous one (point 4)
182
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
183
1
            BASIC_FILTER,
184
1
            /*expected_with_partial_spends_size=*/ GROUP_SIZE * 2 + 1,
185
1
            /*expected_without_partial_spends_size=*/ 3,
186
1
            /*positive_only=*/ false);
187
188
189
    // ##############################################################################
190
    // 6) Try to add a non-eligible UTXO (due not fulfilling the min depth target for
191
    //    "mine" UTXOs) --> it must not be added to any group
192
    // ##############################################################################
193
194
1
    const CTxDestination dest5 = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
195
1
    addCoin(group_verifier.coins_pool, *wallet, dest5, 6 * COIN,
196
1
            /*is_from_me=*/true, CFeeRate(0), /*depth=*/0);
197
198
    // Expect no changes from this round and the previous one (point 5)
199
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
200
1
            BASIC_FILTER,
201
1
            /*expected_with_partial_spends_size=*/ GROUP_SIZE * 2 + 1,
202
1
            /*expected_without_partial_spends_size=*/ 3,
203
1
            /*positive_only=*/ false);
204
205
    // ###########################################################################################
206
    // 7) Surpass the OUTPUT_GROUP_MAX_ENTRIES and verify that a second partial group gets created
207
    // ###########################################################################################
208
209
1
    const CTxDestination dest7 = *Assert(wallet->GetNewDestination(OutputType::BECH32, ""));
210
1
    uint16_t NUM_SINGLE_ENTRIES = 101;
211
102
    for (unsigned long i = 0; i < NUM_SINGLE_ENTRIES; i++) { // OUTPUT_GROUP_MAX_ENTRIES{100}
212
101
        addCoin(group_verifier.coins_pool, *wallet, dest7, 9 * COIN, /*is_from_me=*/true);
213
101
    }
214
215
    // Exclude partial groups only adds one more group to the previous test case (point 6)
216
1
    int PREVIOUS_ROUND_COUNT = GROUP_SIZE * 2 + 1;
217
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
218
1
            BASIC_FILTER,
219
1
            /*expected_with_partial_spends_size=*/ PREVIOUS_ROUND_COUNT + NUM_SINGLE_ENTRIES,
220
1
            /*expected_without_partial_spends_size=*/ 4,
221
1
            /*positive_only=*/ false);
222
223
    // Include partial groups should add one more group inside the "avoid partial spends" count
224
1
    const CoinEligibilityFilter& avoid_partial_groups_filter{1, 6, 0, 0, /*include_partial=*/ true};
225
1
    group_verifier.GroupAndVerify(OutputType::BECH32,
226
1
            avoid_partial_groups_filter,
227
1
            /*expected_with_partial_spends_size=*/ PREVIOUS_ROUND_COUNT + NUM_SINGLE_ENTRIES,
228
1
            /*expected_without_partial_spends_size=*/ 5,
229
1
            /*positive_only=*/ false);
230
1
}
231
232
BOOST_AUTO_TEST_SUITE_END()
233
} // end namespace wallet