1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "UPPERCASE")]
11pub enum OrderSide {
12 Buy,
13 Sell,
14}
15
16impl OrderSide {
17 pub fn to_u8(self) -> u8 {
20 match self {
21 OrderSide::Buy => 0,
22 OrderSide::Sell => 1,
23 }
24 }
25
26 pub fn from_u8(v: u8) -> Option<Self> {
28 match v {
29 0 => Some(OrderSide::Buy),
30 1 => Some(OrderSide::Sell),
31 _ => None,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "UPPERCASE")]
41pub enum OrderType {
42 Gtc,
44 Fok,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct OrderData {
58 pub salt: i64,
60 pub maker: String,
62 pub signer: String,
64 pub taker: String,
66 #[serde(rename = "tokenId")]
68 pub token_id: String,
69 #[serde(rename = "makerAmount")]
71 pub maker_amount: i64,
72 #[serde(rename = "takerAmount")]
74 pub taker_amount: i64,
75 pub expiration: String,
77 pub nonce: i32,
79 #[serde(rename = "feeRateBps")]
81 pub fee_rate_bps: i32,
82 pub side: u8,
84 pub signature: String,
86 #[serde(rename = "signatureType")]
88 pub signature_type: u8,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct CreateOrderRequest {
94 pub order: OrderData,
96 #[serde(rename = "ownerId")]
98 pub owner_id: u64,
99 #[serde(rename = "orderType")]
101 pub order_type: OrderType,
102 #[serde(rename = "marketSlug")]
104 pub market_slug: String,
105 #[serde(skip_serializing_if = "Option::is_none", rename = "clientOrderId")]
107 pub client_order_id: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none", rename = "onBehalfOf")]
110 pub on_behalf_of: Option<u64>,
111}
112
113pub const SCALE: u128 = 1_000_000;
117
118pub const MAX_BPS: i32 = 10_000;
120
121pub const DEFAULT_PRICE_TICK: f64 = 0.001;
123
124pub const DEFAULT_FEE_RATE_BPS: i32 = 300;
126
127fn scale_to_6_decimals(amount: f64) -> u128 {
135 if amount <= 0.0 {
136 return 0;
137 }
138 let formatted = format!("{amount:.12}");
141 let negative = formatted.starts_with('-');
142 let cleaned = if negative {
143 formatted.trim_start_matches('-')
144 } else {
145 formatted.as_str()
146 };
147 let parts: Vec<&str> = cleaned.split('.').collect();
148 let int_part: u128 = parts[0].parse().unwrap_or(0);
149 let frac_str = if parts.len() > 1 { parts[1] } else { "" };
150 let frac_6 = if frac_str.len() > 6 {
152 &frac_str[..6]
153 } else {
154 frac_str
155 };
156 let mut frac_padded = String::with_capacity(6);
158 frac_padded.push_str(frac_6);
159 while frac_padded.len() < 6 {
160 frac_padded.push('0');
161 }
162 let frac_val: u128 = frac_padded.parse().unwrap_or(0);
163 let result = int_part * SCALE + frac_val;
164 if negative {
165 0
166 } else {
167 result
168 }
169}
170
171fn div_ceil_u128(a: u128, b: u128) -> u128 {
176 assert!(b > 0, "division by zero");
177 (a + b - 1) / b
178}
179
180pub fn gtc_amounts(side: OrderSide, price: f64, size: f64) -> (i64, i64) {
212 let shares_scaled = scale_to_6_decimals(size);
213 let price_scaled = scale_to_6_decimals(price);
214
215 let numerator = shares_scaled * price_scaled;
219 let collateral = match side {
220 OrderSide::Buy => div_ceil_u128(numerator, SCALE),
221 OrderSide::Sell => numerator / SCALE,
222 };
223
224 let (maker_amount, taker_amount) = match side {
225 OrderSide::Buy => (collateral, shares_scaled),
226 OrderSide::Sell => (shares_scaled, collateral),
227 };
228
229 let maker = i64::try_from(maker_amount).expect("maker_amount exceeds i64 range");
230 let taker = i64::try_from(taker_amount).expect("taker_amount exceeds i64 range");
231
232 (maker, taker)
233}
234
235pub fn fok_amount(_side: OrderSide, amount: f64) -> i64 {
242 let scaled = scale_to_6_decimals(amount);
243 i64::try_from(scaled).expect("FOK amount exceeds i64 range")
244}
245
246pub fn validate_gtc_order(price: f64, size: f64, price_tick: Option<f64>) -> Result<(), String> {
253 let tick = price_tick.unwrap_or(DEFAULT_PRICE_TICK);
254
255 if !(0.0..=1.0).contains(&price) || price == 0.0 {
256 return Err(format!(
257 "price must be between 0 and 1 (exclusive of 0), got: {price}"
258 ));
259 }
260 if size <= 0.0 {
261 return Err(format!("size must be positive, got: {size}"));
262 }
263
264 let tick_str = float_to_decimal_string(tick);
266 let price_str = float_to_decimal_string(price);
267 let max_decimals = decimal_places(&tick_str);
268 if decimal_places(&price_str) > max_decimals {
269 return Err(format!(
270 "price {price} has too many decimal places — tick {tick} allows at most {max_decimals}"
271 ));
272 }
273
274 let tick_scaled = scale_to_6_decimals(tick);
276 let price_scaled = scale_to_6_decimals(price);
277 if tick_scaled > 0 && (price_scaled % tick_scaled) != 0 {
278 return Err(format!(
279 "price {price} is not tick-aligned — must be a multiple of {tick}"
280 ));
281 }
282
283 let size_str = float_to_decimal_string(size);
285 if decimal_places(&size_str) > 6 {
286 return Err(format!(
287 "size {size} has too many decimal places — maximum is 6"
288 ));
289 }
290
291 Ok(())
292}
293
294pub fn validate_fok_order(amount: f64) -> Result<(), String> {
296 if amount <= 0.0 {
297 return Err(format!("FOK amount must be positive, got: {amount}"));
298 }
299 let amount_str = float_to_decimal_string(amount);
300 if decimal_places(&amount_str) > 6 {
301 return Err(format!(
302 "FOK amount {amount} has too many decimal places — maximum is 6"
303 ));
304 }
305 Ok(())
306}
307
308pub fn validate_order_data(order: &OrderData) -> Result<(), String> {
310 if order.token_id.is_empty() || order.token_id == "0" {
311 return Err("token_id is required and must be non-zero".to_string());
312 }
313 if order.maker_amount <= 0 {
314 return Err("maker_amount must be positive".to_string());
315 }
316 if order.taker_amount <= 0 {
317 return Err("taker_amount must be positive".to_string());
318 }
319 if order.salt <= 0 {
320 return Err(format!("salt must be positive, got: {}", order.salt));
321 }
322 if order.nonce < 0 {
323 return Err(format!("nonce must be non-negative, got: {}", order.nonce));
324 }
325 if order.fee_rate_bps < 0 || order.fee_rate_bps > MAX_BPS {
326 return Err(format!(
327 "fee_rate_bps must be in [0, {MAX_BPS}], got: {}",
328 order.fee_rate_bps
329 ));
330 }
331 if order.side > 1 {
332 return Err(format!(
333 "side must be 0 (BUY) or 1 (SELL), got: {}",
334 order.side
335 ));
336 }
337 if order.signature_type > 2 {
338 return Err(format!(
339 "signature_type must be 0-2, got: {}",
340 order.signature_type
341 ));
342 }
343 Ok(())
344}
345
346fn float_to_decimal_string(value: f64) -> String {
351 let mut formatted = format!("{value:.12}");
352 while formatted.contains('.') && formatted.ends_with('0') {
354 formatted.pop();
355 }
356 if formatted.ends_with('.') {
357 formatted.pop();
358 }
359 if formatted == "-0" {
360 "0".to_string()
361 } else {
362 formatted
363 }
364}
365
366fn decimal_places(value: &str) -> usize {
368 value.split('.').nth(1).map(str::len).unwrap_or(0)
369}
370
371#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn gtc_buy_scales_correctly() {
379 let (maker, taker) = gtc_amounts(OrderSide::Buy, 0.55, 10.0);
380 assert_eq!(maker, 5_500_000);
381 assert_eq!(taker, 10_000_000);
382 }
383
384 #[test]
385 fn gtc_sell_scales_correctly() {
386 let (maker, taker) = gtc_amounts(OrderSide::Sell, 0.55, 10.0);
387 assert_eq!(maker, 10_000_000);
388 assert_eq!(taker, 5_500_000);
389 }
390
391 #[test]
392 fn gtc_buy_uses_ceil_division() {
393 let (maker, _taker) = gtc_amounts(OrderSide::Buy, 0.333333, 1.0);
395 assert_eq!(maker, 333_333);
396 }
397
398 #[test]
399 fn gtc_amounts_are_symmetric() {
400 let (buy_maker, buy_taker) = gtc_amounts(OrderSide::Buy, 0.42, 5.0);
402 let (sell_maker, sell_taker) = gtc_amounts(OrderSide::Sell, 0.42, 5.0);
403 assert_eq!(buy_maker, sell_taker);
404 assert_eq!(buy_taker, sell_maker);
405 }
406
407 #[test]
408 fn fok_amount_scales_correctly() {
409 let scaled = fok_amount(OrderSide::Buy, 10.5);
410 assert_eq!(scaled, 10_500_000);
411 }
412
413 #[test]
414 fn scale_to_6_decimals_truncates() {
415 assert_eq!(scale_to_6_decimals(0.001001), 1001);
417 assert_eq!(scale_to_6_decimals(0.0010015), 1001);
419 }
420
421 #[test]
422 fn scale_to_6_decimals_handles_large_integer() {
423 assert_eq!(scale_to_6_decimals(123.456789), 123_456_789);
424 }
425
426 #[test]
427 fn validate_gtc_rejects_zero_price() {
428 assert!(validate_gtc_order(0.0, 1.0, None).is_err());
429 }
430
431 #[test]
432 fn validate_gtc_rejects_price_above_one() {
433 assert!(validate_gtc_order(1.5, 1.0, None).is_err());
434 }
435
436 #[test]
437 fn validate_gtc_rejects_negative_size() {
438 assert!(validate_gtc_order(0.5, -1.0, None).is_err());
439 }
440
441 #[test]
442 fn validate_gtc_accepts_valid_order() {
443 assert!(validate_gtc_order(0.55, 10.0, None).is_ok());
444 }
445
446 #[test]
447 fn validate_fok_rejects_zero_amount() {
448 assert!(validate_fok_order(0.0).is_err());
449 }
450
451 #[test]
452 fn validate_fok_accepts_valid_amount() {
453 assert!(validate_fok_order(100.0).is_ok());
454 }
455}