Skip to main content

wp_evm_v3_provider/
populate_ticks.rs

1use alloy_primitives::{aliases::I24, Address, U256};
2use alloy_provider::{network::Ethereum, Provider};
3use alloy_sol_types::sol;
4use anyhow::{Context, Result};
5use wp_evm_v3_core::data::{PoolState, TickInfo};
6
7const MIN_TICK: i32 = -887272;
8const MAX_TICK: i32 = 887272;
9const MULTICALL_CHUNK_SIZE: usize = 500;
10
11sol! {
12    #[sol(rpc)]
13    interface IV3PoolBitmap {
14        function tickBitmap(int16 wordPos) external view returns (uint256);
15        function ticks(int24 tick) external view returns (
16            uint128 liquidityGross,
17            int128 liquidityNet,
18            uint256 feeGrowthOutside0X128,
19            uint256 feeGrowthOutside1X128,
20            int56 tickCumulativeOutside,
21            uint160 secondsPerLiquidityOutsideX128,
22            uint32 secondsOutside,
23            bool initialized
24        );
25    }
26}
27
28/// Protocol-specific tick bitmap reader used by the shared tick walker.
29#[allow(async_fn_in_trait)]
30pub trait TickBitmapSource {
31    /// Fetch the raw bitmap word for each word position.
32    async fn fetch_bitmap_words<P: Provider<Ethereum>>(
33        &self,
34        provider: &P,
35        pool: Address,
36        word_positions: &[i16],
37    ) -> Result<Vec<U256>>;
38
39    /// Fetch each initialized tick's normalized `(liquidity_gross, liquidity_net)` row.
40    async fn fetch_tick_rows<P: Provider<Ethereum>>(
41        &self,
42        provider: &P,
43        pool: Address,
44        ticks: &[i32],
45    ) -> Result<Vec<(u128, i128)>>;
46}
47
48/// Tick row normalized by the shared walker before assignment to a family state type.
49pub struct NormalizedTickInfo {
50    pub tick: i32,
51    pub liquidity_gross: u128,
52    pub liquidity_net: i128,
53}
54
55/// Adapter for family state records that store initialized ticks.
56pub trait TickStateSink {
57    fn tick_spacing(&self) -> i32;
58    fn set_ticks(&mut self, ticks: Vec<NormalizedTickInfo>);
59}
60
61impl TickStateSink for PoolState {
62    fn tick_spacing(&self) -> i32 {
63        self.tick_spacing
64    }
65
66    fn set_ticks(&mut self, ticks: Vec<NormalizedTickInfo>) {
67        self.ticks = ticks
68            .into_iter()
69            .map(|tick| TickInfo {
70                tick: tick.tick,
71                liquidity_gross: tick.liquidity_gross,
72                liquidity_net: tick.liquidity_net,
73            })
74            .collect();
75    }
76}
77
78struct V3TickSource;
79
80impl TickBitmapSource for V3TickSource {
81    async fn fetch_bitmap_words<P: Provider<Ethereum>>(
82        &self,
83        provider: &P,
84        pool: Address,
85        word_positions: &[i16],
86    ) -> Result<Vec<U256>> {
87        let contract = IV3PoolBitmap::new(pool, provider);
88        let mut mc = provider.multicall().dynamic::<IV3PoolBitmap::tickBitmapCall>();
89        for &word_pos in word_positions {
90            mc = mc.add_dynamic(contract.tickBitmap(word_pos));
91        }
92        mc.aggregate().await.context("populate_ticks phase1 multicall(tickBitmap)")
93    }
94
95    async fn fetch_tick_rows<P: Provider<Ethereum>>(
96        &self,
97        provider: &P,
98        pool: Address,
99        ticks: &[i32],
100    ) -> Result<Vec<(u128, i128)>> {
101        let contract = IV3PoolBitmap::new(pool, provider);
102        let mut mc = provider.multicall().dynamic::<IV3PoolBitmap::ticksCall>();
103        for &tick in ticks {
104            let tick_i24 = I24::try_from(tick).context("tick index out of i24 range")?;
105            mc = mc.add_dynamic(contract.ticks(tick_i24));
106        }
107
108        let rows = mc.aggregate().await.context("populate_ticks phase2 multicall(ticks)")?;
109        Ok(rows.into_iter().map(|row| (row.liquidityGross, row.liquidityNet)).collect())
110    }
111}
112
113fn word_range(tick_spacing: i32) -> std::ops::RangeInclusive<i16> {
114    let compressed_min = MIN_TICK.div_euclid(tick_spacing);
115    let compressed_max = MAX_TICK.div_euclid(tick_spacing);
116    let word_min = compressed_min.div_euclid(256) as i16;
117    let word_max = compressed_max.div_euclid(256) as i16;
118    word_min..=word_max
119}
120
121fn decompose_bitmap_word(word_pos: i16, word: U256, tick_spacing: i32) -> Vec<i32> {
122    let mut out = Vec::new();
123    for bit_pos in 0u32..256 {
124        if word.bit(bit_pos as usize) {
125            let compressed = (word_pos as i32) * 256 + (bit_pos as i32);
126            out.push(compressed * tick_spacing);
127        }
128    }
129    out
130}
131
132/// Populate `state.ticks` by scanning the full pool tick bitmap with a source adapter.
133///
134/// Two-phase multicall:
135/// 1. Read all bitmap words (MIN_TICK..=MAX_TICK / tick_spacing).
136/// 2. For each initialized tick index, read its normalized tick record.
137///
138/// Invariant check: `sum(liquidity_net) == 0` across the returned set.
139/// If a pool legitimately violates this (partial ticks, non-canonical
140/// deployment) we fail fast — callers should not rely on silent breakage.
141pub async fn populate_ticks_with<P, S, T>(
142    provider: &P,
143    pool: Address,
144    source: &S,
145    state: &mut T,
146) -> Result<()>
147where
148    P: Provider<Ethereum>,
149    S: TickBitmapSource,
150    T: TickStateSink,
151{
152    let tick_spacing = state.tick_spacing();
153    if tick_spacing <= 0 {
154        anyhow::bail!("invalid tick_spacing {}; expected > 0", tick_spacing);
155    }
156
157    // Phase 1: read bitmap words in multicall chunks.
158    let word_positions: Vec<i16> = word_range(tick_spacing).collect();
159    let mut initialized_ticks = Vec::<i32>::new();
160
161    for chunk in word_positions.chunks(MULTICALL_CHUNK_SIZE) {
162        let words = source.fetch_bitmap_words(provider, pool, chunk).await?;
163
164        for (&word_pos, word) in chunk.iter().zip(words.into_iter()) {
165            if !word.is_zero() {
166                initialized_ticks.extend(decompose_bitmap_word(word_pos, word, tick_spacing));
167            }
168        }
169    }
170
171    initialized_ticks.sort_unstable();
172
173    // Phase 2: fetch tick records in multicall chunks.
174    let mut tick_infos = Vec::<NormalizedTickInfo>::with_capacity(initialized_ticks.len());
175    for chunk in initialized_ticks.chunks(MULTICALL_CHUNK_SIZE) {
176        let rows = source.fetch_tick_rows(provider, pool, chunk).await?;
177
178        for (&tick, (liquidity_gross, liquidity_net)) in chunk.iter().zip(rows.into_iter()) {
179            if liquidity_gross > 0 && liquidity_net != 0 {
180                tick_infos.push(NormalizedTickInfo { tick, liquidity_gross, liquidity_net });
181            }
182        }
183    }
184
185    let sum_net: i128 = tick_infos.iter().map(|t| t.liquidity_net).sum();
186    if sum_net != 0 {
187        anyhow::bail!("populate_ticks invariant failed: sum(liquidity_net)={sum_net}, expected 0");
188    }
189
190    state.set_ticks(tick_infos);
191    Ok(())
192}
193
194/// Populate `state.ticks` by scanning the full pool tick bitmap.
195#[tracing::instrument(skip_all, fields(pool = %pool, tick_spacing = state.tick_spacing), err)]
196pub async fn populate_ticks<P: Provider<Ethereum>>(
197    provider: &P,
198    pool: Address,
199    state: &mut PoolState,
200) -> Result<()> {
201    populate_ticks_with(provider, pool, &V3TickSource, state).await
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn bitmap_word_decomposition_recovers_ticks() {
210        let mut word = U256::ZERO;
211        word |= U256::from(1u8) << 0;
212        word |= U256::from(1u8) << 3;
213        let ticks = decompose_bitmap_word(2, word, 60);
214        assert_eq!(ticks, vec![2 * 256 * 60, (2 * 256 + 3) * 60]);
215    }
216}