o2_sdk/
models.rs

1/// Data models for O2 Exchange API types.
2///
3/// All models use serde for JSON serialization/deserialization.
4/// String fields are used for large numeric values to avoid precision loss.
5use rust_decimal::Decimal;
6use serde::{Deserialize, Deserializer, Serialize};
7use std::collections::{BTreeMap, HashMap};
8use std::str::FromStr;
9
10use crate::decimal::UnsignedDecimal;
11use crate::errors::O2Error;
12
13macro_rules! newtype_id {
14    ($(#[$meta:meta])* $name:ident) => {
15        $(#[$meta])*
16        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17        #[serde(transparent)]
18        pub struct $name(String);
19
20        impl $name {
21            /// Create without validation. For internal/serde use only.
22            pub(crate) fn new(s: impl Into<String>) -> Self {
23                Self(s.into())
24            }
25
26            /// The underlying string value.
27            pub fn as_str(&self) -> &str {
28                &self.0
29            }
30        }
31
32        impl AsRef<str> for $name {
33            fn as_ref(&self) -> &str {
34                &self.0
35            }
36        }
37
38        impl std::fmt::Display for $name {
39            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40                f.write_str(&self.0)
41            }
42        }
43
44        impl From<&$name> for $name {
45            fn from(v: &$name) -> Self {
46                v.clone()
47            }
48        }
49
50        impl std::ops::Deref for $name {
51            type Target = str;
52            fn deref(&self) -> &str {
53                &self.0
54            }
55        }
56
57        impl Default for $name {
58            fn default() -> Self {
59                Self(String::new())
60            }
61        }
62    };
63}
64
65/// Trait for types that can be validated and converted into a hex ID newtype.
66/// Implemented by `&str`, `String` (with hex validation) and the ID type itself (passthrough).
67pub trait IntoValidId<T> {
68    fn into_valid(self) -> Result<T, O2Error>;
69}
70
71fn validate_hex(type_name: &str, s: &str) -> Result<(), O2Error> {
72    let hex = s
73        .strip_prefix("0x")
74        .or_else(|| s.strip_prefix("0X"))
75        .unwrap_or(s);
76    if hex.is_empty() {
77        return Err(O2Error::Other(format!(
78            "{type_name}: requires a non-empty hex string, got {s:?}"
79        )));
80    }
81    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
82        return Err(O2Error::Other(format!(
83            "{type_name}: contains non-hex characters: {s:?}"
84        )));
85    }
86    Ok(())
87}
88
89/// Like `newtype_id!` but adds `IntoValidId` with hex validation for `&str`/`String`,
90/// and a passthrough for the type itself. Keeps infallible `From` for internal/serde use.
91macro_rules! hex_id {
92    ($(#[$meta:meta])* $name:ident) => {
93        newtype_id!($(#[$meta])* $name);
94
95        impl IntoValidId<$name> for &str {
96            fn into_valid(self) -> Result<$name, O2Error> {
97                validate_hex(stringify!($name), self)?;
98                Ok($name::new(self))
99            }
100        }
101
102        impl IntoValidId<$name> for String {
103            fn into_valid(self) -> Result<$name, O2Error> {
104                validate_hex(stringify!($name), &self)?;
105                Ok($name::new(self))
106            }
107        }
108
109        impl IntoValidId<$name> for $name {
110            fn into_valid(self) -> Result<$name, O2Error> {
111                Ok(self)
112            }
113        }
114
115        impl IntoValidId<$name> for &$name {
116            fn into_valid(self) -> Result<$name, O2Error> {
117                Ok(self.clone())
118            }
119        }
120    };
121}
122
123newtype_id!(
124    /// A market symbol pair like "FUEL/USDC".
125    MarketSymbol
126);
127
128impl IntoValidId<MarketSymbol> for &str {
129    fn into_valid(self) -> Result<MarketSymbol, O2Error> {
130        MarketSymbol::parse(self)
131    }
132}
133
134impl IntoValidId<MarketSymbol> for String {
135    fn into_valid(self) -> Result<MarketSymbol, O2Error> {
136        MarketSymbol::parse(self)
137    }
138}
139
140impl IntoValidId<MarketSymbol> for MarketSymbol {
141    fn into_valid(self) -> Result<MarketSymbol, O2Error> {
142        Ok(self)
143    }
144}
145
146impl IntoValidId<MarketSymbol> for &MarketSymbol {
147    fn into_valid(self) -> Result<MarketSymbol, O2Error> {
148        Ok(self.clone())
149    }
150}
151
152impl MarketSymbol {
153    /// Parse and normalize a market symbol in `BASE/QUOTE` form.
154    ///
155    /// Normalization currently trims surrounding whitespace and preserves symbol casing.
156    pub fn parse(input: impl AsRef<str>) -> Result<Self, O2Error> {
157        Self::from_str(input.as_ref())
158    }
159}
160
161impl FromStr for MarketSymbol {
162    type Err = O2Error;
163
164    fn from_str(s: &str) -> Result<Self, Self::Err> {
165        let trimmed = s.trim();
166        if trimmed.is_empty() {
167            return Err(O2Error::InvalidRequest(
168                "Market symbol cannot be empty".to_string(),
169            ));
170        }
171
172        let (base_raw, quote_raw) = trimmed.split_once('/').ok_or_else(|| {
173            O2Error::InvalidRequest(format!(
174                "Invalid market symbol '{trimmed}'. Expected format BASE/QUOTE"
175            ))
176        })?;
177
178        if quote_raw.contains('/') {
179            return Err(O2Error::InvalidRequest(format!(
180                "Invalid market symbol '{trimmed}'. Expected exactly one '/' separator"
181            )));
182        }
183
184        let base = base_raw.trim();
185        let quote = quote_raw.trim();
186        if base.is_empty() || quote.is_empty() {
187            return Err(O2Error::InvalidRequest(format!(
188                "Invalid market symbol '{trimmed}'. Base and quote must be non-empty"
189            )));
190        }
191
192        Ok(MarketSymbol::new(format!("{base}/{quote}")))
193    }
194}
195
196/// Converts input into a validated, normalized [`MarketSymbol`].
197pub trait IntoMarketSymbol {
198    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error>;
199}
200
201impl IntoMarketSymbol for MarketSymbol {
202    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error> {
203        MarketSymbol::parse(self.as_str())
204    }
205}
206
207impl IntoMarketSymbol for &MarketSymbol {
208    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error> {
209        MarketSymbol::parse(self.as_str())
210    }
211}
212
213impl IntoMarketSymbol for &str {
214    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error> {
215        MarketSymbol::parse(self)
216    }
217}
218
219impl IntoMarketSymbol for String {
220    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error> {
221        MarketSymbol::parse(self)
222    }
223}
224
225impl IntoMarketSymbol for &String {
226    fn into_market_symbol(self) -> Result<MarketSymbol, O2Error> {
227        MarketSymbol::parse(self)
228    }
229}
230
231hex_id!(
232    /// A hex contract ID.
233    ContractId
234);
235hex_id!(
236    /// A hex market ID.
237    MarketId
238);
239hex_id!(
240    /// A hex order ID.
241    OrderId
242);
243hex_id!(
244    /// A trade identifier.
245    TradeId
246);
247hex_id!(
248    /// A hex trade account ID.
249    TradeAccountId
250);
251hex_id!(
252    /// A hex asset ID.
253    AssetId
254);
255
256fn normalize_hex_prefixed(s: String) -> String {
257    if s.starts_with("0x") || s.starts_with("0X") || s.is_empty() {
258        s
259    } else {
260        format!("0x{s}")
261    }
262}
263
264/// A hex transaction ID.
265#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Default)]
266#[serde(transparent)]
267pub struct TxId(String);
268
269impl TxId {
270    pub fn new(s: impl Into<String>) -> Self {
271        Self(normalize_hex_prefixed(s.into()))
272    }
273    pub fn as_str(&self) -> &str {
274        &self.0
275    }
276}
277
278impl AsRef<str> for TxId {
279    fn as_ref(&self) -> &str {
280        &self.0
281    }
282}
283
284impl std::fmt::Display for TxId {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.write_str(&self.0)
287    }
288}
289
290impl From<String> for TxId {
291    fn from(s: String) -> Self {
292        Self::new(s)
293    }
294}
295
296impl From<&str> for TxId {
297    fn from(s: &str) -> Self {
298        Self::new(s)
299    }
300}
301
302impl std::ops::Deref for TxId {
303    type Target = str;
304    fn deref(&self) -> &str {
305        &self.0
306    }
307}
308
309impl<'de> Deserialize<'de> for TxId {
310    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
311    where
312        D: Deserializer<'de>,
313    {
314        let raw = String::deserialize(deserializer)?;
315        Ok(TxId::new(raw))
316    }
317}
318
319/// Deserialize a value that may be a JSON number or a string containing a number.
320fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
321where
322    D: Deserializer<'de>,
323{
324    use serde::de;
325
326    struct StringOrU64;
327    impl<'de> de::Visitor<'de> for StringOrU64 {
328        type Value = u64;
329        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
330            f.write_str("a u64 or a string containing a decimal/0x-hex u64")
331        }
332        fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
333            Ok(v)
334        }
335        fn visit_i64<E: de::Error>(self, v: i64) -> Result<u64, E> {
336            u64::try_from(v).map_err(de::Error::custom)
337        }
338        fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
339            if let Some(hex) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
340                u64::from_str_radix(hex, 16).map_err(de::Error::custom)
341            } else {
342                v.parse().map_err(de::Error::custom)
343            }
344        }
345    }
346    deserializer.deserialize_any(StringOrU64)
347}
348
349/// Deserialize an optional value that may be a JSON number or a string, storing as u64.
350fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
351where
352    D: Deserializer<'de>,
353{
354    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
355    match value {
356        Some(serde_json::Value::String(s)) => {
357            if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
358                u64::from_str_radix(hex, 16)
359                    .map(Some)
360                    .map_err(serde::de::Error::custom)
361            } else {
362                s.parse().map(Some).map_err(serde::de::Error::custom)
363            }
364        }
365        Some(serde_json::Value::Number(n)) => n
366            .as_u64()
367            .ok_or_else(|| serde::de::Error::custom("number is not u64"))
368            .map(Some),
369        Some(serde_json::Value::Null) | None => Ok(None),
370        Some(v) => Err(serde::de::Error::custom(format!(
371            "expected string/number/null for u64 field, got {v}"
372        ))),
373    }
374}
375
376/// Deserialize a value that may be a JSON number or a string containing a u128.
377fn deserialize_string_or_u128<'de, D>(deserializer: D) -> Result<u128, D::Error>
378where
379    D: Deserializer<'de>,
380{
381    use serde::de;
382
383    struct StringOrU128;
384    impl<'de> de::Visitor<'de> for StringOrU128 {
385        type Value = u128;
386        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
387            f.write_str("a u128 or a string containing a decimal u128")
388        }
389        fn visit_u64<E: de::Error>(self, v: u64) -> Result<u128, E> {
390            Ok(v as u128)
391        }
392        fn visit_u128<E: de::Error>(self, v: u128) -> Result<u128, E> {
393            Ok(v)
394        }
395        fn visit_i64<E: de::Error>(self, v: i64) -> Result<u128, E> {
396            u128::try_from(v).map_err(de::Error::custom)
397        }
398        fn visit_str<E: de::Error>(self, v: &str) -> Result<u128, E> {
399            v.parse().map_err(de::Error::custom)
400        }
401    }
402    deserializer.deserialize_any(StringOrU128)
403}
404
405/// Deserialize a value that may be a JSON number or a string containing an f64.
406fn deserialize_string_or_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
407where
408    D: Deserializer<'de>,
409{
410    use serde::de;
411
412    struct StringOrF64;
413    impl<'de> de::Visitor<'de> for StringOrF64 {
414        type Value = f64;
415        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
416            f.write_str("an f64 or a string containing an f64")
417        }
418        fn visit_f64<E: de::Error>(self, v: f64) -> Result<f64, E> {
419            Ok(v)
420        }
421        fn visit_u64<E: de::Error>(self, v: u64) -> Result<f64, E> {
422            Ok(v as f64)
423        }
424        fn visit_i64<E: de::Error>(self, v: i64) -> Result<f64, E> {
425            Ok(v as f64)
426        }
427        fn visit_str<E: de::Error>(self, v: &str) -> Result<f64, E> {
428            v.parse().map_err(de::Error::custom)
429        }
430    }
431    deserializer.deserialize_any(StringOrF64)
432}
433
434/// Deserialize an optional value that may be a JSON number or a string, storing as f64.
435fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
436where
437    D: Deserializer<'de>,
438{
439    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
440    match value {
441        Some(serde_json::Value::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
442        Some(serde_json::Value::Number(n)) => n
443            .as_f64()
444            .ok_or_else(|| serde::de::Error::custom("number is not f64"))
445            .map(Some),
446        Some(serde_json::Value::Null) | None => Ok(None),
447        Some(v) => Err(serde::de::Error::custom(format!(
448            "expected string/number/null for f64 field, got {v}"
449        ))),
450    }
451}
452
453// ---------------------------------------------------------------------------
454// Public trading enums
455// ---------------------------------------------------------------------------
456
457/// Order side: Buy or Sell.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
459pub enum Side {
460    Buy,
461    Sell,
462}
463
464impl Side {
465    /// Returns the API string representation.
466    pub fn as_str(&self) -> &str {
467        match self {
468            Side::Buy => "Buy",
469            Side::Sell => "Sell",
470        }
471    }
472}
473
474impl std::fmt::Display for Side {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        f.write_str(self.as_str())
477    }
478}
479
480impl Serialize for Side {
481    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
482        serializer.serialize_str(self.as_str())
483    }
484}
485
486impl<'de> Deserialize<'de> for Side {
487    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
488        let raw = String::deserialize(deserializer)?;
489        match raw.to_ascii_lowercase().as_str() {
490            "buy" => Ok(Side::Buy),
491            "sell" => Ok(Side::Sell),
492            _ => Err(serde::de::Error::custom(format!("invalid side '{raw}'"))),
493        }
494    }
495}
496
497/// High-level order type with associated data.
498///
499/// Used in `create_order` and `Action::CreateOrder` to provide compile-time
500/// safety instead of raw `&str` matching. Limit and BoundedMarket variants
501/// carry their required parameters.
502#[derive(Debug, Clone)]
503pub enum OrderType {
504    Spot,
505    Market,
506    FillOrKill,
507    PostOnly,
508    Limit {
509        price: UnsignedDecimal,
510        timestamp: u64,
511    },
512    BoundedMarket {
513        max_price: UnsignedDecimal,
514        min_price: UnsignedDecimal,
515    },
516}
517
518/// High-level action for use with `batch_actions`.
519///
520/// Converts to the low-level `CallArg` and JSON representations internally.
521#[derive(Debug, Clone)]
522pub enum Action {
523    CreateOrder {
524        side: Side,
525        price: UnsignedDecimal,
526        quantity: UnsignedDecimal,
527        order_type: OrderType,
528    },
529    CancelOrder {
530        order_id: OrderId,
531    },
532    SettleBalance,
533    RegisterReferer {
534        to: Identity,
535    },
536}
537
538/// A market-bound human-readable order price.
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct Price {
541    value: UnsignedDecimal,
542    market_id: MarketId,
543    quote_decimals: u32,
544    quote_max_precision: u32,
545}
546
547impl Price {
548    /// Human-readable decimal value.
549    pub fn value(&self) -> UnsignedDecimal {
550        self.value
551    }
552
553    /// Market this price was validated against.
554    pub fn market_id(&self) -> &MarketId {
555        &self.market_id
556    }
557}
558
559impl std::fmt::Display for Price {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        self.value.fmt(f)
562    }
563}
564
565/// A market-bound human-readable order quantity.
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct Quantity {
568    value: UnsignedDecimal,
569    market_id: MarketId,
570    base_decimals: u32,
571    base_max_precision: u32,
572}
573
574impl Quantity {
575    /// Human-readable decimal value.
576    pub fn value(&self) -> UnsignedDecimal {
577        self.value
578    }
579
580    /// Market this quantity was validated against.
581    pub fn market_id(&self) -> &MarketId {
582        &self.market_id
583    }
584}
585
586impl std::fmt::Display for Quantity {
587    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        self.value.fmt(f)
589    }
590}
591
592/// Flexible input accepted by `O2Client::create_order` for price values.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub enum OrderPriceInput {
595    /// A raw decimal that must be validated against the target market.
596    Unchecked(UnsignedDecimal),
597    /// A market-bound typed price.
598    Checked(Price),
599}
600
601impl TryFrom<UnsignedDecimal> for OrderPriceInput {
602    type Error = O2Error;
603    fn try_from(value: UnsignedDecimal) -> Result<Self, Self::Error> {
604        Ok(Self::Unchecked(value))
605    }
606}
607
608impl TryFrom<Price> for OrderPriceInput {
609    type Error = O2Error;
610    fn try_from(value: Price) -> Result<Self, Self::Error> {
611        Ok(Self::Checked(value))
612    }
613}
614
615impl TryFrom<&str> for OrderPriceInput {
616    type Error = O2Error;
617    fn try_from(value: &str) -> Result<Self, Self::Error> {
618        Ok(Self::Unchecked(value.parse()?))
619    }
620}
621
622impl TryFrom<String> for OrderPriceInput {
623    type Error = O2Error;
624    fn try_from(value: String) -> Result<Self, Self::Error> {
625        Ok(Self::Unchecked(value.parse()?))
626    }
627}
628
629/// Flexible input accepted by `O2Client::create_order` for quantity values.
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub enum OrderQuantityInput {
632    /// A raw decimal that must be validated against the target market.
633    Unchecked(UnsignedDecimal),
634    /// A market-bound typed quantity.
635    Checked(Quantity),
636}
637
638impl TryFrom<UnsignedDecimal> for OrderQuantityInput {
639    type Error = O2Error;
640    fn try_from(value: UnsignedDecimal) -> Result<Self, Self::Error> {
641        Ok(Self::Unchecked(value))
642    }
643}
644
645impl TryFrom<Quantity> for OrderQuantityInput {
646    type Error = O2Error;
647    fn try_from(value: Quantity) -> Result<Self, Self::Error> {
648        Ok(Self::Checked(value))
649    }
650}
651
652impl TryFrom<&str> for OrderQuantityInput {
653    type Error = O2Error;
654    fn try_from(value: &str) -> Result<Self, Self::Error> {
655        Ok(Self::Unchecked(value.parse()?))
656    }
657}
658
659impl TryFrom<String> for OrderQuantityInput {
660    type Error = O2Error;
661    fn try_from(value: String) -> Result<Self, Self::Error> {
662        Ok(Self::Unchecked(value.parse()?))
663    }
664}
665
666impl OrderType {
667    /// Convert to the low-level `OrderTypeEncoding` and JSON representation
668    /// used by the encoding and API layers.
669    pub fn to_encoding(
670        &self,
671        market: &Market,
672    ) -> Result<(crate::encoding::OrderTypeEncoding, serde_json::Value), O2Error> {
673        use crate::encoding::OrderTypeEncoding;
674        match self {
675            OrderType::Spot => Ok((OrderTypeEncoding::Spot, serde_json::json!("Spot"))),
676            OrderType::Market => Ok((OrderTypeEncoding::Market, serde_json::json!("Market"))),
677            OrderType::FillOrKill => Ok((
678                OrderTypeEncoding::FillOrKill,
679                serde_json::json!("FillOrKill"),
680            )),
681            OrderType::PostOnly => Ok((OrderTypeEncoding::PostOnly, serde_json::json!("PostOnly"))),
682            OrderType::Limit { price, timestamp } => {
683                let scaled_price = market.scale_price(price)?;
684                Ok((
685                    OrderTypeEncoding::Limit {
686                        price: scaled_price,
687                        timestamp: *timestamp,
688                    },
689                    serde_json::json!({ "Limit": [scaled_price.to_string(), timestamp.to_string()] }),
690                ))
691            }
692            OrderType::BoundedMarket {
693                max_price,
694                min_price,
695            } => {
696                let scaled_max = market.scale_price(max_price)?;
697                let scaled_min = market.scale_price(min_price)?;
698                Ok((
699                    OrderTypeEncoding::BoundedMarket {
700                        max_price: scaled_max,
701                        min_price: scaled_min,
702                    },
703                    serde_json::json!({ "BoundedMarket": { "max_price": scaled_max.to_string(), "min_price": scaled_min.to_string() } }),
704                ))
705            }
706        }
707    }
708}
709
710// ---------------------------------------------------------------------------
711// Identity
712// ---------------------------------------------------------------------------
713
714/// A Fuel Identity — either an Address or a ContractId.
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
716pub enum Identity {
717    Address(String),
718    ContractId(String),
719}
720
721impl Identity {
722    pub fn address_value(&self) -> &str {
723        match self {
724            Identity::Address(a) => a,
725            Identity::ContractId(c) => c,
726        }
727    }
728}
729
730/// A signature wrapper.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub enum Signature {
733    Secp256k1(String),
734}
735
736// ---------------------------------------------------------------------------
737// Market
738// ---------------------------------------------------------------------------
739
740/// Asset info within a market.
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct MarketAsset {
743    pub symbol: String,
744    pub asset: AssetId,
745    pub decimals: u32,
746    pub max_precision: u32,
747}
748
749/// A trading market.
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct Market {
752    pub contract_id: ContractId,
753    pub market_id: MarketId,
754    pub whitelist_id: Option<ContractId>,
755    pub blacklist_id: Option<ContractId>,
756    #[serde(deserialize_with = "deserialize_string_or_u64")]
757    pub maker_fee: u64,
758    #[serde(deserialize_with = "deserialize_string_or_u64")]
759    pub taker_fee: u64,
760    #[serde(deserialize_with = "deserialize_string_or_u64")]
761    pub min_order: u64,
762    #[serde(deserialize_with = "deserialize_string_or_u64")]
763    pub dust: u64,
764    #[serde(deserialize_with = "deserialize_string_or_u64")]
765    pub price_window: u64,
766    pub base: MarketAsset,
767    pub quote: MarketAsset,
768}
769
770impl Market {
771    fn parsed_unsigned(value: &str, field: &str) -> Result<UnsignedDecimal, O2Error> {
772        UnsignedDecimal::from_str(value)
773            .map_err(|e| O2Error::InvalidOrderParams(format!("Invalid {field}: {e}")))
774    }
775
776    fn decimal_scale(value: &UnsignedDecimal) -> u32 {
777        value.inner().normalize().scale()
778    }
779
780    /// Build a typed, market-bound price from a string.
781    pub fn price(&self, value: &str) -> Result<Price, O2Error> {
782        let parsed = Self::parsed_unsigned(value, "price")?;
783        self.price_from_decimal(parsed)
784    }
785
786    /// Build a typed, market-bound price from an `UnsignedDecimal`.
787    pub fn price_from_decimal(&self, value: UnsignedDecimal) -> Result<Price, O2Error> {
788        let scale = Self::decimal_scale(&value);
789        if scale > self.quote.max_precision {
790            return Err(O2Error::InvalidOrderParams(format!(
791                "Price precision {} exceeds max {} for market {}",
792                scale, self.quote.max_precision, self.market_id
793            )));
794        }
795        // Ensure value is representable in chain units for this market.
796        let _ = self.scale_price(&value)?;
797        Ok(Price {
798            value,
799            market_id: self.market_id.clone(),
800            quote_decimals: self.quote.decimals,
801            quote_max_precision: self.quote.max_precision,
802        })
803    }
804
805    /// Build a typed, market-bound quantity from a string.
806    pub fn quantity(&self, value: &str) -> Result<Quantity, O2Error> {
807        let parsed = Self::parsed_unsigned(value, "quantity")?;
808        self.quantity_from_decimal(parsed)
809    }
810
811    /// Build a typed, market-bound quantity from an `UnsignedDecimal`.
812    pub fn quantity_from_decimal(&self, value: UnsignedDecimal) -> Result<Quantity, O2Error> {
813        let scale = Self::decimal_scale(&value);
814        if scale > self.base.max_precision {
815            return Err(O2Error::InvalidOrderParams(format!(
816                "Quantity precision {} exceeds max {} for market {}",
817                scale, self.base.max_precision, self.market_id
818            )));
819        }
820        // Ensure value is representable in chain units for this market.
821        let _ = self.scale_quantity(&value)?;
822        Ok(Quantity {
823            value,
824            market_id: self.market_id.clone(),
825            base_decimals: self.base.decimals,
826            base_max_precision: self.base.max_precision,
827        })
828    }
829
830    /// Validate that a `Price` wrapper is compatible with this market.
831    pub fn validate_price_binding(&self, price: &Price) -> Result<(), O2Error> {
832        if price.market_id != self.market_id
833            || price.quote_decimals != self.quote.decimals
834            || price.quote_max_precision != self.quote.max_precision
835        {
836            return Err(O2Error::Other(format!(
837                "Price wrapper is stale or bound to a different market (expected {}, got {})",
838                self.market_id, price.market_id
839            )));
840        }
841        Ok(())
842    }
843
844    /// Validate that a `Quantity` wrapper is compatible with this market.
845    pub fn validate_quantity_binding(&self, quantity: &Quantity) -> Result<(), O2Error> {
846        if quantity.market_id != self.market_id
847            || quantity.base_decimals != self.base.decimals
848            || quantity.base_max_precision != self.base.max_precision
849        {
850            return Err(O2Error::Other(format!(
851                "Quantity wrapper is stale or bound to a different market (expected {}, got {})",
852                self.market_id, quantity.market_id
853            )));
854        }
855        Ok(())
856    }
857
858    fn checked_pow_u64(exp: u32, field: &str) -> Result<u64, O2Error> {
859        10u64
860            .checked_pow(exp)
861            .ok_or_else(|| O2Error::Other(format!("Invalid {field}: 10^{exp} overflows u64")))
862    }
863
864    fn checked_pow_u128(exp: u32, field: &str) -> Result<u128, O2Error> {
865        10u128
866            .checked_pow(exp)
867            .ok_or_else(|| O2Error::Other(format!("Invalid {field}: 10^{exp} overflows u128")))
868    }
869
870    fn checked_truncate_factor(
871        decimals: u32,
872        max_precision: u32,
873        field: &str,
874    ) -> Result<u64, O2Error> {
875        if max_precision > decimals {
876            return Err(O2Error::Other(format!(
877                "Invalid {field}: max_precision ({max_precision}) exceeds decimals ({decimals})"
878            )));
879        }
880        Self::checked_pow_u64(decimals - max_precision, field)
881    }
882
883    /// Convert a chain-scaled price to human-readable.
884    pub fn format_price(&self, chain_value: u64) -> UnsignedDecimal {
885        let factor = 10u64.pow(self.quote.decimals);
886        let d = Decimal::from(chain_value) / Decimal::from(factor);
887        UnsignedDecimal::new(d).unwrap()
888    }
889
890    /// Convert a human-readable price to chain-scaled integer, truncated to max_precision.
891    pub fn scale_price(&self, human_value: &UnsignedDecimal) -> Result<u64, O2Error> {
892        let factor_u64 = Self::checked_pow_u64(self.quote.decimals, "quote.decimals")?;
893        let factor = Decimal::from(factor_u64);
894        let scaled_str = (*human_value.inner() * factor).floor().to_string();
895        let scaled = scaled_str.parse::<u64>().map_err(|e| {
896            O2Error::Other(format!(
897                "Failed to scale price '{}' into u64: {e}",
898                human_value
899            ))
900        })?;
901        let truncate_factor = Self::checked_truncate_factor(
902            self.quote.decimals,
903            self.quote.max_precision,
904            "quote precision",
905        )?;
906        Ok((scaled / truncate_factor) * truncate_factor)
907    }
908
909    /// Convert a chain-scaled quantity to human-readable.
910    pub fn format_quantity(&self, chain_value: u64) -> UnsignedDecimal {
911        let factor = 10u64.pow(self.base.decimals);
912        let d = Decimal::from(chain_value) / Decimal::from(factor);
913        UnsignedDecimal::new(d).unwrap()
914    }
915
916    /// Convert a human-readable quantity to chain-scaled integer, truncated to max_precision.
917    pub fn scale_quantity(&self, human_value: &UnsignedDecimal) -> Result<u64, O2Error> {
918        let factor_u64 = Self::checked_pow_u64(self.base.decimals, "base.decimals")?;
919        let factor = Decimal::from(factor_u64);
920        let scaled_str = (*human_value.inner() * factor).floor().to_string();
921        let scaled = scaled_str.parse::<u64>().map_err(|e| {
922            O2Error::Other(format!(
923                "Failed to scale quantity '{}' into u64: {e}",
924                human_value
925            ))
926        })?;
927        let truncate_factor = Self::checked_truncate_factor(
928            self.base.decimals,
929            self.base.max_precision,
930            "base precision",
931        )?;
932        Ok((scaled / truncate_factor) * truncate_factor)
933    }
934
935    /// The symbol pair, e.g. "FUEL/USDC".
936    pub fn symbol_pair(&self) -> MarketSymbol {
937        MarketSymbol::new(format!("{}/{}", self.base.symbol, self.quote.symbol))
938    }
939
940    /// Adjust quantity downward so that `(price * quantity) % 10^base_decimals == 0`.
941    /// Returns the original quantity if already valid.
942    pub fn adjust_quantity(&self, price: u64, quantity: u64) -> Result<u64, O2Error> {
943        if price == 0 {
944            return Err(O2Error::InvalidOrderParams(
945                "Price cannot be zero when adjusting quantity".into(),
946            ));
947        }
948        let base_factor = Self::checked_pow_u128(self.base.decimals, "base.decimals")?;
949        let product = price as u128 * quantity as u128;
950        let remainder = product % base_factor;
951        if remainder == 0 {
952            return Ok(quantity);
953        }
954        let adjusted_product = product - remainder;
955        let adjusted = adjusted_product / price as u128;
956        if adjusted > u64::MAX as u128 {
957            return Err(O2Error::InvalidOrderParams(
958                "Adjusted quantity exceeds u64 range".into(),
959            ));
960        }
961        Ok(adjusted as u64)
962    }
963
964    /// Validate that a price*quantity satisfies min_order and FractionalPrice constraints.
965    pub fn validate_order(&self, price: u64, quantity: u64) -> Result<(), O2Error> {
966        let base_factor = Self::checked_pow_u128(self.base.decimals, "base.decimals")?;
967        let quote_value = (price as u128 * quantity as u128) / base_factor;
968        let min_order: u128 = self.min_order as u128;
969        if quote_value < min_order {
970            return Err(O2Error::InvalidOrderParams(format!(
971                "Quote value {} below min_order {}",
972                quote_value, min_order
973            )));
974        }
975        // FractionalPrice check
976        if (price as u128 * quantity as u128) % base_factor != 0 {
977            return Err(O2Error::InvalidOrderParams(
978                "FractionalPrice: (price * quantity) % 10^base_decimals != 0".into(),
979            ));
980        }
981        Ok(())
982    }
983}
984
985/// Top-level response from GET /v1/markets.
986#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct MarketsResponse {
988    pub books_registry_id: ContractId,
989    pub books_whitelist_id: Option<ContractId>,
990    pub books_blacklist_id: Option<ContractId>,
991    pub accounts_registry_id: ContractId,
992    pub trade_account_oracle_id: ContractId,
993    pub fast_bridge_asset_registry_contract_id: Option<ContractId>,
994    pub chain_id: String,
995    pub base_asset_id: AssetId,
996    pub markets: Vec<Market>,
997}
998
999/// Market summary from GET /v1/markets/summary.
1000#[derive(Debug, Clone, Serialize, Deserialize)]
1001pub struct MarketSummary {
1002    pub market_id: MarketId,
1003    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1004    pub high_price: Option<u64>,
1005    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1006    pub low_price: Option<u64>,
1007    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1008    pub last_price: Option<u64>,
1009    #[serde(deserialize_with = "deserialize_string_or_u128")]
1010    pub volume_24h: u128,
1011    #[serde(deserialize_with = "deserialize_string_or_f64")]
1012    pub change_24h: f64,
1013}
1014
1015/// Market ticker from GET /v1/markets/ticker.
1016#[derive(Debug, Clone, Serialize, Deserialize)]
1017pub struct MarketTicker {
1018    pub market_id: MarketId,
1019    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1020    pub high: Option<u64>,
1021    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1022    pub low: Option<u64>,
1023    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1024    pub bid: Option<u64>,
1025    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1026    pub bid_volume: Option<u64>,
1027    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1028    pub ask: Option<u64>,
1029    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1030    pub ask_volume: Option<u64>,
1031    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1032    pub open: Option<u64>,
1033    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1034    pub close: Option<u64>,
1035    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1036    pub last: Option<u64>,
1037    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1038    pub previous_close: Option<u64>,
1039    #[serde(default, deserialize_with = "deserialize_optional_f64")]
1040    pub change: Option<f64>,
1041    #[serde(default, deserialize_with = "deserialize_optional_f64")]
1042    pub percentage: Option<f64>,
1043    #[serde(default, deserialize_with = "deserialize_optional_f64")]
1044    pub average: Option<f64>,
1045    #[serde(deserialize_with = "deserialize_string_or_u128")]
1046    pub base_volume: u128,
1047    #[serde(deserialize_with = "deserialize_string_or_u128")]
1048    pub quote_volume: u128,
1049    #[serde(deserialize_with = "deserialize_string_or_u128")]
1050    pub timestamp: u128,
1051}
1052
1053// ---------------------------------------------------------------------------
1054// Account
1055// ---------------------------------------------------------------------------
1056
1057/// Trading account info from GET /v1/accounts.
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1059pub struct TradeAccount {
1060    #[serde(default)]
1061    pub last_modification: u64,
1062    #[serde(default, deserialize_with = "deserialize_string_or_u64")]
1063    pub nonce: u64,
1064    pub owner: Identity,
1065    #[serde(default)]
1066    pub synced_with_network: Option<bool>,
1067    #[serde(default)]
1068    pub sync_state: Option<serde_json::Value>,
1069}
1070
1071/// Account response from GET /v1/accounts.
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1073pub struct AccountResponse {
1074    pub trade_account_id: Option<TradeAccountId>,
1075    pub trade_account: Option<TradeAccount>,
1076    pub session: Option<SessionInfo>,
1077}
1078
1079/// Session info within an account response.
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct SessionInfo {
1082    pub session_id: Identity,
1083    #[serde(deserialize_with = "deserialize_string_or_u64")]
1084    pub expiry: u64,
1085    pub contract_ids: Vec<ContractId>,
1086}
1087
1088/// Response from POST /v1/accounts (create account).
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090pub struct CreateAccountResponse {
1091    pub trade_account_id: TradeAccountId,
1092    #[serde(deserialize_with = "deserialize_string_or_u64")]
1093    pub nonce: u64,
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Session
1098// ---------------------------------------------------------------------------
1099
1100/// Request body for PUT /v1/session.
1101#[derive(Debug, Clone, Serialize, Deserialize)]
1102pub struct SessionRequest {
1103    pub contract_id: TradeAccountId,
1104    pub session_id: Identity,
1105    pub signature: Signature,
1106    pub contract_ids: Vec<ContractId>,
1107    pub nonce: String,
1108    pub expiry: String,
1109}
1110
1111/// Response from PUT /v1/session.
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113pub struct SessionResponse {
1114    pub tx_id: TxId,
1115    pub trade_account_id: TradeAccountId,
1116    pub contract_ids: Vec<ContractId>,
1117    pub session_id: Identity,
1118    #[serde(deserialize_with = "deserialize_string_or_u64")]
1119    pub session_expiry: u64,
1120}
1121
1122/// Local session state tracked by the client.
1123#[derive(Debug, Clone)]
1124pub struct Session {
1125    pub owner_address: [u8; 32],
1126    pub session_private_key: [u8; 32],
1127    pub session_address: [u8; 32],
1128    pub trade_account_id: TradeAccountId,
1129    pub contract_ids: Vec<ContractId>,
1130    pub expiry: u64,
1131    pub nonce: u64,
1132}
1133
1134// ---------------------------------------------------------------------------
1135// Orders
1136// ---------------------------------------------------------------------------
1137
1138/// An order from the API.
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1140pub struct Order {
1141    #[serde(default)]
1142    pub order_id: OrderId,
1143    pub side: Side,
1144    pub order_type: serde_json::Value,
1145    #[serde(default, deserialize_with = "deserialize_string_or_u64")]
1146    pub quantity: u64,
1147    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1148    pub quantity_fill: Option<u64>,
1149    #[serde(default, deserialize_with = "deserialize_string_or_u64")]
1150    pub price: u64,
1151    #[serde(default, deserialize_with = "deserialize_optional_u64")]
1152    pub price_fill: Option<u64>,
1153    pub timestamp: Option<serde_json::Value>,
1154    #[serde(default)]
1155    pub close: bool,
1156    #[serde(default)]
1157    pub partially_filled: bool,
1158    #[serde(default)]
1159    pub cancel: bool,
1160    #[serde(default)]
1161    pub desired_quantity: Option<serde_json::Value>,
1162    #[serde(default)]
1163    pub base_decimals: Option<u32>,
1164    #[serde(default)]
1165    pub account: Option<Identity>,
1166    #[serde(default)]
1167    pub fill: Option<serde_json::Value>,
1168    #[serde(default)]
1169    pub order_tx_history: Option<Vec<serde_json::Value>>,
1170    #[serde(default)]
1171    pub market_id: Option<MarketId>,
1172    #[serde(default)]
1173    pub owner: Option<Identity>,
1174    #[serde(default)]
1175    pub history: Option<Vec<serde_json::Value>>,
1176    #[serde(default)]
1177    pub fills: Option<Vec<serde_json::Value>>,
1178}
1179
1180/// Response from GET /v1/orders.
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct OrdersResponse {
1183    pub identity: Identity,
1184    pub market_id: MarketId,
1185    #[serde(default)]
1186    pub orders: Vec<Order>,
1187}
1188
1189// ---------------------------------------------------------------------------
1190// Trades
1191// ---------------------------------------------------------------------------
1192
1193/// The querying account's role in a trade.
1194///
1195/// Only present on trades returned by account-scoped endpoints
1196/// (e.g. `get_trades` with an `account` parameter).
1197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1198#[serde(rename_all = "lowercase")]
1199pub enum TraderSide {
1200    Maker,
1201    Taker,
1202    Both,
1203}
1204
1205/// A trade from the API.
1206///
1207/// The `side` field is the **maker's** order side — both maker and taker see
1208/// the same value.  To determine your own direction, combine `side` with
1209/// `trader_side`.
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1211pub struct Trade {
1212    pub trade_id: TradeId,
1213    /// The maker's order side (`Buy` or `Sell`).
1214    pub side: Side,
1215    #[serde(deserialize_with = "deserialize_string_or_u128")]
1216    pub total: u128,
1217    #[serde(deserialize_with = "deserialize_string_or_u64")]
1218    pub quantity: u64,
1219    #[serde(deserialize_with = "deserialize_string_or_u64")]
1220    pub price: u64,
1221    /// Trade execution timestamp (milliseconds since epoch).
1222    #[serde(deserialize_with = "deserialize_string_or_u128")]
1223    pub timestamp: u128,
1224    /// The querying account's role. Only present on account-scoped trade queries.
1225    #[serde(default)]
1226    pub trader_side: Option<TraderSide>,
1227    #[serde(default)]
1228    pub maker: Option<Identity>,
1229    #[serde(default)]
1230    pub taker: Option<Identity>,
1231}
1232
1233/// Response from GET /v1/trades.
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1235pub struct TradesResponse {
1236    #[serde(default)]
1237    pub trades: Vec<Trade>,
1238    pub market_id: MarketId,
1239}
1240
1241// ---------------------------------------------------------------------------
1242// Balance
1243// ---------------------------------------------------------------------------
1244
1245/// Order book balance entry.
1246#[derive(Debug, Clone, Serialize, Deserialize)]
1247pub struct OrderBookBalance {
1248    #[serde(deserialize_with = "deserialize_string_or_u128")]
1249    pub locked: u128,
1250    #[serde(deserialize_with = "deserialize_string_or_u128")]
1251    pub unlocked: u128,
1252    #[serde(deserialize_with = "deserialize_string_or_u128")]
1253    pub fee: u128,
1254}
1255
1256/// Balance response from GET /v1/balance.
1257///
1258/// The balance model tracks funds across three locations:
1259///
1260/// - **`trading_account_balance`** — funds sitting directly in the trading
1261///   account contract, ready to be allocated to any market.
1262/// - **`total_unlocked`** — the *total* available balance, i.e.
1263///   `trading_account_balance` **plus** unlocked amounts sitting in individual
1264///   order-book contracts (e.g. proceeds from filled orders not yet settled).
1265///   **Use this when computing how much you can trade.**
1266/// - **`total_locked`** — funds locked as collateral for currently open orders
1267///   across all order-book contracts.
1268///
1269/// # Warning
1270///
1271/// `total_unlocked` already *includes* `trading_account_balance`.
1272/// Do **not** add them together — that double-counts your funds.
1273///
1274/// ```text
1275/// available_for_new_orders = total_unlocked           // correct
1276/// locked_in_open_orders   = total_locked
1277/// grand_total             = total_unlocked + total_locked
1278/// ```
1279#[derive(Debug, Clone, Serialize, Deserialize)]
1280pub struct BalanceResponse {
1281    pub order_books: HashMap<String, OrderBookBalance>,
1282    /// Total balance locked as collateral for open orders (chain integer).
1283    #[serde(deserialize_with = "deserialize_string_or_u128")]
1284    pub total_locked: u128,
1285    /// Total available balance for trading (chain integer).
1286    ///
1287    /// This is `trading_account_balance` + unlocked amounts in each order-book
1288    /// contract.  Use this value — not `trading_account_balance` alone — when
1289    /// deciding how much you can spend on new orders.
1290    #[serde(deserialize_with = "deserialize_string_or_u128")]
1291    pub total_unlocked: u128,
1292    /// Balance sitting directly in the trading account contract (chain integer).
1293    ///
1294    /// This is a *subset* of `total_unlocked`. Do not add these two fields
1295    /// together.
1296    #[serde(deserialize_with = "deserialize_string_or_u128")]
1297    pub trading_account_balance: u128,
1298}
1299
1300impl BalanceResponse {
1301    /// Total balance available for placing new orders.
1302    ///
1303    /// Equivalent to [`total_unlocked`](Self::total_unlocked).
1304    #[inline]
1305    pub fn available(&self) -> u128 {
1306        self.total_unlocked
1307    }
1308
1309    /// Total balance locked as collateral for open orders.
1310    #[inline]
1311    pub fn locked(&self) -> u128 {
1312        self.total_locked
1313    }
1314
1315    /// Grand total balance (available + locked in orders).
1316    #[inline]
1317    pub fn total(&self) -> u128 {
1318        self.total_unlocked + self.total_locked
1319    }
1320}
1321
1322// ---------------------------------------------------------------------------
1323// Depth
1324// ---------------------------------------------------------------------------
1325
1326/// A single depth level (price + quantity).
1327#[derive(Debug, Clone, Serialize, Deserialize)]
1328pub struct DepthLevel {
1329    #[serde(deserialize_with = "deserialize_string_or_u64")]
1330    pub price: u64,
1331    #[serde(deserialize_with = "deserialize_string_or_u64")]
1332    pub quantity: u64,
1333}
1334
1335/// Depth snapshot from GET /v1/depth or WebSocket subscribe_depth.
1336#[derive(Debug, Clone, Serialize, Deserialize)]
1337pub struct DepthSnapshot {
1338    /// Bid side of the order book, sorted by price descending.
1339    #[serde(default, rename = "buys")]
1340    pub bids: Vec<DepthLevel>,
1341    /// Ask side of the order book, sorted by price ascending.
1342    #[serde(default, rename = "sells")]
1343    pub asks: Vec<DepthLevel>,
1344}
1345
1346/// Depth update from WebSocket subscribe_depth_update.
1347#[derive(Debug, Clone, Serialize, Deserialize)]
1348pub struct DepthUpdate {
1349    pub action: String,
1350    pub changes: Option<DepthSnapshot>,
1351    #[serde(alias = "view")]
1352    pub view: Option<DepthSnapshot>,
1353    pub market_id: MarketId,
1354    pub onchain_timestamp: Option<String>,
1355    pub seen_timestamp: Option<String>,
1356}
1357
1358// ---------------------------------------------------------------------------
1359// Bars
1360// ---------------------------------------------------------------------------
1361
1362/// OHLCV bar/candle data.
1363#[derive(Debug, Clone, Serialize, Deserialize)]
1364pub struct Bar {
1365    #[serde(deserialize_with = "deserialize_string_or_u64")]
1366    pub open: u64,
1367    #[serde(deserialize_with = "deserialize_string_or_u64")]
1368    pub high: u64,
1369    #[serde(deserialize_with = "deserialize_string_or_u64")]
1370    pub low: u64,
1371    #[serde(deserialize_with = "deserialize_string_or_u64")]
1372    pub close: u64,
1373    #[serde(deserialize_with = "deserialize_string_or_u128")]
1374    pub buy_volume: u128,
1375    #[serde(deserialize_with = "deserialize_string_or_u128")]
1376    pub sell_volume: u128,
1377    #[serde(deserialize_with = "deserialize_string_or_u128")]
1378    pub timestamp: u128,
1379}
1380
1381// ---------------------------------------------------------------------------
1382// Session Actions
1383// ---------------------------------------------------------------------------
1384
1385/// A CreateOrder action payload.
1386#[derive(Debug, Clone, Serialize, Deserialize)]
1387pub struct CreateOrderAction {
1388    pub side: String,
1389    pub price: String,
1390    pub quantity: String,
1391    pub order_type: serde_json::Value,
1392}
1393
1394/// A CancelOrder action payload.
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1396pub struct CancelOrderAction {
1397    pub order_id: String,
1398}
1399
1400/// A SettleBalance action payload.
1401#[derive(Debug, Clone, Serialize, Deserialize)]
1402pub struct SettleBalanceAction {
1403    pub to: Identity,
1404}
1405
1406/// A single action in the actions request.
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1408#[serde(untagged)]
1409pub enum ActionItem {
1410    CreateOrder {
1411        #[serde(rename = "CreateOrder")]
1412        create_order: CreateOrderAction,
1413    },
1414    CancelOrder {
1415        #[serde(rename = "CancelOrder")]
1416        cancel_order: CancelOrderAction,
1417    },
1418    SettleBalance {
1419        #[serde(rename = "SettleBalance")]
1420        settle_balance: SettleBalanceAction,
1421    },
1422    RegisterReferer {
1423        #[serde(rename = "RegisterReferer")]
1424        register_referer: SettleBalanceAction,
1425    },
1426}
1427
1428/// A market-grouped set of actions.
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1430pub(crate) struct MarketActions {
1431    pub market_id: MarketId,
1432    pub actions: Vec<serde_json::Value>,
1433}
1434
1435/// Request body for POST /v1/session/actions.
1436#[derive(Debug, Clone, Serialize, Deserialize)]
1437pub(crate) struct SessionActionsRequest {
1438    pub actions: Vec<MarketActions>,
1439    pub signature: Signature,
1440    pub nonce: String,
1441    pub trade_account_id: TradeAccountId,
1442    pub session_id: Identity,
1443    #[serde(skip_serializing_if = "Option::is_none")]
1444    pub collect_orders: Option<bool>,
1445    #[serde(skip_serializing_if = "Option::is_none")]
1446    pub variable_outputs: Option<u32>,
1447}
1448
1449/// Response from POST /v1/session/actions.
1450#[derive(Debug, Clone, Serialize, Deserialize)]
1451pub struct SessionActionsResponse {
1452    pub tx_id: Option<TxId>,
1453    pub orders: Option<Vec<Order>>,
1454    // Error fields
1455    pub code: Option<u32>,
1456    pub message: Option<String>,
1457    pub reason: Option<String>,
1458    pub receipts: Option<serde_json::Value>,
1459}
1460
1461impl SessionActionsResponse {
1462    /// Returns true if the response indicates success (has tx_id).
1463    pub fn is_success(&self) -> bool {
1464        self.tx_id.is_some()
1465    }
1466
1467    /// Returns true if this is a pre-flight validation error (has code field).
1468    pub fn is_preflight_error(&self) -> bool {
1469        self.code.is_some() && self.tx_id.is_none()
1470    }
1471
1472    /// Returns true if this is an on-chain revert error (has message but no code).
1473    pub fn is_onchain_error(&self) -> bool {
1474        self.message.is_some() && self.code.is_none() && self.tx_id.is_none()
1475    }
1476}
1477
1478// ---------------------------------------------------------------------------
1479// Withdraw
1480// ---------------------------------------------------------------------------
1481
1482/// Request body for POST /v1/accounts/withdraw.
1483#[derive(Debug, Clone, Serialize, Deserialize)]
1484pub struct WithdrawRequest {
1485    pub trade_account_id: TradeAccountId,
1486    pub signature: Signature,
1487    pub nonce: String,
1488    pub to: Identity,
1489    pub asset_id: AssetId,
1490    pub amount: String,
1491}
1492
1493/// Response from POST /v1/accounts/withdraw.
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1495pub struct WithdrawResponse {
1496    pub tx_id: Option<String>,
1497    pub code: Option<u32>,
1498    pub message: Option<String>,
1499}
1500
1501// ---------------------------------------------------------------------------
1502// Whitelist
1503// ---------------------------------------------------------------------------
1504
1505/// Request body for POST /analytics/v1/whitelist.
1506#[derive(Debug, Clone, Serialize, Deserialize)]
1507pub struct WhitelistRequest {
1508    #[serde(rename = "tradeAccount")]
1509    pub trade_account: String,
1510}
1511
1512/// Response from POST /analytics/v1/whitelist.
1513#[derive(Debug, Clone, Serialize, Deserialize)]
1514pub struct WhitelistResponse {
1515    pub success: Option<bool>,
1516    #[serde(rename = "tradeAccount")]
1517    pub trade_account: Option<String>,
1518    #[serde(rename = "alreadyWhitelisted")]
1519    pub already_whitelisted: Option<bool>,
1520}
1521
1522// ---------------------------------------------------------------------------
1523// Faucet
1524// ---------------------------------------------------------------------------
1525
1526/// Response from faucet mint.
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct FaucetResponse {
1529    pub message: Option<String>,
1530    pub error: Option<String>,
1531}
1532
1533// ---------------------------------------------------------------------------
1534// Referral
1535// ---------------------------------------------------------------------------
1536
1537/// Response from GET /analytics/v1/referral/code-info.
1538#[derive(Debug, Clone, Serialize, Deserialize)]
1539pub struct ReferralInfo {
1540    pub valid: Option<bool>,
1541    #[serde(rename = "ownerAddress")]
1542    pub owner_address: Option<String>,
1543    #[serde(rename = "isActive")]
1544    pub is_active: Option<bool>,
1545}
1546
1547// ---------------------------------------------------------------------------
1548// Aggregated
1549// ---------------------------------------------------------------------------
1550
1551/// Asset metadata from GET /v1/aggregated/assets.
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1553pub struct AggregatedAssetInfo {
1554    pub name: String,
1555    #[serde(deserialize_with = "deserialize_string_or_u64")]
1556    pub unified_cryptoasset_id: u64,
1557    pub can_withdraw: bool,
1558    pub can_deposit: bool,
1559    #[serde(deserialize_with = "deserialize_string_or_f64")]
1560    pub min_withdraw: f64,
1561    #[serde(deserialize_with = "deserialize_string_or_f64")]
1562    pub min_deposit: f64,
1563    #[serde(deserialize_with = "deserialize_string_or_f64")]
1564    pub maker_fee: f64,
1565    #[serde(deserialize_with = "deserialize_string_or_f64")]
1566    pub taker_fee: f64,
1567}
1568
1569/// Symbol-keyed assets map from GET /v1/aggregated/assets.
1570pub type AggregatedAssets = BTreeMap<String, AggregatedAssetInfo>;
1571
1572/// Aggregated orderbook from GET /v1/aggregated/orderbook.
1573#[derive(Debug, Clone, Serialize, Deserialize)]
1574pub struct AggregatedOrderbook {
1575    #[serde(deserialize_with = "deserialize_string_or_u64")]
1576    pub timestamp: u64,
1577    pub bids: Vec<[f64; 2]>,
1578    pub asks: Vec<[f64; 2]>,
1579}
1580
1581/// CoinGecko aggregated orderbook from GET /v1/aggregated/coingecko/orderbook.
1582#[derive(Debug, Clone, Serialize, Deserialize)]
1583pub struct CoingeckoAggregatedOrderbook {
1584    pub ticker_id: String,
1585    #[serde(deserialize_with = "deserialize_string_or_u64")]
1586    pub timestamp: u64,
1587    pub bids: Vec<[f64; 2]>,
1588    pub asks: Vec<[f64; 2]>,
1589}
1590
1591/// Pair summary from GET /v1/aggregated/summary.
1592#[derive(Debug, Clone, Serialize, Deserialize)]
1593pub struct PairSummary {
1594    pub trading_pairs: String,
1595    pub base_currency: String,
1596    pub quote_currency: String,
1597    #[serde(deserialize_with = "deserialize_string_or_f64")]
1598    pub last_price: f64,
1599    #[serde(deserialize_with = "deserialize_string_or_f64")]
1600    pub lowest_ask: f64,
1601    #[serde(deserialize_with = "deserialize_string_or_f64")]
1602    pub highest_bid: f64,
1603    #[serde(deserialize_with = "deserialize_string_or_f64")]
1604    pub base_volume: f64,
1605    #[serde(deserialize_with = "deserialize_string_or_f64")]
1606    pub quote_volume: f64,
1607    #[serde(deserialize_with = "deserialize_string_or_f64")]
1608    pub price_change_percent_24h: f64,
1609    #[serde(deserialize_with = "deserialize_string_or_f64")]
1610    pub highest_price_24h: f64,
1611    #[serde(deserialize_with = "deserialize_string_or_f64")]
1612    pub lowest_price_24h: f64,
1613}
1614
1615/// Aggregated ticker value from GET /v1/aggregated/ticker.
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617pub struct AggregatedTickerData {
1618    #[serde(deserialize_with = "deserialize_string_or_f64")]
1619    pub last_price: f64,
1620    #[serde(deserialize_with = "deserialize_string_or_f64")]
1621    pub base_volume: f64,
1622    #[serde(deserialize_with = "deserialize_string_or_f64")]
1623    pub quote_volume: f64,
1624}
1625
1626/// Pair-keyed map from GET /v1/aggregated/ticker.
1627pub type AggregatedTicker = BTreeMap<String, AggregatedTickerData>;
1628
1629/// Pair ticker from GET /v1/aggregated/coingecko/tickers.
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1631pub struct PairTicker {
1632    pub ticker_id: String,
1633    pub base_currency: String,
1634    pub target_currency: String,
1635    #[serde(deserialize_with = "deserialize_string_or_f64")]
1636    pub last_price: f64,
1637    #[serde(deserialize_with = "deserialize_string_or_f64")]
1638    pub base_volume: f64,
1639    #[serde(deserialize_with = "deserialize_string_or_f64")]
1640    pub target_volume: f64,
1641    #[serde(deserialize_with = "deserialize_string_or_f64")]
1642    pub bid: f64,
1643    #[serde(deserialize_with = "deserialize_string_or_f64")]
1644    pub ask: f64,
1645    #[serde(deserialize_with = "deserialize_string_or_f64")]
1646    pub high: f64,
1647    #[serde(deserialize_with = "deserialize_string_or_f64")]
1648    pub low: f64,
1649}
1650
1651/// Trade from GET /v1/aggregated/trades.
1652#[derive(Debug, Clone, Serialize, Deserialize)]
1653pub struct AggregatedTrade {
1654    #[serde(deserialize_with = "deserialize_string_or_u64")]
1655    pub trade_id: u64,
1656    #[serde(deserialize_with = "deserialize_string_or_f64")]
1657    pub price: f64,
1658    #[serde(deserialize_with = "deserialize_string_or_f64")]
1659    pub base_volume: f64,
1660    #[serde(deserialize_with = "deserialize_string_or_f64")]
1661    pub quote_volume: f64,
1662    #[serde(deserialize_with = "deserialize_string_or_u64")]
1663    pub timestamp: u64,
1664    #[serde(rename = "type")]
1665    pub trade_type: String,
1666}
1667
1668// ---------------------------------------------------------------------------
1669// WebSocket messages
1670// ---------------------------------------------------------------------------
1671
1672/// WebSocket order update.
1673#[derive(Debug, Clone, Serialize, Deserialize)]
1674pub struct OrderUpdate {
1675    pub action: String,
1676    #[serde(default)]
1677    pub orders: Vec<Order>,
1678    pub onchain_timestamp: Option<String>,
1679    pub seen_timestamp: String,
1680}
1681
1682/// WebSocket trade update.
1683#[derive(Debug, Clone, Serialize, Deserialize)]
1684pub struct TradeUpdate {
1685    pub action: String,
1686    #[serde(default)]
1687    pub trades: Vec<Trade>,
1688    pub market_id: MarketId,
1689    pub onchain_timestamp: Option<String>,
1690    pub seen_timestamp: String,
1691}
1692
1693/// WebSocket balance entry.
1694#[derive(Debug, Clone, Serialize, Deserialize)]
1695pub struct BalanceEntry {
1696    pub identity: Identity,
1697    pub asset_id: AssetId,
1698    #[serde(deserialize_with = "deserialize_string_or_u128")]
1699    pub total_locked: u128,
1700    #[serde(deserialize_with = "deserialize_string_or_u128")]
1701    pub total_unlocked: u128,
1702    #[serde(deserialize_with = "deserialize_string_or_u128")]
1703    pub trading_account_balance: u128,
1704    pub order_books: HashMap<String, OrderBookBalance>,
1705}
1706
1707/// WebSocket balance update.
1708#[derive(Debug, Clone, Serialize, Deserialize)]
1709pub struct BalanceUpdate {
1710    pub action: String,
1711    #[serde(default)]
1712    pub balance: Vec<BalanceEntry>,
1713    pub onchain_timestamp: Option<String>,
1714    pub seen_timestamp: String,
1715}
1716
1717/// WebSocket nonce update.
1718#[derive(Debug, Clone, Serialize, Deserialize)]
1719pub struct NonceUpdate {
1720    pub action: String,
1721    pub contract_id: TradeAccountId,
1722    #[serde(deserialize_with = "deserialize_string_or_u64")]
1723    pub nonce: u64,
1724    pub onchain_timestamp: Option<String>,
1725    pub seen_timestamp: String,
1726}
1727
1728/// Generic WebSocket message for initial parsing.
1729#[derive(Debug, Clone, Serialize, Deserialize)]
1730pub struct WsMessage {
1731    pub action: Option<String>,
1732    #[serde(flatten)]
1733    pub extra: HashMap<String, serde_json::Value>,
1734}
1735
1736/// Transaction result for simple operations (cancel, settle).
1737#[derive(Debug, Clone)]
1738pub struct TxResult {
1739    pub tx_id: String,
1740    pub orders: Vec<Order>,
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746
1747    fn sample_market() -> Market {
1748        Market {
1749            contract_id: ContractId::new(
1750                "0x1111111111111111111111111111111111111111111111111111111111111111",
1751            ),
1752            market_id: MarketId::new(
1753                "0x2222222222222222222222222222222222222222222222222222222222222222",
1754            ),
1755            whitelist_id: None,
1756            blacklist_id: None,
1757            maker_fee: 0,
1758            taker_fee: 0,
1759            min_order: 1,
1760            dust: 0,
1761            price_window: 0,
1762            base: MarketAsset {
1763                symbol: "BASE".to_string(),
1764                asset: AssetId::new(
1765                    "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1766                ),
1767                decimals: 9,
1768                max_precision: 3,
1769            },
1770            quote: MarketAsset {
1771                symbol: "QUOTE".to_string(),
1772                asset: AssetId::new(
1773                    "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1774                ),
1775                decimals: 9,
1776                max_precision: 4,
1777            },
1778        }
1779    }
1780
1781    #[test]
1782    fn market_price_accepts_valid_precision() {
1783        let market = sample_market();
1784        let price = market.price("12.3456").expect("price should be valid");
1785        assert_eq!(price.value(), "12.3456".parse().unwrap());
1786        market
1787            .validate_price_binding(&price)
1788            .expect("binding should match");
1789    }
1790
1791    #[test]
1792    fn market_price_rejects_excess_precision() {
1793        let market = sample_market();
1794        let err = market
1795            .price("12.34567")
1796            .expect_err("price precision should be rejected");
1797        assert!(matches!(err, O2Error::InvalidOrderParams(_)));
1798    }
1799
1800    #[test]
1801    fn market_quantity_rejects_excess_precision() {
1802        let market = sample_market();
1803        let err = market
1804            .quantity("1.2345")
1805            .expect_err("quantity precision should be rejected");
1806        assert!(matches!(err, O2Error::InvalidOrderParams(_)));
1807    }
1808
1809    #[test]
1810    fn market_quantity_binding_rejects_cross_market() {
1811        let market_a = sample_market();
1812        let mut market_b = sample_market();
1813        market_b.market_id =
1814            MarketId::new("0x3333333333333333333333333333333333333333333333333333333333333333");
1815
1816        let quantity = market_a
1817            .quantity("1.234")
1818            .expect("quantity should be valid");
1819        let err = market_b
1820            .validate_quantity_binding(&quantity)
1821            .expect_err("cross-market quantity must be rejected");
1822        assert!(format!("{err}").contains("stale or bound to a different market"));
1823    }
1824
1825    #[test]
1826    fn market_price_binding_rejects_precision_drift() {
1827        let market_a = sample_market();
1828        let mut market_b = sample_market();
1829        market_b.quote.max_precision = market_a.quote.max_precision + 1;
1830
1831        let price = market_a.price("1.2345").expect("price should be valid");
1832        let err = market_b
1833            .validate_price_binding(&price)
1834            .expect_err("precision drift should be rejected");
1835        assert!(format!("{err}").contains("stale or bound to a different market"));
1836    }
1837}