Skip to main content

wp_evm_shadow/
lib.rs

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