Skip to main content

wp_evm_aerodrome/
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` — Aerodrome's NFPM is a
12/// Velodrome Slipstream fork on Base, sharing the same periphery interface
13/// and selectors (verified by the Slipstream selector-lock tests in
14/// `wp-evm-velodrome-interfaces`).
15const SELECTORS: PeripherySelectors = PeripherySelectors {
16    multicall: ISlipstreamPeripheryRouter::multicallCall::SELECTOR,
17    unwrap_native: ISlipstreamPeripheryRouter::unwrapWETH9Call::SELECTOR,
18    sweep_token: ISlipstreamPeripheryRouter::sweepTokenCall::SELECTOR,
19    refund_native: ISlipstreamPeripheryRouter::refundETHCall::SELECTOR,
20};
21
22pub use wp_evm_ramses_provider::data::{
23    CollectFeesParams, ExactInParams, ExactOutParams, PlanFragment, PoolState, PositionState,
24    Quote, RamsesAddLiquidityParams, RemoveAndCollectParams, RemoveLiquidityParams,
25};
26pub use wp_evm_ramses_provider::position::{
27    position_key, RamsesPositionView, VelodromePositionRow,
28};
29pub use wp_evm_ramses_provider::position_views::{PositionFees, PositionViewEntry};
30pub use wp_evm_ramses_provider::quote::QuoteError;
31pub use wp_evm_ramses_provider::Enumeration;
32
33pub const CONFIG: RamsesProtocolConfig = RamsesProtocolConfig {
34    factory: address!("5e7BB104d84c7CB9B682AaC2F3d509f5F406809A"),
35    pool_deployer: Address::ZERO,
36    router: address!("BE6D8f0d05cC4be24d5167a3eF062215bE6D18a5"),
37    position_mgr: address!("827922686190790b37229fd06084350E74485b72"),
38    init_code_hash: b256!("ffb9af9ea6d9e39da47392ecc7055277b9915b8bfc9f83f105821b7791a6ae30"),
39    tick_spacings: &[1, 50, 100, 200, 2000],
40    multicall: address!("cA11bde05977b3631167028862bE2a173976CA11"),
41    quoter: Some(address!("254cF9E1E6e233aa1AC962CB9B05b2cfeAaE15b0")),
42    voter: address!("16613524e02ad97eDfeF371bC883F2F5d6C480A5"),
43};
44
45pub fn config_for_chain(chain: Chain) -> Option<&'static RamsesProtocolConfig> {
46    match chain {
47        Chain::Base => Some(&CONFIG),
48        Chain::Ethereum
49        | Chain::Arbitrum
50        | Chain::Optimism
51        | Chain::Polygon
52        | Chain::Bsc
53        | Chain::Sonic
54        | Chain::HyperEvm
55        | Chain::Avalanche
56        | Chain::Celo => None,
57    }
58}
59
60pub fn factory(chain: Chain) -> Option<Address> {
61    config_for_chain(chain).map(|c| c.factory)
62}
63pub fn pool_deployer(chain: Chain) -> Option<Address> {
64    config_for_chain(chain).map(|c| c.pool_deployer)
65}
66pub fn position_manager(chain: Chain) -> Option<Address> {
67    config_for_chain(chain).map(|c| c.position_mgr)
68}
69pub fn router(chain: Chain) -> Option<Address> {
70    config_for_chain(chain).map(|c| c.router)
71}
72pub fn quoter(chain: Chain) -> Option<Address> {
73    config_for_chain(chain).and_then(|c| c.quoter)
74}
75pub fn multicall(chain: Chain) -> Option<Address> {
76    config_for_chain(chain).map(|c| c.multicall)
77}
78pub fn init_code_hash(chain: Chain) -> Option<B256> {
79    config_for_chain(chain).map(|c| c.init_code_hash)
80}
81pub fn voter(chain: Chain) -> Option<Address> {
82    config_for_chain(chain).map(|c| c.voter)
83}
84pub fn supports(chain: Chain) -> bool {
85    config_for_chain(chain).is_some()
86}
87
88pub async fn pool_state<P: Provider<Ethereum>>(
89    provider: &P,
90    chain: Chain,
91    pool: Address,
92) -> Result<PoolState> {
93    let cfg =
94        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
95    // Aerodrome (Slipstream) pools have a 6-field slot0 (no feeProtocol) — the
96    // V3 7-field `pool_state` reader overruns; use the Velodrome reader.
97    ramses::hydrate::pool_state_velodrome(provider, cfg.multicall, pool).await
98}
99
100pub async fn position_state<P: Provider<Ethereum>>(
101    provider: &P,
102    chain: Chain,
103    token_id: U256,
104) -> Result<PositionState> {
105    let cfg =
106        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
107    ramses::hydrate::position_state_slipstream(provider, cfg.multicall, cfg.position_mgr, token_id)
108        .await
109}
110
111pub async fn position_views<P: Provider<Ethereum>>(
112    provider: &P,
113    chain: Chain,
114    token_ids: &[U256],
115) -> Result<Vec<PositionViewEntry<VelodromePositionRow>>> {
116    let cfg =
117        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
118    ramses::position_views::velodrome_position_views(
119        provider,
120        cfg.multicall,
121        cfg.position_mgr,
122        token_ids,
123    )
124    .await
125}
126
127pub async fn position_views_with_nfpm<P: Provider<Ethereum>>(
128    provider: &P,
129    chain: Chain,
130    nfpm: Address,
131    token_ids: &[U256],
132) -> Result<Vec<PositionViewEntry<VelodromePositionRow>>> {
133    let multicall =
134        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
135    ramses::position_views::velodrome_position_views(provider, multicall, nfpm, token_ids).await
136}
137
138pub async fn enumerate_owner_token_ids<P: Provider<Ethereum>>(
139    provider: &P,
140    chain: Chain,
141    nfpm: Address,
142    owner: Address,
143) -> Result<Enumeration> {
144    let multicall =
145        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
146    ramses::enumerate_owner_token_ids(provider, multicall, nfpm, owner, chain).await
147}
148
149pub async fn populate_positions_fees<P: Provider<Ethereum>>(
150    provider: &P,
151    chain: Chain,
152    entries: &mut [PositionViewEntry<VelodromePositionRow>],
153) -> Result<()> {
154    let cfg =
155        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
156    ramses::position_views::velodrome_populate_position_fees(
157        provider,
158        cfg.multicall,
159        entries,
160        |v| pool_address(chain, v.token0, v.token1, v.tick_spacing, None),
161    )
162    .await
163}
164
165pub fn quote_exact_in(s: &PoolState, p: &ExactInParams) -> Result<Quote, QuoteError> {
166    ramses::quote::exact_in(s, p)
167}
168pub fn quote_exact_out(s: &PoolState, p: &ExactOutParams) -> Result<Quote, QuoteError> {
169    ramses::quote::exact_out(s, p)
170}
171
172pub async fn populate_ticks<P: Provider<Ethereum>>(
173    provider: &P,
174    chain: Chain,
175    pool: Address,
176    state: &mut PoolState,
177) -> Result<()> {
178    config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
179    ramses::populate_ticks::populate_ticks(provider, pool, state).await
180}
181
182pub async fn quote_online_exact_in<P: Provider<Ethereum>>(
183    provider: &P,
184    chain: Chain,
185    state: &PoolState,
186    params: &ExactInParams,
187) -> Result<Quote> {
188    let cfg =
189        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
190    let quoter =
191        cfg.quoter.ok_or_else(|| anyhow!("Aerodrome quoter not registered on {chain:?}"))?;
192    ramses::quote_online::quote_online_exact_in(provider, quoter, state, params).await
193}
194
195pub fn plan_swap_exact_in(
196    s: &PoolState,
197    q: &Quote,
198    p: &ExactInParams,
199    slippage: SlippageBps,
200    deadline: u64,
201    chain: Chain,
202) -> Result<PlanFragment> {
203    let cfg =
204        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
205    Ok(ramses::plan::swap_exact_in(s, q, p, slippage, deadline, cfg.router))
206}
207
208pub fn plan_add_liquidity(
209    p: &RamsesAddLiquidityParams,
210    slippage: SlippageBps,
211    deadline: u64,
212    chain: Chain,
213) -> Result<PlanFragment> {
214    let cfg =
215        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
216    Ok(ramses::plan::add_liquidity_slipstream(p, slippage, deadline, cfg.position_mgr))
217}
218
219/// Build an Aerodrome (Slipstream) NFPM `mint` plan fragment with precomputed
220/// `amount0_min` / `amount1_min` (e.g. derived from a price-aware sqrt-ratio
221/// quote). Mirrors `plan_add_liquidity` but takes the mins directly instead of
222/// a flat slippage haircut.
223pub fn plan_add_liquidity_with_min(
224    p: &RamsesAddLiquidityParams,
225    amount0_min: U256,
226    amount1_min: U256,
227    deadline: u64,
228    chain: Chain,
229) -> Result<PlanFragment> {
230    let cfg =
231        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
232    Ok(ramses::plan::add_liquidity_slipstream_with_min(
233        p,
234        amount0_min,
235        amount1_min,
236        deadline,
237        cfg.position_mgr,
238    ))
239}
240
241#[allow(clippy::too_many_arguments)]
242pub fn plan_increase_liquidity(
243    token_id: U256,
244    token0: Address,
245    token1: Address,
246    amount0_desired: U256,
247    amount1_desired: U256,
248    slippage: SlippageBps,
249    deadline: u64,
250    chain: Chain,
251) -> Result<PlanFragment> {
252    let cfg =
253        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
254    Ok(ramses::plan::increase_liquidity(
255        token_id,
256        token0,
257        token1,
258        amount0_desired,
259        amount1_desired,
260        slippage,
261        deadline,
262        cfg.position_mgr,
263    ))
264}
265
266pub fn plan_remove_liquidity(
267    p: &RemoveLiquidityParams,
268    deadline: u64,
269    chain: Chain,
270) -> Result<PlanFragment> {
271    let cfg =
272        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
273    Ok(ramses::plan::remove_liquidity(p, deadline, cfg.position_mgr))
274}
275
276/// Build an atomic `multicall(decreaseLiquidity, collect, ..unwrap_tail)`
277/// plan fragment for an Aerodrome (Base) NFPM position.
278///
279/// **Native ETH unwrap:** when `recipient == Address::ZERO`, composes
280/// `multicall(decrease, collect, unwrapWETH9, sweepToken)` against the
281/// NFPM. Aerodrome's NFPM is a Velodrome Slipstream fork on Base that
282/// inherits Uniswap V3's `Multicall + PeripheryPayments` byte-for-byte
283/// (verified against deployed Base NFPM `0x8279...5b72` in #232), so the
284/// V3 composition is byte-for-byte portable.
285///
286/// For non-native recipients this is a thin pass-through —
287/// `resolve_native_wrap_remove_and_collect` returns an empty `post_calls`
288/// vec and the bare `multicall([decrease, collect])` is emitted unwrapped.
289pub fn plan_remove_liquidity_and_collect(
290    p: &RemoveAndCollectParams,
291    deadline: u64,
292    chain: Chain,
293) -> Result<PlanFragment> {
294    let cfg =
295        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
296
297    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_remove_and_collect(
298        p,
299        cfg.position_mgr,
300        chain,
301    )?;
302
303    let mut core_params = (*p).clone();
304    core_params.recipient = wrap.effective_collect_recipient;
305
306    let frag = ramses::plan::remove_liquidity_and_collect(&core_params, deadline, cfg.position_mgr);
307    wp_evm_v3_provider::plan::compose_native_remove_collect_multicall(frag, &wrap, SELECTORS)
308}
309
310pub fn plan_collect_fees(p: &CollectFeesParams, chain: Chain) -> Result<PlanFragment> {
311    let cfg =
312        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
313
314    // Resolve `recipient == Address::ZERO` (native unwrap sentinel) by
315    // composing `multicall(collect, unwrapWETH9, sweepToken)` against the
316    // NFPM. Aerodrome's NFPM inherits Uniswap V3's `Multicall +
317    // PeripheryPayments` byte-for-byte (verified 2026-05-29 against the
318    // deployed Base NFPM at 0x8279...5b72), so the V3 Slice 3.5
319    // composition (PR #210) is byte-for-byte portable.
320    //
321    // For non-native recipients this is a thin pass-through —
322    // `resolve_native_wrap_collect` returns an empty `post_calls` vec
323    // and the bare `collect()` calldata is emitted unwrapped.
324    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_collect(p, cfg.position_mgr, chain)?;
325
326    // Safety-critical for V3 forks: do not pass `recipient = 0` through
327    // to the forked NFPM. Aerodrome is a V3 fork (with Velodrome ve(3,3)
328    // additions) and we cannot rely on the upstream V3 `recipient == 0 ?
329    // address(this) : recipient` substitution surviving every fork.
330    // Mirroring V3 Slice 3.5's defensive guard.
331    let core_params = CollectFeesParams {
332        token_id: p.token_id,
333        recipient: wrap.effective_recipient,
334        token0: p.token0,
335        token1: p.token1,
336        caller: p.caller,
337    };
338
339    let frag = ramses::plan::collect_fees(&core_params, cfg.position_mgr);
340    Ok(wp_evm_v3_provider::plan::compose_native_collect_multicall(frag, &wrap, SELECTORS))
341}
342
343pub fn pool_address(
344    chain: Chain,
345    token_a: Address,
346    token_b: Address,
347    tick_spacing: i32,
348    init_code_hash_override: Option<B256>,
349) -> Option<Address> {
350    let cfg = config_for_chain(chain)?;
351    let init_code_hash = init_code_hash_override.unwrap_or(cfg.init_code_hash);
352    Some(ramses::pool_address::compute(cfg.factory, init_code_hash, token_a, token_b, tick_spacing))
353}
354
355pub async fn pending_emissions<P: Provider<Ethereum>>(
356    provider: &P,
357    chain: Chain,
358    pool: Address,
359    account: Address,
360    token_id: U256,
361) -> Result<Option<wp_evm_ramses_provider::velodrome_gauge::VelodromePendingEmissions>> {
362    let cfg =
363        config_for_chain(chain).ok_or_else(|| anyhow!("Aerodrome not deployed on {chain:?}"))?;
364    wp_evm_ramses_provider::velodrome_gauge::pending_emissions(
365        provider,
366        cfg.multicall,
367        cfg.voter,
368        pool,
369        account,
370        token_id,
371    )
372    .await
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn config_for_chain_returns_some_for_base_only() {
381        assert_eq!(config_for_chain(Chain::Base), Some(&CONFIG));
382        for unsupported in [
383            Chain::Ethereum,
384            Chain::Arbitrum,
385            Chain::Optimism,
386            Chain::Polygon,
387            Chain::Bsc,
388            Chain::Sonic,
389            Chain::Avalanche,
390            Chain::Celo,
391        ] {
392            assert!(
393                config_for_chain(unsupported).is_none(),
394                "aerodrome should not surface {unsupported:?}",
395            );
396        }
397    }
398
399    /// `plan_add_liquidity_with_min` targets the NFPM (`cfg.position_mgr`) and
400    /// threads the exact precomputed `amount0_min`/`amount1_min` into the core
401    /// slipstream mint encoder (byte-identical to the bare core call, and
402    /// distinct from a different-mins call).
403    #[test]
404    fn plan_add_liquidity_with_min_threads_precomputed_mins_at_position_manager() {
405        let p = RamsesAddLiquidityParams {
406            token0: address!("4200000000000000000000000000000000000006"),
407            token1: address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"),
408            tick_spacing: 100,
409            tick_lower: -887_200,
410            tick_upper: 887_200,
411            amount0_desired: U256::from(1_000_000u64),
412            amount1_desired: U256::from(500_000_000_000_000u64),
413            recipient: address!("0000000000000000000000000000000000000099"),
414        };
415        let m0 = U256::from(123_456u64);
416        let m1 = U256::from(789_012u64);
417        let frag = plan_add_liquidity_with_min(&p, m0, m1, 9_999_999_999, Chain::Base)
418            .expect("Base supported");
419        assert_eq!(frag.calls.len(), 1);
420        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
421        assert_eq!(frag.approvals.len(), 2);
422
423        let bare = ramses::plan::add_liquidity_slipstream_with_min(
424            &p,
425            m0,
426            m1,
427            9_999_999_999,
428            CONFIG.position_mgr,
429        );
430        assert_eq!(frag.calls[0].calldata, bare.calls[0].calldata);
431        let other = ramses::plan::add_liquidity_slipstream_with_min(
432            &p,
433            m0 + U256::from(1u64),
434            m1,
435            9_999_999_999,
436            CONFIG.position_mgr,
437        );
438        assert_ne!(frag.calls[0].calldata, other.calls[0].calldata);
439    }
440
441    #[test]
442    fn pool_address_matches_canonical_aerodrome_weth_usdc_cl100() {
443        let pool = pool_address(
444            Chain::Base,
445            address!("4200000000000000000000000000000000000006"),
446            address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"),
447            100,
448            None,
449        )
450        .expect("Base supported");
451        assert_eq!(pool, address!("b2cc224c1c9feE385f8ad6a55b4d94E92359DC59"));
452    }
453
454    #[test]
455    fn pool_address_token_order_independent() {
456        let weth = address!("4200000000000000000000000000000000000006");
457        let usdc = address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913");
458        assert_eq!(
459            pool_address(Chain::Base, weth, usdc, 100, None),
460            pool_address(Chain::Base, usdc, weth, 100, None)
461        );
462    }
463
464    #[test]
465    fn plan_collect_fees_native_recipient_emits_multicall_with_unwrap_and_sweep() {
466        // Aerodrome Base — native pair is USDC / WETH. ZERO recipient =
467        // "user wants ETH back, not WETH" sentinel. Mirror of V3 Slice 3.5
468        // (PR #210) — see `wp_evm_uniswap_v3::tests::plan_collect_fees_native_recipient_*`.
469        let params = CollectFeesParams {
470            token_id: U256::from(1u64),
471            recipient: Address::ZERO,
472            token0: address!("4200000000000000000000000000000000000006"), // WETH (Base)
473            token1: address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"), // USDC (Base)
474            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
475        };
476        let frag = plan_collect_fees(&params, Chain::Base).expect("Base supported");
477
478        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
479        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
480        assert_eq!(frag.value, U256::ZERO);
481        assert_eq!(frag.calls[0].value, U256::ZERO);
482        assert!(
483            frag.calls[0].calldata.windows(4).any(|w| w == [0x49, 0x40, 0x4b, 0x7c]),
484            "native collect multicall must include unwrapWETH9(uint256,address) tail"
485        );
486        assert!(
487            frag.calls[0].calldata.windows(4).any(|w| w == [0xdf, 0x2a, 0xb5, 0xbb]),
488            "native collect multicall must include sweepToken(address,uint256,address) tail"
489        );
490    }
491
492    #[test]
493    fn plan_collect_fees_non_native_recipient_passthrough() {
494        let params = CollectFeesParams {
495            token_id: U256::from(1u64),
496            recipient: address!("0000000000000000000000000000000000000099"),
497            token0: address!("4200000000000000000000000000000000000006"),
498            token1: address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"),
499            caller: Address::ZERO,
500        };
501        let frag = plan_collect_fees(&params, Chain::Base).expect("Base supported");
502        let bare = ramses::plan::collect_fees(&params, CONFIG.position_mgr);
503
504        assert_ne!(
505            &frag.calls[0].calldata[..4],
506            &[0xac, 0x96, 0x50, 0xd8],
507            "non-native case must NOT be wrapped in multicall"
508        );
509        assert_eq!(
510            frag.calls[0].calldata, bare.calls[0].calldata,
511            "non-native pass-through must stay byte-identical to bare collect()"
512        );
513    }
514
515    #[test]
516    fn plan_collect_fees_no_native_side_rejects() {
517        // Both sides ERC20 (no WETH) with ZERO recipient — sentinel misuse;
518        // nothing to unwrap. Must reject loudly.
519        let params = CollectFeesParams {
520            token_id: U256::from(1u64),
521            recipient: Address::ZERO,
522            token0: address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"), // USDC
523            token1: address!("50c5725949A6F0c72E6C4a641F24049A917DB0Cb"), // DAI (Base)
524            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
525        };
526        let err = plan_collect_fees(&params, Chain::Base).unwrap_err();
527        let msg = format!("{err:#}");
528        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
529    }
530
531    // ---------------------------------------------------------------
532    // plan_remove_liquidity_and_collect (native ETH unwrap) — mirrors
533    // plan_collect_fees tests above; same WETH-paired Base pair.
534    // ---------------------------------------------------------------
535
536    fn fixture_remove_and_collect_params_weth_paired() -> RemoveAndCollectParams {
537        RemoveAndCollectParams {
538            token_id: U256::from(1u64),
539            liquidity: 1_000_000u128,
540            amount0_min: Some(U256::from(100u64)),
541            amount1_min: Some(U256::from(200u64)),
542            recipient: Address::ZERO,
543            token0: address!("4200000000000000000000000000000000000006"), // WETH (Base)
544            token1: address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"), // USDC (Base)
545            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
546            burn: false,
547        }
548    }
549
550    #[test]
551    fn plan_remove_liquidity_and_collect_native_recipient_emits_4_call_multicall() {
552        // WETH-paired Base position; native unwrap engaged via ZERO recipient.
553        let params = fixture_remove_and_collect_params_weth_paired();
554        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Base)
555            .expect("Base supported");
556
557        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
558        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
559        assert_eq!(frag.value, U256::ZERO);
560        assert!(frag.approvals.is_empty());
561
562        use alloy_sol_types::SolValue;
563        let (inner,): (Vec<alloy_primitives::Bytes>,) =
564            <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
565                .expect("decode outer multicall params");
566        assert_eq!(inner.len(), 4, "expected 4 inner calls");
567        assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
568        assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
569        assert_eq!(&inner[2][..4], &[0x49, 0x40, 0x4b, 0x7c], "inner[2] = unwrapWETH9");
570        assert_eq!(&inner[3][..4], &[0xdf, 0x2a, 0xb5, 0xbb], "inner[3] = sweepToken");
571    }
572
573    #[test]
574    fn plan_remove_liquidity_and_collect_non_native_recipient_passthrough() {
575        let mut params = fixture_remove_and_collect_params_weth_paired();
576        params.recipient = address!("0000000000000000000000000000000000000099");
577        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Base)
578            .expect("Base supported");
579        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
580        let bare =
581            ramses::plan::remove_liquidity_and_collect(&params, 9_999_999_999, CONFIG.position_mgr);
582        assert_eq!(
583            frag.calls[0].calldata, bare.calls[0].calldata,
584            "non-native pass-through must stay byte-identical to bare multicall([decrease, collect])"
585        );
586    }
587
588    #[test]
589    fn plan_remove_liquidity_and_collect_no_native_side_rejects() {
590        let mut params = fixture_remove_and_collect_params_weth_paired();
591        params.token0 = address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913"); // USDC (Base)
592        params.token1 = address!("50c5725949A6F0c72E6C4a641F24049A917DB0Cb"); // DAI (Base)
593        let err =
594            plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Base).unwrap_err();
595        let msg = format!("{err:#}");
596        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
597    }
598
599    // -------------------------------------------------------------
600    // Slice 3 Layer 2 chain-aware tests (per Slice 1+2 pattern)
601    // -------------------------------------------------------------
602
603    #[test]
604    fn factory_returns_chain_specific_address_via_layer2() {
605        assert_eq!(factory(Chain::Base), Some(CONFIG.factory));
606        for unsupported in [
607            Chain::Ethereum,
608            Chain::Arbitrum,
609            Chain::Optimism,
610            Chain::Polygon,
611            Chain::Bsc,
612            Chain::Sonic,
613            Chain::Avalanche,
614            Chain::Celo,
615        ] {
616            assert_eq!(factory(unsupported), None);
617        }
618    }
619
620    /// Structural canary for chain-aware multicall routing.
621    ///
622    /// Skip pattern: requires `BASE_RPC_URL` pointing at a Base archive
623    /// node + `anvil` on PATH. Skips with eprintln otherwise.
624    #[tokio::test]
625    async fn pool_state_routes_to_chain_specific_multicall() {
626        let Some(rpc) = std::env::var("BASE_RPC_URL").ok() else {
627            eprintln!(
628                "SKIP pool_state_routes_to_chain_specific_multicall: \
629                 set BASE_RPC_URL to a Base archive RPC to enable"
630            );
631            return;
632        };
633        let anvil = alloy::node_bindings::Anvil::new().fork(rpc).spawn();
634        let provider = alloy::providers::ProviderBuilder::new().connect_http(anvil.endpoint_url());
635
636        // Canonical Aerodrome Base WETH/USDC CL100 pool — verified via
637        // `pool_address_matches_canonical_aerodrome_weth_usdc_cl100`.
638        let base_pool = address!("b2cc224c1c9feE385f8ad6a55b4d94E92359DC59");
639        let state = pool_state(&provider, Chain::Base, base_pool)
640            .await
641            .expect("Base pool_state must succeed via Layer 2 chain-aware routing");
642        assert!(state.liquidity > 0, "Real on-chain Aerodrome pool should have non-zero liquidity");
643    }
644
645    #[test]
646    fn pool_address_with_override_uses_override_not_config_hash() {
647        let weth = address!("4200000000000000000000000000000000000006");
648        let usdc = address!("833589fCD6eDb6E08f4c7C32D4f71b54bda02913");
649        let custom_hash = b256!("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
650
651        let with_override =
652            pool_address(Chain::Base, weth, usdc, 100, Some(custom_hash)).expect("Base supported");
653        let without_override =
654            pool_address(Chain::Base, weth, usdc, 100, None).expect("Base supported");
655
656        assert_ne!(with_override, without_override);
657    }
658}