Skip to content

Commit a44236a

Browse files
committed
[wallet] randomly generate change targets
If the wallet always chooses 1 million sats as its change target, it is easier to fingerprint transactions created by the Core wallet.
1 parent 1e52e6b commit a44236a

File tree

4 files changed

+47
-4
lines changed

4 files changed

+47
-4
lines changed

src/wallet/coinselection.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,17 @@ CAmount GetSelectionWaste(const std::set<COutput>& inputs, CAmount change_cost,
398398
return waste;
399399
}
400400

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+
401412
void SelectionResult::ComputeAndSetWaste(CAmount change_cost)
402413
{
403414
m_waste = GetSelectionWaste(m_selected_inputs, change_cost, m_target, m_use_effective);

src/wallet/coinselection.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ namespace wallet {
1717
static constexpr CAmount MIN_CHANGE{COIN / 100};
1818
//! final minimum change amount after paying for fees
1919
static const CAmount MIN_FINAL_CHANGE = MIN_CHANGE/2;
20+
//! lower bound for randomly-chosen target change amount
21+
static constexpr CAmount CHANGE_LOWER{50000};
22+
//! upper bound for randomly-chosen target change amount
23+
static constexpr CAmount CHANGE_UPPER{1000000};
24+
// Ensure that any randomly generated change targets are less than or equal to before.
25+
// Otherwise, tests may fail if funds are not enough to cover change.
26+
static_assert(CHANGE_UPPER <= MIN_CHANGE);
27+
static_assert(CHANGE_LOWER <= MIN_FINAL_CHANGE);
2028

2129
/** A UTXO under consideration for use in funding a new transaction. */
2230
class COutput
@@ -99,6 +107,8 @@ struct CoinSelectionParams {
99107
CAmount m_min_change_target{MIN_CHANGE};
100108
/** Cost of creating the change output. */
101109
CAmount m_change_fee{0};
110+
/** The pre-determined minimum value to target when funding a change output. */
111+
CAmount m_change_target{0};
102112
/** Cost of creating the change output + cost of spending the change output in the future. */
103113
CAmount m_cost_of_change{0};
104114
/** The targeted feerate of the transaction being built. */
@@ -223,6 +233,21 @@ struct OutputGroup
223233
*/
224234
[[nodiscard]] CAmount GetSelectionWaste(const std::set<COutput>& inputs, CAmount change_cost, CAmount target, bool use_effective_value = true);
225235

236+
237+
/** Chooose a random change target for each transaction to make it harder to fingerprint the Core
238+
* wallet based on the change output values of transactions it creates.
239+
* The random value is between 50ksat and min(2 * payment_value, 1milsat)
240+
* When payment_value <= 25ksat, the value is just 50ksat.
241+
*
242+
* Making change amounts similar to the payment value may help disguise which output(s) are payments
243+
* are which ones are change. Using double the payment value may increase the number of inputs
244+
* needed (and thus be more expensive in fees), but breaks analysis techniques which assume the
245+
* coins selected are just sufficient to cover the payment amount ("unnecessary input" heuristic).
246+
*
247+
* @param[in] payment_value Average payment value of the transaction output(s).
248+
*/
249+
[[nodiscard]] CAmount GenerateChangeTarget(CAmount payment_value, FastRandomContext& rng);
250+
226251
struct SelectionResult
227252
{
228253
private:

src/wallet/spend.cpp

Lines changed: 8 additions & 3 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 {
@@ -395,9 +397,11 @@ std::optional<SelectionResult> AttemptSelection(const CWallet& wallet, const CAm
395397
results.push_back(*knapsack_result);
396398
}
397399

398-
// We include the minimum final change for SRD as we do want to avoid making really small change.
399-
// KnapsackSolver does not need this because it includes MIN_CHANGE internally.
400-
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;
401405
if (auto srd_result{SelectCoinsSRD(positive_groups, srd_target, coin_selection_params.rng_fast)}) {
402406
srd_result->ComputeAndSetWaste(coin_selection_params.m_cost_of_change);
403407
results.push_back(*srd_result);
@@ -681,6 +685,7 @@ static bool CreateTransactionInternal(
681685
coin_selection_params.m_subtract_fee_outputs = true;
682686
}
683687
}
688+
coin_selection_params.m_change_target = GenerateChangeTarget(std::floor(recipients_sum / vecSend.size()), rng_fast);
684689

685690
// Create change script that will be used if we need change
686691
CScript scriptChange;

test/functional/wallet_bumpfee.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,9 @@ def test_watchonly_psbt(self, peer_node, rbf_node, dest_address):
442442
self.generate(peer_node, 1)
443443

444444
# Create single-input PSBT for transaction to be bumped
445-
psbt = watcher.walletcreatefundedpsbt([], {dest_address: 0.0005}, 0, {"fee_rate": 1}, True)['psbt']
445+
# Ensure the payment amount + change can be fully funded using one of the 0.001BTC inputs.
446+
psbt = watcher.walletcreatefundedpsbt([watcher.listunspent()[0]], {dest_address: 0.0005}, 0,
447+
{"fee_rate": 1, "add_inputs": False}, True)['psbt']
446448
psbt_signed = signer.walletprocesspsbt(psbt=psbt, sign=True, sighashtype="ALL", bip32derivs=True)
447449
psbt_final = watcher.finalizepsbt(psbt_signed["psbt"])
448450
original_txid = watcher.sendrawtransaction(psbt_final["hex"])

0 commit comments

Comments
 (0)