Skip to content

Commit b9a4943

Browse files
committed
fix(mempool): sample fee-estimator denominators when a target resolves
Four review findings on this file were one defect: `Bucket.total`, a single denominator incremented the moment a transaction arrived and used for every confirmation target. A transaction that entered at the current height was therefore already a failure at every target from 1 to 25 blocks. Two fresh arrivals dropped ten prior one-block successes to 10/12, below the 0.85 threshold, and the estimator went silent even though neither arrival had missed anything. The same count also broke the decay. It had been decaying since entry while `record_confirmation` added an undecayed 1.0 on confirmation, so 81 real successes out of 100 could report as roughly 85%. `resolved_within[target]` replaces it: one denominator per target, sampled when that target actually resolves — confirmed within it, or expired unconfirmed. Numerator and denominator now enter together and decay from the same block. Each pending entry carries `resolved_through` rather than deriving it from the height. Deriving it would assume `block_connected` is called exactly once per height; `block_connected` is public and takes the height from its caller, so a repeated call would resolve one target twice. `tx_entered` ignores a repeated txid instead of overwriting the entry, which had reset the transaction's clock on every re-announcement. `tx_left` untracks a departed transaction. The pending map previously shrank only on confirmation, so evictions and replacements accumulated until the 10,000-entry guard silently ignored every future transaction and the estimator was stuck for the life of the process. It records no failure, matching Core's `removeTx(hash, inBlock = false)`: an eviction says something about the mempool, not about whether the transaction would have confirmed. Real misses are sampled as targets pass. All four mutation-verified. The double-resolve test was vacuous first time round — it confirmed at a height past every expired target, so the guard it meant to exercise never ran — and now drives the same height twice, which is the case the guard exists for. Reported by review on PR #14.
1 parent 8f03699 commit b9a4943

1 file changed

Lines changed: 224 additions & 8 deletions

File tree

crates/mempool/src/fee_estimator.rs

Lines changed: 224 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,21 @@ struct Bucket {
6161
/// Decayed count of transactions confirmed within N blocks
6262
/// (index 0 = 1 block, index 24 = 25 blocks).
6363
confirmed_within: [f64; MAX_CONF_TARGET],
64-
/// Decayed count of all transactions observed in this bucket.
65-
total: f64,
64+
/// Decayed count of transactions RESOLVED for each target: confirmed
65+
/// within it, or still unconfirmed once it expired.
66+
///
67+
/// One denominator per target rather than one for all of them. A single
68+
/// count incremented on entry made every pending transaction an immediate
69+
/// failure at every target, so two fresh arrivals could drop ten prior
70+
/// one-block successes to 10/12 and silence the estimator before either
71+
/// arrival had missed anything.
72+
///
73+
/// Sampling at resolution also fixes the decay. The numerator and the
74+
/// denominator now enter together and decay from the same block, where
75+
/// before the denominator had been decaying since entry while the
76+
/// confirmation arrived fresh, reporting 81 successes out of 100 as
77+
/// roughly 85%.
78+
resolved_within: [f64; MAX_CONF_TARGET],
6679
}
6780

6881
impl Bucket {
@@ -71,7 +84,7 @@ impl Bucket {
7184
Self {
7285
fee_rate_sat_per_kvb,
7386
confirmed_within: [0.0; MAX_CONF_TARGET],
74-
total: 0.0,
87+
resolved_within: [0.0; MAX_CONF_TARGET],
7588
}
7689
}
7790
}
@@ -82,6 +95,13 @@ struct PendingEntry {
8295
bucket_index: usize,
8396
/// Block height at which the transaction entered the mempool.
8497
entry_height: u32,
98+
/// Highest target already sampled for this transaction, 0 for none.
99+
///
100+
/// Carried per entry rather than derived from the height, so the sampling
101+
/// cannot double-count or skip. Deriving it would assume `block_connected`
102+
/// is called exactly once for every height, and a skipped or repeated call
103+
/// would then lose failures or record them twice.
104+
resolved_through: usize,
85105
}
86106

87107
/// History-based fee estimator with exponential buckets and per-block decay.
@@ -111,20 +131,45 @@ impl FeeEstimator {
111131
/// `fee_rate_sat_per_kvb` is the effective fee rate
112132
/// (fee / vsize * 1 000). `height` is the current block height.
113133
pub fn tx_entered(&mut self, txid: Txid, fee_rate_sat_per_kvb: u64, height: u32) {
134+
// A second admission of the same txid must not reset its clock. It is
135+
// the same transaction waiting since the same height, and overwriting
136+
// the entry would make it look freshly arrived every time a caller
137+
// re-announced it.
138+
if self.pending.contains_key(&txid) {
139+
return;
140+
}
114141
if self.pending.len() >= MAX_PENDING_ENTRIES {
115142
return;
116143
}
117144
let bucket_index = self.bucket_index_for_rate(fee_rate_sat_per_kvb);
118-
self.buckets[bucket_index].total += 1.0;
145+
// No denominator here. A transaction that just arrived has not missed
146+
// any target yet; it is sampled as each target resolves.
119147
self.pending.insert(
120148
txid,
121149
PendingEntry {
122150
bucket_index,
123151
entry_height: height,
152+
resolved_through: 0,
124153
},
125154
);
126155
}
127156

157+
/// Records that a transaction left the mempool without confirming.
158+
///
159+
/// Call this on eviction, replacement, or any other departure. Without it
160+
/// the pending map only ever shrinks on confirmation, so departures
161+
/// accumulate until the `MAX_PENDING_ENTRIES` guard silently drops every
162+
/// future transaction and the estimator is stuck forever.
163+
///
164+
/// Untracks only. No failure is recorded, matching Core's
165+
/// `removeTx(hash, inBlock = false)`: an eviction or a replacement says
166+
/// something about the mempool, not about whether the transaction would
167+
/// have confirmed by any deadline. Real misses are sampled by
168+
/// `expire_targets` as each target passes.
169+
pub fn tx_left(&mut self, txid: &Txid) {
170+
self.pending.remove(txid);
171+
}
172+
128173
/// Records confirmations from a connected block and applies decay.
129174
///
130175
/// Call this for each connected block. `confirmed_txids` are the txids of
@@ -135,12 +180,40 @@ impl FeeEstimator {
135180
for txid in confirmed_txids {
136181
if let Some(entry) = self.pending.remove(txid) {
137182
let blocks_waited = block_height.saturating_sub(entry.entry_height).max(1);
138-
self.record_confirmation(entry.bucket_index, blocks_waited);
183+
self.record_confirmation(&entry, blocks_waited);
139184
}
140185
}
186+
self.expire_targets(block_height);
141187
self.apply_decay();
142188
}
143189

190+
/// Samples a failure for every target that expired on this block.
191+
///
192+
/// Blocks arrive one at a time, so a transaction crosses exactly one target
193+
/// boundary per block: the one whose window equals how long it has now
194+
/// waited. Targets it has already outlived were sampled on earlier blocks.
195+
///
196+
/// A transaction that outlives the longest target is dropped. It can no
197+
/// longer affect any estimate, and keeping it would fill the pending map.
198+
fn expire_targets(&mut self, block_height: u32) {
199+
let mut outlived = Vec::new();
200+
for (txid, entry) in &mut self.pending {
201+
let waited = usize::try_from(block_height.saturating_sub(entry.entry_height))
202+
.unwrap_or(usize::MAX)
203+
.min(MAX_CONF_TARGET);
204+
while entry.resolved_through < waited {
205+
self.buckets[entry.bucket_index].resolved_within[entry.resolved_through] += 1.0;
206+
entry.resolved_through += 1;
207+
}
208+
if entry.resolved_through == MAX_CONF_TARGET {
209+
outlived.push(*txid);
210+
}
211+
}
212+
for txid in outlived {
213+
self.pending.remove(&txid);
214+
}
215+
}
216+
144217
/// Estimates the minimum fee rate for confirmation within
145218
/// `conf_target_blocks`.
146219
///
@@ -162,7 +235,7 @@ impl FeeEstimator {
162235
let mut result = None;
163236
for bucket in self.buckets.iter().rev() {
164237
cumulative_confirmed += bucket.confirmed_within[target_idx];
165-
cumulative_total += bucket.total;
238+
cumulative_total += bucket.resolved_within[target_idx];
166239
if cumulative_total >= MIN_OBSERVATIONS {
167240
let success_rate = cumulative_confirmed / cumulative_total;
168241
if success_rate >= SUCCESS_THRESHOLD {
@@ -184,14 +257,27 @@ impl FeeEstimator {
184257
}
185258

186259
/// Records a confirmation, incrementing all targets >= `blocks_waited`.
187-
fn record_confirmation(&mut self, bucket_index: usize, blocks_waited: u32) {
260+
fn record_confirmation(&mut self, entry: &PendingEntry, blocks_waited: u32) {
261+
let bucket_index = entry.bucket_index;
188262
let waited = usize::try_from(blocks_waited).unwrap_or(0);
189263
if waited == 0 || waited > MAX_CONF_TARGET {
190264
return;
191265
}
192266
let bucket = &mut self.buckets[bucket_index];
267+
// Confirming at `waited` blocks satisfies every target from `waited`
268+
// up, and resolves each of them at the same moment, so numerator and
269+
// denominator decay together from here.
270+
//
271+
// Shorter targets are not touched: this transaction missed them, and
272+
// `expire_targets` already sampled those failures on the blocks where
273+
// they expired.
193274
for target in waited..=MAX_CONF_TARGET {
194275
bucket.confirmed_within[target - 1] += 1.0;
276+
// Only targets not already sampled as failures, so a late
277+
// confirmation cannot resolve a target twice.
278+
if target > entry.resolved_through {
279+
bucket.resolved_within[target - 1] += 1.0;
280+
}
195281
}
196282
}
197283

@@ -201,7 +287,9 @@ impl FeeEstimator {
201287
for count in &mut bucket.confirmed_within {
202288
*count *= DECAY_FACTOR;
203289
}
204-
bucket.total *= DECAY_FACTOR;
290+
for count in &mut bucket.resolved_within {
291+
*count *= DECAY_FACTOR;
292+
}
205293
}
206294
}
207295
}
@@ -238,6 +326,134 @@ mod tests {
238326
Txid::from_byte_array(bytes)
239327
}
240328

329+
/// A txid spread over more than 256 values, for the capacity test.
330+
fn wide_txid(n: u32) -> Txid {
331+
let mut bytes = [0_u8; 32];
332+
bytes[..4].copy_from_slice(&n.to_le_bytes());
333+
Txid::from_byte_array(bytes)
334+
}
335+
336+
/// Fresh arrivals must not be counted as failures.
337+
///
338+
/// The old denominator was incremented on entry and used for every target,
339+
/// so a burst of pending transactions erased a good estimate before any of
340+
/// them had missed anything.
341+
#[test]
342+
fn a_burst_of_fresh_arrivals_does_not_erase_a_good_estimate() {
343+
let mut est = FeeEstimator::new();
344+
for n in 0..10_u8 {
345+
est.tx_entered(test_txid(n), 10_000, 100);
346+
}
347+
let confirmed: Vec<Txid> = (0..10_u8).map(test_txid).collect();
348+
est.block_connected(&confirmed, 101);
349+
let before = est.estimate(1);
350+
assert!(
351+
before.is_some(),
352+
"ten one-block confirmations must estimate"
353+
);
354+
355+
// A hundred transactions that arrived this instant and have missed
356+
// nothing at all.
357+
for n in 100..200_u32 {
358+
est.tx_entered(wide_txid(n), 10_000, 101);
359+
}
360+
assert_eq!(
361+
est.estimate(1),
362+
before,
363+
"transactions that have not yet had a block cannot be failures"
364+
);
365+
}
366+
367+
/// Re-announcing a transaction must not restart its clock.
368+
#[test]
369+
fn a_repeated_admission_keeps_the_original_entry_height() {
370+
let mut est = FeeEstimator::new();
371+
let txid = test_txid(1);
372+
est.tx_entered(txid, 10_000, 100);
373+
// Same txid, five blocks later, as a duplicate announcement would.
374+
est.tx_entered(txid, 10_000, 105);
375+
376+
let Some(entry) = est.pending.get(&txid) else {
377+
panic!("the transaction must still be tracked");
378+
};
379+
assert_eq!(
380+
entry.entry_height, 100,
381+
"the second admission must not reset the clock"
382+
);
383+
}
384+
385+
/// Departures must free capacity, or the estimator wedges.
386+
///
387+
/// The pending map only ever shrank on confirmation, so evicted and
388+
/// replaced transactions accumulated until the guard silently ignored
389+
/// every future transaction.
390+
#[test]
391+
fn a_departure_frees_capacity_for_new_transactions() {
392+
let mut est = FeeEstimator::new();
393+
for n in 0..u32::try_from(MAX_PENDING_ENTRIES).unwrap_or(u32::MAX) {
394+
est.tx_entered(wide_txid(n), 10_000, 100);
395+
}
396+
assert_eq!(
397+
est.pending.len(),
398+
MAX_PENDING_ENTRIES,
399+
"the map must be full"
400+
);
401+
402+
let fresh = wide_txid(999_999);
403+
est.tx_entered(fresh, 10_000, 100);
404+
assert!(
405+
!est.pending.contains_key(&fresh),
406+
"a full map must refuse, or this test proves nothing"
407+
);
408+
409+
est.tx_left(&wide_txid(0));
410+
est.tx_entered(fresh, 10_000, 100);
411+
assert!(
412+
est.pending.contains_key(&fresh),
413+
"a departure must free the slot it occupied"
414+
);
415+
}
416+
417+
/// A target resolves once, even if the same height is processed twice.
418+
///
419+
/// `block_connected` is public and takes the height from its caller, so
420+
/// nothing structurally prevents it being called twice for one height. When
421+
/// that happens the first call expires a target and the second confirms
422+
/// against the same one, and without the guard both would land in the
423+
/// denominator for a single transaction.
424+
#[test]
425+
fn a_target_resolves_once_when_a_height_is_processed_twice() {
426+
let bucket_index = {
427+
let est = FeeEstimator::new();
428+
est.bucket_index_for_rate(10_000)
429+
};
430+
431+
// Reference run: the height is processed once, the normal case.
432+
let mut once = FeeEstimator::new();
433+
let txid = test_txid(3);
434+
once.tx_entered(txid, 10_000, 100);
435+
once.block_connected(&[], 101);
436+
once.block_connected(&[txid], 102);
437+
438+
// Same sequence, but height 102 arrives twice: once with no
439+
// confirmation, then again carrying it.
440+
let mut twice = FeeEstimator::new();
441+
twice.tx_entered(txid, 10_000, 100);
442+
twice.block_connected(&[], 101);
443+
twice.block_connected(&[], 102);
444+
twice.block_connected(&[txid], 102);
445+
446+
let target_idx = 1;
447+
assert!(
448+
twice.buckets[bucket_index].resolved_within[target_idx]
449+
<= once.buckets[bucket_index].resolved_within[target_idx] + 1e-9,
450+
"one transaction must resolve the two-block target once, not twice: \
451+
{} against {}",
452+
twice.buckets[bucket_index].resolved_within[target_idx],
453+
once.buckets[bucket_index].resolved_within[target_idx]
454+
);
455+
}
456+
241457
#[test]
242458
fn no_data_yields_none() {
243459
let est = FeeEstimator::new();

0 commit comments

Comments
 (0)