Skip to main content

wp_evm_ramses_core/
position.rs

1//! `RamsesPositionView` — lossless 11-field mirror of Ramses-family NFPM
2//! `positions(uint256)` plus the shared position-key helper.
3//!
4//! Differences vs sibling families:
5//! - **10 NFPM fields** (vs 11 Algebra / 12 V3) — no `nonce`, no `operator`,
6//!   no `fee`. Has `tickSpacing` per-position (V3/Algebra store it on the
7//!   pool only). Total view struct = 11 fields after `token_id` echo.
8//! - **`position_key` takes 4 params**: `(owner, index, tick_lower, tick_upper)`.
9//!   Verified against `RamsesExchange/ramses-v3-contracts` source — see plan.
10
11use alloy_primitives::{aliases::U96, keccak256, Address, B256, U256};
12use wp_evm_base::evm::sign_extend_i24;
13use wp_evm_ramses_interfaces::periphery::nfpm::IRamsesNonfungiblePositionManager;
14use wp_evm_velodrome_interfaces::periphery::nfpm::IVelodromeNonfungiblePositionManager;
15
16/// Faithful Rust mirror of the Ramses-family NFPM 10-field `positions()`
17/// return tuple, plus the caller-supplied `token_id` echoed as the first
18/// field for round-tripping.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct RamsesPositionView {
21    /// NFT token ID this position belongs to. Echoed from caller input.
22    pub token_id: U256,
23    /// Pool token0.
24    pub token0: Address,
25    /// Pool token1.
26    pub token1: Address,
27    /// Pool tick spacing (Ramses-family NFPM stores this per-position;
28    /// V3 and Algebra do not).
29    pub tick_spacing: i32,
30    /// Lower tick, sign-extended from int24.
31    pub tick_lower: i32,
32    /// Upper tick, sign-extended from int24.
33    pub tick_upper: i32,
34    /// Position liquidity.
35    pub liquidity: u128,
36    /// Last-touch fee-growth snapshot for token0.
37    pub fee_growth_inside_0_last_x128: U256,
38    /// Last-touch fee-growth snapshot for token1.
39    pub fee_growth_inside_1_last_x128: U256,
40    /// Stale token0 owed snapshot from NFPM.
41    pub tokens_owed_0_stale: u128,
42    /// Stale token1 owed snapshot from NFPM.
43    pub tokens_owed_1_stale: u128,
44}
45
46impl RamsesPositionView {
47    /// Decode a Ramses-family NFPM `positions(uint256)` return tuple.
48    pub fn from_nfpm_returns(
49        token_id: U256,
50        ret: &IRamsesNonfungiblePositionManager::positionsReturn,
51    ) -> Self {
52        Self {
53            token_id,
54            token0: ret.token0,
55            token1: ret.token1,
56            tick_spacing: sign_extend_i24(ret.tickSpacing),
57            tick_lower: sign_extend_i24(ret.tickLower),
58            tick_upper: sign_extend_i24(ret.tickUpper),
59            liquidity: ret.liquidity,
60            fee_growth_inside_0_last_x128: ret.feeGrowthInside0LastX128,
61            fee_growth_inside_1_last_x128: ret.feeGrowthInside1LastX128,
62            tokens_owed_0_stale: ret.tokensOwed0,
63            tokens_owed_1_stale: ret.tokensOwed1,
64        }
65    }
66}
67
68/// 12-field Velodrome NFPM position row.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct VelodromePositionRow {
71    /// NFPM nonce.
72    pub nonce: U96,
73    /// Approved operator.
74    pub operator: Address,
75    /// Pool token0.
76    pub token0: Address,
77    /// Pool token1.
78    pub token1: Address,
79    /// Pool tick spacing.
80    pub tick_spacing: i32,
81    /// Lower tick, sign-extended from int24.
82    pub tick_lower: i32,
83    /// Upper tick, sign-extended from int24.
84    pub tick_upper: i32,
85    /// Position liquidity.
86    pub liquidity: u128,
87    /// Last-touch fee-growth snapshot for token0.
88    pub fee_growth_inside_0_last_x128: U256,
89    /// Last-touch fee-growth snapshot for token1.
90    pub fee_growth_inside_1_last_x128: U256,
91    /// Stale token0 owed snapshot from NFPM.
92    pub tokens_owed_0_stale: u128,
93    /// Stale token1 owed snapshot from NFPM.
94    pub tokens_owed_1_stale: u128,
95}
96
97impl VelodromePositionRow {
98    /// Decode a Velodrome-family NFPM `positions(uint256)` return tuple.
99    pub fn from_nfpm_returns(
100        _token_id: U256,
101        ret: &IVelodromeNonfungiblePositionManager::positionsReturn,
102    ) -> Self {
103        Self {
104            nonce: ret.nonce,
105            operator: ret.operator,
106            token0: ret.token0,
107            token1: ret.token1,
108            tick_spacing: sign_extend_i24(ret.tickSpacing),
109            tick_lower: sign_extend_i24(ret.tickLower),
110            tick_upper: sign_extend_i24(ret.tickUpper),
111            liquidity: ret.liquidity,
112            fee_growth_inside_0_last_x128: ret.feeGrowthInside0LastX128,
113            fee_growth_inside_1_last_x128: ret.feeGrowthInside1LastX128,
114            tokens_owed_0_stale: ret.tokensOwed0,
115            tokens_owed_1_stale: ret.tokensOwed1,
116        }
117    }
118}
119
120/// Compute the Ramses-family pool position key.
121///
122/// `keccak256(abi.encodePacked(owner, index, tick_lower, tick_upper))` —
123/// 20 + 32 + 3 + 3 = **58 bytes**. Verified against
124/// `RamsesExchange/ramses-v3-contracts/contracts/CL/core/libraries/Position.sol`
125/// and `contracts/CL/periphery/libraries/PositionKey.sol` on 2026-04-28.
126///
127/// For NFPM-managed positions, `owner` is the NFPM contract address and
128/// `index` is the NFT `tokenId`. Raw pool depositors pass their own
129/// `(owner, index)`.
130///
131/// **Panics** if either tick is outside the int24 range
132/// `[-2^23, 2^23 - 1]` — out-of-range ticks would silently truncate in
133/// `i24_to_be_bytes` and produce a wrong hash. Realistic Uniswap-style
134/// ticks live well inside int24 (`±887_272`).
135pub fn position_key(owner: Address, index: U256, tick_lower: i32, tick_upper: i32) -> B256 {
136    assert!(
137        (-8_388_608..=8_388_607).contains(&tick_lower),
138        "tick_lower {tick_lower} outside int24 range",
139    );
140    assert!(
141        (-8_388_608..=8_388_607).contains(&tick_upper),
142        "tick_upper {tick_upper} outside int24 range",
143    );
144
145    let mut buf = [0u8; 58];
146    buf[..20].copy_from_slice(owner.as_slice());
147    buf[20..52].copy_from_slice(&index.to_be_bytes::<32>());
148    buf[52..55].copy_from_slice(&i24_to_be_bytes(tick_lower));
149    buf[55..58].copy_from_slice(&i24_to_be_bytes(tick_upper));
150    keccak256(buf)
151}
152
153fn i24_to_be_bytes(tick: i32) -> [u8; 3] {
154    let bytes = tick.to_be_bytes();
155    [bytes[1], bytes[2], bytes[3]]
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use alloy_primitives::{address, aliases::I24, b256};
162
163    /// Synthetic reference vector. Locks the 58-byte buffer layout
164    /// (20 owner + 32 index + 3 lo + 3 hi) against silent off-by-one /
165    /// width drift.
166    ///
167    /// Computed offline 2026-04-28 with `cast keccak`:
168    /// ```text
169    /// owner   = 0x000000000000000000000000000000000000dEaD  (20 bytes)
170    /// index   = 0x0...0                                       (32 bytes)
171    /// tickLo  = -887_188 → 24-bit two's-complement low bytes 0xf2766c
172    /// tickHi  = +887_188 → low bytes 0x0d8994
173    /// packed  = owner ‖ index ‖ tickLo ‖ tickHi   (58 bytes)
174    /// keccak  = 0x3b5f5df2f36078cb5495dbec069ca73eed9072adad9c8b0dcaced3689b802b73
175    /// ```
176    #[test]
177    fn position_key_synthetic_reference_vector() {
178        let key = position_key(
179            address!("000000000000000000000000000000000000dEaD"),
180            U256::ZERO,
181            -887_188,
182            887_188,
183        );
184        assert_eq!(key, b256!("3b5f5df2f36078cb5495dbec069ca73eed9072adad9c8b0dcaced3689b802b73"),);
185    }
186
187    /// On-chain reference vector — Shadow Sonic NFPM tokenId 1_156_688
188    /// at block 68_964_603. The expected key was verified end-to-end:
189    /// `pool.positions(this_key)` returns the same liquidity /
190    /// feeGrowthInside* / tokensOwed* values that
191    /// `NFPM.positions(1_156_688)` returns (see plan reference vector
192    /// table; see also the active-position decode test in
193    /// `wp-evm-ramses-interfaces` PR #96).
194    #[test]
195    fn position_key_real_shadow_position_matches_pool() {
196        let nfpm = address!("12E66C8F215DdD5d48d150c8f46aD0c6fB0F4406");
197        let token_id = U256::from(1_156_688u64);
198        let key = position_key(nfpm, token_id, -887_250, 887_250);
199        assert_eq!(key, b256!("4b6f08061b30865c2b49c44f15339f787b09d20d2429d42fbe3ed5de6199776c"),);
200    }
201
202    /// Round-trip from a synthetic NFPM return through `from_nfpm_returns`.
203    /// Locks the field-by-field copy + sign-extension wiring.
204    #[test]
205    fn from_nfpm_returns_sign_extends_ticks_and_preserves_fields() {
206        let ret = IRamsesNonfungiblePositionManager::positionsReturn {
207            token0: address!("0000000000000000000000000000000000000001"),
208            token1: address!("0000000000000000000000000000000000000002"),
209            tickSpacing: I24::try_from(50i32).unwrap(),
210            tickLower: I24::try_from(-120i32).unwrap(),
211            tickUpper: I24::try_from(240i32).unwrap(),
212            liquidity: 42,
213            feeGrowthInside0LastX128: U256::from(7),
214            feeGrowthInside1LastX128: U256::from(8),
215            tokensOwed0: 9,
216            tokensOwed1: 10,
217        };
218        let view = RamsesPositionView::from_nfpm_returns(U256::from(1), &ret);
219        assert_eq!(view.token_id, U256::from(1));
220        assert_eq!(view.token0, address!("0000000000000000000000000000000000000001"));
221        assert_eq!(view.token1, address!("0000000000000000000000000000000000000002"));
222        assert_eq!(view.tick_spacing, 50);
223        assert_eq!(view.tick_lower, -120);
224        assert_eq!(view.tick_upper, 240);
225        assert_eq!(view.liquidity, 42);
226        assert_eq!(view.fee_growth_inside_0_last_x128, U256::from(7));
227        assert_eq!(view.fee_growth_inside_1_last_x128, U256::from(8));
228        assert_eq!(view.tokens_owed_0_stale, 9);
229        assert_eq!(view.tokens_owed_1_stale, 10);
230    }
231
232    /// Decode the active-position fixture (Sonic block 68_964_603,
233    /// tokenId 1_156_688) from PR #96 through `from_nfpm_returns`.
234    /// Cross-locks the L1 view layer against the L0 ABI codec layer.
235    #[test]
236    fn from_nfpm_returns_decodes_real_shadow_active_position() {
237        let raw = alloy_primitives::hex!(
238            "000000000000000000000000039e2fb66102314ce7b64ce5ce3e5183bc94ad38"
239            "00000000000000000000000050c42deacd8fc9773493ed674b675be577f2634b"
240            "0000000000000000000000000000000000000000000000000000000000000032"
241            "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff2762e"
242            "00000000000000000000000000000000000000000000000000000000000d89d2"
243            "00000000000000000000000000000000000000000000000342c6d2155c8af6b6"
244            "00000000000000000000000000000000003c34f4fcee3e358e2c80986cddcc72"
245            "000000000000000000000000000000000000029cdd807204b5d3d79e9ac52169"
246            "0000000000000000000000000000000000000000000000000000000000000000"
247            "0000000000000000000000000000000000000000000000000000000000000000"
248        );
249        use alloy_sol_types::SolCall;
250        let ret = IRamsesNonfungiblePositionManager::positionsCall::abi_decode_returns(&raw)
251            .expect("fixture decodes");
252
253        let view = RamsesPositionView::from_nfpm_returns(U256::from(1_156_688u64), &ret);
254
255        assert_eq!(view.token_id, U256::from(1_156_688u64));
256        assert_eq!(view.token0, address!("039e2fb66102314ce7b64ce5ce3e5183bc94ad38"));
257        assert_eq!(view.token1, address!("50c42deacd8fc9773493ed674b675be577f2634b"));
258        assert_eq!(view.tick_spacing, 50);
259        assert_eq!(view.tick_lower, -887_250);
260        assert_eq!(view.tick_upper, 887_250);
261        assert_eq!(view.liquidity, 60_151_996_462_209_365_686u128);
262        assert_eq!(
263            view.fee_growth_inside_0_last_x128,
264            U256::from(312_611_906_761_373_619_763_565_392_889_236_594u128),
265        );
266        assert_eq!(
267            view.fee_growth_inside_1_last_x128,
268            U256::from(52_992_964_027_640_673_521_461_442_716_009u128),
269        );
270        assert_eq!(view.tokens_owed_0_stale, 0);
271        assert_eq!(view.tokens_owed_1_stale, 0);
272    }
273
274    #[test]
275    #[should_panic(expected = "tick_lower")]
276    fn position_key_panics_on_oversized_tick_lower() {
277        position_key(Address::ZERO, U256::ZERO, 8_388_608, 0);
278    }
279
280    #[test]
281    #[should_panic(expected = "tick_upper")]
282    fn position_key_panics_on_oversized_tick_upper() {
283        position_key(Address::ZERO, U256::ZERO, 0, -8_388_609);
284    }
285}