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