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    let mint_params = MintParams {
344        token0: params.token0,
345        token1: params.token1,
346        fee: alloy_primitives::aliases::U24::from(params.fee),
347        tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
348            .expect("tick_lower within i24 range"),
349        tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
350            .expect("tick_upper within i24 range"),
351        amount0Desired: params.amount0_desired,
352        amount1Desired: params.amount1_desired,
353        amount0Min: apply_slippage_min(params.amount0_desired, slippage),
354        amount1Min: apply_slippage_min(params.amount1_desired, slippage),
355        recipient: params.recipient,
356        deadline: U256::from(deadline),
357    };
358    let calldata = mintCall { params: mint_params }.abi_encode().into();
359    PlanFragment {
360        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
361        approvals: vec![
362            TokenApproval {
363                token: params.token0,
364                spender: position_manager,
365                min_amount: params.amount0_desired,
366            },
367            TokenApproval {
368                token: params.token1,
369                spender: position_manager,
370                min_amount: params.amount1_desired,
371            },
372        ],
373        value: U256::ZERO,
374    }
375}
376
377/// Build an `increaseLiquidity` plan fragment for an existing v3 NFT
378/// position. Requires `tokenId` to refer to a position the caller (or
379/// recipient of approval) already owns.
380///
381/// `token0` / `token1` are needed for the approval declaration and must
382/// match the position's stored pair (the function does not validate this
383/// — caller's responsibility, typically by hydrating the position first).
384///
385/// Slippage applies to the desired amounts via `apply_slippage_min`,
386/// matching `add_liquidity`'s shape.
387///
388/// Approvals: token0 + token1 to NFPM (same as mint).
389#[allow(
390    clippy::too_many_arguments,
391    reason = "public planner API mirrors NFPM increaseLiquidity parameters"
392)]
393pub fn increase_liquidity(
394    token_id: U256,
395    token0: Address,
396    token1: Address,
397    amount0_desired: U256,
398    amount1_desired: U256,
399    slippage: SlippageBps,
400    deadline: u64,
401    position_manager: Address,
402) -> PlanFragment {
403    let inc_params = IncreaseLiquidityParams {
404        tokenId: token_id,
405        amount0Desired: amount0_desired,
406        amount1Desired: amount1_desired,
407        amount0Min: apply_slippage_min(amount0_desired, slippage),
408        amount1Min: apply_slippage_min(amount1_desired, slippage),
409        deadline: U256::from(deadline),
410    };
411    let calldata = increaseLiquidityCall { params: inc_params }.abi_encode().into();
412    PlanFragment {
413        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
414        approvals: vec![
415            TokenApproval { token: token0, spender: position_manager, min_amount: amount0_desired },
416            TokenApproval { token: token1, spender: position_manager, min_amount: amount1_desired },
417        ],
418        value: U256::ZERO,
419    }
420}
421
422/// Build a decreaseLiquidity plan fragment for a v3 NFT position.
423///
424/// Slippage protection is the caller's responsibility: supply precomputed
425/// `amount0_min` / `amount1_min` in `RemoveLiquidityParams` (typically
426/// derived from a quote against the current pool state minus the desired
427/// slippage tolerance). Passing `None` for both = zero protection — only
428/// safe for private mempools.
429///
430/// No approvals needed — the position manager operates the NFT it minted.
431pub fn remove_liquidity(
432    params: &RemoveLiquidityParams,
433    deadline: u64,
434    position_manager: Address,
435) -> PlanFragment {
436    let dec = DecreaseLiquidityParams {
437        tokenId: params.token_id,
438        liquidity: params.liquidity,
439        amount0Min: params.amount0_min.unwrap_or(U256::ZERO),
440        amount1Min: params.amount1_min.unwrap_or(U256::ZERO),
441        deadline: U256::from(deadline),
442    };
443    let calldata = decreaseLiquidityCall { params: dec }.abi_encode().into();
444    PlanFragment {
445        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
446        approvals: vec![],
447        value: U256::ZERO,
448    }
449}
450
451/// Build an atomic `multicall(decreaseLiquidity, collect)` plan
452/// fragment for a V3 NFT position.
453///
454/// `deadline` threads through `decreaseLiquidity`'s deadline param.
455/// `collect`'s `amount0Max` / `amount1Max` are set to `u128::MAX`
456/// (collect everything in `tokens_owed`).
457///
458/// This helper ALWAYS emits multicall — even for non-native recipient.
459/// Callers who want one-or-the-other should use `remove_liquidity` or
460/// `collect_fees` directly.
461///
462/// **Native unwrap is NOT done here** — this helper is pure encoding.
463/// Facade-layer `plan_remove_liquidity_and_collect` composes
464/// `unwrapWETH9` + `sweepToken` tails on top via the v3-provider
465/// resolver (`resolve_native_wrap_remove_and_collect`).
466///
467/// **Architectural note**: Placing `remove_liquidity_and_collect` in
468/// v3-core (not v3-provider) is intentional. Every existing v3-core
469/// helper (`swap_exact_in`, `add_liquidity`, `remove_liquidity`,
470/// `collect_fees`) emits a single call; this is the first
471/// multicall-emitting v3-core helper. The (`decreaseLiquidity`,
472/// `collect`) composition is family-wide pure encoding (no slippage /
473/// native / chain logic), not facade-layer logic — so v3-core is the
474/// correct home. Future composed primitives (e.g.
475/// `mint_and_initialize_pool` for V3) would follow the same precedent.
476/// Do **not** "migrate" this to v3-provider in a future consistency PR —
477/// the encoding boundary is preserved.
478pub fn remove_liquidity_and_collect(
479    params: &RemoveAndCollectParams,
480    deadline: u64,
481    position_manager: Address,
482) -> PlanFragment {
483    // decreaseLiquidity calldata
484    let dec_params = DecreaseLiquidityParams {
485        tokenId: params.token_id,
486        liquidity: params.liquidity,
487        amount0Min: params.amount0_min.unwrap_or(U256::ZERO),
488        amount1Min: params.amount1_min.unwrap_or(U256::ZERO),
489        deadline: U256::from(deadline),
490    };
491    let decrease_calldata: alloy_primitives::Bytes =
492        decreaseLiquidityCall { params: dec_params }.abi_encode().into();
493
494    // collect calldata (recipient = params.recipient; amount caps = u128::MAX)
495    let collect_params = CollectParams {
496        tokenId: params.token_id,
497        recipient: params.recipient,
498        amount0Max: u128::MAX,
499        amount1Max: u128::MAX,
500    };
501    let collect_calldata: alloy_primitives::Bytes =
502        collectCall { params: collect_params }.abi_encode().into();
503
504    // outer multicall (NFPM inherits Multicall)
505    let mut multicall_data = vec![decrease_calldata, collect_calldata];
506    if params.burn {
507        let burn_calldata: alloy_primitives::Bytes =
508            burnCall { tokenId: params.token_id }.abi_encode().into();
509        multicall_data.push(burn_calldata);
510    }
511    let multicall_calldata =
512        IPeripheryRouter::multicallCall { data: multicall_data }.abi_encode().into();
513
514    PlanFragment {
515        calls: vec![Call {
516            target: position_manager,
517            value: U256::ZERO,
518            calldata: multicall_calldata,
519        }],
520        approvals: vec![],
521        value: U256::ZERO,
522    }
523}
524
525/// Build a v3 position-manager `collect` plan fragment (collect accrued fees).
526///
527/// Uses `u128::MAX` for both `amount0Max` and `amount1Max` — the standard
528/// "collect everything" sentinel in the Uniswap V3 position manager.
529pub fn collect_fees(params: &CollectFeesParams, position_manager: Address) -> PlanFragment {
530    let coll = CollectParams {
531        tokenId: params.token_id,
532        recipient: params.recipient,
533        amount0Max: u128::MAX,
534        amount1Max: u128::MAX,
535    };
536    let calldata = collectCall { params: coll }.abi_encode().into();
537    PlanFragment {
538        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
539        approvals: vec![],
540        value: U256::ZERO,
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::data::{
548        AddLiquidityParams, CollectFeesParams, RemoveAndCollectParams, RemoveLiquidityParams,
549        SwapRouterKind, V3ProtocolConfig,
550    };
551    use alloy_primitives::{address, b256, Address};
552
553    const TEST_CFG: V3ProtocolConfig = V3ProtocolConfig {
554        factory: address!("0x1F98431c8aD98523631AE4a59f267346ea31F984"),
555        pool_deployer: None,
556        router: address!("0xE592427A0AEce92De3Edee1F18E0157C05861564"),
557        swap_router_kind: SwapRouterKind::V1,
558        position_mgr: address!("0xC36442b4a4522E871399CD717aBDD847Ab11FE88"),
559        init_code_hash: b256!("0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54"),
560        fee_tiers: &[100, 500, 3000, 10000],
561        multicall: address!("0xcA11bde05977b3631167028862bE2a173976CA11"),
562        quoter: None,
563    };
564
565    fn dummy_pool_state(t0: Address, t1: Address) -> PoolState {
566        PoolState {
567            token0: t0,
568            token1: t1,
569            fee: 3000,
570            tick_spacing: 60,
571            sqrt_price_x96: U256::from(1u64) << 96,
572            liquidity: 0,
573            tick: 0,
574            ticks: vec![],
575        }
576    }
577
578    fn fixture_exact_in_params() -> ExactInParams {
579        ExactInParams {
580            token_in: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
581            token_out: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
582            amount_in: U256::from(1_000_000u64),
583            recipient: address!("0000000000000000000000000000000000000099"),
584        }
585    }
586
587    fn fixture_exact_out_params() -> ExactOutParams {
588        ExactOutParams {
589            token_in: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
590            token_out: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
591            amount_out: U256::from(500_000_000_000_000u64),
592            recipient: address!("0000000000000000000000000000000000000099"),
593        }
594    }
595
596    fn fixture_quote(state: &PoolState) -> Quote {
597        Quote {
598            amount_in: U256::from(1_000_000u64),
599            amount_out: U256::from(500_000_000_000_000u64),
600            sqrt_price_x96_after: state.sqrt_price_x96,
601            price_impact_bps: 0,
602        }
603    }
604
605    #[test]
606    fn plan_swap_emits_one_call_with_router_target() {
607        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
608        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
609        let s = dummy_pool_state(token_in, token_out);
610        let q = Quote {
611            amount_in: U256::from(1_000_000u64),
612            amount_out: U256::from(500_000_000_000_000u64),
613            sqrt_price_x96_after: s.sqrt_price_x96,
614            price_impact_bps: 0,
615        };
616        let p = ExactInParams {
617            token_in,
618            token_out,
619            amount_in: q.amount_in,
620            recipient: address!("0x0000000000000000000000000000000000000099"),
621        };
622        let frag = swap_exact_in(
623            &s,
624            &q,
625            &p,
626            SlippageBps::new(50),
627            9_999_999_999,
628            TEST_CFG.router,
629            SwapRouterKind::V1,
630        );
631        assert_eq!(frag.calls.len(), 1);
632        assert_eq!(frag.calls[0].target, TEST_CFG.router);
633        assert_eq!(frag.approvals.len(), 1);
634        assert_eq!(frag.approvals[0].token, token_in);
635        assert_eq!(frag.approvals[0].spender, TEST_CFG.router);
636        assert_eq!(frag.approvals[0].min_amount, q.amount_in);
637        assert_eq!(frag.value, U256::ZERO);
638    }
639
640    #[test]
641    fn swap_exact_in_v1_emits_v1_selector() {
642        let params = fixture_exact_in_params();
643        let state = dummy_pool_state(params.token_in, params.token_out);
644        let quote = fixture_quote(&state);
645        let plan = swap_exact_in(
646            &state,
647            &quote,
648            &params,
649            SlippageBps::new(50),
650            0,
651            TEST_CFG.router,
652            SwapRouterKind::V1,
653        );
654        assert_eq!(
655            &plan.calls[0].calldata[..4],
656            &[0x41, 0x4b, 0xf3, 0x89],
657            "V1 selector must be 0x414bf389"
658        );
659    }
660
661    #[test]
662    fn swap_exact_in_v02_emits_v02_selector() {
663        let params = fixture_exact_in_params();
664        let state = dummy_pool_state(params.token_in, params.token_out);
665        let quote = fixture_quote(&state);
666        let plan = swap_exact_in(
667            &state,
668            &quote,
669            &params,
670            SlippageBps::new(50),
671            0,
672            TEST_CFG.router,
673            SwapRouterKind::V02,
674        );
675        assert_eq!(
676            &plan.calls[0].calldata[..4],
677            &[0x04, 0xe4, 0x5a, 0xaf],
678            "V02 selector must be 0x04e45aaf"
679        );
680    }
681
682    #[test]
683    fn swap_exact_in_v1_includes_deadline() {
684        let params = fixture_exact_in_params();
685        let state = dummy_pool_state(params.token_in, params.token_out);
686        let quote = fixture_quote(&state);
687        let v1_plan = swap_exact_in(
688            &state,
689            &quote,
690            &params,
691            SlippageBps::new(50),
692            1_700_000_000,
693            TEST_CFG.router,
694            SwapRouterKind::V1,
695        );
696        let v02_plan = swap_exact_in(
697            &state,
698            &quote,
699            &params,
700            SlippageBps::new(50),
701            1_700_000_000,
702            TEST_CFG.router,
703            SwapRouterKind::V02,
704        );
705        assert_eq!(
706            v1_plan.calls[0].calldata.len(),
707            v02_plan.calls[0].calldata.len() + 32,
708            "V1 calldata must be 32 bytes longer than V02 (deadline field)"
709        );
710    }
711
712    #[test]
713    fn swap_exact_out_v1_emits_v1_selector() {
714        let params = fixture_exact_out_params();
715        let state = dummy_pool_state(params.token_in, params.token_out);
716        let quote = fixture_quote(&state);
717        let plan = swap_exact_out(
718            &state,
719            &quote,
720            &params,
721            SlippageBps::new(50),
722            0,
723            TEST_CFG.router,
724            SwapRouterKind::V1,
725        );
726        assert_eq!(
727            &plan.calls[0].calldata[..4],
728            &[0xdb, 0x3e, 0x21, 0x98],
729            "V1 selector must be 0xdb3e2198"
730        );
731    }
732
733    #[test]
734    fn swap_exact_out_v02_emits_v02_selector() {
735        let params = fixture_exact_out_params();
736        let state = dummy_pool_state(params.token_in, params.token_out);
737        let quote = fixture_quote(&state);
738        let plan = swap_exact_out(
739            &state,
740            &quote,
741            &params,
742            SlippageBps::new(50),
743            0,
744            TEST_CFG.router,
745            SwapRouterKind::V02,
746        );
747        assert_eq!(
748            &plan.calls[0].calldata[..4],
749            &[0x50, 0x23, 0xb4, 0xdf],
750            "V02 selector must be 0x5023b4df"
751        );
752    }
753
754    #[test]
755    fn swap_exact_out_v1_includes_deadline() {
756        let params = fixture_exact_out_params();
757        let state = dummy_pool_state(params.token_in, params.token_out);
758        let quote = fixture_quote(&state);
759        let v1_plan = swap_exact_out(
760            &state,
761            &quote,
762            &params,
763            SlippageBps::new(50),
764            1_700_000_000,
765            TEST_CFG.router,
766            SwapRouterKind::V1,
767        );
768        let v02_plan = swap_exact_out(
769            &state,
770            &quote,
771            &params,
772            SlippageBps::new(50),
773            1_700_000_000,
774            TEST_CFG.router,
775            SwapRouterKind::V02,
776        );
777        assert_eq!(
778            v1_plan.calls[0].calldata.len(),
779            v02_plan.calls[0].calldata.len() + 32,
780            "V1 calldata must be 32 bytes longer than V02 (deadline field)"
781        );
782    }
783
784    #[test]
785    fn swap_exact_out_approval_min_amount_is_amount_in_max() {
786        let params = fixture_exact_out_params();
787        let state = dummy_pool_state(params.token_in, params.token_out);
788        let quote = Quote {
789            amount_in: U256::from(1_000_000u64),
790            amount_out: params.amount_out,
791            sqrt_price_x96_after: state.sqrt_price_x96,
792            price_impact_bps: 0,
793        };
794        let plan = swap_exact_out(
795            &state,
796            &quote,
797            &params,
798            SlippageBps::new(50),
799            0,
800            TEST_CFG.router,
801            SwapRouterKind::V1,
802        );
803        assert_eq!(
804            plan.approvals[0].min_amount,
805            U256::from(1_005_000u64),
806            "approval min_amount must equal amount_in_max (worst case)"
807        );
808    }
809
810    #[test]
811    fn slippage_min_50bps_on_1eth() {
812        let out =
813            apply_slippage_min(U256::from(1_000_000_000_000_000_000u64), SlippageBps::new(50));
814        // 1e18 * 9950 / 10000 = 9.95e17
815        assert_eq!(out, U256::from(995_000_000_000_000_000u64));
816    }
817
818    #[test]
819    fn slippage_max_50bps_on_1eth() {
820        let out =
821            apply_slippage_max(U256::from(1_000_000_000_000_000_000u64), SlippageBps::new(50));
822        assert_eq!(out, U256::from(1_005_000_000_000_000_000u64));
823    }
824
825    #[test]
826    fn plan_add_liquidity_targets_position_manager() {
827        let p = AddLiquidityParams {
828            token0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
829            token1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
830            fee: 3000,
831            tick_lower: -201_000,
832            tick_upper: -198_960,
833            amount0_desired: U256::from(1_000_000u64),
834            amount1_desired: U256::from(500_000_000_000_000u64),
835            recipient: address!("0x0000000000000000000000000000000000000099"),
836        };
837        let frag = add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, TEST_CFG.position_mgr);
838        assert_eq!(frag.calls.len(), 1);
839        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
840        assert_eq!(frag.approvals.len(), 2);
841        assert_eq!(frag.approvals[0].token, p.token0);
842        assert_eq!(frag.approvals[0].spender, TEST_CFG.position_mgr);
843        assert_eq!(frag.approvals[1].token, p.token1);
844        assert_eq!(frag.approvals[1].spender, TEST_CFG.position_mgr);
845        assert_eq!(frag.value, U256::ZERO);
846    }
847
848    #[test]
849    fn plan_remove_liquidity_targets_position_manager_no_approvals() {
850        let p = RemoveLiquidityParams {
851            token_id: U256::from(42u64),
852            liquidity: 1_000_000_000_000u128,
853            amount0_min: None,
854            amount1_min: None,
855        };
856        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
857        assert_eq!(frag.calls.len(), 1);
858        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
859        assert!(frag.approvals.is_empty());
860        assert_eq!(frag.value, U256::ZERO);
861    }
862
863    #[test]
864    fn plan_remove_liquidity_passes_min_amounts_when_supplied() {
865        let p = RemoveLiquidityParams {
866            token_id: U256::from(42u64),
867            liquidity: 1_000_000_000_000u128,
868            amount0_min: Some(U256::from(500_000u64)),
869            amount1_min: Some(U256::from(1_000_000_000u64)),
870        };
871        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
872        // Decode the emitted calldata and assert the mins survived
873        let decoded =
874            decreaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
875        assert_eq!(decoded.params.amount0Min, U256::from(500_000u64));
876        assert_eq!(decoded.params.amount1Min, U256::from(1_000_000_000u64));
877    }
878
879    #[test]
880    fn plan_remove_liquidity_defaults_missing_mins_to_zero() {
881        let p = RemoveLiquidityParams {
882            token_id: U256::from(42u64),
883            liquidity: 1_000_000_000_000u128,
884            amount0_min: None,
885            amount1_min: None,
886        };
887        let frag = remove_liquidity(&p, 9_999_999_999, TEST_CFG.position_mgr);
888        let decoded =
889            decreaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
890        assert_eq!(decoded.params.amount0Min, U256::ZERO);
891        assert_eq!(decoded.params.amount1Min, U256::ZERO);
892    }
893
894    #[test]
895    fn slippage_min_50bps_on_100k_weth() {
896        // 100k WETH = 1e23 wei — well above u64::MAX.
897        // Value must fit in U256 and the helper must not truncate.
898        let quoted: U256 = U256::from(10u64).pow(U256::from(23u64));
899        let out = apply_slippage_min(quoted, SlippageBps::new(50));
900        let expected: U256 = quoted * U256::from(9950u64) / U256::from(10_000u64);
901        assert_eq!(out, expected);
902        // Also sanity check that we're actually in the range claimed
903        assert!(out > U256::from(u64::MAX));
904    }
905
906    #[test]
907    fn plan_collect_fees_targets_position_manager_no_approvals() {
908        let p = CollectFeesParams {
909            token_id: U256::from(42u64),
910            recipient: address!("0x0000000000000000000000000000000000000099"),
911            token0: address!("0x0000000000000000000000000000000000000001"),
912            token1: address!("0x0000000000000000000000000000000000000002"),
913            caller: Address::ZERO,
914        };
915        let frag = collect_fees(&p, TEST_CFG.position_mgr);
916        assert_eq!(frag.calls.len(), 1);
917        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
918        assert!(frag.approvals.is_empty());
919        assert_eq!(frag.value, U256::ZERO);
920    }
921
922    #[test]
923    fn plan_collect_fees_calldata_round_trips_all_fields() {
924        let p = CollectFeesParams {
925            token_id: U256::from(42u64),
926            recipient: address!("0000000000000000000000000000000000000099"),
927            token0: address!("0000000000000000000000000000000000000001"),
928            token1: address!("0000000000000000000000000000000000000002"),
929            caller: Address::ZERO,
930        };
931        let frag = collect_fees(&p, TEST_CFG.position_mgr);
932
933        let decoded = collectCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
934        assert_eq!(decoded.params.tokenId, p.token_id);
935        assert_eq!(decoded.params.recipient, p.recipient);
936        assert_eq!(decoded.params.amount0Max, u128::MAX);
937        assert_eq!(decoded.params.amount1Max, u128::MAX);
938    }
939
940    #[test]
941    fn plan_remove_liquidity_and_collect_targets_nfpm_with_multicall_outer() {
942        let p = RemoveAndCollectParams {
943            token_id: U256::from(1u64),
944            liquidity: 1000u128,
945            amount0_min: Some(U256::from(99u64)),
946            amount1_min: Some(U256::from(199u64)),
947            recipient: address!("0000000000000000000000000000000000000099"),
948            token0: address!("0000000000000000000000000000000000000001"),
949            token1: address!("0000000000000000000000000000000000000002"),
950            caller: Address::ZERO,
951            burn: false,
952        };
953        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, TEST_CFG.position_mgr);
954        assert_eq!(frag.calls.len(), 1);
955        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
956        assert_eq!(
957            &frag.calls[0].calldata[..4],
958            &[0xac, 0x96, 0x50, 0xd8],
959            "outer call selector must be multicall"
960        );
961        assert!(frag.approvals.is_empty());
962        assert_eq!(frag.value, U256::ZERO);
963    }
964
965    #[test]
966    fn plan_remove_liquidity_and_collect_inner_decoded_correctly() {
967        let p = RemoveAndCollectParams {
968            token_id: U256::from(42u64),
969            liquidity: 1_000_000_000_000u128,
970            amount0_min: Some(U256::from(500_000u64)),
971            amount1_min: Some(U256::from(1_000_000_000u64)),
972            recipient: address!("0000000000000000000000000000000000000099"),
973            token0: address!("0000000000000000000000000000000000000001"),
974            token1: address!("0000000000000000000000000000000000000002"),
975            caller: Address::ZERO,
976            burn: false,
977        };
978        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, TEST_CFG.position_mgr);
979
980        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata)
981            .expect("decode outer multicall");
982        assert_eq!(outer.data.len(), 2);
983        assert_eq!(&outer.data[0][..4], decreaseLiquidityCall::SELECTOR.as_slice());
984        assert_eq!(&outer.data[1][..4], collectCall::SELECTOR.as_slice());
985
986        let decrease = decreaseLiquidityCall::abi_decode(&outer.data[0]).expect("decode decrease");
987        assert_eq!(decrease.params.tokenId, p.token_id);
988        assert_eq!(decrease.params.liquidity, p.liquidity);
989        assert_eq!(decrease.params.amount0Min, U256::from(500_000u64));
990        assert_eq!(decrease.params.amount1Min, U256::from(1_000_000_000u64));
991        assert_eq!(decrease.params.deadline, U256::from(9_999_999_999u64));
992
993        let collect = collectCall::abi_decode(&outer.data[1]).expect("decode collect");
994        assert_eq!(collect.params.tokenId, p.token_id);
995        assert_eq!(collect.params.recipient, p.recipient);
996        assert_eq!(collect.params.amount0Max, u128::MAX);
997        assert_eq!(collect.params.amount1Max, u128::MAX);
998    }
999
1000    #[test]
1001    fn plan_remove_liquidity_and_collect_defaults_missing_mins_to_zero() {
1002        let p = RemoveAndCollectParams {
1003            token_id: U256::from(42u64),
1004            liquidity: 1_000_000_000_000u128,
1005            amount0_min: None,
1006            amount1_min: None,
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
1015        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata)
1016            .expect("decode outer multicall");
1017        let decrease = decreaseLiquidityCall::abi_decode(&outer.data[0]).expect("decode decrease");
1018        assert_eq!(decrease.params.amount0Min, U256::ZERO);
1019        assert_eq!(decrease.params.amount1Min, U256::ZERO);
1020    }
1021
1022    #[test]
1023    fn remove_and_collect_appends_burn_when_set() {
1024        let p = RemoveAndCollectParams {
1025            token_id: U256::from(7u64),
1026            liquidity: 1000,
1027            amount0_min: None,
1028            amount1_min: None,
1029            recipient: Address::ZERO,
1030            token0: Address::ZERO,
1031            token1: Address::ZERO,
1032            caller: Address::ZERO,
1033            burn: true,
1034        };
1035        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, Address::ZERO);
1036        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata).unwrap();
1037        assert_eq!(outer.data.len(), 3, "decrease, collect, burn");
1038        assert_eq!(&outer.data[0][..4], decreaseLiquidityCall::SELECTOR.as_slice());
1039        assert_eq!(&outer.data[1][..4], collectCall::SELECTOR.as_slice());
1040        assert_eq!(&outer.data[2][..4], burnCall::SELECTOR.as_slice());
1041        let burn = burnCall::abi_decode(&outer.data[2]).unwrap();
1042        assert_eq!(burn.tokenId, U256::from(7u64));
1043    }
1044
1045    #[test]
1046    fn remove_and_collect_no_burn_when_unset() {
1047        let p = RemoveAndCollectParams {
1048            token_id: U256::from(7u64),
1049            liquidity: 1000,
1050            amount0_min: None,
1051            amount1_min: None,
1052            recipient: Address::ZERO,
1053            token0: Address::ZERO,
1054            token1: Address::ZERO,
1055            caller: Address::ZERO,
1056            burn: false,
1057        };
1058        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, Address::ZERO);
1059        let outer = IPeripheryRouter::multicallCall::abi_decode(&frag.calls[0].calldata).unwrap();
1060        assert_eq!(outer.data.len(), 2, "decrease, collect — no burn");
1061    }
1062
1063    #[test]
1064    fn swap_exact_in_calldata_round_trips_all_fields() {
1065        let token_in = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1066        let token_out = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1067        let s = dummy_pool_state(token_in, token_out); // fee: 3000
1068        let q = Quote {
1069            amount_in: U256::from(1_000_000u64),
1070            amount_out: U256::from(500_000_000_000_000u64),
1071            sqrt_price_x96_after: s.sqrt_price_x96,
1072            price_impact_bps: 0,
1073        };
1074        let recipient = address!("0000000000000000000000000000000000000099");
1075        let p = ExactInParams { token_in, token_out, amount_in: q.amount_in, recipient };
1076        let deadline = 1_700_000_000u64;
1077        let frag = swap_exact_in(
1078            &s,
1079            &q,
1080            &p,
1081            SlippageBps::new(50),
1082            deadline,
1083            TEST_CFG.router,
1084            SwapRouterKind::V1,
1085        );
1086
1087        let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1088        let params = decoded.params;
1089        assert_eq!(params.tokenIn, token_in);
1090        assert_eq!(params.tokenOut, token_out);
1091        assert_eq!(params.fee, alloy_primitives::aliases::U24::from(3000u32));
1092        assert_eq!(params.recipient, recipient);
1093        assert_eq!(params.deadline, U256::from(deadline));
1094        assert_eq!(params.amountIn, q.amount_in);
1095        // amountOutMinimum = amount_out * (10000 - 50) / 10000
1096        let expected_min = q.amount_out * U256::from(9950u64) / U256::from(10000u64);
1097        assert_eq!(params.amountOutMinimum, expected_min);
1098        assert_eq!(params.sqrtPriceLimitX96, alloy_primitives::aliases::U160::ZERO);
1099    }
1100
1101    #[test]
1102    fn add_liquidity_calldata_round_trips_all_fields() {
1103        let p = AddLiquidityParams {
1104            token0: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
1105            token1: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
1106            fee: 3000,
1107            tick_lower: -201_000,
1108            tick_upper: -198_960,
1109            amount0_desired: U256::from(1_000_000u64),
1110            amount1_desired: U256::from(500_000_000_000_000u64),
1111            recipient: address!("0000000000000000000000000000000000000099"),
1112        };
1113        let deadline = 1_700_000_000u64;
1114        let frag = add_liquidity(&p, SlippageBps::new(100), deadline, TEST_CFG.position_mgr);
1115
1116        let decoded = mintCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1117        let mp = decoded.params;
1118        assert_eq!(mp.token0, p.token0);
1119        assert_eq!(mp.token1, p.token1);
1120        assert_eq!(mp.fee, alloy_primitives::aliases::U24::from(3000u32));
1121        assert_eq!(mp.tickLower, alloy_primitives::aliases::I24::try_from(-201_000i32).unwrap());
1122        assert_eq!(mp.tickUpper, alloy_primitives::aliases::I24::try_from(-198_960i32).unwrap());
1123        assert_eq!(mp.amount0Desired, p.amount0_desired);
1124        assert_eq!(mp.amount1Desired, p.amount1_desired);
1125        // amount0Min = amount0_desired * 9900 / 10000 (100 bps = 1%)
1126        let expected_min0 = p.amount0_desired * U256::from(9900u64) / U256::from(10000u64);
1127        assert_eq!(mp.amount0Min, expected_min0);
1128        let expected_min1 = p.amount1_desired * U256::from(9900u64) / U256::from(10000u64);
1129        assert_eq!(mp.amount1Min, expected_min1);
1130        assert_eq!(mp.recipient, p.recipient);
1131        assert_eq!(mp.deadline, U256::from(deadline));
1132    }
1133
1134    #[test]
1135    fn plan_increase_liquidity_targets_position_manager_two_approvals() {
1136        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1137        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1138        let frag = increase_liquidity(
1139            U256::from(123_456u64),
1140            token0,
1141            token1,
1142            U256::from(1_000_000u64),
1143            U256::from(500_000_000_000_000u64),
1144            SlippageBps::new(50),
1145            9_999_999_999,
1146            TEST_CFG.position_mgr,
1147        );
1148        assert_eq!(frag.calls.len(), 1);
1149        assert_eq!(frag.calls[0].target, TEST_CFG.position_mgr);
1150        assert_eq!(frag.calls[0].value, U256::ZERO);
1151        assert_eq!(frag.approvals.len(), 2);
1152        assert_eq!(frag.approvals[0].token, token0);
1153        assert_eq!(frag.approvals[0].spender, TEST_CFG.position_mgr);
1154        assert_eq!(frag.approvals[0].min_amount, U256::from(1_000_000u64));
1155        assert_eq!(frag.approvals[1].token, token1);
1156        assert_eq!(frag.approvals[1].spender, TEST_CFG.position_mgr);
1157        assert_eq!(frag.approvals[1].min_amount, U256::from(500_000_000_000_000u64));
1158        assert_eq!(frag.value, U256::ZERO);
1159    }
1160
1161    #[test]
1162    fn increase_liquidity_calldata_round_trips_all_fields() {
1163        let token0 = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1164        let token1 = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1165        let token_id = U256::from(987_654u64);
1166        let amount0 = U256::from(2_000_000u64);
1167        let amount1 = U256::from(750_000_000_000_000u64);
1168        let deadline = 1_700_000_000u64;
1169        let frag = increase_liquidity(
1170            token_id,
1171            token0,
1172            token1,
1173            amount0,
1174            amount1,
1175            SlippageBps::new(100),
1176            deadline,
1177            TEST_CFG.position_mgr,
1178        );
1179
1180        let decoded =
1181            increaseLiquidityCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1182        let ip = decoded.params;
1183        assert_eq!(ip.tokenId, token_id);
1184        assert_eq!(ip.amount0Desired, amount0);
1185        assert_eq!(ip.amount1Desired, amount1);
1186        // 100 bps = 1% slippage → amount*Min = amount*Desired * 9900 / 10000.
1187        let expected_min0 = amount0 * U256::from(9900u64) / U256::from(10000u64);
1188        assert_eq!(ip.amount0Min, expected_min0);
1189        let expected_min1 = amount1 * U256::from(9900u64) / U256::from(10000u64);
1190        assert_eq!(ip.amount1Min, expected_min1);
1191        assert_eq!(ip.deadline, U256::from(deadline));
1192    }
1193
1194    /// Locks the `increaseLiquidity` selector against silent ABI drift.
1195    /// Verified vs `cast sig 'increaseLiquidity((uint256,uint256,uint256,uint256,uint256,uint256))'`.
1196    #[test]
1197    fn increase_liquidity_selector_matches_v3_canonical() {
1198        assert_eq!(increaseLiquidityCall::SELECTOR, [0x21, 0x9f, 0x5d, 0x17]);
1199    }
1200
1201    #[test]
1202    fn swap_exact_in_with_fee_fn_injects_fee_into_calldata() {
1203        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
1204        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1205        let s = dummy_pool_state(token_in, token_out); // fee: 3000 in the fixture
1206        let q = Quote {
1207            amount_in: U256::from(1_000_000u64),
1208            amount_out: U256::from(500_000_000_000_000u64),
1209            sqrt_price_x96_after: s.sqrt_price_x96,
1210            price_impact_bps: 0,
1211        };
1212        let p = ExactInParams {
1213            token_in,
1214            token_out,
1215            amount_in: q.amount_in,
1216            recipient: address!("0x0000000000000000000000000000000000000099"),
1217        };
1218        // Force fee to 500 (the 0.05% tier) via injection.
1219        let frag = swap_exact_in_with_fee_fn(
1220            &s,
1221            &q,
1222            &p,
1223            SlippageBps::new(50),
1224            9_999_999_999,
1225            TEST_CFG.router,
1226            SwapRouterKind::V1,
1227            |_| 500,
1228        );
1229        // Decode the emitted calldata and assert the fee field survived.
1230        let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
1231        assert_eq!(decoded.params.fee, alloy_primitives::aliases::U24::from(500u32));
1232    }
1233
1234    // ── Issue #6: slippage boundary tests ────────────────────────────────────
1235
1236    #[test]
1237    fn slippage_min_zero_bps_returns_full_amount() {
1238        // 0 bps = no slippage tolerance: the minimum acceptable output
1239        // equals the full quoted amount.
1240        let out = apply_slippage_min(U256::from(1_000_000u64), SlippageBps::new(0));
1241        assert_eq!(out, U256::from(1_000_000u64));
1242    }
1243
1244    #[test]
1245    fn slippage_min_10000_bps_returns_zero() {
1246        // 10_000 bps = 100% slippage: caller accepts any output including
1247        // zero. The formula: 1_000_000 * (10_000 - 10_000) / 10_000 = 0.
1248        let out = apply_slippage_min(U256::from(1_000_000u64), SlippageBps::new(10_000));
1249        assert_eq!(out, U256::ZERO);
1250    }
1251
1252    #[test]
1253    fn slippage_min_zero_quoted_returns_zero() {
1254        // Quoted amount of zero produces zero regardless of slippage bps.
1255        // No divide-by-zero risk because division is over denom (10_000),
1256        // not the quoted amount.
1257        let out = apply_slippage_min(U256::ZERO, SlippageBps::new(50));
1258        assert_eq!(out, U256::ZERO);
1259    }
1260
1261    #[test]
1262    fn slippage_min_max_u256_does_not_overflow() {
1263        // U256::MAX * 9950 / 10000 must not panic. U256 arithmetic is
1264        // checked by default in debug builds via overflow assertions, but
1265        // here we use wrapping multiplication via the U256 primitive.
1266        // The intermediary `U256::MAX * 9950` overflows u256 if done
1267        // naively; the implementation must use U256 arithmetic which
1268        // naturally wraps at 2^256. Verify the result is in (0, MAX).
1269        let out = apply_slippage_min(U256::MAX, SlippageBps::new(50));
1270        // Expected: U256::MAX * 9950 / 10000. Since U256::MAX * 9950
1271        // overflows mod 2^256, the result will be large but < U256::MAX.
1272        assert!(out > U256::ZERO, "expected nonzero result for MAX input");
1273        assert!(out < U256::MAX, "expected result < MAX (slippage was applied)");
1274    }
1275
1276    #[test]
1277    fn burn_selector_is_known() {
1278        assert_eq!(burnCall::SELECTOR, [0x42, 0x96, 0x6c, 0x68]); // burn(uint256)
1279    }
1280}