use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum OrderSide {
Buy,
Sell,
}
impl OrderSide {
pub fn to_u8(self) -> u8 {
match self {
OrderSide::Buy => 0,
OrderSide::Sell => 1,
}
}
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(OrderSide::Buy),
1 => Some(OrderSide::Sell),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum OrderType {
Gtc,
Fok,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderData {
pub salt: i64,
pub maker: String,
pub signer: String,
pub taker: String,
#[serde(rename = "tokenId")]
pub token_id: String,
#[serde(rename = "makerAmount")]
pub maker_amount: i64,
#[serde(rename = "takerAmount")]
pub taker_amount: i64,
pub expiration: String,
pub nonce: i32,
#[serde(rename = "feeRateBps")]
pub fee_rate_bps: i32,
pub side: u8,
pub signature: String,
#[serde(rename = "signatureType")]
pub signature_type: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateOrderRequest {
pub order: OrderData,
#[serde(rename = "ownerId")]
pub owner_id: u64,
#[serde(rename = "orderType")]
pub order_type: OrderType,
#[serde(rename = "marketSlug")]
pub market_slug: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "clientOrderId")]
pub client_order_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "onBehalfOf")]
pub on_behalf_of: Option<u64>,
}
pub const SCALE: u128 = 1_000_000;
pub const MAX_BPS: i32 = 10_000;
pub const DEFAULT_PRICE_TICK: f64 = 0.001;
pub const DEFAULT_FEE_RATE_BPS: i32 = 300;
fn scale_to_6_decimals(amount: f64) -> u128 {
if amount <= 0.0 {
return 0;
}
let formatted = format!("{amount:.12}");
let negative = formatted.starts_with('-');
let cleaned = if negative {
formatted.trim_start_matches('-')
} else {
formatted.as_str()
};
let parts: Vec<&str> = cleaned.split('.').collect();
let int_part: u128 = parts[0].parse().unwrap_or(0);
let frac_str = if parts.len() > 1 { parts[1] } else { "" };
let frac_6 = if frac_str.len() > 6 {
&frac_str[..6]
} else {
frac_str
};
let mut frac_padded = String::with_capacity(6);
frac_padded.push_str(frac_6);
while frac_padded.len() < 6 {
frac_padded.push('0');
}
let frac_val: u128 = frac_padded.parse().unwrap_or(0);
let result = int_part * SCALE + frac_val;
if negative {
0
} else {
result
}
}
fn div_ceil_u128(a: u128, b: u128) -> u128 {
assert!(b > 0, "division by zero");
(a + b - 1) / b
}
pub fn gtc_amounts(side: OrderSide, price: f64, size: f64) -> (i64, i64) {
let shares_scaled = scale_to_6_decimals(size);
let price_scaled = scale_to_6_decimals(price);
let numerator = shares_scaled * price_scaled;
let collateral = match side {
OrderSide::Buy => div_ceil_u128(numerator, SCALE),
OrderSide::Sell => numerator / SCALE,
};
let (maker_amount, taker_amount) = match side {
OrderSide::Buy => (collateral, shares_scaled),
OrderSide::Sell => (shares_scaled, collateral),
};
let maker = i64::try_from(maker_amount).expect("maker_amount exceeds i64 range");
let taker = i64::try_from(taker_amount).expect("taker_amount exceeds i64 range");
(maker, taker)
}
pub fn fok_amount(_side: OrderSide, amount: f64) -> i64 {
let scaled = scale_to_6_decimals(amount);
i64::try_from(scaled).expect("FOK amount exceeds i64 range")
}
pub fn validate_gtc_order(price: f64, size: f64, price_tick: Option<f64>) -> Result<(), String> {
let tick = price_tick.unwrap_or(DEFAULT_PRICE_TICK);
if !(0.0..=1.0).contains(&price) || price == 0.0 {
return Err(format!(
"price must be between 0 and 1 (exclusive of 0), got: {price}"
));
}
if size <= 0.0 {
return Err(format!("size must be positive, got: {size}"));
}
let tick_str = float_to_decimal_string(tick);
let price_str = float_to_decimal_string(price);
let max_decimals = decimal_places(&tick_str);
if decimal_places(&price_str) > max_decimals {
return Err(format!(
"price {price} has too many decimal places — tick {tick} allows at most {max_decimals}"
));
}
let tick_scaled = scale_to_6_decimals(tick);
let price_scaled = scale_to_6_decimals(price);
if tick_scaled > 0 && (price_scaled % tick_scaled) != 0 {
return Err(format!(
"price {price} is not tick-aligned — must be a multiple of {tick}"
));
}
let size_str = float_to_decimal_string(size);
if decimal_places(&size_str) > 6 {
return Err(format!(
"size {size} has too many decimal places — maximum is 6"
));
}
Ok(())
}
pub fn validate_fok_order(amount: f64) -> Result<(), String> {
if amount <= 0.0 {
return Err(format!("FOK amount must be positive, got: {amount}"));
}
let amount_str = float_to_decimal_string(amount);
if decimal_places(&amount_str) > 6 {
return Err(format!(
"FOK amount {amount} has too many decimal places — maximum is 6"
));
}
Ok(())
}
pub fn validate_order_data(order: &OrderData) -> Result<(), String> {
if order.token_id.is_empty() || order.token_id == "0" {
return Err("token_id is required and must be non-zero".to_string());
}
if order.maker_amount <= 0 {
return Err("maker_amount must be positive".to_string());
}
if order.taker_amount <= 0 {
return Err("taker_amount must be positive".to_string());
}
if order.salt <= 0 {
return Err(format!("salt must be positive, got: {}", order.salt));
}
if order.nonce < 0 {
return Err(format!("nonce must be non-negative, got: {}", order.nonce));
}
if order.fee_rate_bps < 0 || order.fee_rate_bps > MAX_BPS {
return Err(format!(
"fee_rate_bps must be in [0, {MAX_BPS}], got: {}",
order.fee_rate_bps
));
}
if order.side > 1 {
return Err(format!(
"side must be 0 (BUY) or 1 (SELL), got: {}",
order.side
));
}
if order.signature_type > 2 {
return Err(format!(
"signature_type must be 0-2, got: {}",
order.signature_type
));
}
Ok(())
}
fn float_to_decimal_string(value: f64) -> String {
let mut formatted = format!("{value:.12}");
while formatted.contains('.') && formatted.ends_with('0') {
formatted.pop();
}
if formatted.ends_with('.') {
formatted.pop();
}
if formatted == "-0" {
"0".to_string()
} else {
formatted
}
}
fn decimal_places(value: &str) -> usize {
value.split('.').nth(1).map(str::len).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gtc_buy_scales_correctly() {
let (maker, taker) = gtc_amounts(OrderSide::Buy, 0.55, 10.0);
assert_eq!(maker, 5_500_000);
assert_eq!(taker, 10_000_000);
}
#[test]
fn gtc_sell_scales_correctly() {
let (maker, taker) = gtc_amounts(OrderSide::Sell, 0.55, 10.0);
assert_eq!(maker, 10_000_000);
assert_eq!(taker, 5_500_000);
}
#[test]
fn gtc_buy_uses_ceil_division() {
let (maker, _taker) = gtc_amounts(OrderSide::Buy, 0.333333, 1.0);
assert_eq!(maker, 333_333);
}
#[test]
fn gtc_amounts_are_symmetric() {
let (buy_maker, buy_taker) = gtc_amounts(OrderSide::Buy, 0.42, 5.0);
let (sell_maker, sell_taker) = gtc_amounts(OrderSide::Sell, 0.42, 5.0);
assert_eq!(buy_maker, sell_taker);
assert_eq!(buy_taker, sell_maker);
}
#[test]
fn fok_amount_scales_correctly() {
let scaled = fok_amount(OrderSide::Buy, 10.5);
assert_eq!(scaled, 10_500_000);
}
#[test]
fn scale_to_6_decimals_truncates() {
assert_eq!(scale_to_6_decimals(0.001001), 1001);
assert_eq!(scale_to_6_decimals(0.0010015), 1001);
}
#[test]
fn scale_to_6_decimals_handles_large_integer() {
assert_eq!(scale_to_6_decimals(123.456789), 123_456_789);
}
#[test]
fn validate_gtc_rejects_zero_price() {
assert!(validate_gtc_order(0.0, 1.0, None).is_err());
}
#[test]
fn validate_gtc_rejects_price_above_one() {
assert!(validate_gtc_order(1.5, 1.0, None).is_err());
}
#[test]
fn validate_gtc_rejects_negative_size() {
assert!(validate_gtc_order(0.5, -1.0, None).is_err());
}
#[test]
fn validate_gtc_accepts_valid_order() {
assert!(validate_gtc_order(0.55, 10.0, None).is_ok());
}
#[test]
fn validate_fok_rejects_zero_amount() {
assert!(validate_fok_order(0.0).is_err());
}
#[test]
fn validate_fok_accepts_valid_amount() {
assert!(validate_fok_order(100.0).is_ok());
}
}