Skip to main content

wp_evm_v3_core/
plan.rs

1//! Pure plan functions for the v3 family.
2//!
3//! Inputs: hydrated PoolState + computed Quote + params + slippage + deadline + config.
4//! Output: a PlanFragment (calls + declared approvals + value).
5//!
6//! No I/O. Slippage and amount-min computation are pure functions of the
7//! Quote produced upstream by the quote module.
8
9use crate::data::{
10    AddLiquidityParams, CollectFeesParams, ExactInParams, ExactOutParams, PlanFragment, PoolState,
11    Quote, RemoveAndCollectParams, RemoveLiquidityParams, SwapRouterKind,
12};
13use alloy_primitives::Address;
14use alloy_primitives::{aliases::U160, U256};
15use alloy_sol_types::{sol, SolCall};
16use wp_evm_base::types::{Call, SlippageBps, TokenApproval};
17use wp_evm_v3_interfaces::periphery::router::IPeripheryRouter;
18
19sol! {
20    #[derive(Debug)]
21    struct ExactInputSingleParams {
22        address tokenIn;
23        address tokenOut;
24        uint24  fee;
25        address recipient;
26        uint256 deadline;
27        uint256 amountIn;
28        uint256 amountOutMinimum;
29        uint160 sqrtPriceLimitX96;
30    }
31
32    function exactInputSingle(ExactInputSingleParams params)
33        external payable returns (uint256 amountOut);
34}
35
36sol! {
37    /// SwapRouter02 `exactInputSingle` — no `deadline` field
38    /// (deadline checked externally via Multicall wrapper if needed).
39    /// Selector: 0x04e45aaf.
40    #[derive(Debug)]
41    struct ExactInputSingleParamsV02 {
42        address tokenIn;
43        address tokenOut;
44        uint24  fee;
45        address recipient;
46        uint256 amountIn;
47        uint256 amountOutMinimum;
48        uint160 sqrtPriceLimitX96;
49    }
50
51    /// V02 variant — same function name, different params shape.
52    /// Rust binding lives in its own interface scope to avoid name collision.
53    interface ISwapRouter02 {
54        function exactInputSingle(ExactInputSingleParamsV02 params)
55            external payable returns (uint256 amountOut);
56    }
57}
58
59sol! {
60    /// V1 SwapRouter `exactOutputSingle` — with `deadline` field.
61    /// Selector: 0xdb3e2198.
62    #[derive(Debug)]
63    struct ExactOutputSingleParams {
64        address tokenIn;
65        address tokenOut;
66        uint24  fee;
67        address recipient;
68        uint256 deadline;
69        uint256 amountOut;
70        uint256 amountInMaximum;
71        uint160 sqrtPriceLimitX96;
72    }
73
74    function exactOutputSingle(ExactOutputSingleParams params)
75        external payable returns (uint256 amountIn);
76}
77
78sol! {
79    /// SwapRouter02 `exactOutputSingle` — no `deadline` field.
80    /// Selector: 0x5023b4df.
81    #[derive(Debug)]
82    struct ExactOutputSingleParamsV02 {
83        address tokenIn;
84        address tokenOut;
85        uint24  fee;
86        address recipient;
87        uint256 amountOut;
88        uint256 amountInMaximum;
89        uint160 sqrtPriceLimitX96;
90    }
91
92    /// V02 variant — same function name, different params shape.
93    interface ISwapRouter02ExactOut {
94        function exactOutputSingle(ExactOutputSingleParamsV02 params)
95            external payable returns (uint256 amountIn);
96    }
97}
98
99sol! {
100    #[derive(Debug)]
101    struct MintParams {
102        address token0;
103        address token1;
104        uint24  fee;
105        int24   tickLower;
106        int24   tickUpper;
107        uint256 amount0Desired;
108        uint256 amount1Desired;
109        uint256 amount0Min;
110        uint256 amount1Min;
111        address recipient;
112        uint256 deadline;
113    }
114    function mint(MintParams params) external payable
115        returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
116
117    #[derive(Debug)]
118    struct IncreaseLiquidityParams {
119        uint256 tokenId;
120        uint256 amount0Desired;
121        uint256 amount1Desired;
122        uint256 amount0Min;
123        uint256 amount1Min;
124        uint256 deadline;
125    }
126    function increaseLiquidity(IncreaseLiquidityParams params) external payable
127        returns (uint128 liquidity, uint256 amount0, uint256 amount1);
128
129    #[derive(Debug)]
130    struct DecreaseLiquidityParams {
131        uint256 tokenId;
132        uint128 liquidity;
133        uint256 amount0Min;
134        uint256 amount1Min;
135        uint256 deadline;
136    }
137    function decreaseLiquidity(DecreaseLiquidityParams params) external payable
138        returns (uint256 amount0, uint256 amount1);
139
140    #[derive(Debug)]
141    struct CollectParams {
142        uint256 tokenId;
143        address recipient;
144        uint128 amount0Max;
145        uint128 amount1Max;
146    }
147    function collect(CollectParams params) external payable
148        returns (uint256 amount0, uint256 amount1);
149
150    function burn(uint256 tokenId) external payable;
151}
152
153/// Build a v3 router `exactInputSingle` plan fragment.
154///
155/// Pure. No chain interaction. The caller (hydrate or wrapper) must supply
156/// the deadline as an absolute unix timestamp.
157pub fn swap_exact_in(
158    state: &PoolState,
159    quote: &Quote,
160    params: &ExactInParams,
161    slippage: SlippageBps,
162    deadline: u64,
163    router: Address,
164    kind: SwapRouterKind,
165) -> PlanFragment {
166    swap_exact_in_with_fee_fn(state, quote, params, slippage, deadline, router, kind, |s| s.fee)
167}
168
169/// Plan a v3 swap with an injectable fee function.
170///
171/// See `quote::exact_in_with_fee_fn` for the motivation. This variant exists
172/// so Phase 4 protocols (Algebra / dynamic-fee CL) can emit calldata targeting
173/// the correct fee tier without needing a v3-family API change.
174///
175/// Pure. No chain interaction.
176#[allow(
177    clippy::too_many_arguments,
178    reason = "public planner API threads router ABI kind plus fee injection"
179)]
180pub fn swap_exact_in_with_fee_fn<F>(
181    state: &PoolState,
182    quote: &Quote,
183    params: &ExactInParams,
184    slippage: SlippageBps,
185    deadline: u64,
186    router: Address,
187    kind: SwapRouterKind,
188    fee_fn: F,
189) -> PlanFragment
190where
191    F: Fn(&PoolState) -> u32,
192{
193    let effective_fee = fee_fn(state);
194    let amount_out_min = apply_slippage_min(quote.amount_out, slippage);
195    let fee_u24 = alloy_primitives::aliases::U24::from(effective_fee);
196
197    let calldata = match kind {
198        SwapRouterKind::V1 => {
199            let call_params = ExactInputSingleParams {
200                tokenIn: params.token_in,
201                tokenOut: params.token_out,
202                fee: fee_u24,
203                recipient: params.recipient,
204                deadline: U256::from(deadline),
205                amountIn: params.amount_in,
206                amountOutMinimum: amount_out_min,
207                sqrtPriceLimitX96: U160::ZERO,
208            };
209            exactInputSingleCall { params: call_params }.abi_encode().into()
210        }
211        SwapRouterKind::V02 => {
212            let call_params = ExactInputSingleParamsV02 {
213                tokenIn: params.token_in,
214                tokenOut: params.token_out,
215                fee: fee_u24,
216                recipient: params.recipient,
217                amountIn: params.amount_in,
218                amountOutMinimum: amount_out_min,
219                sqrtPriceLimitX96: U160::ZERO,
220            };
221            ISwapRouter02::exactInputSingleCall { params: call_params }.abi_encode().into()
222        }
223    };
224
225    PlanFragment {
226        calls: vec![Call { target: router, calldata, value: U256::ZERO }],
227        approvals: vec![TokenApproval {
228            token: params.token_in,
229            spender: router,
230            min_amount: params.amount_in,
231        }],
232        value: U256::ZERO,
233    }
234}
235
236/// Build a v3 router `exactOutputSingle` plan fragment.
237///
238/// `quote.amount_in` (from a prior `quote::exact_out` computation) is
239/// used to derive `amountInMaximum = quote.amount_in * (1 + slippage)`.
240/// The caller's `params.amount_out` flows through as `amountOut` (exact
241/// target).
242///
243/// Pure. No chain interaction.
244pub fn swap_exact_out(
245    state: &PoolState,
246    quote: &Quote,
247    params: &ExactOutParams,
248    slippage: SlippageBps,
249    deadline: u64,
250    router: Address,
251    kind: SwapRouterKind,
252) -> PlanFragment {
253    swap_exact_out_with_fee_fn(state, quote, params, slippage, deadline, router, kind, |s| s.fee)
254}
255
256/// Plan a v3 exact-out swap with an injectable fee function.
257#[allow(
258    clippy::too_many_arguments,
259    reason = "public planner API threads router ABI kind plus fee injection"
260)]
261pub fn swap_exact_out_with_fee_fn<F>(
262    state: &PoolState,
263    quote: &Quote,
264    params: &ExactOutParams,
265    slippage: SlippageBps,
266    deadline: u64,
267    router: Address,
268    kind: SwapRouterKind,
269    fee_fn: F,
270) -> PlanFragment
271where
272    F: Fn(&PoolState) -> u32,
273{
274    let effective_fee = fee_fn(state);
275    let amount_in_max = apply_slippage_max(quote.amount_in, slippage);
276    let fee_u24 = alloy_primitives::aliases::U24::from(effective_fee);
277
278    let calldata = match kind {
279        SwapRouterKind::V1 => {
280            let call_params = ExactOutputSingleParams {
281                tokenIn: params.token_in,
282                tokenOut: params.token_out,
283                fee: fee_u24,
284                recipient: params.recipient,
285                deadline: U256::from(deadline),
286                amountOut: params.amount_out,
287                amountInMaximum: amount_in_max,
288                sqrtPriceLimitX96: U160::ZERO,
289            };
290            exactOutputSingleCall { params: call_params }.abi_encode().into()
291        }
292        SwapRouterKind::V02 => {
293            let call_params = ExactOutputSingleParamsV02 {
294                tokenIn: params.token_in,
295                tokenOut: params.token_out,
296                fee: fee_u24,
297                recipient: params.recipient,
298                amountOut: params.amount_out,
299                amountInMaximum: amount_in_max,
300                sqrtPriceLimitX96: U160::ZERO,
301            };
302            ISwapRouter02ExactOut::exactOutputSingleCall { params: call_params }.abi_encode().into()
303        }
304    };
305
306    PlanFragment {
307        calls: vec![Call { target: router, calldata, value: U256::ZERO }],
308        approvals: vec![TokenApproval {
309            token: params.token_in,
310            spender: router,
311            min_amount: amount_in_max,
312        }],
313        value: U256::ZERO,
314    }
315}
316
317/// Compute the minimum acceptable output given a quoted amount and slippage.
318///
319/// `amount_out_min = quoted * (10_000 - slippage_bps) / 10_000`
320pub fn apply_slippage_min(quoted: U256, slippage: SlippageBps) -> U256 {
321    let bps = U256::from(slippage.as_bps());
322    let denom = U256::from(10_000u64);
323    quoted * (denom - bps) / denom
324}
325
326/// Compute the maximum acceptable input for an exact-out swap.
327pub fn apply_slippage_max(quoted: U256, slippage: SlippageBps) -> U256 {
328    let bps = U256::from(slippage.as_bps());
329    let denom = U256::from(10_000u64);
330    quoted * (denom + bps) / denom
331}
332
333/// Build a v3 position-manager `mint` plan fragment (add liquidity).
334///
335/// Declares approvals for both tokens against the position manager.
336/// `amount0Min` / `amount1Min` are derived from `slippage`.
337pub fn add_liquidity(
338    params: &AddLiquidityParams,
339    slippage: SlippageBps,
340    deadline: u64,
341    position_manager: Address,
342) -> PlanFragment {
343    add_liquidity_with_min(
344        params,
345        apply_slippage_min(params.amount0_desired, slippage),
346        apply_slippage_min(params.amount1_desired, slippage),
347        deadline,
348        position_manager,
349    )
350}
351
352/// Build a v3 position-manager `mint` plan fragment (add liquidity) with
353/// precomputed `amount0_min` / `amount1_min`.
354///
355/// Identical to `add_liquidity` except the mins are supplied directly by the
356/// caller (e.g. derived from a price-aware sqrt-ratio quote) rather than
357/// derived from a flat slippage haircut. `add_liquidity` delegates here.
358///
359/// Declares approvals for both tokens against the position manager.
360pub fn add_liquidity_with_min(
361    params: &AddLiquidityParams,
362    amount0_min: U256,
363    amount1_min: U256,
364    deadline: u64,
365    position_manager: Address,
366) -> PlanFragment {
367    let mint_params = MintParams {
368        token0: params.token0,
369        token1: params.token1,
370        fee: alloy_primitives::aliases::U24::from(params.fee),
371        tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
372            .expect("tick_lower within i24 range"),
373        tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
374            .expect("tick_upper within i24 range"),
375        amount0Desired: params.amount0_desired,
376        amount1Desired: params.amount1_desired,
377        amount0Min: amount0_min,
378        amount1Min: amount1_min,
379        recipient: params.recipient,
380        deadline: U256::from(deadline),
381    };
382    let calldata = mintCall { params: mint_params }.abi_encode().into();
383    PlanFragment {
384        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
385        approvals: vec![
386            TokenApproval {
387                token: params.token0,
388                spender: position_manager,
389                min_amount: params.amount0_desired,
390            },
391            TokenApproval {
392                token: params.token1,
393                spender: position_manager,
394                min_amount: params.amount1_desired,
395            },
396        ],
397        value: U256::ZERO,
398    }
399}
400
401/// Build an `increaseLiquidity` plan fragment for an existing v3 NFT
402/// position. Requires `tokenId` to refer to a position the caller (or
403/// recipient of approval) already owns.
404///
405/// `token0` / `token1` are needed for the approval declaration and must
406/// match the position's stored pair (the function does not validate this
407/// — caller's responsibility, typically by hydrating the position first).
408///
409/// Slippage applies to the desired amounts via `apply_slippage_min`,
410/// matching `add_liquidity`'s shape.
411///
412/// Approvals: token0 + token1 to NFPM (same as mint).
413#[allow(
414    clippy::too_many_arguments,
415    reason = "public planner API mirrors NFPM increaseLiquidity parameters"
416)]
417pub fn increase_liquidity(
418    token_id: U256,
419    token0: Address,
420    token1: Address,
421    amount0_desired: U256,
422    amount1_desired: U256,
423    slippage: SlippageBps,
424    deadline: u64,
425    position_manager: Address,
426) -> PlanFragment {
427    increase_liquidity_with_min(
428        token_id,
429        token0,
430        token1,
431        amount0_desired,
432        amount1_desired,
433        apply_slippage_min(amount0_desired, slippage),
434        apply_slippage_min(amount1_desired, slippage),
435        deadline,
436        position_manager,
437    )
438}
439
440/// Build an `increaseLiquidity` plan fragment with precomputed
441/// `amount0_min` / `amount1_min`.
442///
443/// Identical to `increase_liquidity` except the mins are supplied directly by
444/// the caller (e.g. derived from a price-aware sqrt-ratio quote) rather than
445/// derived from a flat slippage haircut. `increase_liquidity` delegates here.
446///
447/// Approvals: token0 + token1 to NFPM (same as mint).
448#[allow(
449    clippy::too_many_arguments,
450    reason = "public planner API mirrors NFPM increaseLiquidity parameters"
451)]
452pub fn increase_liquidity_with_min(
453    token_id: U256,
454    token0: Address,
455    token1: Address,
456    amount0_desired: U256,
457    amount1_desired: U256,
458    amount0_min: U256,
459    amount1_min: U256,
460    deadline: u64,
461    position_manager: Address,
462) -> PlanFragment {
463    let inc_params = IncreaseLiquidityParams {
464        tokenId: token_id,
465        amount0Desired: amount0_desired,
466        amount1Desired: amount1_desired,
467        amount0Min: amount0_min,
468        amount1Min: amount1_min,
469        deadline: U256::from(deadline),
470    };
471    let calldata = increaseLiquidityCall { params: inc_params }.abi_encode().into();
472    PlanFragment {
473        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
474        approvals: vec![
475            TokenApproval { token: token0, spender: position_manager, min_amount: amount0_desired },
476            TokenApproval { token: token1, spender: position_manager, min_amount: amount1_desired },
477        ],
478        value: U256::ZERO,
479    }
480}
481
482/// Build a decreaseLiquidity plan fragment for a v3 NFT position.
483///
484/// Slippage protection is the caller's responsibility: supply precomputed
485/// `amount0_min` / `amount1_min` in `RemoveLiquidityParams` (typically
486/// derived from a quote against the current pool state minus the desired
487/// slippage tolerance). Passing `None` for both = zero protection — only
488/// safe for private mempools.
489///
490/// No approvals needed — the position manager operates the NFT it minted.
491pub fn remove_liquidity(
492    params: &RemoveLiquidityParams,
493    deadline: u64,
494    position_manager: Address,
495) -> PlanFragment {
496    let dec = DecreaseLiquidityParams {
497        tokenId: params.token_id,
498        liquidity: params.liquidity,
499        amount0Min: params.amount0_min.unwrap_or(U256::ZERO),
500        amount1Min: params.amount1_min.unwrap_or(U256::ZERO),
501        deadline: U256::from(deadline),
502    };
503    let calldata = decreaseLiquidityCall { params: dec }.abi_encode().into();
504    PlanFragment {
505        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
506        approvals: vec![],
507        value: U256::ZERO,
508    }
509}
510
511/// Build an atomic `multicall(decreaseLiquidity, collect)` plan
512/// fragment for a V3 NFT position.
513///
514/// `deadline` threads through `decreaseLiquidity`'s deadline param.
515/// `collect`'s `amount0Max` / `amount1Max` are set to `u128::MAX`
516/// (collect everything in `tokens_owed`).
517///
518/// This helper ALWAYS emits multicall — even for non-native recipient.
519/// Callers who want one-or-the-other should use `remove_liquidity` or
520/// `collect_fees` directly.
521///
522/// **Native unwrap is NOT done here** — this helper is pure encoding.
523/// Facade-layer `plan_remove_liquidity_and_collect` composes
524/// `unwrapWETH9` + `sweepToken` tails on top via the v3-provider
525/// resolver (`resolve_native_wrap_remove_and_collect`).
526///
527/// **Architectural note**: Placing `remove_liquidity_and_collect` in
528/// v3-core (not v3-provider) is intentional. Every existing v3-core
529/// helper (`swap_exact_in`, `add_liquidity`, `remove_liquidity`,
530/// `collect_fees`) emits a single call; this is the first
531/// multicall-emitting v3-core helper. The (`decreaseLiquidity`,
532/// `collect`) composition is family-wide pure encoding (no slippage /
533/// native / chain logic), not facade-layer logic — so v3-core is the
534/// correct home. Future composed primitives (e.g.
535/// `mint_and_initialize_pool` for V3) would follow the same precedent.
536/// Do **not** "migrate" this to v3-provider in a future consistency PR —
537/// the encoding boundary is preserved.
538pub fn remove_liquidity_and_collect(
539    params: &RemoveAndCollectParams,
540    deadline: u64,
541    position_manager: Address,
542) -> PlanFragment {
543    // decreaseLiquidity calldata
544    let dec_params = DecreaseLiquidityParams {
545        tokenId: params.token_id,
546        liquidity: params.liquidity,
547        amount0Min: params.amount0_min.unwrap_or(U256::ZERO),
548        amount1Min: params.amount1_min.unwrap_or(U256::ZERO),
549        deadline: U256::from(deadline),
550    };
551    let decrease_calldata: alloy_primitives::Bytes =
552        decreaseLiquidityCall { params: dec_params }.abi_encode().into();
553
554    // collect calldata (recipient = params.recipient; amount caps = u128::MAX)
555    let collect_params = CollectParams {
556        tokenId: params.token_id,
557        recipient: params.recipient,
558        amount0Max: u128::MAX,
559        amount1Max: u128::MAX,
560    };
561    let collect_calldata: alloy_primitives::Bytes =
562        collectCall { params: collect_params }.abi_encode().into();
563
564    // outer multicall (NFPM inherits Multicall)
565    let mut multicall_data = vec![decrease_calldata, collect_calldata];
566    if params.burn {
567        let burn_calldata: alloy_primitives::Bytes =
568            burnCall { tokenId: params.token_id }.abi_encode().into();
569        multicall_data.push(burn_calldata);
570    }
571    let multicall_calldata =
572        IPeripheryRouter::multicallCall { data: multicall_data }.abi_encode().into();
573
574    PlanFragment {
575        calls: vec![Call {
576            target: position_manager,
577            value: U256::ZERO,
578            calldata: multicall_calldata,
579        }],
580        approvals: vec![],
581        value: U256::ZERO,
582    }
583}
584
585/// Build a v3 position-manager `collect` plan fragment (collect accrued fees).
586///
587/// Uses `u128::MAX` for both `amount0Max` and `amount1Max` — the standard
588/// "collect everything" sentinel in the Uniswap V3 position manager.
589pub fn collect_fees(params: &CollectFeesParams, position_manager: Address) -> PlanFragment {
590    let coll = CollectParams {
591        tokenId: params.token_id,
592        recipient: params.recipient,
593        amount0Max: u128::MAX,
594        amount1Max: u128::MAX,
595    };
596    let calldata = collectCall { params: coll }.abi_encode().into();
597    PlanFragment {
598        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
599        approvals: vec![],
600        value: U256::ZERO,
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::data::{
608        AddLiquidityParams, CollectFeesParams, RemoveAndCollectParams, RemoveLiquidityParams,
609        SwapRouterKind, V3ProtocolConfig,
610    };
611    use alloy_primitives::{address, b256, Address};
612
613    const TEST_CFG: V3ProtocolConfig = V3ProtocolConfig {
614        factory: address!("0x1F98431c8aD98523631AE4a59f267346ea31F984"),
615        pool_deployer: None,
616        router: address!("0xE592427A0AEce92De3Edee1F18E0157C05861564"),
617        swap_router_kind: SwapRouterKind::V1,
618        position_mgr: address!("0xC36442b4a4522E871399CD717aBDD847Ab11FE88"),
619        init_code_hash: b256!("0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54"),
620        fee_tiers: &[100, 500, 3000, 10000],
621        multicall: address!("0xcA11bde05977b3631167028862bE2a173976CA11"),
622        quoter: None,
623    };
624
625    fn dummy_pool_state(t0: Address, t1: Address) -> PoolState {
626        PoolState {
627            token0: t0,
628            token1: t1,
629            fee: 3000,
630            tick_spacing: 60,
631            sqrt_price_x96: U256::from(1u64) << 96,
632            liquidity: 0,
633            tick: 0,
634            ticks: vec![],
635        }
636    }
637
638    fn fixture_exact_in_params() -> ExactInParams {
639        ExactInParams {
640            token_in: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
641            token_out: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
642            amount_in: U256::from(1_000_000u64),
643            recipient: address!("0000000000000000000000000000000000000099"),
644        }
645    }
646
647    fn fixture_exact_out_params() -> ExactOutParams {
648        ExactOutParams {
649            token_in: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
650            token_out: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
651            amount_out: U256::from(500_000_000_000_000u64),
652            recipient: address!("0000000000000000000000000000000000000099"),
653        }
654    }
655
656    fn fixture_quote(state: &PoolState) -> Quote {
657        Quote {
658            amount_in: U256::from(1_000_000u64),
659            amount_out: U256::from(500_000_000_000_000u64),
660            sqrt_price_x96_after: state.sqrt_price_x96,
661            price_impact_bps: 0,
662        }
663    }
664
665    #[test]
666    fn plan_swap_emits_one_call_with_router_target() {
667        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
668        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
669        let s = dummy_pool_state(token_in, token_out);
670        let q = Quote {
671            amount_in: U256::from(1_000_000u64),
672            amount_out: U256::from(500_000_000_000_000u64),
673            sqrt_price_x96_after: s.sqrt_price_x96,
674            price_impact_bps: 0,
675        };
676        let p = ExactInParams {
677            token_in,
678            token_out,
679            amount_in: q.amount_in,
680            recipient: address!("0x0000000000000000000000000000000000000099"),
681        };
682        let frag = swap_exact_in(
683            &s,
684            &q,
685            &p,
686            SlippageBps::new(50),
687            9_999_999_999,
688            TEST_CFG.router,
689            SwapRouterKind::V1,
690        );
691        assert_eq!(frag.calls.len(), 1);
692        assert_eq!(frag.calls[0].target, TEST_CFG.router);
693        assert_eq!(frag.approvals.len(), 1);
694        assert_eq!(frag.approvals[0].token, token_in);
695        assert_eq!(frag.approvals[0].spender, TEST_CFG.router);
696        assert_eq!(frag.approvals[0].min_amount, q.amount_in);
697        assert_eq!(frag.value, U256::ZERO);
698    }
699
700    #[test]
701    fn swap_exact_in_v1_emits_v1_selector() {
702        let params = fixture_exact_in_params();
703        let state = dummy_pool_state(params.token_in, params.token_out);
704        let quote = fixture_quote(&state);
705        let plan = swap_exact_in(
706            &state,
707            &quote,
708            &params,
709            SlippageBps::new(50),
710            0,
711            TEST_CFG.router,
712            SwapRouterKind::V1,
713        );
714        assert_eq!(
715            &plan.calls[0].calldata[..4],
716            &[0x41, 0x4b, 0xf3, 0x89],
717            "V1 selector must be 0x414bf389"
718        );
719    }
720
721    #[test]
722    fn swap_exact_in_v02_emits_v02_selector() {
723        let params = fixture_exact_in_params();
724        let state = dummy_pool_state(params.token_in, params.token_out);
725        let quote = fixture_quote(&state);
726        let plan = swap_exact_in(
727            &state,
728            &quote,
729            &params,
730            SlippageBps::new(50),
731            0,
732            TEST_CFG.router,
733            SwapRouterKind::V02,
734        );
735        assert_eq!(
736            &plan.calls[0].calldata[..4],
737            &[0x04, 0xe4, 0x5a, 0xaf],
738            "V02 selector must be 0x04e45aaf"
739        );
740    }
741
742    #[test]
743    fn swap_exact_in_v1_includes_deadline() {
744        let params = fixture_exact_in_params();
745        let state = dummy_pool_state(params.token_in, params.token_out);
746        let quote = fixture_quote(&state);
747        let v1_plan = swap_exact_in(
748            &state,
749            &quote,
750            &params,
751            SlippageBps::new(50),
752            1_700_000_000,
753            TEST_CFG.router,
754            SwapRouterKind::V1,
755        );
756        let v02_plan = swap_exact_in(
757            &state,
758            &quote,
759            &params,
760            SlippageBps::new(50),
761            1_700_000_000,
762            TEST_CFG.router,
763            SwapRouterKind::V02,
764        );
765        assert_eq!(
766            v1_plan.calls[0].calldata.len(),
767            v02_plan.calls[0].calldata.len() + 32,
768            "V1 calldata must be 32 bytes longer than V02 (deadline field)"
769        );
770    }
771
772    #[test]
773    fn swap_exact_out_v1_emits_v1_selector() {
774        let params = fixture_exact_out_params();
775        let state = dummy_pool_state(params.token_in, params.token_out);
776        let quote = fixture_quote(&state);
777        let plan = swap_exact_out(
778            &state,
779            &quote,
780            &params,
781            SlippageBps::new(50),
782            0,
783            TEST_CFG.router,
784            SwapRouterKind::V1,
785        );
786        assert_eq!(
787            &plan.calls[0].calldata[..4],
788            &[0xdb, 0x3e, 0x21, 0x98],
789            "V1 selector must be 0xdb3e2198"
790        );
791    }
792
793    #[test]
794    fn swap_exact_out_v02_emits_v02_selector() {
795        let params = fixture_exact_out_params();
796        let state = dummy_pool_state(params.token_in, params.token_out);
797        let quote = fixture_quote(&state);
798        let plan = swap_exact_out(
799            &state,
800            &quote,
801            &params,
802            SlippageBps::new(50),
803            0,
804            TEST_CFG.router,
805            SwapRouterKind::V02,
806        );
807        assert_eq!(
808            &plan.calls[0].calldata[..4],
809            &[0x50, 0x23, 0xb4, 0xdf],
810            "V02 selector must be 0x5023b4df"
811        );
812    }
813
814    #[test]
815    fn swap_exact_out_v1_includes_deadline() {
816        let params = fixture_exact_out_params();
817        let state = dummy_pool_state(params.token_in, params.token_out);
818        let quote = fixture_quote(&state);
819        let v1_plan = swap_exact_out(
820            &state,
821            &quote,
822            &params,
823            SlippageBps::new(50),
824            1_700_000_000,
825            TEST_CFG.router,
826            SwapRouterKind::V1,
827        );
828        let v02_plan = swap_exact_out(
829            &state,
830            &quote,
831            &params,
832            SlippageBps::new(50),
833            1_700_000_000,
834            TEST_CFG.router,
835            SwapRouterKind::V02,
836        );
837        assert_eq!(
838            v1_plan.calls[0].calldata.len(),
839            v02_plan.calls[0].calldata.len() + 32,
840            "V1 calldata must be 32 bytes longer than V02 (deadline field)"
841        );
842    }
843
844    #[test]
845    fn swap_exact_out_approval_min_amount_is_amount_in_max() {
846        let params = fixture_exact_out_params();
847        let state = dummy_pool_state(params.token_in, params.token_out);
848        let quote = Quote {
849            amount_in: U256::from(1_000_000u64),
850            amount_out: params.amount_out,
851            sqrt_price_x96_after: state.sqrt_price_x96,
852            price_impact_bps: 0,
853        };
854        let plan = swap_exact_out(
855            &state,
856            &quote,
857            &params,
858            SlippageBps::new(50),
859            0,
860            TEST_CFG.router,
861            SwapRouterKind::V1,
862        );
863        assert_eq!(
864            plan.approvals[0].min_amount,
865            U256::from(1_005_000u64),
866            "approval min_amount must equal amount_in_max (worst case)"
867        );
868    }
869
870    #[test]
871    fn slippage_min_50bps_on_1eth() {
872        let out =
873            apply_slippage_min(U256::from(1_000_000_000_000_000_000u64), SlippageBps::new(50));
874        // 1e18 * 9950 / 10000 = 9.95e17
875        assert_eq!(out, U256::from(995_000_000_000_000_000u64));
876    }
877
878    #[test]
879    fn slippage_max_50bps_on_1eth() {
880        let out =
881            apply_slippage_max(U256::from(1_000_000_000_000_000_000u64), SlippageBps::new(50));
882        assert_eq!(out, U256::from(1_005_000_000_000_000_000u64));
883    }
884
885    #[test]
886    fn plan_add_liquidity_targets_position_manager() {
887        let p = AddLiquidityParams {
888            token0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
889            token1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
890            fee: 3000,
891            tick_lower: -201_000,
892            tick_upper: -198_960,
893            amount0_desired: U256::from(1_000_000u64),
894            amount1_desired: U256::from(500_000_000_000_000u64),
895            recipient: address!("0x0000000000000000000000000000000000000099"),
896        };
897        let frag = add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, TEST_CFG.position_mgr);
898        assert_eq!(frag.calls.len(), 1);
899        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
900        assert_eq!(frag.approvals.len(), 2);
901        assert_eq!(frag.approvals[0].token, p.token0);
902        assert_eq!(frag.approvals[0].spender, TEST_CFG.position_mgr);
903        assert_eq!(frag.approvals[1].token, p.token1);
904        assert_eq!(frag.approvals[1].spender, TEST_CFG.position_mgr);
905        assert_eq!(frag.value, U256::ZERO);
906    }
907
908    #[test]
909    fn plan_remove_liquidity_targets_position_manager_no_approvals() {
910        let p = RemoveLiquidityParams {
911            token_id: U256::from(42u64),
912            liquidity: 1_000_000_000_000u128,
913            amount0_min: None,
914            amount1_min: None,
915        };
916        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
917        assert_eq!(frag.calls.len(), 1);
918        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
919        assert!(frag.approvals.is_empty());
920        assert_eq!(frag.value, U256::ZERO);
921    }
922
923    #[test]
924    fn plan_remove_liquidity_passes_min_amounts_when_supplied() {
925        let p = RemoveLiquidityParams {
926            token_id: U256::from(42u64),
927            liquidity: 1_000_000_000_000u128,
928            amount0_min: Some(U256::from(500_000u64)),
929            amount1_min: Some(U256::from(1_000_000_000u64)),
930        };
931        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
932        // Decode the emitted calldata and assert the mins survived
933        let decoded =
934            decreaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
935        assert_eq!(decoded.params.amount0Min, U256::from(500_000u64));
936        assert_eq!(decoded.params.amount1Min, U256::from(1_000_000_000u64));
937    }
938
939    #[test]
940    fn plan_remove_liquidity_defaults_missing_mins_to_zero() {
941        let p = RemoveLiquidityParams {
942            token_id: U256::from(42u64),
943            liquidity: 1_000_000_000_000u128,
944            amount0_min: None,
945            amount1_min: None,
946        };
947        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
948        let decoded =
949            decreaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
950        assert_eq!(decoded.params.amount0Min, U256::ZERO);
951        assert_eq!(decoded.params.amount1Min, U256::ZERO);
952    }
953
954    #[test]
955    fn slippage_min_50bps_on_100k_weth() {
956        // 100k WETH = 1e23 wei — well above u64::MAX.
957        // Value must fit in U256 and the helper must not truncate.
958        let quoted: U256 = U256::from(10u64).pow(U256::from(23u64));
959        let out = apply_slippage_min(quoted, SlippageBps::new(50));
960        let expected: U256 = quoted * U256::from(9950u64) / U256::from(10_000u64);
961        assert_eq!(out, expected);
962        // Also sanity check that we're actually in the range claimed
963        assert!(out > U256::from(u64::MAX));
964    }
965
966    #[test]
967    fn plan_collect_fees_targets_position_manager_no_approvals() {
968        let p = CollectFeesParams {
969            token_id: U256::from(42u64),
970            recipient: address!("0x0000000000000000000000000000000000000099"),
971            token0: address!("0x0000000000000000000000000000000000000001"),
972            token1: address!("0x0000000000000000000000000000000000000002"),
973            caller: Address::ZERO,
974        };
975        let frag = collect_fees(&p, TEST_CFG.position_mgr);
976        assert_eq!(frag.calls.len(), 1);
977        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
978        assert!(frag.approvals.is_empty());
979        assert_eq!(frag.value, U256::ZERO);
980    }
981
982    #[test]
983    fn plan_collect_fees_calldata_round_trips_all_fields() {
984        let p = CollectFeesParams {
985            token_id: U256::from(42u64),
986            recipient: address!("0000000000000000000000000000000000000099"),
987            token0: address!("0000000000000000000000000000000000000001"),
988            token1: address!("0000000000000000000000000000000000000002"),
989            caller: Address::ZERO,
990        };
991        let frag = collect_fees(&p, TEST_CFG.position_mgr);
992
993        let decoded = collectCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
994        assert_eq!(decoded.params.tokenId, p.token_id);
995        assert_eq!(decoded.params.recipient, p.recipient);
996        assert_eq!(decoded.params.amount0Max, u128::MAX);
997        assert_eq!(decoded.params.amount1Max, u128::MAX);
998    }
999
1000    #[test]
1001    fn plan_remove_liquidity_and_collect_targets_nfpm_with_multicall_outer() {
1002        let p = RemoveAndCollectParams {
1003            token_id: U256::from(1u64),
1004            liquidity: 1000u128,
1005            amount0_min: Some(U256::from(99u64)),
1006            amount1_min: Some(U256::from(199u64)),
1007            recipient: address!("0000000000000000000000000000000000000099"),
1008            token0: address!("0000000000000000000000000000000000000001"),
1009            token1: address!("0000000000000000000000000000000000000002"),
1010            caller: Address::ZERO,
1011            burn: false,
1012        };
1013        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, TEST_CFG.position_mgr);
1014        assert_eq!(frag.calls.len(), 1);
1015        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
1016        assert_eq!(
1017            &frag.calls[0].calldata[..4],
1018            &[0xac, 0x96, 0x50, 0xd8],
1019            "outer call selector must be multicall"
1020        );
1021        assert!(frag.approvals.is_empty());
1022        assert_eq!(frag.value, U256::ZERO);
1023    }
1024
1025    #[test]
1026    fn plan_remove_liquidity_and_collect_inner_decoded_correctly() {
1027        let p = RemoveAndCollectParams {
1028            token_id: U256::from(42u64),
1029            liquidity: 1_000_000_000_000u128,
1030            amount0_min: Some(U256::from(500_000u64)),
1031            amount1_min: Some(U256::from(1_000_000_000u64)),
1032            recipient: address!("0000000000000000000000000000000000000099"),
1033            token0: address!("0000000000000000000000000000000000000001"),
1034            token1: address!("0000000000000000000000000000000000000002"),
1035            caller: Address::ZERO,
1036            burn: false,
1037        };
1038        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, TEST_CFG.position_mgr);
1039
1040        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata)
1041            .expect("decode outer multicall");
1042        assert_eq!(outer.data.len(), 2);
1043        assert_eq!(&outer.data[0][..4], decreaseLiquidityCall::SELECTOR.as_slice());
1044        assert_eq!(&outer.data[1][..4], collectCall::SELECTOR.as_slice());
1045
1046        let decrease = decreaseLiquidityCall::abi_decode(&outer.data[0]).expect("decode decrease");
1047        assert_eq!(decrease.params.tokenId, p.token_id);
1048        assert_eq!(decrease.params.liquidity, p.liquidity);
1049        assert_eq!(decrease.params.amount0Min, U256::from(500_000u64));
1050        assert_eq!(decrease.params.amount1Min, U256::from(1_000_000_000u64));
1051        assert_eq!(decrease.params.deadline, U256::from(9_999_999_999u64));
1052
1053        let collect = collectCall::abi_decode(&outer.data[1]).expect("decode collect");
1054        assert_eq!(collect.params.tokenId, p.token_id);
1055        assert_eq!(collect.params.recipient, p.recipient);
1056        assert_eq!(collect.params.amount0Max, u128::MAX);
1057        assert_eq!(collect.params.amount1Max, u128::MAX);
1058    }
1059
1060    #[test]
1061    fn plan_remove_liquidity_and_collect_defaults_missing_mins_to_zero() {
1062        let p = RemoveAndCollectParams {
1063            token_id: U256::from(42u64),
1064            liquidity: 1_000_000_000_000u128,
1065            amount0_min: None,
1066            amount1_min: None,
1067            recipient: address!("0000000000000000000000000000000000000099"),
1068            token0: address!("0000000000000000000000000000000000000001"),
1069            token1: address!("0000000000000000000000000000000000000002"),
1070            caller: Address::ZERO,
1071            burn: false,
1072        };
1073        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, TEST_CFG.position_mgr);
1074
1075        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata)
1076            .expect("decode outer multicall");
1077        let decrease = decreaseLiquidityCall::abi_decode(&outer.data[0]).expect("decode decrease");
1078        assert_eq!(decrease.params.amount0Min, U256::ZERO);
1079        assert_eq!(decrease.params.amount1Min, U256::ZERO);
1080    }
1081
1082    #[test]
1083    fn remove_and_collect_appends_burn_when_set() {
1084        let p = RemoveAndCollectParams {
1085            token_id: U256::from(7u64),
1086            liquidity: 1000,
1087            amount0_min: None,
1088            amount1_min: None,
1089            recipient: Address::ZERO,
1090            token0: Address::ZERO,
1091            token1: Address::ZERO,
1092            caller: Address::ZERO,
1093            burn: true,
1094        };
1095        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, Address::ZERO);
1096        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata).unwrap();
1097        assert_eq!(outer.data.len(), 3, "decrease, collect, burn");
1098        assert_eq!(&outer.data[0][..4], decreaseLiquidityCall::SELECTOR.as_slice());
1099        assert_eq!(&outer.data[1][..4], collectCall::SELECTOR.as_slice());
1100        assert_eq!(&outer.data[2][..4], burnCall::SELECTOR.as_slice());
1101        let burn = burnCall::abi_decode(&outer.data[2]).unwrap();
1102        assert_eq!(burn.tokenId, U256::from(7u64));
1103    }
1104
1105    #[test]
1106    fn remove_and_collect_no_burn_when_unset() {
1107        let p = RemoveAndCollectParams {
1108            token_id: U256::from(7u64),
1109            liquidity: 1000,
1110            amount0_min: None,
1111            amount1_min: None,
1112            recipient: Address::ZERO,
1113            token0: Address::ZERO,
1114            token1: Address::ZERO,
1115            caller: Address::ZERO,
1116            burn: false,
1117        };
1118        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, Address::ZERO);
1119        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata).unwrap();
1120        assert_eq!(outer.data.len(), 2, "decrease, collect — no burn");
1121    }
1122
1123    #[test]
1124    fn swap_exact_in_calldata_round_trips_all_fields() {
1125        let token_in = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1126        let token_out = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1127        let s = dummy_pool_state(token_in, token_out); // fee: 3000
1128        let q = Quote {
1129            amount_in: U256::from(1_000_000u64),
1130            amount_out: U256::from(500_000_000_000_000u64),
1131            sqrt_price_x96_after: s.sqrt_price_x96,
1132            price_impact_bps: 0,
1133        };
1134        let recipient = address!("0000000000000000000000000000000000000099");
1135        let p = ExactInParams { token_in, token_out, amount_in: q.amount_in, recipient };
1136        let deadline = 1_700_000_000u64;
1137        let frag = swap_exact_in(
1138            &s,
1139            &q,
1140            &p,
1141            SlippageBps::new(50),
1142            deadline,
1143            TEST_CFG.router,
1144            SwapRouterKind::V1,
1145        );
1146
1147        let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1148        let params = decoded.params;
1149        assert_eq!(params.tokenIn, token_in);
1150        assert_eq!(params.tokenOut, token_out);
1151        assert_eq!(params.fee, alloy_primitives::aliases::U24::from(3000u32));
1152        assert_eq!(params.recipient, recipient);
1153        assert_eq!(params.deadline, U256::from(deadline));
1154        assert_eq!(params.amountIn, q.amount_in);
1155        // amountOutMinimum = amount_out * (10000 - 50) / 10000
1156        let expected_min = q.amount_out * U256::from(9950u64) / U256::from(10000u64);
1157        assert_eq!(params.amountOutMinimum, expected_min);
1158        assert_eq!(params.sqrtPriceLimitX96, alloy_primitives::aliases::U160::ZERO);
1159    }
1160
1161    #[test]
1162    fn add_liquidity_calldata_round_trips_all_fields() {
1163        let p = AddLiquidityParams {
1164            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
1165            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
1166            fee: 3000,
1167            tick_lower: -201_000,
1168            tick_upper: -198_960,
1169            amount0_desired: U256::from(1_000_000u64),
1170            amount1_desired: U256::from(500_000_000_000_000u64),
1171            recipient: address!("0000000000000000000000000000000000000099"),
1172        };
1173        let deadline = 1_700_000_000u64;
1174        let frag = add_liquidity(&p, SlippageBps::new(100), deadline, TEST_CFG.position_mgr);
1175
1176        let decoded = mintCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1177        let mp = decoded.params;
1178        assert_eq!(mp.token0, p.token0);
1179        assert_eq!(mp.token1, p.token1);
1180        assert_eq!(mp.fee, alloy_primitives::aliases::U24::from(3000u32));
1181        assert_eq!(mp.tickLower, alloy_primitives::aliases::I24::try_from(-201_000i32).unwrap());
1182        assert_eq!(mp.tickUpper, alloy_primitives::aliases::I24::try_from(-198_960i32).unwrap());
1183        assert_eq!(mp.amount0Desired, p.amount0_desired);
1184        assert_eq!(mp.amount1Desired, p.amount1_desired);
1185        // amount0Min = amount0_desired * 9900 / 10000 (100 bps = 1%)
1186        let expected_min0 = p.amount0_desired * U256::from(9900u64) / U256::from(10000u64);
1187        assert_eq!(mp.amount0Min, expected_min0);
1188        let expected_min1 = p.amount1_desired * U256::from(9900u64) / U256::from(10000u64);
1189        assert_eq!(mp.amount1Min, expected_min1);
1190        assert_eq!(mp.recipient, p.recipient);
1191        assert_eq!(mp.deadline, U256::from(deadline));
1192    }
1193
1194    #[test]
1195    fn plan_increase_liquidity_targets_position_manager_two_approvals() {
1196        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1197        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1198        let frag = increase_liquidity(
1199            U256::from(123_456u64),
1200            token0,
1201            token1,
1202            U256::from(1_000_000u64),
1203            U256::from(500_000_000_000_000u64),
1204            SlippageBps::new(50),
1205            9_999_999_999,
1206            TEST_CFG.position_mgr,
1207        );
1208        assert_eq!(frag.calls.len(), 1);
1209        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
1210        assert_eq!(frag.calls[0].value, U256::ZERO);
1211        assert_eq!(frag.approvals.len(), 2);
1212        assert_eq!(frag.approvals[0].token, token0);
1213        assert_eq!(frag.approvals[0].spender, TEST_CFG.position_mgr);
1214        assert_eq!(frag.approvals[0].min_amount, U256::from(1_000_000u64));
1215        assert_eq!(frag.approvals[1].token, token1);
1216        assert_eq!(frag.approvals[1].spender, TEST_CFG.position_mgr);
1217        assert_eq!(frag.approvals[1].min_amount, U256::from(500_000_000_000_000u64));
1218        assert_eq!(frag.value, U256::ZERO);
1219    }
1220
1221    #[test]
1222    fn increase_liquidity_calldata_round_trips_all_fields() {
1223        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1224        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1225        let token_id = U256::from(987_654u64);
1226        let amount0 = U256::from(2_000_000u64);
1227        let amount1 = U256::from(750_000_000_000_000u64);
1228        let deadline = 1_700_000_000u64;
1229        let frag = increase_liquidity(
1230            token_id,
1231            token0,
1232            token1,
1233            amount0,
1234            amount1,
1235            SlippageBps::new(100),
1236            deadline,
1237            TEST_CFG.position_mgr,
1238        );
1239
1240        let decoded =
1241            increaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1242        let ip = decoded.params;
1243        assert_eq!(ip.tokenId, token_id);
1244        assert_eq!(ip.amount0Desired, amount0);
1245        assert_eq!(ip.amount1Desired, amount1);
1246        // 100 bps = 1% slippage → amount*Min = amount*Desired * 9900 / 10000.
1247        let expected_min0 = amount0 * U256::from(9900u64) / U256::from(10000u64);
1248        assert_eq!(ip.amount0Min, expected_min0);
1249        let expected_min1 = amount1 * U256::from(9900u64) / U256::from(10000u64);
1250        assert_eq!(ip.amount1Min, expected_min1);
1251        assert_eq!(ip.deadline, U256::from(deadline));
1252    }
1253
1254    /// Locks the `increaseLiquidity` selector against silent ABI drift.
1255    /// Verified vs `cast sig 'increaseLiquidity((uint256,uint256,uint256,uint256,uint256,uint256))'`.
1256    #[test]
1257    fn increase_liquidity_selector_matches_v3_canonical() {
1258        assert_eq!(increaseLiquidityCall::SELECTOR, [0x21, 0x9f, 0x5d, 0x17]);
1259    }
1260
1261    #[test]
1262    fn swap_exact_in_with_fee_fn_injects_fee_into_calldata() {
1263        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1264        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1265        let s = dummy_pool_state(token_in, token_out); // fee: 3000 in the fixture
1266        let q = Quote {
1267            amount_in: U256::from(1_000_000u64),
1268            amount_out: U256::from(500_000_000_000_000u64),
1269            sqrt_price_x96_after: s.sqrt_price_x96,
1270            price_impact_bps: 0,
1271        };
1272        let p = ExactInParams {
1273            token_in,
1274            token_out,
1275            amount_in: q.amount_in,
1276            recipient: address!("0x0000000000000000000000000000000000000099"),
1277        };
1278        // Force fee to 500 (the 0.05% tier) via injection.
1279        let frag = swap_exact_in_with_fee_fn(
1280            &s,
1281            &q,
1282            &p,
1283            SlippageBps::new(50),
1284            9_999_999_999,
1285            TEST_CFG.router,
1286            SwapRouterKind::V1,
1287            |_| 500,
1288        );
1289        // Decode the emitted calldata and assert the fee field survived.
1290        let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1291        assert_eq!(decoded.params.fee, alloy_primitives::aliases::U24::from(500u32));
1292    }
1293
1294    // ── Issue #6: slippage boundary tests ────────────────────────────────────
1295
1296    #[test]
1297    fn slippage_min_zero_bps_returns_full_amount() {
1298        // 0 bps = no slippage tolerance: the minimum acceptable output
1299        // equals the full quoted amount.
1300        let out = apply_slippage_min(U256::from(1_000_000u64), SlippageBps::new(0));
1301        assert_eq!(out, U256::from(1_000_000u64));
1302    }
1303
1304    #[test]
1305    fn slippage_min_10000_bps_returns_zero() {
1306        // 10_000 bps = 100% slippage: caller accepts any output including
1307        // zero. The formula: 1_000_000 * (10_000 - 10_000) / 10_000 = 0.
1308        let out = apply_slippage_min(U256::from(1_000_000u64), SlippageBps::new(10_000));
1309        assert_eq!(out, U256::ZERO);
1310    }
1311
1312    #[test]
1313    fn slippage_min_zero_quoted_returns_zero() {
1314        // Quoted amount of zero produces zero regardless of slippage bps.
1315        // No divide-by-zero risk because division is over denom (10_000),
1316        // not the quoted amount.
1317        let out = apply_slippage_min(U256::ZERO, SlippageBps::new(50));
1318        assert_eq!(out, U256::ZERO);
1319    }
1320
1321    #[test]
1322    fn slippage_min_max_u256_does_not_overflow() {
1323        // U256::MAX * 9950 / 10000 must not panic. U256 arithmetic is
1324        // checked by default in debug builds via overflow assertions, but
1325        // here we use wrapping multiplication via the U256 primitive.
1326        // The intermediary `U256::MAX * 9950` overflows u256 if done
1327        // naively; the implementation must use U256 arithmetic which
1328        // naturally wraps at 2^256. Verify the result is in (0, MAX).
1329        let out = apply_slippage_min(U256::MAX, SlippageBps::new(50));
1330        // Expected: U256::MAX * 9950 / 10000. Since U256::MAX * 9950
1331        // overflows mod 2^256, the result will be large but < U256::MAX.
1332        assert!(out > U256::ZERO, "expected nonzero result for MAX input");
1333        assert!(out < U256::MAX, "expected result < MAX (slippage was applied)");
1334    }
1335
1336    #[test]
1337    fn burn_selector_is_known() {
1338        assert_eq!(burnCall::SELECTOR, [0x42, 0x96, 0x6c, 0x68]); // burn(uint256)
1339    }
1340
1341    #[test]
1342    fn add_liquidity_with_min_encodes_supplied_mins() {
1343        let p = AddLiquidityParams {
1344            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
1345            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
1346            fee: 3000,
1347            tick_lower: -201_000,
1348            tick_upper: -198_960,
1349            amount0_desired: U256::from(1_000_000u64),
1350            amount1_desired: U256::from(500_000_000_000_000u64),
1351            recipient: address!("0000000000000000000000000000000000000099"),
1352        };
1353        let m0 = U256::from(123_456u64);
1354        let m1 = U256::from(789_012u64);
1355        let frag = add_liquidity_with_min(&p, m0, m1, 9_999_999_999, TEST_CFG.position_mgr);
1356        let decoded = mintCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1357        assert_eq!(decoded.params.amount0Min, m0);
1358        assert_eq!(decoded.params.amount1Min, m1);
1359    }
1360
1361    #[test]
1362    fn add_liquidity_delegates_to_with_min_byte_identical() {
1363        let p = AddLiquidityParams {
1364            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
1365            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
1366            fee: 3000,
1367            tick_lower: -201_000,
1368            tick_upper: -198_960,
1369            amount0_desired: U256::from(1_000_000u64),
1370            amount1_desired: U256::from(500_000_000_000_000u64),
1371            recipient: address!("0000000000000000000000000000000000000099"),
1372        };
1373        let slip = SlippageBps::new(50);
1374        let dl = 9_999_999_999u64;
1375        let pm = TEST_CFG.position_mgr;
1376        let via_slippage = add_liquidity(&p, slip, dl, pm);
1377        let via_min = add_liquidity_with_min(
1378            &p,
1379            apply_slippage_min(p.amount0_desired, slip),
1380            apply_slippage_min(p.amount1_desired, slip),
1381            dl,
1382            pm,
1383        );
1384        assert_eq!(via_slippage, via_min);
1385    }
1386
1387    #[test]
1388    fn increase_liquidity_with_min_encodes_supplied_mins() {
1389        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1390        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1391        let m0 = U256::from(123_456u64);
1392        let m1 = U256::from(789_012u64);
1393        let frag = increase_liquidity_with_min(
1394            U256::from(987_654u64),
1395            token0,
1396            token1,
1397            U256::from(2_000_000u64),
1398            U256::from(750_000_000_000_000u64),
1399            m0,
1400            m1,
1401            9_999_999_999,
1402            TEST_CFG.position_mgr,
1403        );
1404        let decoded =
1405            increaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1406        assert_eq!(decoded.params.amount0Min, m0);
1407        assert_eq!(decoded.params.amount1Min, m1);
1408    }
1409
1410    #[test]
1411    fn increase_liquidity_delegates_to_with_min_byte_identical() {
1412        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1413        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1414        let token_id = U256::from(987_654u64);
1415        let amount0 = U256::from(2_000_000u64);
1416        let amount1 = U256::from(750_000_000_000_000u64);
1417        let slip = SlippageBps::new(100);
1418        let dl = 1_700_000_000u64;
1419        let pm = TEST_CFG.position_mgr;
1420        let via_slippage =
1421            increase_liquidity(token_id, token0, token1, amount0, amount1, slip, dl, pm);
1422        let via_min = increase_liquidity_with_min(
1423            token_id,
1424            token0,
1425            token1,
1426            amount0,
1427            amount1,
1428            apply_slippage_min(amount0, slip),
1429            apply_slippage_min(amount1, slip),
1430            dl,
1431            pm,
1432        );
1433        assert_eq!(via_slippage, via_min);
1434    }
1435}