Skip to content

Commit 1abbae6

Browse files
committed
Merge bitcoin/bitcoin#24584: wallet: avoid mixing different OutputTypes during coin selection
71d1d13 test: add unit test for AvailableCoins (josibake) da03cb4 test: functional test for new coin selection logic (josibake) 438e048 wallet: run coin selection by `OutputType` (josibake) 77b0707 refactor: use CoinsResult struct in SelectCoins (josibake) 2e67291 refactor: store by OutputType in CoinsResult (josibake) Pull request description: # Concept Following bitcoin/bitcoin#23789, Bitcoin Core wallet will now generate a change address that matches the payment address type. This improves privacy by not revealing which of the outputs is the change at the time of the transaction in scenarios where the input address types differ from the payment address type. However, information about the change can be leaked in a later transaction. This proposal attempts to address that concern. ## Leaking information in a later transaction Consider the following scenario: ![mix input types(1)](https://user-images.githubusercontent.com/7444140/158597086-788339b0-c698-4b60-bd45-9ede4cd3a483.png) 1. Alice has a wallet with bech32 type UTXOs and pays Bob, who gives her a P2SH address 2. Alice's wallet generates a P2SH change output, preserving her privacy in `txid: a` 3. Alice then pays Carol, who gives her a bech32 address 4. Alice's wallet combines the P2SH UTXO with a bech32 UTXO and `txid: b` has two bech32 outputs From a chain analysis perspective, it is reasonable to infer that the P2SH input in `txid: b` was the change from `txid: a`. To avoid leaking information in this scenario, Alice's wallet should avoid picking the P2SH output and instead fund the transaction with only bech32 Outputs. If the payment to Carol can be funded with just the P2SH output, it should be preferred over the bech32 outputs as this will convert the P2SH UTXO to bech32 UTXOs via the payment and change outputs of the new transaction. **TLDR;** Avoid mixing output types, spend non-default `OutputTypes` when it is economical to do so. # Approach `AvailableCoins` now populates a struct, which makes it easier to access coins by `OutputType`. Coin selection tries to find a funding solution by each output type and chooses the most economical by waste metric. If a solution can't be found without mixing, coin selection runs over the entire wallet, allowing mixing, which is the same as the current behavior. I've also added a functional test (`test/functional/wallet_avoid_mixing_output_types.py`) and unit test (`src/wallet/test/availablecoins_tests.cpp`. ACKs for top commit: achow101: re-ACK 71d1d13 aureleoules: ACK 71d1d13. Xekyo: reACK 71d1d13 via `git range-diff master 6530d19 71d1d13` LarryRuane: ACK 71d1d13 Tree-SHA512: 2e0716efdae5adf5479446fabc731ae81d595131d3b8bade98b64ba323d0e0c6d964a67f8c14c89c428998bda47993fa924f3cfca1529e2bd49eaa4e31b7e426
2 parents 317ef03 + 71d1d13 commit 1abbae6

File tree

11 files changed

+612
-182
lines changed

11 files changed

+612
-182
lines changed

src/Makefile.test.include

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ BITCOIN_TESTS += \
168168
wallet/test/wallet_crypto_tests.cpp \
169169
wallet/test/wallet_transaction_tests.cpp \
170170
wallet/test/coinselector_tests.cpp \
171+
wallet/test/availablecoins_tests.cpp \
171172
wallet/test/init_tests.cpp \
172173
wallet/test/ismine_tests.cpp \
173174
wallet/test/scriptpubkeyman_tests.cpp

src/bench/coin_selection.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ static void CoinSelection(benchmark::Bench& bench)
5656
addCoin(3 * COIN, wallet, wtxs);
5757

5858
// Create coins
59-
std::vector<COutput> coins;
59+
wallet::CoinsResult available_coins;
6060
for (const auto& wtx : wtxs) {
6161
const auto txout = wtx->tx->vout.at(0);
62-
coins.emplace_back(COutPoint(wtx->GetHash(), 0), txout, /*depth=*/6 * 24, CalculateMaximumSignedInputSize(txout, &wallet, /*coin_control=*/nullptr), /*spendable=*/true, /*solvable=*/true, /*safe=*/true, wtx->GetTxTime(), /*from_me=*/true, /*fees=*/ 0);
62+
available_coins.bech32.emplace_back(COutPoint(wtx->GetHash(), 0), txout, /*depth=*/6 * 24, CalculateMaximumSignedInputSize(txout, &wallet, /*coin_control=*/nullptr), /*spendable=*/true, /*solvable=*/true, /*safe=*/true, wtx->GetTxTime(), /*from_me=*/true, /*fees=*/ 0);
6363
}
6464

6565
const CoinEligibilityFilter filter_standard(1, 6, 0);
@@ -76,7 +76,7 @@ static void CoinSelection(benchmark::Bench& bench)
7676
/*avoid_partial=*/ false,
7777
};
7878
bench.run([&] {
79-
auto result = AttemptSelection(wallet, 1003 * COIN, filter_standard, coins, coin_selection_params);
79+
auto result = AttemptSelection(wallet, 1003 * COIN, filter_standard, available_coins, coin_selection_params, /*allow_mixed_output_types=*/true);
8080
assert(result);
8181
assert(result->GetSelectedValue() == 1003 * COIN);
8282
assert(result->GetInputSet().size() == 2);

src/wallet/rpc/coins.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ RPCHelpMan listunspent()
638638
cctl.m_max_depth = nMaxDepth;
639639
cctl.m_include_unsafe_inputs = include_unsafe;
640640
LOCK(pwallet->cs_wallet);
641-
vecOutputs = AvailableCoinsListUnspent(*pwallet, &cctl, nMinimumAmount, nMaximumAmount, nMinimumSumAmount, nMaximumCount).coins;
641+
vecOutputs = AvailableCoinsListUnspent(*pwallet, &cctl, nMinimumAmount, nMaximumAmount, nMinimumSumAmount, nMaximumCount).all();
642642
}
643643

644644
LOCK(pwallet->cs_wallet);

src/wallet/rpc/spend.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,7 +1382,7 @@ RPCHelpMan sendall()
13821382
total_input_value += tx->tx->vout[input.prevout.n].nValue;
13831383
}
13841384
} else {
1385-
for (const COutput& output : AvailableCoins(*pwallet, &coin_control, fee_rate, /*nMinimumAmount=*/0).coins) {
1385+
for (const COutput& output : AvailableCoins(*pwallet, &coin_control, fee_rate, /*nMinimumAmount=*/0).all()) {
13861386
CHECK_NONFATAL(output.input_bytes > 0);
13871387
if (send_max && fee_rate.GetFee(output.input_bytes) > output.txout.nValue) {
13881388
continue;

src/wallet/spend.cpp

Lines changed: 135 additions & 29 deletions
Large diffs are not rendered by default.

src/wallet/spend.h

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,38 @@ struct TxSize {
2929
TxSize CalculateMaximumSignedTxSize(const CTransaction& tx, const CWallet* wallet, const std::vector<CTxOut>& txouts, const CCoinControl* coin_control = nullptr);
3030
TxSize CalculateMaximumSignedTxSize(const CTransaction& tx, const CWallet* wallet, const CCoinControl* coin_control = nullptr) EXCLUSIVE_LOCKS_REQUIRED(wallet->cs_wallet);
3131

32+
/**
33+
* COutputs available for spending, stored by OutputType.
34+
* This struct is really just a wrapper around OutputType vectors with a convenient
35+
* method for concatenating and returning all COutputs as one vector.
36+
*
37+
* clear(), size() methods are implemented so that one can interact with
38+
* the CoinsResult struct as if it was a vector
39+
*/
3240
struct CoinsResult {
33-
std::vector<COutput> coins;
34-
// Sum of all the coins amounts
41+
/** Vectors for each OutputType */
42+
std::vector<COutput> legacy;
43+
std::vector<COutput> P2SH_segwit;
44+
std::vector<COutput> bech32;
45+
std::vector<COutput> bech32m;
46+
47+
/** Other is a catch-all for anything that doesn't match the known OutputTypes */
48+
std::vector<COutput> other;
49+
50+
/** Concatenate and return all COutputs as one vector */
51+
std::vector<COutput> all() const;
52+
53+
/** The following methods are provided so that CoinsResult can mimic a vector,
54+
* i.e., methods can work with individual OutputType vectors or on the entire object */
55+
uint64_t size() const;
56+
void clear();
57+
58+
/** Sum of all available coins */
3559
CAmount total_amount{0};
3660
};
61+
3762
/**
38-
* Return vector of available COutputs.
39-
* By default, returns only the spendable coins.
63+
* Populate the CoinsResult struct with vectors of available COutputs, organized by OutputType.
4064
*/
4165
CoinsResult AvailableCoins(const CWallet& wallet,
4266
const CCoinControl* coinControl = nullptr,
@@ -67,35 +91,52 @@ const CTxOut& FindNonChangeParentOutput(const CWallet& wallet, const COutPoint&
6791
std::map<CTxDestination, std::vector<COutput>> ListCoins(const CWallet& wallet) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
6892

6993
std::vector<OutputGroup> GroupOutputs(const CWallet& wallet, const std::vector<COutput>& outputs, const CoinSelectionParams& coin_sel_params, const CoinEligibilityFilter& filter, bool positive_only);
94+
/**
95+
* Attempt to find a valid input set that preserves privacy by not mixing OutputTypes.
96+
* `ChooseSelectionResult()` will be called on each OutputType individually and the best
97+
* the solution (according to the waste metric) will be chosen. If a valid input cannot be found from any
98+
* single OutputType, fallback to running `ChooseSelectionResult()` over all available coins.
99+
*
100+
* param@[in] wallet The wallet which provides solving data for the coins
101+
* param@[in] nTargetValue The target value
102+
* param@[in] eligilibity_filter A filter containing rules for which coins are allowed to be included in this selection
103+
* param@[in] available_coins The struct of coins, organized by OutputType, available for selection prior to filtering
104+
* param@[in] coin_selection_params Parameters for the coin selection
105+
* param@[in] allow_mixed_output_types Relax restriction that SelectionResults must be of the same OutputType
106+
* returns If successful, a SelectionResult containing the input set
107+
* If failed, a nullopt
108+
*/
109+
std::optional<SelectionResult> AttemptSelection(const CWallet& wallet, const CAmount& nTargetValue, const CoinEligibilityFilter& eligibility_filter, const CoinsResult& available_coins,
110+
const CoinSelectionParams& coin_selection_params, bool allow_mixed_output_types);
70111

71112
/**
72113
* Attempt to find a valid input set that meets the provided eligibility filter and target.
73114
* Multiple coin selection algorithms will be run and the input set that produces the least waste
74115
* (according to the waste metric) will be chosen.
75116
*
76-
* param@[in] wallet The wallet which provides solving data for the coins
77-
* param@[in] nTargetValue The target value
78-
* param@[in] eligilibity_filter A filter containing rules for which coins are allowed to be included in this selection
79-
* param@[in] coins The vector of coins available for selection prior to filtering
80-
* param@[in] coin_selection_params Parameters for the coin selection
81-
* returns If successful, a SelectionResult containing the input set
82-
* If failed, a nullopt
117+
* param@[in] wallet The wallet which provides solving data for the coins
118+
* param@[in] nTargetValue The target value
119+
* param@[in] eligilibity_filter A filter containing rules for which coins are allowed to be included in this selection
120+
* param@[in] available_coins The struct of coins, organized by OutputType, available for selection prior to filtering
121+
* param@[in] coin_selection_params Parameters for the coin selection
122+
* returns If successful, a SelectionResult containing the input set
123+
* If failed, a nullopt
83124
*/
84-
std::optional<SelectionResult> AttemptSelection(const CWallet& wallet, const CAmount& nTargetValue, const CoinEligibilityFilter& eligibility_filter, std::vector<COutput> coins,
125+
std::optional<SelectionResult> ChooseSelectionResult(const CWallet& wallet, const CAmount& nTargetValue, const CoinEligibilityFilter& eligibility_filter, const std::vector<COutput>& available_coins,
85126
const CoinSelectionParams& coin_selection_params);
86127

87128
/**
88129
* Select a set of coins such that nTargetValue is met and at least
89130
* all coins from coin_control are selected; never select unconfirmed coins if they are not ours
90131
* param@[in] wallet The wallet which provides data necessary to spend the selected coins
91-
* param@[in] vAvailableCoins The vector of coins available to be spent
132+
* param@[in] available_coins The struct of coins, organized by OutputType, available for selection prior to filtering
92133
* param@[in] nTargetValue The target value
93134
* param@[in] coin_selection_params Parameters for this coin selection such as feerates, whether to avoid partial spends,
94135
* and whether to subtract the fee from the outputs.
95136
* returns If successful, a SelectionResult containing the selected coins
96137
* If failed, a nullopt.
97138
*/
98-
std::optional<SelectionResult> SelectCoins(const CWallet& wallet, const std::vector<COutput>& vAvailableCoins, const CAmount& nTargetValue, const CCoinControl& coin_control,
139+
std::optional<SelectionResult> SelectCoins(const CWallet& wallet, CoinsResult& available_coins, const CAmount& nTargetValue, const CCoinControl& coin_control,
99140
const CoinSelectionParams& coin_selection_params) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet);
100141

101142
struct CreatedTransactionResult
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2022 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 <validation.h>
6+
#include <wallet/coincontrol.h>
7+
#include <wallet/spend.h>
8+
#include <wallet/test/util.h>
9+
#include <wallet/test/wallet_test_fixture.h>
10+
11+
#include <boost/test/unit_test.hpp>
12+
13+
namespace wallet {
14+
BOOST_FIXTURE_TEST_SUITE(availablecoins_tests, WalletTestingSetup)
15+
class AvailableCoinsTestingSetup : public TestChain100Setup
16+
{
17+
public:
18+
AvailableCoinsTestingSetup()
19+
{
20+
CreateAndProcessBlock({}, {});
21+
wallet = CreateSyncedWallet(*m_node.chain, m_node.chainman->ActiveChain(), m_args, coinbaseKey);
22+
}
23+
24+
~AvailableCoinsTestingSetup()
25+
{
26+
wallet.reset();
27+
}
28+
CWalletTx& AddTx(CRecipient recipient)
29+
{
30+
CTransactionRef tx;
31+
CCoinControl dummy;
32+
{
33+
constexpr int RANDOM_CHANGE_POSITION = -1;
34+
auto res = CreateTransaction(*wallet, {recipient}, RANDOM_CHANGE_POSITION, dummy);
35+
BOOST_CHECK(res);
36+
tx = res.GetObj().tx;
37+
}
38+
wallet->CommitTransaction(tx, {}, {});
39+
CMutableTransaction blocktx;
40+
{
41+
LOCK(wallet->cs_wallet);
42+
blocktx = CMutableTransaction(*wallet->mapWallet.at(tx->GetHash()).tx);
43+
}
44+
CreateAndProcessBlock({CMutableTransaction(blocktx)}, GetScriptForRawPubKey(coinbaseKey.GetPubKey()));
45+
46+
LOCK(wallet->cs_wallet);
47+
wallet->SetLastBlockProcessed(wallet->GetLastBlockHeight() + 1, m_node.chainman->ActiveChain().Tip()->GetBlockHash());
48+
auto it = wallet->mapWallet.find(tx->GetHash());
49+
BOOST_CHECK(it != wallet->mapWallet.end());
50+
it->second.m_state = TxStateConfirmed{m_node.chainman->ActiveChain().Tip()->GetBlockHash(), m_node.chainman->ActiveChain().Height(), /*index=*/1};
51+
return it->second;
52+
}
53+
54+
std::unique_ptr<CWallet> wallet;
55+
};
56+
57+
BOOST_FIXTURE_TEST_CASE(BasicOutputTypesTest, AvailableCoinsTestingSetup)
58+
{
59+
CoinsResult available_coins;
60+
BResult<CTxDestination> dest;
61+
LOCK(wallet->cs_wallet);
62+
63+
// Verify our wallet has one usable coinbase UTXO before starting
64+
// This UTXO is a P2PK, so it should show up in the Other bucket
65+
available_coins = AvailableCoins(*wallet);
66+
BOOST_CHECK_EQUAL(available_coins.size(), 1U);
67+
BOOST_CHECK_EQUAL(available_coins.other.size(), 1U);
68+
69+
// We will create a self transfer for each of the OutputTypes and
70+
// verify it is put in the correct bucket after running GetAvailablecoins
71+
//
72+
// For each OutputType, We expect 2 UTXOs in our wallet following the self transfer:
73+
// 1. One UTXO as the recipient
74+
// 2. One UTXO from the change, due to payment address matching logic
75+
76+
// Bech32m
77+
dest = wallet->GetNewDestination(OutputType::BECH32M, "");
78+
BOOST_ASSERT(dest.HasRes());
79+
AddTx(CRecipient{{GetScriptForDestination(dest.GetObj())}, 1 * COIN, /*fSubtractFeeFromAmount=*/true});
80+
available_coins = AvailableCoins(*wallet);
81+
BOOST_CHECK_EQUAL(available_coins.bech32m.size(), 2U);
82+
83+
// Bech32
84+
dest = wallet->GetNewDestination(OutputType::BECH32, "");
85+
BOOST_ASSERT(dest.HasRes());
86+
AddTx(CRecipient{{GetScriptForDestination(dest.GetObj())}, 2 * COIN, /*fSubtractFeeFromAmount=*/true});
87+
available_coins = AvailableCoins(*wallet);
88+
BOOST_CHECK_EQUAL(available_coins.bech32.size(), 2U);
89+
90+
// P2SH-SEGWIT
91+
dest = wallet->GetNewDestination(OutputType::P2SH_SEGWIT, "");
92+
AddTx(CRecipient{{GetScriptForDestination(dest.GetObj())}, 3 * COIN, /*fSubtractFeeFromAmount=*/true});
93+
available_coins = AvailableCoins(*wallet);
94+
BOOST_CHECK_EQUAL(available_coins.P2SH_segwit.size(), 2U);
95+
96+
// Legacy (P2PKH)
97+
dest = wallet->GetNewDestination(OutputType::LEGACY, "");
98+
BOOST_ASSERT(dest.HasRes());
99+
AddTx(CRecipient{{GetScriptForDestination(dest.GetObj())}, 4 * COIN, /*fSubtractFeeFromAmount=*/true});
100+
available_coins = AvailableCoins(*wallet);
101+
BOOST_CHECK_EQUAL(available_coins.legacy.size(), 2U);
102+
}
103+
104+
BOOST_AUTO_TEST_SUITE_END()
105+
} // namespace wallet

0 commit comments

Comments
 (0)