wp-evm-v4-core 0.1.14

Pure data + quote + plan for v4-family DEXes — no async dependencies
Documentation
//! `PositionInfo` uint256 unpacking for v4 PositionManager NFTs.
//!
//! V4's canonical `getPoolAndPositionInfo(tokenId)` returns a packed
//! uint256 whose layout (LSB-first) is:
//!
//! - Bits 0..8:    hasSubscriber      (8 bits, unsigned)
//! - Bits 8..32:   tickLower          (24 bits, signed)
//! - Bits 32..56:  tickUpper          (24 bits, signed)
//! - Bits 56..256: poolId (truncated) (200 bits — unused downstream)
//!
//! We only extract `tickLower` + `tickUpper` since the full `poolId`
//! is already available via `hydrate::pool_id(&pool_key)`.

use alloy_primitives::U256;
use wp_evm_base::types::SlippageBps;

/// Q128 fixed-point denominator used by Uniswap fee-growth accumulators.
#[cfg(test)]
const Q128: U256 = U256::from_limbs([0, 0, 1, 0]);

/// Bit mask for a 24-bit window.
const MASK_24: u32 = 0xFF_FF_FF;

/// Sign-extend a 24-bit signed integer (stored in the low 24 bits of
/// `raw`) to a full `i32`. Returns `i32` in the range
/// `[-8_388_608, 8_388_607]` — the full i24 range.
fn sign_extend_24(raw: u32) -> i32 {
    let low = raw & MASK_24;
    if low & 0x80_00_00 != 0 {
        (low | 0xFF_00_00_00) as i32
    } else {
        low as i32
    }
}

/// Extract a 32-bit lane starting at `bit_offset` from a `U256`.
fn extract_u32(info: U256, bit_offset: usize) -> u32 {
    let shifted = info >> bit_offset;
    let limbs = shifted.as_limbs();
    limbs[0] as u32
}

/// Decoded PositionInfo.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PositionInfo {
    pub tick_lower: i32,
    pub tick_upper: i32,
    pub has_subscriber: bool,
}

/// Decode a packed `PositionInfo` uint256 returned by
/// `IPositionManagerView.getPoolAndPositionInfo(tokenId)._1`.
pub fn decode_position_info(info: U256) -> PositionInfo {
    let has_subscriber = (info.as_limbs()[0] as u8) != 0;
    let tick_lower = sign_extend_24(extract_u32(info, 8));
    let tick_upper = sign_extend_24(extract_u32(info, 32));
    PositionInfo { tick_lower, tick_upper, has_subscriber }
}

/// Compute fees accrued since the position's last recorded fee-growth snapshot.
///
/// V4 stores fee growth as Q128.128 accumulators. The token amount owed by a
/// position is:
///
/// `(fee_growth_inside_now - fee_growth_inside_last) * liquidity / 2^128`
///
/// The subtraction intentionally wraps like EVM `uint256` arithmetic because
/// fee-growth counters are monotonically increasing modulo `uint256`.
pub fn compute_accrued_fees(
    liquidity: u128,
    fee_growth_inside0_now_x128: U256,
    fee_growth_inside1_now_x128: U256,
    fee_growth_inside0_last_x128: U256,
    fee_growth_inside1_last_x128: U256,
) -> (U256, U256) {
    wp_evm_amm_math::fee_growth::get_tokens_owed(
        fee_growth_inside0_last_x128,
        fee_growth_inside1_last_x128,
        liquidity,
        fee_growth_inside0_now_x128,
        fee_growth_inside1_now_x128,
    )
}

/// Apply "slippage up" to a quoted amount.
pub fn apply_slippage_max(quoted: U256, slippage: SlippageBps) -> U256 {
    let bps = U256::from(slippage.as_bps());
    let denom = U256::from(10_000u64);
    quoted * (denom + bps) / denom
}

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

    fn pack(tick_lower: i32, tick_upper: i32, has_subscriber: bool) -> U256 {
        let lo = (tick_lower as u32) & MASK_24;
        let up = (tick_upper as u32) & MASK_24;
        let mut out = U256::ZERO;
        if has_subscriber {
            out |= U256::from(1u64);
        }
        out |= U256::from(lo) << 8;
        out |= U256::from(up) << 32;
        out
    }

    #[test]
    fn decode_round_trip_positive_ticks() {
        let packed = pack(60, 120, false);
        let pi = decode_position_info(packed);
        assert_eq!(pi.tick_lower, 60);
        assert_eq!(pi.tick_upper, 120);
        assert!(!pi.has_subscriber);
    }

    #[test]
    fn decode_round_trip_negative_ticks() {
        let packed = pack(-60, 60, false);
        let pi = decode_position_info(packed);
        assert_eq!(pi.tick_lower, -60);
        assert_eq!(pi.tick_upper, 60);
    }

    #[test]
    fn decode_round_trip_extreme_ticks() {
        let packed = pack(-887272, 887272, true);
        let pi = decode_position_info(packed);
        assert_eq!(pi.tick_lower, -887272);
        assert_eq!(pi.tick_upper, 887272);
        assert!(pi.has_subscriber);
    }

    #[test]
    fn decode_zero_info_is_all_defaults() {
        let pi = decode_position_info(U256::ZERO);
        assert_eq!(pi.tick_lower, 0);
        assert_eq!(pi.tick_upper, 0);
        assert!(!pi.has_subscriber);
    }

    #[test]
    fn decode_ignores_high_bits_pool_id_noise() {
        let base = pack(-60, 60, false);
        let noise = U256::from_str_radix("deadbeefcafebabedeadbeefcafebabe", 16).unwrap() << 56;
        let polluted = base | noise;
        let pi = decode_position_info(polluted);
        assert_eq!(pi.tick_lower, -60);
        assert_eq!(pi.tick_upper, 60);
    }

    #[test]
    fn compute_accrued_fees_zero_liquidity_is_zero() {
        let (fee0, fee1) = compute_accrued_fees(
            0,
            Q128 * U256::from(10u64),
            Q128 * U256::from(20u64),
            U256::ZERO,
            U256::ZERO,
        );
        assert_eq!(fee0, U256::ZERO);
        assert_eq!(fee1, U256::ZERO);
    }

    #[test]
    fn compute_accrued_fees_scales_q128_deltas_by_liquidity() {
        let (fee0, fee1) = compute_accrued_fees(
            100,
            Q128 * U256::from(3u64),
            Q128 * U256::from(5u64),
            Q128,
            Q128 * U256::from(2u64),
        );
        assert_eq!(fee0, U256::from(200u64));
        assert_eq!(fee1, U256::from(300u64));
    }

    #[test]
    fn compute_accrued_fees_truncates_fractional_tokens() {
        let (fee0, fee1) = compute_accrued_fees(
            3,
            Q128 / U256::from(2u64),
            Q128 + Q128 / U256::from(3u64),
            U256::ZERO,
            U256::ZERO,
        );
        assert_eq!(fee0, U256::from(1u64));
        assert_eq!(fee1, U256::from(3u64));
    }

    #[test]
    fn apply_slippage_max_scales_up() {
        let quoted = U256::from(1_000_000u64);
        let got = apply_slippage_max(quoted, SlippageBps::new(100));
        assert_eq!(got, U256::from(1_010_000u64));
    }

    #[test]
    fn apply_slippage_max_zero_bps_is_identity() {
        let quoted = U256::from(1_000_000u64);
        let got = apply_slippage_max(quoted, SlippageBps::new(0));
        assert_eq!(got, quoted);
    }

    #[test]
    fn apply_slippage_max_10000_bps_doubles_amount() {
        let quoted = U256::from(1_000_000u64);
        let got = apply_slippage_max(quoted, SlippageBps::new(10_000));
        assert_eq!(got, U256::from(2_000_000u64));
    }

    #[test]
    fn apply_slippage_max_zero_quoted_returns_zero() {
        let got = apply_slippage_max(U256::ZERO, SlippageBps::new(250));
        assert_eq!(got, U256::ZERO);
    }
}