Skip to main content

nautilus_blockchain/contracts/
uniswap_v3_pool.rs

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