Skip to content

Commit d68bc74

Browse files
murchandamussipa
andcommitted
fuzz: Test optimality of CoinGrinder
Co-authored-by: Pieter Wuille <[email protected]>
1 parent 67df6c6 commit d68bc74

File tree

1 file changed

+82
-0
lines changed

1 file changed

+82
-0
lines changed

src/wallet/test/fuzz/coinselection.cpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <test/util/setup_common.h>
1212
#include <wallet/coinselection.h>
1313

14+
#include <numeric>
1415
#include <vector>
1516

1617
namespace wallet {
@@ -134,6 +135,87 @@ FUZZ_TARGET(coin_grinder)
134135
}
135136
}
136137

138+
FUZZ_TARGET(coin_grinder_is_optimal)
139+
{
140+
FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()};
141+
142+
FastRandomContext fast_random_context{ConsumeUInt256(fuzzed_data_provider)};
143+
CoinSelectionParams coin_params{fast_random_context};
144+
coin_params.m_subtract_fee_outputs = false;
145+
// Set effective feerate up to MAX_MONEY sats per 1'000'000 vB (2'100'000'000 sat/vB = 21'000 BTC/kvB).
146+
coin_params.m_effective_feerate = CFeeRate{ConsumeMoney(fuzzed_data_provider, MAX_MONEY), 1'000'000};
147+
coin_params.m_min_change_target = ConsumeMoney(fuzzed_data_provider);
148+
149+
// Create some coins
150+
CAmount max_spendable{0};
151+
int next_locktime{0};
152+
static constexpr unsigned max_output_groups{16};
153+
std::vector<OutputGroup> group_pos;
154+
LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), max_output_groups)
155+
{
156+
// With maximum m_effective_feerate and n_input_bytes = 1'000'000, input_fee <= MAX_MONEY.
157+
const int n_input_bytes{fuzzed_data_provider.ConsumeIntegralInRange<int>(1, 1'000'000)};
158+
// Only make UTXOs with positive effective value
159+
const CAmount input_fee = coin_params.m_effective_feerate.GetFee(n_input_bytes);
160+
// Ensure that each UTXO has at least an effective value of 1 sat
161+
const CAmount eff_value{fuzzed_data_provider.ConsumeIntegralInRange<CAmount>(1, MAX_MONEY - max_spendable - max_output_groups + group_pos.size())};
162+
const CAmount amount{eff_value + input_fee};
163+
std::vector<COutput> temp_utxo_pool;
164+
165+
AddCoin(amount, /*n_input=*/0, n_input_bytes, ++next_locktime, temp_utxo_pool, coin_params.m_effective_feerate);
166+
max_spendable += eff_value;
167+
168+
auto output_group = OutputGroup(coin_params);
169+
output_group.Insert(std::make_shared<COutput>(temp_utxo_pool.at(0)), /*ancestors=*/0, /*descendants=*/0);
170+
group_pos.push_back(output_group);
171+
}
172+
size_t num_groups = group_pos.size();
173+
assert(num_groups <= max_output_groups);
174+
175+
// Only choose targets below max_spendable
176+
const CAmount target{fuzzed_data_provider.ConsumeIntegralInRange<CAmount>(1, std::max(CAmount{1}, max_spendable - coin_params.m_min_change_target))};
177+
178+
// Brute force optimal solution
179+
CAmount best_amount{MAX_MONEY};
180+
int best_weight{std::numeric_limits<int>::max()};
181+
for (uint32_t pattern = 1; (pattern >> num_groups) == 0; ++pattern) {
182+
CAmount subset_amount{0};
183+
int subset_weight{0};
184+
for (unsigned i = 0; i < num_groups; ++i) {
185+
if ((pattern >> i) & 1) {
186+
subset_amount += group_pos[i].GetSelectionAmount();
187+
subset_weight += group_pos[i].m_weight;
188+
}
189+
}
190+
if ((subset_amount >= target + coin_params.m_min_change_target) && (subset_weight < best_weight || (subset_weight == best_weight && subset_amount < best_amount))) {
191+
best_weight = subset_weight;
192+
best_amount = subset_amount;
193+
}
194+
}
195+
196+
if (best_weight < std::numeric_limits<int>::max()) {
197+
// Sufficient funds and acceptable weight: CoinGrinder should find at least one solution
198+
int high_max_weight = fuzzed_data_provider.ConsumeIntegralInRange<int>(best_weight, std::numeric_limits<int>::max());
199+
200+
auto result_cg = CoinGrinder(group_pos, target, coin_params.m_min_change_target, high_max_weight);
201+
assert(result_cg);
202+
assert(result_cg->GetWeight() <= high_max_weight);
203+
assert(result_cg->GetSelectedEffectiveValue() >= target + coin_params.m_min_change_target);
204+
assert(best_weight < result_cg->GetWeight() || (best_weight == result_cg->GetWeight() && best_amount <= result_cg->GetSelectedEffectiveValue()));
205+
if (result_cg->GetAlgoCompleted()) {
206+
// If CoinGrinder exhausted the search space, it must return the optimal solution
207+
assert(best_weight == result_cg->GetWeight());
208+
assert(best_amount == result_cg->GetSelectedEffectiveValue());
209+
}
210+
}
211+
212+
// CoinGrinder cannot ever find a better solution than the brute-forced best, or there is none in the first place
213+
int low_max_weight = fuzzed_data_provider.ConsumeIntegralInRange<int>(0, best_weight - 1);
214+
auto result_cg = CoinGrinder(group_pos, target, coin_params.m_min_change_target, low_max_weight);
215+
// Max_weight should have been exceeded, or there were insufficient funds
216+
assert(!result_cg);
217+
}
218+
137219
FUZZ_TARGET(coinselection)
138220
{
139221
FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()};

0 commit comments

Comments
 (0)