The Coin Selection Problem

Choosing UTXOs Like a Knapsack Packer

Every time a Bitcoin wallet builds a transaction, it faces a decision that looks deceptively simple: which unspent outputs (UTXOs) should it spend to cover the payment? In practice this is a genuine combinatorial optimization problem — a close cousin of the classic knapsack problem — with two competing objectives pulling in different directions: minimize the transaction fee (fewer, larger inputs are cheaper) and maximize privacy (avoid leaving an obviously-linkable change output, and avoid revealing too much about your total holdings).

In this article we formalize UTXO selection as a constrained optimization problem, implement the same core algorithm real wallets use — Branch and Bound (BnB) — compare it against two baseline strategies, and visualize the fee/privacy landscape across a range of payment sizes and network fee rates.

Why This Is a Knapsack Problem

Given a wallet holding UTXOs $u_1, u_2, \dots, u_n$ (in satoshis) and a target payment $T$, we want to choose a subset $S \subseteq {1,\dots,n}$, represented by binary decision variables $x_i \in {0,1}$, such that:

$$
\sum_{i=1}^{n} x_i , u_i \geq T + F(S)
$$

where $F(S)$ is the fee required to spend that particular set of inputs. This is a variable-capacity knapsack: unlike the textbook 0/1 knapsack, the “capacity” itself ($T + F(S)$) depends on how many items you pack, because every additional input adds bytes — and therefore fee — to the transaction.

The transaction size in virtual bytes is modeled as:

$$
V(n_{in}, n_{out}) = \beta + n_{in}\cdot v_{in} + n_{out}\cdot v_{out}
$$

where $\beta$ is the fixed overhead (version, locktime, segwit marker), $v_{in}$ is the size of a single P2WPKH input (approximately 68 vB), and $v_{out}$ is the size of a single P2WPKH output (approximately 31 vB). The fee is then:

$$
F(n_{in}, n_{out}) = \tau \cdot V(n_{in}, n_{out})
$$

with $\tau$ the fee rate in sat/vB.

Reframing With Effective Value

The trick that makes this tractable is the notion of effective value — the amount an input is actually worth once you subtract the cost of spending it:

$$
e_i = u_i - \tau \cdot v_{in}
$$

Any UTXO with $e_i \leq 0$ is uneconomical to spend at the current fee rate (dust) and should be excluded outright. Using effective values, we fold the per-input fee cost directly into the “size” of each knapsack item, and define an adjusted target that accounts for the base overhead and a single payment output:

$$
T’ = T + \tau(\beta + v_{out})
$$

The goal becomes: find $S$ minimizing the waste

$$
w(S) = \sum_{i \in S} e_i - T’, \qquad \text{subject to } \sum_{i \in S} e_i \geq T’
$$

If we can land exactly in the window $[T’, T’ + C_{change}]$, where

$$
C_{change} = \tau(v_{in} + v_{out})
$$

is the cost of adding a change output, we avoid creating change entirely — the small excess is simply absorbed into the miner fee. No change output means no change address, which is one of the biggest wins for privacy: it removes the “change output heuristic” that chain analysts use to link addresses back to the same wallet.

The Branch and Bound Algorithm

Exhaustively checking all $2^n$ subsets is fine for small wallets but doesn’t scale. Branch and Bound explores the same search tree but prunes aggressively:

  • Lower-bound pruning: if the current partial sum plus the sum of all remaining candidates still can’t reach $T’$, abandon the branch.
  • Upper-bound pruning: if the current partial sum already exceeds $T’ + C_{change}$, abandon the branch (since effective values are positive, it only gets worse).
  • Candidates are sorted by effective value descending, so promising large inputs are tried first and the tree collapses quickly.

This is the same idea used by Bitcoin Core’s actual coin selection engine.

Baseline Strategies for Comparison

  • Greedy Largest-First: repeatedly add the biggest remaining UTXO until the target is covered. Fast, deterministic, but tends to always produce change and reveals your largest holdings.
  • Single Random Draw (SRD): shuffle the UTXO set and accumulate in random order, repeated over many trials, keeping the best result. This is what wallets fall back on when BnB can’t find an exact match, and it has the nice property of not correlating input selection with UTXO size (a privacy plus).

A Concrete Example

We’ll use a wallet holding 15 UTXOs ranging from 2,100 to 120,000 sats, a payment target of 150,000 sats, and a fee rate of 15 sat/vB. The script below runs all three strategies, reports the fee, leftover change, and a heuristic privacy score, then produces three visualizations: the UTXO set itself, a head-to-head comparison of the strategies, and a 3D surface showing how selection cost scales with both payment size and fee rate.

Full Python Implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import random

plt.style.use('dark_background')

# ---------------------------------------------------------------
# Cost model constants (P2WPKH, segwit)
# ---------------------------------------------------------------
BASE_VBYTES = 10.5 # tx overhead: version, locktime, segwit marker/flag
INPUT_VBYTES = 68.0 # size of one P2WPKH input
OUTPUT_VBYTES = 31.0 # size of one P2WPKH output

# ---------------------------------------------------------------
# Wallet UTXO set (satoshis)
# ---------------------------------------------------------------
UTXOS = [15000, 3200, 89000, 42000, 7600, 120000, 2100, 56000,
9800, 31000, 4700, 67000, 18500, 5300, 99000]

TARGET = 150000 # payment amount, sats
FEERATE = 15.0 # sat/vB

random.seed(42)
np.random.seed(42)


def fee_no_change(k, feerate):
return feerate * (BASE_VBYTES + k * INPUT_VBYTES + OUTPUT_VBYTES)


def cost_of_change(feerate):
return feerate * (INPUT_VBYTES + OUTPUT_VBYTES)


def build_result(selected_indices, utxos, target, feerate):
"""Given selected UTXO indices, compute fee, change and waste."""
k = len(selected_indices)
raw_sum = sum(utxos[i] for i in selected_indices)
f_nc = fee_no_change(k, feerate)
remainder = raw_sum - target - f_nc
c_change = cost_of_change(feerate)

if remainder <= c_change:
# excess is small enough to just overpay the fee, no change output
fee = f_nc + max(remainder, 0.0)
change = 0.0
n_outputs = 1
else:
fee = feerate * (BASE_VBYTES + k * INPUT_VBYTES + 2 * OUTPUT_VBYTES)
change = raw_sum - target - fee
n_outputs = 2

waste = raw_sum - target # == fee + change, by construction
return {
'indices': selected_indices,
'k': k,
'raw_sum': raw_sum,
'fee': fee,
'change': change,
'n_outputs': n_outputs,
'waste': waste,
}


def privacy_score(result, target):
"""Heuristic 0-100 score: rewards no change output, penalizes many inputs."""
no_change_bonus = 25 if result['change'] == 0 else 0
change_penalty = 35 * (result['change'] / target) if target > 0 else 0
input_penalty = 2.5 * result['k']
score = 75 - change_penalty - input_penalty + no_change_bonus
return float(np.clip(score, 0, 100))


# ---------------------------------------------------------------
# Branch and Bound coin selection
# ---------------------------------------------------------------
def branch_and_bound(utxos, target, feerate, node_limit=200000):
tau_in = feerate * INPUT_VBYTES
candidates = [(i, u) for i, u in enumerate(utxos) if u - tau_in > 0]
if not candidates:
return None

candidates.sort(key=lambda p: p[1] - tau_in, reverse=True)
orig_idx = [p[0] for p in candidates]
eff_vals = [p[1] - tau_in for p in candidates]
n = len(eff_vals)

target_adj = target + feerate * (BASE_VBYTES + OUTPUT_VBYTES)
c_change = cost_of_change(feerate)

suffix_sum = [0.0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix_sum[i] = suffix_sum[i + 1] + eff_vals[i]

best = {'waste': None, 'sel': None}
nodes = [0]

def dfs(i, current_sum, current_sel):
nodes[0] += 1
if nodes[0] > node_limit:
return
if current_sum > target_adj + c_change:
return
if current_sum >= target_adj:
w = current_sum - target_adj
if best['waste'] is None or w < best['waste']:
best['waste'] = w
best['sel'] = current_sel.copy()
return
if i >= n:
return
if current_sum + suffix_sum[i] < target_adj:
return
current_sel.append(i)
dfs(i + 1, current_sum + eff_vals[i], current_sel)
current_sel.pop()
dfs(i + 1, current_sum, current_sel)

dfs(0, 0.0, [])

if best['sel'] is None:
return None
chosen_original = [orig_idx[j] for j in best['sel']]
return build_result(chosen_original, utxos, target, feerate)


# ---------------------------------------------------------------
# Greedy Largest-First
# ---------------------------------------------------------------
def greedy_largest_first(utxos, target, feerate):
order = sorted(range(len(utxos)), key=lambda i: utxos[i], reverse=True)
selected = []
raw_sum = 0.0
for i in order:
selected.append(i)
raw_sum += utxos[i]
k = len(selected)
if raw_sum >= target + fee_no_change(k, feerate):
break
return build_result(selected, utxos, target, feerate)


# ---------------------------------------------------------------
# Single Random Draw
# ---------------------------------------------------------------
def single_random_draw(utxos, target, feerate, trials=300):
best_result = None
n = len(utxos)
for _ in range(trials):
order = list(range(n))
random.shuffle(order)
selected = []
raw_sum = 0.0
for i in order:
selected.append(i)
raw_sum += utxos[i]
k = len(selected)
if raw_sum >= target + fee_no_change(k, feerate):
break
else:
continue # ran out of UTXOs without covering target
result = build_result(selected, utxos, target, feerate)
if best_result is None or result['waste'] < best_result['waste']:
best_result = result
return best_result


# ---------------------------------------------------------------
# Run the concrete example
# ---------------------------------------------------------------
bnb_result = branch_and_bound(UTXOS, TARGET, FEERATE)
greedy_result = greedy_largest_first(UTXOS, TARGET, FEERATE)
srd_result = single_random_draw(UTXOS, TARGET, FEERATE)

strategies = {
'Branch & Bound': bnb_result,
'Greedy Largest-First': greedy_result,
'Single Random Draw': srd_result,
}

print(f"Target payment: {TARGET:,} sats | Fee rate: {FEERATE} sat/vB\n")
for name, r in strategies.items():
if r is None:
print(f"{name}: no exact match found")
continue
p = privacy_score(r, TARGET)
print(f"[{name}]")
print(f" inputs used : {r['k']}")
print(f" fee : {r['fee']:.0f} sats")
print(f" change : {r['change']:.0f} sats")
print(f" waste (fee+chg): {r['waste']:.0f} sats")
print(f" privacy score : {p:.1f} / 100")
print()

# =================================================================
# Visualization 1: the UTXO set itself
# =================================================================
fig1, ax1 = plt.subplots(figsize=(11, 5))
sorted_utxos = sorted(UTXOS, reverse=True)
colors1 = plt.cm.plasma(np.linspace(0.15, 0.9, len(sorted_utxos)))
bars = ax1.bar(range(len(sorted_utxos)), sorted_utxos, color=colors1, edgecolor='black')
ax1.axhline(TARGET, color='#00e5ff', linestyle='--', linewidth=1.5,
label=f'Target: {TARGET:,} sats')
ax1.set_xlabel('UTXO (sorted by size)')
ax1.set_ylabel('Amount (sats)')
ax1.set_title('Wallet UTXO Set')
ax1.legend()
ax1.grid(alpha=0.2)
plt.tight_layout()
plt.show()

# =================================================================
# Visualization 2: strategy comparison
# =================================================================
labels = list(strategies.keys())
fees = [strategies[l]['fee'] for l in labels]
changes = [strategies[l]['change'] for l in labels]
inputs_used = [strategies[l]['k'] for l in labels]

fig2, axes2 = plt.subplots(1, 3, figsize=(15, 5))
metrics = [
(fees, 'Fee (sats)', '#ff6b6b'),
(changes, 'Change / Privacy Leak (sats)', '#feca57'),
(inputs_used, 'Number of Inputs', '#48dbfb'),
]
for ax, (values, title, color) in zip(axes2, metrics):
ax.bar(labels, values, color=color, edgecolor='black')
ax.set_title(title)
ax.tick_params(axis='x', rotation=20)
ax.grid(alpha=0.2, axis='y')
fig2.suptitle('Strategy Comparison: Fee vs. Change vs. Input Count', fontsize=13)
plt.tight_layout()
plt.show()

# =================================================================
# Visualization 3: 3D waste landscape over target amount and fee rate
# =================================================================
targets_grid = np.linspace(50000, 280000, 14)
feerates_grid = np.linspace(5, 60, 14)
X, Y = np.meshgrid(targets_grid, feerates_grid)
Z = np.full(X.shape, np.nan)

for a in range(X.shape[0]):
for b in range(X.shape[1]):
t = X[a, b]
f = Y[a, b]
res = branch_and_bound(UTXOS, t, f)
if res is None:
res = greedy_largest_first(UTXOS, t, f)
if res is not None:
Z[a, b] = res['waste']

fig3 = plt.figure(figsize=(11, 8))
ax3 = fig3.add_subplot(111, projection='3d')
surf = ax3.plot_surface(X, Y, Z, cmap='plasma', edgecolor='none', antialiased=True)
ax3.set_xlabel('Target Payment (sats)')
ax3.set_ylabel('Fee Rate (sat/vB)')
ax3.set_zlabel('Selection Waste (sats)')
ax3.set_title('Coin Selection Waste Across Payment Size and Fee Rate')
fig3.colorbar(surf, shrink=0.6, aspect=12, label='Waste (sats)')
plt.tight_layout()
plt.show()
Target payment: 150,000 sats  |  Fee rate: 15.0 sat/vB

[Branch & Bound]
  inputs used   : 7
  fee           : 7800 sats
  change        : 0 sats
  waste (fee+chg): 7800 sats
  privacy score : 82.5 / 100

[Greedy Largest-First]
  inputs used   : 2
  fee           : 3128 sats
  change        : 65872 sats
  waste (fee+chg): 69000 sats
  privacy score : 54.6 / 100

[Single Random Draw]
  inputs used   : 3
  fee           : 4000 sats
  change        : 0 sats
  waste (fee+chg): 4000 sats
  privacy score : 92.5 / 100

Code Walkthrough

Cost model functionsfee_no_change, cost_of_change, and build_result centralize every fee/change calculation so all three strategies are scored with exactly the same accounting rules. build_result first checks whether the leftover amount after paying a single-output fee fits within cost_of_change; if so, the excess is simply absorbed into the fee (no change output, best for privacy). Otherwise a second output is added and the true change amount is computed with the correct two-output fee.

branch_and_bound is the core solver. It first strips out any UTXO whose effective value would be negative (dust at the current fee rate), sorts the rest by effective value descending, and precomputes suffix sums so it can instantly tell, at any point in the search, whether the remaining candidates could possibly reach the target. The recursive dfs function tries including and excluding each candidate in turn; the two pruning checks (current_sum > target_adj + c_change and current_sum + suffix_sum[i] < target_adj) keep the search space tiny in practice even though the worst case is exponential.

greedy_largest_first and single_random_draw are simpler accumulation strategies used purely as a baseline. SRD repeats the random accumulation 300 times and keeps whichever trial produced the least waste — this mirrors how real wallets use randomness to avoid a predictable, fingerprintable selection pattern.

privacy_score is a deliberately simple heuristic, not a rigorous metric: it rewards avoiding a change output, and penalizes both large change amounts (a stronger signal to chain analysts) and using many inputs (which reveals more of your holdings at once).

The grid search in Visualization 3 re-runs Branch and Bound (falling back to greedy) at 196 combinations of target amount and fee rate. Because each run only searches a 15-item tree, the whole grid completes in well under a second — this is the same reason real wallets can afford to run BnB on every transaction they build.

Interpreting the 3D Fee Landscape

The resulting surface should show waste rising in two directions: moving along the fee-rate axis makes every input more expensive to include, so the algorithm either needs fewer, larger inputs (harder to hit exactly) or absorbs more into the fee; moving along the target-amount axis simply requires summing more value, increasing the chance of overshoot. The interesting region to look at is where the surface dips toward the axis — those are the (target, fee rate) combinations where an exact, change-free match exists in this particular UTXO set, which is exactly the situation Branch and Bound is designed to find.

Privacy Considerations Recap

Fee minimization and privacy maximization aren’t always aligned. A single large UTXO minimizes fees (one input, tiny transaction) but reveals a big chunk of your balance in one output. Combining many small UTXOs can look more “organic” and avoid revealing a single large holding, but costs more in fees and links more addresses together in one transaction. Avoiding a change output is close to a free lunch for privacy — it removes the single most exploited heuristic in blockchain analysis — which is precisely why Branch and Bound searches for an exact match before falling back to any change-producing strategy.

Conclusion

UTXO selection is a small but genuinely NP-hard combinatorial problem hiding inside every Bitcoin transaction. Branch and Bound turns it into a fast, practical search by reframing the knapsack in terms of effective value and pruning aggressively, and it’s a good illustration of how a well-chosen problem transformation can make an exponential search tree collapse to something that runs in milliseconds on real wallet-sized inputs.