diff --git a/src/quoting/base_pool.rs b/src/quoting/base_pool.rs index f094b3e..7e567cf 100644 --- a/src/quoting/base_pool.rs +++ b/src/quoting/base_pool.rs @@ -2,7 +2,9 @@ use crate::math::swap::{compute_step, is_price_increasing, ComputeStepError}; use crate::math::tick::{to_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO}; use crate::math::uint::U256; use crate::quoting::types::{NodeKey, Pool, Quote, QuoteParams, Tick}; -use crate::quoting::util::approximate_number_of_tick_spacings_crossed; +use crate::quoting::util::{ + approximate_number_of_tick_spacings_crossed, construct_sorted_ticks, ConstructSortedTicksError, +}; use alloc::vec::Vec; use core::ops::{Add, AddAssign}; use num_traits::Zero; @@ -64,6 +66,7 @@ pub struct BasePool { #[derive(Debug, PartialEq, Eq, Clone, Copy)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum BasePoolError { + ConstructSortedTicksFromPartialDataError(ConstructSortedTicksError), /// Token0 must be less than token1. TokenOrderInvalid, /// Tick spacing must be less than or equal to max tick spacing. @@ -91,6 +94,76 @@ pub enum BasePoolError { } impl BasePool { + /// Creates a BasePool from partial tick data retrieved from a quote data fetcher lens contract. + /// + /// This helper constructor takes partial tick data along with min/max tick boundaries and constructs + /// a valid BasePool instance with properly balanced liquidity deltas. + /// + /// # Arguments + /// + /// * `key` - The NodeKey containing token information and configuration + /// * `sqrt_ratio` - The square root price ratio of the pool + /// * `partial_ticks` - A vector of ticks retrieved from the lens contract + /// * `min_tick_searched` - The minimum tick that was searched (not necessarily a multiple of tick spacing) + /// * `max_tick_searched` - The maximum tick that was searched (not necessarily a multiple of tick spacing) + /// * `liquidity` - The current liquidity of the pool + /// * `current_tick` - The current tick of the pool + /// + /// # Returns + /// + /// * `Result` - A new BasePool instance or an error + pub fn from_partial_data( + key: NodeKey, + sqrt_ratio: U256, + partial_ticks: Vec, + min_tick_searched: i32, + max_tick_searched: i32, + liquidity: u128, + current_tick: i32, + ) -> Result { + // Use the construct_sorted_ticks function to get valid sorted ticks + let tick_spacing = key.config.tick_spacing; + let spacing_i32 = tick_spacing as i32; + + // Get sorted ticks using the utility function + let sorted_ticks = construct_sorted_ticks( + partial_ticks, + min_tick_searched, + max_tick_searched, + tick_spacing, + liquidity, + current_tick, + ) + .map_err(BasePoolError::ConstructSortedTicksFromPartialDataError)?; + + // Ensure all ticks are multiples of tick spacing + for tick in &sorted_ticks { + if tick.index % spacing_i32 != 0 { + return Err(BasePoolError::TickNotMultipleOfSpacing); + } + } + + // Find the active tick index (closest initialized tick at or below current_tick) + let mut active_tick_index = None; + for (i, tick) in sorted_ticks.iter().enumerate() { + if tick.index <= current_tick { + active_tick_index = Some(i); + } else { + break; + } + } + + // Create the BasePoolState with the provided sqrt_ratio, liquidity, and computed active_tick_index + let state = BasePoolState { + sqrt_ratio, + liquidity, + active_tick_index, + }; + + // Call the existing constructor with the prepared parameters + Self::new(key, state, sorted_ticks) + } + pub fn new( key: NodeKey, state: BasePoolState, @@ -197,6 +270,171 @@ impl BasePool { } } +// Tests for the from_partial_data constructor +#[cfg(test)] +mod from_partial_data_tests { + use super::*; + use crate::math::tick::{MAX_TICK, MIN_TICK}; + use crate::quoting::types::Config; + use alloc::vec; + + // Constants for testing + const TOKEN0: U256 = U256([1, 0, 0, 0]); + const TOKEN1: U256 = U256([2, 0, 0, 0]); + + // Helper function to create a test config + fn create_test_config(tick_spacing: u32) -> Config { + Config { + tick_spacing, + fee: 0, + extension: U256::zero(), + } + } + + #[test] + fn test_from_partial_data_empty_ticks() { + // Test creating a pool with empty tick data + let key = NodeKey { + token0: TOKEN0, + token1: TOKEN1, + config: create_test_config(10), + }; + + let sqrt_ratio = to_sqrt_ratio(0).unwrap(); + let partial_ticks = Vec::new(); + let min_tick_searched = -5005; + let max_tick_searched = 5005; + let liquidity = 1000; + let current_tick = 0; + + let result = BasePool::from_partial_data( + key, + sqrt_ratio, + partial_ticks, + min_tick_searched, + max_tick_searched, + liquidity, + current_tick, + ); + + assert!(result.is_ok()); + let pool = result.unwrap(); + + // Verify the pool has MIN_TICK and MAX_TICK ticks + let ticks = pool.get_sorted_ticks(); + assert_eq!(ticks.len(), 2); + assert_eq!(ticks[0].index, -5010); + assert_eq!(ticks[0].liquidity_delta, liquidity as i128); + assert_eq!(ticks[1].index, 5010); + assert_eq!(ticks[1].liquidity_delta, -(liquidity as i128)); + } + + #[test] + fn test_from_partial_data_with_partial_ticks() { + // Test creating a pool with partial ticks + let key = NodeKey { + token0: TOKEN0, + token1: TOKEN1, + config: create_test_config(10), + }; + + let sqrt_ratio = to_sqrt_ratio(50).unwrap(); + let partial_ticks = vec![ + Tick { + index: 0, + liquidity_delta: 500, + }, + Tick { + index: 100, + liquidity_delta: -200, + }, + ]; + + let min_tick_searched = -45; + let max_tick_searched = 145; + let liquidity = 750; + let current_tick = 52; + + let result = BasePool::from_partial_data( + key, + sqrt_ratio, + partial_ticks, + min_tick_searched, + max_tick_searched, + liquidity, + current_tick, + ); + + assert!(result.is_ok()); + let pool = result.unwrap(); + + // Verify the constructed pool has the correct properties + let ticks = pool.get_sorted_ticks(); + + // Check that we have ticks at the min and max boundaries + assert_eq!( + pool.sorted_ticks.first().unwrap(), + &Tick { + index: -50, + liquidity_delta: 250 + } + ); + assert_eq!( + pool.sorted_ticks.last().unwrap(), + &Tick { + index: 150, + liquidity_delta: -550 + } + ); + + // Verify active_tick_index points to tick at or before current_tick + let active_index = pool.state.active_tick_index; + assert_eq!(active_index.unwrap(), 1); + + // Verify all liquidity deltas sum to zero + let sum: i128 = ticks.iter().map(|t| t.liquidity_delta).sum(); + assert_eq!(sum, 0); + + // Verify active liquidity matches + assert_eq!(pool.state.liquidity, liquidity); + } + + #[test] + fn test_from_partial_data_tick_spacing_validation() { + // Test that the tick spacing validation works + let key = NodeKey { + token0: TOKEN0, + token1: TOKEN1, + config: create_test_config(0), // Invalid tick spacing + }; + + let sqrt_ratio = to_sqrt_ratio(0).unwrap(); + let partial_ticks = Vec::new(); + let min_tick_searched = MIN_TICK; + let max_tick_searched = MAX_TICK; + let liquidity = 1000; + let current_tick = 0; + + let result = BasePool::from_partial_data( + key, + sqrt_ratio, + partial_ticks, + min_tick_searched, + max_tick_searched, + liquidity, + current_tick, + ); + + // Should fail with TickSpacingCannotBeZero + assert_eq!( + result.unwrap_err(), + BasePoolError::ConstructSortedTicksFromPartialDataError( + ConstructSortedTicksError::InvalidTickSpacing + ) + ); + } +} + impl Pool for BasePool { type Resources = BasePoolResources; type State = BasePoolState; diff --git a/src/quoting/util.rs b/src/quoting/util.rs index f90b6bf..a769534 100644 --- a/src/quoting/util.rs +++ b/src/quoting/util.rs @@ -1,5 +1,9 @@ +use crate::math::tick::{MAX_TICK, MIN_TICK}; use crate::math::uint::U256; +use crate::quoting::base_pool::MAX_TICK_SPACING; use crate::quoting::types::Tick; +use alloc::vec::Vec; +use num_traits::Zero; // Function to find the nearest initialized tick index. pub fn find_nearest_initialized_tick_index(sorted_ticks: &[Tick], tick: i32) -> Option { @@ -47,14 +51,125 @@ pub fn approximate_number_of_tick_spacings_crossed( ticks_crossed / tick_spacing } +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ConstructSortedTicksError { + CurrentTickOutsideSearchedRange, + MinTickLessThanMaxTick, + InvalidTickSpacing, +} + +/// Converts a partial view of sorted tick data into valid sorted tick data for a pool. +/// +/// This function takes partial tick data retrieved from a quote data fetcher lens contract +/// along with min/max tick range, and constructs a valid set of sorted ticks that ensures: +/// 1. All liquidity deltas add up to zero +/// 2. The current liquidity matches the sum of liquidity deltas from MIN_TICK to current active tick +/// +/// # Arguments +/// +/// * `partial_ticks` - A vector of ticks retrieved from the lens contract +/// * `min_tick_searched` - The minimum tick that was searched (not necessarily a multiple of tick spacing) +/// * `max_tick_searched` - The maximum tick that was searched (not necessarily a multiple of tick spacing) +/// * `tick_spacing` - The tick spacing of the pool +/// * `liquidity` - The current liquidity of the pool +/// * `current_tick` - The current tick of the pool, must be between min and max tick searched +pub fn construct_sorted_ticks( + partial_ticks: Vec, + min_tick_searched: i32, + max_tick_searched: i32, + tick_spacing: u32, + liquidity: u128, + current_tick: i32, +) -> Result, ConstructSortedTicksError> { + if current_tick < min_tick_searched || current_tick > max_tick_searched { + return Err(ConstructSortedTicksError::CurrentTickOutsideSearchedRange); + } + if min_tick_searched > max_tick_searched { + return Err(ConstructSortedTicksError::MinTickLessThanMaxTick); + } + if tick_spacing.is_zero() || tick_spacing > MAX_TICK_SPACING { + return Err(ConstructSortedTicksError::InvalidTickSpacing); + } + + let spacing_i32 = tick_spacing as i32; + + let valid_min_tick = (((min_tick_searched - (spacing_i32 - 1)) / spacing_i32) * spacing_i32) + .max((MIN_TICK / spacing_i32) * spacing_i32); + + let valid_max_tick = (((max_tick_searched + (spacing_i32 - 1)) / spacing_i32) * spacing_i32) + .min((MAX_TICK / spacing_i32) * spacing_i32); + + // Sort and deduplicate ticks + let mut result = partial_ticks.clone(); + result.sort_by_key(|tick| tick.index); + + // Calculate sum of liquidity for ticks at or below current_tick + let mut liquidity_sum = 0_i128; + + // First pass: find active tick index and calculate running sum + for tick in result.iter() { + if tick.index <= current_tick { + liquidity_sum += tick.liquidity_delta; + } else { + break; + } + } + + // Calculate min tick delta (difference between expected and actual liquidity) + let min_tick_liquidity_delta = (liquidity as i128) - liquidity_sum; + + // Calculate max tick delta (ensure all deltas sum to zero) + // For this, we need to sum all tick deltas and negate the result plus min_delta + let all_delta_sum: i128 = result.iter().map(|t| t.liquidity_delta).sum(); + let max_tick_liquidity_delta = -(min_tick_liquidity_delta + all_delta_sum); + + if min_tick_liquidity_delta != 0 { + // Check if we already have min/max boundary ticks + let has_min_tick = result.first().map_or(false, |t| t.index == valid_min_tick); + + // Add or update min boundary tick + if has_min_tick { + result.first_mut().unwrap().liquidity_delta += min_tick_liquidity_delta; + } else { + result.insert( + 0, + Tick { + index: valid_min_tick, + liquidity_delta: min_tick_liquidity_delta, + }, + ); + } + } + + if max_tick_liquidity_delta != 0 { + let has_max_tick = result.last().map_or(false, |t| t.index == valid_max_tick); + + // Add or update max boundary tick + if has_max_tick { + // Update existing tick + result.last_mut().unwrap().liquidity_delta += max_tick_liquidity_delta; + } else { + // Add new max boundary tick + result.push(Tick { + index: valid_max_tick, + liquidity_delta: max_tick_liquidity_delta, + }); + } + } + + Ok(result) +} + #[cfg(test)] mod tests { - use crate::math::tick::{MAX_SQRT_RATIO, MIN_SQRT_RATIO}; + use crate::math::tick::{MAX_SQRT_RATIO, MAX_TICK, MIN_SQRT_RATIO, MIN_TICK}; use crate::math::uint::U256; use crate::quoting::types::Tick; use crate::quoting::util::find_nearest_initialized_tick_index; use crate::quoting::util::{ - approximate_number_of_tick_spacings_crossed, u256_to_float_base_x128, + approximate_number_of_tick_spacings_crossed, construct_sorted_ticks, + u256_to_float_base_x128, }; use alloc::vec; @@ -262,4 +377,237 @@ mod tests { 2.2773612363638864e24 ); } + + mod construct_sorted_ticks_tests { + use super::*; + use crate::quoting::util::ConstructSortedTicksError; + + #[test] + fn test_empty_ticks() { + let result = construct_sorted_ticks(vec![], MIN_TICK, MAX_TICK, 1, 1000, 0).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].index, MIN_TICK); + assert_eq!(result[0].liquidity_delta, 1000); + assert_eq!(result[1].index, MAX_TICK); + assert_eq!(result[1].liquidity_delta, -1000); + } + + #[test] + fn test_empty_ticks_rounded_tick_spacing() { + let result = construct_sorted_ticks(vec![], MIN_TICK, MAX_TICK, 10, 1000, 0).unwrap(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0].index, -88722830); + assert_eq!(result[0].liquidity_delta, 1000); + assert_eq!(result[1].index, 88722830); + assert_eq!(result[1].liquidity_delta, -1000); + } + + #[test] + fn test_empty_ticks_zero_liquidity() { + let result = construct_sorted_ticks(vec![], MIN_TICK, MAX_TICK, 1, 0, 0).unwrap(); + + assert_eq!(result.len(), 0); + } + + #[test] + fn test_min_max_tick_rounding() { + let tick_spacing = 10; + let min_searched = -15; // Should round down to -20 + let max_searched = 25; // Should round up to 30 + + let ticks = vec![Tick { + index: 0, + liquidity_delta: 100, + }]; + + let result = + construct_sorted_ticks(ticks, min_searched, max_searched, tick_spacing, 100, -5) + .unwrap(); + + // We should have added ticks at -20 and 30 + assert!(result.iter().any(|t| t.index == -20)); + assert!(result.iter().any(|t| t.index == 30)); + + // The sum of all liquidity deltas should be zero + let sum: i128 = result.iter().map(|t| t.liquidity_delta).sum(); + assert_eq!(sum, 0); + } + + #[test] + fn test_current_tick_active_liquidity() { + let tick_spacing = 10; + let current_tick = 15; + let liquidity = 200; + + let ticks = vec![ + Tick { + index: 0, + liquidity_delta: 100, + }, + Tick { + index: 20, + liquidity_delta: -50, + }, + ]; + + let result = + construct_sorted_ticks(ticks, -10, 30, tick_spacing, liquidity, current_tick) + .unwrap(); + + // Verify that the liquidity at the current tick is correct + let mut active_liquidity = 0_u128; + for tick in &result { + if tick.index <= current_tick { + if tick.liquidity_delta > 0 { + active_liquidity = + active_liquidity.saturating_add(tick.liquidity_delta.unsigned_abs()); + } else if active_liquidity >= tick.liquidity_delta.unsigned_abs() { + active_liquidity = + active_liquidity.saturating_sub(tick.liquidity_delta.unsigned_abs()); + } + } + } + + assert_eq!(active_liquidity, liquidity); + + // The sum of all liquidity deltas should be zero + let sum: i128 = result.iter().map(|t| t.liquidity_delta).sum(); + assert_eq!(sum, 0); + } + + #[test] + fn test_partial_view_with_existing_liquidity() { + let tick_spacing = 10; + + // Create partial ticks that don't include the whole range + let partial_ticks = vec![ + Tick { + index: 0, + liquidity_delta: 500, + }, + Tick { + index: 100, + liquidity_delta: -200, + }, + ]; + + let min_searched = -45; + let max_searched = 145; + let current_tick = 52; + let liquidity = 750; // Current liquidity at tick 50 + + let result = construct_sorted_ticks( + partial_ticks, + min_searched, + max_searched, + tick_spacing, + liquidity, + current_tick, + ) + .unwrap(); + + // Check that we have ticks at the min and max boundaries + assert_eq!( + result.first().unwrap(), + &Tick { + index: -50, + liquidity_delta: 250 + } + ); + assert_eq!( + result.last().unwrap(), + &Tick { + index: 150, + liquidity_delta: -550 + } + ); + + // Verify sum is zero + let sum: i128 = result.iter().map(|t| t.liquidity_delta).sum(); + assert_eq!(sum, 0); + } + + #[test] + fn test_current_tick_below_min_tick() { + let tick_spacing = 10; + let min_searched = 0; + let max_searched = 100; + let current_tick = -20; // Current tick is below the min searched tick + let liquidity = 100; + + let partial_ticks = vec![ + Tick { + index: 0, + liquidity_delta: 200, + }, + Tick { + index: 50, + liquidity_delta: -100, + }, + ]; + + assert_eq!( + construct_sorted_ticks( + partial_ticks, + min_searched, + max_searched, + tick_spacing, + liquidity, + current_tick, + ) + .unwrap_err(), + ConstructSortedTicksError::CurrentTickOutsideSearchedRange + ); + } + + #[test] + fn test_with_min_max_tick_boundary() { + let tick_spacing = 10; + + let partial_ticks = vec![ + Tick { + index: MIN_TICK, + liquidity_delta: 1000, + }, + Tick { + index: 0, + liquidity_delta: 500, + }, + Tick { + index: MAX_TICK, + liquidity_delta: -1500, + }, + ]; + + let result = + construct_sorted_ticks(partial_ticks, MIN_TICK, MAX_TICK, tick_spacing, 1000, -10) + .unwrap(); + + // Check boundaries are preserved + assert!(result.iter().any(|t| t.index == MIN_TICK)); + assert!(result.iter().any(|t| t.index == MAX_TICK)); + + // Sum should be zero + let sum: i128 = result.iter().map(|t| t.liquidity_delta).sum(); + assert_eq!(sum, 0); + + // Active liquidity should match + let mut active_liquidity = 0_u128; + for tick in &result { + if tick.index <= -10 { + if tick.liquidity_delta > 0 { + active_liquidity = + active_liquidity.saturating_add(tick.liquidity_delta.unsigned_abs()); + } else if active_liquidity >= tick.liquidity_delta.unsigned_abs() { + active_liquidity = + active_liquidity.saturating_sub(tick.liquidity_delta.unsigned_abs()); + } + } + } + + assert_eq!(active_liquidity, 1000); + } + } }