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