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, Multicall3};
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            uint32 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
88const PANCAKESWAP_V3_PROTOCOL_FEE_LANE_SIZE: u32 = 65_536;
89
90/// Protocol-fee encoding used by the pool's `slot0.feeProtocol` field.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum FeeProtocolEncoding {
93    /// Uniswap V3 packs two 4-bit denominators into one byte.
94    UniswapV3Packed,
95    /// PancakeSwap V3 packs two 16-bit basis-point shares into one `uint32`.
96    PancakeSwapV3BasisPoints,
97}
98
99/// Represents errors that can occur when interacting with UniswapV3Pool contract.
100#[derive(Debug, Error)]
101pub enum UniswapV3PoolError {
102    #[error("RPC error: {0}")]
103    RpcError(#[from] BlockchainRpcClientError),
104    #[error("Failed to decode {field} for pool {pool}: {reason} (raw data: {raw_data})")]
105    DecodingError {
106        field: String,
107        pool: Address,
108        reason: String,
109        raw_data: String,
110    },
111    #[error("Call failed for {field} at pool {pool}: {reason}")]
112    CallFailed {
113        field: String,
114        pool: Address,
115        reason: String,
116    },
117    #[error("Tick {tick} is not initialized in pool {pool}")]
118    TickNotInitialized { tick: i32, pool: Address },
119}
120
121/// Interface for interacting with UniswapV3Pool contracts on a blockchain.
122///
123/// This struct provides methods to query pool state including slot0, liquidity,
124/// fee growth, tick data, and position data. Supports both single calls and
125/// batch multicalls for efficiency.
126#[derive(Debug)]
127pub struct UniswapV3PoolContract {
128    /// The base contract providing common RPC execution functionality.
129    base: BaseContract,
130}
131
132impl UniswapV3PoolContract {
133    /// Creates a new UniswapV3Pool contract interface with the specified RPC client.
134    #[must_use]
135    pub fn new(client: Arc<BlockchainHttpRpcClient>, multicall_calls_per_rpc_request: u32) -> Self {
136        Self {
137            base: BaseContract::new_with_multicall_limit(client, multicall_calls_per_rpc_request),
138        }
139    }
140
141    /// Gets all global state in a single multicall.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the multicall fails or any decoding fails.
146    pub async fn get_global_state(
147        &self,
148        pool_address: &Address,
149        block: Option<u64>,
150        fee_protocol_encoding: FeeProtocolEncoding,
151    ) -> Result<PoolState, UniswapV3PoolError> {
152        let calls = vec![
153            ContractCall {
154                target: *pool_address,
155                allow_failure: false,
156                call_data: UniswapV3Pool::slot0Call {}.abi_encode(),
157            },
158            ContractCall {
159                target: *pool_address,
160                allow_failure: false,
161                call_data: UniswapV3Pool::liquidityCall {}.abi_encode(),
162            },
163            ContractCall {
164                target: *pool_address,
165                allow_failure: false,
166                call_data: UniswapV3Pool::feeGrowthGlobal0X128Call {}.abi_encode(),
167            },
168            ContractCall {
169                target: *pool_address,
170                allow_failure: false,
171                call_data: UniswapV3Pool::feeGrowthGlobal1X128Call {}.abi_encode(),
172            },
173            ContractCall {
174                target: *pool_address,
175                allow_failure: false,
176                call_data: UniswapV3Pool::protocolFeesCall {}.abi_encode(),
177            },
178        ];
179
180        let results = self.base.execute_multicall(calls, block).await?;
181
182        if results.len() != 5 {
183            return Err(UniswapV3PoolError::CallFailed {
184                field: "global_state_multicall".to_string(),
185                pool: *pool_address,
186                reason: format!("Expected 5 results, received {}", results.len()),
187            });
188        }
189
190        // Decode slot0
191        let slot0 =
192            UniswapV3Pool::slot0Call::abi_decode_returns(&results[0].returnData).map_err(|e| {
193                UniswapV3PoolError::DecodingError {
194                    field: "slot0".to_string(),
195                    pool: *pool_address,
196                    reason: e.to_string(),
197                    raw_data: hex::encode(&results[0].returnData),
198                }
199            })?;
200
201        // Decode liquidity
202        let liquidity = UniswapV3Pool::liquidityCall::abi_decode_returns(&results[1].returnData)
203            .map_err(|e| UniswapV3PoolError::DecodingError {
204                field: "liquidity".to_string(),
205                pool: *pool_address,
206                reason: e.to_string(),
207                raw_data: hex::encode(&results[1].returnData),
208            })?;
209
210        // Decode feeGrowthGlobal0X128
211        let fee_growth_0 =
212            UniswapV3Pool::feeGrowthGlobal0X128Call::abi_decode_returns(&results[2].returnData)
213                .map_err(|e| UniswapV3PoolError::DecodingError {
214                    field: "feeGrowthGlobal0X128".to_string(),
215                    pool: *pool_address,
216                    reason: e.to_string(),
217                    raw_data: hex::encode(&results[2].returnData),
218                })?;
219
220        // Decode feeGrowthGlobal1X128
221        let fee_growth_1 =
222            UniswapV3Pool::feeGrowthGlobal1X128Call::abi_decode_returns(&results[3].returnData)
223                .map_err(|e| UniswapV3PoolError::DecodingError {
224                    field: "feeGrowthGlobal1X128".to_string(),
225                    pool: *pool_address,
226                    reason: e.to_string(),
227                    raw_data: hex::encode(&results[3].returnData),
228                })?;
229
230        // Decode protocolFees
231        let protocol_fees = UniswapV3Pool::protocolFeesCall::abi_decode_returns(
232            &results[4].returnData,
233        )
234        .map_err(|e| UniswapV3PoolError::DecodingError {
235            field: "protocolFees".to_string(),
236            pool: *pool_address,
237            reason: e.to_string(),
238            raw_data: hex::encode(&results[4].returnData),
239        })?;
240
241        let mut state = PoolState {
242            current_tick: slot0.tick.as_i32(),
243            price_sqrt_ratio_x96: slot0.sqrtPriceX96,
244            liquidity,
245            protocol_fees_token0: U256::from(protocol_fees.token0),
246            protocol_fees_token1: U256::from(protocol_fees.token1),
247            fee_protocol: 0,
248            fee_protocol0_basis_points: None,
249            fee_protocol1_basis_points: None,
250            fee_growth_global_0: fee_growth_0,
251            fee_growth_global_1: fee_growth_1,
252        };
253
254        match fee_protocol_encoding {
255            FeeProtocolEncoding::UniswapV3Packed => {
256                let fee_protocol = u8::try_from(slot0.feeProtocol).map_err(|e| {
257                    UniswapV3PoolError::DecodingError {
258                        field: "slot0.feeProtocol".to_string(),
259                        pool: *pool_address,
260                        reason: e.to_string(),
261                        raw_data: slot0.feeProtocol.to_string(),
262                    }
263                })?;
264                state.set_uniswap_v3_fee_protocol(fee_protocol);
265            }
266            FeeProtocolEncoding::PancakeSwapV3BasisPoints => {
267                let (fee_protocol0, fee_protocol1) =
268                    split_pancakeswap_v3_fee_protocol(slot0.feeProtocol);
269                state.set_protocol_fee_basis_points(fee_protocol0, fee_protocol1);
270            }
271        }
272
273        Ok(state)
274    }
275
276    /// Gets tick data for a specific tick.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the RPC call fails or decoding fails.
281    pub async fn get_tick(
282        &self,
283        pool_address: &Address,
284        tick: i32,
285        block: Option<u64>,
286    ) -> Result<PoolTick, UniswapV3PoolError> {
287        let tick_i24 = I24::try_from(tick).map_err(|_| UniswapV3PoolError::CallFailed {
288            field: "tick".to_string(),
289            pool: *pool_address,
290            reason: format!("Tick {tick} out of range for int24"),
291        })?;
292
293        let call_data = UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode();
294        let raw_response = self
295            .base
296            .execute_call(pool_address, &call_data, block)
297            .await?;
298
299        let tick_info =
300            UniswapV3Pool::ticksCall::abi_decode_returns(&raw_response).map_err(|e| {
301                UniswapV3PoolError::DecodingError {
302                    field: format!("ticks({tick})"),
303                    pool: *pool_address,
304                    reason: e.to_string(),
305                    raw_data: hex::encode(&raw_response),
306                }
307            })?;
308
309        Ok(PoolTick::new(
310            tick,
311            tick_info.liquidityGross,
312            tick_info.liquidityNet,
313            tick_info.feeGrowthOutside0X128,
314            tick_info.feeGrowthOutside1X128,
315            tick_info.initialized,
316            0, // last_updated_block - not available from RPC
317        ))
318    }
319
320    /// Gets tick data for multiple ticks in a single multicall.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the multicall fails, a subcall fails, or any tick decoding fails.
325    pub async fn batch_get_ticks(
326        &self,
327        pool_address: &Address,
328        ticks: &[i32],
329        block: Option<u64>,
330    ) -> Result<HashMap<i32, PoolTick>, UniswapV3PoolError> {
331        let calls: Vec<ContractCall> = ticks
332            .iter()
333            .map(|&tick| {
334                let tick_i24 = I24::try_from(tick).map_err(|_| UniswapV3PoolError::CallFailed {
335                    field: format!("ticks({tick})"),
336                    pool: *pool_address,
337                    reason: "tick is out of range for int24".to_string(),
338                })?;
339                Ok(ContractCall {
340                    target: *pool_address,
341                    allow_failure: false,
342                    call_data: UniswapV3Pool::ticksCall { tick: tick_i24 }.abi_encode(),
343                })
344            })
345            .collect::<Result<_, UniswapV3PoolError>>()?;
346
347        let results = self.base.execute_multicall(calls, block).await?;
348        decode_tick_results(pool_address, ticks, &results)
349    }
350
351    /// Computes the position key used by Uniswap V3.
352    ///
353    /// The key is: keccak256(abi.encodePacked(owner, tickLower, tickUpper))
354    #[must_use]
355    pub fn compute_position_key(owner: &Address, tick_lower: i32, tick_upper: i32) -> [u8; 32] {
356        // Pack: address (20 bytes) + int24 (3 bytes) + int24 (3 bytes) = 26 bytes total
357        let mut packed = Vec::with_capacity(26);
358
359        // Add owner address (20 bytes)
360        packed.extend_from_slice(owner.as_slice());
361
362        // Add tick_lower as int24 (3 bytes, big-endian, sign-extended)
363        let tick_lower_bytes = tick_lower.to_be_bytes();
364        packed.extend_from_slice(&tick_lower_bytes[1..4]);
365
366        // Add tick_upper as int24 (3 bytes, big-endian, sign-extended)
367        let tick_upper_bytes = tick_upper.to_be_bytes();
368        packed.extend_from_slice(&tick_upper_bytes[1..4]);
369
370        keccak256(&packed).into()
371    }
372
373    /// Gets position data for multiple positions in a single multicall.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if the multicall fails, a subcall fails, or any position decoding fails.
378    pub async fn batch_get_positions(
379        &self,
380        pool_address: &Address,
381        positions: &[(Address, i32, i32)],
382        block: Option<u64>,
383    ) -> Result<Vec<PoolPosition>, UniswapV3PoolError> {
384        let calls: Vec<ContractCall> = positions
385            .iter()
386            .map(|(owner, tick_lower, tick_upper)| {
387                let position_key = Self::compute_position_key(owner, *tick_lower, *tick_upper);
388                ContractCall {
389                    target: *pool_address,
390                    allow_failure: false,
391                    call_data: UniswapV3Pool::positionsCall {
392                        key: position_key.into(),
393                    }
394                    .abi_encode(),
395                }
396            })
397            .collect();
398
399        let results = self.base.execute_multicall(calls, block).await?;
400        decode_position_results(pool_address, positions, &results)
401    }
402
403    /// Fetches a complete pool snapshot directly from on-chain state.
404    ///
405    /// Retrieves global state, tick data, and position data from the blockchain
406    /// and constructs a `PoolSnapshot` representing the current on-chain state.
407    /// This snapshot can be compared against profiler state for validation.
408    ///
409    /// # Errors
410    ///
411    /// Returns error if any RPC calls fail or data cannot be decoded.
412    #[expect(clippy::too_many_arguments)]
413    pub async fn fetch_snapshot(
414        &self,
415        pool_address: &Address,
416        instrument_id: InstrumentId,
417        tick_values: &[i32],
418        position_keys: &[(Address, i32, i32)],
419        block_position: BlockPosition,
420        ts_event: UnixNanos,
421        ts_init: UnixNanos,
422        fee_protocol_encoding: FeeProtocolEncoding,
423    ) -> Result<PoolSnapshot, UniswapV3PoolError> {
424        // Fetch all data at the specified block
425        let block = Some(block_position.number);
426        let global_state = self
427            .get_global_state(pool_address, block, fee_protocol_encoding)
428            .await?;
429        let ticks_map = self
430            .batch_get_ticks(pool_address, tick_values, block)
431            .await?;
432        let positions = self
433            .batch_get_positions(pool_address, position_keys, block)
434            .await?;
435
436        Ok(PoolSnapshot::new(
437            instrument_id,
438            global_state,
439            positions,
440            ticks_map.into_values().collect(),
441            PoolAnalytics::default(),
442            block_position,
443            ts_event,
444            ts_init,
445        ))
446    }
447}
448
449fn decode_tick_results(
450    pool_address: &Address,
451    ticks: &[i32],
452    results: &[Multicall3::Result],
453) -> Result<HashMap<i32, PoolTick>, UniswapV3PoolError> {
454    if results.len() != ticks.len() {
455        return Err(UniswapV3PoolError::CallFailed {
456            field: "ticks".to_string(),
457            pool: *pool_address,
458            reason: format!(
459                "expected {} multicall results, received {}",
460                ticks.len(),
461                results.len()
462            ),
463        });
464    }
465
466    let mut tick_infos = HashMap::with_capacity(ticks.len());
467
468    for (&tick_value, result) in ticks.iter().zip(results) {
469        if !result.success {
470            return Err(UniswapV3PoolError::CallFailed {
471                field: format!("ticks({tick_value})"),
472                pool: *pool_address,
473                reason: "multicall subcall failed".to_string(),
474            });
475        }
476
477        let tick_info =
478            UniswapV3Pool::ticksCall::abi_decode_returns(&result.returnData).map_err(|e| {
479                UniswapV3PoolError::DecodingError {
480                    field: format!("ticks({tick_value})"),
481                    pool: *pool_address,
482                    reason: e.to_string(),
483                    raw_data: hex::encode(&result.returnData),
484                }
485            })?;
486        tick_infos.insert(
487            tick_value,
488            PoolTick::new(
489                tick_value,
490                tick_info.liquidityGross,
491                tick_info.liquidityNet,
492                tick_info.feeGrowthOutside0X128,
493                tick_info.feeGrowthOutside1X128,
494                tick_info.initialized,
495                0,
496            ),
497        );
498    }
499
500    Ok(tick_infos)
501}
502
503fn decode_position_results(
504    pool_address: &Address,
505    positions: &[(Address, i32, i32)],
506    results: &[Multicall3::Result],
507) -> Result<Vec<PoolPosition>, UniswapV3PoolError> {
508    if results.len() != positions.len() {
509        return Err(UniswapV3PoolError::CallFailed {
510            field: "positions".to_string(),
511            pool: *pool_address,
512            reason: format!(
513                "expected {} multicall results, received {}",
514                positions.len(),
515                results.len()
516            ),
517        });
518    }
519
520    positions
521        .iter()
522        .zip(results)
523        .map(|((owner, tick_lower, tick_upper), result)| {
524            let field = format!("positions({owner}, {tick_lower}, {tick_upper})");
525
526            if !result.success {
527                return Err(UniswapV3PoolError::CallFailed {
528                    field,
529                    pool: *pool_address,
530                    reason: "multicall subcall failed".to_string(),
531                });
532            }
533
534            let info = UniswapV3Pool::positionsCall::abi_decode_returns(&result.returnData)
535                .map_err(|e| UniswapV3PoolError::DecodingError {
536                    field,
537                    pool: *pool_address,
538                    reason: e.to_string(),
539                    raw_data: hex::encode(&result.returnData),
540                })?;
541            Ok(PoolPosition {
542                owner: *owner,
543                tick_lower: *tick_lower,
544                tick_upper: *tick_upper,
545                liquidity: info.liquidity,
546                fee_growth_inside_0_last: info.feeGrowthInside0LastX128,
547                fee_growth_inside_1_last: info.feeGrowthInside1LastX128,
548                tokens_owed_0: info.tokensOwed0,
549                tokens_owed_1: info.tokensOwed1,
550                total_amount0_deposited: U256::ZERO,
551                total_amount1_deposited: U256::ZERO,
552                total_amount0_collected: 0,
553                total_amount1_collected: 0,
554            })
555        })
556        .collect()
557}
558
559const fn split_pancakeswap_v3_fee_protocol(fee_protocol: u32) -> (u32, u32) {
560    (
561        fee_protocol % PANCAKESWAP_V3_PROTOCOL_FEE_LANE_SIZE,
562        fee_protocol >> 16,
563    )
564}
565
566#[cfg(test)]
567mod tests {
568    use alloy::primitives::Bytes;
569    use rstest::rstest;
570
571    use super::*;
572
573    #[rstest]
574    fn split_pancakeswap_v3_fee_protocol_returns_token_basis_points() {
575        let fee_protocol = 3_200 + (4_000 << 16);
576
577        assert_eq!(
578            split_pancakeswap_v3_fee_protocol(fee_protocol),
579            (3_200, 4_000)
580        );
581        assert_eq!(split_pancakeswap_v3_fee_protocol(0), (0, 0));
582    }
583
584    #[rstest]
585    fn decode_tick_results_rejects_failed_subcall() {
586        let results = vec![Multicall3::Result {
587            success: false,
588            returnData: Bytes::new(),
589        }];
590
591        let error = decode_tick_results(&Address::ZERO, &[10], &results).unwrap_err();
592
593        assert!(matches!(
594            error,
595            UniswapV3PoolError::CallFailed { field, .. } if field == "ticks(10)"
596        ));
597    }
598
599    #[rstest]
600    fn decode_tick_results_rejects_missing_result() {
601        let error = decode_tick_results(&Address::ZERO, &[10], &[]).unwrap_err();
602
603        assert!(matches!(
604            error,
605            UniswapV3PoolError::CallFailed { field, .. } if field == "ticks"
606        ));
607    }
608
609    #[rstest]
610    fn decode_position_results_rejects_undecodable_result() {
611        let results = vec![Multicall3::Result {
612            success: true,
613            returnData: Bytes::new(),
614        }];
615
616        let error = decode_position_results(&Address::ZERO, &[(Address::ZERO, -10, 10)], &results)
617            .unwrap_err();
618
619        assert!(matches!(error, UniswapV3PoolError::DecodingError { .. }));
620    }
621
622    #[rstest]
623    fn decode_position_results_rejects_failed_subcall() {
624        let results = vec![Multicall3::Result {
625            success: false,
626            returnData: Bytes::new(),
627        }];
628
629        let error = decode_position_results(&Address::ZERO, &[(Address::ZERO, -10, 10)], &results)
630            .unwrap_err();
631
632        assert!(matches!(
633            error,
634            UniswapV3PoolError::CallFailed { field, .. }
635                if field == "positions(0x0000000000000000000000000000000000000000, -10, 10)"
636        ));
637    }
638
639    #[rstest]
640    fn decode_position_results_rejects_missing_result() {
641        let error =
642            decode_position_results(&Address::ZERO, &[(Address::ZERO, -10, 10)], &[]).unwrap_err();
643
644        assert!(matches!(
645            error,
646            UniswapV3PoolError::CallFailed { field, .. } if field == "positions"
647        ));
648    }
649}