Make fee math exact: integer FeeRate and integer BnB scores - #72
Draft
evanlinjin wants to merge 3 commits into
Draft
Make fee math exact: integer FeeRate and integer BnB scores#72evanlinjin wants to merge 3 commits into
evanlinjin wants to merge 3 commits into
Conversation
`ideal_fee` cancels large sat amounts down to a small fee, so in `f32` the rounding error can exceed the result itself. The old code asserted the result non-negative, which a selection driving `scale = 52692/145266` trips: the product comes back ~0.0039 short of the target value. `assert!` is unconditional, so this panicked in release too. Clamping the negative case alone would only address the harmless direction — a bound that is too *low* just loosens the search. The dangerous direction is a bound that is too *high*: `bnb.rs` keeps a branch only when `best > bound`, so an inflated bound prunes the optimum. Computing in `f64` and rounding down covers both; every real score is a whole number of sats, so flooring stays admissible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`FeeRate` was an `f32` sat/wu value, and every fee derived from it went through float multiply-then-ceil. Those fees are whole satoshis and feed `CoinSelector::excess` -> `is_funded`, so a rounding error there doesn't merely pick a worse selection — it reports a selection as funded while the transaction is short of its target feerate, with nothing to signal it. The float computation was exact for everyday transactions; it diverged once the implied fee outgrew `f32`'s exact-integer range (from ~2^22 sats for `implied_fee_wu`, ~2^24 for `implied_fee`), then drifted up to 2 sats in either direction. `is_funded_flips_exactly_at_the_required_fee` pins the boundary and does catch the old code accepting a selection one sat short. Store sat per 1000 vbytes instead, matching Bitcoin Core's `CFeeRate`: finer resolution than sat/kwu, and it makes `implied_fee` literally Core's `GetFee`. All integer-valued fee functions (`implied_fee`, `implied_fee_wu`, `spend_fee`, `dust_threshold`, `CoinSelector::effective_value`, `ChangePolicy`'s waste floor) are now exact, with `u128` intermediates that saturate rather than wrap. The genuinely fractional quantities (`spwu`, `value_pwu`, `waste`) stay floats — they are search heuristics where an error costs a slightly worse pick, not validity. BREAKING: `FeeRate` constructors quantize to whole sat/kvb (0.001 sat/vb). `FeeRate::sub` saturates at zero rather than producing a negative feerate. `float::FloatExt` is removed — it existed only to supply `f32::ceil` under `no_std` for these fee paths, and nothing uses it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BnbMetric::score` and `bound` returned `Ordf32`, but both are whole satoshi counts: `LowestFee::fee_score` computes an exact `u64` and then threw it into an `f32`. The bound had it worse — it divided to get `scale`, multiplied back up by a candidate value, and cancelled that against the selected and target values. At Bitcoin-scale amounts that cancellation lost whole satoshis, and the error went in both directions. Erring low only loosens the bound. Erring high makes it inadmissible: `bnb.rs` keeps a branch only when `best > bound`, so an inflated bound discards the branch holding the optimum and branch and bound silently returns a worse selection. `bnb_does_not_prune_the_optimum_at_btc_scale` is a case found by search where it does exactly that, returning 1261 against a true optimum of 1258. Make both `Option<u64>`. `scale` is now kept as an exact `num/den` fraction and only multiplied out, so the bound never round-trips through a division; the `max_weight` prune compares cross-multiplied rather than in floats. The credit the bound gives itself for a change output a descendant might add now uses `waste_floor`, rounded down, so it can never exceed what a descendant would really pay. Comparisons between branches are exact integer comparisons. Verified against exhaustive search: the full proptest suite at 4000 cases, including `ensure_bound_is_not_too_tight`, which checks every branch's bound against the actual score of every descendant. BREAKING: `BnbMetric::score`/`bound` return `Option<u64>`; `CoinSelector::run_bnb` returns `(u64, Drain)` and `bnb_solutions` yields `(CoinSelector, u64)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Description
Follows up on the
LowestFee::boundpanic reported in #71. That report found a real bug, but clamping the negativeideal_feeto0.0treats the harmless direction only — a bound that is too low just loosens the search, while a bound that is too high prunes the optimum. This PR fixes the root cause and then removes floats from the two paths where they change answers rather than just search quality.Three independent commits:
1.
ideal_feeinf64, floored — the immediate panic fix.ideal_feecancels large satoshi amounts down to a small fee, so inf32the rounding error can exceed the result itself.assert!is unconditional, so the old code panicked in release too.2.
FeeRateas an exact integer (sat/kvB) —implied_fee,implied_fee_wu,spend_fee,dust_thresholdandeffective_valueall return whole satoshis and feedexcess()→is_funded(). A rounding error there doesn't pick a worse selection, it reports a selection as funded while the transaction is short of its target feerate. Stored as sat per 1000 vbytes, matching Bitcoin Core'sCFeeRate, which makesimplied_feeliterally Core'sGetFee.3.
BnbMetric::score/boundasOption<u64>— both were already whole satoshi counts being stuffed into anf32.scaleis now an exactnum/denfraction that is only ever multiplied out, so the bound never round-trips through a division.The genuinely fractional quantities (
spwu,value_pwu,waste) stay floats — they are search heuristics where an error costs a slightly worse pick, not validity.Notes to the reviewers
On severity, stated plainly: the old float fee functions were exact for everyday transactions. They only diverged once the implied fee outgrew
f32's exact-integer range — from ~2^22 sats forimplied_fee_wu, ~2^24 forimplied_fee— which needs something like a 112k wu transaction at 599 sat/vB. This is a latent edge case, not something users hit today. It is worth fixing because integers cost nothing here and the failure mode is silent underpayment.Every added test was checked against the pre-fix code to confirm it actually fails there:
bound_does_not_panic_on_f32_roundingassertion failed: ideal_fee >= 0.0implied_fees_are_exactimplied_fee_wu(391127)at 43 sat/vb:4204615vs4204616is_funded_flips_exactly_at_the_required_feeis_fundedreturnstrueone sat below the required feeimplied_fee_saturates_instead_of_wrapping2^63instead of saturatingbnb_does_not_prune_the_optimum_at_btc_scaleThat last case could not be constructed by hand. It came out of a throwaway randomized harness comparing BnB against exhaustive search over near-tied subset sums at ~34M sat magnitudes: one mismatch in 4000 seeds, now pinned as a regression test. It is the concrete demonstration that an inflated lower bound makes
bnb.rsdiscard the branch holding the optimum and silently return a worse selection.Changelog notice
FeeRateis now an exact integer (satoshi per 1000 vbytes); constructors quantize to 0.001 sat/vB.FeeRate::subsaturates at zero instead of producing a negative feerate.BnbMetric::score/boundreturnOption<u64>;CoinSelector::run_bnbreturns(u64, Drain)andbnb_solutionsyields(CoinSelector, u64).float::FloatExtis removed — it existed only to supplyf32::ceilunderno_stdfor these fee paths.Checklists
All Submissions:
cargo fmtandcargo clippybefore committingBugfixes:
Verified with the full proptest suite at
PROPTEST_CASES=4000(16x the default), includingensure_bound_is_not_too_tight, which checks every branch's bound against the actual score of every descendant. Each of the three commits builds and passes on its own.🤖 Generated with Claude Code