feat(metrics): add ChangelessWaste for privacy-driven coin selection#47
Draft
evanlinjin wants to merge 3 commits into
Draft
feat(metrics): add ChangelessWaste for privacy-driven coin selection#47evanlinjin wants to merge 3 commits into
evanlinjin wants to merge 3 commits into
Conversation
8606b6f to
bbaf01b
Compare
d03543f to
a52499b
Compare
6fccf10 to
fda1d77
Compare
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>
cc99cea to
8307106
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Wastemetric (see Why no generalWastemetric? below).This branch is rebased onto the current
master, which landed two breaking refactors the metric now conforms to:Targetis passed toBnbMetricmethods per-call instead of stored on the metric.BnbMetric::drain) rather than taking aChangePolicy.Public additions
ChangelessWaste— likeLowestFee, it owns its change decision instead of taking aChangePolicy. Fields:long_term_feerate,dust_relay_feerate,drain_weights. It uses the same rule asLowestFeeto 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()returnsDrain::NONEby 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 boundingD.input_weightfor any target-meeting descendantD ⊇ cs:rate_diff >= 0: lower-boundD.input_weight—cs.input_weight()when the target is already met, otherwise the resize trick (below).rate_diff < 0: upper-boundD.input_weightvia an LP-relaxed fractional knapsack over the positive-effective_valuecandidates that must be excluded to keep the selection changeless (i.e. to keepexcess_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 heuristicChangelessuses).Includes proptests
can_eventually_find_best_solution(finds the true optimum) andensure_bound_is_not_too_tight(the bound is a valid lower bound), plus two hand-written regime sanity checks.Internal cleanup
LowestFee::boundandChangelessWaste::boundshared the same "resize trick" — walk thevalue_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 insrc/metrics.rs:resize_boundreturns aResizeBoundenum (Exact|Resize) so each caller keeps its own exact-match computation —LowestFeereturnsscore,ChangelessWastereturnswaste— while sharing the find + deselect + scale mechanism. No behavior change forLowestFee(verified by its existing proptests).Bound tightening for
ChangelessWaste(rate_diff < 0)The consolidation case (
rate_diff < 0) previously used a trivialall_selected.input_weight * rate_diffupper bound, which ignores that selecting every candidate would typically force a change output. The LP-relaxed knapsack UB is dramatically tighter:(round counts, BnB cap 2M).
rate_diff >= 0paths are unchanged.Why no general
Wastemetric?Earlier iterations included a cross-regime
Wastemetric scoring both changeless and with-change selections. It was dropped because:ChangelessWastehas a clean bound.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.ChangelessWaste.Note
ChangelessWasteis a standalone metric (notChangeless<Waste>) precisely because its value is the custom tightinput_weight-based bound — composing a generalWastemetric 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 theensure_bound_is_not_too_tightandcan_eventually_find_best_solutionproptests for bothChangelessWasteandLowestFee.cargo clippy --all-targets --all-features— clean.cargo fmt --all --check— clean.cargo doc --no-depswith-D warnings— clean.🤖 Generated with Claude Code