Skip to content

feat(metrics): add ChangelessWaste for privacy-driven coin selection#47

Draft
evanlinjin wants to merge 3 commits into
bitcoindevkit:masterfrom
evanlinjin:changeless-waste-metric
Draft

feat(metrics): add ChangelessWaste for privacy-driven coin selection#47
evanlinjin wants to merge 3 commits into
bitcoindevkit:masterfrom
evanlinjin:changeless-waste-metric

Conversation

@evanlinjin

@evanlinjin evanlinjin commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

Adds ChangelessWaste, a BnB metric that minimises the waste metric subject to the constraint that the selection produces no change output.

The use case is privacy-driven coin selection — CoinJoin / PayJoin / send-to-one-shot addresses where the change output is itself a leak. This matches what Bitcoin Core's BnB-CB algorithm covers. We deliberately don't expose a cross-regime Waste metric (see Why no general Waste metric? below).

This branch is rebased onto the current master, which landed two breaking refactors the metric now conforms to:

  • Target is passed to BnbMetric methods per-call instead of stored on the metric.
  • Metrics decide their own change output (BnbMetric::drain) rather than taking a ChangePolicy.

Public additions

ChangelessWaste — like LowestFee, it owns its change decision instead of taking a ChangePolicy. Fields: long_term_feerate, dust_relay_feerate, drain_weights. It uses the same rule as LowestFee to decide whether a selection would have change (change is worthwhile when the recovered excess exceeds the future spend cost and clears the dust threshold), and rejects any such selection — only genuinely changeless selections are scored. drain() returns Drain::NONE by definition.

For a changeless selection the waste collapses to input_weight * (feerate - long_term_feerate) + max(0, excess), which (unlike the general with-change waste) is monotone, so the bound reduces to bounding D.input_weight for any target-meeting descendant D ⊇ cs:

  • rate_diff >= 0: lower-bound D.input_weightcs.input_weight() when the target is already met, otherwise the resize trick (below).
  • rate_diff < 0: upper-bound D.input_weight via an LP-relaxed fractional knapsack over the positive-effective_value candidates that must be excluded to keep the selection changeless (i.e. to keep excess_with_drain_weight <= max(drain_spend_cost, dust_threshold - 1)).

Branches where every reachable descendant is forced to have change are pruned via change_unavoidable (the same heuristic Changeless uses).

Includes proptests can_eventually_find_best_solution (finds the true optimum) and ensure_bound_is_not_too_tight (the bound is a valid lower bound), plus two hand-written regime sanity checks.

Internal cleanup

LowestFee::bound and ChangelessWaste::bound shared the same "resize trick" — walk the value_pwu-sorted unselected list until the target is crossed, then fractionally resize the crossing input so the target is hit with zero excess. Extracted into a single private helper in src/metrics.rs:

  • resize_bound returns a ResizeBound enum (Exact | Resize) so each caller keeps its own exact-match computation — LowestFee returns score, ChangelessWaste returns waste — while sharing the find + deselect + scale mechanism. No behavior change for LowestFee (verified by its existing proptests).

Bound tightening for ChangelessWaste (rate_diff < 0)

The consolidation case (rate_diff < 0) previously used a trivial all_selected.input_weight * rate_diff upper bound, which ignores that selecting every candidate would typically force a change output. The LP-relaxed knapsack UB is dramatically tighter:

n scenario trivial UB LP knapsack
15 rate_diff_neg 3,590 415
20 rate_diff_neg 34,296 2,138
30 rate_diff_neg 2M (cap) 74,055

(round counts, BnB cap 2M). rate_diff >= 0 paths are unchanged.

Why no general Waste metric?

Earlier iterations included a cross-regime Waste metric scoring both changeless and with-change selections. It was dropped because:

  • The bound is genuinely hard. With change allowed, waste is non-monotonic and discontinuous: adding inputs raises waste in the changeless regime (excess is burned to fee), then it drops when the excess crosses the threshold where a change output becomes worthwhile. A valid, tight BnB lower bound has to span both regimes and the flip between them. Constraining to changeless removes that discontinuity — which is the whole reason ChangelessWaste has a clean bound.
  • It over-consolidates. When long_term_feerate > feerate (a common low-fee scenario) every input has negative waste contribution, so the unconstrained BnB optimum is "select every UTXO" — rarely what a caller wants.
  • Core uses waste as a tiebreaker, not as the BnB objective. The legitimate use cases — tiebreaking equally-cheap fee solutions, and finding good changeless candidates — don't need the cross-regime bound; the latter is exactly ChangelessWaste.

Note ChangelessWaste is a standalone metric (not Changeless<Waste>) precisely because its value is the custom tight input_weight-based bound — composing a general Waste metric would mean building that hard cross-regime bound only to constrain it away.

Test plan

  • cargo test --release — full suite passes (40 tests incl. lib + doctests), notably the ensure_bound_is_not_too_tight and can_eventually_find_best_solution proptests for both ChangelessWaste and LowestFee.
  • cargo clippy --all-targets --all-features — clean.
  • cargo fmt --all --check — clean.
  • cargo doc --no-deps with -D warnings — clean.

🤖 Generated with Claude Code

@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch 3 times, most recently from 8606b6f to bbaf01b Compare May 20, 2026 03:38
@evanlinjin evanlinjin changed the title feat(metrics): add Waste, ChangelessWaste, ChangelessLowestFee + tighten changeless bound feat(metrics): add ChangelessWaste for privacy-driven coin selection May 20, 2026
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch 3 times, most recently from d03543f to a52499b Compare May 21, 2026 10:00
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch 3 times, most recently from 6fccf10 to fda1d77 Compare July 7, 2026 13:19
evanlinjin and others added 3 commits July 8, 2026 08:32
Adds a waste metric restricted to changeless selections. Removing the
change output from the picture eliminates the non-monotonic discontinuity
that complicates the general waste bound, so the LB reduces to a lower
bound on `input_weight * (feerate - long_term_feerate)`.

The metric owns its change policy (same rule as LowestFee: change is
worthwhile when the recovered excess exceeds the future spend cost and
clears the dust threshold — and the drain must fit `max_weight`).

Bound construction notes:

- For target-not-met, rate_diff >= 0, the lower bound on any funded
  descendant's input_weight is computed entirely in raw-weight-linear
  arithmetic (fractional knapsack over `ev_lin = value - raw_weight*spwu`):
  the true vbyte-rounded fee only rounds up from the linear fee, and
  `input_weight()`'s segwit/varint corrections are common to every
  descendant and only grow, so the linear quantity cannot be undercut by a
  correction-avoiding descendant (unlike LowestFee's resize walk, which is
  computed from the greedy prefix's corrected, rounded fee).
- The changelessness prune (`change_unavoidable`) compares the least-excess
  construction against the changeless edge with a conservative
  `corrections*spwu + sat_vb + 1` slack for the same reason, and never
  prunes under RBF replacement (where the rate-driven least-excess
  construction is not minimal).
- Sat-magnitude arithmetic is f64 (exact for all sat amounts; f32's 24-bit
  mantissa loses whole sats above ~0.17 BTC and cancellation at the funding
  boundary flips comparisons), funded-ness at the boundary is decided by
  exact integer `is_funded`, and the unreachable-target prune requires a
  residual linear gap over half a sat.

Includes proptests (`can_eventually_find_best_solution`,
`ensure_bound_is_not_too_tight`, and a segwit-heavy bound-validity sweep)
plus deterministic regression tests for the segwit-correction,
vbyte-rounding, cap-refusal, absolute-fee, and BTC-scale-f32 edge cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous bound for `rate_diff < 0` was `d_all.input_weight * rate_diff`,
which ignored that selecting every candidate would typically force a change
output (making the selection infeasible under the changeless constraint). The
new bound recasts the problem as: minimize the weight of candidates excluded
from `D_all` such that `excess_with_drain_weight` drops to the changeless
edge (`changeless_max_excess`). This is a 0/1 covering knapsack; the LP
relaxation (sort positive-ev candidates by `ev/weight` descending and exclude
fractionally) gives a safe upper bound on `D.input_weight` for any feasible
changeless descendant.

The knapsack credits each removed candidate its rate-based linear
`effective_value`, so it falls back to the trivial `D_all` bound whenever a
non-rate fee constraint (absolute, RBF replacement) can bind, demands
`sat_vb + 1` sats less than the full delta to absorb vbyte rounding, and —
when `max_weight` makes `drain_value`'s cap refusal reachable — falls back
to the `D_all` bound clamped by the cap, since a cap-refused descendant is
changeless while keeping every excess-contributing candidate
(`bound_is_valid_when_cap_refuses_change_rate_diff_neg` covers that case).

Benchmark improvements (round counts, BnB cap 2M):
  n=15 rate_diff_neg:  3,590  ->    415   (~9x)
  n=20 rate_diff_neg: 34,296  ->  2,138   (~16x)
  n=30 rate_diff_neg: 2M cap  -> 74,055   (>27x, was hitting cap)

rate_diff >= 0 paths are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…helper

Move the "resize trick" out of `LowestFee::bound` into a private
`resize_bound` helper in `metrics.rs`, returning a `ResizeBound` enum so the
caller keeps its own exact-match computation. No behavior change.

The main point is the helper's doc: the walk's crossing point and `scale`
embed the greedy prefix's actual (`input_weight()`-corrected, vbyte-rounded)
fee and weight, which a correction-avoiding descendant can undercut — so it
carries an explicit CAUTION that it must not be used as an `input_weight`
lower bound (`ChangelessWaste::bound` uses raw-weight-linear arithmetic for
exactly that reason). The same caveat applies in fee space to the remaining
use here; that pre-existing `LowestFee` issue is tracked upstream in bitcoindevkit#56.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the changeless-waste-metric branch from cc99cea to 8307106 Compare July 8, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant