Skip to main content

wp_evm_ramses_core/
plan.rs

1//! Pure plan functions for the Ramses family.
2
3use 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;
79use slipstream_nfpm::{mintCall as slipstreamMintCall, SlipstreamMintParams};
80
81// Expose the Ramses mint types for raw-calldata consumers (e.g. the arbitrage
82// Shadow call_builder), re-exported on the wp-evm-shadow facade. `ShadowMintParams`
83// also satisfies the internal uses below (`pub use` brings it into module scope).
84pub use shadow_nfpm::{mintCall, ShadowMintParams};
85
86pub fn swap_exact_in(
87    state: &PoolState,
88    quote: &Quote,
89    params: &ExactInParams,
90    slippage: SlippageBps,
91    deadline: u64,
92    router: Address,
93) -> PlanFragment {
94    let amount_out_min = apply_slippage_min(quote.amount_out, slippage);
95
96    let call_params = ExactInputSingleParams {
97        tokenIn: params.token_in,
98        tokenOut: params.token_out,
99        tickSpacing: alloy_primitives::aliases::I24::try_from(state.tick_spacing)
100            .expect("tick_spacing fits in i24"),
101        recipient: params.recipient,
102        deadline: U256::from(deadline),
103        amountIn: params.amount_in,
104        amountOutMinimum: amount_out_min,
105        sqrtPriceLimitX96: alloy_primitives::aliases::U160::ZERO,
106    };
107
108    let calldata = exactInputSingleCall { params: call_params }.abi_encode().into();
109
110    PlanFragment {
111        calls: vec![Call { target: router, calldata, value: U256::ZERO }],
112        approvals: vec![TokenApproval {
113            token: params.token_in,
114            spender: router,
115            min_amount: params.amount_in,
116        }],
117        value: U256::ZERO,
118    }
119}
120
121pub fn add_liquidity(
122    params: &RamsesAddLiquidityParams,
123    slippage: SlippageBps,
124    deadline: u64,
125    position_manager: Address,
126) -> PlanFragment {
127    let mint_params = ShadowMintParams {
128        token0: params.token0,
129        token1: params.token1,
130        tickSpacing: alloy_primitives::aliases::I24::try_from(params.tick_spacing)
131            .expect("tick_spacing fits in i24"),
132        tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
133            .expect("tick_lower within i24 range"),
134        tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
135            .expect("tick_upper within i24 range"),
136        amount0Desired: params.amount0_desired,
137        amount1Desired: params.amount1_desired,
138        amount0Min: apply_slippage_min(params.amount0_desired, slippage),
139        amount1Min: apply_slippage_min(params.amount1_desired, slippage),
140        recipient: params.recipient,
141        deadline: U256::from(deadline),
142    };
143    let calldata = shadowMintCall { params: mint_params }.abi_encode().into();
144    PlanFragment {
145        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
146        approvals: vec![
147            TokenApproval {
148                token: params.token0,
149                spender: position_manager,
150                min_amount: params.amount0_desired,
151            },
152            TokenApproval {
153                token: params.token1,
154                spender: position_manager,
155                min_amount: params.amount1_desired,
156            },
157        ],
158        value: U256::ZERO,
159    }
160}
161
162pub fn add_liquidity_slipstream(
163    params: &RamsesAddLiquidityParams,
164    slippage: SlippageBps,
165    deadline: u64,
166    position_manager: Address,
167) -> PlanFragment {
168    let mint_params = SlipstreamMintParams {
169        token0: params.token0,
170        token1: params.token1,
171        tickSpacing: alloy_primitives::aliases::I24::try_from(params.tick_spacing)
172            .expect("tick_spacing fits in i24"),
173        tickLower: alloy_primitives::aliases::I24::try_from(params.tick_lower)
174            .expect("tick_lower within i24 range"),
175        tickUpper: alloy_primitives::aliases::I24::try_from(params.tick_upper)
176            .expect("tick_upper within i24 range"),
177        amount0Desired: params.amount0_desired,
178        amount1Desired: params.amount1_desired,
179        amount0Min: apply_slippage_min(params.amount0_desired, slippage),
180        amount1Min: apply_slippage_min(params.amount1_desired, slippage),
181        recipient: params.recipient,
182        deadline: U256::from(deadline),
183        sqrtPriceX96: alloy_primitives::aliases::U160::ZERO,
184    };
185    let calldata = slipstreamMintCall { params: mint_params }.abi_encode().into();
186    PlanFragment {
187        calls: vec![Call { target: position_manager, calldata, value: U256::ZERO }],
188        approvals: vec![
189            TokenApproval {
190                token: params.token0,
191                spender: position_manager,
192                min_amount: params.amount0_desired,
193            },
194            TokenApproval {
195                token: params.token1,
196                spender: position_manager,
197                min_amount: params.amount1_desired,
198            },
199        ],
200        value: U256::ZERO,
201    }
202}
203
204#[allow(clippy::too_many_arguments)]
205pub fn increase_liquidity(
206    token_id: alloy_primitives::U256,
207    token0: alloy_primitives::Address,
208    token1: alloy_primitives::Address,
209    amount0_desired: alloy_primitives::U256,
210    amount1_desired: alloy_primitives::U256,
211    slippage: SlippageBps,
212    deadline: u64,
213    position_manager: Address,
214) -> PlanFragment {
215    wp_evm_v3_core::plan::increase_liquidity(
216        token_id,
217        token0,
218        token1,
219        amount0_desired,
220        amount1_desired,
221        slippage,
222        deadline,
223        position_manager,
224    )
225}
226
227pub fn remove_liquidity(
228    params: &RemoveLiquidityParams,
229    deadline: u64,
230    position_manager: Address,
231) -> PlanFragment {
232    wp_evm_v3_core::plan::remove_liquidity(params, deadline, position_manager)
233}
234
235/// Build an atomic `multicall(decreaseLiquidity, collect)` plan fragment
236/// for a Ramses-family NFPM position.
237///
238/// Delegates straight to `wp_evm_v3_core::plan::remove_liquidity_and_collect`
239/// — Ramses-fork NFPMs inherit V3's `decreaseLiquidity` + `collect` + outer
240/// `multicall(bytes[])` byte-for-byte (the family's structural divergence
241/// from V3 is `tickSpacing` instead of `fee` in mint params; remove+collect
242/// has no `fee` / `tickSpacing` field on the inner ABI). Mirrors this
243/// module's `remove_liquidity` one-line delegation.
244///
245/// Native-unwrap composition is NOT done here — the facade layer pairs
246/// this with `compose_native_remove_collect_multicall`.
247pub fn remove_liquidity_and_collect(
248    params: &RemoveAndCollectParams,
249    deadline: u64,
250    position_manager: Address,
251) -> PlanFragment {
252    wp_evm_v3_core::plan::remove_liquidity_and_collect(params, deadline, position_manager)
253}
254
255pub fn collect_fees(params: &CollectFeesParams, position_manager: Address) -> PlanFragment {
256    wp_evm_v3_core::plan::collect_fees(params, position_manager)
257}
258
259/// Encode a batched Ramses-family CL gauge reward claim.
260///
261/// Produces a single `Call` to the **Voter** (`claimClGaugeRewards`),
262/// `value: 0`, no approvals. `voter` is caller-supplied (no config lookup) —
263/// matching `claim_gauge`'s param style. `claims` carries one entry per
264/// gauge; pass an empty slice and you get an empty-array call (caller's
265/// responsibility to avoid).
266pub fn claim_cl_gauge_rewards(voter: Address, claims: &[GaugeClaim]) -> PlanFragment {
267    let gauges: Vec<Address> = claims.iter().map(|c| c.gauge).collect();
268    let tokens: Vec<Vec<Address>> = claims.iter().map(|c| c.reward_tokens.clone()).collect();
269    let nfp_token_ids: Vec<Vec<U256>> = claims.iter().map(|c| c.token_ids.clone()).collect();
270    let calldata = IRamsesVoter::claimClGaugeRewardsCall {
271        _gauges: gauges,
272        _tokens: tokens,
273        _nfpTokenIds: nfp_token_ids,
274    }
275    .abi_encode()
276    .into();
277    PlanFragment {
278        calls: vec![Call { target: voter, calldata, value: U256::ZERO }],
279        approvals: vec![],
280        value: U256::ZERO,
281    }
282}
283
284/// Encode a single-token Velodrome/Aerodrome gauge claim
285/// (`getReward(uint256)` on the gauge). Hoisted from the Slipstream facade so
286/// the selector is interface-locked in `wp-evm-velodrome-interfaces`.
287pub fn claim_gauge(gauge: Address, token_id: U256) -> PlanFragment {
288    let calldata = IVelodromeCLGauge::getRewardCall { tokenId: token_id }.abi_encode().into();
289    PlanFragment {
290        calls: vec![Call { target: gauge, calldata, value: U256::ZERO }],
291        approvals: vec![],
292        value: U256::ZERO,
293    }
294}
295
296/// Group raw `earned` grids into claimable `GaugeClaim`s, pruning per-pair
297/// zeros. A reward token is kept only if some position earns it; a position
298/// is kept only if it earns some token. A gauge emptied by pruning is
299/// dropped. Never inspects `isAlive` — de-listed gauges still hold claimable
300/// `earned`. Mirrors arbitrage `filter_items_with_non_zero_rewards`.
301pub fn build_gauge_claims(grids: &[GaugeEarnedGrid]) -> Vec<GaugeClaim> {
302    let mut out = Vec::new();
303    for g in grids {
304        let kept_tokens: Vec<usize> = (0..g.reward_tokens.len())
305            .filter(|&t| (0..g.token_ids.len()).any(|p| g.earned[t][p] > U256::ZERO))
306            .collect();
307        let kept_positions: Vec<usize> = (0..g.token_ids.len())
308            .filter(|&p| (0..g.reward_tokens.len()).any(|t| g.earned[t][p] > U256::ZERO))
309            .collect();
310        if kept_tokens.is_empty() || kept_positions.is_empty() {
311            continue;
312        }
313        out.push(GaugeClaim {
314            gauge: g.gauge,
315            reward_tokens: kept_tokens.iter().map(|&t| g.reward_tokens[t]).collect(),
316            token_ids: kept_positions.iter().map(|&p| g.token_ids[p]).collect(),
317        });
318    }
319    out
320}
321
322/// Prune raw `earned` grids and encode the batched claim in one step. Returns
323/// `None` when nothing survives pruning — the caller's "never submit an empty
324/// claim tx" contract. `voter` is caller-supplied. Pure, so the `None`-collapse
325/// is unit-testable without a provider (the async facade is a thin wrapper).
326pub fn claim_cl_gauge_rewards_from_grids(
327    voter: Address,
328    grids: &[GaugeEarnedGrid],
329) -> Option<PlanFragment> {
330    let claims = build_gauge_claims(grids);
331    if claims.is_empty() {
332        None
333    } else {
334        Some(claim_cl_gauge_rewards(voter, &claims))
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use alloy_primitives::{address, Address};
342
343    const ROUTER: Address = address!("0x2222222222222222222222222222222222222222");
344    const POSITION_MANAGER: Address = address!("0x3333333333333333333333333333333333333333");
345
346    fn dummy_pool_state(t0: Address, t1: Address) -> PoolState {
347        PoolState {
348            token0: t0,
349            token1: t1,
350            fee: 0,
351            tick_spacing: 50,
352            sqrt_price_x96: U256::from(1u64) << 96,
353            liquidity: 0,
354            tick: 0,
355            ticks: vec![],
356        }
357    }
358
359    #[test]
360    fn plan_swap_emits_ramses_selector() {
361        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
362        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
363        let s = dummy_pool_state(token_in, token_out);
364        let q = Quote {
365            amount_in: U256::from(1_000_000u64),
366            amount_out: U256::from(500_000_000_000_000u64),
367            sqrt_price_x96_after: s.sqrt_price_x96,
368            price_impact_bps: 0,
369        };
370        let p = ExactInParams {
371            token_in,
372            token_out,
373            amount_in: q.amount_in,
374            recipient: address!("0x0000000000000000000000000000000000000099"),
375        };
376        let frag = swap_exact_in(&s, &q, &p, SlippageBps::new(50), 9_999_999_999, ROUTER);
377        assert_eq!(frag.calls.len(), 1);
378        assert_eq!(frag.calls[0].target, ROUTER);
379        assert_eq!(&frag.calls[0].calldata[..4], &[0xa0, 0x26, 0x38, 0x3e]);
380    }
381
382    #[test]
383    fn plan_swap_encodes_tick_spacing_not_fee() {
384        let token_in = address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
385        let token_out = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
386        let mut s = dummy_pool_state(token_in, token_out);
387        s.tick_spacing = 200;
388        let q = Quote {
389            amount_in: U256::from(1_000_000u64),
390            amount_out: U256::from(500_000_000_000_000u64),
391            sqrt_price_x96_after: s.sqrt_price_x96,
392            price_impact_bps: 0,
393        };
394        let p = ExactInParams {
395            token_in,
396            token_out,
397            amount_in: q.amount_in,
398            recipient: address!("0x0000000000000000000000000000000000000099"),
399        };
400        let frag = swap_exact_in(&s, &q, &p, SlippageBps::new(50), 9_999_999_999, ROUTER);
401        let decoded = exactInputSingleCall::abi_decode(&frag.calls[0].calldata).expect("decode ok");
402        assert_eq!(
403            decoded.params.tickSpacing,
404            alloy_primitives::aliases::I24::try_from(200i32).unwrap()
405        );
406    }
407
408    #[test]
409    fn add_liquidity_targets_position_manager() {
410        let p = RamsesAddLiquidityParams {
411            token0: address!("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
412            token1: address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
413            tick_spacing: 50,
414            tick_lower: -887_272,
415            tick_upper: 887_272,
416            amount0_desired: U256::from(1_000_000u64),
417            amount1_desired: U256::from(500_000_000_000_000u64),
418            recipient: address!("0x0000000000000000000000000000000000000099"),
419        };
420        let frag = add_liquidity(&p, SlippageBps::new(50), 9_999_999_999, POSITION_MANAGER);
421        assert_eq!(frag.calls[0].target, POSITION_MANAGER);
422        assert_eq!(frag.approvals.len(), 2);
423    }
424
425    #[test]
426    fn remove_liquidity_and_collect_targets_position_manager_with_outer_multicall() {
427        // Outer call must be `multicall(bytes[])` (selector `0xac9650d8`,
428        // universal across V3/Ramses/Velodrome) over exactly 2 inner
429        // calls: decreaseLiquidity then collect.
430        use alloy_sol_types::SolValue;
431        let p = RemoveAndCollectParams {
432            token_id: U256::from(1u64),
433            liquidity: 1_000u128,
434            amount0_min: Some(U256::from(99u64)),
435            amount1_min: Some(U256::from(199u64)),
436            recipient: address!("0000000000000000000000000000000000000099"),
437            token0: address!("0000000000000000000000000000000000000001"),
438            token1: address!("0000000000000000000000000000000000000002"),
439            caller: Address::ZERO,
440            burn: false,
441        };
442        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
443        assert_eq!(frag.calls.len(), 1);
444        assert_eq!(frag.calls[0].target, POSITION_MANAGER);
445        assert_eq!(
446            &frag.calls[0].calldata[..4],
447            &[0xac, 0x96, 0x50, 0xd8],
448            "outer selector must be multicall(bytes[])"
449        );
450        assert!(frag.approvals.is_empty());
451        assert_eq!(frag.value, U256::ZERO);
452
453        // Decode outer and assert inner pair: decreaseLiquidity (0x0c49ccbe)
454        // followed by collect (0xfc6f7865). These selectors are V3-canonical
455        // and Ramses inherits them via the v3-core delegation.
456        let (inner,): (Vec<alloy_primitives::Bytes>,) =
457            <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
458                .expect("decode outer multicall params");
459        assert_eq!(inner.len(), 2);
460        assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
461        assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
462    }
463
464    #[test]
465    fn remove_liquidity_and_collect_delegation_matches_v3_core_bytewise() {
466        // Ramses delegates to v3-core verbatim — proving byte-equality
467        // here pins the delegation against accidental local re-encoding.
468        let p = RemoveAndCollectParams {
469            token_id: U256::from(42u64),
470            liquidity: 1_000_000_000_000u128,
471            amount0_min: Some(U256::from(500_000u64)),
472            amount1_min: Some(U256::from(1_000_000_000u64)),
473            recipient: address!("0000000000000000000000000000000000000099"),
474            token0: address!("0000000000000000000000000000000000000001"),
475            token1: address!("0000000000000000000000000000000000000002"),
476            caller: Address::ZERO,
477            burn: false,
478        };
479        let ours = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
480        let core =
481            wp_evm_v3_core::plan::remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
482        assert_eq!(ours.calls[0].calldata, core.calls[0].calldata);
483        assert_eq!(ours.calls[0].target, core.calls[0].target);
484    }
485
486    #[test]
487    fn remove_liquidity_and_collect_delegation_carries_burn() {
488        let p = RemoveAndCollectParams {
489            token_id: U256::from(42u64),
490            liquidity: 1_000_000_000_000u128,
491            amount0_min: Some(U256::from(500_000u64)),
492            amount1_min: Some(U256::from(1_000_000_000u64)),
493            recipient: address!("0000000000000000000000000000000000000099"),
494            token0: address!("0000000000000000000000000000000000000001"),
495            token1: address!("0000000000000000000000000000000000000002"),
496            caller: Address::ZERO,
497            burn: true,
498        };
499        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
500        let v3 =
501            wp_evm_v3_core::plan::remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
502        assert_eq!(frag, v3, "ramses delegates burn:true byte-for-byte");
503    }
504
505    #[test]
506    fn claim_cl_gauge_rewards_targets_voter_with_ordered_arrays() {
507        use alloy_sol_types::SolCall;
508        let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
509        let claims = vec![
510            GaugeClaim {
511                gauge: address!("1111111111111111111111111111111111111111"),
512                reward_tokens: vec![
513                    address!("aaaa000000000000000000000000000000000000"),
514                    address!("bbbb000000000000000000000000000000000000"),
515                ],
516                token_ids: vec![U256::from(10u64), U256::from(11u64)],
517            },
518            GaugeClaim {
519                gauge: address!("2222222222222222222222222222222222222222"),
520                reward_tokens: vec![address!("cccc000000000000000000000000000000000000")],
521                token_ids: vec![U256::from(20u64)],
522            },
523        ];
524        let frag = claim_cl_gauge_rewards(voter, &claims);
525
526        assert_eq!(frag.calls.len(), 1);
527        assert_eq!(frag.calls[0].target, voter);
528        assert!(frag.approvals.is_empty());
529        assert_eq!(frag.value, U256::ZERO);
530        // selector claimClGaugeRewards(address[],address[][],uint256[][])
531        assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
532
533        // Decode and assert positional array order (the byte-equality invariant).
534        let decoded =
535            wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall::abi_decode(
536                &frag.calls[0].calldata,
537            )
538            .expect("decode");
539        assert_eq!(decoded._gauges, vec![claims[0].gauge, claims[1].gauge]);
540        assert_eq!(decoded._tokens[0], claims[0].reward_tokens);
541        assert_eq!(decoded._nfpTokenIds[1], claims[1].token_ids);
542
543        // Byte-equality vs an independently-built reference call (raw arrays,
544        // NOT via the GaugeClaim mapping) — pins the GaugeClaim->arg mapping
545        // byte-for-byte, which the structural decode above cannot catch.
546        let reference = wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall {
547            _gauges: vec![
548                address!("1111111111111111111111111111111111111111"),
549                address!("2222222222222222222222222222222222222222"),
550            ],
551            _tokens: vec![
552                vec![
553                    address!("aaaa000000000000000000000000000000000000"),
554                    address!("bbbb000000000000000000000000000000000000"),
555                ],
556                vec![address!("cccc000000000000000000000000000000000000")],
557            ],
558            _nfpTokenIds: vec![vec![U256::from(10u64), U256::from(11u64)], vec![U256::from(20u64)]],
559        }
560        .abi_encode();
561        assert_eq!(&frag.calls[0].calldata[..], &reference[..]);
562    }
563
564    #[test]
565    fn claim_gauge_targets_gauge_with_get_reward_selector() {
566        let gauge = address!("4444444444444444444444444444444444444444");
567        let frag = claim_gauge(gauge, U256::from(42u64));
568        assert_eq!(frag.calls.len(), 1);
569        assert_eq!(frag.calls[0].target, gauge);
570        assert!(frag.approvals.is_empty());
571        assert_eq!(frag.value, U256::ZERO);
572        // getReward(uint256) = 0x1c4b774b
573        assert_eq!(&frag.calls[0].calldata[..4], &[0x1c, 0x4b, 0x77, 0x4b]);
574    }
575
576    #[test]
577    fn claim_cl_gauge_rewards_from_grids_none_when_empty_or_all_zero() {
578        let voter = Address::from([9u8; 20]);
579        // all-zero grid prunes to nothing -> None
580        let zero = grid(0x11, 1, &[1], vec![vec![0]]);
581        assert!(claim_cl_gauge_rewards_from_grids(voter, &[zero]).is_none());
582        // empty input -> None
583        assert!(claim_cl_gauge_rewards_from_grids(voter, &[]).is_none());
584    }
585
586    #[test]
587    fn claim_cl_gauge_rewards_from_grids_some_targets_voter_when_nonzero() {
588        let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
589        let g = grid(0x11, 1, &[1], vec![vec![7]]);
590        let frag = claim_cl_gauge_rewards_from_grids(voter, &[g]).expect("nonzero -> Some");
591        assert_eq!(frag.calls[0].target, voter);
592        assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
593    }
594
595    fn grid(
596        gauge_byte: u8,
597        tokens: usize,
598        ids: &[u64],
599        earned: Vec<Vec<u64>>,
600    ) -> crate::data::GaugeEarnedGrid {
601        crate::data::GaugeEarnedGrid {
602            gauge: Address::from([gauge_byte; 20]),
603            reward_tokens: (0..tokens).map(|i| Address::from([i as u8 + 1; 20])).collect(),
604            token_ids: ids.iter().map(|&i| U256::from(i)).collect(),
605            earned: earned.into_iter().map(|r| r.into_iter().map(U256::from).collect()).collect(),
606        }
607    }
608
609    #[test]
610    fn build_gauge_claims_drops_all_zero_token_and_position() {
611        // 2 tokens x 2 positions. token[1] all-zero -> dropped.
612        // position[0] all-zero -> dropped. Survivor: token[0] x position[1].
613        let g = grid(0x11, 2, &[100, 101], vec![vec![0, 5], vec![0, 0]]);
614        let claims = build_gauge_claims(&[g]);
615        assert_eq!(claims.len(), 1);
616        assert_eq!(claims[0].reward_tokens, vec![Address::from([1u8; 20])]);
617        assert_eq!(claims[0].token_ids, vec![U256::from(101u64)]);
618    }
619
620    #[test]
621    fn build_gauge_claims_drops_fully_empty_gauge() {
622        let g = grid(0x22, 2, &[1, 2], vec![vec![0, 0], vec![0, 0]]);
623        assert!(build_gauge_claims(&[g]).is_empty());
624    }
625
626    #[test]
627    fn build_gauge_claims_keeps_multiple_gauges() {
628        let a = grid(0x11, 1, &[1], vec![vec![9]]);
629        let b = grid(0x22, 1, &[2], vec![vec![0]]); // dropped
630        let c = grid(0x33, 1, &[3], vec![vec![3]]);
631        let claims = build_gauge_claims(&[a, b, c]);
632        assert_eq!(claims.len(), 2);
633        assert_eq!(claims[0].gauge, Address::from([0x11u8; 20]));
634        assert_eq!(claims[1].gauge, Address::from([0x33u8; 20]));
635    }
636
637    #[test]
638    fn build_gauge_claims_empty_input_empty_output() {
639        assert!(build_gauge_claims(&[]).is_empty());
640    }
641}