Skip to content

Add configurable fee rate bounds with decoupled simulation layout#13

Merged
laurenshareshian merged 15 commits intomainfrom
laurenshareshian/configurablefeerate
Mar 24, 2026
Merged

Add configurable fee rate bounds with decoupled simulation layout#13
laurenshareshian merged 15 commits intomainfrom
laurenshareshian/configurablefeerate

Conversation

@laurenshareshian
Copy link
Copy Markdown
Collaborator

@laurenshareshian laurenshareshian commented Mar 24, 2026

Summary

  • Dropping the min fee rate from 1.0 to 0.1 sats/vbyte is a big change. If we want to decrease more slowly and monitor the effects, we need to make the min rate configurable. For symmetry, I make the max fee rate configurable as well.
  • Introduces BucketLayout (internal simulation array layout) to replace the hardcoded BUCKET_MIN and BUCKET_MAX constants
  • minFeeRate controls the simulation array lower bound — transactions below this threshold are excluded from the modeled state space
  • maxFeeRate is a pure output filter — estimates whose fee rate exceeds this bound are returned as null, but the simulation always models the full fee rate space (buckets 0–1000) regardless of this value
  • minFeeRate default is 1.0 sat/vB; users on Bitcoin Core 29.1+ can opt in to sub-1 sat/vB support with FeeEstimator(minFeeRate = 0.1)
  • maxFeeRate default is 22027.0 sat/vB
  • Removes duplicate minFeeRate validation (now centralized in BucketLayout) and unused MAX_SIMULATABLE_FEE_RATE
  • Removes @InternalAugurApi opt-in annotation (redundant with Kotlin internal visibility)
  • Bumps version to 0.3.0

Design

  • BucketLayout is an internal-only class that takes only minFeeRate. bucketMax is fixed at 1000 (the legacy simulation ceiling). This ensures maxFeeRate never changes the modeled state space — no lossy bucket folding, no inflow distortion.
  • FeeEstimatesCalculator takes maxFeeRate as a separate parameter and applies it in prepareResultArray as a reporting filter.
  • MempoolSnapshot.fromMempoolTransactions is unchanged — it buckets all transactions regardless of fee rate bounds. The minFeeRate filtering happens later when snapshots are converted to the internal simulation array.
  • minFeeRate validation is centralized in BucketLayout.init (positivity + simulation ceiling check).

API compatibility note

This release changes JVM binary signatures for FeeEstimator constructors and configure(). FeeEstimator gains minFeeRate and maxFeeRate parameters (with Kotlin defaults). These new defaults alter the synthetic JVM overloads. All changes are source-compatible — existing Kotlin and Java callers compile without modification — but callers compiled against 0.1.0 or 0.2.x must be recompiled; linking against old bytecode will produce NoSuchMethodError at runtime. The three known consumers (baywatch, bitcoin-augur-reference, bitcoin-augur-benchmarking) all recompile on dependency bump.

One subtle behavioral change: the fee rate ceiling filter changed from < exp(10) to <= 22027.0. An estimate at exactly bucket 1000 (~22026.47 sat/vB) was previously null, now passes. This is near-impossible to hit in practice.

Test plan

  • All 60 tests pass
  • BucketLayout ceil test: minFeeRate = 0.15 yields bucket -189 (>= 0.15), not -190 (< 0.15)
  • BucketLayout has fixed bucketMax = 1000 regardless of construction
  • BucketLayout rejects minFeeRate too high for simulation ceiling
  • Custom minFeeRate accepts lower buckets in MempoolSnapshotF64Array
  • maxFeeRate filters estimates as output-only (does not affect simulation arrays)
  • Estimates at or below maxFeeRate are preserved
  • Constructor rejects invalid bounds (zero, negative)
  • minFeeRate and maxFeeRate are independent — no cross-validation required
  • Manual verification with reference implementation

🤖 Generated with Claude Code

laurenshareshian and others added 2 commits March 23, 2026 17:57
Introduces BucketConfig to replace the hardcoded BUCKET_MIN constant,
letting library consumers set their own minimum fee rate floor. The
default is 1.0 sat/vB; users on Bitcoin Core 29.1+ can opt in to
sub-1 sat/vB support with FeeEstimator(minFeeRate = 0.1).

Bumps version to 0.3.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add upper bound validation: minFeeRate must be <= 22026 sat/vB
  (beyond which bucketMin exceeds BUCKET_MAX and array size goes negative)
- Use ceil instead of round in BucketConfig so the lowest bucket never
  represents a fee rate below the user's configured minimum
- Add tests for both edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@laurenshareshian laurenshareshian changed the title Add configurable minimum fee rate Add configurable minimum and maximum fee rate Mar 24, 2026
@laurenshareshian laurenshareshian force-pushed the laurenshareshian/configurablefeerate branch from 322567b to 0dd17ee Compare March 24, 2026 01:35
Moves BUCKET_MAX from a hardcoded constant into BucketConfig alongside
bucketMin. The toArrayIndex/toBucketIndex helpers now live on
BucketConfig since they depend on the configured max.

Transactions with fee rates above maxFeeRate are folded into the highest
bucket so their block weight is still counted in simulations, while fee
estimates above the max are returned as null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@laurenshareshian laurenshareshian force-pushed the laurenshareshian/configurablefeerate branch from 0dd17ee to 8ce8fe2 Compare March 24, 2026 01:38
laurenshareshian and others added 7 commits March 23, 2026 19:01
…ange

fromMempoolTransactions now accepts minFeeRate/maxFeeRate so that snapshot
bucketing matches the FeeEstimator config. Previously, custom fee rate
bounds only took effect during estimation but not during snapshot creation,
silently clamping high-fee transactions to the default bucket max.

Also adds a BucketConfig init validation that the discretized bucket range
is non-empty, catching edge cases where minFeeRate and maxFeeRate are too
close together (e.g. 1.001 and 1.002) which would produce zero-length
bucket arrays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add input validation to BucketConfig.init (positive fee rates, min < max)
  so fromMempoolTransactions rejects invalid inputs instead of silently
  producing NaN-derived bucket indices
- Fix README default maxFeeRate comment (22026.0 -> 22027.0)
- Deduplicate DEFAULT_MIN/MAX_FEE_RATE by delegating FeeEstimator constants
  to BucketConfig
- Add FeeEstimator.createSnapshot() to prevent bucket config drift between
  snapshot creation and estimation
- Move BucketConfig construction after validation in FeeEstimator.init so
  invalid inputs produce clear error messages
- Document asymmetric clamping in calculateBucketIndex (fold high, drop low)
- Fix folding test to use separate slots for folded vs valid weight
- Remove duplicate empty-bucket-range test from BucketCreatorTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add @jvmoverloads to configure() to preserve old 4-arg binary signature
- Move BucketConfig property computation after require checks (defensive hygiene)
- Remove duplicate fee rate validation from FeeEstimator (BucketConfig is source of truth)
- Deprecate MempoolSnapshot.fromMempoolTransactions() in favor of FeeEstimator.createSnapshot()
- Fix KDoc maxFeeRate default (22026.0 -> 22027.0)
- Document new minFeeRate/maxFeeRate params in configure() KDoc
- Add inline comment explaining round() vs ceil/floor in calculateBucketIndex
- Replace bare assert() with assertNotNull/assertTrue in tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e assertTrue

- Drop @JvmStatic from deprecated fromMempoolTransactions (no need to
  expand Java API surface for a deprecated method)
- Drop ReplaceWith to avoid suggesting throwaway FeeEstimator instances
- Clarify in README that above-max transactions are folded into highest bucket
- Replace bare assert() with assertTrue() in new tests for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keep fromMempoolTransactions supported (no external Java callers exist),
remove @jvmoverloads from configure/createSnapshot/fromMempoolTransactions
to avoid unnecessary API surface, and document DEFAULT_MAX_FEE_RATE choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ent, clarify minFeeRate KDoc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ently

- Clarify that estimates whose fee rate *exceeds* the bound are null (not
  "above"), matching the <= boundary check in prepareResultArray
- Replace bare assert() with assertNotNull/assertTrue in
  FeeEstimatesCalculatorTest for consistency with other test cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@laurenshareshian laurenshareshian marked this pull request as ready for review March 24, 2026 17:15
@sanket1729
Copy link
Copy Markdown

Fee rate bounds (minFeeRate/maxFeeRate) and bucket configuration (bucketMin/bucketMax/arraySize) serve different purposes and shouldn't be conflated in a single BucketConfig class.

  • Fee rate bounds are policy parameters: "what's the lowest relay fee?" and "above what do we stop reporting estimates?" These are user-facing input/output concerns.
  • Bucket bounds are internal implementation details: how the fee rate space is discretized into array slots for the mining simulation.

Right now BucketConfig derives bucket bounds directly from fee rate bounds, coupling the array layout to user-facing config. But they don't need to be the same:

  1. Max side: "don't report above X" is an output filter on prepareResultArray. You don't need to shrink the array — folding above-max txs into the highest bucket is lossy and could affect simulation accuracy.
  2. Min side: Dropping sub-relay-fee txs is input filtering, independent of array sizing.
  3. Array size: Making it smaller with tighter bounds is a memory optimization, not a semantic requirement.

A cleaner separation would keep the internal bucket structure fixed (or separately configured) and apply fee rate bounds as input/output filters.

maxFeeRate was previously used to derive bucketMax, which sized the
internal simulation arrays. This coupled a user-facing output filter
to the internal state space, changing simulation dynamics (bucket
folding, inflow distortion) when callers lowered maxFeeRate.

Now:
- Rename BucketConfig → BucketLayout (internal simulation layout only)
- BucketLayout takes only minFeeRate; bucketMax is fixed at 1000
- maxFeeRate is passed separately to FeeEstimatesCalculator as a
  pure output filter in prepareResultArray
- Remove maxFeeRate from MempoolSnapshot.fromMempoolTransactions
- Remove minFeeRate < maxFeeRate validation (independent concerns)

Default behavior is unchanged: bucketMax=1000, maxFeeRate=22027.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@laurenshareshian laurenshareshian changed the title Add configurable minimum and maximum fee rate Add configurable fee rate bounds with decoupled simulation layout Mar 24, 2026
laurenshareshian and others added 2 commits March 24, 2026 11:21
… fix nits

- Make fromMempoolTransactions(minFeeRate) internal; public overload uses
  default layout only. Callers needing custom minFeeRate use createSnapshot.
- Restore strict < in prepareResultArray to preserve pre-PR filter behavior.
- Add assertNull to minFeeRate-above-maxFeeRate test (was a no-op loop).
- Fix DEFAULT_MAX_FEE_RATE KDoc: "rounded up from exp(10)" not "~exp(10)".
- Make BucketLayout.DEFAULT lazy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Deprecate MempoolSnapshot.fromMempoolTransactions() in favor of
  FeeEstimator.createSnapshot() to prevent bucket layout mismatches
- Add minFeeRate validation in FeeEstimator.init for clearer errors
- Change maxFeeRate filter from < to <= (include boundary value)
- Document snapshot rollover behavior when changing minFeeRate
- Fix DEFAULT_MAX_FEE_RATE KDoc to not conflate output filter with
  simulation ceiling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
return Array(feeRates.shape[0]) { blockTargetIndex ->
Array(feeRates.shape[1]) { probabilityIndex ->
feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxAllowedFeeRate }
feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxFeeRate }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs now say estimates are nulled only when they exceed maxFeeRate, but this strict < check also drops values exactly equal to the bound. That leaves the implementation out of sync with the README/KDoc and misses the exact-bound edge case in tests. Either switch this to <= or tighten the wording everywhere to say the bound is exclusive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test. This change is intentional.

* @param maxFeeRate New maximum fee rate in sat/vB (null to keep current)
* @return A new [FeeEstimator] instance with the specified settings
*/
public fun configure(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still changes the published ABI for configure(): main exposed the 4-arg overload, but this PR only leaves the new 6-arg signature in lib.api. Existing compiled Kotlin/Java callers of the old method will hit NoSuchMethodError. If this release is meant to stay binary-compatible, it needs a delegating shim that preserves the old method shape.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is intentionally not binary-compatible, which is why it's a 0.2.x → 0.3.0 bump. None of the three known consumers call configure(), and all recompile on dependency bump (this is a statically linked library, not a shared runtime dependency). Adding a delegating shim would preserve a method signature nobody calls.

sanket1729
sanket1729 previously approved these changes Mar 24, 2026
Copy link
Copy Markdown

@sanket1729 sanket1729 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving per request. I left two inline comments for follow-up on the remaining ABI and maxFeeRate-boundary issues.

- Pass BucketLayout directly instead of minFeeRate in internal
  fromMempoolTransactions to avoid redundant object allocation
- Add user-friendly upper-bound validation for minFeeRate in FeeEstimator
- Clarify that snapshots are layout-agnostic; minFeeRate only affects
  simulation array conversion, not snapshot creation
- Document ceil-vs-round boundary edge case for nonstandard minFeeRate values
- Soften deprecation message to reflect actual coupling
- Document intentional rounding of DEFAULT_MAX_FEE_RATE
- Remove unnecessary lazy from BucketLayout.DEFAULT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sanket1729
sanket1729 previously approved these changes Mar 24, 2026
…precation

- Remove BucketLayout param from createFeeRateBuckets/calculateBucketIndex
  since bucketMax is fixed at 1000; clamp directly to SIMULATION_BUCKET_MAX
- Delete internal fromMempoolTransactions overload that accepted BucketLayout
- Move MAX_SIMULATABLE_FEE_RATE to BucketLayout companion (colocate with
  SIMULATION_BUCKET_MAX)
- Remove redundant @InternalAugurApi from all internal classes and clean up
  @OptIn boilerplate — Kotlin internal visibility already prevents external access
- Undeprecate fromMempoolTransactions since snapshots are layout-agnostic
- Strengthen createSnapshot KDoc to explain layout-agnostic snapshot behavior
- Add boundary edge-case test verifying estimates exactly equal to maxFeeRate
  are preserved by the <= filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@laurenshareshian
Copy link
Copy Markdown
Collaborator Author

laurenshareshian commented Mar 24, 2026

Fee rate bounds (minFeeRate/maxFeeRate) and bucket configuration (bucketMin/bucketMax/arraySize) serve different purposes and shouldn't be conflated in a single BucketConfig class.

  • Fee rate bounds are policy parameters: "what's the lowest relay fee?" and "above what do we stop reporting estimates?" These are user-facing input/output concerns.
  • Bucket bounds are internal implementation details: how the fee rate space is discretized into array slots for the mining simulation.

Right now BucketConfig derives bucket bounds directly from fee rate bounds, coupling the array layout to user-facing config. But they don't need to be the same:

  1. Max side: "don't report above X" is an output filter on prepareResultArray. You don't need to shrink the array — folding above-max txs into the highest bucket is lossy and could affect simulation accuracy.
  2. Min side: Dropping sub-relay-fee txs is input filtering, independent of array sizing.
  3. Array size: Making it smaller with tighter bounds is a memory optimization, not a semantic requirement.

A cleaner separation would keep the internal bucket structure fixed (or separately configured) and apply fee rate bounds as input/output filters.

Addressed: maxFeeRate is now a pure output filter — it no longer affects the simulation array. bucketMax is fixed at 1000 regardless of maxFeeRate. The filter is applied in prepareResultArray via .takeIf { it <= maxFeeRate }. minFeeRate remains the only parameter that controls array layout (via BucketLayout).

@laurenshareshian laurenshareshian merged commit 6c22bf9 into main Mar 24, 2026
10 checks passed
@laurenshareshian laurenshareshian deleted the laurenshareshian/configurablefeerate branch March 24, 2026 20:55
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.

2 participants