Skip to content

Make fee math exact: integer FeeRate and integer BnB scores - #72

Draft
evanlinjin wants to merge 3 commits into
bitcoindevkit:masterfrom
evanlinjin:fix/exact-integer-fee-math
Draft

Make fee math exact: integer FeeRate and integer BnB scores#72
evanlinjin wants to merge 3 commits into
bitcoindevkit:masterfrom
evanlinjin:fix/exact-integer-fee-math

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 15, 2026

Copy link
Copy Markdown
Member

Description

Follows up on the LowestFee::bound panic reported in #71. That report found a real bug, but clamping the negative ideal_fee to 0.0 treats 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_fee in f64, floored — the immediate panic fix. ideal_fee cancels large satoshi amounts down to a small fee, so in f32 the rounding error can exceed the result itself. assert! is unconditional, so the old code panicked in release too.

2. FeeRate as an exact integer (sat/kvB)implied_fee, implied_fee_wu, spend_fee, dust_threshold and effective_value all return whole satoshis and feed excess()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's CFeeRate, which makes implied_fee literally Core's GetFee.

3. BnbMetric::score/bound as Option<u64> — both were already whole satoshi counts being stuffed into an f32. scale is now an exact num/den fraction 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 for implied_fee_wu, ~2^24 for implied_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:

Test Failure without the fix
bound_does_not_panic_on_f32_rounding assertion failed: ideal_fee >= 0.0
implied_fees_are_exact implied_fee_wu(391127) at 43 sat/vb: 4204615 vs 4204616
is_funded_flips_exactly_at_the_required_fee is_funded returns true one sat below the required fee
implied_fee_saturates_instead_of_wrapping wraps to 2^63 instead of saturating
bnb_does_not_prune_the_optimum_at_btc_scale BnB returns 1261 against a true optimum of 1258

That 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.rs discard the branch holding the optimum and silently return a worse selection.

Changelog notice

  • FeeRate is now an exact integer (satoshi per 1000 vbytes); constructors quantize to 0.001 sat/vB.
  • FeeRate::sub saturates at zero instead of producing a negative feerate.
  • BnbMetric::score/bound return Option<u64>; CoinSelector::run_bnb returns (u64, Drain) and bnb_solutions yields (CoinSelector, u64).
  • float::FloatExt is removed — it existed only to supply f32::ceil under no_std for these fee paths.

Checklists

All Submissions:

  • I've signed all my commits
  • I followed the contribution guidelines
  • I ran cargo fmt and cargo clippy before committing

Bugfixes:

  • This pull request breaks the existing API
  • I've added tests to reproduce the issue which are now passing

Verified with the full proptest suite at PROPTEST_CASES=4000 (16x the default), including ensure_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

evanlinjin and others added 3 commits August 15, 2026 09:17
`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>
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