Skip to main content

polyoxide_clob/
types.rs

1use std::fmt;
2
3use alloy::primitives::{Address, B256};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// Error when parsing a tick size from an invalid value
8#[derive(Error, Debug, Clone, PartialEq)]
9#[error("invalid tick size: {0}. Valid values are 0.1, 0.01, 0.001, or 0.0001")]
10pub struct ParseTickSizeError(String);
11
12/// Side of an order (buy or sell).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
14#[serde(rename_all = "UPPERCASE")]
15pub enum OrderSide {
16    Buy,
17    Sell,
18}
19
20impl OrderSide {
21    /// Returns the uppercase string representation ("BUY" / "SELL") for API queries.
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            Self::Buy => "BUY",
25            Self::Sell => "SELL",
26        }
27    }
28}
29
30impl Serialize for OrderSide {
31    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32    where
33        S: serde::Serializer,
34    {
35        match self {
36            Self::Buy => serializer.serialize_str("BUY"),
37            Self::Sell => serializer.serialize_str("SELL"),
38        }
39    }
40}
41
42impl fmt::Display for OrderSide {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Buy => write!(f, "0"),
46            Self::Sell => write!(f, "1"),
47        }
48    }
49}
50
51/// Order type/kind
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "UPPERCASE")]
54pub enum OrderKind {
55    /// Good-till-Cancelled
56    Gtc,
57    /// Fill-or-Kill
58    Fok,
59    /// Good-till-Date
60    Gtd,
61    /// Fill-and-Kill
62    Fak,
63}
64
65impl fmt::Display for OrderKind {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            Self::Gtc => write!(f, "GTC"),
69            Self::Fok => write!(f, "FOK"),
70            Self::Gtd => write!(f, "GTD"),
71            Self::Fak => write!(f, "FAK"),
72        }
73    }
74}
75
76/// Signature type for order signing (EOA, Proxy, Gnosis Safe, or EIP-1271).
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
78#[repr(u8)]
79pub enum SignatureType {
80    #[default]
81    Eoa = 0,
82    PolyProxy = 1,
83    PolyGnosisSafe = 2,
84    /// EIP-1271 smart-contract wallet signatures (V2 orders only).
85    ///
86    /// Signing is not yet implemented; order creation rejects this variant.
87    Poly1271 = 3,
88}
89
90impl SignatureType {
91    /// Returns true if the signature type indicates a proxy wallet
92    pub fn is_proxy(&self) -> bool {
93        matches!(self, Self::PolyProxy | Self::PolyGnosisSafe)
94    }
95}
96
97impl Serialize for SignatureType {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: serde::Serializer,
101    {
102        serializer.serialize_u8(*self as u8)
103    }
104}
105
106impl<'de> Deserialize<'de> for SignatureType {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: serde::Deserializer<'de>,
110    {
111        let v = u8::deserialize(deserializer)?;
112        match v {
113            0 => Ok(Self::Eoa),
114            1 => Ok(Self::PolyProxy),
115            2 => Ok(Self::PolyGnosisSafe),
116            3 => Ok(Self::Poly1271),
117            _ => Err(serde::de::Error::custom(format!(
118                "invalid signature type: {}",
119                v
120            ))),
121        }
122    }
123}
124
125impl fmt::Display for SignatureType {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::Eoa => write!(f, "eoa"),
129            Self::PolyProxy => write!(f, "poly-proxy"),
130            Self::PolyGnosisSafe => write!(f, "poly-gnosis-safe"),
131            Self::Poly1271 => write!(f, "poly-1271"),
132        }
133    }
134}
135
136/// Tick size (minimum price increment)
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum TickSize {
139    /// 0.1
140    Tenth,
141    /// 0.01
142    Hundredth,
143    /// 0.001
144    Thousandth,
145    /// 0.0001
146    TenThousandth,
147}
148
149impl TickSize {
150    /// Returns the tick size as an `f64` value.
151    pub fn as_f64(&self) -> f64 {
152        match self {
153            Self::Tenth => 0.1,
154            Self::Hundredth => 0.01,
155            Self::Thousandth => 0.001,
156            Self::TenThousandth => 0.0001,
157        }
158    }
159
160    /// Returns the number of decimal places for this tick size.
161    pub fn decimals(&self) -> u32 {
162        match self {
163            Self::Tenth => 1,
164            Self::Hundredth => 2,
165            Self::Thousandth => 3,
166            Self::TenThousandth => 4,
167        }
168    }
169}
170
171/// Options for creating an order
172#[derive(Debug, Clone, Copy, Default)]
173pub struct PartialCreateOrderOptions {
174    pub tick_size: Option<TickSize>,
175    pub neg_risk: Option<bool>,
176}
177
178impl TryFrom<&str> for TickSize {
179    type Error = ParseTickSizeError;
180
181    fn try_from(s: &str) -> Result<Self, Self::Error> {
182        match s {
183            "0.1" => Ok(Self::Tenth),
184            "0.01" => Ok(Self::Hundredth),
185            "0.001" => Ok(Self::Thousandth),
186            "0.0001" => Ok(Self::TenThousandth),
187            _ => Err(ParseTickSizeError(s.to_string())),
188        }
189    }
190}
191
192impl TryFrom<f64> for TickSize {
193    type Error = ParseTickSizeError;
194
195    fn try_from(n: f64) -> Result<Self, Self::Error> {
196        const EPSILON: f64 = 1e-10;
197        if (n - 0.1).abs() < EPSILON {
198            Ok(Self::Tenth)
199        } else if (n - 0.01).abs() < EPSILON {
200            Ok(Self::Hundredth)
201        } else if (n - 0.001).abs() < EPSILON {
202            Ok(Self::Thousandth)
203        } else if (n - 0.0001).abs() < EPSILON {
204            Ok(Self::TenThousandth)
205        } else {
206            Err(ParseTickSizeError(n.to_string()))
207        }
208    }
209}
210
211impl std::str::FromStr for TickSize {
212    type Err = ParseTickSizeError;
213
214    fn from_str(s: &str) -> Result<Self, Self::Err> {
215        Self::try_from(s)
216    }
217}
218
219fn serialize_salt<S>(salt: &str, serializer: S) -> Result<S::Ok, S::Error>
220where
221    S: serde::Serializer,
222{
223    // Parse the string as u64 and serialize it as a JSON number.
224    // Salt values are generated in u64 range to stay within JSON
225    // safe integer limits (the official Polymarket clients do the same).
226    let val = salt
227        .parse::<u64>()
228        .map_err(|_| serde::ser::Error::custom("invalid salt: must fit in u64"))?;
229    serializer.serialize_u64(val)
230}
231
232/// Unsigned order (Polymarket CLOB V2 wire shape).
233///
234/// The signed EIP-712 struct (see `core::eip712`) covers `salt`, `maker`, `signer`,
235/// `token_id`, `maker_amount`, `taker_amount`, `side`, `signature_type`, `timestamp`,
236/// `metadata`, and `builder`. `expiration` travels on the wire (for GTD orders) but is
237/// NOT part of the signed struct. `neg_risk` is local-only (`#[serde(skip)]`) and
238/// selects the exchange vs. neg-risk-exchange verifying contract during signing.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct Order {
242    #[serde(serialize_with = "serialize_salt")]
243    pub salt: String,
244    pub maker: Address,
245    pub signer: Address,
246    pub token_id: String,
247    pub maker_amount: String,
248    pub taker_amount: String,
249    pub side: OrderSide,
250    /// Wire-only (GTD expiry); NOT part of the V2 signed struct.
251    pub expiration: String,
252    pub signature_type: SignatureType,
253    /// Unix milliseconds; provides order uniqueness in V2 (replaces the V1 `nonce`).
254    pub timestamp: String,
255    pub metadata: B256,
256    pub builder: B256,
257    #[serde(skip)]
258    pub neg_risk: bool,
259}
260
261/// Arguments for creating a market order
262#[derive(Debug, Clone)]
263pub struct MarketOrderArgs {
264    pub token_id: String,
265    /// For BUY: Amount in USDC to spend
266    /// For SELL: Amount of token to sell
267    pub amount: f64,
268    pub side: OrderSide,
269    /// Worst acceptable price to fill at.
270    /// If None, it will be calculated from the orderbook.
271    pub price: Option<f64>,
272    /// Reserved / ignored under CLOB V2. Fees are collected on-chain at match time, not
273    /// signed into the order, so this field is not read anywhere in the order path.
274    /// (V1 vestige; retained for source compatibility — removal is a recommended follow-up.)
275    pub fee_rate_bps: Option<u16>,
276    /// Reserved / ignored under CLOB V2. Order uniqueness now comes from the order
277    /// `timestamp`, so this field is not read anywhere in the order path.
278    /// (V1 vestige; retained for source compatibility — removal is a recommended follow-up.)
279    pub nonce: Option<u64>,
280    pub funder: Option<Address>,
281    pub signature_type: Option<SignatureType>,
282    pub order_type: Option<OrderKind>,
283}
284
285/// Signed order
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct SignedOrder {
289    #[serde(flatten)]
290    pub order: Order,
291    pub signature: String,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use alloy::primitives::Address;
298    use std::str::FromStr;
299
300    #[test]
301    fn test_order_serialization() {
302        let order = Order {
303            salt: "123".to_string(),
304            maker: Address::from_str("0x0000000000000000000000000000000000000001").unwrap(),
305            signer: Address::from_str("0x0000000000000000000000000000000000000002").unwrap(),
306            token_id: "456".to_string(),
307            maker_amount: "1000".to_string(),
308            taker_amount: "2000".to_string(),
309            side: OrderSide::Buy,
310            expiration: "0".to_string(),
311            signature_type: SignatureType::Eoa,
312            timestamp: "1700000000000".to_string(),
313            metadata: B256::ZERO,
314            builder: B256::ZERO,
315            neg_risk: false,
316        };
317
318        let signed_order = SignedOrder {
319            order,
320            signature: "0xabc".to_string(),
321        };
322
323        let json = serde_json::to_value(&signed_order).unwrap();
324
325        // Check camelCase
326        assert!(json.get("makerAmount").is_some());
327        assert!(json.get("takerAmount").is_some());
328        assert!(json.get("tokenId").is_some());
329        assert!(json.get("signatureType").is_some());
330
331        // Check flattened fields
332        assert!(json.get("signature").is_some());
333        assert!(json.get("salt").is_some());
334
335        // Check values
336        assert_eq!(json["makerAmount"], "1000");
337        assert_eq!(json["side"], "BUY");
338        assert_eq!(json["signatureType"], 0);
339        assert_eq!(json["timestamp"], "1700000000000");
340    }
341
342    #[test]
343    fn signed_order_serializes_v2_wire_shape() {
344        let order = Order {
345            salt: "479249096354".to_string(),
346            maker: alloy::primitives::Address::ZERO,
347            signer: alloy::primitives::Address::ZERO,
348            token_id: "100".to_string(),
349            maker_amount: "1000000".to_string(),
350            taker_amount: "5000000".to_string(),
351            side: OrderSide::Buy,
352            expiration: "0".to_string(),
353            signature_type: SignatureType::PolyProxy,
354            timestamp: "1700000000000".to_string(),
355            metadata: alloy::primitives::B256::ZERO,
356            builder: alloy::primitives::B256::ZERO,
357            neg_risk: false,
358        };
359        let signed = SignedOrder {
360            order,
361            signature: "0xdead".to_string(),
362        };
363        let v = serde_json::to_value(&signed).unwrap();
364        for k in [
365            "salt",
366            "maker",
367            "signer",
368            "tokenId",
369            "makerAmount",
370            "takerAmount",
371            "side",
372            "expiration",
373            "signatureType",
374            "timestamp",
375            "metadata",
376            "builder",
377            "signature",
378        ] {
379            assert!(v.get(k).is_some(), "missing wire field {k}");
380        }
381        for k in ["taker", "nonce", "feeRateBps"] {
382            assert!(v.get(k).is_none(), "V1 field {k} must be gone");
383        }
384        assert!(v["salt"].is_number());
385        assert!(v["makerAmount"].is_string());
386        assert_eq!(v["builder"].as_str().unwrap().len(), 66);
387    }
388
389    #[test]
390    fn order_side_serde_roundtrip() {
391        let buy: OrderSide = serde_json::from_str("\"BUY\"").unwrap();
392        let sell: OrderSide = serde_json::from_str("\"SELL\"").unwrap();
393        assert_eq!(buy, OrderSide::Buy);
394        assert_eq!(sell, OrderSide::Sell);
395
396        assert_eq!(serde_json::to_string(&OrderSide::Buy).unwrap(), "\"BUY\"");
397        assert_eq!(serde_json::to_string(&OrderSide::Sell).unwrap(), "\"SELL\"");
398    }
399
400    #[test]
401    fn order_side_display_is_numeric() {
402        // Display uses 0/1 for EIP-712 encoding
403        assert_eq!(OrderSide::Buy.to_string(), "0");
404        assert_eq!(OrderSide::Sell.to_string(), "1");
405    }
406
407    #[test]
408    fn order_side_rejects_lowercase() {
409        let result = serde_json::from_str::<OrderSide>("\"buy\"");
410        assert!(result.is_err(), "Should reject lowercase order side");
411    }
412
413    #[test]
414    fn order_kind_serde_roundtrip() {
415        for (variant, expected) in [
416            (OrderKind::Gtc, "GTC"),
417            (OrderKind::Fok, "FOK"),
418            (OrderKind::Gtd, "GTD"),
419            (OrderKind::Fak, "FAK"),
420        ] {
421            let serialized = serde_json::to_string(&variant).unwrap();
422            assert_eq!(serialized, format!("\"{}\"", expected));
423
424            let deserialized: OrderKind = serde_json::from_str(&serialized).unwrap();
425            assert_eq!(deserialized, variant);
426        }
427    }
428
429    #[test]
430    fn order_kind_display() {
431        assert_eq!(OrderKind::Gtc.to_string(), "GTC");
432        assert_eq!(OrderKind::Fok.to_string(), "FOK");
433        assert_eq!(OrderKind::Gtd.to_string(), "GTD");
434        assert_eq!(OrderKind::Fak.to_string(), "FAK");
435    }
436
437    #[test]
438    fn signature_type_serde_as_u8() {
439        assert_eq!(serde_json::to_string(&SignatureType::Eoa).unwrap(), "0");
440        assert_eq!(
441            serde_json::to_string(&SignatureType::PolyProxy).unwrap(),
442            "1"
443        );
444        assert_eq!(
445            serde_json::to_string(&SignatureType::PolyGnosisSafe).unwrap(),
446            "2"
447        );
448
449        let eoa: SignatureType = serde_json::from_str("0").unwrap();
450        assert_eq!(eoa, SignatureType::Eoa);
451        let proxy: SignatureType = serde_json::from_str("1").unwrap();
452        assert_eq!(proxy, SignatureType::PolyProxy);
453        let gnosis: SignatureType = serde_json::from_str("2").unwrap();
454        assert_eq!(gnosis, SignatureType::PolyGnosisSafe);
455
456        assert_eq!(
457            serde_json::to_string(&SignatureType::Poly1271).unwrap(),
458            "3"
459        );
460        let poly1271: SignatureType = serde_json::from_str("3").unwrap();
461        assert_eq!(poly1271, SignatureType::Poly1271);
462    }
463
464    #[test]
465    fn signature_type_rejects_invalid_u8() {
466        let result = serde_json::from_str::<SignatureType>("4");
467        assert!(result.is_err(), "Should reject invalid signature type 4");
468
469        let result = serde_json::from_str::<SignatureType>("255");
470        assert!(result.is_err(), "Should reject invalid signature type 255");
471    }
472
473    #[test]
474    fn signature_type_display() {
475        assert_eq!(SignatureType::Eoa.to_string(), "eoa");
476        assert_eq!(SignatureType::PolyProxy.to_string(), "poly-proxy");
477        assert_eq!(
478            SignatureType::PolyGnosisSafe.to_string(),
479            "poly-gnosis-safe"
480        );
481    }
482
483    #[test]
484    fn signature_type_default_is_eoa() {
485        assert_eq!(SignatureType::default(), SignatureType::Eoa);
486    }
487
488    #[test]
489    fn signature_type_is_proxy() {
490        assert!(!SignatureType::Eoa.is_proxy());
491        assert!(SignatureType::PolyProxy.is_proxy());
492        assert!(SignatureType::PolyGnosisSafe.is_proxy());
493        // Poly1271 (EIP-1271) is a smart-contract wallet, not a Polymarket proxy.
494        assert!(!SignatureType::Poly1271.is_proxy());
495    }
496
497    #[test]
498    fn tick_size_from_str() {
499        assert_eq!(TickSize::try_from("0.1").unwrap(), TickSize::Tenth);
500        assert_eq!(TickSize::try_from("0.01").unwrap(), TickSize::Hundredth);
501        assert_eq!(TickSize::try_from("0.001").unwrap(), TickSize::Thousandth);
502        assert_eq!(
503            TickSize::try_from("0.0001").unwrap(),
504            TickSize::TenThousandth
505        );
506    }
507
508    #[test]
509    fn tick_size_from_str_rejects_invalid() {
510        assert!(TickSize::try_from("0.5").is_err());
511        assert!(TickSize::try_from("1.0").is_err());
512        assert!(TickSize::try_from("abc").is_err());
513        assert!(TickSize::try_from("0.00001").is_err());
514    }
515
516    #[test]
517    fn tick_size_from_f64() {
518        assert_eq!(TickSize::try_from(0.1).unwrap(), TickSize::Tenth);
519        assert_eq!(TickSize::try_from(0.01).unwrap(), TickSize::Hundredth);
520        assert_eq!(TickSize::try_from(0.001).unwrap(), TickSize::Thousandth);
521        assert_eq!(TickSize::try_from(0.0001).unwrap(), TickSize::TenThousandth);
522    }
523
524    #[test]
525    fn tick_size_from_f64_rejects_invalid() {
526        assert!(TickSize::try_from(0.5).is_err());
527        assert!(TickSize::try_from(0.0).is_err());
528        assert!(TickSize::try_from(1.0).is_err());
529    }
530
531    #[test]
532    fn tick_size_as_f64() {
533        assert!((TickSize::Tenth.as_f64() - 0.1).abs() < f64::EPSILON);
534        assert!((TickSize::Hundredth.as_f64() - 0.01).abs() < f64::EPSILON);
535        assert!((TickSize::Thousandth.as_f64() - 0.001).abs() < f64::EPSILON);
536        assert!((TickSize::TenThousandth.as_f64() - 0.0001).abs() < f64::EPSILON);
537    }
538
539    #[test]
540    fn tick_size_decimals() {
541        assert_eq!(TickSize::Tenth.decimals(), 1);
542        assert_eq!(TickSize::Hundredth.decimals(), 2);
543        assert_eq!(TickSize::Thousandth.decimals(), 3);
544        assert_eq!(TickSize::TenThousandth.decimals(), 4);
545    }
546
547    #[test]
548    fn tick_size_from_str_trait() {
549        let ts: TickSize = "0.01".parse().unwrap();
550        assert_eq!(ts, TickSize::Hundredth);
551    }
552
553    #[test]
554    fn parse_tick_size_error_display() {
555        let err = TickSize::try_from("bad").unwrap_err();
556        let msg = err.to_string();
557        assert!(
558            msg.contains("bad"),
559            "Error should contain invalid value: {}",
560            msg
561        );
562        assert!(
563            msg.contains("0.1"),
564            "Error should list valid values: {}",
565            msg
566        );
567    }
568
569    #[test]
570    fn order_neg_risk_skipped_in_serialization() {
571        let order = Order {
572            salt: "1".to_string(),
573            maker: Address::ZERO,
574            signer: Address::ZERO,
575            token_id: "1".to_string(),
576            maker_amount: "1".to_string(),
577            taker_amount: "1".to_string(),
578            side: OrderSide::Buy,
579            expiration: "0".to_string(),
580            signature_type: SignatureType::Eoa,
581            timestamp: "0".to_string(),
582            metadata: B256::ZERO,
583            builder: B256::ZERO,
584            neg_risk: true,
585        };
586        let json = serde_json::to_value(&order).unwrap();
587        assert!(
588            json.get("neg_risk").is_none() && json.get("negRisk").is_none(),
589            "neg_risk should be skipped in serialization: {}",
590            json
591        );
592    }
593
594    #[test]
595    fn salt_serialized_as_number() {
596        let order = Order {
597            salt: "18446744073709551615".to_string(), // u64::MAX
598            maker: Address::ZERO,
599            signer: Address::ZERO,
600            token_id: "1".to_string(),
601            maker_amount: "1".to_string(),
602            taker_amount: "1".to_string(),
603            side: OrderSide::Buy,
604            expiration: "0".to_string(),
605            signature_type: SignatureType::Eoa,
606            timestamp: "0".to_string(),
607            metadata: B256::ZERO,
608            builder: B256::ZERO,
609            neg_risk: false,
610        };
611        let json = serde_json::to_value(&order).unwrap();
612        assert!(
613            json["salt"].is_number(),
614            "Salt should be a number: {:?}",
615            json["salt"]
616        );
617        assert_eq!(json["salt"].as_u64().unwrap(), u64::MAX);
618    }
619
620    #[test]
621    fn salt_rejects_u128() {
622        let order = Order {
623            salt: "340282366920938463463374607431768211455".to_string(), // u128::MAX
624            maker: Address::ZERO,
625            signer: Address::ZERO,
626            token_id: "1".to_string(),
627            maker_amount: "1".to_string(),
628            taker_amount: "1".to_string(),
629            side: OrderSide::Buy,
630            expiration: "0".to_string(),
631            signature_type: SignatureType::Eoa,
632            timestamp: "0".to_string(),
633            metadata: B256::ZERO,
634            builder: B256::ZERO,
635            neg_risk: false,
636        };
637        assert!(
638            serde_json::to_value(&order).is_err(),
639            "u128 salt should be rejected"
640        );
641    }
642}