1use crate::data::{
4 CollectFeesParams, ExactInParams, GaugeClaim, GaugeEarnedGrid, PlanFragment, PoolState, Quote,
5 RamsesAddLiquidityParams, RemoveAndCollectParams, RemoveLiquidityParams,
6};
7use alloy_primitives::{Address, U256};
8use alloy_sol_types::{sol, SolCall};
9use wp_evm_base::types::{Call, SlippageBps, TokenApproval};
10use wp_evm_ramses_interfaces::gauge::IRamsesVoter;
11use wp_evm_v3_core::plan::apply_slippage_min;
12use wp_evm_velodrome_interfaces::gauge::IVelodromeCLGauge;
13
14sol! {
15 #[derive(Debug)]
16 struct ExactInputSingleParams {
17 address tokenIn;
18 address tokenOut;
19 int24 tickSpacing;
20 address recipient;
21 uint256 deadline;
22 uint256 amountIn;
23 uint256 amountOutMinimum;
24 uint160 sqrtPriceLimitX96;
25 }
26
27 function exactInputSingle(ExactInputSingleParams params)
28 external payable returns (uint256 amountOut);
29}
30
31pub(crate) mod shadow_nfpm {
32 use alloy_sol_types::sol;
33
34 sol! {
35 #[derive(Debug)]
36 struct ShadowMintParams {
37 address token0;
38 address token1;
39 int24 tickSpacing;
40 int24 tickLower;
41 int24 tickUpper;
42 uint256 amount0Desired;
43 uint256 amount1Desired;
44 uint256 amount0Min;
45 uint256 amount1Min;
46 address recipient;
47 uint256 deadline;
48 }
49 function mint(ShadowMintParams params) external payable
50 returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
51 }
52}
53
54pub(crate) mod slipstream_nfpm {
55 use alloy_sol_types::sol;
56
57 sol! {
58 #[derive(Debug)]
59 struct SlipstreamMintParams {
60 address token0;
61 address token1;
62 int24 tickSpacing;
63 int24 tickLower;
64 int24 tickUpper;
65 uint256 amount0Desired;
66 uint256 amount1Desired;
67 uint256 amount0Min;
68 uint256 amount1Min;
69 address recipient;
70 uint256 deadline;
71 uint160 sqrtPriceX96;
72 }
73 function mint(SlipstreamMintParams params) external payable
74 returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
75 }
76}
77
78use shadow_nfpm::{mintCall as shadowMintCall, ShadowMintParams};
79use slipstream_nfpm::{mintCall as slipstreamMintCall, SlipstreamMintParams};
80
81pub fn swap_exact_in(
82 state: &PoolState,
83 quote: &Quote,
84 params: &ExactInParams,
85 slippage: SlippageBps,
86 deadline: u64,
87 router: Address,
88) -> PlanFragment {
89 let amount_out_min = apply_slippage_min(quote.amount_out, slippage);
90
91 let call_params = ExactInputSingleParams {
92 tokenIn: params.token_in,
93 tokenOut: params.token_out,
94 tickSpacing: alloy_primitives::aliases::I24::try_from(state.tick_spacing)
95 .expect("tick_spacing fits in i24"),
96 recipient: params.recipient,
97 deadline: U256::from(deadline),
98 amountIn: params.amount_in,
99 amountOutMinimum: amount_out_min,
100 sqrtPriceLimitX96: alloy_primitives::aliases::U160::ZERO,
101 };
102
103 let calldata = exactInputSingleCall { params: call_params }.abi_encode().into();
104
105 PlanFragment {
106 calls: vec![Call { target: router, calldata, value: U256::ZERO }],
107 approvals: vec![TokenApproval {
108 token: params.token_in,
109 spender: router,
110 min_amount: params.amount_in,
111 }],
112 value: U256::ZERO,
113 }
114}
115
116pub fn add_liquidity(
117 params: &RamsesAddLiquidityParams,
118 slippage: SlippageBps,
119 deadline: u64,
120 position_manager: Address,
121) -> PlanFragment {
122 let mint_params = ShadowMintParams {
123 token0: params.token0,
124 token1: params.token1,
125 tickSpacing: alloy_primitives::aliases::I24::try_from(params.tick_spacing)
126 .expect("tick_spacing fits in i24"),
127 tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
128 .expect("tick_lower within i24 range"),
129 tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
130 .expect("tick_upper within i24 range"),
131 amount0Desired: params.amount0_desired,
132 amount1Desired: params.amount1_desired,
133 amount0Min: apply_slippage_min(params.amount0_desired, slippage),
134 amount1Min: apply_slippage_min(params.amount1_desired, slippage),
135 recipient: params.recipient,
136 deadline: U256::from(deadline),
137 };
138 let calldata = shadowMintCall { params: mint_params }.abi_encode().into();
139 PlanFragment {
140 calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
141 approvals: vec![
142 TokenApproval {
143 token: params.token0,
144 spender: position_manager,
145 min_amount: params.amount0_desired,
146 },
147 TokenApproval {
148 token: params.token1,
149 spender: position_manager,
150 min_amount: params.amount1_desired,
151 },
152 ],
153 value: U256::ZERO,
154 }
155}
156
157pub fn add_liquidity_slipstream(
158 params: &RamsesAddLiquidityParams,
159 slippage: SlippageBps,
160 deadline: u64,
161 position_manager: Address,
162) -> PlanFragment {
163 let mint_params = SlipstreamMintParams {
164 token0: params.token0,
165 token1: params.token1,
166 tickSpacing: alloy_primitives::aliases::I24::try_from(params.tick_spacing)
167 .expect("tick_spacing fits in i24"),
168 tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
169 .expect("tick_lower within i24 range"),
170 tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
171 .expect("tick_upper within i24 range"),
172 amount0Desired: params.amount0_desired,
173 amount1Desired: params.amount1_desired,
174 amount0Min: apply_slippage_min(params.amount0_desired, slippage),
175 amount1Min: apply_slippage_min(params.amount1_desired, slippage),
176 recipient: params.recipient,
177 deadline: U256::from(deadline),
178 sqrtPriceX96: alloy_primitives::aliases::U160::ZERO,
179 };
180 let calldata = slipstreamMintCall { params: mint_params }.abi_encode().into();
181 PlanFragment {
182 calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
183 approvals: vec![
184 TokenApproval {
185 token: params.token0,
186 spender: position_manager,
187 min_amount: params.amount0_desired,
188 },
189 TokenApproval {
190 token: params.token1,
191 spender: position_manager,
192 min_amount: params.amount1_desired,
193 },
194 ],
195 value: U256::ZERO,
196 }
197}
198
199#[allow(clippy::too_many_arguments)]
200pub fn increase_liquidity(
201 token_id: alloy_primitives::U256,
202 token0: alloy_primitives::Address,
203 token1: alloy_primitives::Address,
204 amount0_desired: alloy_primitives::U256,
205 amount1_desired: alloy_primitives::U256,
206 slippage: SlippageBps,
207 deadline: u64,
208 position_manager: Address,
209) -> PlanFragment {
210 wp_evm_v3_core::plan::increase_liquidity(
211 token_id,
212 token0,
213 token1,
214 amount0_desired,
215 amount1_desired,
216 slippage,
217 deadline,
218 position_manager,
219 )
220}
221
222pub fn remove_liquidity(
223 params: &RemoveLiquidityParams,
224 deadline: u64,
225 position_manager: Address,
226) -> PlanFragment {
227 wp_evm_v3_core::plan::remove_liquidity(params, deadline, position_manager)
228}
229
230pub fn remove_liquidity_and_collect(
243 params: &RemoveAndCollectParams,
244 deadline: u64,
245 position_manager: Address,
246) -> PlanFragment {
247 wp_evm_v3_core::plan::remove_liquidity_and_collect(params, deadline, position_manager)
248}
249
250pub fn collect_fees(params: &CollectFeesParams, position_manager: Address) -> PlanFragment {
251 wp_evm_v3_core::plan::collect_fees(params, position_manager)
252}
253
254pub fn claim_cl_gauge_rewards(voter: Address, claims: &[GaugeClaim]) -> PlanFragment {
262 let gauges: Vec<Address> = claims.iter().map(|c| c.gauge).collect();
263 let tokens: Vec<Vec<Address>> = claims.iter().map(|c| c.reward_tokens.clone()).collect();
264 let nfp_token_ids: Vec<Vec<U256>> = claims.iter().map(|c| c.token_ids.clone()).collect();
265 let calldata = IRamsesVoter::claimClGaugeRewardsCall {
266 _gauges: gauges,
267 _tokens: tokens,
268 _nfpTokenIds: nfp_token_ids,
269 }
270 .abi_encode()
271 .into();
272 PlanFragment {
273 calls: vec![Call { target: voter, calldata, value: U256::ZERO }],
274 approvals: vec![],
275 value: U256::ZERO,
276 }
277}
278
279pub fn claim_gauge(gauge: Address, token_id: U256) -> PlanFragment {
283 let calldata = IVelodromeCLGauge::getRewardCall { tokenId: token_id }.abi_encode().into();
284 PlanFragment {
285 calls: vec![Call { target: gauge, calldata, value: U256::ZERO }],
286 approvals: vec![],
287 value: U256::ZERO,
288 }
289}
290
291pub fn build_gauge_claims(grids: &[GaugeEarnedGrid]) -> Vec<GaugeClaim> {
297 let mut out = Vec::new();
298 for g in grids {
299 let kept_tokens: Vec<usize> = (0..g.reward_tokens.len())
300 .filter(|&t| (0..g.token_ids.len()).any(|p| g.earned[t][p] > U256::ZERO))
301 .collect();
302 let kept_positions: Vec<usize> = (0..g.token_ids.len())
303 .filter(|&p| (0..g.reward_tokens.len()).any(|t| g.earned[t][p] > U256::ZERO))
304 .collect();
305 if kept_tokens.is_empty() || kept_positions.is_empty() {
306 continue;
307 }
308 out.push(GaugeClaim {
309 gauge: g.gauge,
310 reward_tokens: kept_tokens.iter().map(|&t| g.reward_tokens[t]).collect(),
311 token_ids: kept_positions.iter().map(|&p| g.token_ids[p]).collect(),
312 });
313 }
314 out
315}
316
317pub fn claim_cl_gauge_rewards_from_grids(
322 voter: Address,
323 grids: &[GaugeEarnedGrid],
324) -> Option<PlanFragment> {
325 let claims = build_gauge_claims(grids);
326 if claims.is_empty() {
327 None
328 } else {
329 Some(claim_cl_gauge_rewards(voter, &claims))
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use alloy_primitives::{address, Address};
337
338 const ROUTER: Address = address!("0x2222222222222222222222222222222222222222");
339 const POSITION_MANAGER: Address = address!("0x3333333333333333333333333333333333333333");
340
341 fn dummy_pool_state(t0: Address, t1: Address) -> PoolState {
342 PoolState {
343 token0: t0,
344 token1: t1,
345 fee: 0,
346 tick_spacing: 50,
347 sqrt_price_x96: U256::from(1u64) << 96,
348 liquidity: 0,
349 tick: 0,
350 ticks: vec![],
351 }
352 }
353
354 #[test]
355 fn plan_swap_emits_ramses_selector() {
356 let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
357 let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
358 let s = dummy_pool_state(token_in, token_out);
359 let q = Quote {
360 amount_in: U256::from(1_000_000u64),
361 amount_out: U256::from(500_000_000_000_000u64),
362 sqrt_price_x96_after: s.sqrt_price_x96,
363 price_impact_bps: 0,
364 };
365 let p = ExactInParams {
366 token_in,
367 token_out,
368 amount_in: q.amount_in,
369 recipient: address!("0x0000000000000000000000000000000000000099"),
370 };
371 let frag = swap_exact_in(&s, &q, &p, SlippageBps::new(50), 9_999_999_999, ROUTER);
372 assert_eq!(frag.calls.len(), 1);
373 assert_eq!(frag.calls[0].target, ROUTER);
374 assert_eq!(&frag.calls[0].calldata[..4], &[0xa0, 0x26, 0x38, 0x3e]);
375 }
376
377 #[test]
378 fn plan_swap_encodes_tick_spacing_not_fee() {
379 let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
380 let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
381 let mut s = dummy_pool_state(token_in, token_out);
382 s.tick_spacing = 200;
383 let q = Quote {
384 amount_in: U256::from(1_000_000u64),
385 amount_out: U256::from(500_000_000_000_000u64),
386 sqrt_price_x96_after: s.sqrt_price_x96,
387 price_impact_bps: 0,
388 };
389 let p = ExactInParams {
390 token_in,
391 token_out,
392 amount_in: q.amount_in,
393 recipient: address!("0x0000000000000000000000000000000000000099"),
394 };
395 let frag = swap_exact_in(&s, &q, &p, SlippageBps::new(50), 9_999_999_999, ROUTER);
396 let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
397 assert_eq!(
398 decoded.params.tickSpacing,
399 alloy_primitives::aliases::I24::try_from(200i32).unwrap()
400 );
401 }
402
403 #[test]
404 fn add_liquidity_targets_position_manager() {
405 let p = RamsesAddLiquidityParams {
406 token0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
407 token1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
408 tick_spacing: 50,
409 tick_lower: -887_272,
410 tick_upper: 887_272,
411 amount0_desired: U256::from(1_000_000u64),
412 amount1_desired: U256::from(500_000_000_000_000u64),
413 recipient: address!("0x0000000000000000000000000000000000000099"),
414 };
415 let frag = add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, POSITION_MANAGER);
416 assert_eq!(frag.calls[0].target, POSITION_MANAGER);
417 assert_eq!(frag.approvals.len(), 2);
418 }
419
420 #[test]
421 fn remove_liquidity_and_collect_targets_position_manager_with_outer_multicall() {
422 use alloy_sol_types::SolValue;
426 let p = RemoveAndCollectParams {
427 token_id: U256::from(1u64),
428 liquidity: 1_000u128,
429 amount0_min: Some(U256::from(99u64)),
430 amount1_min: Some(U256::from(199u64)),
431 recipient: address!("0000000000000000000000000000000000000099"),
432 token0: address!("0000000000000000000000000000000000000001"),
433 token1: address!("0000000000000000000000000000000000000002"),
434 caller: Address::ZERO,
435 burn: false,
436 };
437 let frag = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
438 assert_eq!(frag.calls.len(), 1);
439 assert_eq!(frag.calls[0].target, POSITION_MANAGER);
440 assert_eq!(
441 &frag.calls[0].calldata[..4],
442 &[0xac, 0x96, 0x50, 0xd8],
443 "outer selector must be multicall(bytes[])"
444 );
445 assert!(frag.approvals.is_empty());
446 assert_eq!(frag.value, U256::ZERO);
447
448 let (inner,): (Vec<alloy_primitives::Bytes>,) =
452 <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
453 .expect("decode outer multicall params");
454 assert_eq!(inner.len(), 2);
455 assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
456 assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
457 }
458
459 #[test]
460 fn remove_liquidity_and_collect_delegation_matches_v3_core_bytewise() {
461 let p = RemoveAndCollectParams {
464 token_id: U256::from(42u64),
465 liquidity: 1_000_000_000_000u128,
466 amount0_min: Some(U256::from(500_000u64)),
467 amount1_min: Some(U256::from(1_000_000_000u64)),
468 recipient: address!("0000000000000000000000000000000000000099"),
469 token0: address!("0000000000000000000000000000000000000001"),
470 token1: address!("0000000000000000000000000000000000000002"),
471 caller: Address::ZERO,
472 burn: false,
473 };
474 let ours = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
475 let core =
476 wp_evm_v3_core::plan::remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
477 assert_eq!(ours.calls[0].calldata, core.calls[0].calldata);
478 assert_eq!(ours.calls[0].target, core.calls[0].target);
479 }
480
481 #[test]
482 fn remove_liquidity_and_collect_delegation_carries_burn() {
483 let p = RemoveAndCollectParams {
484 token_id: U256::from(42u64),
485 liquidity: 1_000_000_000_000u128,
486 amount0_min: Some(U256::from(500_000u64)),
487 amount1_min: Some(U256::from(1_000_000_000u64)),
488 recipient: address!("0000000000000000000000000000000000000099"),
489 token0: address!("0000000000000000000000000000000000000001"),
490 token1: address!("0000000000000000000000000000000000000002"),
491 caller: Address::ZERO,
492 burn: true,
493 };
494 let frag = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
495 let v3 =
496 wp_evm_v3_core::plan::remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
497 assert_eq!(frag, v3, "ramses delegates burn:true byte-for-byte");
498 }
499
500 #[test]
501 fn claim_cl_gauge_rewards_targets_voter_with_ordered_arrays() {
502 use alloy_sol_types::SolCall;
503 let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
504 let claims = vec![
505 GaugeClaim {
506 gauge: address!("1111111111111111111111111111111111111111"),
507 reward_tokens: vec![
508 address!("aaaa000000000000000000000000000000000000"),
509 address!("bbbb000000000000000000000000000000000000"),
510 ],
511 token_ids: vec![U256::from(10u64), U256::from(11u64)],
512 },
513 GaugeClaim {
514 gauge: address!("2222222222222222222222222222222222222222"),
515 reward_tokens: vec![address!("cccc000000000000000000000000000000000000")],
516 token_ids: vec![U256::from(20u64)],
517 },
518 ];
519 let frag = claim_cl_gauge_rewards(voter, &claims);
520
521 assert_eq!(frag.calls.len(), 1);
522 assert_eq!(frag.calls[0].target, voter);
523 assert!(frag.approvals.is_empty());
524 assert_eq!(frag.value, U256::ZERO);
525 assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
527
528 let decoded =
530 wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall::abi_decode(
531 &frag.calls[0].calldata,
532 )
533 .expect("decode");
534 assert_eq!(decoded._gauges, vec![claims[0].gauge, claims[1].gauge]);
535 assert_eq!(decoded._tokens[0], claims[0].reward_tokens);
536 assert_eq!(decoded._nfpTokenIds[1], claims[1].token_ids);
537
538 let reference = wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall {
542 _gauges: vec![
543 address!("1111111111111111111111111111111111111111"),
544 address!("2222222222222222222222222222222222222222"),
545 ],
546 _tokens: vec![
547 vec![
548 address!("aaaa000000000000000000000000000000000000"),
549 address!("bbbb000000000000000000000000000000000000"),
550 ],
551 vec![address!("cccc000000000000000000000000000000000000")],
552 ],
553 _nfpTokenIds: vec![vec![U256::from(10u64), U256::from(11u64)], vec![U256::from(20u64)]],
554 }
555 .abi_encode();
556 assert_eq!(&frag.calls[0].calldata[..], &reference[..]);
557 }
558
559 #[test]
560 fn claim_gauge_targets_gauge_with_get_reward_selector() {
561 let gauge = address!("4444444444444444444444444444444444444444");
562 let frag = claim_gauge(gauge, U256::from(42u64));
563 assert_eq!(frag.calls.len(), 1);
564 assert_eq!(frag.calls[0].target, gauge);
565 assert!(frag.approvals.is_empty());
566 assert_eq!(frag.value, U256::ZERO);
567 assert_eq!(&frag.calls[0].calldata[..4], &[0x1c, 0x4b, 0x77, 0x4b]);
569 }
570
571 #[test]
572 fn claim_cl_gauge_rewards_from_grids_none_when_empty_or_all_zero() {
573 let voter = Address::from([9u8; 20]);
574 let zero = grid(0x11, 1, &[1], vec![vec![0]]);
576 assert!(claim_cl_gauge_rewards_from_grids(voter, &[zero]).is_none());
577 assert!(claim_cl_gauge_rewards_from_grids(voter, &[]).is_none());
579 }
580
581 #[test]
582 fn claim_cl_gauge_rewards_from_grids_some_targets_voter_when_nonzero() {
583 let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
584 let g = grid(0x11, 1, &[1], vec![vec![7]]);
585 let frag = claim_cl_gauge_rewards_from_grids(voter, &[g]).expect("nonzero -> Some");
586 assert_eq!(frag.calls[0].target, voter);
587 assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
588 }
589
590 fn grid(
591 gauge_byte: u8,
592 tokens: usize,
593 ids: &[u64],
594 earned: Vec<Vec<u64>>,
595 ) -> crate::data::GaugeEarnedGrid {
596 crate::data::GaugeEarnedGrid {
597 gauge: Address::from([gauge_byte; 20]),
598 reward_tokens: (0..tokens).map(|i| Address::from([i as u8 + 1; 20])).collect(),
599 token_ids: ids.iter().map(|&i| U256::from(i)).collect(),
600 earned: earned.into_iter().map(|r| r.into_iter().map(U256::from).collect()).collect(),
601 }
602 }
603
604 #[test]
605 fn build_gauge_claims_drops_all_zero_token_and_position() {
606 let g = grid(0x11, 2, &[100, 101], vec![vec![0, 5], vec![0, 0]]);
609 let claims = build_gauge_claims(&[g]);
610 assert_eq!(claims.len(), 1);
611 assert_eq!(claims[0].reward_tokens, vec![Address::from([1u8; 20])]);
612 assert_eq!(claims[0].token_ids, vec![U256::from(101u64)]);
613 }
614
615 #[test]
616 fn build_gauge_claims_drops_fully_empty_gauge() {
617 let g = grid(0x22, 2, &[1, 2], vec![vec![0, 0], vec![0, 0]]);
618 assert!(build_gauge_claims(&[g]).is_empty());
619 }
620
621 #[test]
622 fn build_gauge_claims_keeps_multiple_gauges() {
623 let a = grid(0x11, 1, &[1], vec![vec![9]]);
624 let b = grid(0x22, 1, &[2], vec![vec![0]]); let c = grid(0x33, 1, &[3], vec![vec![3]]);
626 let claims = build_gauge_claims(&[a, b, c]);
627 assert_eq!(claims.len(), 2);
628 assert_eq!(claims[0].gauge, Address::from([0x11u8; 20]));
629 assert_eq!(claims[1].gauge, Address::from([0x33u8; 20]));
630 }
631
632 #[test]
633 fn build_gauge_claims_empty_input_empty_output() {
634 assert!(build_gauge_claims(&[]).is_empty());
635 }
636}