Skip to main content

wp_evm_slipstream/
lib.rs

1use alloy_primitives::{address, b256, Address, B256, U256};
2use alloy_provider::{network::Ethereum, Provider};
3use alloy_sol_types::SolCall;
4use anyhow::{anyhow, Result};
5use wp_evm_base::{chain::Chain, types::SlippageBps};
6use wp_evm_ramses_provider as ramses;
7use wp_evm_ramses_provider::data::RamsesProtocolConfig;
8use wp_evm_v3_provider::plan::PeripherySelectors;
9use wp_evm_velodrome_interfaces::periphery::router::ISlipstreamPeripheryRouter;
10
11/// Selectors for `ISlipstreamPeripheryRouter` (Uniswap V3-flavour names —
12/// `unwrapWETH9` / `refundETH`). Aerodrome reuses this same interface, but
13/// each facade keeps its own const for grep-ability.
14const SELECTORS: PeripherySelectors = PeripherySelectors {
15    multicall: ISlipstreamPeripheryRouter::multicallCall::SELECTOR,
16    unwrap_native: ISlipstreamPeripheryRouter::unwrapWETH9Call::SELECTOR,
17    sweep_token: ISlipstreamPeripheryRouter::sweepTokenCall::SELECTOR,
18    refund_native: ISlipstreamPeripheryRouter::refundETHCall::SELECTOR,
19};
20
21pub use wp_evm_ramses_provider::data::{
22    CollectFeesParams, ExactInParams, ExactOutParams, PlanFragment, PoolState, PositionState,
23    Quote, RamsesAddLiquidityParams, RemoveAndCollectParams, RemoveLiquidityParams,
24};
25pub use wp_evm_ramses_provider::position::{
26    position_key, RamsesPositionView, VelodromePositionRow,
27};
28pub use wp_evm_ramses_provider::position_views::{PositionFees, PositionViewEntry};
29pub use wp_evm_ramses_provider::quote::QuoteError;
30pub use wp_evm_ramses_provider::Enumeration;
31// M4-C: the `wpe velodrome position …` write cmds call the raw ramses planner
32// (incl. add_liquidity_slipstream) — re-export it so the CLI drops wp-evm-ramses-core.
33pub use wp_evm_ramses_provider::plan;
34pub use wp_evm_v3_provider::pool_views::{PoolReadEntry, PoolViewData};
35
36/// Batch-read Velodrome / Aerodrome (Slipstream) pool snapshots.
37pub async fn pool_views<P: Provider<Ethereum>>(
38    provider: &P,
39    pools: &[Address],
40) -> Result<Vec<PoolReadEntry>> {
41    wp_evm_v3_provider::pool_views::pool_views(
42        provider,
43        ramses::MULTICALL3_ADDRESS,
44        pools,
45        &ramses::pool_views::VelodromePoolViewSource,
46    )
47    .await
48}
49
50pub async fn position_token_pair<P: Provider<Ethereum>>(
51    provider: &P,
52    nfpm: Address,
53    token_id: U256,
54) -> Result<(Address, Address)> {
55    ramses::hydrate::velodrome_position_token_pair(provider, nfpm, token_id).await
56}
57
58pub const CONFIG: RamsesProtocolConfig = RamsesProtocolConfig {
59    factory: address!("Cc0bDDB707055e04e497aB22a59c2aF4391cd12F"),
60    pool_deployer: Address::ZERO,
61    router: address!("0792a633F0c19c351081CF4B211F68F79bCc9676"),
62    position_mgr: address!("416b433906b1B72FA758e166e239c43d68dC6F29"),
63    init_code_hash: b256!("339492e30b7a68609e535da9b0773082bfe60230ca47639ee5566007d525f5a7"),
64    tick_spacings: &[1, 50, 100, 200, 2000],
65    multicall: address!("cA11bde05977b3631167028862bE2a173976CA11"),
66    quoter: None,
67    voter: address!("41C914ee0c7E1A5edCD0295623e6dC557B5aBf3C"),
68};
69
70pub fn config_for_chain(chain: Chain) -> Option<&'static RamsesProtocolConfig> {
71    match chain {
72        Chain::Optimism => Some(&CONFIG),
73        Chain::Ethereum
74        | Chain::Arbitrum
75        | Chain::Polygon
76        | Chain::Base
77        | Chain::Bsc
78        | Chain::Sonic
79        | Chain::HyperEvm
80        | Chain::Avalanche
81        | Chain::Celo => None,
82    }
83}
84
85pub fn factory(chain: Chain) -> Option<Address> {
86    config_for_chain(chain).map(|c| c.factory)
87}
88pub fn pool_deployer(chain: Chain) -> Option<Address> {
89    config_for_chain(chain).map(|c| c.pool_deployer)
90}
91pub fn position_manager(chain: Chain) -> Option<Address> {
92    config_for_chain(chain).map(|c| c.position_mgr)
93}
94pub fn router(chain: Chain) -> Option<Address> {
95    config_for_chain(chain).map(|c| c.router)
96}
97pub fn quoter(chain: Chain) -> Option<Address> {
98    config_for_chain(chain).and_then(|c| c.quoter)
99}
100pub fn multicall(chain: Chain) -> Option<Address> {
101    config_for_chain(chain).map(|c| c.multicall)
102}
103pub fn init_code_hash(chain: Chain) -> Option<B256> {
104    config_for_chain(chain).map(|c| c.init_code_hash)
105}
106pub fn voter(chain: Chain) -> Option<Address> {
107    config_for_chain(chain).map(|c| c.voter)
108}
109pub fn supports(chain: Chain) -> bool {
110    config_for_chain(chain).is_some()
111}
112
113pub async fn pool_state<P: Provider<Ethereum>>(
114    provider: &P,
115    chain: Chain,
116    pool: Address,
117) -> Result<PoolState> {
118    let cfg =
119        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
120    // Velodrome (Slipstream) pools have a 6-field slot0 (no feeProtocol) — the
121    // V3 7-field `pool_state` reader overruns; use the Velodrome reader.
122    ramses::hydrate::pool_state_velodrome(provider, cfg.multicall, pool).await
123}
124
125pub async fn position_state<P: Provider<Ethereum>>(
126    provider: &P,
127    chain: Chain,
128    token_id: U256,
129) -> Result<PositionState> {
130    let cfg =
131        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
132    ramses::hydrate::position_state_slipstream(provider, cfg.multicall, cfg.position_mgr, token_id)
133        .await
134}
135
136pub async fn position_views<P: Provider<Ethereum>>(
137    provider: &P,
138    chain: Chain,
139    token_ids: &[U256],
140) -> Result<Vec<PositionViewEntry<VelodromePositionRow>>> {
141    let cfg =
142        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
143    ramses::position_views::velodrome_position_views(
144        provider,
145        cfg.multicall,
146        cfg.position_mgr,
147        token_ids,
148    )
149    .await
150}
151
152pub async fn position_views_with_nfpm<P: Provider<Ethereum>>(
153    provider: &P,
154    chain: Chain,
155    nfpm: Address,
156    token_ids: &[U256],
157) -> Result<Vec<PositionViewEntry<VelodromePositionRow>>> {
158    let multicall =
159        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
160    ramses::position_views::velodrome_position_views(provider, multicall, nfpm, token_ids).await
161}
162
163pub async fn enumerate_owner_token_ids<P: Provider<Ethereum>>(
164    provider: &P,
165    chain: Chain,
166    nfpm: Address,
167    owner: Address,
168) -> Result<Enumeration> {
169    let multicall =
170        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
171    ramses::enumerate_owner_token_ids(provider, multicall, nfpm, owner, chain).await
172}
173
174pub async fn populate_positions_fees<P: Provider<Ethereum>>(
175    provider: &P,
176    chain: Chain,
177    entries: &mut [PositionViewEntry<VelodromePositionRow>],
178) -> Result<()> {
179    let cfg =
180        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
181    ramses::position_views::velodrome_populate_position_fees(
182        provider,
183        cfg.multicall,
184        entries,
185        |v| pool_address(chain, v.token0, v.token1, v.tick_spacing, None),
186    )
187    .await
188}
189
190pub fn quote_exact_in(s: &PoolState, p: &ExactInParams) -> Result<Quote, QuoteError> {
191    ramses::quote::exact_in(s, p)
192}
193pub fn quote_exact_out(s: &PoolState, p: &ExactOutParams) -> Result<Quote, QuoteError> {
194    ramses::quote::exact_out(s, p)
195}
196
197pub async fn populate_ticks<P: Provider<Ethereum>>(
198    provider: &P,
199    chain: Chain,
200    pool: Address,
201    state: &mut PoolState,
202) -> Result<()> {
203    config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
204    ramses::populate_ticks::populate_ticks(provider, pool, state).await
205}
206
207pub async fn quote_online_exact_in<P: Provider<Ethereum>>(
208    provider: &P,
209    chain: Chain,
210    state: &PoolState,
211    params: &ExactInParams,
212) -> Result<Quote> {
213    let cfg =
214        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
215    let quoter = cfg
216        .quoter
217        .ok_or_else(|| anyhow!("Velodrome Slipstream quoter not registered on {chain:?}"))?;
218    ramses::quote_online::quote_online_exact_in(provider, quoter, state, params).await
219}
220
221pub fn plan_swap_exact_in(
222    s: &PoolState,
223    q: &Quote,
224    p: &ExactInParams,
225    slippage: SlippageBps,
226    deadline: u64,
227    chain: Chain,
228) -> Result<PlanFragment> {
229    let cfg =
230        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
231    Ok(ramses::plan::swap_exact_in(s, q, p, slippage, deadline, cfg.router))
232}
233
234pub fn plan_add_liquidity(
235    p: &RamsesAddLiquidityParams,
236    slippage: SlippageBps,
237    deadline: u64,
238    chain: Chain,
239) -> Result<PlanFragment> {
240    let cfg =
241        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
242    Ok(ramses::plan::add_liquidity_slipstream(p, slippage, deadline, cfg.position_mgr))
243}
244
245/// Build a Slipstream NFPM `mint` plan fragment with precomputed
246/// `amount0_min` / `amount1_min` (e.g. derived from a price-aware sqrt-ratio
247/// quote). Mirrors `plan_add_liquidity` but takes the mins directly instead of
248/// a flat slippage haircut.
249pub fn plan_add_liquidity_with_min(
250    p: &RamsesAddLiquidityParams,
251    amount0_min: U256,
252    amount1_min: U256,
253    deadline: u64,
254    chain: Chain,
255) -> Result<PlanFragment> {
256    let cfg =
257        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
258    Ok(ramses::plan::add_liquidity_slipstream_with_min(
259        p,
260        amount0_min,
261        amount1_min,
262        deadline,
263        cfg.position_mgr,
264    ))
265}
266
267#[allow(clippy::too_many_arguments)]
268pub fn plan_increase_liquidity(
269    token_id: U256,
270    token0: Address,
271    token1: Address,
272    amount0_desired: U256,
273    amount1_desired: U256,
274    slippage: SlippageBps,
275    deadline: u64,
276    chain: Chain,
277) -> Result<PlanFragment> {
278    let cfg =
279        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
280    Ok(ramses::plan::increase_liquidity(
281        token_id,
282        token0,
283        token1,
284        amount0_desired,
285        amount1_desired,
286        slippage,
287        deadline,
288        cfg.position_mgr,
289    ))
290}
291
292pub fn plan_remove_liquidity(
293    p: &RemoveLiquidityParams,
294    deadline: u64,
295    chain: Chain,
296) -> Result<PlanFragment> {
297    let cfg =
298        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
299    Ok(ramses::plan::remove_liquidity(p, deadline, cfg.position_mgr))
300}
301
302/// Build an atomic `multicall(decreaseLiquidity, collect, ..unwrap_tail)`
303/// plan fragment for a Slipstream NFPM position.
304///
305/// **Native ETH unwrap:** when `recipient == Address::ZERO`, composes
306/// `multicall(decrease, collect, unwrapWETH9, sweepToken)` against the
307/// NFPM rather than the bare `multicall([decrease, collect])` of the
308/// non-native happy path. Mirrors the existing `plan_collect_fees` ETH
309/// unwrap (#232) and the V3 facade's `plan_remove_liquidity_and_collect`
310/// (Slice 3.6). Slipstream's NFPM inherits Uniswap V3's `Multicall +
311/// PeripheryPayments` byte-for-byte (verified against deployed Optimism
312/// NFPM `0x416b...6F29` in #232), so the V3 composition is byte-for-byte
313/// portable.
314///
315/// For non-native recipients this is a thin pass-through —
316/// `resolve_native_wrap_remove_and_collect` returns an empty `post_calls`
317/// vec and the bare `multicall([decrease, collect])` is emitted unwrapped.
318pub fn plan_remove_liquidity_and_collect(
319    p: &RemoveAndCollectParams,
320    deadline: u64,
321    chain: Chain,
322) -> Result<PlanFragment> {
323    let cfg =
324        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
325
326    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_remove_and_collect(
327        p,
328        cfg.position_mgr,
329        chain,
330    )?;
331
332    let mut core_params = (*p).clone();
333    core_params.recipient = wrap.effective_collect_recipient;
334
335    let frag = ramses::plan::remove_liquidity_and_collect(&core_params, deadline, cfg.position_mgr);
336    wp_evm_v3_provider::plan::compose_native_remove_collect_multicall(frag, &wrap, SELECTORS)
337}
338
339pub fn plan_collect_fees(p: &CollectFeesParams, chain: Chain) -> Result<PlanFragment> {
340    let cfg =
341        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
342
343    // Resolve `recipient == Address::ZERO` (native unwrap sentinel) by
344    // composing `multicall(collect, unwrapWETH9, sweepToken)` against the
345    // NFPM. Slipstream's NFPM inherits Uniswap V3's `Multicall +
346    // PeripheryPayments` byte-for-byte (verified 2026-05-29 against the
347    // deployed Optimism NFPM at 0x416b...6F29), so the V3 Slice 3.5
348    // composition (PR #210) is byte-for-byte portable.
349    //
350    // For non-native recipients this is a thin pass-through —
351    // `resolve_native_wrap_collect` returns an empty `post_calls` vec
352    // and the bare `collect()` calldata is emitted unwrapped.
353    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_collect(p, cfg.position_mgr, chain)?;
354
355    // Safety-critical for V3 forks: do not pass `recipient = 0` through
356    // to the forked NFPM. Slipstream is a V3 fork (with Velodrome ve(3,3)
357    // additions) and we cannot rely on the upstream V3 `recipient == 0 ?
358    // address(this) : recipient` substitution surviving every fork.
359    // Mirroring V3 Slice 3.5's defensive guard.
360    let core_params = CollectFeesParams {
361        token_id: p.token_id,
362        recipient: wrap.effective_recipient,
363        token0: p.token0,
364        token1: p.token1,
365        caller: p.caller,
366    };
367
368    let frag = ramses::plan::collect_fees(&core_params, cfg.position_mgr);
369    Ok(wp_evm_v3_provider::plan::compose_native_collect_multicall(frag, &wrap, SELECTORS))
370}
371
372pub fn pool_address(
373    chain: Chain,
374    token_a: Address,
375    token_b: Address,
376    tick_spacing: i32,
377    init_code_hash_override: Option<B256>,
378) -> Option<Address> {
379    let cfg = config_for_chain(chain)?;
380    let init_code_hash = init_code_hash_override.unwrap_or(cfg.init_code_hash);
381    Some(ramses::pool_address::compute(cfg.factory, init_code_hash, token_a, token_b, tick_spacing))
382}
383
384pub async fn pending_emissions<P: Provider<Ethereum>>(
385    provider: &P,
386    chain: Chain,
387    pool: Address,
388    account: Address,
389    token_id: U256,
390) -> Result<Option<wp_evm_ramses_provider::velodrome_gauge::VelodromePendingEmissions>> {
391    let cfg =
392        config_for_chain(chain).ok_or_else(|| anyhow!("Velodrome not deployed on {chain:?}"))?;
393    wp_evm_ramses_provider::velodrome_gauge::pending_emissions(
394        provider,
395        cfg.multicall,
396        cfg.voter,
397        pool,
398        account,
399        token_id,
400    )
401    .await
402}
403
404pub fn plan_claim_gauge(gauge: Address, token_id: U256) -> PlanFragment {
405    // Hoisted: the getReward(uint256) encoder now lives in the Ramses-family
406    // core (selector-locked in wp-evm-velodrome-interfaces). Signature is
407    // unchanged for backward compatibility.
408    wp_evm_ramses_provider::plan::claim_gauge(gauge, token_id)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use wp_evm_ramses_provider::data::TickInfo;
415
416    #[test]
417    fn config_router_is_known_slipstream_address() {
418        assert_eq!(CONFIG.router, address!("0792a633F0c19c351081CF4B211F68F79bCc9676"));
419    }
420
421    #[test]
422    fn config_factory_is_known_slipstream_address() {
423        assert_eq!(CONFIG.factory, address!("Cc0bDDB707055e04e497aB22a59c2aF4391cd12F"));
424    }
425
426    #[test]
427    fn config_tick_spacings_match_cl_factory_constructor() {
428        assert_eq!(CONFIG.tick_spacings, &[1, 50, 100, 200, 2000]);
429    }
430
431    #[test]
432    fn config_for_chain_returns_some_for_optimism_only() {
433        assert_eq!(config_for_chain(Chain::Optimism), Some(&CONFIG));
434        for unsupported in [
435            Chain::Ethereum,
436            Chain::Arbitrum,
437            Chain::Polygon,
438            Chain::Base,
439            Chain::Bsc,
440            Chain::Sonic,
441            Chain::Avalanche,
442            Chain::Celo,
443        ] {
444            assert!(
445                config_for_chain(unsupported).is_none(),
446                "slipstream should not surface {unsupported:?}",
447            );
448        }
449    }
450
451    #[test]
452    fn pool_address_matches_canonical_velodrome_usdc_weth_cl100() {
453        let pool = pool_address(
454            Chain::Optimism,
455            address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85"),
456            address!("4200000000000000000000000000000000000006"),
457            100,
458            None,
459        )
460        .expect("Optimism supported");
461        assert_eq!(pool, address!("478946BcD4a5a22b316470F5486fAfb928C0bA25"));
462    }
463
464    #[test]
465    fn pool_address_token_order_independent() {
466        let usdc = address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85");
467        let weth = address!("4200000000000000000000000000000000000006");
468        assert_eq!(
469            pool_address(Chain::Optimism, usdc, weth, 100, None),
470            pool_address(Chain::Optimism, weth, usdc, 100, None)
471        );
472    }
473
474    #[test]
475    fn plan_claim_gauge_targets_gauge_with_get_reward_selector() {
476        let gauge = address!("4444444444444444444444444444444444444444");
477        let token_id = U256::from(42u64);
478        let frag = plan_claim_gauge(gauge, token_id);
479
480        assert_eq!(frag.calls.len(), 1);
481        assert_eq!(frag.calls[0].target, gauge);
482        assert!(frag.approvals.is_empty());
483        assert_eq!(frag.value, U256::ZERO);
484
485        // getReward(uint256) selector = keccak256("getReward(uint256)")[0..4]
486        // = 0x1c4b774b  (verified: alloy sol! macro + eth_hash keccak256)
487        assert_eq!(&frag.calls[0].calldata[..4], &[0x1c, 0x4b, 0x77, 0x4b]);
488    }
489
490    /// Slipstream `plan_add_liquidity` uses `RamsesAddLiquidityParams` with
491    /// `tick_spacing`, not the v3-family `AddLiquidityParams` with `fee`.
492    #[test]
493    fn plan_add_liquidity_accepts_ramses_params_with_tick_spacing() {
494        let p = RamsesAddLiquidityParams {
495            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
496            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
497            tick_spacing: 100,
498            tick_lower: -887_200,
499            tick_upper: 887_200,
500            amount0_desired: U256::from(1_000_000u64),
501            amount1_desired: U256::from(500_000_000_000_000u64),
502            recipient: address!("0000000000000000000000000000000000000099"),
503        };
504        let frag = plan_add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, Chain::Optimism)
505            .expect("Optimism supported");
506        assert_eq!(frag.calls.len(), 1);
507        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
508        assert_eq!(frag.approvals.len(), 2);
509        assert_eq!(frag.approvals[0].token, p.token0);
510        assert_eq!(frag.approvals[1].token, p.token1);
511        assert_eq!(frag.value, U256::ZERO);
512    }
513
514    /// `plan_add_liquidity_with_min` targets the NFPM (`cfg.position_mgr`) and
515    /// threads the exact precomputed `amount0_min`/`amount1_min` into the core
516    /// slipstream mint encoder (byte-identical to the bare core call, and
517    /// distinct from a different-mins call). Mirror of `plan_add_liquidity` test.
518    #[test]
519    fn plan_add_liquidity_with_min_threads_precomputed_mins_at_position_manager() {
520        let p = RamsesAddLiquidityParams {
521            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
522            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
523            tick_spacing: 100,
524            tick_lower: -887_200,
525            tick_upper: 887_200,
526            amount0_desired: U256::from(1_000_000u64),
527            amount1_desired: U256::from(500_000_000_000_000u64),
528            recipient: address!("0000000000000000000000000000000000000099"),
529        };
530        let m0 = U256::from(123_456u64);
531        let m1 = U256::from(789_012u64);
532        let frag = plan_add_liquidity_with_min(&p, m0, m1, 9_999_999_999, Chain::Optimism)
533            .expect("Optimism supported");
534        assert_eq!(frag.calls.len(), 1);
535        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
536        assert_eq!(frag.approvals.len(), 2);
537
538        // Byte-identical to the bare core encoder with the same mins — pins the
539        // facade's min threading against accidental re-derivation.
540        let bare = ramses::plan::add_liquidity_slipstream_with_min(
541            &p,
542            m0,
543            m1,
544            9_999_999_999,
545            CONFIG.position_mgr,
546        );
547        assert_eq!(frag.calls[0].calldata, bare.calls[0].calldata);
548        // Different mins => different calldata (the mins are actually encoded).
549        let other = ramses::plan::add_liquidity_slipstream_with_min(
550            &p,
551            m0 + U256::from(1u64),
552            m1,
553            9_999_999_999,
554            CONFIG.position_mgr,
555        );
556        assert_ne!(frag.calls[0].calldata, other.calls[0].calldata);
557    }
558
559    fn fixture_usdc_weth() -> PoolState {
560        let sqrt_price_x96 = U256::from_str_radix("3543191142285914205922034323214", 10).unwrap();
561        PoolState {
562            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
563            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
564            fee: 3000,
565            tick_spacing: 100,
566            sqrt_price_x96,
567            liquidity: 2_000_000_000_000_000_000_000u128,
568            tick: 76012,
569            ticks: vec![
570                TickInfo {
571                    tick: 74900,
572                    liquidity_net: 1_000_000_000_000_000_000_000i128,
573                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
574                },
575                TickInfo {
576                    tick: 75900,
577                    liquidity_net: 1_000_000_000_000_000_000_000i128,
578                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
579                },
580                TickInfo {
581                    tick: 76100,
582                    liquidity_net: -2_000_000_000_000_000_000_000i128,
583                    liquidity_gross: 2_000_000_000_000_000_000_000u128,
584                },
585            ],
586        }
587    }
588
589    #[test]
590    fn quote_exact_in_delegates_to_ramses_family() {
591        let state = fixture_usdc_weth();
592        let params = ExactInParams {
593            token_in: state.token0,
594            token_out: state.token1,
595            amount_in: U256::from(1_000_000u64),
596            recipient: address!("0000000000000000000000000000000000000099"),
597        };
598        let quote = quote_exact_in(&state, &params).expect("quote should succeed");
599        assert!(quote.amount_out > U256::ZERO);
600        assert_eq!(quote.amount_in, params.amount_in);
601    }
602
603    #[test]
604    fn plan_swap_exact_in_targets_slipstream_router() {
605        let state = fixture_usdc_weth();
606        let quote = Quote {
607            amount_in: U256::from(1_000_000u64),
608            amount_out: U256::from(500_000_000_000_000u64),
609            sqrt_price_x96_after: state.sqrt_price_x96,
610            price_impact_bps: 0,
611        };
612        let params = ExactInParams {
613            token_in: state.token0,
614            token_out: state.token1,
615            amount_in: quote.amount_in,
616            recipient: address!("0000000000000000000000000000000000000099"),
617        };
618        let frag = plan_swap_exact_in(
619            &state,
620            &quote,
621            &params,
622            SlippageBps::new(50),
623            u64::MAX,
624            Chain::Optimism,
625        )
626        .expect("Optimism supported");
627        assert_eq!(frag.calls.len(), 1);
628        assert_eq!(frag.calls[0].target, CONFIG.router);
629        assert_eq!(frag.approvals.len(), 1);
630    }
631
632    #[test]
633    fn quote_exact_out_delegates_to_ramses_family() {
634        let state = fixture_usdc_weth();
635        let params = ExactOutParams {
636            token_in: state.token0,
637            token_out: state.token1,
638            amount_out: U256::from(500_000_000_000_000u64),
639            recipient: address!("0000000000000000000000000000000000000099"),
640        };
641        let quote = quote_exact_out(&state, &params).expect("exact-out quote should succeed");
642        assert!(quote.amount_in > U256::ZERO);
643        assert_eq!(quote.amount_out, params.amount_out);
644    }
645
646    #[test]
647    fn plan_remove_liquidity_targets_position_manager_no_approvals() {
648        let params = RemoveLiquidityParams {
649            token_id: U256::from(42u64),
650            liquidity: 1_000_000_000_000u128,
651            amount0_min: None,
652            amount1_min: None,
653        };
654        let frag =
655            plan_remove_liquidity(&params, u64::MAX, Chain::Optimism).expect("Optimism supported");
656        assert_eq!(frag.calls.len(), 1);
657        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
658        assert!(frag.approvals.is_empty());
659        assert_eq!(frag.value, U256::ZERO);
660    }
661
662    #[test]
663    fn plan_collect_fees_targets_position_manager_no_approvals() {
664        let params = CollectFeesParams {
665            token_id: U256::from(42u64),
666            recipient: address!("0000000000000000000000000000000000000099"),
667            token0: address!("0000000000000000000000000000000000000001"),
668            token1: address!("0000000000000000000000000000000000000002"),
669            caller: Address::ZERO,
670        };
671        let frag = plan_collect_fees(&params, Chain::Optimism).expect("Optimism supported");
672        assert_eq!(frag.calls.len(), 1);
673        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
674        assert!(frag.approvals.is_empty());
675        assert_eq!(frag.value, U256::ZERO);
676    }
677
678    #[test]
679    fn plan_collect_fees_native_recipient_emits_multicall_with_unwrap_and_sweep() {
680        // Slipstream Optimism — native pair is USDC / WETH. ZERO recipient
681        // = "user wants ETH back, not WETH" sentinel. Mirror of V3 Slice 3.5
682        // (PR #210) — see `wp_evm_uniswap_v3::tests::plan_collect_fees_native_recipient_*`.
683        let params = CollectFeesParams {
684            token_id: U256::from(1u64),
685            recipient: Address::ZERO,
686            token0: address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // USDC.e (Optimism)
687            token1: address!("4200000000000000000000000000000000000006"), // WETH (Optimism)
688            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
689        };
690        let frag = plan_collect_fees(&params, Chain::Optimism).expect("Optimism supported");
691
692        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
693        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
694        assert_eq!(frag.value, U256::ZERO);
695        assert_eq!(frag.calls[0].value, U256::ZERO);
696        assert!(
697            frag.calls[0].calldata.windows(4).any(|w| w == [0x49, 0x40, 0x4b, 0x7c]),
698            "native collect multicall must include unwrapWETH9(uint256,address) tail"
699        );
700        assert!(
701            frag.calls[0].calldata.windows(4).any(|w| w == [0xdf, 0x2a, 0xb5, 0xbb]),
702            "native collect multicall must include sweepToken(address,uint256,address) tail"
703        );
704    }
705
706    #[test]
707    fn plan_collect_fees_non_native_recipient_passthrough() {
708        let params = CollectFeesParams {
709            token_id: U256::from(1u64),
710            recipient: address!("0000000000000000000000000000000000000099"),
711            token0: address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85"),
712            token1: address!("4200000000000000000000000000000000000006"),
713            caller: Address::ZERO,
714        };
715        let frag = plan_collect_fees(&params, Chain::Optimism).expect("Optimism supported");
716        let bare = ramses::plan::collect_fees(&params, CONFIG.position_mgr);
717
718        assert_ne!(
719            &frag.calls[0].calldata[..4],
720            &[0xac, 0x96, 0x50, 0xd8],
721            "non-native case must NOT be wrapped in multicall"
722        );
723        assert_eq!(
724            frag.calls[0].calldata, bare.calls[0].calldata,
725            "non-native pass-through must stay byte-identical to bare collect()"
726        );
727    }
728
729    #[test]
730    fn plan_collect_fees_no_native_side_rejects() {
731        // Both sides ERC20 (no WETH) with ZERO recipient — sentinel misuse;
732        // nothing to unwrap. Must reject loudly.
733        let params = CollectFeesParams {
734            token_id: U256::from(1u64),
735            recipient: Address::ZERO,
736            token0: address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // USDC.e
737            token1: address!("94b008aA00579c1307B0EF2c499aD98a8ce58e58"), // USDT (Optimism)
738            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
739        };
740        let err = plan_collect_fees(&params, Chain::Optimism).unwrap_err();
741        let msg = format!("{err:#}");
742        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
743    }
744
745    // ---------------------------------------------------------------
746    // plan_remove_liquidity_and_collect (native ETH unwrap) — mirrors
747    // plan_collect_fees tests above; same WETH-paired Optimism pair.
748    // ---------------------------------------------------------------
749
750    fn fixture_remove_and_collect_params_weth_paired() -> RemoveAndCollectParams {
751        RemoveAndCollectParams {
752            token_id: U256::from(1u64),
753            liquidity: 1_000_000u128,
754            amount0_min: Some(U256::from(100u64)),
755            amount1_min: Some(U256::from(200u64)),
756            recipient: Address::ZERO,
757            token0: address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // USDC.e (Optimism)
758            token1: address!("4200000000000000000000000000000000000006"), // WETH (Optimism)
759            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
760            burn: false,
761        }
762    }
763
764    #[test]
765    fn plan_remove_liquidity_and_collect_native_recipient_emits_4_call_multicall() {
766        let params = fixture_remove_and_collect_params_weth_paired();
767        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Optimism)
768            .expect("Optimism supported");
769
770        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
771        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
772        assert_eq!(frag.value, U256::ZERO);
773        assert!(frag.approvals.is_empty());
774
775        use alloy_sol_types::SolValue;
776        let (inner,): (Vec<alloy_primitives::Bytes>,) =
777            <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
778                .expect("decode outer multicall params");
779        assert_eq!(inner.len(), 4, "expected 4 inner calls");
780        assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
781        assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
782        assert_eq!(&inner[2][..4], &[0x49, 0x40, 0x4b, 0x7c], "inner[2] = unwrapWETH9");
783        assert_eq!(&inner[3][..4], &[0xdf, 0x2a, 0xb5, 0xbb], "inner[3] = sweepToken");
784    }
785
786    #[test]
787    fn plan_remove_liquidity_and_collect_non_native_recipient_passthrough() {
788        let mut params = fixture_remove_and_collect_params_weth_paired();
789        params.recipient = address!("0000000000000000000000000000000000000099");
790        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Optimism)
791            .expect("Optimism supported");
792
793        // Bare 2-call multicall — no native unwrap appended.
794        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
795        let bare =
796            ramses::plan::remove_liquidity_and_collect(&params, 9_999_999_999, CONFIG.position_mgr);
797        assert_eq!(
798            frag.calls[0].calldata, bare.calls[0].calldata,
799            "non-native pass-through must stay byte-identical to bare multicall([decrease, collect])"
800        );
801    }
802
803    #[test]
804    fn plan_remove_liquidity_and_collect_no_native_side_rejects() {
805        let mut params = fixture_remove_and_collect_params_weth_paired();
806        params.token1 = address!("94b008aA00579c1307B0EF2c499aD98a8ce58e58"); // USDT (Optimism)
807        let err =
808            plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Optimism).unwrap_err();
809        let msg = format!("{err:#}");
810        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
811    }
812
813    // -------------------------------------------------------------
814    // Slice 3 Layer 2 chain-aware tests (per Slice 1+2 pattern)
815    // -------------------------------------------------------------
816
817    #[test]
818    fn factory_returns_chain_specific_address_via_layer2() {
819        assert_eq!(factory(Chain::Optimism), Some(CONFIG.factory));
820        for unsupported in [
821            Chain::Ethereum,
822            Chain::Arbitrum,
823            Chain::Polygon,
824            Chain::Base,
825            Chain::Bsc,
826            Chain::Sonic,
827            Chain::Avalanche,
828            Chain::Celo,
829        ] {
830            assert_eq!(factory(unsupported), None);
831        }
832    }
833
834    /// Structural canary for chain-aware multicall routing.
835    ///
836    /// Skip pattern: requires `OPTIMISM_RPC_URL` pointing at an Optimism
837    /// archive node + `anvil` on PATH. Skips with eprintln otherwise.
838    #[tokio::test]
839    async fn pool_state_routes_to_chain_specific_multicall() {
840        let Some(rpc) = std::env::var("OPTIMISM_RPC_URL").ok() else {
841            eprintln!(
842                "SKIP pool_state_routes_to_chain_specific_multicall: \
843                 set OPTIMISM_RPC_URL to an Optimism archive RPC to enable"
844            );
845            return;
846        };
847        let anvil = alloy::node_bindings::Anvil::new().fork(rpc).spawn();
848        let provider = alloy::providers::ProviderBuilder::new().connect_http(anvil.endpoint_url());
849
850        // Canonical Velodrome Slipstream Optimism USDC/WETH CL100 pool —
851        // verified via `pool_address_matches_canonical_velodrome_usdc_weth_cl100`.
852        let optimism_pool = address!("478946BcD4a5a22b316470F5486fAfb928C0bA25");
853        let state = pool_state(&provider, Chain::Optimism, optimism_pool)
854            .await
855            .expect("Optimism pool_state must succeed via Layer 2 chain-aware routing");
856        assert!(
857            state.liquidity > 0,
858            "Real on-chain Velodrome Slipstream pool should have non-zero liquidity"
859        );
860    }
861
862    #[test]
863    fn pool_address_with_override_uses_override_not_config_hash() {
864        let usdc = address!("0b2C639c533813f4Aa9D7837CAf62653d097Ff85");
865        let weth = address!("4200000000000000000000000000000000000006");
866        let custom_hash = b256!("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
867
868        let with_override = pool_address(Chain::Optimism, usdc, weth, 100, Some(custom_hash))
869            .expect("Optimism supported");
870        let without_override =
871            pool_address(Chain::Optimism, usdc, weth, 100, None).expect("Optimism supported");
872
873        assert_ne!(with_override, without_override);
874    }
875}