diff --git a/contracts/orderbook/src/constants.rs b/contracts/orderbook/src/constants.rs new file mode 100644 index 0000000..ed88e3d --- /dev/null +++ b/contracts/orderbook/src/constants.rs @@ -0,0 +1,16 @@ +use cosmwasm_std::Decimal256; +use std::str::FromStr; + +pub const MIN_TICK: i64 = -108000000; +pub const MAX_TICK: i64 = 342000000; +pub const EXPONENT_AT_PRICE_ONE: i32 = -6; +pub const GEOMETRIC_EXPONENT_INCREMENT_DISTANCE_IN_TICKS: i64 = 9_000_000; + +// TODO: optimize this using lazy_static +pub fn max_spot_price() -> Decimal256 { + Decimal256::from_str("100000000000000000000000000000000000000").unwrap() +} + +pub fn min_spot_price() -> Decimal256 { + Decimal256::from_str("0.000000000001").unwrap() +} diff --git a/contracts/orderbook/src/error.rs b/contracts/orderbook/src/error.rs index 2993952..51ca437 100644 --- a/contracts/orderbook/src/error.rs +++ b/contracts/orderbook/src/error.rs @@ -1,4 +1,7 @@ -use cosmwasm_std::{CoinsError, OverflowError, StdError, Uint128}; +use cosmwasm_std::{ + CheckedFromRatioError, CheckedMultiplyRatioError, CoinsError, ConversionOverflowError, + DecimalRangeExceeded, DivideByZeroError, OverflowError, StdError, Uint128, +}; use cw_utils::PaymentError; use thiserror::Error; @@ -41,6 +44,25 @@ pub enum ContractError { #[error("Reply error: {id:?}, {error:?}")] ReplyError { id: u64, error: String }, + // Decimal-related errors + #[error("{0}")] + ConversionOverflow(#[from] ConversionOverflowError), + + #[error("{0}")] + CheckedMultiplyRatio(#[from] CheckedMultiplyRatioError), + + #[error("{0}")] + CheckedFromRatio(#[from] CheckedFromRatioError), + + #[error("{0}")] + DivideByZero(#[from] DivideByZeroError), + + #[error("{0}")] + DecimalRangeExceeded(#[from] DecimalRangeExceeded), + + // Tick out of bounds error + #[error("Tick out of bounds: {tick_id:?}")] + TickOutOfBounds { tick_id: i64 }, #[error("Cannot fulfill order. Order ID: {order_id:?}, Book ID: {book_id:?}, Amount Required: {amount_required:?}, Amount Remaining: {amount_remaining:?} {reason:?}")] InvalidFulfillment { order_id: u64, @@ -53,3 +75,5 @@ pub enum ContractError { #[error("Mismatched order direction")] MismatchedOrderDirection {}, } + +pub type ContractResult = Result; diff --git a/contracts/orderbook/src/lib.rs b/contracts/orderbook/src/lib.rs index 29d45d5..decde25 100644 --- a/contracts/orderbook/src/lib.rs +++ b/contracts/orderbook/src/lib.rs @@ -1,9 +1,11 @@ +pub mod constants; pub mod contract; mod error; pub mod msg; mod order; mod orderbook; pub mod state; +pub mod tick_math; pub mod types; #[cfg(test)] diff --git a/contracts/orderbook/src/mod.rs b/contracts/orderbook/src/mod.rs index 7a50c55..0d5f205 100644 --- a/contracts/orderbook/src/mod.rs +++ b/contracts/orderbook/src/mod.rs @@ -1,7 +1,11 @@ pub mod types; +pub mod constants; pub mod order; pub mod orderbook; +pub mod tick_math; +pub use constants::*; pub use order::*; pub use orderbook::*; +pub use tick_math::*; diff --git a/contracts/orderbook/src/order.rs b/contracts/orderbook/src/order.rs index ce56273..3e2276c 100644 --- a/contracts/orderbook/src/order.rs +++ b/contracts/orderbook/src/order.rs @@ -1,6 +1,7 @@ +use crate::constants::{MAX_TICK, MIN_TICK}; use crate::error::ContractError; +use crate::state::ORDERBOOKS; use crate::state::*; -use crate::state::{MAX_TICK, MIN_TICK, ORDERBOOKS}; use crate::types::{Fulfillment, LimitOrder, MarketOrder, OrderDirection, REPLY_ID_REFUND}; use cosmwasm_std::{ coin, ensure, ensure_eq, ensure_ne, BankMsg, Decimal, DepsMut, Env, MessageInfo, Order, diff --git a/contracts/orderbook/src/orderbook.rs b/contracts/orderbook/src/orderbook.rs index cc56950..48e8c14 100644 --- a/contracts/orderbook/src/orderbook.rs +++ b/contracts/orderbook/src/orderbook.rs @@ -1,5 +1,6 @@ +use crate::constants::{MAX_TICK, MIN_TICK}; use crate::error::ContractError; -use crate::state::{new_orderbook_id, MAX_TICK, MIN_TICK, ORDERBOOKS}; +use crate::state::{new_orderbook_id, ORDERBOOKS}; use crate::types::Orderbook; use cosmwasm_std::{DepsMut, Env, MessageInfo, Response}; diff --git a/contracts/orderbook/src/state.rs b/contracts/orderbook/src/state.rs index 5c62fd0..0d4efff 100644 --- a/contracts/orderbook/src/state.rs +++ b/contracts/orderbook/src/state.rs @@ -3,9 +3,6 @@ use crate::ContractError; use cosmwasm_std::{Addr, Order, StdResult, Storage, Uint128}; use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; -pub const MIN_TICK: i64 = -108000000; -pub const MAX_TICK: i64 = 342000000; - // Counters for ID tracking pub const ORDER_ID: Item = Item::new("order_id"); pub const ORDERBOOK_ID: Item = Item::new("orderbook_id"); diff --git a/contracts/orderbook/src/tests/mod.rs b/contracts/orderbook/src/tests/mod.rs index 74f51d4..6d5ef63 100644 --- a/contracts/orderbook/src/tests/mod.rs +++ b/contracts/orderbook/src/tests/mod.rs @@ -1,3 +1,4 @@ pub mod test_order; pub mod test_orderbook; pub mod test_state; +pub mod test_tick_math; diff --git a/contracts/orderbook/src/tests/test_order.rs b/contracts/orderbook/src/tests/test_order.rs index a68f933..4e7e793 100644 --- a/contracts/orderbook/src/tests/test_order.rs +++ b/contracts/orderbook/src/tests/test_order.rs @@ -1,4 +1,5 @@ use crate::{ + constants::{MAX_TICK, MIN_TICK}, error::ContractError, order::*, orderbook::*, diff --git a/contracts/orderbook/src/tests/test_orderbook.rs b/contracts/orderbook/src/tests/test_orderbook.rs index 6a8dd00..ca00a29 100644 --- a/contracts/orderbook/src/tests/test_orderbook.rs +++ b/contracts/orderbook/src/tests/test_orderbook.rs @@ -1,6 +1,7 @@ use crate::{ + constants::{MAX_TICK, MIN_TICK}, orderbook::*, - state::{MAX_TICK, MIN_TICK, ORDERBOOKS}, + state::ORDERBOOKS, }; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; diff --git a/contracts/orderbook/src/tests/test_tick_math.rs b/contracts/orderbook/src/tests/test_tick_math.rs new file mode 100644 index 0000000..2f4d53f --- /dev/null +++ b/contracts/orderbook/src/tests/test_tick_math.rs @@ -0,0 +1,205 @@ +use crate::constants::*; +use crate::error::ContractError; +use crate::tick_math::{pow_ten, tick_to_price}; +use cosmwasm_std::{Decimal256, Uint256}; +use std::str::FromStr; + +struct TickToPriceTestCase { + tick_index: i64, + expected_price: Decimal256, + expected_error: Option, +} + +#[test] +fn test_tick_to_price() { + // This constant is used to test price iterations near max tick. + // It essentially derives the amount we expect price to increment by, + // which with an EXPONENT_AT_PRICE_ONE of -6 should be 10^31. + let min_increment_near_max_price = Decimal256::from_ratio( + Uint256::from(10u8) + .checked_pow((37 + EXPONENT_AT_PRICE_ONE) as u32) + .unwrap(), + Uint256::one(), + ); + let tick_price_test_cases = vec![ + TickToPriceTestCase { + tick_index: MAX_TICK, + expected_price: max_spot_price(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: MIN_TICK, + expected_price: Decimal256::from_str("0.000000000001").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 40000000, + expected_price: Decimal256::from_str("50000").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 4010000, + expected_price: Decimal256::from_str("5.01").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 40000001, + expected_price: Decimal256::from_str("50000.01").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -9999900, + expected_price: Decimal256::from_str("0.090001").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -2000, + expected_price: Decimal256::from_str("0.9998").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 40303000, + expected_price: Decimal256::from_str("53030").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: MAX_TICK - 1, + expected_price: max_spot_price() + .checked_sub(min_increment_near_max_price) + .unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: MIN_TICK, + expected_price: min_spot_price(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: MIN_TICK + 1, + expected_price: Decimal256::from_str("0.000000000001000001").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -17765433, + expected_price: Decimal256::from_str("0.012345670000000000").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -17765432, + expected_price: Decimal256::from_str("0.012345680000000000").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -107765433, + expected_price: Decimal256::from_str("0.000000000001234567").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: -107765432, + expected_price: Decimal256::from_str("0.000000000001234568").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 81234567, + expected_price: Decimal256::from_str("1234567000").unwrap(), + expected_error: None, + }, + // This case involves truncation in the previous case, so the expected price is adjusted accordingly + TickToPriceTestCase { + tick_index: 81234567, // Same tick index as the previous case due to truncation + expected_price: Decimal256::from_str("1234567000").unwrap(), // Expected price matches the truncated price + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 81234568, + expected_price: Decimal256::from_str("1234568000").unwrap(), + expected_error: None, + }, + TickToPriceTestCase { + tick_index: 0, + expected_price: Decimal256::from_str("1").unwrap(), + expected_error: None, + }, + ]; + + for test in tick_price_test_cases { + let result = tick_to_price(test.tick_index); + + match test.expected_error { + Some(expected_err) => assert_eq!(result.unwrap_err(), expected_err), + None => assert_eq!(test.expected_price, result.unwrap()), + } + } +} + +#[test] +fn test_tick_to_price_error_cases() { + let test_cases = vec![ + TickToPriceTestCase { + tick_index: MAX_TICK + 1, + expected_price: Decimal256::zero(), + expected_error: Some(ContractError::TickOutOfBounds { + tick_id: MAX_TICK + 1, + }), + }, + TickToPriceTestCase { + tick_index: MIN_TICK - 1, + expected_price: Decimal256::zero(), + expected_error: Some(ContractError::TickOutOfBounds { + tick_id: MIN_TICK - 1, + }), + }, + ]; + + for test in test_cases { + let result = tick_to_price(test.tick_index); + assert!(result.is_err()); + if let Some(expected_err) = test.expected_error { + assert_eq!(result.unwrap_err(), expected_err); + } + } +} + +#[test] +fn test_pow_ten() { + struct PowTenTestCase { + exponent: i32, + expected_result: Decimal256, + } + + let test_cases = vec![ + PowTenTestCase { + exponent: 0, + expected_result: Decimal256::from_str("1").unwrap(), + }, + PowTenTestCase { + exponent: 1, + expected_result: Decimal256::from_str("10").unwrap(), + }, + PowTenTestCase { + exponent: -1, + expected_result: Decimal256::from_str("0.1").unwrap(), + }, + PowTenTestCase { + exponent: 5, + expected_result: Decimal256::from_str("100000").unwrap(), + }, + PowTenTestCase { + exponent: -5, + expected_result: Decimal256::from_str("0.00001").unwrap(), + }, + PowTenTestCase { + exponent: 10, + expected_result: Decimal256::from_str("10000000000").unwrap(), + }, + PowTenTestCase { + exponent: -10, + expected_result: Decimal256::from_str("0.0000000001").unwrap(), + }, + ]; + + for test in test_cases { + let result = pow_ten(test.exponent).unwrap(); + assert_eq!(test.expected_result, result); + } +} diff --git a/contracts/orderbook/src/tick_math.rs b/contracts/orderbook/src/tick_math.rs index 8b13789..aed0946 100644 --- a/contracts/orderbook/src/tick_math.rs +++ b/contracts/orderbook/src/tick_math.rs @@ -1 +1,74 @@ +use crate::constants::{ + EXPONENT_AT_PRICE_ONE, GEOMETRIC_EXPONENT_INCREMENT_DISTANCE_IN_TICKS, MAX_TICK, MIN_TICK, +}; +use crate::error::*; +use cosmwasm_std::{ensure, Decimal256, Uint256}; +// tick_to_price converts a tick index to a price. +// If tick_index is zero, the function returns Decimal256::one(). +// Errors if the given tick is outside of the bounds allowed by MIN_TICK and MAX_TICK. +#[allow(clippy::manual_range_contains)] +pub fn tick_to_price(tick_index: i64) -> ContractResult { + if tick_index == 0 { + return Ok(Decimal256::one()); + } + + ensure!( + tick_index >= MIN_TICK && tick_index <= MAX_TICK, + ContractError::TickOutOfBounds { + tick_id: tick_index + } + ); + + // geometric_exponent_delta is the number of times we have incremented the exponent by + // GEOMETRIC_EXPONENT_INCREMENT_DISTANCE_IN_TICKS to reach the current tick index. + let geometric_exponent_delta: i64 = tick_index / GEOMETRIC_EXPONENT_INCREMENT_DISTANCE_IN_TICKS; + + // The exponent at the current tick is the exponent at price one plus the number of times we have incremented the exponent by + let mut exponent_at_current_tick = (EXPONENT_AT_PRICE_ONE as i64) + geometric_exponent_delta; + + // We must decrement the exponentAtCurrentTick when entering the negative tick range in order to constantly step up in precision when going further down in ticks + // Otherwise, from tick 0 to tick -(geometricExponentIncrementDistanceInTicks), we would use the same exponent as the exponentAtPriceOne + if tick_index < 0 { + exponent_at_current_tick -= 1; + } + + // We can derive the contribution of each additive tick with 10^(exponent_at_current_tick)) + let current_additive_increment_in_ticks = pow_ten(exponent_at_current_tick as i32)?; + + // The current number of additive ticks are equivalent to the portion of the tick index that is not covered by the geometric component. + let num_additive_ticks = + tick_index - (geometric_exponent_delta * GEOMETRIC_EXPONENT_INCREMENT_DISTANCE_IN_TICKS); + + // Price is equal to the sum of the geometric and additive components. + // Since we derive `geometric_exponent_delta` by division with truncation, we can get the geometric component + // by simply taking 10^(geometric_exponent_delta). + // + // The additive component is simply the number of additive ticks by the current additive increment per tick. + let geometric_component = pow_ten(geometric_exponent_delta as i32)?; + let additive_component = Decimal256::from_ratio( + Uint256::from(num_additive_ticks.unsigned_abs()), + Uint256::one(), + ) + .checked_mul(current_additive_increment_in_ticks)?; + + // We manually handle sign here to avoid expensive conversions between Decimal256 and SignedDecimal256. + let price = if num_additive_ticks < 0 { + geometric_component.checked_sub(additive_component) + } else { + geometric_component.checked_add(additive_component) + }?; + + Ok(price) +} + +// Takes an exponent and returns 10^exponent. Supports negative exponents. +pub fn pow_ten(expo: i32) -> ContractResult { + let target_expo = Uint256::from(10u8).checked_pow(expo.unsigned_abs())?; + if expo < 0 { + Ok(Decimal256::checked_from_ratio(Uint256::one(), target_expo)?) + } else { + let res = Uint256::one().checked_mul(target_expo)?; + Ok(Decimal256::from_ratio(res, Uint256::one())) + } +}