wp-evm-algebra-core 0.1.14

Pure data + quote + plan for Algebra-family CL DEXes
Documentation
//! Pure Algebra eternal-farming claim planning (no provider, no clock).

use std::collections::HashSet;

use alloy_primitives::{Address, Bytes, U256};
use alloy_sol_types::SolCall;
use wp_evm_algebra_interfaces::farming::{IFarmingCenter, IMulticall};
use wp_evm_base::types::{Call, PlanFragment};

use crate::data::{FarmingClaim, FarmingEarnedGrid};

/// Drop positions whose reward AND bonus are both zero; drop incentives with no
/// surviving positions. Mirrors blackhole-lib reward.rs prune + ramses build_gauge_claims.
pub fn build_farming_claims(grids: &[FarmingEarnedGrid]) -> Vec<FarmingClaim> {
    let mut claims = Vec::new();
    for g in grids {
        let mut ids = Vec::new();
        for i in 0..g.token_ids.len() {
            if g.reward[i] > U256::ZERO || g.bonus_reward[i] > U256::ZERO {
                ids.push(g.token_ids[i]);
            }
        }
        if !ids.is_empty() {
            claims.push(FarmingClaim { incentive_key: g.incentive_key.clone(), token_ids: ids });
        }
    }
    claims
}

/// Encode the two-phase collect+claim into one FarmingCenter call.
/// Phase 1: collectRewards(key, tokenId) per (claim, tokenId).
/// Phase 2: claimReward(token, recipient, 0) per unique rewardToken AND bonusRewardToken
/// (both added unconditionally — matches blackhole-lib; a zero bonus emits claimReward(0x0,…)).
/// Returns a single Call to `farming_center`, value 0, no approvals. None if `claims` empty.
pub fn plan_collect_and_claim(
    farming_center: Address,
    claims: &[FarmingClaim],
    recipient: Address,
) -> Option<PlanFragment> {
    if claims.is_empty() {
        return None;
    }
    let mut inner: Vec<Bytes> = Vec::new();
    let mut tokens: HashSet<Address> = HashSet::new();
    for c in claims {
        for &tid in &c.token_ids {
            inner.push(
                IFarmingCenter::collectRewardsCall { key: c.incentive_key.clone(), tokenId: tid }
                    .abi_encode()
                    .into(),
            );
            tokens.insert(c.incentive_key.rewardToken);
            tokens.insert(c.incentive_key.bonusRewardToken);
        }
    }
    if inner.is_empty() {
        return None;
    }
    for token in tokens {
        inner.push(
            IFarmingCenter::claimRewardCall {
                rewardToken: token,
                to: recipient,
                amountRequested: U256::ZERO,
            }
            .abi_encode()
            .into(),
        );
    }
    // Raw single call, else wrap in multicall(bytes[]) — mirror blackhole-lib encode_multicall.
    let calldata: Bytes = if inner.len() == 1 {
        inner.into_iter().next().unwrap()
    } else {
        IMulticall::multicallCall { data: inner }.abi_encode().into()
    };
    Some(PlanFragment {
        calls: vec![Call { target: farming_center, calldata, value: U256::ZERO }],
        approvals: vec![],
        value: U256::ZERO,
    })
}

/// Reader-fed convenience: prune grids then plan. None when nothing survives.
pub fn claim_from_grids(
    farming_center: Address,
    grids: &[FarmingEarnedGrid],
    recipient: Address,
) -> Option<PlanFragment> {
    plan_collect_and_claim(farming_center, &build_farming_claims(grids), recipient)
}

#[cfg(test)]
mod tests {
    use super::*;
    use wp_evm_algebra_interfaces::farming::IncentiveKey;

    fn key(r: u8, b: u8, p: u8) -> IncentiveKey {
        IncentiveKey {
            rewardToken: Address::repeat_byte(r),
            bonusRewardToken: Address::repeat_byte(b),
            pool: Address::repeat_byte(p),
            nonce: U256::from(1),
        }
    }

    #[test]
    fn prune_drops_all_zero_positions_and_collapses_to_none() {
        let g = FarmingEarnedGrid {
            pool: Address::repeat_byte(9),
            incentive_key: key(1, 2, 9),
            token_ids: vec![U256::from(1), U256::from(2)],
            reward: vec![U256::ZERO, U256::ZERO],
            bonus_reward: vec![U256::ZERO, U256::ZERO],
        };
        assert!(build_farming_claims(std::slice::from_ref(&g)).is_empty());
        assert!(claim_from_grids(Address::repeat_byte(7), &[g], Address::repeat_byte(8)).is_none());
    }

    #[test]
    fn plan_collect_and_claim_returns_none_when_claims_have_no_token_ids() {
        let claims = vec![FarmingClaim { incentive_key: key(1, 2, 9), token_ids: vec![] }];
        assert!(plan_collect_and_claim(Address::repeat_byte(7), &claims, Address::repeat_byte(8))
            .is_none());
    }

    #[test]
    fn plan_decodes_to_expected_shape() {
        // Self-consistency: decode the plan's own multicall and assert the expected
        // structure (1 collectRewards + 2 claimReward for the dual tokens). Like the
        // blackhole-lib encoder, the claimReward set comes from a HashSet, so order is
        // non-deterministic — compare DECODED + SORTED inner calls. (Cross-impl byte
        // parity rests on the verified selectors + CREATE2 fixture + the faithful
        // transcription of blackhole's encoder, not on this shape check.)
        let recipient = Address::repeat_byte(8);
        let farming_center = Address::repeat_byte(7);
        let incentive_key = key(1, 2, 9);
        let claims = vec![FarmingClaim {
            incentive_key: incentive_key.clone(),
            token_ids: vec![U256::from(1)],
        }];
        let frag = plan_collect_and_claim(farming_center, &claims, recipient).unwrap();

        assert_eq!(frag.calls.len(), 1);
        assert_eq!(frag.calls[0].target, farming_center);
        assert!(frag.approvals.is_empty());
        assert_eq!(frag.value, U256::ZERO);
        assert_eq!(frag.calls[0].value, U256::ZERO);

        let outer = IMulticall::multicallCall::abi_decode(&frag.calls[0].calldata)
            .expect("decode multicall");
        assert_eq!(outer.data.len(), 3);

        let mut collects = Vec::new();
        let mut claim_tokens = Vec::new();
        for call in outer.data {
            if call.starts_with(IFarmingCenter::collectRewardsCall::SELECTOR.as_slice()) {
                let decoded = IFarmingCenter::collectRewardsCall::abi_decode(&call)
                    .expect("decode collectRewards");
                collects.push((decoded.key, decoded.tokenId));
            } else if call.starts_with(IFarmingCenter::claimRewardCall::SELECTOR.as_slice()) {
                let decoded =
                    IFarmingCenter::claimRewardCall::abi_decode(&call).expect("decode claimReward");
                assert_eq!(decoded.to, recipient);
                assert_eq!(decoded.amountRequested, U256::ZERO);
                claim_tokens.push(decoded.rewardToken);
            } else {
                panic!(
                    "unexpected inner selector: 0x{}",
                    alloy_primitives::hex::encode(&call[..4])
                );
            }
        }

        assert_eq!(collects, vec![(incentive_key, U256::from(1))]);
        claim_tokens.sort();
        assert_eq!(claim_tokens, vec![Address::repeat_byte(1), Address::repeat_byte(2)]);
    }
}