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, 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
230/// Build an atomic `multicall(decreaseLiquidity, collect)` plan fragment
231/// for a Ramses-family NFPM position.
232///
233/// Delegates straight to `wp_evm_v3_core::plan::remove_liquidity_and_collect`
234/// — Ramses-fork NFPMs inherit V3's `decreaseLiquidity` + `collect` + outer
235/// `multicall(bytes[])` byte-for-byte (the family's structural divergence
236/// from V3 is `tickSpacing` instead of `fee` in mint params; remove+collect
237/// has no `fee` / `tickSpacing` field on the inner ABI). Mirrors this
238/// module's `remove_liquidity` one-line delegation.
239///
240/// Native-unwrap composition is NOT done here — the facade layer pairs
241/// this with `compose_native_remove_collect_multicall`.
242pub 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
254/// Encode a batched Ramses-family CL gauge reward claim.
255///
256/// Produces a single `Call` to the **Voter** (`claimClGaugeRewards`),
257/// `value: 0`, no approvals. `voter` is caller-supplied (no config lookup) —
258/// matching `claim_gauge`'s param style. `claims` carries one entry per
259/// gauge; pass an empty slice and you get an empty-array call (caller's
260/// responsibility to avoid).
261pub 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
279/// Encode a single-token Velodrome/Aerodrome gauge claim
280/// (`getReward(uint256)` on the gauge). Hoisted from the Slipstream facade so
281/// the selector is interface-locked in `wp-evm-velodrome-interfaces`.
282pub 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
291/// Group raw `earned` grids into claimable `GaugeClaim`s, pruning per-pair
292/// zeros. A reward token is kept only if some position earns it; a position
293/// is kept only if it earns some token. A gauge emptied by pruning is
294/// dropped. Never inspects `isAlive` — de-listed gauges still hold claimable
295/// `earned`. Mirrors arbitrage `filter_items_with_non_zero_rewards`.
296pub 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
317/// Prune raw `earned` grids and encode the batched claim in one step. Returns
318/// `None` when nothing survives pruning — the caller's "never submit an empty
319/// claim tx" contract. `voter` is caller-supplied. Pure, so the `None`-collapse
320/// is unit-testable without a provider (the async facade is a thin wrapper).
321pub 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        // Outer call must be `multicall(bytes[])` (selector `0xac9650d8`,
423        // universal across V3/Ramses/Velodrome) over exactly 2 inner
424        // calls: decreaseLiquidity then collect.
425        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        };
436        let frag = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
437        assert_eq!(frag.calls.len(), 1);
438        assert_eq!(frag.calls[0].target, POSITION_MANAGER);
439        assert_eq!(
440            &frag.calls[0].calldata[..4],
441            &[0xac, 0x96, 0x50, 0xd8],
442            "outer selector must be multicall(bytes[])"
443        );
444        assert!(frag.approvals.is_empty());
445        assert_eq!(frag.value, U256::ZERO);
446
447        // Decode outer and assert inner pair: decreaseLiquidity (0x0c49ccbe)
448        // followed by collect (0xfc6f7865). These selectors are V3-canonical
449        // and Ramses inherits them via the v3-core delegation.
450        let (inner,): (Vec<alloy_primitives::Bytes>,) =
451            <(Vec<alloy_primitives::Bytes>,)>::abi_decode_params(&frag.calls[0].calldata[4..])
452                .expect("decode outer multicall params");
453        assert_eq!(inner.len(), 2);
454        assert_eq!(&inner[0][..4], &[0x0c, 0x49, 0xcc, 0xbe], "inner[0] = decreaseLiquidity");
455        assert_eq!(&inner[1][..4], &[0xfc, 0x6f, 0x78, 0x65], "inner[1] = collect");
456    }
457
458    #[test]
459    fn remove_liquidity_and_collect_delegation_matches_v3_core_bytewise() {
460        // Ramses delegates to v3-core verbatim — proving byte-equality
461        // here pins the delegation against accidental local re-encoding.
462        let p = RemoveAndCollectParams {
463            token_id: U256::from(42u64),
464            liquidity: 1_000_000_000_000u128,
465            amount0_min: Some(U256::from(500_000u64)),
466            amount1_min: Some(U256::from(1_000_000_000u64)),
467            recipient: address!("0000000000000000000000000000000000000099"),
468            token0: address!("0000000000000000000000000000000000000001"),
469            token1: address!("0000000000000000000000000000000000000002"),
470            caller: Address::ZERO,
471        };
472        let ours = remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
473        let core =
474            wp_evm_v3_core::plan::remove_liquidity_and_collect(&p, 9_999_999_999, POSITION_MANAGER);
475        assert_eq!(ours.calls[0].calldata, core.calls[0].calldata);
476        assert_eq!(ours.calls[0].target, core.calls[0].target);
477    }
478
479    #[test]
480    fn claim_cl_gauge_rewards_targets_voter_with_ordered_arrays() {
481        use alloy_sol_types::SolCall;
482        let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
483        let claims = vec![
484            GaugeClaim {
485                gauge: address!("1111111111111111111111111111111111111111"),
486                reward_tokens: vec![
487                    address!("aaaa000000000000000000000000000000000000"),
488                    address!("bbbb000000000000000000000000000000000000"),
489                ],
490                token_ids: vec![U256::from(10u64), U256::from(11u64)],
491            },
492            GaugeClaim {
493                gauge: address!("2222222222222222222222222222222222222222"),
494                reward_tokens: vec![address!("cccc000000000000000000000000000000000000")],
495                token_ids: vec![U256::from(20u64)],
496            },
497        ];
498        let frag = claim_cl_gauge_rewards(voter, &claims);
499
500        assert_eq!(frag.calls.len(), 1);
501        assert_eq!(frag.calls[0].target, voter);
502        assert!(frag.approvals.is_empty());
503        assert_eq!(frag.value, U256::ZERO);
504        // selector claimClGaugeRewards(address[],address[][],uint256[][])
505        assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
506
507        // Decode and assert positional array order (the byte-equality invariant).
508        let decoded =
509            wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall::abi_decode(
510                &frag.calls[0].calldata,
511            )
512            .expect("decode");
513        assert_eq!(decoded._gauges, vec![claims[0].gauge, claims[1].gauge]);
514        assert_eq!(decoded._tokens[0], claims[0].reward_tokens);
515        assert_eq!(decoded._nfpTokenIds[1], claims[1].token_ids);
516
517        // Byte-equality vs an independently-built reference call (raw arrays,
518        // NOT via the GaugeClaim mapping) — pins the GaugeClaim->arg mapping
519        // byte-for-byte, which the structural decode above cannot catch.
520        let reference = wp_evm_ramses_interfaces::gauge::IRamsesVoter::claimClGaugeRewardsCall {
521            _gauges: vec![
522                address!("1111111111111111111111111111111111111111"),
523                address!("2222222222222222222222222222222222222222"),
524            ],
525            _tokens: vec![
526                vec![
527                    address!("aaaa000000000000000000000000000000000000"),
528                    address!("bbbb000000000000000000000000000000000000"),
529                ],
530                vec![address!("cccc000000000000000000000000000000000000")],
531            ],
532            _nfpTokenIds: vec![vec![U256::from(10u64), U256::from(11u64)], vec![U256::from(20u64)]],
533        }
534        .abi_encode();
535        assert_eq!(&frag.calls[0].calldata[..], &reference[..]);
536    }
537
538    #[test]
539    fn claim_gauge_targets_gauge_with_get_reward_selector() {
540        let gauge = address!("4444444444444444444444444444444444444444");
541        let frag = claim_gauge(gauge, U256::from(42u64));
542        assert_eq!(frag.calls.len(), 1);
543        assert_eq!(frag.calls[0].target, gauge);
544        assert!(frag.approvals.is_empty());
545        assert_eq!(frag.value, U256::ZERO);
546        // getReward(uint256) = 0x1c4b774b
547        assert_eq!(&frag.calls[0].calldata[..4], &[0x1c, 0x4b, 0x77, 0x4b]);
548    }
549
550    #[test]
551    fn claim_cl_gauge_rewards_from_grids_none_when_empty_or_all_zero() {
552        let voter = Address::from([9u8; 20]);
553        // all-zero grid prunes to nothing -> None
554        let zero = grid(0x11, 1, &[1], vec![vec![0]]);
555        assert!(claim_cl_gauge_rewards_from_grids(voter, &[zero]).is_none());
556        // empty input -> None
557        assert!(claim_cl_gauge_rewards_from_grids(voter, &[]).is_none());
558    }
559
560    #[test]
561    fn claim_cl_gauge_rewards_from_grids_some_targets_voter_when_nonzero() {
562        let voter = address!("9f59398d0a397b2eEB8a6123a6c7295cb0b0062D");
563        let g = grid(0x11, 1, &[1], vec![vec![7]]);
564        let frag = claim_cl_gauge_rewards_from_grids(voter, &[g]).expect("nonzero -> Some");
565        assert_eq!(frag.calls[0].target, voter);
566        assert_eq!(&frag.calls[0].calldata[..4], &[0xea, 0xb3, 0x7e, 0xec]);
567    }
568
569    fn grid(
570        gauge_byte: u8,
571        tokens: usize,
572        ids: &[u64],
573        earned: Vec<Vec<u64>>,
574    ) -> crate::data::GaugeEarnedGrid {
575        crate::data::GaugeEarnedGrid {
576            gauge: Address::from([gauge_byte; 20]),
577            reward_tokens: (0..tokens).map(|i| Address::from([i as u8 + 1; 20])).collect(),
578            token_ids: ids.iter().map(|&i| U256::from(i)).collect(),
579            earned: earned.into_iter().map(|r| r.into_iter().map(U256::from).collect()).collect(),
580        }
581    }
582
583    #[test]
584    fn build_gauge_claims_drops_all_zero_token_and_position() {
585        // 2 tokens x 2 positions. token[1] all-zero -> dropped.
586        // position[0] all-zero -> dropped. Survivor: token[0] x position[1].
587        let g = grid(0x11, 2, &[100, 101], vec![vec![0, 5], vec![0, 0]]);
588        let claims = build_gauge_claims(&[g]);
589        assert_eq!(claims.len(), 1);
590        assert_eq!(claims[0].reward_tokens, vec![Address::from([1u8; 20])]);
591        assert_eq!(claims[0].token_ids, vec![U256::from(101u64)]);
592    }
593
594    #[test]
595    fn build_gauge_claims_drops_fully_empty_gauge() {
596        let g = grid(0x22, 2, &[1, 2], vec![vec![0, 0], vec![0, 0]]);
597        assert!(build_gauge_claims(&[g]).is_empty());
598    }
599
600    #[test]
601    fn build_gauge_claims_keeps_multiple_gauges() {
602        let a = grid(0x11, 1, &[1], vec![vec![9]]);
603        let b = grid(0x22, 1, &[2], vec![vec![0]]); // dropped
604        let c = grid(0x33, 1, &[3], vec![vec![3]]);
605        let claims = build_gauge_claims(&[a, b, c]);
606        assert_eq!(claims.len(), 2);
607        assert_eq!(claims[0].gauge, Address::from([0x11u8; 20]));
608        assert_eq!(claims[1].gauge, Address::from([0x33u8; 20]));
609    }
610
611    #[test]
612    fn build_gauge_claims_empty_input_empty_output() {
613        assert!(build_gauge_claims(&[]).is_empty());
614    }
615}