Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions src/client/trades.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -91,6 +91,24 @@ fn scale_opening_margin(margin: f64) -> std::result::Result<i128, ValidationErro
Ok(scaled)
}

/// Convert maker prices to aligned ticks and enforce the protocol's maker band.
fn maker_ticks_from_prices(
price_lower: f64,
price_upper: f64,
) -> 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 ──────────────────────────────────────────

Expand Down Expand Up @@ -158,16 +176,8 @@ impl PerpClient {
) -> Result<OpenResult> {
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,
Expand Down Expand Up @@ -437,12 +447,50 @@ 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() {
assert!(scale_opening_margin(4.999_999).is_err());
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());
}
}
26 changes: 12 additions & 14 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -125,13 +123,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);
}
}
45 changes: 23 additions & 22 deletions src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down Expand Up @@ -153,7 +153,7 @@ pub fn margin_ratio_to_leverage(margin_ratio: u32) -> Result<f64, ValidationErro
/// This is the base decoder for all Q96-encoded values. The input is
/// already a price (or index), not a sqrt price.
///
/// Formula: `price = value × 1e6 / 2^96 / 1e6`
/// Formula: `price = floor(value / 2^96) + (value mod 2^96) / 2^96`
///
/// Used for beacon index values (`IndexUpdated.index`) and as the
/// building block for [`sqrt_price_x96_to_price`].
Expand All @@ -179,16 +179,19 @@ pub fn price_x96_to_f64(value: U256) -> Result<f64, ValidationError> {
});
}

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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -587,15 +590,15 @@ mod tests {

#[test]
fn price_very_small_works() {
// 0.001 is at the protocol minimum
let result = price_to_sqrt_price_x96(0.001);
// 1e-6 is the protocol's minimum starting price.
let result = price_to_sqrt_price_x96(1e-6);
assert!(result.is_ok());
}

#[test]
fn price_1000_works() {
// 1000 is at the protocol maximum
let result = price_to_sqrt_price_x96(1000.0);
fn price_1e6_works() {
// 1e6 is the protocol's maximum starting price.
let result = price_to_sqrt_price_x96(1e6);
assert!(result.is_ok());
}

Expand Down Expand Up @@ -626,31 +629,29 @@ mod tests {

#[test]
fn sqrt_price_x96_protocol_min() {
// MIN_SQRT_PRICE_X96 corresponds to price ≈ 0.001
let price = sqrt_price_x96_to_price(MIN_SQRT_PRICE_X96).unwrap();
assert!(
(price - 0.001).abs() < Q96_PRECISION,
"MIN_SQRT_PRICE_X96 gave price={price}, expected ≈0.001"
(price - 1e-6).abs() < 1e-15,
"MIN_SQRT_PRICE_X96 gave price={price}, expected ≈1e-6"
);
assert!(price > 0.0);
}

#[test]
fn sqrt_price_x96_protocol_max() {
// MAX_SQRT_PRICE_X96 corresponds to price ≈ 1000
// MAX_SQRT_PRICE_X96 corresponds to price ≈ 1e6.
let price = sqrt_price_x96_to_price(MAX_SQRT_PRICE_X96).unwrap();
assert!(
(price - 1000.0).abs() < Q96_PRECISION,
"MAX_SQRT_PRICE_X96 gave price={price}, expected ≈1000"
(price - 1e6).abs() < Q96_PRECISION,
"MAX_SQRT_PRICE_X96 gave price={price}, expected ≈1e6"
);
}

// ── price ↔ sqrtPriceX96 roundtrip ─────────────────────────────

#[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, 999.0] {
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;
Expand Down
15 changes: 10 additions & 5 deletions src/math/tick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,14 +504,19 @@ mod tests {

#[test]
fn protocol_min_max_ticks_produce_valid_prices() {
// PerpCity uses ±69090 as its bounds.
// PerpCity uses ±138180 as its maker bounds, slightly wider than
// the factory's 1e-6..=1e6 starting-price range.
let min_price = tick_to_price(constants::MIN_TICK).unwrap();
let max_price = tick_to_price(constants::MAX_TICK).unwrap();
assert!(min_price > 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}"
);
}
}
Loading