@@ -548,58 +548,20 @@ impl<P: Provider> BlockWindowCalculator<P> {
548548 let span = spans:: block_range_for_timestamps ( start_ts. as_u64 ( ) , end_ts. as_u64 ( ) ) ;
549549 let _guard = span. enter ( ) ;
550550
551- if start_ts > end_ts {
552- return Err ( BlockWindowError :: invalid_timestamp_range ( start_ts, end_ts) ) ;
553- }
554-
555- let genesis_ts = self
556- . bounds_memo
557- . get_or_fetch_genesis ( || self . get_block_timestamp ( 0 ) )
558- . await ?;
559-
560- let ( latest_block, latest_ts) = self
561- . bounds_memo
562- . get_or_fetch_head ( || async {
563- let latest_block = self
564- . provider
565- . get_block_number ( )
566- . await
567- . map_err ( RpcError :: get_block_number_failed) ?;
568- let latest_ts = if latest_block == 0 {
569- genesis_ts
570- } else {
571- self . get_block_timestamp ( latest_block) . await ?
572- } ;
573- Ok ( ( latest_block, latest_ts) )
574- } )
575- . await ?;
576-
577- info ! (
578- start_ts = %start_ts,
579- end_ts = %end_ts,
580- latest_block,
581- "Resolving timestamp range to block range"
582- ) ;
583-
584- let ( start_block, end_block) = compute_block_range_given_bounds (
551+ block_range_for_timestamps_with (
552+ & self . bounds_memo ,
585553 start_ts,
586554 end_ts,
587- latest_block,
588- genesis_ts,
589- latest_ts,
590555 |n| self . get_block_timestamp ( n) ,
556+ || async {
557+ self . provider
558+ . get_block_number ( )
559+ . await
560+ . map_err ( RpcError :: get_block_number_failed)
561+ . map_err ( BlockWindowError :: from)
562+ } ,
591563 )
592- . await ?;
593-
594- info ! (
595- start_ts = %start_ts,
596- end_ts = %end_ts,
597- start_block,
598- end_block,
599- "Resolved timestamp range to block range"
600- ) ;
601-
602- Ok ( ( start_block, end_block) )
564+ . await
603565 }
604566
605567 /// Gets (or computes and caches) the daily block window for a specific chain and date
@@ -867,6 +829,75 @@ where
867829 Ok ( ( start_block, end_block) )
868830}
869831
832+ /// Memo-aware resolution of an inclusive timestamp range to its inclusive
833+ /// block range. Pulled out of [`BlockWindowCalculator::block_range_for_timestamps`]
834+ /// so the full path — input validation, genesis/head memoization via
835+ /// [`ChainBoundsMemo`], non-monotonic detection, and binary search — can be
836+ /// exercised in tests without a live RPC.
837+ ///
838+ /// `fetch_ts(0)` supplies the genesis timestamp, `fetch_latest_block_number()`
839+ /// supplies the chain head's block number, and `fetch_ts(latest_block)`
840+ /// supplies the head's timestamp. The production path wires
841+ /// `self.get_block_timestamp` and `self.provider.get_block_number` through.
842+ async fn block_range_for_timestamps_with < F , FtFut , G , GnFut > (
843+ bounds_memo : & ChainBoundsMemo ,
844+ start_ts : UnixTimestamp ,
845+ end_ts : UnixTimestamp ,
846+ mut fetch_ts : F ,
847+ fetch_latest_block_number : G ,
848+ ) -> Result < ( BlockNumber , BlockNumber ) , BlockWindowError >
849+ where
850+ F : FnMut ( BlockNumber ) -> FtFut ,
851+ FtFut : std:: future:: Future < Output = Result < UnixTimestamp , BlockWindowError > > ,
852+ G : FnOnce ( ) -> GnFut ,
853+ GnFut : std:: future:: Future < Output = Result < BlockNumber , BlockWindowError > > ,
854+ {
855+ if start_ts > end_ts {
856+ return Err ( BlockWindowError :: invalid_timestamp_range ( start_ts, end_ts) ) ;
857+ }
858+
859+ let genesis_ts = bounds_memo. get_or_fetch_genesis ( || fetch_ts ( 0 ) ) . await ?;
860+
861+ let ( latest_block, latest_ts) = bounds_memo
862+ . get_or_fetch_head ( || async {
863+ let latest_block = fetch_latest_block_number ( ) . await ?;
864+ let latest_ts = if latest_block == 0 {
865+ genesis_ts
866+ } else {
867+ fetch_ts ( latest_block) . await ?
868+ } ;
869+ Ok ( ( latest_block, latest_ts) )
870+ } )
871+ . await ?;
872+
873+ info ! (
874+ start_ts = %start_ts,
875+ end_ts = %end_ts,
876+ latest_block,
877+ "Resolving timestamp range to block range"
878+ ) ;
879+
880+ let ( start_block, end_block) = compute_block_range_given_bounds (
881+ start_ts,
882+ end_ts,
883+ latest_block,
884+ genesis_ts,
885+ latest_ts,
886+ fetch_ts,
887+ )
888+ . await ?;
889+
890+ info ! (
891+ start_ts = %start_ts,
892+ end_ts = %end_ts,
893+ start_block,
894+ end_block,
895+ "Resolved timestamp range to block range"
896+ ) ;
897+
898+ Ok ( ( start_block, end_block) )
899+ }
900+
870901#[ cfg( test) ]
871902mod tests {
872903 use super :: * ;
@@ -1510,4 +1541,191 @@ mod tests {
15101541 ) ;
15111542 }
15121543 }
1544+
1545+ /// End-to-end tests for the memo-aware wiring in
1546+ /// [`block_range_for_timestamps_with`] — the closures inside
1547+ /// [`BlockWindowCalculator::block_range_for_timestamps`] are reachable
1548+ /// today only by running against a live RPC. These tests drive that
1549+ /// path via the closure-injected helper using an in-memory fixture so
1550+ /// the wiring (genesis fetch → head fetch → bounds-aware binary search)
1551+ /// is exercised under `cargo test`.
1552+ mod wiring {
1553+ use super :: * ;
1554+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
1555+ use std:: sync:: { Arc , Mutex as StdMutex } ;
1556+
1557+ type BoxedTsFut = std:: pin:: Pin <
1558+ Box < dyn std:: future:: Future < Output = Result < UnixTimestamp , BlockWindowError > > > ,
1559+ > ;
1560+ type BoxedHeadFut = std:: pin:: Pin <
1561+ Box < dyn std:: future:: Future < Output = Result < BlockNumber , BlockWindowError > > > ,
1562+ > ;
1563+
1564+ /// Records each block requested in `log`, then returns
1565+ /// `timestamps[block]`. Lets tests count how many times the
1566+ /// underlying timestamp fetcher was invoked for any given block.
1567+ fn counting_fetch_ts (
1568+ timestamps : Vec < i64 > ,
1569+ log : Arc < StdMutex < Vec < BlockNumber > > > ,
1570+ ) -> impl FnMut ( BlockNumber ) -> BoxedTsFut {
1571+ move |n : BlockNumber | {
1572+ let ts = timestamps[ n as usize ] ;
1573+ let log = log. clone ( ) ;
1574+ Box :: pin ( async move {
1575+ log. lock ( ) . expect ( "test log mutex poisoned" ) . push ( n) ;
1576+ Ok ( UnixTimestamp ( ts) )
1577+ } )
1578+ }
1579+ }
1580+
1581+ /// Mirrors `self.provider.get_block_number()` and increments
1582+ /// `counter` on each invocation.
1583+ fn counting_fetch_head (
1584+ latest : BlockNumber ,
1585+ counter : Arc < AtomicUsize > ,
1586+ ) -> impl FnOnce ( ) -> BoxedHeadFut {
1587+ move || {
1588+ Box :: pin ( async move {
1589+ counter. fetch_add ( 1 , Ordering :: SeqCst ) ;
1590+ Ok ( latest)
1591+ } )
1592+ }
1593+ }
1594+
1595+ #[ tokio:: test]
1596+ async fn bounds_memoized_across_two_calls_within_ttl ( ) {
1597+ // Five-block monotonic chain. Two back-to-back resolutions
1598+ // within the default TTL should collapse the genesis+head
1599+ // probes from 2·2 into a single pair.
1600+ //
1601+ // Both windows are chosen so that the bound-overlap short-circuits
1602+ // in `compute_block_range_given_bounds` fire instead of the
1603+ // binary search — that isolates the memo-bootstrap fetches
1604+ // (block 0 + block `latest`) from the binary-search probes,
1605+ // which are not memoized and would otherwise re-probe both
1606+ // bookends during their bisection.
1607+ let timestamps = vec ! [ 1000 , 1100 , 1200 , 1300 , 1400 ] ;
1608+ let latest: BlockNumber = 4 ;
1609+ let bounds_memo = ChainBoundsMemo :: new ( DEFAULT_HEAD_TTL ) ;
1610+
1611+ let log = Arc :: new ( StdMutex :: new ( Vec :: < BlockNumber > :: new ( ) ) ) ;
1612+ let head_counter = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
1613+
1614+ // First call covers the full chain — both bounds clamp to the
1615+ // chain extremes without running the binary search. Only the
1616+ // memo bootstrap (genesis + head) hits `fetch_ts`.
1617+ let ( s1, e1) = block_range_for_timestamps_with (
1618+ & bounds_memo,
1619+ UnixTimestamp ( 500 ) ,
1620+ UnixTimestamp ( 9999 ) ,
1621+ counting_fetch_ts ( timestamps. clone ( ) , log. clone ( ) ) ,
1622+ counting_fetch_head ( latest, head_counter. clone ( ) ) ,
1623+ )
1624+ . await
1625+ . unwrap ( ) ;
1626+ assert_eq ! ( ( s1, e1) , ( 0 , latest) ) ;
1627+
1628+ // Second call lies strictly past chain head: the `start_ts >
1629+ // latest_ts` short-circuit fires using the memoized `latest_ts`
1630+ // (1400). The returned (latest, latest) proves the cached head
1631+ // was read back, not just written.
1632+ let ( s2, e2) = block_range_for_timestamps_with (
1633+ & bounds_memo,
1634+ UnixTimestamp ( 2000 ) ,
1635+ UnixTimestamp ( 3000 ) ,
1636+ counting_fetch_ts ( timestamps, log. clone ( ) ) ,
1637+ counting_fetch_head ( latest, head_counter. clone ( ) ) ,
1638+ )
1639+ . await
1640+ . unwrap ( ) ;
1641+ assert_eq ! ( ( s2, e2) , ( latest, latest) ) ;
1642+
1643+ // The memo collapses the four genesis+head probes that the
1644+ // naive implementation would issue (2 per call) into a single
1645+ // pair, in the order genesis-then-head.
1646+ let calls = log. lock ( ) . unwrap ( ) ;
1647+ assert_eq ! (
1648+ calls. as_slice( ) ,
1649+ & [ 0u64 , latest] ,
1650+ "memo must collapse genesis+head fetches across calls within TTL (got: {calls:?})"
1651+ ) ;
1652+ assert_eq ! (
1653+ head_counter. load( Ordering :: SeqCst ) ,
1654+ 1 ,
1655+ "head block number must be fetched once within TTL"
1656+ ) ;
1657+ }
1658+
1659+ #[ tokio:: test]
1660+ async fn single_block_chain_reuses_genesis_for_head ( ) {
1661+ // Single-block chain (latest_block == 0). The head closure
1662+ // must reuse the memoized genesis timestamp instead of
1663+ // refetching block 0; a regression that drops the
1664+ // `latest_block == 0` short-circuit shows up here as a second
1665+ // fetch of block 0.
1666+ let timestamps = vec ! [ 1500 ] ;
1667+ let latest: BlockNumber = 0 ;
1668+ let bounds_memo = ChainBoundsMemo :: new ( DEFAULT_HEAD_TTL ) ;
1669+
1670+ let log = Arc :: new ( StdMutex :: new ( Vec :: < BlockNumber > :: new ( ) ) ) ;
1671+ let head_counter = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
1672+
1673+ let ( s, e) = block_range_for_timestamps_with (
1674+ & bounds_memo,
1675+ UnixTimestamp ( 1000 ) ,
1676+ UnixTimestamp ( 2000 ) ,
1677+ counting_fetch_ts ( timestamps, log. clone ( ) ) ,
1678+ counting_fetch_head ( latest, head_counter. clone ( ) ) ,
1679+ )
1680+ . await
1681+ . unwrap ( ) ;
1682+ assert_eq ! ( ( s, e) , ( 0 , 0 ) ) ;
1683+
1684+ let calls = log. lock ( ) . unwrap ( ) ;
1685+ assert_eq ! (
1686+ calls. as_slice( ) ,
1687+ & [ 0u64 ] ,
1688+ "block 0 must be fetched exactly once for a single-block chain (calls: {calls:?})"
1689+ ) ;
1690+ assert_eq ! ( head_counter. load( Ordering :: SeqCst ) , 1 ) ;
1691+ }
1692+
1693+ #[ tokio:: test]
1694+ async fn interior_window_threads_memoized_latest_ts_through_binary_search ( ) {
1695+ // Window strictly inside chain history: both bounds drive the
1696+ // binary search, so the returned `(start, end)` depends on
1697+ // `latest_ts` being honestly threaded from the memo into
1698+ // `compute_block_range_given_bounds`. A regression that swaps
1699+ // `genesis_ts` and `latest_ts` at the call site, or zeroes
1700+ // out `latest_block` after the head closure runs, changes
1701+ // the answer (the `end_ts >= latest_ts` short-circuit fires
1702+ // under the wrong `latest_ts`, clamping `end_block` to
1703+ // `latest` instead of the correct interior block).
1704+ //
1705+ // The earlier tests prove the memo is consulted; this one
1706+ // proves the memoized values are actually fed to the binary
1707+ // search.
1708+ let timestamps = vec ! [ 1000 , 1100 , 1200 , 1300 , 1400 ] ;
1709+ let latest: BlockNumber = 4 ;
1710+ let bounds_memo = ChainBoundsMemo :: new ( DEFAULT_HEAD_TTL ) ;
1711+
1712+ let log = Arc :: new ( StdMutex :: new ( Vec :: < BlockNumber > :: new ( ) ) ) ;
1713+ let head_counter = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
1714+
1715+ let ( s, e) = block_range_for_timestamps_with (
1716+ & bounds_memo,
1717+ UnixTimestamp ( 1150 ) ,
1718+ UnixTimestamp ( 1250 ) ,
1719+ counting_fetch_ts ( timestamps, log. clone ( ) ) ,
1720+ counting_fetch_head ( latest, head_counter. clone ( ) ) ,
1721+ )
1722+ . await
1723+ . unwrap ( ) ;
1724+
1725+ // First block with ts >= 1150 is block 2 (ts=1200).
1726+ // Last block with ts <= 1250 is also block 2 (ts=1200).
1727+ assert_eq ! ( ( s, e) , ( 2 , 2 ) ) ;
1728+ assert_eq ! ( head_counter. load( Ordering :: SeqCst ) , 1 ) ;
1729+ }
1730+ }
15131731}
0 commit comments