From 765bf7d439e458de8ed429d3d311b27ebde0b52b Mon Sep 17 00:00:00 2001 From: Rohan Date: Fri, 17 Jul 2026 11:45:29 -0400 Subject: [PATCH 1/2] Fix canonical tick bounds and maker validation --- src/client/trades.rs | 72 ++++++++++++++++++++++++++++++++++++-------- src/constants.rs | 20 ++++++------ src/convert.rs | 30 ++++++------------ src/math/tick.rs | 15 ++++++--- 4 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/client/trades.rs b/src/client/trades.rs index d7eaf2f..4d17ca1 100644 --- a/src/client/trades.rs +++ b/src/client/trades.rs @@ -3,7 +3,7 @@ use alloy::primitives::{Address, B256, Bytes, I256, U256}; use alloy::sol_types::SolEvent; -use crate::constants::{MIN_OPENING_MARGIN, TICK_SPACING}; +use crate::constants::{MAX_TICK, MIN_OPENING_MARGIN, MIN_TICK, TICK_SPACING}; use crate::contracts::{IERC20, Perp}; use crate::convert::{scale_from_6dec, scale_to_6dec}; use crate::errors::{ContractError, Result, ValidationError}; @@ -91,6 +91,24 @@ fn scale_opening_margin(margin: f64) -> std::result::Result std::result::Result<(i32, i32), ValidationError> { + let tick_lower = align_tick_down(price_to_tick(price_lower)?, TICK_SPACING); + let tick_upper = align_tick_up(price_to_tick(price_upper)?, TICK_SPACING); + + if tick_lower < MIN_TICK || tick_upper > MAX_TICK || tick_lower >= tick_upper { + return Err(ValidationError::InvalidTickRange { + lower: tick_lower, + upper: tick_upper, + }); + } + + Ok((tick_lower, tick_upper)) +} + impl PerpClient { // ── Position operations ────────────────────────────────────────── @@ -158,16 +176,8 @@ impl PerpClient { ) -> Result { let margin_scaled = scale_opening_margin(params.margin)?; - let tick_lower = align_tick_down(price_to_tick(params.price_lower)?, TICK_SPACING); - let tick_upper = align_tick_up(price_to_tick(params.price_upper)?, TICK_SPACING); - - if tick_lower >= tick_upper { - return Err(ValidationError::InvalidTickRange { - lower: tick_lower, - upper: tick_upper, - } - .into()); - } + let (tick_lower, tick_upper) = + maker_ticks_from_prices(params.price_lower, params.price_upper)?; let wire_params = crate::contracts::OpenMakerParams { holder: self.address, @@ -437,7 +447,9 @@ impl PerpClient { #[cfg(test)] mod tests { - use super::scale_opening_margin; + use super::{maker_ticks_from_prices, scale_opening_margin}; + use crate::constants::{MAX_TICK, MIN_TICK, TICK_SPACING}; + use crate::math::tick::tick_to_price; #[test] fn opening_margin_enforces_protocol_minimum() { @@ -445,4 +457,40 @@ mod tests { assert_eq!(scale_opening_margin(5.0).unwrap(), 5_000_000); assert_eq!(scale_opening_margin(5.000_001).unwrap(), 5_000_001); } + + #[test] + fn maker_tick_range_accepts_protocol_bounds() { + let ticks = maker_ticks_from_prices( + tick_to_price(MIN_TICK).unwrap(), + tick_to_price(MAX_TICK).unwrap(), + ) + .unwrap(); + assert_eq!(ticks, (MIN_TICK, MAX_TICK)); + } + + #[test] + fn maker_starting_price_bounds_align_to_protocol_ticks() { + assert_eq!( + maker_ticks_from_prices(1e-6, 1e6).unwrap(), + (MIN_TICK, MAX_TICK) + ); + } + + #[test] + fn maker_tick_range_rejects_lower_bound_violation() { + let result = maker_ticks_from_prices( + tick_to_price(MIN_TICK - TICK_SPACING).unwrap(), + tick_to_price(0).unwrap(), + ); + assert!(result.is_err()); + } + + #[test] + fn maker_tick_range_rejects_upper_bound_violation() { + let result = maker_ticks_from_prices( + tick_to_price(0).unwrap(), + tick_to_price(MAX_TICK + TICK_SPACING).unwrap(), + ); + assert!(result.is_err()); + } } diff --git a/src/constants.rs b/src/constants.rs index 89c271b..e779de4 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -40,17 +40,17 @@ pub const MIN_OPENING_MARGIN: u32 = 5_000_000; /// Funding interval in seconds (1 day). pub const INTERVAL: u64 = 86_400; -/// sqrt(0.001) * 2^96 — the minimum allowed sqrtPriceX96. -pub const MIN_SQRT_PRICE_X96: U256 = uint!(2505414483750479311864138016_U256); +/// sqrt(1e-6) * 2^96 — the minimum starting sqrtPriceX96 accepted by the factory. +pub const MIN_SQRT_PRICE_X96: U256 = uint!(79228162514264337593543951_U256); -/// sqrt(1000) * 2^96 — the maximum allowed sqrtPriceX96. -pub const MAX_SQRT_PRICE_X96: U256 = uint!(2505414483750479311864138015696_U256); +/// sqrt(1e6) * 2^96 — the maximum starting sqrtPriceX96 accepted by the factory. +pub const MAX_SQRT_PRICE_X96: U256 = uint!(79228162514264337593543950336000_U256); -/// Minimum tick (~= TickMath.getTickAtSqrtPrice(MIN_SQRT_PRICE_X96)). -pub const MIN_TICK: i32 = -69_090; +/// Minimum allowed maker tick, slightly below the factory's minimum starting price. +pub const MIN_TICK: i32 = -138_180; -/// Maximum tick (~= TickMath.getTickAtSqrtPrice(MAX_SQRT_PRICE_X96)). -pub const MAX_TICK: i32 = 69_090; +/// Maximum allowed maker tick, slightly above the factory's maximum starting price. +pub const MAX_TICK: i32 = 138_180; /// Total supply of the internal accounting token: type(uint120).max. pub const ACCOUNTING_TOKEN_SUPPLY: U256 = U256::from_limbs([u64::MAX, u64::MAX >> 8, 0, 0]); // 2^120 - 1 @@ -125,13 +125,13 @@ mod tests { #[test] fn min_sqrt_price_x96_matches_contract() { - let expected = U256::from_str_radix("2505414483750479311864138016", 10).unwrap(); + let expected = U256::from_str_radix("79228162514264337593543951", 10).unwrap(); assert_eq!(MIN_SQRT_PRICE_X96, expected); } #[test] fn max_sqrt_price_x96_matches_contract() { - let expected = U256::from_str_radix("2505414483750479311864138015696", 10).unwrap(); + let expected = U256::from_str_radix("79228162514264337593543950336000", 10).unwrap(); assert_eq!(MAX_SQRT_PRICE_X96, expected); } } diff --git a/src/convert.rs b/src/convert.rs index 7b1166b..85a7eda 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -286,7 +286,7 @@ pub fn sqrt_price_x96_to_price(sqrt_price_x96: U256) -> Result 0.0); assert!(max_price > min_price); - // MIN_TICK ≈ -69090 → price ≈ 0.001 - assert!((min_price - 0.001).abs() < 0.0005, "min_price={min_price}"); - // MAX_TICK ≈ 69090 → price ≈ 1000 - assert!((max_price - 1000.0).abs() < 1.0, "max_price={max_price}"); + assert!( + (min_price - 1e-6).abs() / 1e-6 < 0.01, + "min_price={min_price}" + ); + assert!( + (max_price - 1e6).abs() / 1e6 < 0.01, + "max_price={max_price}" + ); } } From 3907ad462b1f140fa225cdb30cc87bbda94a6bca Mon Sep 17 00:00:00 2001 From: Rohan Date: Fri, 17 Jul 2026 11:51:25 -0400 Subject: [PATCH 2/2] Match Uniswap TickMath Q96 rounding --- src/math/tick.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/math/tick.rs b/src/math/tick.rs index 93ffbaf..cefe1af 100644 --- a/src/math/tick.rs +++ b/src/math/tick.rs @@ -111,7 +111,9 @@ pub fn get_sqrt_ratio_at_tick(tick: i32) -> Result { result = U256::MAX / result; } - Ok(result >> 32) + // Match Uniswap V4 TickMath: round up when reducing Q128.128 to Q128.96 + // so getTickAtSqrtPrice remains consistent with this result. + Ok((result + U256::from(u32::MAX)) >> 32) } /// Compute (a × b) >> 128 using native u128 widening multiply. @@ -519,4 +521,16 @@ mod tests { "max_price={max_price}" ); } + + #[test] + fn protocol_tick_sqrt_prices_match_contract_tick_math() { + assert_eq!( + get_sqrt_ratio_at_tick(constants::MIN_TICK).unwrap(), + U256::from_str_radix("79156945126914824732836954", 10).unwrap() + ); + assert_eq!( + get_sqrt_ratio_at_tick(constants::MAX_TICK).unwrap(), + U256::from_str_radix("79299443975792720780679863727831", 10).unwrap() + ); + } }