Skip to content

Report null instead of a wrong fee rate when an estimate is unavailable - #15

Open
sanket1729 wants to merge 2 commits into
mainfrom
sanketk/fee-estimation-correctness
Open

Report null instead of a wrong fee rate when an estimate is unavailable#15
sanket1729 wants to merge 2 commits into
mainfrom
sanketk/fee-estimation-correctness

Conversation

@sanket1729

Copy link
Copy Markdown
Contributor

1 sat/vB meant three different things — a real recommendation, "no data", and "the simulation failed" — so an unanswerable request came back as the cheapest possible fee. Nine bugs: the bucket-0 fallback, monotonicity spreading it down a whole column, a sentinel fee rate that leaks once maxFeeRate is raised, a blend ramp that goes negative past the long-term window and ignored longTermWindowDuration, an inflow divide-by-zero when no block height has two snapshots plus whole-second truncation, unsorted blockTargets, negative bucket weight read as freed block capacity, and a nondeterministic getNearestBlockTarget that could overflow.

Behaviour change: cells that cannot be answered now return null instead of a plausible number.

Stacked on #14.

Simulation cost grows with the block target: getExpectedBlocksMined evaluates a
Poisson tail of 4 * target entries and then simulates that many blocks, so a
target of 1_000_000 spends roughly two minutes of CPU. Neither the numOfBlocks
argument nor the blockTargets constructor list had an upper bound, and
bitcoin-augur-server passes an HTTP query parameter straight into numOfBlocks,
so one unauthenticated request can saturate a core.

Reject targets above MAX_BLOCK_TARGET, one week of blocks.
1 sat/vB meant three different things: a real recommendation, "no data", and
"the simulation failed". Callers could not tell them apart, so an unanswerable
request came back as the cheapest possible fee.

- A confidence level above 1 - exp(-blockTarget) cannot be met at any fee rate.
  runSimulations returned bucket 0 for it, which is a real fee rate.
- findBestIndex flagged failure with bucketMax + 1, whose fee rate is 22247.84
  sat/vB. That only stayed hidden because it sits above the default maxFeeRate;
  raising maxFeeRate surfaced it as an estimate.
- enforceMonotonicity clamped each target to the previous one, so a collapsed
  short target dragged every longer target in the column down with it.
- The short/long blend ramp 1 - (1 - t/144)^2 turns back down past the window
  and reaches -35 at 1008 blocks, extrapolating the two estimates apart instead
  of averaging them. It also ignored longTermWindowDuration.
- InflowCalculator divided by a zero span whenever no block height had two
  snapshots, the normal state for a per-block collector: Infinity, then NaN in
  every bucket, then a table of nulls with no indication why. It also measured
  the span in whole seconds, truncating sub-second spacing to zero.
- Unsorted blockTargets broke the monotonicity walk.
- mineBlock treated a negative bucket weight as freed capacity.
- getNearestBlockTarget resolved exact ties by map iteration order and could
  overflow on extreme inputs.

Fixture weights are now seeded so the new assertions are reproducible.
@sanket1729
sanket1729 marked this pull request as ready for review August 5, 2026 19:43
@laurenshareshian

Copy link
Copy Markdown
Collaborator

There are lots of comments in here that reference historically how things used to be. I think that might be confusing/unnecessary for a new reader coming in here with a fresh set of eyes with no context. What do you think about removing them? Could do a pass for "previously", "used to be", etc. maybe if you agree?

Comment on lines +228 to +230
// independent of maxFeeRate. The old out-of-band bucket sentinel did not: it relied on the
// gap between exp(1001/100) and DEFAULT_MAX_FEE_RATE, so raising maxFeeRate surfaced
// 22247.84 sat/vB as a real estimate.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"The old out-of-band bucket sentinel" this might be confusing/unnecessary to a new reader?

Comment on lines +83 to +84
// nothing to normalize. Dividing anyway yielded Infinity, then 0.0 * Infinity = NaN in every
// bucket, and those NaNs voided the whole fee table with no indication of why.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"Dividing anyway" this might be confusing/unnecessary to a new reader?

Base automatically changed from sanketk/bound-numofblocks to main August 5, 2026 20:23
Comment on lines +194 to +196
// observable between two snapshots at the same height, so the measured span is zero. Dividing by
// it produced Infinity, then 0.0 * Infinity = NaN in every bucket, and those NaNs propagated
// through the simulation until every cell of the fee table came back null.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same

Comment on lines +79 to +80
// Monotonicity assumes ascending targets. With an unsorted list the longest target was visited
// first and became the bound for every shorter one.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same. Etc I'll stop flagging all of them bug you get the idea 😄

// Comparing in Long avoids overflow on extreme inputs, and the second comparator makes the
// result independent of map iteration order -- previously an exact tie returned whichever key
// the map happened to yield first.
return estimates.keys.minWithOrNull(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Now that unanswerable cells are nulled, convertToFeeEstimate still adds a BlockTarget with an empty probabilities map, so this can return a target with no fee rate at all — targets [1, 6] at p=0.9 returns 1 for a request of 2. Consider skipping targets whose probabilities map is empty.

Comment on lines +134 to +135
if (simdSnapshots.groupingBy { it.blockHeight }.eachCount().none { it.value > 1 }) {
return FeeEstimate(emptyMap(), orderedSnapshots.last().timestamp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes the observable response shape for sparse history:
Before: {3: {}, 6: {}, …, 144: {}} — configured targets were present with no probability values.
After: {} — no target keys are returned.

Maybe we should do a full version bump for anyone that might be using the public endpoint.

Comment on lines +134 to +135
if (simdSnapshots.groupingBy { it.blockHeight }.eachCount().none { it.value > 1 }) {
return FeeEstimate(emptyMap(), orderedSnapshots.last().timestamp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Another thing about this line...Augur checks inflow availability across all history but calculates inflow separately for the 30m/24h windows. After a collection gap, old data can satisfy the guard while the recent window is treated as zero inflow, potentially underpricing short targets. Maybe the estimator should track availability per window and either use the available horizon or return no estimate, rather than treating missing observations as measured zero inflow. Not sure if this is too complicated 🤔

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