Skip to content

Commit 6d5771b

Browse files
committed
Merge bitcoin/bitcoin#24494: wallet: generate random change target for each tx for better privacy
9053f64 [doc] release notes for random change target (glozow) 46f2fed [wallet] remove MIN_CHANGE (glozow) a44236a [wallet] randomly generate change targets (glozow) 1e52e6b refactor coin selection for parameterizable change target (glozow) Pull request description: Closes #24458 - the wallet always chooses 1 million sats as its change target, making it easier to fingerprint transactions created by the Core wallet. Instead of using a fixed value, choose one randomly each time (within a range). Using 50ksat (around $20) as the lower bound and `min(1 million sat, 2 * average payment value)` as the upper bound. RFC: If the payment is <25ksat, this doesn't work, so we're using the range (payment amount, 50ksat) instead. ACKs for top commit: achow101: ACK 9053f64 Xekyo: reACK 9053f64 Tree-SHA512: 45ce5d064697065549473347648e29935733f3deffc71a6ab995449431f60302d1f9911a0994dfdb960b48c48b5d8859f168b396ff2a62db67d535a7db041d35
2 parents f66c827 + 9053f64 commit 6d5771b

File tree

8 files changed

+157
-92
lines changed

8 files changed

+157
-92
lines changed

doc/release-notes-24494.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
To help prevent fingerprinting transactions created by the Bitcoin Core wallet, change output
2+
amounts are now randomized. (#24494)

src/bench/coin_selection.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
using node::NodeContext;
1515
using wallet::AttemptSelection;
16+
using wallet::CHANGE_LOWER;
1617
using wallet::COutput;
1718
using wallet::CWallet;
1819
using wallet::CWalletTx;
@@ -66,6 +67,7 @@ static void CoinSelection(benchmark::Bench& bench)
6667
rand,
6768
/* change_output_size= */ 34,
6869
/* change_spend_size= */ 148,
70+
/*min_change_target=*/ CHANGE_LOWER,
6971
/* effective_feerate= */ CFeeRate(0),
7072
/* long_term_feerate= */ CFeeRate(0),
7173
/* discard_feerate= */ CFeeRate(0),

src/qt/coincontroldialog.cpp

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
#include <QTreeWidget>
3434

3535
using wallet::CCoinControl;
36-
using wallet::MIN_CHANGE;
3736

3837
QList<CAmount> CoinControlDialog::payAmounts;
3938
bool CoinControlDialog::fSubtractFeeFromAmount = false;
@@ -486,11 +485,10 @@ void CoinControlDialog::updateLabels(CCoinControl& m_coin_control, WalletModel *
486485
if (!CoinControlDialog::fSubtractFeeFromAmount)
487486
nChange -= nPayFee;
488487

489-
// Never create dust outputs; if we would, just add the dust to the fee.
490-
if (nChange > 0 && nChange < MIN_CHANGE)
491-
{
488+
if (nChange > 0) {
492489
// Assumes a p2pkh script size
493490
CTxOut txout(nChange, CScript() << std::vector<unsigned char>(24, 0));
491+
// Never create dust outputs; if we would, just add the dust to the fee.
494492
if (IsDust(txout, model->node().getDustRelayFee()))
495493
{
496494
nPayFee += nChange;

src/wallet/coinselection.cpp

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,24 @@ std::optional<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& ut
187187
return std::nullopt;
188188
}
189189

190-
static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::vector<OutputGroup>& groups, const CAmount& nTotalLower, const CAmount& nTargetValue,
190+
/** Find a subset of the OutputGroups that is at least as large as, but as close as possible to, the
191+
* target amount; solve subset sum.
192+
* param@[in] groups OutputGroups to choose from, sorted by value in descending order.
193+
* param@[in] nTotalLower Total (effective) value of the UTXOs in groups.
194+
* param@[in] nTargetValue Subset sum target, not including change.
195+
* param@[out] vfBest Boolean vector representing the subset chosen that is closest to
196+
* nTargetValue, with indices corresponding to groups. If the ith
197+
* entry is true, that means the ith group in groups was selected.
198+
* param@[out] nBest Total amount of subset chosen that is closest to nTargetValue.
199+
* param@[in] iterations Maximum number of tries.
200+
*/
201+
static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::vector<OutputGroup>& groups,
202+
const CAmount& nTotalLower, const CAmount& nTargetValue,
191203
std::vector<char>& vfBest, CAmount& nBest, int iterations = 1000)
192204
{
193205
std::vector<char> vfIncluded;
194206

207+
// Worst case "best" approximation is just all of the groups.
195208
vfBest.assign(groups.size(), true);
196209
nBest = nTotalLower;
197210

@@ -217,6 +230,8 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
217230
if (nTotal >= nTargetValue)
218231
{
219232
fReachedTarget = true;
233+
// If the total is between nTargetValue and nBest, it's our new best
234+
// approximation.
220235
if (nTotal < nBest)
221236
{
222237
nBest = nTotal;
@@ -231,12 +246,15 @@ static void ApproximateBestSubset(FastRandomContext& insecure_rand, const std::v
231246
}
232247
}
233248

234-
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue, FastRandomContext& rng)
249+
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
250+
CAmount change_target, FastRandomContext& rng)
235251
{
236252
SelectionResult result(nTargetValue);
237253

238254
// List of values less than target
239255
std::optional<OutputGroup> lowest_larger;
256+
// Groups with selection amount smaller than the target and any change we might produce.
257+
// Don't include groups larger than this, because they will only cause us to overshoot.
240258
std::vector<OutputGroup> applicable_groups;
241259
CAmount nTotalLower = 0;
242260

@@ -246,7 +264,7 @@ std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups,
246264
if (group.GetSelectionAmount() == nTargetValue) {
247265
result.AddInput(group);
248266
return result;
249-
} else if (group.GetSelectionAmount() < nTargetValue + MIN_CHANGE) {
267+
} else if (group.GetSelectionAmount() < nTargetValue + change_target) {
250268
applicable_groups.push_back(group);
251269
nTotalLower += group.GetSelectionAmount();
252270
} else if (!lowest_larger || group.GetSelectionAmount() < lowest_larger->GetSelectionAmount()) {
@@ -273,14 +291,14 @@ std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups,
273291
CAmount nBest;
274292

275293
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue, vfBest, nBest);
276-
if (nBest != nTargetValue && nTotalLower >= nTargetValue + MIN_CHANGE) {
277-
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue + MIN_CHANGE, vfBest, nBest);
294+
if (nBest != nTargetValue && nTotalLower >= nTargetValue + change_target) {
295+
ApproximateBestSubset(rng, applicable_groups, nTotalLower, nTargetValue + change_target, vfBest, nBest);
278296
}
279297

280298
// If we have a bigger coin and (either the stochastic approximation didn't find a good solution,
281299
// or the next bigger coin is closer), return the bigger coin
282300
if (lowest_larger &&
283-
((nBest != nTargetValue && nBest < nTargetValue + MIN_CHANGE) || lowest_larger->GetSelectionAmount() <= nBest)) {
301+
((nBest != nTargetValue && nBest < nTargetValue + change_target) || lowest_larger->GetSelectionAmount() <= nBest)) {
284302
result.AddInput(*lowest_larger);
285303
} else {
286304
for (unsigned int i = 0; i < applicable_groups.size(); i++) {
@@ -380,6 +398,17 @@ CAmount GetSelectionWaste(const std::set<COutput>& inputs, CAmount change_cost,
380398
return waste;
381399
}
382400

401+
CAmount GenerateChangeTarget(CAmount payment_value, FastRandomContext& rng)
402+
{
403+
if (payment_value <= CHANGE_LOWER / 2) {
404+
return CHANGE_LOWER;
405+
} else {
406+
// random value between 50ksat and min (payment_value * 2, 1milsat)
407+
const auto upper_bound = std::min(payment_value * 2, CHANGE_UPPER);
408+
return rng.randrange(upper_bound - CHANGE_LOWER) + CHANGE_LOWER;
409+
}
410+
}
411+
383412
void SelectionResult::ComputeAndSetWaste(CAmount change_cost)
384413
{
385414
m_waste = GetSelectionWaste(m_selected_inputs, change_cost, m_target, m_use_effective);

src/wallet/coinselection.h

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
#include <optional>
1414

1515
namespace wallet {
16-
//! target minimum change amount
17-
static constexpr CAmount MIN_CHANGE{COIN / 100};
18-
//! final minimum change amount after paying for fees
19-
static const CAmount MIN_FINAL_CHANGE = MIN_CHANGE/2;
16+
//! lower bound for randomly-chosen target change amount
17+
static constexpr CAmount CHANGE_LOWER{50000};
18+
//! upper bound for randomly-chosen target change amount
19+
static constexpr CAmount CHANGE_UPPER{1000000};
2020

2121
/** A UTXO under consideration for use in funding a new transaction. */
2222
struct COutput {
@@ -93,8 +93,13 @@ struct CoinSelectionParams {
9393
size_t change_output_size = 0;
9494
/** Size of the input to spend a change output in virtual bytes. */
9595
size_t change_spend_size = 0;
96+
/** Mininmum change to target in Knapsack solver: select coins to cover the payment and
97+
* at least this value of change. */
98+
CAmount m_min_change_target{0};
9699
/** Cost of creating the change output. */
97100
CAmount m_change_fee{0};
101+
/** The pre-determined minimum value to target when funding a change output. */
102+
CAmount m_change_target{0};
98103
/** Cost of creating the change output + cost of spending the change output in the future. */
99104
CAmount m_cost_of_change{0};
100105
/** The targeted feerate of the transaction being built. */
@@ -114,11 +119,13 @@ struct CoinSelectionParams {
114119
* reuse. Dust outputs are not eligible to be added to output groups and thus not considered. */
115120
bool m_avoid_partial_spends = false;
116121

117-
CoinSelectionParams(FastRandomContext& rng_fast, size_t change_output_size, size_t change_spend_size, CFeeRate effective_feerate,
122+
CoinSelectionParams(FastRandomContext& rng_fast, size_t change_output_size, size_t change_spend_size,
123+
CAmount min_change_target, CFeeRate effective_feerate,
118124
CFeeRate long_term_feerate, CFeeRate discard_feerate, size_t tx_noinputs_size, bool avoid_partial)
119125
: rng_fast{rng_fast},
120126
change_output_size(change_output_size),
121127
change_spend_size(change_spend_size),
128+
m_min_change_target(min_change_target),
122129
m_effective_feerate(effective_feerate),
123130
m_long_term_feerate(long_term_feerate),
124131
m_discard_feerate(discard_feerate),
@@ -217,6 +224,21 @@ struct OutputGroup
217224
*/
218225
[[nodiscard]] CAmount GetSelectionWaste(const std::set<COutput>& inputs, CAmount change_cost, CAmount target, bool use_effective_value = true);
219226

227+
228+
/** Chooose a random change target for each transaction to make it harder to fingerprint the Core
229+
* wallet based on the change output values of transactions it creates.
230+
* The random value is between 50ksat and min(2 * payment_value, 1milsat)
231+
* When payment_value <= 25ksat, the value is just 50ksat.
232+
*
233+
* Making change amounts similar to the payment value may help disguise which output(s) are payments
234+
* are which ones are change. Using double the payment value may increase the number of inputs
235+
* needed (and thus be more expensive in fees), but breaks analysis techniques which assume the
236+
* coins selected are just sufficient to cover the payment amount ("unnecessary input" heuristic).
237+
*
238+
* @param[in] payment_value Average payment value of the transaction output(s).
239+
*/
240+
[[nodiscard]] CAmount GenerateChangeTarget(CAmount payment_value, FastRandomContext& rng);
241+
220242
struct SelectionResult
221243
{
222244
private:
@@ -266,7 +288,8 @@ std::optional<SelectionResult> SelectCoinsBnB(std::vector<OutputGroup>& utxo_poo
266288
std::optional<SelectionResult> SelectCoinsSRD(const std::vector<OutputGroup>& utxo_pool, CAmount target_value, FastRandomContext& rng);
267289

268290
// Original coin selection algorithm as a fallback
269-
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue, FastRandomContext& rng);
291+
std::optional<SelectionResult> KnapsackSolver(std::vector<OutputGroup>& groups, const CAmount& nTargetValue,
292+
CAmount change_target, FastRandomContext& rng);
270293
} // namespace wallet
271294

272295
#endif // BITCOIN_WALLET_COINSELECTION_H

src/wallet/spend.cpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
#include <wallet/transaction.h>
2020
#include <wallet/wallet.h>
2121

22+
#include <cmath>
23+
2224
using interfaces::FoundBlock;
2325

2426
namespace wallet {
@@ -389,14 +391,17 @@ std::optional<SelectionResult> AttemptSelection(const CWallet& wallet, const CAm
389391
std::vector<OutputGroup> all_groups = GroupOutputs(wallet, coins, coin_selection_params, eligibility_filter, false /* positive_only */);
390392
// While nTargetValue includes the transaction fees for non-input things, it does not include the fee for creating a change output.
391393
// So we need to include that for KnapsackSolver as well, as we are expecting to create a change output.
392-
if (auto knapsack_result{KnapsackSolver(all_groups, nTargetValue + coin_selection_params.m_change_fee, coin_selection_params.rng_fast)}) {
394+
if (auto knapsack_result{KnapsackSolver(all_groups, nTargetValue + coin_selection_params.m_change_fee,
395+
coin_selection_params.m_min_change_target, coin_selection_params.rng_fast)}) {
393396
knapsack_result->ComputeAndSetWaste(coin_selection_params.m_cost_of_change);
394397
results.push_back(*knapsack_result);
395398
}
396399

397-
// We include the minimum final change for SRD as we do want to avoid making really small change.
398-
// KnapsackSolver does not need this because it includes MIN_CHANGE internally.
399-
const CAmount srd_target = nTargetValue + coin_selection_params.m_change_fee + MIN_FINAL_CHANGE;
400+
// Include change for SRD as we want to avoid making really small change if the selection just
401+
// barely meets the target. Just use the lower bound change target instead of the randomly
402+
// generated one, since SRD will result in a random change amount anyway; avoid making the
403+
// target needlessly large.
404+
const CAmount srd_target = nTargetValue + coin_selection_params.m_change_fee + CHANGE_LOWER;
400405
if (auto srd_result{SelectCoinsSRD(positive_groups, srd_target, coin_selection_params.rng_fast)}) {
401406
srd_result->ComputeAndSetWaste(coin_selection_params.m_cost_of_change);
402407
results.push_back(*srd_result);
@@ -680,6 +685,7 @@ static bool CreateTransactionInternal(
680685
coin_selection_params.m_subtract_fee_outputs = true;
681686
}
682687
}
688+
coin_selection_params.m_change_target = GenerateChangeTarget(std::floor(recipients_sum / vecSend.size()), rng_fast);
683689

684690
// Create change script that will be used if we need change
685691
CScript scriptChange;

0 commit comments

Comments
 (0)