1use 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 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 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 struct PositionInfo {
68 uint128 liquidity;
69 uint256 feeGrowthInside0LastX128;
70 uint256 feeGrowthInside1LastX128;
71 uint128 tokensOwed0;
72 uint128 tokensOwed1;
73 }
74
75 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum FeeProtocolEncoding {
93 UniswapV3Packed,
95 PancakeSwapV3BasisPoints,
97}
98
99#[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#[derive(Debug)]
127pub struct UniswapV3PoolContract {
128 base: BaseContract,
130}
131
132impl UniswapV3PoolContract {
133 #[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 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 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 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 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 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 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 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, ))
318 }
319
320 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 #[must_use]
355 pub fn compute_position_key(owner: &Address, tick_lower: i32, tick_upper: i32) -> [u8; 32] {
356 let mut packed = Vec::with_capacity(26);
358
359 packed.extend_from_slice(owner.as_slice());
361
362 let tick_lower_bytes = tick_lower.to_be_bytes();
364 packed.extend_from_slice(&tick_lower_bytes[1..4]);
365
366 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 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 #[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 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}