Skip to main content

wp_evm_v3_provider/
position_views.rs

1//! Batch position-view reads and current-fee augmentation for V3-family NFPM positions.
2//!
3//! Hoisted from the CLI presenter so V3-fork facades can expose the same
4//! resilient batch pipeline without duplicating Multicall3 decode/math logic.
5
6use alloy_primitives::{aliases::I24, Address, U256};
7use alloy_provider::{network::Ethereum, Provider};
8use alloy_sol_types::SolCall;
9use anyhow::Result;
10use wp_evm_multicall::{aggregate3, IMulticall3};
11use wp_evm_v3_core::position::PositionView;
12use wp_evm_v3_interfaces::periphery::nfpm::INonfungiblePositionManagerView;
13
14/// Per-position fee data produced by Phase 2 + fee-growth math.
15/// `None` on `pool` / `fees` indicates the corresponding pool read
16/// reverted (uninitialized pool, NFPM mismatch, etc.) — entry's
17/// position data still emits, just without fee augmentation.
18#[derive(Debug, Clone)]
19pub struct PositionFees {
20    pub pool: Address,
21    pub current_tick: i32,
22    pub sqrt_price_x96: U256,
23    pub in_range: bool,
24    pub current_owed_0: U256,
25    pub current_owed_1: U256,
26}
27
28/// One entry in the output — either decoded values or an error string.
29///
30/// Per-entry success/error discrimination keeps the batch resilient:
31/// a single bad token ID (nonexistent / burned / non-NFPM contract)
32/// does not abort the whole multicall.
33#[derive(Debug, Clone)]
34pub struct PositionViewEntry<V> {
35    /// The token ID echoed back from the input.
36    pub token_id: U256,
37    /// Decoded `(view, owner)` on success, error message on failure.
38    pub result: std::result::Result<(V, Address), String>,
39    /// `Some(fees)` only when `--with-fees` was set AND Phase 2
40    /// succeeded for this position. `None` otherwise.
41    pub fees: Option<PositionFees>,
42}
43
44/// Phase 1: dispatch NFPM `positions(tokenId)` + `ownerOf(tokenId)`
45/// for every token ID via a single Multicall3 RPC, decode each
46/// (success or per-entry error), and return the entries.
47///
48/// Split out from the original `fetch_and_render_positions` so Slice E's
49/// `--with-fees` upgrade can splice a Phase-2 pool batch between
50/// fetch and render without forking this helper.
51pub async fn position_views<P: Provider<Ethereum>>(
52    provider: &P,
53    multicall: Address,
54    nfpm: Address,
55    token_ids: &[U256],
56) -> Result<Vec<PositionViewEntry<PositionView>>> {
57    let mut calls: Vec<IMulticall3::Call3> = Vec::with_capacity(token_ids.len() * 2);
58    for tid in token_ids {
59        calls.extend(build_token_calls(nfpm, *tid));
60    }
61
62    let raw_results = aggregate3(provider, multicall, calls).await?;
63    let expected = token_ids.len() * 2;
64    anyhow::ensure!(
65        raw_results.len() == expected,
66        "multicall returned {} results for {} token IDs; expected {}",
67        raw_results.len(),
68        token_ids.len(),
69        expected,
70    );
71
72    Ok(token_ids
73        .iter()
74        .zip(raw_results.chunks(2))
75        .map(|(tid, chunk)| decode_one_position(*tid, chunk))
76        .collect())
77}
78
79fn build_token_calls(nfpm: Address, token_id: U256) -> [IMulticall3::Call3; 2] {
80    let mk = |data: Vec<u8>| IMulticall3::Call3 {
81        target: nfpm,
82        allowFailure: true,
83        callData: data.into(),
84    };
85    [
86        mk(INonfungiblePositionManagerView::positionsCall { tokenId: token_id }.abi_encode()),
87        mk(INonfungiblePositionManagerView::ownerOfCall { tokenId: token_id }.abi_encode()),
88    ]
89}
90
91fn decode_one_position(
92    token_id: U256,
93    chunk: &[IMulticall3::Result],
94) -> PositionViewEntry<PositionView> {
95    debug_assert_eq!(chunk.len(), 2);
96    match try_decode(token_id, chunk) {
97        Ok(decoded) => PositionViewEntry { token_id, result: Ok(decoded), fees: None },
98        Err(err) => PositionViewEntry { token_id, result: Err(err.to_string()), fees: None },
99    }
100}
101
102fn try_decode(token_id: U256, chunk: &[IMulticall3::Result]) -> Result<(PositionView, Address)> {
103    let need = |idx: usize, label: &str| -> Result<&[u8]> {
104        let r = &chunk[idx];
105        if !r.success {
106            anyhow::bail!("{label} reverted (token may not exist or NFPM mismatch)");
107        }
108        Ok(r.returnData.as_ref())
109    };
110
111    let positions_ret =
112        INonfungiblePositionManagerView::positionsCall::abi_decode_returns(need(0, "positions")?)?;
113    let owner =
114        INonfungiblePositionManagerView::ownerOfCall::abi_decode_returns(need(1, "ownerOf")?)?;
115
116    let view = PositionView::from_nfpm_returns(token_id, &positions_ret);
117    Ok((view, owner))
118}
119
120/// 4-call pool-globals decode result. Used by the dedup'd Phase 2 to
121/// share globals across positions in the same pool.
122#[derive(Debug, Clone)]
123pub struct PoolGlobals {
124    pub current_tick_i24: I24,
125    pub sqrt_price_x96: U256,
126    pub fg_global_0: U256,
127    pub fg_global_1: U256,
128}
129
130/// Pool-state ABI plug for the position-fee engine. One impl per family ABI,
131/// always on a LOCAL unit struct (orphan-safe). Decode-only: the engine owns
132/// the single aggregate3 so the dedup batch (4·K + 2·N) stays one multicall.
133pub trait PoolFeeGrowthSource {
134    fn pool_globals_calls(&self, pool: Address) -> [IMulticall3::Call3; 4];
135    fn tick_calls(
136        &self,
137        pool: Address,
138        tick_lower: I24,
139        tick_upper: I24,
140    ) -> [IMulticall3::Call3; 2];
141    fn decode_pool_globals(&self, chunk: &[IMulticall3::Result]) -> Option<PoolGlobals>;
142    /// (lower0, lower1, upper0, upper1) fee-growth-outside for the two ticks.
143    fn decode_tick_outside(
144        &self,
145        chunk: &[IMulticall3::Result],
146    ) -> Option<(U256, U256, U256, U256)>;
147}
148
149/// V3-family pool-state ABI adapter for the shared position-fee engine.
150pub struct V3PoolFeeGrowthSource;
151
152impl PoolFeeGrowthSource for V3PoolFeeGrowthSource {
153    fn pool_globals_calls(&self, pool: Address) -> [IMulticall3::Call3; 4] {
154        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
155
156        let mk = |data: Vec<u8>| IMulticall3::Call3 {
157            target: pool,
158            allowFailure: true,
159            callData: data.into(),
160        };
161        [
162            mk(IUniswapV3PoolState::slot0Call {}.abi_encode()),
163            mk(IUniswapV3PoolState::liquidityCall {}.abi_encode()),
164            mk(IUniswapV3PoolState::feeGrowthGlobal0X128Call {}.abi_encode()),
165            mk(IUniswapV3PoolState::feeGrowthGlobal1X128Call {}.abi_encode()),
166        ]
167    }
168
169    fn tick_calls(
170        &self,
171        pool: Address,
172        tick_lower: I24,
173        tick_upper: I24,
174    ) -> [IMulticall3::Call3; 2] {
175        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
176
177        let mk = |data: Vec<u8>| IMulticall3::Call3 {
178            target: pool,
179            allowFailure: true,
180            callData: data.into(),
181        };
182        [
183            mk(IUniswapV3PoolState::ticksCall { tick: tick_lower }.abi_encode()),
184            mk(IUniswapV3PoolState::ticksCall { tick: tick_upper }.abi_encode()),
185        ]
186    }
187
188    fn decode_pool_globals(&self, chunk: &[IMulticall3::Result]) -> Option<PoolGlobals> {
189        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
190
191        if chunk.iter().any(|r| !r.success) {
192            return None;
193        }
194        let slot0 =
195            IUniswapV3PoolState::slot0Call::abi_decode_returns(&chunk[0].returnData).ok()?;
196        let _liquidity =
197            IUniswapV3PoolState::liquidityCall::abi_decode_returns(&chunk[1].returnData).ok()?;
198        let fg_global_0 =
199            IUniswapV3PoolState::feeGrowthGlobal0X128Call::abi_decode_returns(&chunk[2].returnData)
200                .ok()?;
201        let fg_global_1 =
202            IUniswapV3PoolState::feeGrowthGlobal1X128Call::abi_decode_returns(&chunk[3].returnData)
203                .ok()?;
204        Some(PoolGlobals {
205            current_tick_i24: slot0.tick,
206            sqrt_price_x96: U256::from(slot0.sqrtPriceX96),
207            fg_global_0,
208            fg_global_1,
209        })
210    }
211
212    fn decode_tick_outside(
213        &self,
214        chunk: &[IMulticall3::Result],
215    ) -> Option<(U256, U256, U256, U256)> {
216        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
217
218        if chunk.iter().any(|r| !r.success) {
219            return None;
220        }
221        let tick_lower_info =
222            IUniswapV3PoolState::ticksCall::abi_decode_returns(&chunk[0].returnData).ok()?;
223        let tick_upper_info =
224            IUniswapV3PoolState::ticksCall::abi_decode_returns(&chunk[1].returnData).ok()?;
225        Some((
226            tick_lower_info.feeGrowthOutside0X128,
227            tick_lower_info.feeGrowthOutside1X128,
228            tick_upper_info.feeGrowthOutside0X128,
229            tick_upper_info.feeGrowthOutside1X128,
230        ))
231    }
232}
233
234/// Output of `build_dedup_call_plan`. Carries enough metadata to
235/// route decoded results back to source positions.
236#[derive(Debug)]
237struct DedupCallPlan {
238    /// Multicall3 call vector. Layout: `4*unique_pools.len()` globals
239    /// (slot0, liquidity, feeGrowthGlobal0X128, feeGrowthGlobal1X128
240    /// per pool), followed by `2*targets.len()` per-position tick
241    /// calls (`ticks(lower)`, `ticks(upper)`).
242    pub calls: Vec<IMulticall3::Call3>,
243    /// Pool addresses in first-appearance order. `unique_pools[k]`'s
244    /// 4 globals occupy `calls[4*k .. 4*(k+1)]`.
245    pub unique_pools: Vec<Address>,
246    /// For each input target (in order), the index into `unique_pools`
247    /// telling the decoder which pool's globals to read for that
248    /// position's fee math.
249    pub position_to_pool_idx: Vec<usize>,
250}
251
252/// Build the dedup'd Phase-2 call plan from `(target_idx, pool,
253/// tick_pair)` triples. K unique pools × 4 globals + N positions ×
254/// 2 ticks. Pure function — no provider dependency, fully testable.
255fn build_dedup_call_plan<S: PoolFeeGrowthSource>(
256    targets: &[(usize, Address, (i32, i32))],
257    source: &S,
258) -> DedupCallPlan {
259    use std::collections::HashMap;
260
261    // First pass: collect unique pools in first-appearance order.
262    // BTreeMap-ordered would be deterministic but breaks the
263    // "first-appearance" property; HashMap + sidecar Vec preserves it.
264    let mut unique_pools: Vec<Address> = Vec::new();
265    let mut pool_to_idx: HashMap<Address, usize> = HashMap::new();
266    for (_, pool, _) in targets {
267        if !pool_to_idx.contains_key(pool) {
268            pool_to_idx.insert(*pool, unique_pools.len());
269            unique_pools.push(*pool);
270        }
271    }
272
273    // Per-position pool index, for the decoder.
274    let position_to_pool_idx: Vec<usize> =
275        targets.iter().map(|(_, pool, _)| pool_to_idx[pool]).collect();
276
277    // Build calls: 4*K globals + 2*N tick calls.
278    let mut calls: Vec<IMulticall3::Call3> =
279        Vec::with_capacity(4 * unique_pools.len() + 2 * targets.len());
280
281    for pool in &unique_pools {
282        calls.extend(source.pool_globals_calls(*pool));
283    }
284    for (_, pool, (tick_lower, tick_upper)) in targets {
285        let tl = i32_to_i24_truncating(*tick_lower);
286        let tu = i32_to_i24_truncating(*tick_upper);
287        calls.extend(source.tick_calls(*pool, tl, tu));
288    }
289
290    DedupCallPlan { calls, unique_pools, position_to_pool_idx }
291}
292
293/// Truncating `i32 → I24` matching Solidity int24 cast semantics.
294///
295/// **Infallible by construction.** Takes the low 3 bytes of the i32's
296/// big-endian representation; alloy's `I24::from_be_bytes::<3>` then
297/// sign-extends within int24 range.
298///
299/// For any `PositionView` produced via `PositionView::from_nfpm_returns`
300/// (which sign-extends from on-chain `int24` via
301/// `wp_evm_base::evm::sign_extend_i24`), this is a lossless round-trip
302/// — the `i32` is always in `[-2^23, 2^23)`. For pathological inputs
303/// outside that range (e.g. a future test or refactor that constructs
304/// `PositionView` directly with a wonky tick), the truncation matches
305/// what Solidity does on `int24(largeInt)` cast. Worst case: that one
306/// entry's `fees` reflect a different tick than intended (silently
307/// wrong if the truncated tick is initialized in the pool, `None` if
308/// not). Bad data stays contained to that one entry — **per-entry
309/// resilience preserved** without aborting the whole batch.
310///
311/// Mirrors the byte-truncation pattern used by Slice B's
312/// `i24_to_be_bytes` in `wp-evm-v3-core::position`.
313fn i32_to_i24_truncating(tick: i32) -> I24 {
314    let bytes = tick.to_be_bytes();
315    I24::from_be_bytes::<3>([bytes[1], bytes[2], bytes[3]])
316}
317
318/// Scalar inputs required by the shared fee-growth engine.
319#[derive(Debug, Clone, Copy)]
320pub struct FeeMathInputs {
321    pub tick_lower: i32,
322    pub tick_upper: i32,
323    pub liquidity: u128,
324    pub fee_growth_inside_0_last_x128: U256,
325    pub fee_growth_inside_1_last_x128: U256,
326    pub tokens_owed_0_stale: u128,
327    pub tokens_owed_1_stale: u128,
328}
329
330/// Phase 2: for every entry whose Phase-1 fetch succeeded, derive its
331/// pool address (R7), build a dedup'd pool-call plan, dispatch one
332/// Multicall3 RPC for all of them, decode + run fee-growth math,
333/// attach `fees` to each entry.
334///
335/// Pool-global failures (`slot0`, `liquidity`, fee-growth globals)
336/// leave fees unavailable for every position in that pool. Per-position
337/// tick failures leave only that entry's `fees = None`. Whole-batch RPC
338/// errors propagate up.
339pub async fn populate_position_fees_with<P, V, S>(
340    provider: &P,
341    multicall: Address,
342    entries: &mut [PositionViewEntry<V>],
343    source: &S,
344    derive_pool: impl Fn(&V) -> Option<Address>,
345    extract: impl Fn(&V) -> FeeMathInputs,
346) -> Result<()>
347where
348    P: Provider<Ethereum>,
349    V: Clone,
350    S: PoolFeeGrowthSource,
351{
352    use wp_evm_amm_math::fee_growth::get_fee_growth_inside;
353    use wp_evm_base::evm::sign_extend_i24;
354
355    // Collect (entry_idx, pool, view inputs) triples for every entry whose
356    // Phase-1 fetch succeeded. Skip entries with unsupported (protocol,
357    // chain) — caller (run) validates, but per-entry resilience is
358    // preserved as a hard invariant.
359    let mut targets: Vec<(usize, Address, V, FeeMathInputs)> = Vec::new();
360    for (idx, entry) in entries.iter().enumerate() {
361        if let Ok((view, _owner)) = &entry.result {
362            let pool = match derive_pool(view) {
363                Some(addr) => addr,
364                None => continue,
365            };
366            targets.push((idx, pool, view.clone(), extract(view)));
367        }
368    }
369
370    if targets.is_empty() {
371        return Ok(());
372    }
373
374    // Build the dedup'd call plan: K unique pools × 4 globals +
375    // N positions × 2 tick calls. Single Multicall3 RPC.
376    let tick_targets: Vec<(usize, Address, (i32, i32))> = targets
377        .iter()
378        .map(|(idx, pool, _view, inputs)| (*idx, *pool, (inputs.tick_lower, inputs.tick_upper)))
379        .collect();
380    let plan = build_dedup_call_plan(&tick_targets, source);
381
382    let raw = aggregate3(provider, multicall, plan.calls).await?;
383    let expected = 4 * plan.unique_pools.len() + 2 * targets.len();
384    anyhow::ensure!(
385        raw.len() == expected,
386        "phase-2 multicall returned {} results for K={} pools + N={} positions; expected {}",
387        raw.len(),
388        plan.unique_pools.len(),
389        targets.len(),
390        expected,
391    );
392
393    // Decode globals once per unique pool. Per-pool failure → None,
394    // distributed to every position in that pool.
395    let globals_section = &raw[..4 * plan.unique_pools.len()];
396    let pool_globals: Vec<Option<PoolGlobals>> = (0..plan.unique_pools.len())
397        .map(|k| source.decode_pool_globals(&globals_section[4 * k..4 * (k + 1)]))
398        .collect();
399
400    // Decode ticks per position. Per-position failure → None for that
401    // entry only.
402    let ticks_offset = 4 * plan.unique_pools.len();
403    for (n, (entry_idx, pool, _view, inputs)) in targets.iter().enumerate() {
404        let pool_idx = plan.position_to_pool_idx[n];
405        let Some(globals) = &pool_globals[pool_idx] else {
406            // Pool globals failed → this entry's fees stay None.
407            continue;
408        };
409        let tick_chunk = &raw[ticks_offset + 2 * n..ticks_offset + 2 * (n + 1)];
410        let Some((lower0, lower1, upper0, upper1)) = source.decode_tick_outside(tick_chunk) else {
411            continue;
412        };
413
414        let current_tick = sign_extend_i24(globals.current_tick_i24);
415        let in_range = inputs.tick_lower <= current_tick && current_tick < inputs.tick_upper;
416
417        let (fg_inside_0, fg_inside_1) = get_fee_growth_inside(
418            inputs.tick_lower,
419            inputs.tick_upper,
420            current_tick,
421            globals.fg_global_0,
422            globals.fg_global_1,
423            lower0,
424            lower1,
425            upper0,
426            upper1,
427        );
428
429        let (current_owed_0, current_owed_1) =
430            current_tokens_owed(*inputs, fg_inside_0, fg_inside_1);
431
432        entries[*entry_idx].fees = Some(PositionFees {
433            pool: *pool,
434            current_tick,
435            sqrt_price_x96: globals.sqrt_price_x96,
436            in_range,
437            current_owed_0,
438            current_owed_1,
439        });
440    }
441
442    Ok(())
443}
444
445pub async fn populate_position_fees<P: Provider<Ethereum>>(
446    provider: &P,
447    multicall: Address,
448    entries: &mut [PositionViewEntry<PositionView>],
449    derive_pool: impl Fn(&PositionView) -> Option<Address>,
450) -> Result<()> {
451    populate_position_fees_with(
452        provider,
453        multicall,
454        entries,
455        &V3PoolFeeGrowthSource,
456        derive_pool,
457        |v| FeeMathInputs {
458            tick_lower: v.tick_lower,
459            tick_upper: v.tick_upper,
460            liquidity: v.liquidity,
461            fee_growth_inside_0_last_x128: v.fee_growth_inside_0_last_x128,
462            fee_growth_inside_1_last_x128: v.fee_growth_inside_1_last_x128,
463            tokens_owed_0_stale: v.tokens_owed_0_stale,
464            tokens_owed_1_stale: v.tokens_owed_1_stale,
465        },
466    )
467    .await
468}
469
470fn current_tokens_owed(
471    inputs: FeeMathInputs,
472    fee_growth_inside_0x128: U256,
473    fee_growth_inside_1x128: U256,
474) -> (U256, U256) {
475    let (accrued_0, accrued_1) = wp_evm_amm_math::fee_growth::get_tokens_owed(
476        inputs.fee_growth_inside_0_last_x128,
477        inputs.fee_growth_inside_1_last_x128,
478        inputs.liquidity,
479        fee_growth_inside_0x128,
480        fee_growth_inside_1x128,
481    );
482    (
483        U256::from(inputs.tokens_owed_0_stale) + accrued_0,
484        U256::from(inputs.tokens_owed_1_stale) + accrued_1,
485    )
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use alloy_primitives::address;
492
493    #[test]
494    fn current_tokens_owed_includes_stale_tokens_owed_snapshot() {
495        use alloy_primitives::aliases::{I24, U24, U96};
496
497        let positions_ret = INonfungiblePositionManagerView::positionsReturn {
498            nonce: U96::ZERO,
499            operator: Address::ZERO,
500            token0: Address::ZERO,
501            token1: Address::ZERO,
502            fee: U24::from(500u32),
503            tickLower: I24::try_from(0i32).unwrap(),
504            tickUpper: I24::try_from(60i32).unwrap(),
505            liquidity: 1u128,
506            feeGrowthInside0LastX128: U256::ZERO,
507            feeGrowthInside1LastX128: U256::ZERO,
508            tokensOwed0: 7u128,
509            tokensOwed1: 11u128,
510        };
511        let view = PositionView::from_nfpm_returns(U256::from(1u64), &positions_ret);
512        let q128 = U256::from(1u64) << 128usize;
513
514        let inputs = FeeMathInputs {
515            tick_lower: view.tick_lower,
516            tick_upper: view.tick_upper,
517            liquidity: view.liquidity,
518            fee_growth_inside_0_last_x128: view.fee_growth_inside_0_last_x128,
519            fee_growth_inside_1_last_x128: view.fee_growth_inside_1_last_x128,
520            tokens_owed_0_stale: view.tokens_owed_0_stale,
521            tokens_owed_1_stale: view.tokens_owed_1_stale,
522        };
523        let (owed_0, owed_1) = current_tokens_owed(inputs, q128, q128);
524
525        assert_eq!(owed_0, U256::from(8u64));
526        assert_eq!(owed_1, U256::from(12u64));
527    }
528
529    /// Locks the post-dedup call layout: for `N` positions across `K`
530    /// unique pools, Phase 2 issues exactly `4*K + 2*N` calls per multicall.
531    /// The test inspects the call vector before dispatch so it doesn't need a real RPC.
532
533    #[test]
534    fn dedup_call_plan_emits_4k_plus_2n_calls() {
535        let pool_a = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
536        let pool_b = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
537
538        // 3 positions: 2 in pool_a, 1 in pool_b → K=2, N=3 → 4*2 + 2*3 = 14 calls
539        let targets: Vec<(usize, Address, (i32, i32))> =
540            vec![(0, pool_a, (-1000, 1000)), (1, pool_a, (-2000, 2000)), (2, pool_b, (-500, 500))];
541
542        let plan = build_dedup_call_plan(&targets, &V3PoolFeeGrowthSource);
543        assert_eq!(plan.calls.len(), 14, "expected 4*K + 2*N = 4*2 + 2*3");
544        assert_eq!(plan.unique_pools.len(), 2, "K = 2 unique pools");
545        assert_eq!(plan.unique_pools[0], pool_a, "pool_a registered first");
546        assert_eq!(plan.unique_pools[1], pool_b);
547        assert_eq!(plan.position_to_pool_idx, vec![0, 0, 1]);
548    }
549
550    /// Single-pool wallet: 4 globals + 2*N tick calls. Best case for dedup.
551
552    #[test]
553    fn dedup_call_plan_single_pool_collapses_globals() {
554        let pool = address!("cccccccccccccccccccccccccccccccccccccccc");
555        let targets: Vec<(usize, Address, (i32, i32))> =
556            (0..14).map(|i| (i, pool, (-1000 + i as i32 * 10, 1000 + i as i32 * 10))).collect();
557
558        let plan = build_dedup_call_plan(&targets, &V3PoolFeeGrowthSource);
559        // K = 1, N = 14 → 4 + 28 = 32 (was 84 pre-dedup; 62% savings).
560        assert_eq!(plan.calls.len(), 32);
561        assert_eq!(plan.unique_pools.len(), 1);
562        assert_eq!(plan.position_to_pool_idx, vec![0; 14]);
563    }
564
565    /// All-distinct case: K = N. Call count is identical to pre-dedup
566    /// (no degradation).
567
568    #[test]
569    fn dedup_call_plan_all_distinct_pools_no_degradation() {
570        // 5 distinct pools, one position each. K = N = 5 → 4*5 + 2*5 = 30
571        // (was 6*5 = 30 pre-dedup; identical).
572        let targets: Vec<(usize, Address, (i32, i32))> = (0..5)
573            .map(|i| {
574                let mut bytes = [0u8; 20];
575                bytes[19] = i as u8 + 1;
576                (i, Address::from(bytes), (-100 * (i as i32 + 1), 100 * (i as i32 + 1)))
577            })
578            .collect();
579
580        let plan = build_dedup_call_plan(&targets, &V3PoolFeeGrowthSource);
581        assert_eq!(plan.calls.len(), 30);
582        assert_eq!(plan.unique_pools.len(), 5);
583    }
584
585    #[test]
586    fn decode_pool_globals_and_tick_chunk_decode_results_and_reverts_return_none() {
587        use alloy_primitives::aliases::{I24, I56, U160};
588        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
589
590        let ok = |return_data: Vec<u8>| IMulticall3::Result {
591            success: true,
592            returnData: return_data.into(),
593        };
594
595        let slot0 = IUniswapV3PoolState::slot0Return {
596            sqrtPriceX96: U160::from(123u64),
597            tick: I24::try_from(-42i32).unwrap(),
598            observationIndex: 1,
599            observationCardinality: 2,
600            observationCardinalityNext: 3,
601            feeProtocol: 0,
602            unlocked: true,
603        };
604        let tick_lower = IUniswapV3PoolState::ticksReturn {
605            liquidityGross: 10u128,
606            liquidityNet: -5i128,
607            feeGrowthOutside0X128: U256::from(100u64),
608            feeGrowthOutside1X128: U256::from(200u64),
609            tickCumulativeOutside: I56::try_from(-9i64).unwrap(),
610            secondsPerLiquidityOutsideX128: U160::from(300u64),
611            secondsOutside: 400,
612            initialized: true,
613        };
614        let tick_upper = IUniswapV3PoolState::ticksReturn {
615            liquidityGross: 20u128,
616            liquidityNet: 5i128,
617            feeGrowthOutside0X128: U256::from(101u64),
618            feeGrowthOutside1X128: U256::from(201u64),
619            tickCumulativeOutside: I56::try_from(9i64).unwrap(),
620            secondsPerLiquidityOutsideX128: U160::from(301u64),
621            secondsOutside: 401,
622            initialized: true,
623        };
624
625        let chunk = [
626            ok(IUniswapV3PoolState::slot0Call::abi_encode_returns(&slot0)),
627            ok(IUniswapV3PoolState::liquidityCall::abi_encode_returns(&123_456u128)),
628            ok(IUniswapV3PoolState::feeGrowthGlobal0X128Call::abi_encode_returns(&U256::from(
629                1_000u64,
630            ))),
631            ok(IUniswapV3PoolState::feeGrowthGlobal1X128Call::abi_encode_returns(&U256::from(
632                2_000u64,
633            ))),
634            ok(IUniswapV3PoolState::ticksCall::abi_encode_returns(&tick_lower)),
635            ok(IUniswapV3PoolState::ticksCall::abi_encode_returns(&tick_upper)),
636        ];
637
638        let source = V3PoolFeeGrowthSource;
639        let globals = source.decode_pool_globals(&chunk[..4]).expect("globals decode");
640        let (lower0, lower1, upper0, upper1) =
641            source.decode_tick_outside(&chunk[4..]).expect("ticks decode");
642        assert_eq!(globals.current_tick_i24, slot0.tick);
643        assert_eq!(globals.sqrt_price_x96, U256::from(slot0.sqrtPriceX96));
644        assert_eq!(globals.fg_global_0, U256::from(1_000u64));
645        assert_eq!(globals.fg_global_1, U256::from(2_000u64));
646        assert_eq!(lower0, U256::from(100u64));
647        assert_eq!(lower1, U256::from(200u64));
648        assert_eq!(upper0, U256::from(101u64));
649        assert_eq!(upper1, U256::from(201u64));
650
651        let mut reverted_globals = chunk[..4].to_vec();
652        reverted_globals[3].success = false;
653        assert!(source.decode_pool_globals(&reverted_globals).is_none());
654
655        let mut reverted_ticks = chunk[4..].to_vec();
656        reverted_ticks[1].success = false;
657        assert!(source.decode_tick_outside(&reverted_ticks).is_none());
658    }
659
660    /// Locks `i32_to_i24_truncating` as a lossless round-trip for any
661    /// `i32` already in int24 range — i.e. for every value
662    /// `PositionView::from_nfpm_returns` can produce. If a future
663    /// refactor breaks this invariant, the test trips before silently
664    /// corrupting Phase-2 pool calls.
665
666    #[test]
667    fn i32_to_i24_truncating_roundtrips_via_sign_extend() {
668        use wp_evm_base::evm::sign_extend_i24;
669
670        for tick in [-887_220i32, -8_388_608, -60, -1, 0, 1, 60, 887_220, 8_388_607] {
671            let i24 = i32_to_i24_truncating(tick);
672            assert_eq!(sign_extend_i24(i24), tick, "round-trip failed at tick={tick}");
673        }
674    }
675
676    /// End-to-end fee-growth math lock against a frozen on-chain
677    /// snapshot of Uniswap V3 Arbitrum mainnet position 5435993
678    /// (PENDLE/WETH 0.30% pool, in-range, idle-since-mint).
679    ///
680    /// Inputs (read via `cast` on 2026-04-26):
681    /// - NFPM positions(5435993) — see `view` below
682    /// - Pool 0xdbaeb7f0...7852c slot0/globals/ticks(-76200)/ticks(-72780)
683    ///
684    /// Expected output (independently produced by the live CLI run):
685    /// - current_owed_0 = 1_754_557_829_256_627_897 (≈ 1.7546 PENDLE)
686    /// - current_owed_1 =       953_585_198_240_599 (≈ 0.000954 WETH)
687    /// - in_range       = true
688    /// - current_tick   = -74_964
689    ///
690    /// What this locks: the full Phase-2 composition —
691    /// `decode_pool_globals` + `decode_tick_chunk` → `get_fee_growth_inside` (with Slice C's
692    /// reordered params) → `get_tokens_owed` → `current_tokens_owed`
693    /// (stale + delta) — against a real-world snapshot. Any future
694    /// refactor that changes formula, param order, signed-extension
695    /// semantics, or wrapping arithmetic will trip this.
696    ///
697    /// **NOT** a parity test against an independent oracle (e.g.
698    /// simulated `collect()`); it locks the implementation against
699    /// itself given a real-world input. For a third-party-truth
700    /// parity check, fork integration tests against an `eth_call`
701    /// `collect` simulation are the natural follow-up.
702
703    #[test]
704    fn arbitrum_position_5435993_math_snapshot_lock() {
705        use alloy_primitives::aliases::{I24, I56, U160, U24, U96};
706        use wp_evm_amm_math::fee_growth::get_fee_growth_inside;
707        use wp_evm_base::evm::sign_extend_i24;
708        use wp_evm_v3_interfaces::pool::state::IUniswapV3PoolState;
709
710        // -------- Position state (from NFPM positions(5435993) on Arbitrum) --------
711        let positions_ret = INonfungiblePositionManagerView::positionsReturn {
712            nonce: U96::ZERO,
713            operator: Address::ZERO,
714            token0: address!("0c880f6761F1af8d9Aa9C466984b80DAb9a8c9e8"), // PENDLE
715            token1: address!("82aF49447D8a07e3bd95BD0d56f35241523fBab1"), // WETH
716            fee: U24::from(3_000u32),
717            tickLower: I24::try_from(-76_200i32).unwrap(),
718            tickUpper: I24::try_from(-72_780i32).unwrap(),
719            liquidity: 137_776_574_478_361_638_142u128,
720            feeGrowthInside0LastX128: U256::from_str_radix(
721                "115792089237316195423570985008687907851891836270363301344313529832249190462215",
722                10,
723            )
724            .unwrap(),
725            feeGrowthInside1LastX128: U256::from_str_radix(
726                "115792089237316195423570985008687907853268070783021718997865526573101277782114",
727                10,
728            )
729            .unwrap(),
730            tokensOwed0: 0u128,
731            tokensOwed1: 0u128,
732        };
733        let view = PositionView::from_nfpm_returns(U256::from(5_435_993u64), &positions_ret);
734
735        // -------- Pool state (from pool 0xdbaeb7f0...7852c on Arbitrum) --------
736        let slot0 = IUniswapV3PoolState::slot0Return {
737            sqrtPriceX96: U160::from_str_radix("1866984747909073049766330368", 10).unwrap(),
738            tick: I24::try_from(-74_964i32).unwrap(),
739            observationIndex: 1566,
740            observationCardinality: 1800,
741            observationCardinalityNext: 1800,
742            feeProtocol: 102,
743            unlocked: true,
744        };
745        let liquidity_pool = 0u128; // pool's current in-range liquidity — read but unused by fee math
746        let fg_global_0 =
747            U256::from_str_radix("3257941510536840956130253336502569755530", 10).unwrap();
748        let fg_global_1 =
749            U256::from_str_radix("90146056400431291924115652136134661340", 10).unwrap();
750        let tick_lower_info = IUniswapV3PoolState::ticksReturn {
751            liquidityGross: 1_025_821_222_695_237_314_208u128,
752            liquidityNet: 1_025_821_222_695_237_314_208i128,
753            feeGrowthOutside0X128: U256::from_str_radix(
754                "3011970555915117741071824734093997682470",
755                10,
756            )
757            .unwrap(),
758            feeGrowthOutside1X128: U256::from_str_radix(
759                "89996042868724336997184963186756559285",
760                10,
761            )
762            .unwrap(),
763            tickCumulativeOutside: I56::try_from(-6_183_495_934_001i64).unwrap(),
764            secondsPerLiquidityOutsideX128: U160::from_str_radix(
765                "1055476276114891623306785093325462239337919411",
766                10,
767            )
768            .unwrap(),
769            secondsOutside: 1_768_271_064,
770            initialized: true,
771        };
772        let tick_upper_info = IUniswapV3PoolState::ticksReturn {
773            liquidityGross: 527_757_663_279_894_400_755u128,
774            liquidityNet: -178_735_004_268_239_931_963i128,
775            feeGrowthOutside0X128: U256::from_str_radix(
776                "1619785920262637406907822566892852684677",
777                10,
778            )
779            .unwrap(),
780            feeGrowthOutside1X128: U256::from_str_radix(
781                "2061540973486906648897049606535835620",
782                10,
783            )
784            .unwrap(),
785            tickCumulativeOutside: I56::try_from(-3_908_878_221_061i64).unwrap(),
786            secondsPerLiquidityOutsideX128: U160::from_str_radix("130589885041679980488482", 10)
787                .unwrap(),
788            secondsOutside: 58_739_145,
789            initialized: true,
790        };
791
792        // -------- Round-trip the pool state through the Phase-2 decoders --------
793        // Locks the multicall→struct path alongside the math.
794        let ok = |data: Vec<u8>| IMulticall3::Result { success: true, returnData: data.into() };
795        let chunk = [
796            ok(IUniswapV3PoolState::slot0Call::abi_encode_returns(&slot0)),
797            ok(IUniswapV3PoolState::liquidityCall::abi_encode_returns(&liquidity_pool)),
798            ok(IUniswapV3PoolState::feeGrowthGlobal0X128Call::abi_encode_returns(&fg_global_0)),
799            ok(IUniswapV3PoolState::feeGrowthGlobal1X128Call::abi_encode_returns(&fg_global_1)),
800            ok(IUniswapV3PoolState::ticksCall::abi_encode_returns(&tick_lower_info)),
801            ok(IUniswapV3PoolState::ticksCall::abi_encode_returns(&tick_upper_info)),
802        ];
803        let source = V3PoolFeeGrowthSource;
804        let globals = source
805            .decode_pool_globals(&chunk[..4])
806            .expect("frozen Arbitrum pool globals must decode cleanly");
807        let (lower0, lower1, upper0, upper1) = source
808            .decode_tick_outside(&chunk[4..])
809            .expect("frozen Arbitrum ticks must decode cleanly");
810
811        // -------- Replicate `augment_with_fees`'s per-position math --------
812        let current_tick = sign_extend_i24(globals.current_tick_i24);
813        assert_eq!(current_tick, -74_964, "current_tick decoded from frozen slot0");
814
815        let in_range = view.tick_lower <= current_tick && current_tick < view.tick_upper;
816        assert!(in_range, "position 5435993 was in-range at the snapshot block");
817
818        let (fg_inside_0, fg_inside_1) = get_fee_growth_inside(
819            view.tick_lower,
820            view.tick_upper,
821            current_tick,
822            globals.fg_global_0,
823            globals.fg_global_1,
824            lower0,
825            lower1,
826            upper0,
827            upper1,
828        );
829
830        // Use current_tokens_owed (production helper) — locks the
831        // (stale + delta) composition through the same code path
832        // augment_with_fees uses, not a manual replication.
833        let inputs = FeeMathInputs {
834            tick_lower: view.tick_lower,
835            tick_upper: view.tick_upper,
836            liquidity: view.liquidity,
837            fee_growth_inside_0_last_x128: view.fee_growth_inside_0_last_x128,
838            fee_growth_inside_1_last_x128: view.fee_growth_inside_1_last_x128,
839            tokens_owed_0_stale: view.tokens_owed_0_stale,
840            tokens_owed_1_stale: view.tokens_owed_1_stale,
841        };
842        let (current_owed_0, current_owed_1) =
843            current_tokens_owed(inputs, fg_inside_0, fg_inside_1);
844
845        // -------- Assert against the live-CLI-produced reference --------
846        assert_eq!(
847            current_owed_0,
848            U256::from(1_754_557_829_256_627_897u128),
849            "current_owed_0 (PENDLE wei) drifted from frozen Arbitrum reference",
850        );
851        assert_eq!(
852            current_owed_1,
853            U256::from(953_585_198_240_599u128),
854            "current_owed_1 (WETH wei) drifted from frozen Arbitrum reference",
855        );
856    }
857}