Skip to content

Commit 5bcf94d

Browse files
committed
Fetch uncached V3 pools on demand so cold quotes find liquidity
1 parent a1aa54b commit 5bcf94d

2 files changed

Lines changed: 287 additions & 39 deletions

File tree

crates/liquidity-sources/src/uniswap_v3/graph_api.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,13 @@ impl V3PoolDataSource for UniV3SubgraphClient {
261261
ids: &[Address],
262262
target_block: u64,
263263
) -> Result<PoolsWithTicks> {
264+
// `0` means "latest available" (see the V3PoolDataSource contract). The
265+
// subgraph needs a concrete block — `block: { number: 0 }` queries genesis
266+
// — so resolve a recent safe block first.
267+
let target_block = match target_block {
268+
0 => self.get_safe_block().await?,
269+
n => n,
270+
};
264271
let (pools, ticks) = futures::try_join!(
265272
self.get_pools_by_pool_ids(ids, target_block),
266273
self.get_ticks_by_pools_ids(ids, target_block)

crates/liquidity-sources/src/uniswap_v3/pool_fetching.rs

Lines changed: 280 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,19 @@ impl PoolsCheckpointHandler {
221221
})
222222
}
223223

224-
/// For a given list of token pairs, fetches the pools for the ones that
225-
/// exist in the checkpoint. For the ones that don't exist, flag as
226-
/// missing and expect to exist after the next maintenance run.
227-
fn get(&self, token_pairs: &HashSet<TokenPair>) -> (HashMap<Address, Arc<PoolInfo>>, u64) {
224+
/// Returns cached pools for the pairs, plus the ids of any that exist but
225+
/// aren't cached yet. Misses are recorded for the next maintenance run; the
226+
/// quote path fetches them on demand.
227+
fn get(
228+
&self,
229+
token_pairs: &HashSet<TokenPair>,
230+
) -> (HashMap<Address, Arc<PoolInfo>>, Vec<Address>, u64) {
228231
let mut pool_ids = token_pairs
229232
.iter()
230233
.filter_map(|pair| self.pools_by_token_pair.get(pair))
231234
.flatten()
232235
.peekable();
233236

234-
tracing::trace!("get checkpoint for pool_ids: {:?}", pool_ids);
235-
236237
match pool_ids.peek() {
237238
Some(_) => {
238239
let mut pools_checkpoint = self.pools_checkpoint.lock().unwrap();
@@ -245,57 +246,93 @@ impl PoolsCheckpointHandler {
245246
}
246247
None => true,
247248
})
249+
.copied()
248250
.collect::<Vec<_>>();
249251

250252
tracing::trace!(
251253
"cache hit: {:?}, cache miss: {:?}",
252254
existing_pools.keys(),
253255
missing_pools
254256
);
255-
pools_checkpoint.missing_pools.extend(missing_pools);
256-
(existing_pools, pools_checkpoint.block_number)
257+
pools_checkpoint
258+
.missing_pools
259+
.extend(missing_pools.iter().copied());
260+
(existing_pools, missing_pools, pools_checkpoint.block_number)
257261
}
258262
None => Default::default(),
259263
}
260264
}
261265

262-
/// Fetches state/ticks for missing pools and moves them from
263-
/// `missing_pools` to `pools`
264-
async fn update_missing_pools(&self) -> Result<()> {
265-
let (missing_pools, block_number) = {
266-
let checkpoint = self.pools_checkpoint.lock().unwrap();
267-
if checkpoint.missing_pools.is_empty() {
268-
return Ok(());
269-
}
270-
(checkpoint.missing_pools.clone(), checkpoint.block_number)
271-
};
272-
tracing::debug!("currently missing pools are {:?}", missing_pools);
273-
274-
let pool_ids = missing_pools.into_iter().collect::<Vec<_>>();
275-
let start = std::time::Instant::now();
266+
/// Fetches and converts the given pools from the source at `target_block`,
267+
/// skipping any that can't be converted yet (e.g. no ticks). Doesn't touch
268+
/// the cache. Pass the checkpoint block to cache for replay, or 0 to take
269+
/// the indexer's current snapshot without blocking.
270+
async fn fetch_pools(
271+
&self,
272+
pool_ids: &[Address],
273+
target_block: u64,
274+
) -> Result<Vec<(Address, Arc<PoolInfo>)>> {
275+
if pool_ids.is_empty() {
276+
return Ok(Vec::new());
277+
}
276278
let pools_with_ticks = self
277279
.source
278-
.get_pools_with_ticks_by_ids(&pool_ids, block_number)
279-
.await;
280-
tracing::debug!(
281-
requested_pools = pool_ids.len(),
282-
time = ?start.elapsed(),
283-
request_successful = pools_with_ticks.is_ok(),
284-
"fetched pool ticks"
285-
);
286-
let pools_with_ticks = pools_with_ticks?;
280+
.get_pools_with_ticks_by_ids(pool_ids, target_block)
281+
.await?;
282+
Ok(pools_with_ticks
283+
.pools
284+
.into_iter()
285+
.filter_map(|pool| {
286+
let id = pool.id;
287+
match PoolInfo::try_from(pool) {
288+
Ok(info) => Some((id, Arc::new(info))),
289+
Err(err) => {
290+
tracing::debug!(?id, ?err, "skipping pool missing tick data");
291+
None
292+
}
293+
}
294+
})
295+
.collect())
296+
}
287297

288-
let mut checkpoint = self.pools_checkpoint.lock().unwrap();
289-
for pool in pools_with_ticks.pools {
290-
checkpoint.missing_pools.remove(&pool.id);
291-
checkpoint.pools.insert(pool.id, Arc::new(pool.try_into()?));
298+
/// Fetches the given pools at the indexer's current block, without waiting.
299+
/// The checkpoint block can sit ahead of the indexer (it tracks latest
300+
/// minus the reorg buffer, the indexer serves finalized), so waiting
301+
/// for it would hang the quote. Returns empty on failure so the quote
302+
/// falls back to cached pools.
303+
async fn fetch_current(&self, pool_ids: &[Address]) -> Vec<(Address, Arc<PoolInfo>)> {
304+
match self.fetch_pools(pool_ids, 0).await {
305+
Ok(pools) => pools,
306+
Err(err) => {
307+
tracing::debug!(
308+
?err,
309+
"on-demand pool fetch failed; serving cached pools only"
310+
);
311+
Vec::new()
312+
}
292313
}
314+
}
293315

294-
tracing::debug!("number of cached pools is {}", checkpoint.pools.len());
316+
/// Fetches the pools flagged missing by `get` at the checkpoint block and
317+
/// caches them. Runs from periodic maintenance.
318+
async fn update_missing_pools(&self) -> Result<()> {
319+
let (missing, block_number) = {
320+
let checkpoint = self.pools_checkpoint.lock().unwrap();
321+
(
322+
checkpoint.missing_pools.iter().copied().collect::<Vec<_>>(),
323+
checkpoint.block_number,
324+
)
325+
};
326+
let fetched = self.fetch_pools(&missing, block_number).await?;
327+
let mut checkpoint = self.pools_checkpoint.lock().unwrap();
328+
for (id, info) in fetched {
329+
checkpoint.missing_pools.remove(&id);
330+
checkpoint.pools.insert(id, info);
331+
}
295332
if !checkpoint.missing_pools.is_empty() {
296333
tracing::warn!(
297-
"not all missing pools updated: {:?}",
298-
checkpoint.missing_pools
334+
remaining = checkpoint.missing_pools.len(),
335+
"not all missing pools updated"
299336
);
300337
}
301338
Ok(())
@@ -421,14 +458,28 @@ impl PoolFetching for UniswapV3PoolFetcher {
421458

422459
// this is the only place where this function uses checkpoint - no data racing
423460
// between maintenance
424-
let (mut checkpoint, checkpoint_block_number) = self.checkpoint.get(token_pairs);
461+
let (mut checkpoint, missing, checkpoint_block_number) = self.checkpoint.get(token_pairs);
462+
463+
// No pools registered for these pairs: `get` returns block 0, so skip the
464+
// replay below (it would otherwise scan events from block 1) and return.
465+
if checkpoint.is_empty() && missing.is_empty() {
466+
return Ok(Vec::new());
467+
}
425468

426469
if block_number > checkpoint_block_number {
427470
let block_range = RangeInclusive::try_new(checkpoint_block_number + 1, block_number)?;
428471
let events = self.events.lock().await.store().get_events(block_range);
429472
append_events(&mut checkpoint, events);
430473
}
431474

475+
// The warm cache only holds the top pools by raw liquidity, so plenty of
476+
// real pairs are absent. Fetch those on demand instead of waiting for the
477+
// next maintenance tick. They come back current, so merge them after the
478+
// replay rather than replaying them.
479+
if !missing.is_empty() {
480+
checkpoint.extend(self.checkpoint.fetch_current(&missing).await);
481+
}
482+
432483
// return only pools which current liquidity is positive
433484
Ok(checkpoint
434485
.into_values()
@@ -766,4 +817,194 @@ mod tests {
766817
])
767818
);
768819
}
820+
821+
/// Serves a fixed set of pools (with ticks) from
822+
/// `get_pools_with_ticks_by_ids` so the on-demand fetch path can be
823+
/// exercised without a real source. `served_block` models the indexer's
824+
/// head: a request for a higher `target_block` fails, mirroring the real
825+
/// client's `wait_until` blocking on a block the indexer hasn't reached.
826+
struct StubSource {
827+
with_ticks: HashMap<Address, PoolData>,
828+
served_block: u64,
829+
}
830+
831+
impl StubSource {
832+
fn new(pools: impl IntoIterator<Item = PoolData>) -> Self {
833+
Self {
834+
with_ticks: pools.into_iter().map(|p| (p.id, p)).collect(),
835+
served_block: u64::MAX,
836+
}
837+
}
838+
}
839+
840+
#[async_trait::async_trait]
841+
impl V3PoolDataSource for StubSource {
842+
async fn get_registered_pools(
843+
&self,
844+
_target_block: u64,
845+
) -> Result<crate::uniswap_v3::graph_api::RegisteredPools> {
846+
Ok(Default::default())
847+
}
848+
849+
async fn get_pools_with_ticks_by_ids(
850+
&self,
851+
ids: &[Address],
852+
target_block: u64,
853+
) -> Result<crate::uniswap_v3::graph_api::PoolsWithTicks> {
854+
anyhow::ensure!(
855+
target_block <= self.served_block,
856+
"indexer at {} hasn't reached target block {target_block}",
857+
self.served_block,
858+
);
859+
let pools = ids
860+
.iter()
861+
.filter_map(|id| self.with_ticks.get(id).cloned())
862+
.collect();
863+
Ok(crate::uniswap_v3::graph_api::PoolsWithTicks {
864+
fetched_block_number: self.served_block,
865+
pools,
866+
})
867+
}
868+
}
869+
870+
fn pool_with_ticks(id: Address, token0: Address, token1: Address) -> PoolData {
871+
PoolData {
872+
id,
873+
token0: Token {
874+
id: token0,
875+
decimals: 6,
876+
},
877+
token1: Token {
878+
id: token1,
879+
decimals: 18,
880+
},
881+
fee_tier: U256::from(3000),
882+
liquidity: U256::from(1_000_000u64),
883+
sqrt_price: U256::from(1u64),
884+
tick: BigInt::from(0),
885+
ticks: Some(vec![crate::uniswap_v3::graph_api::TickData {
886+
tick_idx: BigInt::from(-100),
887+
liquidity_net: BigInt::from(1_000),
888+
pool_address: id,
889+
}]),
890+
block_number: 100,
891+
}
892+
}
893+
894+
fn handler(source: StubSource, checkpoint: PoolsCheckpoint) -> PoolsCheckpointHandler {
895+
PoolsCheckpointHandler {
896+
source: Arc::new(source),
897+
pools_by_token_pair: HashMap::new(),
898+
pools_checkpoint: Mutex::new(checkpoint),
899+
}
900+
}
901+
902+
/// A pool registered for a pair but absent from the warm cache (the
903+
/// raw-liquidity-ranked top-N) is flagged missing by `get` and resolved by
904+
/// a single on-demand `fetch_current`, without waiting for a
905+
/// maintenance cycle.
906+
#[tokio::test]
907+
async fn on_demand_fetch_serves_uncached_pool() {
908+
let token0 = Address::with_last_byte(1);
909+
let token1 = Address::with_last_byte(2);
910+
let pair = TokenPair::new(token0, token1).unwrap();
911+
let pool = Address::with_last_byte(9);
912+
913+
let mut handler = handler(
914+
StubSource::new([pool_with_ticks(pool, token0, token1)]),
915+
PoolsCheckpoint {
916+
pools: HashMap::new(),
917+
block_number: 100,
918+
missing_pools: HashSet::new(),
919+
},
920+
);
921+
handler.pools_by_token_pair = HashMap::from([(pair, vec![pool])]);
922+
923+
// Cold: not cached, flagged missing.
924+
let (cached, missing, _) = handler.get(&HashSet::from([pair]));
925+
assert!(cached.is_empty());
926+
assert_eq!(missing, vec![pool]);
927+
928+
// On-demand fetch resolves it.
929+
let fetched = handler.fetch_current(&missing).await;
930+
assert_eq!(fetched.len(), 1);
931+
assert_eq!(fetched[0].0, pool);
932+
}
933+
934+
/// The on-demand path must not block on the checkpoint block (which can sit
935+
/// persistently ahead of the indexer's served block); it fetches at the
936+
/// indexer's current block. A source that errors for any block above its
937+
/// head still yields the pool via `fetch_current`.
938+
#[tokio::test]
939+
async fn on_demand_does_not_wait_for_future_block() {
940+
let token0 = Address::with_last_byte(1);
941+
let token1 = Address::with_last_byte(2);
942+
let pool = Address::with_last_byte(9);
943+
944+
let mut source = StubSource::new([pool_with_ticks(pool, token0, token1)]);
945+
source.served_block = 50; // indexer behind the checkpoint below
946+
947+
let handler = handler(
948+
source,
949+
PoolsCheckpoint {
950+
pools: HashMap::new(),
951+
block_number: 100, // checkpoint ahead of the indexer
952+
missing_pools: HashSet::new(),
953+
},
954+
);
955+
956+
// fetch_current uses target 0, so it succeeds despite served_block <
957+
// checkpoint.
958+
let fetched = handler.fetch_current(&[pool]).await;
959+
assert_eq!(fetched.len(), 1);
960+
assert_eq!(fetched[0].0, pool);
961+
962+
// Fetching at the checkpoint block would fail (indexer hasn't reached it).
963+
assert!(handler.fetch_pools(&[pool], 100).await.is_err());
964+
}
965+
966+
/// Unknown pairs have no registered pools, so `get` reports block 0 with
967+
/// nothing cached or missing — the signal `fetch` uses to skip the replay.
968+
#[test]
969+
fn get_reports_zero_block_for_unknown_pairs() {
970+
let handler = handler(
971+
StubSource::new([]),
972+
PoolsCheckpoint {
973+
pools: HashMap::new(),
974+
block_number: 100,
975+
missing_pools: HashSet::new(),
976+
},
977+
);
978+
let pair = TokenPair::new(Address::with_last_byte(1), Address::with_last_byte(2)).unwrap();
979+
let (cached, missing, block) = handler.get(&HashSet::from([pair]));
980+
assert!(cached.is_empty());
981+
assert!(missing.is_empty());
982+
assert_eq!(block, 0);
983+
}
984+
985+
/// A pool that can't be converted (e.g. ticks not yet available) is skipped
986+
/// rather than failing the whole batch; the convertible pool is returned.
987+
#[tokio::test]
988+
async fn fetch_pools_skips_unconvertible_pool() {
989+
let token0 = Address::with_last_byte(1);
990+
let token1 = Address::with_last_byte(2);
991+
let good = Address::with_last_byte(9);
992+
let bad = Address::with_last_byte(10);
993+
994+
let mut bad_pool = pool_with_ticks(bad, token0, token1);
995+
bad_pool.ticks = None; // PoolInfo::try_from fails on missing ticks
996+
997+
let handler = handler(
998+
StubSource::new([pool_with_ticks(good, token0, token1), bad_pool]),
999+
PoolsCheckpoint {
1000+
pools: HashMap::new(),
1001+
block_number: 100,
1002+
missing_pools: HashSet::new(),
1003+
},
1004+
);
1005+
1006+
let fetched = handler.fetch_pools(&[good, bad], 0).await.unwrap();
1007+
assert_eq!(fetched.len(), 1);
1008+
assert_eq!(fetched[0].0, good);
1009+
}
7691010
}

0 commit comments

Comments
 (0)