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,
});
Comment on lines +99 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate caller price ordering before widening ticks.

Equal—or reversed—prices that round to the same unaligned tick can become align_down(t) < align_up(t) and pass this check, opening an unintended range. Reject price_lower >= price_upper before alignment, and add a regression test using equal and reversed prices within one tick.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/trades.rs` around lines 99 - 106, Validate that price_lower is
strictly less than price_upper before converting or aligning ticks in the
surrounding trade range logic. Return the existing invalid-range error for equal
or reversed prices, while preserving the current tick-bound checks; add
regression coverage for equal and reversed prices that fall within a single
tick.

}

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());
}
}
20 changes: 10 additions & 10 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 Down Expand Up @@ -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);
}
}
30 changes: 10 additions & 20 deletions src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ pub fn sqrt_price_x96_to_price(sqrt_price_x96: U256) -> Result<f64, ValidationEr
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::{MAX_SQRT_PRICE_X96, MIN_SQRT_PRICE_X96, Q96_PRECISION};
use crate::constants::{MAX_SQRT_PRICE_X96, Q96_PRECISION};

// ── scale_to_6dec ──────────────────────────────────────────────

Expand Down Expand Up @@ -587,15 +587,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());
}
Comment on lines +590 to 593

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the minimum-price conversion round up and assert the protocol value.

At 1e-6, even ideal scaling produces floor(Q96 / 1000), while MIN_SQRT_PRICE_X96 is one unit higher; Q96 % 1000 = 336. The current test therefore passes while the produced value is below the factory minimum.

Proposed fix
-    Ok((scaled_int * Q96) / BIGINT_1E6)
+    Ok((scaled_int * Q96 + BIGINT_1E6 - U256::from(1u8)) / BIGINT_1E6)
-use crate::constants::{MAX_SQRT_PRICE_X96, Q96_PRECISION};
+use crate::constants::{MAX_SQRT_PRICE_X96, MIN_SQRT_PRICE_X96, Q96_PRECISION};

 fn price_very_small_works() {
-    let result = price_to_sqrt_price_x96(1e-6);
-    assert!(result.is_ok());
+    assert_eq!(
+        price_to_sqrt_price_x96(1e-6).unwrap(),
+        MIN_SQRT_PRICE_X96
+    );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 1e-6 is the protocol's minimum starting price.
let result = price_to_sqrt_price_x96(1e-6);
assert!(result.is_ok());
}
// 1e-6 is the protocol's minimum starting price.
assert_eq!(
price_to_sqrt_price_x96(1e-6).unwrap(),
MIN_SQRT_PRICE_X96
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/convert.rs` around lines 590 - 593, Update the minimum-price test around
price_to_sqrt_price_x96(1e-6) to assert the conversion succeeds and its value
equals MIN_SQRT_PRICE_X96, ensuring rounding produces the protocol minimum
rather than a lower truncated value.


#[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 @@ -624,23 +624,13 @@ mod tests {
assert!(sqrt_price_x96_to_price(U256::ZERO).is_err());
}

#[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"
);
}

#[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"
);
}

Expand All @@ -650,7 +640,7 @@ mod tests {
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 &[0.01, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0, 500.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
31 changes: 25 additions & 6 deletions src/math/tick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ pub fn get_sqrt_ratio_at_tick(tick: i32) -> Result<U256, ValidationError> {
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.
Expand Down Expand Up @@ -504,14 +506,31 @@ 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}"
);
}

#[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()
);
}
}
Loading