Skip to content

fees: never quote below mempoolminfee, and hold the top rate in a fee spike - #1251

Open
niteshbalusu11 wants to merge 6 commits into
reardencode:masterfrom
smolcars:mempool/fee-quote-floor
Open

niteshbalusu11 wants to merge 6 commits into
reardencode:masterfrom
smolcars:mempool/fee-quote-floor

Conversation

@niteshbalusu11

@niteshbalusu11 niteshbalusu11 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Electrum blockchain.estimatefee and Esplora /fee-estimates could quote a feerate this node rejects. Only estimatesmartfee was raised to mempoolminfee; the others stopped at 0.1 sat/vB. And when arrivals priced above 1000 sat/vB overfilled the next blocks, a pool holding less than a block quoted 0.1 sat/vB for the next block, and every longer target was capped at that.

  • Floor: estimatesmartfee, estimaterawfee, Electrum, and Esplora read one hub path that floors each rate at the live mempoolminfee when it answers. The RPC-only clamp is deleted. Reading the floor per request also covers a request that lands while another refresh runs, and a package rollback that raises the floor without republishing it.
  • Fee spike: when no rate on the flow ladder fits, the min-relay fallback in flow_for_depth no longer turns that into 0.1 sat/vB; it is deleted. That depth falls back to the frontier, then block history, and has no estimate when neither has a rate.
  • conf_target: parsed as Core's int32. 4294967297 used to be cut to target 1. A value outside int32, or a fraction, is now -1 JSON integer out of range, and -1 is -8, as Core v31.1 answers on regtest.
  • Docs: warmup_blend keeps the cold quote when the warm side is missing, as its rustdoc says, and comments that disagreed with the code are fixed.

Proof: each fix started from a test that failed first. A 2000 sat/vB arrival burst on a thin pool quoted 0.1 sat/vB for the next block and now quotes block history, or no estimate without history. Raising min relay to 5000 sat/kvB left Electrum/Esplora at 2000, including with a refresh in flight, and now gives 5000. A package rollback into a near-full pool quoted 149 against a 200 floor and now gives 200. 4294967297 answered as target 1 and is now an error; 6.0 and 1e1 stay errors. Local fmt, clippy, deny, ast-grep, unwrap-ratchet, OS smoke, and the workspace suite pass, except two timing tests that pass alone: integration_multinode::mocktime_generate_keeps_ponging_peer (it also fails under load on master, 1 of 3 rbitcoin-test runs on 5cc40ff05) and peer::disconnect_after_tip_sync_clears_far_side_within_5s.

Found in the same review and left for later: FeeFlowMeter::note_admit decays the bucket it updates twice, a snapshot read changes the next EMA sample, and CPFP members are metered at their own feerates rather than the chunk rate.

Reviewed with sol-loop: 4 rounds with gpt-6.1-sol on high. Sol verdict: approved.

Created with Claude Opus 5.5 in Claude Code (T3 Code).

🤖 Generated with Claude Code

niteshbalusu11 and others added 4 commits October 27, 2026 18:41
parse_conf_target cast the JSON number to u32 before the 1..=1008 check,
so 4294967297 became target 1 and estimatesmartfee answered it. Core
v31.1 reads conf_target with getInt<int>: a value outside the int range,
or a fraction, is -1 "JSON integer out of range", and an int outside
1..=1008 (negatives included) is -8. The parser now does the same for
estimatesmartfee and estimaterawfee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
min_rate_for_capacity returned None when even 1000 sat/vB, the top of
the flow ladder, did not fit the next N blocks. The refresh passed that
None to flow_for_depth, which read it as "no flow data": the flow rate
became the frontier, or min relay for a near depth whose pool was
thinner than N blocks. The monotone pass then capped every later target
at that rate. Three blocks of 2000 sat/vB arrivals on a one-transaction
pool quoted 0.1 sat/vB for the next block.

The search now answers the ladder top when nothing fits, so a near
target quotes at least 1000 sat/vB in that case. projected is None only
when the flow meter lock is poisoned, and the refresh then publishes
the cold quote alone (alpha 0), so the min-relay fallback for a live
pool without a projected rate is deleted with its parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
estimatesmartfee raised its answer to mempoolminfee in the RPC layer.
Electrum blockchain.estimatefee, Esplora /fee-estimates, and
estimaterawfee read the hub's fee table, which was floored at the static
0.1 sat/vB only. With -minrelaytxfee raised, or a full pool's rolling
minimum above the estimate, those surfaces quoted a rate this node's own
mempool rejects.

FeeSnapshot::rate_btc_per_kb now takes a floor, and both hub accessors
pass mempool_min_fee_sat_kvb, read under a short read lock when they
answer. All four surfaces share that one floored path, and the RPC-only
clamp in smart_fee_json is deleted. The floor is read per request, not
stored in the table, so a raised floor applies even while another
request's refresh runs, and it does not depend on every floor change
calling publish_fee_floor: a package rollback that restores a conflict
into a near-full pool raises the floor without a publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
warmup_blend dropped a cold quote at alpha 1 when the warm side had no
rate, though its rustdoc and mempool-fee-estimation.md say a missing
warm side leaves the cold quote. published_depth_rate cannot reach that
case (history defines both sides), so the arm now matches the contract
and folds into one.

Comments that disagreed with the code are corrected: target 0, not 0-2,
maps to the default depth; CONFIDENCE_FAR is flow capacity confidence
(history reads its own 95% quantile); confirm-memory holds each entry's
own feerate, not a package rate. The duplicated estimatesmartfee doc
line is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

REQUEST_CHANGES

min_rate_for_capacity quotes the ladder top after that rate has failed the capacity check, and wallets will pay it. See the comment on fee_est.rs.

Nit: parse_conf_target treats a whole-valued JSON float as an int32. Core rejects it.

estimaterawfee picking up the mempoolminfee floor matches the documented 10-minute product, the same answer as estimatesmartfee. Core's estimaterawfee is a different, unfloored, per-horizon result; COMPAT.md already does not claim that shape. The new tx_relay tests insert history rows under a mutex and reuse the shared 3-block maturity pad. They do not sleep or mine a chain, so they stay inside the 2s per-test budget.

Comment thread crates/rbitcoin-mempool/src/fee_est.rs Outdated
Comment thread crates/rbitcoin-rpc/src/methods/mempool.rs
niteshbalusu11 and others added 2 commits October 27, 2026 20:10
Core v31.1 answers estimatesmartfee 6.0 and 1e1 with -1 "JSON integer
out of range". rbitcoin-rpc builds serde_json with arbitrary_precision,
so Number::as_i64 parses the raw token and rejects both already. The
chain-ops journey now sends those tokens so that stays pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit on this branch answered the 1000 sat/vB ladder top
when no candidate rate fit the next N blocks. That rate had just failed
the capacity check, so it is a price the projection says will not clear,
and about 150,000 vB admitted above it kept targets 1-5 (and, with no
history, every held target) at 1000 sat/vB while the admit EMA decayed.

min_rate_for_capacity returns None again when nothing fits. The min-relay
fallback in flow_for_depth stays deleted, so that None no longer becomes
0.1 sat/vB: the depth falls back to the frontier, then to block history,
and has no estimate when neither has a rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@niteshbalusu11

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of niteshbalusu11

@reardencode ready for another look. rearden-grok's review is addressed: 612e27f leaves a depth with no fitting ladder rate to the frontier and block history instead of quoting 1000 sat/vB, and 608067b pins 6.0 and 1e1 as conf_target errors (already rejected; replies are on both threads). The branch is on master 5cc40ff, and all required checks are green on 612e27f.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVE

Both earlier findings are fixed. min_rate_for_capacity returns None when every rung fails the capacity check, and flow_for_depth no longer substitutes min relay. With no frontier, a near target uses block history, and with no history the quote stays negative. arrivals_above_the_ladder_leave_near_targets_to_history checks both: targets 1 and 2 are below zero before any history, then exactly 2,000 sat/kvB after the history rows, which is neither min relay nor the 1000 sat/vB top. A defined rate still passes through rate_btc_per_kb, so Electrum, Esplora, and both fee RPCs keep the mempoolminfee floor. A missing rate stays a missing rate.

serde_json 1.0.154 with arbitrary_precision stores the token and as_i64 is str::parse::<i64>, which rejects 6.0 and 1e1. The RPC server parses the body with that same serde_json (from_slice in rpc_post). The new test builds those tokens with from_str and sends them through dispatch for estimatesmartfee and estimaterawfee, asserting -1 and JSON integer out of range.

The new history loop is the same in-memory record_fee_sample insert the other fee tests use. No sleeps, polling, or extra chain.

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