wp-evm-ramses-core 0.1.9

Pure data + quote + plan for Ramses-family CL DEXes
Documentation
//! `RamsesPositionView` — lossless 11-field mirror of Ramses-family NFPM
//! `positions(uint256)` plus the shared position-key helper.
//!
//! Differences vs sibling families:
//! - **10 NFPM fields** (vs 11 Algebra / 12 V3) — no `nonce`, no `operator`,
//!   no `fee`. Has `tickSpacing` per-position (V3/Algebra store it on the
//!   pool only). Total view struct = 11 fields after `token_id` echo.
//! - **`position_key` takes 4 params**: `(owner, index, tick_lower, tick_upper)`.
//!   Verified against `RamsesExchange/ramses-v3-contracts` source — see plan.

use alloy_primitives::{aliases::U96, keccak256, Address, B256, U256};
use wp_evm_base::evm::sign_extend_i24;
use wp_evm_ramses_interfaces::periphery::nfpm::IRamsesNonfungiblePositionManager;
use wp_evm_velodrome_interfaces::periphery::nfpm::IVelodromeNonfungiblePositionManager;

/// Faithful Rust mirror of the Ramses-family NFPM 10-field `positions()`
/// return tuple, plus the caller-supplied `token_id` echoed as the first
/// field for round-tripping.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RamsesPositionView {
    /// NFT token ID this position belongs to. Echoed from caller input.
    pub token_id: U256,
    /// Pool token0.
    pub token0: Address,
    /// Pool token1.
    pub token1: Address,
    /// Pool tick spacing (Ramses-family NFPM stores this per-position;
    /// V3 and Algebra do not).
    pub tick_spacing: i32,
    /// Lower tick, sign-extended from int24.
    pub tick_lower: i32,
    /// Upper tick, sign-extended from int24.
    pub tick_upper: i32,
    /// Position liquidity.
    pub liquidity: u128,
    /// Last-touch fee-growth snapshot for token0.
    pub fee_growth_inside_0_last_x128: U256,
    /// Last-touch fee-growth snapshot for token1.
    pub fee_growth_inside_1_last_x128: U256,
    /// Stale token0 owed snapshot from NFPM.
    pub tokens_owed_0_stale: u128,
    /// Stale token1 owed snapshot from NFPM.
    pub tokens_owed_1_stale: u128,
}

impl RamsesPositionView {
    /// Decode a Ramses-family NFPM `positions(uint256)` return tuple.
    pub fn from_nfpm_returns(
        token_id: U256,
        ret: &IRamsesNonfungiblePositionManager::positionsReturn,
    ) -> Self {
        Self {
            token_id,
            token0: ret.token0,
            token1: ret.token1,
            tick_spacing: sign_extend_i24(ret.tickSpacing),
            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,
        }
    }
}

/// 12-field Velodrome NFPM position row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VelodromePositionRow {
    /// NFPM nonce.
    pub nonce: U96,
    /// Approved operator.
    pub operator: Address,
    /// Pool token0.
    pub token0: Address,
    /// Pool token1.
    pub token1: Address,
    /// Pool tick spacing.
    pub tick_spacing: i32,
    /// Lower tick, sign-extended from int24.
    pub tick_lower: i32,
    /// Upper tick, sign-extended from int24.
    pub tick_upper: i32,
    /// Position liquidity.
    pub liquidity: u128,
    /// Last-touch fee-growth snapshot for token0.
    pub fee_growth_inside_0_last_x128: U256,
    /// Last-touch fee-growth snapshot for token1.
    pub fee_growth_inside_1_last_x128: U256,
    /// Stale token0 owed snapshot from NFPM.
    pub tokens_owed_0_stale: u128,
    /// Stale token1 owed snapshot from NFPM.
    pub tokens_owed_1_stale: u128,
}

impl VelodromePositionRow {
    /// Decode a Velodrome-family NFPM `positions(uint256)` return tuple.
    pub fn from_nfpm_returns(
        _token_id: U256,
        ret: &IVelodromeNonfungiblePositionManager::positionsReturn,
    ) -> Self {
        Self {
            nonce: ret.nonce,
            operator: ret.operator,
            token0: ret.token0,
            token1: ret.token1,
            tick_spacing: sign_extend_i24(ret.tickSpacing),
            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 Ramses-family pool position key.
///
/// `keccak256(abi.encodePacked(owner, index, tick_lower, tick_upper))` —
/// 20 + 32 + 3 + 3 = **58 bytes**. Verified against
/// `RamsesExchange/ramses-v3-contracts/contracts/CL/core/libraries/Position.sol`
/// and `contracts/CL/periphery/libraries/PositionKey.sol` on 2026-04-28.
///
/// For NFPM-managed positions, `owner` is the NFPM contract address and
/// `index` is the NFT `tokenId`. Raw pool depositors pass their own
/// `(owner, index)`.
///
/// **Panics** if either tick is outside the int24 range
/// `[-2^23, 2^23 - 1]` — out-of-range ticks would silently truncate in
/// `i24_to_be_bytes` and produce a wrong hash. Realistic Uniswap-style
/// ticks live well inside int24 (`±887_272`).
pub fn position_key(owner: Address, index: U256, tick_lower: i32, tick_upper: i32) -> B256 {
    assert!(
        (-8_388_608..=8_388_607).contains(&tick_lower),
        "tick_lower {tick_lower} outside int24 range",
    );
    assert!(
        (-8_388_608..=8_388_607).contains(&tick_upper),
        "tick_upper {tick_upper} outside int24 range",
    );

    let mut buf = [0u8; 58];
    buf[..20].copy_from_slice(owner.as_slice());
    buf[20..52].copy_from_slice(&index.to_be_bytes::<32>());
    buf[52..55].copy_from_slice(&i24_to_be_bytes(tick_lower));
    buf[55..58].copy_from_slice(&i24_to_be_bytes(tick_upper));
    keccak256(buf)
}

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, aliases::I24, b256};

    /// Synthetic reference vector. Locks the 58-byte buffer layout
    /// (20 owner + 32 index + 3 lo + 3 hi) against silent off-by-one /
    /// width drift.
    ///
    /// Computed offline 2026-04-28 with `cast keccak`:
    /// ```text
    /// owner   = 0x000000000000000000000000000000000000dEaD  (20 bytes)
    /// index   = 0x0...0                                       (32 bytes)
    /// tickLo  = -887_188 → 24-bit two's-complement low bytes 0xf2766c
    /// tickHi  = +887_188 → low bytes 0x0d8994
    /// packed  = owner ‖ index ‖ tickLo ‖ tickHi   (58 bytes)
    /// keccak  = 0x3b5f5df2f36078cb5495dbec069ca73eed9072adad9c8b0dcaced3689b802b73
    /// ```
    #[test]
    fn position_key_synthetic_reference_vector() {
        let key = position_key(
            address!("000000000000000000000000000000000000dEaD"),
            U256::ZERO,
            -887_188,
            887_188,
        );
        assert_eq!(key, b256!("3b5f5df2f36078cb5495dbec069ca73eed9072adad9c8b0dcaced3689b802b73"),);
    }

    /// On-chain reference vector — Shadow Sonic NFPM tokenId 1_156_688
    /// at block 68_964_603. The expected key was verified end-to-end:
    /// `pool.positions(this_key)` returns the same liquidity /
    /// feeGrowthInside* / tokensOwed* values that
    /// `NFPM.positions(1_156_688)` returns (see plan reference vector
    /// table; see also the active-position decode test in
    /// `wp-evm-ramses-interfaces` PR #96).
    #[test]
    fn position_key_real_shadow_position_matches_pool() {
        let nfpm = address!("12E66C8F215DdD5d48d150c8f46aD0c6fB0F4406");
        let token_id = U256::from(1_156_688u64);
        let key = position_key(nfpm, token_id, -887_250, 887_250);
        assert_eq!(key, b256!("4b6f08061b30865c2b49c44f15339f787b09d20d2429d42fbe3ed5de6199776c"),);
    }

    /// Round-trip from a synthetic NFPM return through `from_nfpm_returns`.
    /// Locks the field-by-field copy + sign-extension wiring.
    #[test]
    fn from_nfpm_returns_sign_extends_ticks_and_preserves_fields() {
        let ret = IRamsesNonfungiblePositionManager::positionsReturn {
            token0: address!("0000000000000000000000000000000000000001"),
            token1: address!("0000000000000000000000000000000000000002"),
            tickSpacing: I24::try_from(50i32).unwrap(),
            tickLower: I24::try_from(-120i32).unwrap(),
            tickUpper: I24::try_from(240i32).unwrap(),
            liquidity: 42,
            feeGrowthInside0LastX128: U256::from(7),
            feeGrowthInside1LastX128: U256::from(8),
            tokensOwed0: 9,
            tokensOwed1: 10,
        };
        let view = RamsesPositionView::from_nfpm_returns(U256::from(1), &ret);
        assert_eq!(view.token_id, U256::from(1));
        assert_eq!(view.token0, address!("0000000000000000000000000000000000000001"));
        assert_eq!(view.token1, address!("0000000000000000000000000000000000000002"));
        assert_eq!(view.tick_spacing, 50);
        assert_eq!(view.tick_lower, -120);
        assert_eq!(view.tick_upper, 240);
        assert_eq!(view.liquidity, 42);
        assert_eq!(view.fee_growth_inside_0_last_x128, U256::from(7));
        assert_eq!(view.fee_growth_inside_1_last_x128, U256::from(8));
        assert_eq!(view.tokens_owed_0_stale, 9);
        assert_eq!(view.tokens_owed_1_stale, 10);
    }

    /// Decode the active-position fixture (Sonic block 68_964_603,
    /// tokenId 1_156_688) from PR #96 through `from_nfpm_returns`.
    /// Cross-locks the L1 view layer against the L0 ABI codec layer.
    #[test]
    fn from_nfpm_returns_decodes_real_shadow_active_position() {
        let raw = alloy_primitives::hex!(
            "000000000000000000000000039e2fb66102314ce7b64ce5ce3e5183bc94ad38"
            "00000000000000000000000050c42deacd8fc9773493ed674b675be577f2634b"
            "0000000000000000000000000000000000000000000000000000000000000032"
            "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff2762e"
            "00000000000000000000000000000000000000000000000000000000000d89d2"
            "00000000000000000000000000000000000000000000000342c6d2155c8af6b6"
            "00000000000000000000000000000000003c34f4fcee3e358e2c80986cddcc72"
            "000000000000000000000000000000000000029cdd807204b5d3d79e9ac52169"
            "0000000000000000000000000000000000000000000000000000000000000000"
            "0000000000000000000000000000000000000000000000000000000000000000"
        );
        use alloy_sol_types::SolCall;
        let ret = IRamsesNonfungiblePositionManager::positionsCall::abi_decode_returns(&raw)
            .expect("fixture decodes");

        let view = RamsesPositionView::from_nfpm_returns(U256::from(1_156_688u64), &ret);

        assert_eq!(view.token_id, U256::from(1_156_688u64));
        assert_eq!(view.token0, address!("039e2fb66102314ce7b64ce5ce3e5183bc94ad38"));
        assert_eq!(view.token1, address!("50c42deacd8fc9773493ed674b675be577f2634b"));
        assert_eq!(view.tick_spacing, 50);
        assert_eq!(view.tick_lower, -887_250);
        assert_eq!(view.tick_upper, 887_250);
        assert_eq!(view.liquidity, 60_151_996_462_209_365_686u128);
        assert_eq!(
            view.fee_growth_inside_0_last_x128,
            U256::from(312_611_906_761_373_619_763_565_392_889_236_594u128),
        );
        assert_eq!(
            view.fee_growth_inside_1_last_x128,
            U256::from(52_992_964_027_640_673_521_461_442_716_009u128),
        );
        assert_eq!(view.tokens_owed_0_stale, 0);
        assert_eq!(view.tokens_owed_1_stale, 0);
    }

    #[test]
    #[should_panic(expected = "tick_lower")]
    fn position_key_panics_on_oversized_tick_lower() {
        position_key(Address::ZERO, U256::ZERO, 8_388_608, 0);
    }

    #[test]
    #[should_panic(expected = "tick_upper")]
    fn position_key_panics_on_oversized_tick_upper() {
        position_key(Address::ZERO, U256::ZERO, 0, -8_388_609);
    }
}