Skip to main content

wp_evm_shadow/
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_interfaces::periphery::router::IRamsesPeripheryRouter;
7use wp_evm_ramses_provider as ramses;
8pub use wp_evm_ramses_provider::data::RamsesProtocolConfig;
9use wp_evm_v3_provider::plan::PeripherySelectors;
10
11/// Selectors for `IRamsesPeripheryRouter`. Despite Sonic's native being S/wS,
12/// the function is still named `unwrapWETH9` — Ramses preserved Uniswap V3
13/// periphery names verbatim. Selector-locked in `wp-evm-ramses-interfaces`.
14const SELECTORS: PeripherySelectors = PeripherySelectors {
15    multicall: IRamsesPeripheryRouter::multicallCall::SELECTOR,
16    unwrap_native: IRamsesPeripheryRouter::unwrapWETH9Call::SELECTOR,
17    sweep_token: IRamsesPeripheryRouter::sweepTokenCall::SELECTOR,
18    refund_native: IRamsesPeripheryRouter::refundETHCall::SELECTOR,
19};
20
21pub use wp_evm_ramses_provider::data::{
22    CollectFeesParams, ExactInParams, ExactOutParams, GaugeClaim, GaugeEarnedGrid, PlanFragment,
23    PoolState, PositionState, Quote, RamsesAddLiquidityParams, RemoveAndCollectParams,
24    RemoveLiquidityParams,
25};
26pub use wp_evm_ramses_provider::gauge::gauge_earned_grids;
27pub use wp_evm_ramses_provider::position::{position_key, RamsesPositionView};
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: re-export raw planner + pool-address primitive for CLI dep-hygiene.
32pub use wp_evm_ramses_provider::plan;
33pub use wp_evm_ramses_provider::pool_address as pool_address_raw;
34pub use wp_evm_v3_provider::pool_views::{PoolReadEntry, PoolViewData};
35
36/// Batch-read Ramses (Shadow) pool snapshots through a single Multicall3.
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::RamsesPoolViewSource,
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::position_token_pair(provider, nfpm, token_id).await
56}
57
58pub const CONFIG: RamsesProtocolConfig = RamsesProtocolConfig {
59    factory: address!("cD2d0637c94fe77C2896BbCBB174cefFb08DE6d7"),
60    pool_deployer: address!("8BBDc15759a8eCf99A92E004E0C64ea9A5142d59"),
61    router: address!("5543c6176feb9b4b179078205d7c29eea2e2d695"),
62    position_mgr: address!("12E66C8F215DdD5d48d150c8f46aD0c6fB0F4406"),
63    init_code_hash: b256!("c701ee63862761c31d620a4a083c61bdc1e81761e6b9c9267fd19afd22e0821d"),
64    tick_spacings: &[1, 5, 10, 50, 100, 200],
65    multicall: address!("cA11bde05977b3631167028862bE2a173976CA11"),
66    quoter: None,
67    voter: address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D"),
68};
69
70pub fn config_for_chain(chain: Chain) -> Option<&'static RamsesProtocolConfig> {
71    match chain {
72        Chain::Sonic => Some(&CONFIG),
73        Chain::Ethereum
74        | Chain::Arbitrum
75        | Chain::Optimism
76        | Chain::Polygon
77        | Chain::Base
78        | Chain::Bsc
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 = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
119    ramses::hydrate::pool_state(provider, cfg.multicall, pool).await
120}
121
122pub async fn position_state<P: Provider<Ethereum>>(
123    provider: &P,
124    chain: Chain,
125    token_id: U256,
126) -> Result<PositionState> {
127    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
128    ramses::hydrate::position_state_shadow(provider, cfg.multicall, cfg.position_mgr, token_id)
129        .await
130}
131
132pub async fn position_views<P: Provider<Ethereum>>(
133    provider: &P,
134    chain: Chain,
135    token_ids: &[U256],
136) -> Result<Vec<PositionViewEntry<RamsesPositionView>>> {
137    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
138    ramses::position_views::position_views(provider, cfg.multicall, cfg.position_mgr, token_ids)
139        .await
140}
141
142pub async fn position_views_with_nfpm<P: Provider<Ethereum>>(
143    provider: &P,
144    chain: Chain,
145    nfpm: Address,
146    token_ids: &[U256],
147) -> Result<Vec<PositionViewEntry<RamsesPositionView>>> {
148    let multicall =
149        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
150    ramses::position_views::position_views(provider, multicall, nfpm, token_ids).await
151}
152
153pub async fn enumerate_owner_token_ids<P: Provider<Ethereum>>(
154    provider: &P,
155    chain: Chain,
156    nfpm: Address,
157    owner: Address,
158) -> Result<Enumeration> {
159    let multicall =
160        config_for_chain(chain).map(|cfg| cfg.multicall).unwrap_or(ramses::MULTICALL3_ADDRESS);
161    ramses::enumerate_owner_token_ids(provider, multicall, nfpm, owner, chain).await
162}
163
164pub async fn populate_positions_fees<P: Provider<Ethereum>>(
165    provider: &P,
166    chain: Chain,
167    entries: &mut [PositionViewEntry<RamsesPositionView>],
168) -> Result<()> {
169    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
170    ramses::position_views::populate_position_fees(provider, cfg.multicall, entries, |v| {
171        pool_address(chain, v.token0, v.token1, v.tick_spacing, None)
172    })
173    .await
174}
175
176pub fn quote_exact_in(s: &PoolState, p: &ExactInParams) -> Result<Quote, QuoteError> {
177    ramses::quote::exact_in(s, p)
178}
179pub fn quote_exact_out(s: &PoolState, p: &ExactOutParams) -> Result<Quote, QuoteError> {
180    ramses::quote::exact_out(s, p)
181}
182
183pub async fn populate_ticks<P: Provider<Ethereum>>(
184    provider: &P,
185    chain: Chain,
186    pool: Address,
187    state: &mut PoolState,
188) -> Result<()> {
189    config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
190    ramses::populate_ticks::populate_ticks(provider, pool, state).await
191}
192
193pub async fn quote_online_exact_in<P: Provider<Ethereum>>(
194    provider: &P,
195    chain: Chain,
196    state: &PoolState,
197    params: &ExactInParams,
198) -> Result<Quote> {
199    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
200    let quoter = cfg.quoter.ok_or_else(|| anyhow!("Shadow quoter not registered on {chain:?}"))?;
201    ramses::quote_online::quote_online_exact_in(provider, quoter, state, params).await
202}
203
204pub fn plan_swap_exact_in(
205    s: &PoolState,
206    q: &Quote,
207    p: &ExactInParams,
208    slippage: SlippageBps,
209    deadline: u64,
210    chain: Chain,
211) -> Result<PlanFragment> {
212    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
213    Ok(ramses::plan::swap_exact_in(s, q, p, slippage, deadline, cfg.router))
214}
215
216pub fn plan_add_liquidity(
217    p: &RamsesAddLiquidityParams,
218    slippage: SlippageBps,
219    deadline: u64,
220    chain: Chain,
221) -> Result<PlanFragment> {
222    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
223    Ok(ramses::plan::add_liquidity(p, slippage, deadline, cfg.position_mgr))
224}
225
226#[allow(clippy::too_many_arguments)]
227pub fn plan_increase_liquidity(
228    token_id: U256,
229    token0: Address,
230    token1: Address,
231    amount0_desired: U256,
232    amount1_desired: U256,
233    slippage: SlippageBps,
234    deadline: u64,
235    chain: Chain,
236) -> Result<PlanFragment> {
237    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
238    Ok(ramses::plan::increase_liquidity(
239        token_id,
240        token0,
241        token1,
242        amount0_desired,
243        amount1_desired,
244        slippage,
245        deadline,
246        cfg.position_mgr,
247    ))
248}
249
250pub fn plan_remove_liquidity(
251    p: &RemoveLiquidityParams,
252    deadline: u64,
253    chain: Chain,
254) -> Result<PlanFragment> {
255    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
256    Ok(ramses::plan::remove_liquidity(p, deadline, cfg.position_mgr))
257}
258
259/// Build an atomic `multicall(decreaseLiquidity, collect, ..unwrap_tail)`
260/// plan fragment for a Shadow Sonic NFPM position.
261///
262/// **Native wS unwrap (R26-future-1 follow-up):** when `recipient ==
263/// Address::ZERO`, this composes the calldata as `multicall(decrease,
264/// collect, unwrapWETH9, sweepToken)` against the NFPM rather than the
265/// bare `multicall([decrease, collect])` of the non-native happy path.
266/// Mirrors the existing `plan_collect_fees` native-S unwrap (#234) plus
267/// the V3 facade's `plan_remove_liquidity_and_collect` (Slice 3.6).
268///
269/// Shadow's NFPM is a Ramses-fork of Uniswap V3 and inherits
270/// `Multicall + PeripheryPayments` byte-for-byte (verified end-to-end
271/// against deployed Shadow Sonic NFPM
272/// `0x12E66C8F215DdD5d48d150c8f46aD0c6fB0F4406` in #234). Despite Sonic's
273/// native being S/wS, the unwrap function is still named `unwrapWETH9`
274/// — Ramses preserved Uniswap V3 periphery names verbatim.
275///
276/// For non-native recipients this is a thin pass-through —
277/// `resolve_native_wrap_remove_and_collect` returns an empty
278/// `post_calls` vec and the bare `multicall([decrease, collect])` is
279/// emitted unwrapped.
280pub fn plan_remove_liquidity_and_collect(
281    p: &RemoveAndCollectParams,
282    deadline: u64,
283    chain: Chain,
284) -> Result<PlanFragment> {
285    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
286
287    // No bridge — Shadow's RemoveAndCollectParams IS v3-core's (re-exported
288    // through ramses-core::data). Pass `p` straight to the v3-provider
289    // resolver.
290    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_remove_and_collect(
291        p,
292        cfg.position_mgr,
293        chain,
294    )?;
295
296    // Safety-critical for V3 forks: do not pass `recipient = 0` through
297    // to the forked NFPM. Mirroring the `plan_collect_fees` and V3
298    // facade Slice 3.5 defensive guard.
299    let mut core_params = (*p).clone();
300    core_params.recipient = wrap.effective_collect_recipient;
301
302    let frag = ramses::plan::remove_liquidity_and_collect(&core_params, deadline, cfg.position_mgr);
303    wp_evm_v3_provider::plan::compose_native_remove_collect_multicall(frag, &wrap, SELECTORS)
304}
305
306pub fn plan_collect_fees(p: &CollectFeesParams, chain: Chain) -> Result<PlanFragment> {
307    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
308
309    // Resolve `recipient == Address::ZERO` (native unwrap sentinel) by
310    // composing `multicall(collect, unwrapWETH9, sweepToken)` against the
311    // NFPM. Shadow's NFPM is a Ramses-fork of Uniswap V3 and inherits
312    // `Multicall + PeripheryPayments` byte-for-byte (verified 2026-05-29
313    // against deployed Shadow Sonic NFPM 0x12E66C8F215DdD5d48d150c8f46aD0c6fB0F4406):
314    // `multicall(bytes[])` accepts empty array, `unwrapWETH9` accepts call.
315    // Note: despite Sonic's native being S/wS, the function is still named
316    // `unwrapWETH9` — Ramses fork preserved Uniswap naming verbatim.
317    //
318    // For non-native recipients this is a thin pass-through —
319    // `resolve_native_wrap_collect` returns an empty `post_calls` vec
320    // and the bare `collect()` calldata is emitted unwrapped.
321    let wrap = wp_evm_v3_provider::plan::resolve_native_wrap_collect(p, cfg.position_mgr, chain)?;
322
323    // Safety-critical for V3 forks: do not pass `recipient = 0` through
324    // to the forked NFPM. Shadow is a V3 fork and we cannot rely on the
325    // upstream V3 `recipient == 0 ? address(this) : recipient` substitution
326    // surviving every fork. Mirroring V3 Slice 3.5's defensive guard.
327    let core_params = CollectFeesParams {
328        token_id: p.token_id,
329        recipient: wrap.effective_recipient,
330        token0: p.token0,
331        token1: p.token1,
332        caller: p.caller,
333    };
334
335    let frag = ramses::plan::collect_fees(&core_params, cfg.position_mgr);
336    Ok(wp_evm_v3_provider::plan::compose_native_collect_multicall(frag, &wrap, SELECTORS))
337}
338
339pub fn pool_address(
340    chain: Chain,
341    token_a: Address,
342    token_b: Address,
343    tick_spacing: i32,
344    init_code_hash_override: Option<B256>,
345) -> Option<Address> {
346    let cfg = config_for_chain(chain)?;
347    let init_code_hash = init_code_hash_override.unwrap_or(cfg.init_code_hash);
348    Some(ramses::pool_address::compute(
349        cfg.pool_deployer,
350        init_code_hash,
351        token_a,
352        token_b,
353        tick_spacing,
354    ))
355}
356
357pub async fn pending_emissions<P: Provider<Ethereum>>(
358    provider: &P,
359    chain: Chain,
360    pool: Address,
361    token_id: U256,
362) -> Result<Option<wp_evm_ramses_provider::gauge::PendingEmissions>> {
363    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
364    wp_evm_ramses_provider::gauge::pending_emissions(
365        provider,
366        cfg.multicall,
367        cfg.voter,
368        pool,
369        token_id,
370    )
371    .await
372}
373
374/// Pure: encode a batched Shadow gauge reward claim for fully-resolved
375/// `claims`. Looks up the chain's Voter and delegates to the family encoder.
376pub fn plan_claim_cl_gauge_rewards(chain: Chain, claims: &[GaugeClaim]) -> Result<PlanFragment> {
377    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
378    Ok(ramses::plan::claim_cl_gauge_rewards(cfg.voter, claims))
379}
380
381/// Async: resolve gauges + reward tokens + per-position `earned` for
382/// `positions` (`(pool, token_id)` across any pools), prune per-pair zeros,
383/// and encode ONE batched `claimClGaugeRewards`. Returns `Ok(None)` when
384/// nothing is claimable (ungauged / empty reward tokens / all-zero earned).
385/// Never gates on `isAlive`.
386pub async fn claim_cl_gauge_rewards_online<P: Provider<Ethereum>>(
387    provider: &P,
388    chain: Chain,
389    positions: &[(Address, U256)],
390) -> Result<Option<PlanFragment>> {
391    let cfg = config_for_chain(chain).ok_or_else(|| anyhow!("Shadow not deployed on {chain:?}"))?;
392    let grids =
393        ramses::gauge::gauge_earned_grids(provider, cfg.multicall, cfg.voter, positions).await?;
394    // Prune + encode (or None) via the pure, unit-tested family helper — this
395    // fn is now a thin reader + helper wrapper.
396    Ok(ramses::plan::claim_cl_gauge_rewards_from_grids(cfg.voter, &grids))
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use wp_evm_ramses_provider::data::TickInfo;
403
404    #[test]
405    fn gauge_earned_grids_is_reexported_at_facade() {
406        // Type-level smoke: the reader fn is reachable as wp_evm_shadow::gauge_earned_grids.
407        let _f = crate::gauge_earned_grids::<alloy_provider::RootProvider>;
408    }
409
410    #[test]
411    fn config_router_is_known_shadow_address() {
412        assert_eq!(CONFIG.router, address!("5543c6176feb9b4b179078205d7c29eea2e2d695"));
413    }
414
415    #[test]
416    fn config_factory_is_known_shadow_address() {
417        assert_eq!(CONFIG.factory, address!("cD2d0637c94fe77C2896BbCBB174cefFb08DE6d7"));
418    }
419
420    #[test]
421    fn config_pool_deployer_matches_factory_ramsesv3pooldeployer_getter() {
422        // 0x8BBDc15... was queried via factory.ramsesV3PoolDeployer()
423        // against Sonic public RPC (2026-04-25). Locks the value to
424        // prevent a future "fix-back to factory" regression.
425        assert_eq!(CONFIG.pool_deployer, address!("8BBDc15759a8eCf99A92E004E0C64ea9A5142d59"));
426    }
427
428    #[test]
429    fn pool_address_matches_canonical_usdce_ws_ts50_sonic() {
430        let usdc_e = address!("29219dD400f2Bf60E5a23d13Be72B486D4038894");
431        let ws = address!("039e2fb66102314Ce7b64Ce5CE3E5183bc94aD38");
432        let pool = pool_address(Chain::Sonic, usdc_e, ws, 50, None).expect("Sonic supported");
433        assert_eq!(pool, address!("324963c267C354c7660Ce8CA3F5f167E05649970"));
434    }
435
436    #[test]
437    fn config_tick_spacings_match_shadow() {
438        assert_eq!(CONFIG.tick_spacings, &[1, 5, 10, 50, 100, 200]);
439    }
440
441    /// Shadow `plan_add_liquidity` uses `RamsesAddLiquidityParams` with `tick_spacing`,
442    /// not the v3-family `AddLiquidityParams` with `fee`.
443    #[test]
444    fn plan_add_liquidity_accepts_ramses_params_with_tick_spacing() {
445        let p = RamsesAddLiquidityParams {
446            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
447            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
448            tick_spacing: 50,
449            tick_lower: -887_272,
450            tick_upper: 887_272,
451            amount0_desired: U256::from(1_000_000u64),
452            amount1_desired: U256::from(500_000_000_000_000u64),
453            recipient: address!("0000000000000000000000000000000000000099"),
454        };
455        let frag = plan_add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, Chain::Sonic)
456            .expect("Sonic supported");
457        assert_eq!(frag.calls.len(), 1);
458        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
459        assert_eq!(frag.approvals.len(), 2);
460        assert_eq!(frag.approvals[0].token, p.token0);
461        assert_eq!(frag.approvals[1].token, p.token1);
462        assert_eq!(frag.value, U256::ZERO);
463    }
464
465    fn fixture_usdc_weth() -> PoolState {
466        let sqrt_price_x96 = U256::from_str_radix("3543191142285914205922034323214", 10).unwrap();
467        PoolState {
468            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
469            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
470            fee: 3000,
471            tick_spacing: 50,
472            sqrt_price_x96,
473            liquidity: 2_000_000_000_000_000_000_000u128,
474            tick: 76012,
475            ticks: vec![
476                TickInfo {
477                    tick: 74950,
478                    liquidity_net: 1_000_000_000_000_000_000_000i128,
479                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
480                },
481                TickInfo {
482                    tick: 75950,
483                    liquidity_net: 1_000_000_000_000_000_000_000i128,
484                    liquidity_gross: 1_000_000_000_000_000_000_000u128,
485                },
486                TickInfo {
487                    tick: 76050,
488                    liquidity_net: -2_000_000_000_000_000_000_000i128,
489                    liquidity_gross: 2_000_000_000_000_000_000_000u128,
490                },
491            ],
492        }
493    }
494
495    #[test]
496    fn quote_exact_in_delegates_to_ramses_family() {
497        let state = fixture_usdc_weth();
498        let params = ExactInParams {
499            token_in: state.token0,
500            token_out: state.token1,
501            amount_in: U256::from(1_000_000u64),
502            recipient: address!("0000000000000000000000000000000000000099"),
503        };
504        let quote = quote_exact_in(&state, &params).expect("quote should succeed");
505        assert!(quote.amount_out > U256::ZERO);
506        assert_eq!(quote.amount_in, params.amount_in);
507    }
508
509    #[test]
510    fn plan_swap_exact_in_targets_shadow_router() {
511        let state = fixture_usdc_weth();
512        let quote = Quote {
513            amount_in: U256::from(1_000_000u64),
514            amount_out: U256::from(500_000_000_000_000u64),
515            sqrt_price_x96_after: state.sqrt_price_x96,
516            price_impact_bps: 0,
517        };
518        let params = ExactInParams {
519            token_in: state.token0,
520            token_out: state.token1,
521            amount_in: quote.amount_in,
522            recipient: address!("0000000000000000000000000000000000000099"),
523        };
524        let frag = plan_swap_exact_in(
525            &state,
526            &quote,
527            &params,
528            SlippageBps::new(50),
529            u64::MAX,
530            Chain::Sonic,
531        )
532        .expect("Sonic supported");
533        assert_eq!(frag.calls.len(), 1);
534        assert_eq!(frag.calls[0].target, CONFIG.router);
535        assert_eq!(frag.approvals.len(), 1);
536    }
537
538    #[test]
539    fn quote_exact_out_delegates_to_ramses_family() {
540        let state = fixture_usdc_weth();
541        let params = ExactOutParams {
542            token_in: state.token0,
543            token_out: state.token1,
544            amount_out: U256::from(500_000_000_000_000u64),
545            recipient: address!("0000000000000000000000000000000000000099"),
546        };
547        let quote = quote_exact_out(&state, &params).expect("exact-out quote should succeed");
548        assert!(quote.amount_in > U256::ZERO);
549        assert_eq!(quote.amount_out, params.amount_out);
550    }
551
552    #[test]
553    fn plan_remove_liquidity_targets_position_manager_no_approvals() {
554        let params = RemoveLiquidityParams {
555            token_id: U256::from(42u64),
556            liquidity: 1_000_000_000_000u128,
557            amount0_min: None,
558            amount1_min: None,
559        };
560        let frag = plan_remove_liquidity(&params, u64::MAX, Chain::Sonic).expect("Sonic supported");
561        assert_eq!(frag.calls.len(), 1);
562        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
563        assert!(frag.approvals.is_empty());
564        assert_eq!(frag.value, U256::ZERO);
565    }
566
567    #[test]
568    fn plan_collect_fees_targets_position_manager_no_approvals() {
569        let params = CollectFeesParams {
570            token_id: U256::from(42u64),
571            recipient: address!("0000000000000000000000000000000000000099"),
572            token0: address!("0000000000000000000000000000000000000001"),
573            token1: address!("0000000000000000000000000000000000000002"),
574            caller: Address::ZERO,
575        };
576        let frag = plan_collect_fees(&params, Chain::Sonic).expect("Sonic supported");
577        assert_eq!(frag.calls.len(), 1);
578        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
579        assert!(frag.approvals.is_empty());
580        assert_eq!(frag.value, U256::ZERO);
581    }
582
583    #[test]
584    fn plan_claim_cl_gauge_rewards_targets_voter() {
585        let claims = vec![GaugeClaim {
586            gauge: address!("1111111111111111111111111111111111111111"),
587            reward_tokens: vec![address!("aaaa000000000000000000000000000000000000")],
588            token_ids: vec![U256::from(1u64)],
589        }];
590        let frag = plan_claim_cl_gauge_rewards(Chain::Sonic, &claims).expect("Sonic supported");
591        assert_eq!(frag.calls.len(), 1);
592        assert_eq!(frag.calls[0].target, CONFIG.voter);
593        assert!(frag.approvals.is_empty());
594        assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
595    }
596
597    #[test]
598    fn plan_claim_cl_gauge_rewards_rejects_unsupported_chain() {
599        let claims = vec![GaugeClaim {
600            gauge: Address::ZERO,
601            reward_tokens: vec![Address::ZERO],
602            token_ids: vec![U256::from(1u64)],
603        }];
604        assert!(plan_claim_cl_gauge_rewards(Chain::Base, &claims).is_err());
605    }
606
607    #[test]
608    fn plan_collect_fees_native_recipient_emits_multicall_with_unwrap_and_sweep() {
609        // Shadow Sonic — native pair is token0=someERC20 / token1=wS. ZERO recipient
610        // = "user wants native S back, not wS" sentinel. Mirror of V3 Slice 3.5
611        // (PR #210) and L21 Slice 1 (PR #232) — see Slipstream unit tests.
612        // wS on Sonic: 0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38
613        let params = CollectFeesParams {
614            token_id: U256::from(1u64),
615            recipient: Address::ZERO,
616            token0: address!("29219dd400f2bf60e5a23d13be72b486d4038894"), // USDC.e (Sonic)
617            token1: address!("039e2fb66102314ce7b64ce5ce3e5183bc94ad38"), // wS (Sonic native)
618            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
619        };
620        let frag = plan_collect_fees(&params, Chain::Sonic).expect("Sonic supported");
621
622        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
623        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
624        assert_eq!(frag.value, U256::ZERO);
625        assert_eq!(frag.calls[0].value, U256::ZERO);
626        assert!(
627            frag.calls[0].calldata.windows(4).any(|w| w == [0x49, 0x40, 0x4b, 0x7c]),
628            "native collect multicall must include unwrapWETH9(uint256,address) tail"
629        );
630        assert!(
631            frag.calls[0].calldata.windows(4).any(|w| w == [0xdf, 0x2a, 0xb5, 0xbb]),
632            "native collect multicall must include sweepToken(address,uint256,address) tail"
633        );
634    }
635
636    #[test]
637    fn plan_collect_fees_non_native_recipient_passthrough() {
638        let params = CollectFeesParams {
639            token_id: U256::from(1u64),
640            recipient: address!("0000000000000000000000000000000000000099"),
641            token0: address!("29219dd400f2bf60e5a23d13be72b486d4038894"), // USDC.e (Sonic)
642            token1: address!("039e2fb66102314ce7b64ce5ce3e5183bc94ad38"), // wS (Sonic native)
643            caller: Address::ZERO,
644        };
645        let frag = plan_collect_fees(&params, Chain::Sonic).expect("Sonic supported");
646        let bare = ramses::plan::collect_fees(&params, CONFIG.position_mgr);
647
648        assert_ne!(
649            &frag.calls[0].calldata[..4],
650            &[0xac, 0x96, 0x50, 0xd8],
651            "non-native case must NOT be wrapped in multicall"
652        );
653        assert_eq!(
654            frag.calls[0].calldata, bare.calls[0].calldata,
655            "non-native pass-through must stay byte-identical to bare collect()"
656        );
657    }
658
659    #[test]
660    fn plan_collect_fees_no_native_side_rejects() {
661        // Both sides ERC20 (no wS) with ZERO recipient — sentinel misuse;
662        // nothing to unwrap. Must reject loudly.
663        let params = CollectFeesParams {
664            token_id: U256::from(1u64),
665            recipient: Address::ZERO,
666            token0: address!("29219dd400f2bf60e5a23d13be72b486d4038894"), // USDC.e (Sonic)
667            token1: address!("0000000000000000000000000000000000000002"), // some other ERC20
668            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
669        };
670        let err = plan_collect_fees(&params, Chain::Sonic).unwrap_err();
671        let msg = format!("{err:#}");
672        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
673    }
674
675    // ---------------------------------------------------------------
676    // plan_remove_liquidity_and_collect (native wS unwrap) — mirrors
677    // plan_collect_fees tests above; same wS-paired position fixture.
678    // ---------------------------------------------------------------
679
680    fn fixture_remove_and_collect_params_ws_paired() -> RemoveAndCollectParams {
681        RemoveAndCollectParams {
682            token_id: U256::from(1u64),
683            liquidity: 1_000_000u128,
684            amount0_min: Some(U256::from(100u64)),
685            amount1_min: Some(U256::from(200u64)),
686            recipient: Address::ZERO,
687            token0: address!("29219dd400f2bf60e5a23d13be72b486d4038894"), // USDC.e (Sonic)
688            token1: address!("039e2fb66102314ce7b64ce5ce3e5183bc94ad38"), // wS (Sonic native)
689            caller: address!("dEaDbEEFdeAdBeEfDEadBeEFDeaDbEEfdeadbEEF"),
690        }
691    }
692
693    #[test]
694    fn plan_remove_liquidity_and_collect_native_recipient_emits_4_call_multicall() {
695        // wS-paired position (token1=wS) with ZERO recipient — sentinel
696        // engages native unwrap. Outer call must be `multicall(bytes[])`
697        // (selector `0xac9650d8`) carrying 4 inner calls:
698        // [decreaseLiquidity, collect, unwrapWETH9, sweepToken].
699        let params = fixture_remove_and_collect_params_ws_paired();
700        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Sonic)
701            .expect("Sonic supported");
702
703        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
704        assert_eq!(frag.calls[0].target, CONFIG.position_mgr);
705        assert_eq!(frag.value, U256::ZERO);
706        assert_eq!(frag.calls[0].value, U256::ZERO);
707        assert!(frag.approvals.is_empty());
708
709        // unwrapWETH9 = 0x49404b7c (Ramses preserves V3 naming despite
710        // Sonic's native being S/wS).
711        assert!(
712            frag.calls[0].calldata.windows(4).any(|w| w == [0x49, 0x40, 0x4b, 0x7c]),
713            "native remove+collect multicall must include unwrapWETH9(uint256,address) tail"
714        );
715        // sweepToken = 0xdf2ab5bb (universal across V3/Ramses/Velodrome).
716        assert!(
717            frag.calls[0].calldata.windows(4).any(|w| w == [0xdf, 0x2a, 0xb5, 0xbb]),
718            "native remove+collect multicall must include sweepToken(address,uint256,address) tail"
719        );
720
721        // Decode outer multicall and assert exactly 4 inner calls in order.
722        use alloy_sol_types::SolValue;
723        let (inner,): (Vec<alloy_primitives::Bytes>,) =
724            <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
725                .expect("decode outer multicall params");
726        assert_eq!(inner.len(), 4, "expected 4 inner calls");
727        assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
728        assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
729        assert_eq!(&inner[2][..4], &[0x49, 0x40, 0x4b, 0x7c], "inner[2] = unwrapWETH9");
730        assert_eq!(&inner[3][..4], &[0xdf, 0x2a, 0xb5, 0xbb], "inner[3] = sweepToken");
731    }
732
733    #[test]
734    fn plan_remove_liquidity_and_collect_non_native_recipient_passthrough() {
735        // Explicit recipient (not ZERO) — sentinel does NOT engage; the
736        // facade returns the bare `multicall([decrease, collect])` from
737        // the family's core fn, byte-identical.
738        let mut params = fixture_remove_and_collect_params_ws_paired();
739        params.recipient = address!("0000000000000000000000000000000000000099");
740        let frag = plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Sonic)
741            .expect("Sonic supported");
742
743        // Still outer multicall — the bare 2-call pair from the core fn.
744        assert_eq!(&frag.calls[0].calldata[..4], &[0xac, 0x96, 0x50, 0xd8]);
745        // The bare core fn would produce identical bytes given the same
746        // recipient substitution we'd compute (effective_recipient ==
747        // params.recipient since sentinel didn't engage).
748        let bare =
749            ramses::plan::remove_liquidity_and_collect(&params, 9_999_999_999, CONFIG.position_mgr);
750        assert_eq!(
751            frag.calls[0].calldata, bare.calls[0].calldata,
752            "non-native pass-through must stay byte-identical to bare multicall([decrease, collect])"
753        );
754    }
755
756    #[test]
757    fn plan_remove_liquidity_and_collect_no_native_side_rejects() {
758        // Both sides ERC20 (no wS) with ZERO recipient — sentinel misuse;
759        // nothing to unwrap. Must reject loudly.
760        let mut params = fixture_remove_and_collect_params_ws_paired();
761        params.token1 = address!("0000000000000000000000000000000000000002");
762        let err =
763            plan_remove_liquidity_and_collect(&params, 9_999_999_999, Chain::Sonic).unwrap_err();
764        let msg = format!("{err:#}");
765        assert!(msg.contains("neither") || msg.contains("native"), "got: {msg}");
766    }
767
768    #[test]
769    fn config_for_chain_returns_some_for_sonic_only() {
770        assert_eq!(config_for_chain(Chain::Sonic), Some(&CONFIG));
771
772        for unsupported in [
773            Chain::Ethereum,
774            Chain::Arbitrum,
775            Chain::Optimism,
776            Chain::Polygon,
777            Chain::Base,
778            Chain::Bsc,
779            Chain::Avalanche,
780            Chain::Celo,
781        ] {
782            assert!(
783                config_for_chain(unsupported).is_none(),
784                "shadow should not surface {unsupported:?}",
785            );
786        }
787    }
788
789    #[test]
790    fn position_view_and_key_are_re_exported() {
791        // Pure type-level smoke: this just has to compile.
792        let _: fn(
793            alloy_primitives::Address,
794            alloy_primitives::U256,
795            i32,
796            i32,
797        ) -> alloy_primitives::B256 = position_key;
798        let _: std::marker::PhantomData<RamsesPositionView> = std::marker::PhantomData;
799    }
800
801    // -------------------------------------------------------------
802    // Slice 3 Layer 2 chain-aware tests (per Slice 1+2 pattern)
803    // -------------------------------------------------------------
804
805    #[test]
806    fn factory_returns_chain_specific_address_via_layer2() {
807        assert_eq!(factory(Chain::Sonic), Some(CONFIG.factory));
808        for unsupported in [
809            Chain::Ethereum,
810            Chain::Arbitrum,
811            Chain::Optimism,
812            Chain::Polygon,
813            Chain::Base,
814            Chain::Bsc,
815            Chain::Avalanche,
816            Chain::Celo,
817        ] {
818            assert_eq!(factory(unsupported), None);
819        }
820    }
821
822    /// Structural canary for chain-aware multicall routing — replaces the
823    /// pre-fix tautological getter check (which only proved
824    /// `multicall(chain)` returns the right address, NOT that `pool_state`
825    /// actually uses it).
826    ///
827    /// Skip pattern: requires `SONIC_RPC_URL` pointing at a Sonic archive
828    /// node + `anvil` on PATH. Skips with eprintln otherwise — does NOT
829    /// pass-by-default to avoid silent CI green when env is missing.
830    #[tokio::test]
831    async fn pool_state_routes_to_chain_specific_multicall() {
832        let Some(rpc) = std::env::var("SONIC_RPC_URL").ok() else {
833            eprintln!(
834                "SKIP pool_state_routes_to_chain_specific_multicall: \
835                 set SONIC_RPC_URL to a Sonic archive RPC to enable"
836            );
837            return;
838        };
839        let anvil = alloy::node_bindings::Anvil::new().fork(rpc).spawn();
840        let provider = alloy::providers::ProviderBuilder::new().connect_http(anvil.endpoint_url());
841
842        // Canonical Shadow Sonic USDC.e/wS ts=50 pool — verified via
843        // `pool_address_matches_canonical_usdce_ws_ts50_sonic` above.
844        let sonic_pool = address!("324963c267C354c7660Ce8CA3F5f167E05649970");
845        let state = pool_state(&provider, Chain::Sonic, sonic_pool)
846            .await
847            .expect("Sonic pool_state must succeed via Layer 2 chain-aware routing");
848        assert!(state.liquidity > 0, "Real on-chain Shadow pool should have non-zero liquidity");
849    }
850
851    #[test]
852    fn pool_address_with_override_uses_override_not_config_hash() {
853        let usdc_e = address!("29219dD400f2Bf60E5a23d13Be72B486D4038894");
854        let ws = address!("039e2fb66102314Ce7b64Ce5CE3E5183bc94aD38");
855        let custom_hash = b256!("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff");
856
857        let with_override =
858            pool_address(Chain::Sonic, usdc_e, ws, 50, Some(custom_hash)).expect("Sonic supported");
859        let without_override =
860            pool_address(Chain::Sonic, usdc_e, ws, 50, None).expect("Sonic supported");
861
862        assert_ne!(with_override, without_override);
863    }
864}