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 56b1840a1a4b4d5d8f9e08d3198525e7dc737fc6 Mon Sep 17 00:00:00 2001 From: Rohan Date: Fri, 17 Jul 2026 11:47:53 -0400 Subject: [PATCH 2/2] Preserve fractional precision when decoding Q96 prices --- src/constants.rs | 6 ++---- src/convert.rs | 35 +++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index e779de4..8087365 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -60,10 +60,8 @@ pub const MAX_PROTOCOL_FEE: u32 = 50_000; /// Maximum absolute error when decoding a Q96 fixed-point value to f64. /// -/// The conversion `(value * 1e6) / Q96` uses integer division, which -/// truncates the remainder. The truncation loses at most 1 unit in the -/// intermediate integer, mapping to `1 / 1e6 = 0.000001` in the final -/// f64. This bound holds regardless of price magnitude. +/// Q96 decoding retains the integer and fractional components separately. +/// This remains a conservative tolerance for callers comparing decoded prices. pub const Q96_PRECISION: f64 = 0.000001; /// ERC721 name for PerpCity position NFTs. diff --git a/src/convert.rs b/src/convert.rs index 85a7eda..d935a8d 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -12,11 +12,11 @@ //! //! Uniswap V4 prices are stored as `sqrtPriceX96 = sqrt(price) × 2^96`. //! The [`price_to_sqrt_price_x96`] / [`sqrt_price_x96_to_price`] pair -//! handles this encoding, using a 6-decimal intermediate for precision. +//! handles this encoding while retaining the Q96 fractional component. use alloy::primitives::U256; -use crate::constants::Q96; +use crate::constants::{Q96, Q96_U128}; use crate::errors::ValidationError; // ── Module-level constants ───────────────────────────────────────────── @@ -153,7 +153,7 @@ pub fn margin_ratio_to_leverage(margin_ratio: u32) -> Result Result { }); } - let intermediate = (value * BIGINT_1E6) / Q96; + let integer = value / Q96; - if intermediate > U256::from(MAX_SAFE_F64_INT) { + if integer > U256::from(MAX_SAFE_F64_INT) { return Err(ValidationError::Overflow { - context: "Q96 price exceeds safe f64 integer range after scaling".into(), + context: "Q96 price integer component exceeds safe f64 range".into(), }); } - let int_val = intermediate.as_limbs()[0]; - Ok(int_val as f64 / F64_1E6) + let remainder = value % Q96; + let limbs = remainder.as_limbs(); + let remainder_u128 = u128::from(limbs[0]) | (u128::from(limbs[1]) << 64); + + Ok(integer.as_limbs()[0] as f64 + remainder_u128 as f64 / Q96_U128 as f64) } // ── Price ↔ sqrtPriceX96 ────────────────────────────────────────────── @@ -286,7 +289,7 @@ pub fn sqrt_price_x96_to_price(sqrt_price_x96: U256) -> Result 0.0); + } + #[test] fn sqrt_price_x96_protocol_max() { // MAX_SQRT_PRICE_X96 corresponds to price ≈ 1e6. @@ -638,9 +651,7 @@ mod tests { #[test] fn price_sqrt_price_x96_roundtrip() { - // Test a range of prices. The 6-decimal intermediate means we - // lose precision at around 1e-6 relative error. - for &price in &[0.01, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0, 500.0, 1e6] { + for &price in &[1e-6, 0.01, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0, 1e6] { let sqrt_px96 = price_to_sqrt_price_x96(price).unwrap(); let recovered = sqrt_price_x96_to_price(sqrt_px96).unwrap(); let rel_error = (recovered - price).abs() / price;