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