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