@@ -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
6881impl 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