nautilus_blockchain/contracts/
uniswap_v3_pool.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::HashMap, sync::Arc};
17
18use alloy::{
19    primitives::{Address, U256, keccak256},
20    sol,
21    sol_types::{SolCall, private::primitives::aliases::I24},
22};
23use nautilus_model::{
24    defi::{
25        data::block::BlockPosition,
26        pool_analysis::{
27            position::PoolPosition,
28            snapshot::{PoolAnalytics, PoolSnapshot, PoolState},
29        },
30        tick_map::tick::PoolTick,
31    },
32    identifiers::InstrumentId,
33};
34use thiserror::Error;
35
36use super::base::{BaseContract, ContractCall};
37use crate::rpc::{error::BlockchainRpcClientError, http::BlockchainHttpRpcClient};
38
39sol! {
40    #[sol(rpc)]
41    contract UniswapV3Pool {
42        /// Packed struct containing core pool state
43        struct Slot0Data {
44            uint160 sqrtPriceX96;
45            int24 tick;
46            uint16 observationIndex;
47            uint16 observationCardinality;
48            uint16 observationCardinalityNext;
49            uint8 feeProtocol;
50            bool unlocked;
51        }
52
53        /// Tick information
54        struct TickInfo {
55            uint128 liquidityGross;
56            int128 liquidityNet;
57            uint256 feeGrowthOutside0X128;
58            uint256 feeGrowthOutside1X128;
59            int56 tickCumulativeOutside;
60            uint160 secondsPerLiquidityOutsideX128;
61            uint32 secondsOutside;
62            bool initialized;
63        }
64
65        /// Position information
66        struct PositionInfo {
67            uint128 liquidity;
68            uint256 feeGrowthInside0LastX128;
69            uint256 feeGrowthInside1LastX128;
70            uint128 tokensOwed0;
71            uint128 tokensOwed1;
72        }
73
74        // Core state getters
75        function slot0() external view returns (Slot0Data memory);
76        function liquidity() external view returns (uint128);
77        function feeGrowthGlobal0X128() external view returns (uint256);
78        function feeGrowthGlobal1X128() external view returns (uint256);
79
80        // Tick and position getters
81        function ticks(int24 tick) external view returns (TickInfo memory);
82        function positions(bytes32 key) external view returns (PositionInfo memory);
83    }
84}
85
86/// Represents errors that can occur when interacting with UniswapV3Pool contract.
87#[derive(Debug, Error)]
88pub enum UniswapV3PoolError {
89    #[error("RPC error: {0}")]
90    RpcError(#[from] BlockchainRpcClientError),
91    #[error("Failed to decode {field} for pool {pool}: {reason} (raw data: {raw_data})")]
92    DecodingError {
93        field: String,
94        pool: Address,
95        reason: String,
96        raw_data: String,
97    },
98    #[error("Call failed for {field} at pool {pool}: {reason}")]
99    CallFailed {
100        field: String,
101        pool: Address,
102        reason: String,
103    },
104    #[error("Tick {tick} is not initialized in pool {pool}")]
105    TickNotInitialized { tick: i32, pool: Address },
106}
107
108/// Interface for interacting with UniswapV3Pool contracts on a blockchain.
109///
110/// This struct provides methods to query pool state including slot0, liquidity,
111/// fee growth, tick data, and position data. Supports both single calls and
112/// batch multicalls for efficiency.
113#[derive(Debug)]
114pub struct UniswapV3PoolContract {
115    /// The base contract providing common RPC execution functionality.
116    base: BaseContract,
117}
118
119impl UniswapV3PoolContract {
120    /// Creates a new UniswapV3Pool contract interface with the specified RPC client.
121    #[must_use]
122    pub fn new(client: Arc<BlockchainHttpRpcClient>) -> Self {
123        Self {
124            base: BaseContract::new(client),
125        }
126    }
127
128    /// Gets all global state in a single multicall.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the multicall fails or any decoding fails.
133    pub async fn get_global_state(
134        &self,
135        pool_address: &Address,
136        block: Option<u64>,
137    ) -> Result<PoolState, UniswapV3PoolError> {
138        let calls = vec![
139            ContractCall {
140                target: *pool_address,
141                allow_failure: false,
142                call_data: UniswapV3Pool::slot0Call {}.abi_encode(),
143            },
144            ContractCall {
145                target: *pool_address,
146                allow_failure: false,
147                call_data: UniswapV3Pool::liquidityCall {}.abi_encode(),
148            },
149            ContractCall {
150                target: *pool_address,
151                allow_failure: false,
152                call_data: UniswapV3Pool::feeGrowthGlobal0X128Call {}.abi_encode(),
153            },
154            ContractCall {
155                target: *pool_address,
156                allow_failure: false,
157                call_data: UniswapV3Pool::feeGrowthGlobal1X128Call {}.abi_encode(),
158            },
159        ];
160
161        let results = self.base.execute_multicall(calls, block).await?;
162
163        if results.len() != 4 {
164            return Err(UniswapV3PoolError::CallFailed {
165                field: "global_state_multicall".to_string(),
166                pool: *pool_address,
167                reason: format!("Expected 4 results, got {}", results.len()),
168            });
169        }
170
171        // Decode slot0
172        let slot0 =
173            UniswapV3Pool::slot0Call::abi_decode_returns(&results[0].returnData).map_err(|e| {
174                UniswapV3PoolError::DecodingError {
175                    field: "slot0".to_string(),
176                    pool: *pool_address,
177                    reason: e.to_string(),
178                    raw_data: hex::encode(&results[0].returnData),
179                }
180            })?;
181
182        // Decode liquidity
183        let liquidity = UniswapV3Pool::liquidityCall::abi_decode_returns(&results[1].returnData)
184            .map_err(|e| UniswapV3PoolError::DecodingError {
185                field: "liquidity".to_string(),
186                pool: *pool_address,
187                reason: e.to_string(),
188                raw_data: hex::encode(&results[1].returnData),
189            })?;
190
191        // Decode feeGrowthGlobal0X128
192        let fee_growth_0 =
193            UniswapV3Pool::feeGrowthGlobal0X128Call::abi_decode_returns(&results[2].returnData)
194                .map_err(|e| UniswapV3PoolError::DecodingError {
195                    field: "feeGrowthGlobal0X128".to_string(),
196                    pool: *pool_address,
197                    reason: e.to_string(),
198                    raw_data: hex::encode(&results[2].returnData),
199                })?;
200
201        // Decode feeGrowthGlobal1X128
202        let fee_growth_1 =
203            UniswapV3Pool::feeGrowthGlobal1X128Call::abi_decode_returns(&results[3].returnData)
204                .map_err(|e| UniswapV3PoolError::DecodingError {
205                    field: "feeGrowthGlobal1X128".to_string(),
206                    pool: *pool_address,
207                    reason: e.to_string(),
208                    raw_data: hex::encode(&results[3].returnData),
209                })?;
210
211        Ok(PoolState {
212            current_tick: slot0.tick.as_i32(),
213            price_sqrt_ratio_x96: slot0.sqrtPriceX96,
214            liquidity,
215            protocol_fees_token0: U256::ZERO,
216            protocol_fees_token1: U256::ZERO,
217            fee_protocol: slot0.feeProtocol,
218            fee_growth_global_0: fee_growth_0,
219            fee_growth_global_1: fee_growth_1,
220        })
221    }
222
223    /// Gets tick data for a specific tick.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error if the RPC call fails or decoding fails.
228    pub async fn get_tick(
229        &self,
230        pool_address: &Address,
231        tick: i32,
232        block: Option<u64>,
233    ) -> Result<PoolTick, UniswapV3PoolError> {
234        let tick_i24 = I24::try_from(tick).map_err(|_| UniswapV3PoolError::CallFailed {
235            field: "tick".to_string(),
236            pool: *pool_address,
237            reason: format!("Tick {} out of range for int24", tick),
238        })?;
239
240        let call_data = UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode();
241        let raw_response = self
242            .base
243            .execute_call(pool_address, &call_data, block)
244            .await?;
245
246        let tick_info =
247            UniswapV3Pool::ticksCall::abi_decode_returns(&raw_response).map_err(|e| {
248                UniswapV3PoolError::DecodingError {
249                    field: format!("ticks({})", tick),
250                    pool: *pool_address,
251                    reason: e.to_string(),
252                    raw_data: hex::encode(&raw_response),
253                }
254            })?;
255
256        Ok(PoolTick::new(
257            tick,
258            tick_info.liquidityGross,
259            tick_info.liquidityNet,
260            tick_info.feeGrowthOutside0X128,
261            tick_info.feeGrowthOutside1X128,
262            tick_info.initialized,
263            0, // last_updated_block - not available from RPC
264        ))
265    }
266
267    /// Gets tick data for multiple ticks in a single multicall.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the multicall fails or if any tick decoding fails.
272    /// Uninitialized ticks are silently skipped (not included in the result HashMap).
273    pub async fn batch_get_ticks(
274        &self,
275        pool_address: &Address,
276        ticks: &[i32],
277        block: Option<u64>,
278    ) -> Result<HashMap<i32, PoolTick>, UniswapV3PoolError> {
279        let calls: Vec<ContractCall> = ticks
280            .iter()
281            .filter_map(|&tick| {
282                I24::try_from(tick).ok().map(|tick_i24| ContractCall {
283                    target: *pool_address,
284                    allow_failure: true,
285                    call_data: UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode(),
286                })
287            })
288            .collect();
289
290        let results = self.base.execute_multicall(calls, block).await?;
291
292        let mut tick_infos = HashMap::with_capacity(ticks.len());
293        for (i, &tick_value) in ticks.iter().enumerate() {
294            if i >= results.len() {
295                break;
296            }
297
298            let result = &results[i];
299            if !result.success {
300                // Skip uninitialized ticks
301                continue;
302            }
303
304            let tick_info = UniswapV3Pool::ticksCall::abi_decode_returns(&result.returnData)
305                .map_err(|e| UniswapV3PoolError::DecodingError {
306                    field: format!("ticks({})", tick_value),
307                    pool: *pool_address,
308                    reason: e.to_string(),
309                    raw_data: hex::encode(&result.returnData),
310                })?;
311
312            tick_infos.insert(
313                tick_value,
314                PoolTick::new(
315                    tick_value,
316                    tick_info.liquidityGross,
317                    tick_info.liquidityNet,
318                    tick_info.feeGrowthOutside0X128,
319                    tick_info.feeGrowthOutside1X128,
320                    tick_info.initialized,
321                    0, // last_updated_block - not available from RPC
322                ),
323            );
324        }
325
326        Ok(tick_infos)
327    }
328
329    /// Computes the position key used by Uniswap V3.
330    ///
331    /// The key is: keccak256(abi.encodePacked(owner, tickLower, tickUpper))
332    #[must_use]
333    pub fn compute_position_key(owner: &Address, tick_lower: i32, tick_upper: i32) -> [u8; 32] {
334        // Pack: address (20 bytes) + int24 (3 bytes) + int24 (3 bytes) = 26 bytes total
335        let mut packed = Vec::with_capacity(26);
336
337        // Add owner address (20 bytes)
338        packed.extend_from_slice(owner.as_slice());
339
340        // Add tick_lower as int24 (3 bytes, big-endian, sign-extended)
341        let tick_lower_bytes = tick_lower.to_be_bytes();
342        packed.extend_from_slice(&tick_lower_bytes[1..4]);
343
344        // Add tick_upper as int24 (3 bytes, big-endian, sign-extended)
345        let tick_upper_bytes = tick_upper.to_be_bytes();
346        packed.extend_from_slice(&tick_upper_bytes[1..4]);
347
348        keccak256(&packed).into()
349    }
350
351    /// Gets position data for multiple positions in a single multicall.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the multicall fails. Individual position failures are
356    /// captured in the Result values of the returned Vec.
357    pub async fn batch_get_positions(
358        &self,
359        pool_address: &Address,
360        positions: &[(Address, i32, i32)],
361        block: Option<u64>,
362    ) -> Result<Vec<PoolPosition>, UniswapV3PoolError> {
363        let calls: Vec<ContractCall> = positions
364            .iter()
365            .map(|(owner, tick_lower, tick_upper)| {
366                let position_key = Self::compute_position_key(owner, *tick_lower, *tick_upper);
367                ContractCall {
368                    target: *pool_address,
369                    allow_failure: true,
370                    call_data: UniswapV3Pool::positionsCall {
371                        key: position_key.into(),
372                    }
373                    .abi_encode(),
374                }
375            })
376            .collect();
377
378        let results = self.base.execute_multicall(calls, block).await?;
379
380        let position_infos: Vec<PoolPosition> = positions
381            .iter()
382            .enumerate()
383            .filter_map(|(i, (owner, tick_lower, tick_upper))| {
384                if i >= results.len() {
385                    return None;
386                }
387
388                let result = &results[i];
389                if !result.success {
390                    return None;
391                }
392
393                UniswapV3Pool::positionsCall::abi_decode_returns(&result.returnData)
394                    .ok()
395                    .map(|info| PoolPosition {
396                        owner: *owner,
397                        tick_lower: *tick_lower,
398                        tick_upper: *tick_upper,
399                        liquidity: info.liquidity,
400                        fee_growth_inside_0_last: info.feeGrowthInside0LastX128,
401                        fee_growth_inside_1_last: info.feeGrowthInside1LastX128,
402                        tokens_owed_0: info.tokensOwed0,
403                        tokens_owed_1: info.tokensOwed1,
404                        total_amount0_deposited: U256::ZERO,
405                        total_amount1_deposited: U256::ZERO,
406                        total_amount0_collected: 0,
407                        total_amount1_collected: 0,
408                    })
409            })
410            .collect();
411
412        Ok(position_infos)
413    }
414
415    /// Fetches a complete pool snapshot directly from on-chain state.
416    ///
417    /// Retrieves global state, tick data, and position data from the blockchain
418    /// and constructs a `PoolSnapshot` representing the current on-chain state.
419    /// This snapshot can be compared against profiler state for validation.
420    ///
421    /// # Errors
422    ///
423    /// Returns error if any RPC calls fail or data cannot be decoded.
424    pub async fn fetch_snapshot(
425        &self,
426        pool_address: &Address,
427        instrument_id: InstrumentId,
428        tick_values: &[i32],
429        position_keys: &[(Address, i32, i32)],
430        block_position: BlockPosition,
431    ) -> Result<PoolSnapshot, UniswapV3PoolError> {
432        // Fetch all data at the specified block
433        let block = Some(block_position.number);
434        let global_state = self.get_global_state(pool_address, block).await?;
435        let ticks_map = self
436            .batch_get_ticks(pool_address, tick_values, block)
437            .await?;
438        let positions = self
439            .batch_get_positions(pool_address, position_keys, block)
440            .await?;
441
442        Ok(PoolSnapshot::new(
443            instrument_id,
444            global_state,
445            positions,
446            ticks_map.into_values().collect(),
447            PoolAnalytics::default(),
448            block_position,
449        ))
450    }
451}