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, U160, 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/// Combined global state of a Uniswap V3 pool.
87#[derive(Debug, Clone, PartialEq)]
88pub struct PoolGlobalState {
89    /// Current sqrt price
90    pub sqrt_price_x96: U160,
91    /// Current tick
92    pub tick: i32,
93    /// Current liquidity
94    pub liquidity: u128,
95    /// Global fee growth for token0
96    pub fee_growth_global_0_x128: U256,
97    /// Global fee growth for token1
98    pub fee_growth_global_1_x128: U256,
99    /// Protocol fee setting
100    pub fee_protocol: u8,
101}
102
103/// Represents errors that can occur when interacting with UniswapV3Pool contract.
104#[derive(Debug, Error)]
105pub enum UniswapV3PoolError {
106    #[error("RPC error: {0}")]
107    RpcError(#[from] BlockchainRpcClientError),
108    #[error("Failed to decode {field} for pool {pool}: {reason} (raw data: {raw_data})")]
109    DecodingError {
110        field: String,
111        pool: Address,
112        reason: String,
113        raw_data: String,
114    },
115    #[error("Call failed for {field} at pool {pool}: {reason}")]
116    CallFailed {
117        field: String,
118        pool: Address,
119        reason: String,
120    },
121    #[error("Tick {tick} is not initialized in pool {pool}")]
122    TickNotInitialized { tick: i32, pool: Address },
123}
124
125/// Interface for interacting with UniswapV3Pool contracts on a blockchain.
126///
127/// This struct provides methods to query pool state including slot0, liquidity,
128/// fee growth, tick data, and position data. Supports both single calls and
129/// batch multicalls for efficiency.
130#[derive(Debug)]
131pub struct UniswapV3PoolContract {
132    /// The base contract providing common RPC execution functionality.
133    base: BaseContract,
134}
135
136impl UniswapV3PoolContract {
137    /// Creates a new UniswapV3Pool contract interface with the specified RPC client.
138    #[must_use]
139    pub fn new(client: Arc<BlockchainHttpRpcClient>) -> Self {
140        Self {
141            base: BaseContract::new(client),
142        }
143    }
144
145    /// Gets all global state in a single multicall.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the multicall fails or any decoding fails.
150    pub async fn get_global_state(
151        &self,
152        pool_address: &Address,
153        block: Option<u64>,
154    ) -> Result<PoolGlobalState, UniswapV3PoolError> {
155        let calls = vec![
156            ContractCall {
157                target: *pool_address,
158                allow_failure: false,
159                call_data: UniswapV3Pool::slot0Call {}.abi_encode(),
160            },
161            ContractCall {
162                target: *pool_address,
163                allow_failure: false,
164                call_data: UniswapV3Pool::liquidityCall {}.abi_encode(),
165            },
166            ContractCall {
167                target: *pool_address,
168                allow_failure: false,
169                call_data: UniswapV3Pool::feeGrowthGlobal0X128Call {}.abi_encode(),
170            },
171            ContractCall {
172                target: *pool_address,
173                allow_failure: false,
174                call_data: UniswapV3Pool::feeGrowthGlobal1X128Call {}.abi_encode(),
175            },
176        ];
177
178        let results = self.base.execute_multicall(calls, block).await?;
179
180        if results.len() != 4 {
181            return Err(UniswapV3PoolError::CallFailed {
182                field: "global_state_multicall".to_string(),
183                pool: *pool_address,
184                reason: format!("Expected 4 results, got {}", results.len()),
185            });
186        }
187
188        // Decode slot0
189        let slot0 =
190            UniswapV3Pool::slot0Call::abi_decode_returns(&results[0].returnData).map_err(|e| {
191                UniswapV3PoolError::DecodingError {
192                    field: "slot0".to_string(),
193                    pool: *pool_address,
194                    reason: e.to_string(),
195                    raw_data: hex::encode(&results[0].returnData),
196                }
197            })?;
198
199        // Decode liquidity
200        let liquidity = UniswapV3Pool::liquidityCall::abi_decode_returns(&results[1].returnData)
201            .map_err(|e| UniswapV3PoolError::DecodingError {
202                field: "liquidity".to_string(),
203                pool: *pool_address,
204                reason: e.to_string(),
205                raw_data: hex::encode(&results[1].returnData),
206            })?;
207
208        // Decode feeGrowthGlobal0X128
209        let fee_growth_0 =
210            UniswapV3Pool::feeGrowthGlobal0X128Call::abi_decode_returns(&results[2].returnData)
211                .map_err(|e| UniswapV3PoolError::DecodingError {
212                    field: "feeGrowthGlobal0X128".to_string(),
213                    pool: *pool_address,
214                    reason: e.to_string(),
215                    raw_data: hex::encode(&results[2].returnData),
216                })?;
217
218        // Decode feeGrowthGlobal1X128
219        let fee_growth_1 =
220            UniswapV3Pool::feeGrowthGlobal1X128Call::abi_decode_returns(&results[3].returnData)
221                .map_err(|e| UniswapV3PoolError::DecodingError {
222                    field: "feeGrowthGlobal1X128".to_string(),
223                    pool: *pool_address,
224                    reason: e.to_string(),
225                    raw_data: hex::encode(&results[3].returnData),
226                })?;
227
228        Ok(PoolGlobalState {
229            sqrt_price_x96: slot0.sqrtPriceX96,
230            tick: slot0.tick.as_i32(),
231            liquidity,
232            fee_growth_global_0_x128: fee_growth_0,
233            fee_growth_global_1_x128: fee_growth_1,
234            fee_protocol: slot0.feeProtocol,
235        })
236    }
237
238    /// Gets tick data for a specific tick.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the RPC call fails or decoding fails.
243    pub async fn get_tick(
244        &self,
245        pool_address: &Address,
246        tick: i32,
247        block: Option<u64>,
248    ) -> Result<PoolTick, UniswapV3PoolError> {
249        let tick_i24 = I24::try_from(tick).map_err(|_| UniswapV3PoolError::CallFailed {
250            field: "tick".to_string(),
251            pool: *pool_address,
252            reason: format!("Tick {} out of range for int24", tick),
253        })?;
254
255        let call_data = UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode();
256        let raw_response = self
257            .base
258            .execute_call(pool_address, &call_data, block)
259            .await?;
260
261        let tick_info =
262            UniswapV3Pool::ticksCall::abi_decode_returns(&raw_response).map_err(|e| {
263                UniswapV3PoolError::DecodingError {
264                    field: format!("ticks({})", tick),
265                    pool: *pool_address,
266                    reason: e.to_string(),
267                    raw_data: hex::encode(&raw_response),
268                }
269            })?;
270
271        Ok(PoolTick::new(
272            tick,
273            tick_info.liquidityGross,
274            tick_info.liquidityNet,
275            tick_info.feeGrowthOutside0X128,
276            tick_info.feeGrowthOutside1X128,
277            tick_info.initialized,
278            0, // last_updated_block - not available from RPC
279        ))
280    }
281
282    /// Gets tick data for multiple ticks in a single multicall.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the multicall fails or if any tick decoding fails.
287    /// Uninitialized ticks are silently skipped (not included in the result HashMap).
288    pub async fn batch_get_ticks(
289        &self,
290        pool_address: &Address,
291        ticks: &[i32],
292        block: Option<u64>,
293    ) -> Result<HashMap<i32, PoolTick>, UniswapV3PoolError> {
294        let calls: Vec<ContractCall> = ticks
295            .iter()
296            .filter_map(|&tick| {
297                I24::try_from(tick).ok().map(|tick_i24| ContractCall {
298                    target: *pool_address,
299                    allow_failure: true,
300                    call_data: UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode(),
301                })
302            })
303            .collect();
304
305        let results = self.base.execute_multicall(calls, block).await?;
306
307        let mut tick_infos = HashMap::with_capacity(ticks.len());
308        for (i, &tick_value) in ticks.iter().enumerate() {
309            if i >= results.len() {
310                break;
311            }
312
313            let result = &results[i];
314            if !result.success {
315                // Skip uninitialized ticks
316                continue;
317            }
318
319            let tick_info = UniswapV3Pool::ticksCall::abi_decode_returns(&result.returnData)
320                .map_err(|e| UniswapV3PoolError::DecodingError {
321                    field: format!("ticks({})", tick_value),
322                    pool: *pool_address,
323                    reason: e.to_string(),
324                    raw_data: hex::encode(&result.returnData),
325                })?;
326
327            tick_infos.insert(
328                tick_value,
329                PoolTick::new(
330                    tick_value,
331                    tick_info.liquidityGross,
332                    tick_info.liquidityNet,
333                    tick_info.feeGrowthOutside0X128,
334                    tick_info.feeGrowthOutside1X128,
335                    tick_info.initialized,
336                    0, // last_updated_block - not available from RPC
337                ),
338            );
339        }
340
341        Ok(tick_infos)
342    }
343
344    /// Computes the position key used by Uniswap V3.
345    ///
346    /// The key is: keccak256(abi.encodePacked(owner, tickLower, tickUpper))
347    #[must_use]
348    pub fn compute_position_key(owner: &Address, tick_lower: i32, tick_upper: i32) -> [u8; 32] {
349        // Pack: address (20 bytes) + int24 (3 bytes) + int24 (3 bytes) = 26 bytes total
350        let mut packed = Vec::with_capacity(26);
351
352        // Add owner address (20 bytes)
353        packed.extend_from_slice(owner.as_slice());
354
355        // Add tick_lower as int24 (3 bytes, big-endian, sign-extended)
356        let tick_lower_bytes = tick_lower.to_be_bytes();
357        packed.extend_from_slice(&tick_lower_bytes[1..4]);
358
359        // Add tick_upper as int24 (3 bytes, big-endian, sign-extended)
360        let tick_upper_bytes = tick_upper.to_be_bytes();
361        packed.extend_from_slice(&tick_upper_bytes[1..4]);
362
363        keccak256(&packed).into()
364    }
365
366    /// Gets position data for multiple positions in a single multicall.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the multicall fails. Individual position failures are
371    /// captured in the Result values of the returned Vec.
372    pub async fn batch_get_positions(
373        &self,
374        pool_address: &Address,
375        positions: &[(Address, i32, i32)],
376        block: Option<u64>,
377    ) -> Result<Vec<PoolPosition>, UniswapV3PoolError> {
378        let calls: Vec<ContractCall> = positions
379            .iter()
380            .map(|(owner, tick_lower, tick_upper)| {
381                let position_key = Self::compute_position_key(owner, *tick_lower, *tick_upper);
382                ContractCall {
383                    target: *pool_address,
384                    allow_failure: true,
385                    call_data: UniswapV3Pool::positionsCall {
386                        key: position_key.into(),
387                    }
388                    .abi_encode(),
389                }
390            })
391            .collect();
392
393        let results = self.base.execute_multicall(calls, block).await?;
394
395        let position_infos: Vec<PoolPosition> = positions
396            .iter()
397            .enumerate()
398            .filter_map(|(i, (owner, tick_lower, tick_upper))| {
399                if i >= results.len() {
400                    return None;
401                }
402
403                let result = &results[i];
404                if !result.success {
405                    return None;
406                }
407
408                UniswapV3Pool::positionsCall::abi_decode_returns(&result.returnData)
409                    .ok()
410                    .map(|info| PoolPosition {
411                        owner: *owner,
412                        tick_lower: *tick_lower,
413                        tick_upper: *tick_upper,
414                        liquidity: info.liquidity,
415                        fee_growth_inside_0_last: info.feeGrowthInside0LastX128,
416                        fee_growth_inside_1_last: info.feeGrowthInside1LastX128,
417                        tokens_owed_0: info.tokensOwed0,
418                        tokens_owed_1: info.tokensOwed1,
419                        total_amount0_deposited: U256::ZERO,
420                        total_amount1_deposited: U256::ZERO,
421                        total_amount0_collected: 0,
422                        total_amount1_collected: 0,
423                    })
424            })
425            .collect();
426
427        Ok(position_infos)
428    }
429
430    /// Fetches a complete pool snapshot directly from on-chain state.
431    ///
432    /// Retrieves global state, tick data, and position data from the blockchain
433    /// and constructs a `PoolSnapshot` representing the current on-chain state.
434    /// This snapshot can be compared against profiler state for validation.
435    ///
436    /// # Errors
437    ///
438    /// Returns error if any RPC calls fail or data cannot be decoded.
439    pub async fn fetch_snapshot(
440        &self,
441        pool_address: &Address,
442        instrument_id: InstrumentId,
443        tick_values: &[i32],
444        position_keys: &[(Address, i32, i32)],
445        block: Option<u64>,
446    ) -> Result<PoolSnapshot, UniswapV3PoolError> {
447        // Fetch all data
448        let global_state = self.get_global_state(pool_address, block).await?;
449        let ticks_map = self
450            .batch_get_ticks(pool_address, tick_values, block)
451            .await?;
452        let positions = self
453            .batch_get_positions(pool_address, position_keys, block)
454            .await?;
455
456        // Convert HashMap<i32, Tick> to Vec<Tick>
457        let ticks: Vec<PoolTick> = ticks_map.into_values().collect();
458
459        // Construct PoolState from on-chain global state
460        let pool_state = PoolState {
461            current_tick: global_state.tick,
462            price_sqrt_ratio_x96: global_state.sqrt_price_x96,
463            liquidity: global_state.liquidity,
464            protocol_fees_token0: U256::ZERO,
465            protocol_fees_token1: U256::ZERO,
466            fee_protocol: global_state.fee_protocol,
467            fee_growth_global_0: global_state.fee_growth_global_0_x128,
468            fee_growth_global_1: global_state.fee_growth_global_1_x128,
469        };
470
471        // For on-chain snapshots used in comparison, analytics and block position are not relevant
472        let analytics = PoolAnalytics::default();
473        let block_position = BlockPosition {
474            number: 0,
475            transaction_hash: String::new(),
476            transaction_index: 0,
477            log_index: 0,
478        };
479
480        Ok(PoolSnapshot::new(
481            instrument_id,
482            pool_state,
483            positions,
484            ticks,
485            analytics,
486            block_position,
487        ))
488    }
489}