Skip to main content

wp_evm_v3_provider/
hydrate.rs

1//! Chain I/O for the v3 family.
2//!
3//! This is the **only module in this crate that reads from a provider**.
4//! `data`, `quote`, and `plan` are all pure. Consumers hydrate a `PoolState`
5//! once via `pool_state(...)` and then call `quote::*` / `plan::*` any
6//! number of times against the in-memory snapshot.
7//!
8//! Both `pool_state` and `position_state` use `wp-evm-multicall::aggregate3`
9//! to batch all view calls into a **single Multicall3 RPC** — pool state
10//! fans 6 calls (`token0`, `token1`, `fee`, `tickSpacing`, `liquidity`,
11//! `slot0`); position state fans 2 (`positions(tokenId)`, `ownerOf(tokenId)`).
12//! Aligns hydrate with the `wpe pool view` / `wpe position view` CLI
13//! pattern; ABI bindings come from `wp-evm-v3-interfaces` (`pool::*` +
14//! `periphery::nfpm`) — no inline `sol!` duplication.
15//!
16//! Multicall3 address is read from `config.multicall` so chains that
17//! deploy at non-canonical addresses work without code changes.
18
19use alloy_primitives::{Address, U256};
20use alloy_provider::{network::Ethereum, Provider};
21use alloy_sol_types::SolCall;
22use anyhow::{Context, Result};
23use wp_evm_base::{chain::Chain, evm::sign_extend_i24};
24use wp_evm_multicall::{aggregate3, IMulticall3};
25use wp_evm_v3_core::data::{PoolState, PositionState};
26use wp_evm_v3_interfaces::periphery::nfpm::INonfungiblePositionManagerView;
27use wp_evm_v3_interfaces::pool::{
28    immutables::IUniswapV3PoolImmutables, state::IUniswapV3PoolState,
29};
30
31/// Hydrate a `PoolState` from on-chain data via a single Multicall3 RPC.
32///
33/// Reads token addresses, fee, tickSpacing, liquidity, and slot0 in one
34/// batched call. The returned `PoolState.ticks` is intentionally empty
35/// in this phase — quote functions can exact-in small amounts that
36/// stay within the current tick. Phase 4 extends this with a windowed
37/// tickBitmap scan so large swaps that cross ticks can be quoted purely
38/// from in-memory state (see `populate_ticks`).
39#[tracing::instrument(skip_all, fields(pool = %pool), err)]
40pub async fn pool_state<P: Provider<Ethereum>>(
41    provider: &P,
42    multicall: Address,
43    pool: Address,
44) -> Result<PoolState> {
45    let mk = |data: Vec<u8>| IMulticall3::Call3 {
46        target: pool,
47        allowFailure: false,
48        callData: data.into(),
49    };
50
51    let calls = vec![
52        mk(IUniswapV3PoolImmutables::token0Call {}.abi_encode()),
53        mk(IUniswapV3PoolImmutables::token1Call {}.abi_encode()),
54        mk(IUniswapV3PoolImmutables::feeCall {}.abi_encode()),
55        mk(IUniswapV3PoolImmutables::tickSpacingCall {}.abi_encode()),
56        mk(IUniswapV3PoolState::liquidityCall {}.abi_encode()),
57        mk(IUniswapV3PoolState::slot0Call {}.abi_encode()),
58    ];
59
60    let raw = aggregate3(provider, multicall, calls).await?;
61    anyhow::ensure!(
62        raw.len() == 6,
63        "pool_state multicall returned {} results, expected 6",
64        raw.len(),
65    );
66
67    let token0 =
68        IUniswapV3PoolImmutables::token0Call::abi_decode_returns(raw[0].returnData.as_ref())
69            .context("decode token0")?;
70    let token1 =
71        IUniswapV3PoolImmutables::token1Call::abi_decode_returns(raw[1].returnData.as_ref())
72            .context("decode token1")?;
73    let fee = IUniswapV3PoolImmutables::feeCall::abi_decode_returns(raw[2].returnData.as_ref())
74        .context("decode fee")?;
75    let spacing =
76        IUniswapV3PoolImmutables::tickSpacingCall::abi_decode_returns(raw[3].returnData.as_ref())
77            .context("decode tickSpacing")?;
78    let liquidity =
79        IUniswapV3PoolState::liquidityCall::abi_decode_returns(raw[4].returnData.as_ref())
80            .context("decode liquidity")?;
81    let slot0 = IUniswapV3PoolState::slot0Call::abi_decode_returns(raw[5].returnData.as_ref())
82        .context("decode slot0")?;
83
84    Ok(PoolState {
85        token0,
86        token1,
87        fee: fee.to::<u32>(),
88        tick_spacing: sign_extend_i24(spacing),
89        sqrt_price_x96: U256::from(slot0.sqrtPriceX96),
90        liquidity,
91        tick: sign_extend_i24(slot0.tick),
92        ticks: vec![],
93    })
94}
95
96/// Hydrate a `PositionState` for a v3 NFT position via a single
97/// Multicall3 RPC.
98///
99/// Reads `positions(tokenId)` (12-tuple) + `ownerOf(tokenId)` from the
100/// NFPM at `config.position_mgr`. Multicall3 is dispatched at
101/// `config.multicall`. `tokensOwed*` carries NFPM's stored snapshot —
102/// only fresh when the position was just touched. For current
103/// (live-accrued) fees, use `wpe position view --with-fees` which
104/// runs the full Phase-2 fee-growth math.
105#[tracing::instrument(skip_all, fields(token_id = %token_id), err)]
106pub async fn position_state<P: Provider<Ethereum>>(
107    provider: &P,
108    multicall: Address,
109    position_manager: Address,
110    token_id: U256,
111) -> Result<PositionState> {
112    let mk = |data: Vec<u8>| IMulticall3::Call3 {
113        target: position_manager,
114        allowFailure: false,
115        callData: data.into(),
116    };
117
118    let calls = vec![
119        mk(INonfungiblePositionManagerView::positionsCall { tokenId: token_id }.abi_encode()),
120        mk(INonfungiblePositionManagerView::ownerOfCall { tokenId: token_id }.abi_encode()),
121    ];
122
123    let raw = aggregate3(provider, multicall, calls).await?;
124    anyhow::ensure!(
125        raw.len() == 2,
126        "position_state multicall returned {} results, expected 2",
127        raw.len(),
128    );
129
130    let pos = INonfungiblePositionManagerView::positionsCall::abi_decode_returns(
131        raw[0].returnData.as_ref(),
132    )
133    .context("decode positions")?;
134    let owner = INonfungiblePositionManagerView::ownerOfCall::abi_decode_returns(
135        raw[1].returnData.as_ref(),
136    )
137    .context("decode ownerOf")?;
138
139    Ok(PositionState {
140        token_id,
141        owner,
142        token0: pos.token0,
143        token1: pos.token1,
144        fee: pos.fee.to(),
145        tick_lower: sign_extend_i24(pos.tickLower),
146        tick_upper: sign_extend_i24(pos.tickUpper),
147        liquidity: pos.liquidity,
148        fees_owed_0: U256::from(pos.tokensOwed0),
149        fees_owed_1: U256::from(pos.tokensOwed1),
150    })
151}
152
153/// Read `(token0, token1)` from an NFPM via a typed `positions(tokenId)`
154/// eth_call. Hoisted from the CLI v3 increase-liquidity command — single
155/// eth_call, byte-identical `(token0, token1)` to the prior raw read.
156pub async fn position_token_pair<P: Provider<Ethereum>>(
157    provider: &P,
158    nfpm: Address,
159    token_id: U256,
160) -> Result<(Address, Address)> {
161    // Read via the pure ABI codec from wp-evm-v3-interfaces (the interface crate
162    // stays a dependency-light pure codec — no `#[sol(rpc)]`, no async deps). The
163    // rpc machinery lives here in the provider via alloy-contract's
164    // `SolCallBuilder`, which is exactly what generated `sol!` bindings call.
165    let ret = alloy_contract::SolCallBuilder::new_sol(
166        provider,
167        &nfpm,
168        &INonfungiblePositionManagerView::positionsCall { tokenId: token_id },
169    )
170    .call()
171    .await
172    .context("eth_call positions")?;
173    Ok((ret.token0, ret.token1))
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct Enumeration {
178    /// Successfully decoded token IDs from Phase 2.
179    pub token_ids: Vec<U256>,
180    /// Phase-1 `Multicall3.getBlockNumber()` result: snapshot block.
181    pub block_number: U256,
182    /// Phase-1 `balanceOf(owner)` result: number of indices requested.
183    pub expected_count: u64,
184    /// Phase-2 indices whose `tokenOfOwnerByIndex` call reverted or failed to decode.
185    pub failed_indices: Vec<u64>,
186}
187
188impl Enumeration {
189    fn empty(block_number: U256) -> Self {
190        Self { token_ids: Vec::new(), block_number, expected_count: 0, failed_indices: Vec::new() }
191    }
192}
193
194/// Enumerate an owner's NFPM token IDs at a single pinned block.
195///
196/// ERC721Enumerable: `balanceOf` + `tokenOfOwnerByIndex` calldata is
197/// target-independent, so this one fn serves every CLMM family's NFPM.
198/// Phase 1 (balanceOf + getBlockNumber, allowFailure:false) pins the snapshot;
199/// Phase 2 (tokenOfOwnerByIndex 0..N, allowFailure:true) records reverts in
200/// `failed_indices` (the Phase-1<->2 transfer-out race contract).
201pub async fn enumerate_owner_token_ids<P: Provider<Ethereum>>(
202    provider: &P,
203    multicall: Address,
204    nfpm: Address,
205    owner: Address,
206    chain: Chain,
207) -> Result<Enumeration> {
208    let calls = vec![
209        IMulticall3::Call3 {
210            target: nfpm,
211            allowFailure: false,
212            callData: INonfungiblePositionManagerView::balanceOfCall { owner }.abi_encode().into(),
213        },
214        IMulticall3::Call3 {
215            target: multicall,
216            allowFailure: false,
217            callData: IMulticall3::getBlockNumberCall {}.abi_encode().into(),
218        },
219    ];
220
221    let raw = aggregate3(provider, multicall, calls).await?;
222    anyhow::ensure!(raw.len() == 2, "phase1 multicall returned {} results, expected 2", raw.len());
223
224    let balance = INonfungiblePositionManagerView::balanceOfCall::abi_decode_returns(
225        raw[0].returnData.as_ref(),
226    )
227    .context("decode balanceOf")?;
228    let block_number =
229        IMulticall3::getBlockNumberCall::abi_decode_returns(raw[1].returnData.as_ref())
230            .context("decode getBlockNumber")?;
231
232    let expected_count: u64 = balance.try_into().with_context(|| {
233        format!(
234            "balanceOf returned {balance} (exceeds u64) — does NFPM point at a real ERC-721? \
235             Chain: {chain}; NFPM: {nfpm:#x}",
236        )
237    })?;
238
239    if expected_count == 0 {
240        return Ok(Enumeration::empty(block_number));
241    }
242
243    let calls: Vec<IMulticall3::Call3> = (0..expected_count)
244        .map(|i| IMulticall3::Call3 {
245            target: nfpm,
246            allowFailure: true,
247            callData: INonfungiblePositionManagerView::tokenOfOwnerByIndexCall {
248                owner,
249                index: U256::from(i),
250            }
251            .abi_encode()
252            .into(),
253        })
254        .collect();
255
256    let raw = aggregate3(provider, multicall, calls).await?;
257    anyhow::ensure!(
258        raw.len() == expected_count as usize,
259        "phase2 multicall returned {} results, expected {}",
260        raw.len(),
261        expected_count,
262    );
263
264    let mut token_ids = Vec::with_capacity(raw.len());
265    let mut failed_indices = Vec::new();
266    for (idx, r) in raw.iter().enumerate() {
267        if !r.success {
268            failed_indices.push(idx as u64);
269            continue;
270        }
271        match INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::abi_decode_returns(
272            r.returnData.as_ref(),
273        ) {
274            Ok(token_id) => token_ids.push(token_id),
275            Err(_) => failed_indices.push(idx as u64),
276        }
277    }
278
279    Ok(Enumeration { token_ids, block_number, expected_count, failed_indices })
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use alloy_primitives::{address, Bytes};
286    use alloy_sol_types::SolCall;
287
288    fn ok(return_data: Vec<u8>) -> IMulticall3::Result {
289        IMulticall3::Result { success: true, returnData: Bytes::from(return_data) }
290    }
291
292    fn failed() -> IMulticall3::Result {
293        IMulticall3::Result { success: false, returnData: Bytes::new() }
294    }
295
296    #[tokio::test]
297    async fn enumerate_reports_phase2_failed_indices() {
298        use alloy::transports::mock::Asserter;
299        use alloy_provider::ProviderBuilder;
300
301        let asserter = Asserter::new();
302        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
303        let multicall = address!("cA11bde05977b3631167028862bE2a173976CA11");
304        let nfpm = address!("00000000000000000000000000000000000000f1");
305        let owner = address!("00000000000000000000000000000000000000a1");
306
307        let phase1_returns = vec![
308            ok(INonfungiblePositionManagerView::balanceOfCall::abi_encode_returns(&U256::from(
309                3u64,
310            ))),
311            ok(IMulticall3::getBlockNumberCall::abi_encode_returns(&U256::from(123u64))),
312        ];
313        asserter.push_success(&IMulticall3::aggregate3Call::abi_encode_returns(&phase1_returns));
314
315        let phase2_returns = vec![
316            ok(INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::abi_encode_returns(
317                &U256::from(11u64),
318            )),
319            ok(INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::abi_encode_returns(
320                &U256::from(22u64),
321            )),
322            failed(),
323        ];
324        asserter.push_success(&IMulticall3::aggregate3Call::abi_encode_returns(&phase2_returns));
325
326        let enumeration =
327            enumerate_owner_token_ids(&provider, multicall, nfpm, owner, Chain::Ethereum)
328                .await
329                .expect("enumeration succeeds");
330
331        assert_eq!(enumeration.expected_count, 3);
332        assert_eq!(enumeration.block_number, U256::from(123u64));
333        assert_eq!(enumeration.token_ids, vec![U256::from(11u64), U256::from(22u64)]);
334        assert_eq!(enumeration.failed_indices, vec![2]);
335    }
336
337    #[tokio::test]
338    async fn enumerate_empty_wallet_short_circuits() {
339        use alloy::transports::mock::Asserter;
340        use alloy_provider::ProviderBuilder;
341
342        let asserter = Asserter::new();
343        let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone());
344        let multicall = address!("cA11bde05977b3631167028862bE2a173976CA11");
345        let nfpm = address!("00000000000000000000000000000000000000f1");
346        let owner = address!("00000000000000000000000000000000000000a1");
347
348        let phase1_returns = vec![
349            ok(INonfungiblePositionManagerView::balanceOfCall::abi_encode_returns(&U256::ZERO)),
350            ok(IMulticall3::getBlockNumberCall::abi_encode_returns(&U256::from(456u64))),
351        ];
352        asserter.push_success(&IMulticall3::aggregate3Call::abi_encode_returns(&phase1_returns));
353
354        let enumeration =
355            enumerate_owner_token_ids(&provider, multicall, nfpm, owner, Chain::Ethereum)
356                .await
357                .expect("enumeration succeeds");
358
359        assert_eq!(enumeration.expected_count, 0);
360        assert_eq!(enumeration.block_number, U256::from(456u64));
361        assert!(enumeration.token_ids.is_empty());
362        assert!(enumeration.failed_indices.is_empty());
363    }
364
365    #[tokio::test]
366    async fn position_token_pair_decodes_v3_pair() {
367        use alloy::transports::mock::Asserter;
368        use alloy_provider::ProviderBuilder;
369        let ret = INonfungiblePositionManagerView::positionsReturn {
370            nonce: alloy_primitives::aliases::U96::ZERO,
371            operator: Address::ZERO,
372            token0: Address::with_last_byte(0xAA),
373            token1: Address::with_last_byte(0xBB),
374            fee: alloy_primitives::aliases::U24::from(500u32),
375            tickLower: alloy_primitives::aliases::I24::try_from(-100i32).unwrap(),
376            tickUpper: alloy_primitives::aliases::I24::try_from(100i32).unwrap(),
377            liquidity: 1u128,
378            feeGrowthInside0LastX128: U256::ZERO,
379            feeGrowthInside1LastX128: U256::ZERO,
380            tokensOwed0: 0u128,
381            tokensOwed1: 0u128,
382        };
383        let asserter = Asserter::new();
384        asserter.push_success(&alloy_primitives::Bytes::from(
385            INonfungiblePositionManagerView::positionsCall::abi_encode_returns(&ret),
386        ));
387        let provider = ProviderBuilder::new().connect_mocked_client(asserter);
388        let (t0, t1) =
389            position_token_pair(&provider, Address::ZERO, U256::from(1u64)).await.unwrap();
390        assert_eq!(t0, Address::with_last_byte(0xAA));
391        assert_eq!(t1, Address::with_last_byte(0xBB));
392    }
393
394    #[test]
395    fn nfpm_enumerable_selectors_match_across_families() {
396        use wp_evm_algebra_interfaces::periphery::nfpm::IAlgebraNonfungiblePositionManager;
397        use wp_evm_ramses_interfaces::periphery::nfpm::IRamsesNonfungiblePositionManager;
398        use wp_evm_velodrome_interfaces::periphery::nfpm::IVelodromeNonfungiblePositionManager;
399
400        assert_eq!(
401            INonfungiblePositionManagerView::balanceOfCall::SELECTOR,
402            IAlgebraNonfungiblePositionManager::balanceOfCall::SELECTOR,
403        );
404        assert_eq!(
405            INonfungiblePositionManagerView::balanceOfCall::SELECTOR,
406            IRamsesNonfungiblePositionManager::balanceOfCall::SELECTOR,
407        );
408        assert_eq!(
409            INonfungiblePositionManagerView::balanceOfCall::SELECTOR,
410            IVelodromeNonfungiblePositionManager::balanceOfCall::SELECTOR,
411        );
412        assert_eq!(
413            INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::SELECTOR,
414            IAlgebraNonfungiblePositionManager::tokenOfOwnerByIndexCall::SELECTOR,
415        );
416        assert_eq!(
417            INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::SELECTOR,
418            IRamsesNonfungiblePositionManager::tokenOfOwnerByIndexCall::SELECTOR,
419        );
420        assert_eq!(
421            INonfungiblePositionManagerView::tokenOfOwnerByIndexCall::SELECTOR,
422            IVelodromeNonfungiblePositionManager::tokenOfOwnerByIndexCall::SELECTOR,
423        );
424    }
425}