wp-evm-v3-core 0.1.1

Pure data + quote + plan for v3-family (CL) DEXes — no async dependencies
Documentation
//! `PositionView` — faithful 12-field Rust mirror of Uniswap V3's NFPM
//! `positions(uint256)` return tuple, plus the `position_key` helper
//! matching V3 pools' internal `positions[bytes32]` mapping key.
//!
//! Distinct from the existing 10-field `data::PositionState`:
//! `PositionState` is the legacy "user-friendly" view consumed by
//! `hydrate::position_state` (it merges `tokensOwed*` into `fees_owed_*`
//! and drops `nonce` / `operator` / `feeGrowthInside*LastX128`).
//! `PositionView` is the loss-less ABI mirror needed by the R9 batch
//! reader and fee-growth math (Slice C / E).
//!
//! Sources:
//! - <https://github.com/Uniswap/v3-periphery/blob/main/contracts/interfaces/INonfungiblePositionManager.sol>
//! - <https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/Position.sol> (position_key formula)

use alloy_primitives::{keccak256, Address, B256, U256};
use wp_evm_base::evm::sign_extend_i24;
use wp_evm_v3_interfaces::periphery::nfpm::INonfungiblePositionManagerView;

/// Faithful 12-field Rust mirror of NFPM's `positions(uint256)` return
/// tuple. All field names match upstream Solidity 1:1 (snake_case'd).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PositionView {
    /// NFT token ID this position is bound to. Echoed in from the caller
    /// — NFPM doesn't return it from `positions()`.
    pub token_id: U256,
    /// Permit nonce. Increments on each ERC-721 permit signature.
    pub nonce: U256,
    /// Address approved to spend this token (per ERC-721). Often
    /// `address(0)` on idle positions.
    pub operator: Address,
    /// Pool's `token0` (lower address-sort).
    pub token0: Address,
    /// Pool's `token1` (higher address-sort).
    pub token1: Address,
    /// V3 fee tier in pips (1 pip = 1/1_000_000).
    pub fee: u32,
    /// Lower tick of the position's range. Sign-extended from the
    /// on-chain `int24` to `i32`.
    pub tick_lower: i32,
    /// Upper tick of the position's range.
    pub tick_upper: i32,
    /// Liquidity contributed by this position.
    pub liquidity: u128,
    /// Snapshot of the pool's `feeGrowthInside0X128` at the most recent
    /// touch (mint / inc / dec / collect / burn). Compare against the
    /// pool's *current* `feeGrowthInside0X128` to compute uncollected
    /// fees — see `wp-evm-amm-math::fee_growth::get_tokens_owed`.
    pub fee_growth_inside_0_last_x128: U256,
    /// Same for token1.
    pub fee_growth_inside_1_last_x128: U256,
    /// **Stale** uncollected token0 fees as of the last touch. Idle
    /// positions' real current owed fees must be derived via
    /// fee-growth math; this field is preserved for parity with the
    /// upstream tuple but should not be trusted as "current."
    pub tokens_owed_0_stale: u128,
    /// Same for token1.
    pub tokens_owed_1_stale: u128,
}

impl PositionView {
    /// Decode an NFPM `positions(uint256)` return tuple into a
    /// `PositionView`. Sign-extends the two `int24` tick fields and
    /// downcasts `uint96 nonce` / `uint24 fee` to canonical Rust widths.
    pub fn from_nfpm_returns(
        token_id: U256,
        ret: &INonfungiblePositionManagerView::positionsReturn,
    ) -> Self {
        Self {
            token_id,
            nonce: U256::from(ret.nonce),
            operator: ret.operator,
            token0: ret.token0,
            token1: ret.token1,
            fee: ret.fee.to(),
            tick_lower: sign_extend_i24(ret.tickLower),
            tick_upper: sign_extend_i24(ret.tickUpper),
            liquidity: ret.liquidity,
            fee_growth_inside_0_last_x128: ret.feeGrowthInside0LastX128,
            fee_growth_inside_1_last_x128: ret.feeGrowthInside1LastX128,
            tokens_owed_0_stale: ret.tokensOwed0,
            tokens_owed_1_stale: ret.tokensOwed1,
        }
    }
}

/// Compute the V3 pool's internal position-mapping key for a given
/// `(owner, tick_lower, tick_upper)` triple.
///
/// Mirrors `Position.sol`'s storage key:
///
/// ```solidity
/// bytes32 positionKey = keccak256(abi.encodePacked(owner, tickLower, tickUpper));
/// ```
///
/// The two ticks are encoded as `int24` (3 bytes each, big-endian, sign-
/// extended on the high bit). Useful when reading position storage
/// directly from the pool's `positions(bytes32)` mapping — bypassing NFPM.
///
/// # Panics
///
/// In debug builds, panics if either tick falls outside the `int24`
/// representable range (`-8_388_608..=8_388_607`). Release builds
/// truncate to the low 24 bits, matching Solidity `int24` casting semantics.
pub fn position_key(owner: Address, tick_lower: i32, tick_upper: i32) -> B256 {
    debug_assert!(
        (-8_388_608..=8_388_607).contains(&tick_lower),
        "tick_lower {} out of int24 range",
        tick_lower
    );
    debug_assert!(
        (-8_388_608..=8_388_607).contains(&tick_upper),
        "tick_upper {} out of int24 range",
        tick_upper
    );

    // abi.encodePacked(address, int24, int24) = 20 + 3 + 3 = 26 bytes.
    let mut buf = [0u8; 26];
    buf[..20].copy_from_slice(owner.as_slice());
    buf[20..23].copy_from_slice(&i24_to_be_bytes(tick_lower));
    buf[23..26].copy_from_slice(&i24_to_be_bytes(tick_upper));
    keccak256(buf)
}

/// Encode a sign-extended `i32` as 3 big-endian bytes (Solidity `int24`).
/// Mirrors EVM's truncation-on-cast: high byte ignored.
fn i24_to_be_bytes(tick: i32) -> [u8; 3] {
    let bytes = tick.to_be_bytes();
    [bytes[1], bytes[2], bytes[3]]
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloy_primitives::{address, b256};

    #[test]
    fn position_key_matches_solidity_reference() {
        // Reference vector — derived via:
        //   cast keccak 0x000000000000000000000000000000000000deadf2764c0d89b4
        // (= keccak256(abi.encodePacked(addr, int24, int24)) of an
        // owner = 0xdead, full-range mainnet position.)
        let owner: Address = address!("000000000000000000000000000000000000dEaD");
        let tick_lower: i32 = -887_220;
        let tick_upper: i32 = 887_220;

        let key = position_key(owner, tick_lower, tick_upper);

        // Pre-computed expected: keccak256 of:
        //   0xdead bytes (20)  ||  0xf2764c (int24 -887220)  ||  0x0d89b4 (int24 887220)
        // = 26 bytes total.
        let expected = b256!("1de7686f3203885e60772ba1129871b5a5d3c9b611ecdb79223ca2e8da545eb2");
        assert_eq!(key, expected);
    }

    #[test]
    fn position_key_is_order_sensitive() {
        let owner: Address = address!("000000000000000000000000000000000000dEaD");
        let key_normal = position_key(owner, -100, 100);
        let key_swapped = position_key(owner, 100, -100);
        assert_ne!(key_normal, key_swapped);
    }

    #[test]
    fn position_view_round_trips_int24_negative_ticks() {
        use alloy_primitives::aliases::{I24, U24, U96};

        let token0: Address = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
        let token1: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");

        let ret = INonfungiblePositionManagerView::positionsReturn {
            nonce: U96::ZERO,
            operator: Address::ZERO,
            token0,
            token1,
            fee: U24::from(500u32),
            tickLower: I24::try_from(-887_220i32).unwrap(),
            tickUpper: I24::try_from(887_220i32).unwrap(),
            liquidity: 1_000_000_000_000_000_000u128,
            feeGrowthInside0LastX128: U256::from(42u64),
            feeGrowthInside1LastX128: U256::from(99u64),
            tokensOwed0: 0u128,
            tokensOwed1: 0u128,
        };

        let view = PositionView::from_nfpm_returns(U256::from(12_345u64), &ret);

        // The whole point: int24 sign-extension survives.
        assert_eq!(view.tick_lower, -887_220);
        assert_eq!(view.tick_upper, 887_220);
        assert_eq!(view.fee, 500);
        assert_eq!(view.token_id, U256::from(12_345u64));
        assert_eq!(view.fee_growth_inside_0_last_x128, U256::from(42u64));
        assert_eq!(view.fee_growth_inside_1_last_x128, U256::from(99u64));
    }

    #[test]
    fn i24_to_be_bytes_handles_min_and_max() {
        // int24 max =  8_388_607 → 0x7fffff
        assert_eq!(i24_to_be_bytes(8_388_607), [0x7f, 0xff, 0xff]);
        // int24 min = -8_388_608 → 0x800000
        assert_eq!(i24_to_be_bytes(-8_388_608), [0x80, 0x00, 0x00]);
        // 0 → 0x000000
        assert_eq!(i24_to_be_bytes(0), [0x00, 0x00, 0x00]);
        // -1 (sign-extended in i32 is 0xffffffff) → low 3 bytes = 0xffffff
        assert_eq!(i24_to_be_bytes(-1), [0xff, 0xff, 0xff]);
    }
}