Skip to main content

polyoxide_clob/api/
orders.rs

1use std::collections::HashMap;
2
3use polyoxide_core::{HttpClient, QueryBuilder};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    account::{Credentials, Signer, Wallet},
8    error::ClobError,
9    request::{AuthMode, Request},
10};
11
12/// Orders namespace for order-related operations
13#[derive(Clone)]
14pub struct Orders {
15    pub(crate) http_client: HttpClient,
16    pub(crate) wallet: Wallet,
17    pub(crate) credentials: Credentials,
18    pub(crate) signer: Signer,
19    pub(crate) chain_id: u64,
20}
21
22impl Orders {
23    /// List the user's open orders (`GET /data/orders`).
24    ///
25    /// Results are cursor-paginated; feed `next_cursor` from the response back
26    /// via [`ListOrders::next_cursor`] to fetch the next page.
27    pub fn list(&self) -> ListOrders {
28        ListOrders {
29            request: Request::get(
30                self.http_client.clone(),
31                "/data/orders",
32                AuthMode::L2 {
33                    address: self.wallet.address(),
34                    credentials: self.credentials.clone(),
35                    signer: self.signer.clone(),
36                },
37                self.chain_id,
38            ),
39        }
40    }
41
42    /// Get a specific order by ID
43    pub fn get(&self, order_id: impl Into<String>) -> Request<OpenOrder> {
44        Request::get(
45            self.http_client.clone(),
46            format!("/data/order/{}", urlencoding::encode(&order_id.into())),
47            AuthMode::L2 {
48                address: self.wallet.address(),
49                credentials: self.credentials.clone(),
50                signer: self.signer.clone(),
51            },
52            self.chain_id,
53        )
54    }
55
56    /// Cancel an order
57    pub fn cancel(&self, order_id: impl Into<String>) -> CancelOrderRequest {
58        CancelOrderRequest {
59            http_client: self.http_client.clone(),
60            auth: AuthMode::L2 {
61                address: self.wallet.address(),
62                credentials: self.credentials.clone(),
63                signer: self.signer.clone(),
64            },
65            chain_id: self.chain_id,
66            order_id: order_id.into(),
67        }
68    }
69
70    /// Cancel all open orders
71    pub async fn cancel_all(&self) -> Result<BatchCancelResponse, ClobError> {
72        Request::<BatchCancelResponse>::delete(
73            self.http_client.clone(),
74            "/cancel-all",
75            AuthMode::L2 {
76                address: self.wallet.address(),
77                credentials: self.credentials.clone(),
78                signer: self.signer.clone(),
79            },
80            self.chain_id,
81        )
82        .send()
83        .await
84    }
85
86    /// Cancel all orders for a specific market and asset
87    pub async fn cancel_market(
88        &self,
89        market: impl Into<String>,
90        asset_id: impl Into<String>,
91    ) -> Result<BatchCancelResponse, ClobError> {
92        #[derive(Serialize)]
93        struct Body {
94            market: String,
95            asset_id: String,
96        }
97
98        Request::<BatchCancelResponse>::delete(
99            self.http_client.clone(),
100            "/cancel-market-orders",
101            AuthMode::L2 {
102                address: self.wallet.address(),
103                credentials: self.credentials.clone(),
104                signer: self.signer.clone(),
105            },
106            self.chain_id,
107        )
108        .body(&Body {
109            market: market.into(),
110            asset_id: asset_id.into(),
111        })?
112        .send()
113        .await
114    }
115
116    /// Check if an order is being scored for rewards
117    pub fn is_scoring(&self, order_id: impl Into<String>) -> Request<OrderScoringResponse> {
118        Request::get(
119            self.http_client.clone(),
120            "/order-scoring",
121            AuthMode::L2 {
122                address: self.wallet.address(),
123                credentials: self.credentials.clone(),
124                signer: self.signer.clone(),
125            },
126            self.chain_id,
127        )
128        .query("order_id", order_id.into())
129    }
130
131    /// Check if multiple orders are being scored for rewards
132    pub fn are_scoring(
133        &self,
134        order_ids: impl Into<Vec<String>>,
135    ) -> Request<Vec<OrderScoringResponse>> {
136        Request::get(
137            self.http_client.clone(),
138            "/orders-scoring",
139            AuthMode::L2 {
140                address: self.wallet.address(),
141                credentials: self.credentials.clone(),
142                signer: self.signer.clone(),
143            },
144            self.chain_id,
145        )
146        .query_many("order_ids", order_ids.into())
147    }
148
149    /// Cancel multiple orders by ID (up to 3000)
150    pub async fn cancel_many(
151        &self,
152        order_ids: impl Into<Vec<String>>,
153    ) -> Result<BatchCancelResponse, ClobError> {
154        let ids: Vec<String> = order_ids.into();
155
156        Request::<BatchCancelResponse>::delete(
157            self.http_client.clone(),
158            "/orders",
159            AuthMode::L2 {
160                address: self.wallet.address(),
161                credentials: self.credentials.clone(),
162                signer: self.signer.clone(),
163            },
164            self.chain_id,
165        )
166        .body(&ids)?
167        .send()
168        .await
169    }
170}
171
172/// Request builder for canceling an order
173pub struct CancelOrderRequest {
174    http_client: HttpClient,
175    auth: AuthMode,
176    chain_id: u64,
177    order_id: String,
178}
179
180impl CancelOrderRequest {
181    /// Execute the cancel request
182    pub async fn send(self) -> Result<BatchCancelResponse, ClobError> {
183        #[derive(serde::Serialize)]
184        struct CancelRequest {
185            #[serde(rename = "orderID")]
186            order_id: String,
187        }
188
189        let request = CancelRequest {
190            order_id: self.order_id,
191        };
192
193        Request::delete(self.http_client, "/order", self.auth, self.chain_id)
194            .body(&request)?
195            .send()
196            .await
197    }
198}
199
200/// A resting order, as returned by `GET /data/orders` and
201/// `GET /data/order/{orderID}` (both return this same shape).
202///
203/// This is an order *summary*, not a signed order: the venue does not echo back
204/// the signed payload, so there is no `token_id`, `maker_amount`,
205/// `signature_type`, `metadata`, `builder`, `salt`, or `signature` here. Field
206/// names are snake_case on the wire — there is deliberately no `rename_all`.
207///
208/// Pinned to a body captured from the live venue on 2026-07-24 (see
209/// `open_order_deserializes_captured_response` below), cross-checked against
210/// the `/data/orders` response schema in `docs/specs/clob/openapi.yaml`.
211/// Prior to 0.22.0 this struct declared camelCase names, a flattened
212/// [`SignedOrder`], and a string `created_at`, so **any** response containing a
213/// real order failed to deserialize with ``missing field `assetId` ``.
214///
215/// Fields the schema marks required are non-optional here, matching the
216/// capture. If the venue ever omits one, deserialization fails loudly rather
217/// than silently producing a half-empty order — and
218/// [`ListOrders::send_raw`] is the escape hatch for reading the body anyway.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct OpenOrder {
221    /// Order ID (`0x`-prefixed hash).
222    pub id: String,
223    /// Order status, e.g. `LIVE`.
224    pub status: String,
225    /// Owning API key (a UUID, not an address).
226    pub owner: String,
227    /// Maker address — the proxy/deposit wallet, not the signing EOA.
228    pub maker_address: String,
229    /// Market condition ID.
230    pub market: String,
231    /// Asset (token) ID for the outcome being traded.
232    pub asset_id: String,
233    /// `BUY` or `SELL`.
234    pub side: String,
235    /// Original order size, as a decimal string.
236    pub original_size: String,
237    /// Size matched so far, as a decimal string.
238    pub size_matched: String,
239    /// Limit price, as a decimal string.
240    pub price: String,
241    /// Outcome label, e.g. `Yes`.
242    pub outcome: String,
243    /// Expiration as a Unix timestamp string; `"0"` means no expiry.
244    pub expiration: String,
245    /// Order type, e.g. `GTC`.
246    pub order_type: String,
247    /// IDs of trades associated with this order.
248    #[serde(default)]
249    pub associate_trades: Vec<String>,
250    /// Creation time as a Unix timestamp in **seconds** — an integer on the
251    /// wire, not a string.
252    pub created_at: i64,
253}
254
255/// Response from posting an order
256#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(rename_all(deserialize = "camelCase"))]
258pub struct OrderResponse {
259    pub success: bool,
260    pub error_msg: Option<String>,
261    #[serde(rename(deserialize = "orderID"))]
262    pub order_id: Option<String>,
263    #[serde(default, rename(deserialize = "transactionsHashes"))]
264    pub transaction_hashes: Vec<String>,
265    pub status: Option<String>,
266    pub taking_amount: Option<String>,
267    pub making_amount: Option<String>,
268}
269
270/// Response from order scoring check
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct OrderScoringResponse {
273    pub order_id: String,
274    pub scoring: bool,
275}
276
277/// Response from cancel and batch cancel operations.
278///
279/// The Polymarket API returns this shape for all cancel endpoints:
280/// `DELETE /order`, `DELETE /orders`, `DELETE /cancel-all`, `DELETE /cancel-market-orders`.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(rename_all(deserialize = "camelCase"))]
283pub struct BatchCancelResponse {
284    #[serde(default)]
285    pub canceled: Vec<String>,
286    #[serde(default)]
287    pub not_canceled: HashMap<String, String>,
288}
289
290/// Paginated response from `GET /data/orders`
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ListOrdersResponse {
293    pub data: Vec<OpenOrder>,
294    pub next_cursor: Option<String>,
295}
296
297/// Request builder for listing open orders with optional filters.
298pub struct ListOrders {
299    request: Request<ListOrdersResponse>,
300}
301
302impl ListOrders {
303    /// Filter by a specific order ID.
304    pub fn id(mut self, order_id: impl Into<String>) -> Self {
305        self.request = self.request.query("id", order_id.into());
306        self
307    }
308
309    /// Filter by market (condition ID).
310    pub fn market(mut self, condition_id: impl Into<String>) -> Self {
311        self.request = self.request.query("market", condition_id.into());
312        self
313    }
314
315    /// Filter by asset (token ID).
316    pub fn asset_id(mut self, token_id: impl Into<String>) -> Self {
317        self.request = self.request.query("asset_id", token_id.into());
318        self
319    }
320
321    /// Continue from a pagination cursor.
322    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
323        self.request = self.request.query("next_cursor", cursor.into());
324        self
325    }
326
327    /// Execute the request.
328    pub async fn send(self) -> Result<ListOrdersResponse, ClobError> {
329        self.request.send().await
330    }
331
332    /// Execute the request and return the raw HTTP response, unparsed.
333    ///
334    /// An escape hatch for when [`OpenOrder`] cannot represent what the venue
335    /// returned. Without it, a typed-struct mismatch is a hard block with no
336    /// way for a caller to read the response at all — which is exactly what
337    /// happened when `OpenOrder` expected an `assetId` the venue never sends.
338    ///
339    /// Also the way to capture a body for a test fixture:
340    ///
341    /// ```no_run
342    /// # async fn doctest(clob: polyoxide_clob::Clob) -> Result<(), Box<dyn std::error::Error>> {
343    /// let body = clob.orders()?.list().send_raw().await?.text().await?;
344    /// println!("{body}");
345    /// # Ok(())
346    /// # }
347    /// ```
348    pub async fn send_raw(self) -> Result<reqwest::Response, ClobError> {
349        self.request.send_raw().await
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    /// Verbatim `GET /data/orders` body captured from the live venue on
358    /// 2026-07-24, with identifiers redacted. Only the identifier *values* were
359    /// changed — every key, type, and string/number distinction is as the venue
360    /// sent it.
361    ///
362    /// Recapture with:
363    /// `clob.orders()?.list().send_raw().await?.text().await?`
364    /// (needs an account holding at least one resting order — with none, the
365    /// body is `{"data":[],...}`, which is why this bug survived to 0.21.0).
366    const CAPTURED_OPEN_ORDERS: &str = r#"{
367        "data": [{
368            "id": "0xc566ca3bb8d61f08d649214f7a5daf5041f5577c1e60c9131ae418aa62eff165",
369            "status": "LIVE",
370            "owner": "aa17dfae-754d-2498-f336-8bd1db84f525",
371            "maker_address": "0xb98ad946c7f753596F26396Bf3F34A2EeBc39E86",
372            "market": "0x7018d32e315a69c0537fc42f8e574ee4a24b3babaae302cda3d9f5f8e5b0bd6e",
373            "asset_id": "84371186359433032934344934234365568331165136826770712345678901234567",
374            "side": "BUY",
375            "original_size": "5",
376            "size_matched": "0",
377            "price": "0.01",
378            "outcome": "Yes",
379            "expiration": "0",
380            "order_type": "GTC",
381            "associate_trades": [],
382            "created_at": 1784930007
383        }],
384        "next_cursor": "LTE=",
385        "limit": 100,
386        "count": 1
387    }"#;
388
389    #[test]
390    fn open_order_deserializes_captured_response() {
391        // The regression guard. Before 0.22.0 this failed with
392        // `missing field ` + "`assetId`" + `, because the struct declared
393        // camelCase names the venue does not send.
394        let resp: ListOrdersResponse = serde_json::from_str(CAPTURED_OPEN_ORDERS)
395            .expect("captured live body must deserialize");
396        assert_eq!(resp.data.len(), 1);
397
398        let o = &resp.data[0];
399        assert_eq!(o.status, "LIVE");
400        assert_eq!(o.side, "BUY");
401        assert_eq!(o.original_size, "5");
402        assert_eq!(o.size_matched, "0");
403        assert_eq!(o.price, "0.01");
404        assert_eq!(o.outcome, "Yes");
405        assert_eq!(o.expiration, "0");
406        assert_eq!(o.order_type, "GTC");
407        assert!(o.associate_trades.is_empty());
408        // Integer on the wire, not a string.
409        assert_eq!(o.created_at, 1_784_930_007);
410        // maker_address is the proxy wallet, distinct from the signing EOA.
411        assert!(o.maker_address.starts_with("0x"));
412        // owner is an API-key UUID, not an address.
413        assert!(!o.owner.starts_with("0x"));
414        assert_eq!(resp.next_cursor.as_deref(), Some("LTE="));
415    }
416
417    #[test]
418    fn open_order_rejects_the_camel_case_shape_it_used_to_expect() {
419        // Negative control. If someone reintroduces `rename_all(camelCase)`,
420        // the positive test above starts failing and this one starts passing —
421        // so the pair cannot both be satisfied by a single convention.
422        let json = r#"{"id":"x","status":"LIVE","owner":"o","makerAddress":"0x1",
423            "market":"0x2","assetId":"0x3","side":"BUY","originalSize":"5",
424            "sizeMatched":"0","price":"0.01","outcome":"Yes","expiration":"0",
425            "orderType":"GTC","associateTrades":[],"createdAt":1}"#;
426        assert!(
427            serde_json::from_str::<OpenOrder>(json).is_err(),
428            "camelCase must not deserialize; the venue sends snake_case"
429        );
430    }
431
432    #[test]
433    fn open_order_created_at_must_be_an_integer() {
434        // The venue sends a Unix-seconds integer. A string here was part of the
435        // original bug and would silently pass if the field were `String`.
436        let json = CAPTURED_OPEN_ORDERS.replace("1784930007", "\"2024-01-01T00:00:00Z\"");
437        assert!(
438            serde_json::from_str::<ListOrdersResponse>(&json).is_err(),
439            "a string created_at must be rejected"
440        );
441    }
442
443    #[test]
444    fn order_response_deserializes() {
445        let json = r#"{
446            "success": true,
447            "errorMsg": null,
448            "orderID": "order-789",
449            "transactionsHashes": ["0xhash1", "0xhash2"],
450            "status": "LIVE",
451            "takingAmount": "500",
452            "makingAmount": "1000"
453        }"#;
454        let resp: OrderResponse = serde_json::from_str(json).unwrap();
455        assert!(resp.success);
456        assert!(resp.error_msg.is_none());
457        assert_eq!(resp.order_id.as_deref(), Some("order-789"));
458        assert_eq!(resp.transaction_hashes.len(), 2);
459        assert_eq!(resp.status.as_deref(), Some("LIVE"));
460        assert_eq!(resp.taking_amount.as_deref(), Some("500"));
461        assert_eq!(resp.making_amount.as_deref(), Some("1000"));
462    }
463
464    #[test]
465    fn order_response_defaults_transaction_hashes() {
466        let json = r#"{"success": false, "errorMsg": "bad order"}"#;
467        let resp: OrderResponse = serde_json::from_str(json).unwrap();
468        assert!(!resp.success);
469        assert_eq!(resp.error_msg.as_deref(), Some("bad order"));
470        assert!(resp.transaction_hashes.is_empty());
471        assert!(resp.order_id.is_none());
472        assert!(resp.status.is_none());
473        assert!(resp.taking_amount.is_none());
474        assert!(resp.making_amount.is_none());
475    }
476
477    #[test]
478    fn batch_cancel_response_deserializes() {
479        let json = r#"{
480            "canceled": ["order-1", "order-2"],
481            "notCanceled": {"order-3": "insufficient balance"}
482        }"#;
483        let resp: BatchCancelResponse = serde_json::from_str(json).unwrap();
484        assert_eq!(resp.canceled, vec!["order-1", "order-2"]);
485        assert_eq!(resp.not_canceled.len(), 1);
486        assert_eq!(
487            resp.not_canceled.get("order-3").unwrap(),
488            "insufficient balance"
489        );
490    }
491
492    #[test]
493    fn batch_cancel_response_defaults_empty() {
494        let json = r#"{}"#;
495        let resp: BatchCancelResponse = serde_json::from_str(json).unwrap();
496        assert!(resp.canceled.is_empty());
497        assert!(resp.not_canceled.is_empty());
498    }
499
500    #[test]
501    fn batch_cancel_response_serializes() {
502        let resp = BatchCancelResponse {
503            canceled: vec!["a".into(), "b".into()],
504            not_canceled: HashMap::from([("c".into(), "error".into())]),
505        };
506        let json = serde_json::to_value(&resp).unwrap();
507        assert_eq!(json["canceled"], serde_json::json!(["a", "b"]));
508        assert_eq!(json["not_canceled"]["c"], "error");
509    }
510
511    #[test]
512    fn list_orders_response_empty() {
513        let json = r#"{"data": [], "next_cursor": "LTE="}"#;
514        let resp: ListOrdersResponse = serde_json::from_str(json).unwrap();
515        assert!(resp.data.is_empty());
516        assert_eq!(resp.next_cursor.as_deref(), Some("LTE="));
517    }
518
519    #[test]
520    fn list_orders_response_null_cursor() {
521        let json = r#"{"data": [], "next_cursor": null}"#;
522        let resp: ListOrdersResponse = serde_json::from_str(json).unwrap();
523        assert!(resp.data.is_empty());
524        assert!(resp.next_cursor.is_none());
525    }
526
527    #[test]
528    fn order_scoring_response_deserializes() {
529        let json = r#"{"order_id": "order-1", "scoring": true}"#;
530        let resp: OrderScoringResponse = serde_json::from_str(json).unwrap();
531        assert_eq!(resp.order_id, "order-1");
532        assert!(resp.scoring);
533    }
534
535    #[test]
536    fn order_scoring_response_batch_deserializes() {
537        let json = r#"[
538            {"order_id": "order-1", "scoring": true},
539            {"order_id": "order-2", "scoring": false}
540        ]"#;
541        let resp: Vec<OrderScoringResponse> = serde_json::from_str(json).unwrap();
542        assert_eq!(resp.len(), 2);
543        assert!(resp[0].scoring);
544        assert!(!resp[1].scoring);
545    }
546}