Skip to main content

polyoxide_clob/api/
account.rs

1use std::collections::HashMap;
2
3use alloy::primitives::Address;
4use polyoxide_core::{HttpClient, QueryBuilder};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    account::{Credentials, Signer, Wallet},
9    error::ClobError,
10    request::{AuthMode, Request},
11    types::{OrderSide, SignatureType},
12};
13
14/// Account API namespace for account-related operations
15#[derive(Clone)]
16pub struct AccountApi {
17    pub(crate) http_client: HttpClient,
18    pub(crate) wallet: Wallet,
19    pub(crate) credentials: Credentials,
20    pub(crate) signer: Signer,
21    pub(crate) chain_id: u64,
22    pub(crate) signature_type: SignatureType,
23}
24
25impl AccountApi {
26    /// Get balance and allowance for a token
27    pub fn balance_allowance(
28        &self,
29        token_id: impl Into<String>,
30    ) -> Request<BalanceAllowanceResponse> {
31        Request::get(
32            self.http_client.clone(),
33            "/balance-allowance",
34            AuthMode::L2 {
35                address: self.wallet.clone().address(),
36                credentials: self.credentials.clone(),
37                signer: self.signer.clone(),
38            },
39            self.chain_id,
40        )
41        .query("asset_type", "CONDITIONAL")
42        .query("token_id", token_id.into())
43        .query("signature_type", self.signature_type as u8)
44    }
45
46    /// Get the caller's USDC (collateral) balance and allowance.
47    pub fn usdc_balance(&self) -> Request<BalanceAllowanceResponse> {
48        Request::get(
49            self.http_client.clone(),
50            "/balance-allowance",
51            AuthMode::L2 {
52                address: self.wallet.clone().address(),
53                credentials: self.credentials.clone(),
54                signer: self.signer.clone(),
55            },
56            self.chain_id,
57        )
58        .query("asset_type", "COLLATERAL")
59        .query("signature_type", self.signature_type as u8)
60    }
61
62    /// Force a refresh of the caller's balance and allowances from on-chain data.
63    ///
64    /// Calls `GET /balance-allowance/update` with the following query parameters:
65    /// - `asset_type` (required): `"COLLATERAL"` or `"CONDITIONAL"`.
66    /// - `token_id` (optional): asset ID. Defaults to `"-1"` (ERC20 collateral) server-side.
67    /// - `signature_type` (optional): `0` = EOA, `1` = POLY_PROXY, `2` = POLY_GNOSIS_SAFE.
68    ///   Defaults to `0` server-side.
69    ///
70    /// Returns the raw JSON body from the server (typically `{}` on success).
71    pub async fn update_balance_allowance(
72        &self,
73        asset_type: impl Into<String>,
74        token_id: Option<String>,
75        signature_type: Option<u8>,
76    ) -> Result<serde_json::Value, ClobError> {
77        let mut request = Request::<serde_json::Value>::get(
78            self.http_client.clone(),
79            "/balance-allowance/update".to_string(),
80            AuthMode::L2 {
81                address: self.wallet.clone().address(),
82                credentials: self.credentials.clone(),
83                signer: self.signer.clone(),
84            },
85            self.chain_id,
86        )
87        .query("asset_type", asset_type.into());
88        if let Some(token_id) = token_id {
89            request = request.query("token_id", token_id);
90        }
91        if let Some(signature_type) = signature_type {
92            request = request.query("signature_type", signature_type);
93        }
94
95        // The endpoint returns 200 with an empty body on success, so parse the
96        // body manually and treat an empty body as a null JSON value rather than
97        // letting `send()` fail with "EOF while parsing a value".
98        let text = request.send_raw().await?.text().await?;
99        if text.trim().is_empty() {
100            return Ok(serde_json::Value::Null);
101        }
102        Ok(serde_json::from_str(&text)?)
103    }
104
105    /// Send a basic heartbeat to keep the session alive
106    ///
107    /// Calls `POST /heartbeats`. No request body; returns `{"status": "ok"}` on success.
108    /// If heartbeats are not sent regularly, all open orders for the user will be
109    /// automatically canceled.
110    pub async fn heartbeat(&self) -> Result<HeartbeatResponse, ClobError> {
111        Request::<HeartbeatResponse>::post(
112            self.http_client.clone(),
113            "/heartbeats".to_string(),
114            AuthMode::L2 {
115                address: self.wallet.clone().address(),
116                credentials: self.credentials.clone(),
117                signer: self.signer.clone(),
118            },
119            self.chain_id,
120        )
121        .send()
122        .await
123    }
124
125    /// Send a v1 heartbeat with session tracking via heartbeat ID
126    ///
127    /// Calls `POST /v1/heartbeats`.
128    pub async fn heartbeat_v1(&self) -> Result<serde_json::Value, ClobError> {
129        Request::<serde_json::Value>::post(
130            self.http_client.clone(),
131            "/v1/heartbeats".to_string(),
132            AuthMode::L2 {
133                address: self.wallet.clone().address(),
134                credentials: self.credentials.clone(),
135                signer: self.signer.clone(),
136            },
137            self.chain_id,
138        )
139        .send()
140        .await
141    }
142
143    /// Get builder trades with optional filtering
144    /// List trades attributed to a builder code.
145    ///
146    /// `builder_code` is required by `GET /builder/trades`; the API rejects the
147    /// request with "builder code is required" when it is omitted.
148    pub fn builder_trades(&self, builder_code: impl Into<String>) -> ListBuilderTrades {
149        let request = Request::get(
150            self.http_client.clone(),
151            "/builder/trades",
152            AuthMode::L2 {
153                address: self.wallet.clone().address(),
154                credentials: self.credentials.clone(),
155                signer: self.signer.clone(),
156            },
157            self.chain_id,
158        )
159        .query("builder_code", builder_code.into());
160        ListBuilderTrades { request }
161    }
162
163    /// Get trades for a maker address (required), with optional additional filtering
164    pub fn trades(&self, maker_address: impl Into<String>) -> ListClobTrades {
165        let request = Request::get(
166            self.http_client.clone(),
167            "/data/trades",
168            AuthMode::L2 {
169                address: self.wallet.clone().address(),
170                credentials: self.credentials.clone(),
171                signer: self.signer.clone(),
172            },
173            self.chain_id,
174        )
175        .query("maker_address", maker_address.into());
176        ListClobTrades { request }
177    }
178}
179
180/// Request builder for listing CLOB trades with optional filters
181pub struct ListClobTrades {
182    request: Request<ListTradesResponse>,
183}
184
185impl ListClobTrades {
186    /// Filter by specific trade ID
187    pub fn id(mut self, id: impl Into<String>) -> Self {
188        self.request = self.request.query("id", id.into());
189        self
190    }
191
192    /// Filter by market (condition ID)
193    pub fn market(mut self, condition_id: impl Into<String>) -> Self {
194        self.request = self.request.query("market", condition_id.into());
195        self
196    }
197
198    /// Filter by asset (token ID)
199    pub fn asset_id(mut self, token_id: impl Into<String>) -> Self {
200        self.request = self.request.query("asset_id", token_id.into());
201        self
202    }
203
204    /// Filter trades before this timestamp
205    pub fn before(mut self, timestamp: impl Into<String>) -> Self {
206        self.request = self.request.query("before", timestamp.into());
207        self
208    }
209
210    /// Filter trades after this timestamp
211    pub fn after(mut self, timestamp: impl Into<String>) -> Self {
212        self.request = self.request.query("after", timestamp.into());
213        self
214    }
215
216    /// Continue from a pagination cursor
217    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
218        self.request = self.request.query("next_cursor", cursor.into());
219        self
220    }
221
222    /// Execute the request
223    pub async fn send(self) -> Result<ListTradesResponse, ClobError> {
224        self.request.send().await
225    }
226}
227
228/// Request builder for listing builder trades with optional filters
229pub struct ListBuilderTrades {
230    request: Request<ListBuilderTradesResponse>,
231}
232
233impl ListBuilderTrades {
234    /// Filter trades after this cursor
235    pub fn after(mut self, cursor: impl Into<String>) -> Self {
236        self.request = self.request.query("after", cursor.into());
237        self
238    }
239
240    /// Filter by maker address
241    pub fn maker_address(mut self, address: impl Into<String>) -> Self {
242        self.request = self.request.query("maker_address", address.into());
243        self
244    }
245
246    /// Filter by market (condition ID)
247    pub fn market(mut self, condition_id: impl Into<String>) -> Self {
248        self.request = self.request.query("market", condition_id.into());
249        self
250    }
251
252    /// Filter by a specific trade ID
253    pub fn id(mut self, id: impl Into<String>) -> Self {
254        self.request = self.request.query("id", id.into());
255        self
256    }
257
258    /// Filter by asset (token ID)
259    pub fn asset_id(mut self, token_id: impl Into<String>) -> Self {
260        self.request = self.request.query("asset_id", token_id.into());
261        self
262    }
263
264    /// Filter trades before this Unix timestamp
265    pub fn before(mut self, timestamp: impl Into<String>) -> Self {
266        self.request = self.request.query("before", timestamp.into());
267        self
268    }
269
270    /// Continue from a pagination cursor
271    pub fn next_cursor(mut self, cursor: impl Into<String>) -> Self {
272        self.request = self.request.query("next_cursor", cursor.into());
273        self
274    }
275
276    /// Execute the request
277    pub async fn send(self) -> Result<ListBuilderTradesResponse, ClobError> {
278        self.request.send().await
279    }
280}
281
282/// Trade information
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct Trade {
285    pub id: String,
286    pub taker_order_id: String,
287    pub market: String,
288    pub asset_id: String,
289    pub side: OrderSide,
290    pub size: String,
291    pub fee_rate_bps: String,
292    pub price: String,
293    pub status: String,
294    pub match_time: String,
295    #[serde(default)]
296    pub last_update: Option<String>,
297    pub outcome: String,
298    #[serde(default)]
299    pub bucket_index: Option<u32>,
300    pub owner: Address,
301    pub maker_address: Option<String>,
302    #[serde(default)]
303    pub maker_orders: Vec<MakerOrder>,
304    pub transaction_hash: String,
305    pub trader_side: Option<String>,
306}
307
308/// Individual maker order within a trade
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct MakerOrder {
311    pub order_id: String,
312    pub owner: String,
313    pub maker_address: String,
314    pub matched_amount: String,
315    pub price: String,
316    pub fee_rate_bps: String,
317    pub asset_id: String,
318    pub outcome: String,
319    pub side: OrderSide,
320}
321
322/// Paginated response from `GET /data/trades`
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ListTradesResponse {
325    pub data: Vec<Trade>,
326    pub next_cursor: Option<String>,
327}
328
329/// Builder trade from `GET /builder/trades`
330///
331/// Different from [`Trade`] — uses camelCase field names and has
332/// builder-specific fields (`trade_type`, `builder`, `size_usdc`, `fee`, etc.).
333#[derive(Debug, Clone, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct BuilderTrade {
336    pub id: String,
337    pub trade_type: String,
338    pub taker_order_hash: String,
339    pub builder: String,
340    pub market: String,
341    pub asset_id: String,
342    pub side: String,
343    pub size: String,
344    pub size_usdc: String,
345    pub price: String,
346    pub status: String,
347    pub outcome: String,
348    pub outcome_index: u32,
349    pub owner: String,
350    pub maker: String,
351    pub transaction_hash: String,
352    pub match_time: String,
353    #[serde(default)]
354    pub bucket_index: Option<u32>,
355    pub fee: String,
356    pub fee_usdc: String,
357    #[serde(rename = "err_msg")]
358    pub err_msg: Option<String>,
359    pub created_at: Option<String>,
360    pub updated_at: Option<String>,
361}
362
363/// Paginated response from `GET /builder/trades`
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct ListBuilderTradesResponse {
366    pub data: Vec<BuilderTrade>,
367    pub next_cursor: Option<String>,
368}
369
370/// Balance and allowance response
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct BalanceAllowanceResponse {
373    pub balance: String,
374    pub allowances: HashMap<String, String>,
375}
376
377/// Response from `POST /heartbeats`
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct HeartbeatResponse {
380    pub status: String,
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn trade_deserialization() {
389        let json = r#"{
390            "id": "trade-123",
391            "taker_order_id": "order-456",
392            "market": "0xcondition",
393            "asset_id": "0xtoken",
394            "side": "BUY",
395            "size": "100.5",
396            "fee_rate_bps": "0",
397            "price": "0.55",
398            "status": "MATCHED",
399            "match_time": "1700000000",
400            "last_update": null,
401            "outcome": "Yes",
402            "bucket_index": null,
403            "owner": "0x0000000000000000000000000000000000000001",
404            "transaction_hash": "0xhash123"
405        }"#;
406        let trade: Trade = serde_json::from_str(json).unwrap();
407        assert_eq!(trade.id, "trade-123");
408        assert_eq!(trade.side, OrderSide::Buy);
409        assert_eq!(trade.price, "0.55");
410        assert!(trade.last_update.is_none());
411        assert!(trade.bucket_index.is_none());
412        // New fields default to None/empty when absent
413        assert!(trade.maker_address.is_none());
414        assert!(trade.maker_orders.is_empty());
415        assert!(trade.trader_side.is_none());
416    }
417
418    #[test]
419    fn trade_with_optional_fields() {
420        let json = r#"{
421            "id": "t1",
422            "taker_order_id": "o1",
423            "market": "0xcond",
424            "asset_id": "0xasset",
425            "side": "SELL",
426            "size": "50",
427            "fee_rate_bps": "100",
428            "price": "0.72",
429            "status": "MATCHED",
430            "match_time": "1700001000",
431            "last_update": "1700002000",
432            "outcome": "No",
433            "bucket_index": 3,
434            "owner": "0x0000000000000000000000000000000000000002",
435            "maker_address": "0xmaker",
436            "maker_orders": [{
437                "order_id": "mo-1",
438                "owner": "0xowner",
439                "maker_address": "0xmaker",
440                "matched_amount": "50",
441                "price": "0.72",
442                "fee_rate_bps": "100",
443                "asset_id": "0xasset",
444                "outcome": "No",
445                "side": "BUY"
446            }],
447            "transaction_hash": "0xhash456",
448            "trader_side": "TAKER"
449        }"#;
450        let trade: Trade = serde_json::from_str(json).unwrap();
451        assert_eq!(trade.side, OrderSide::Sell);
452        assert_eq!(trade.last_update.as_deref(), Some("1700002000"));
453        assert_eq!(trade.bucket_index, Some(3));
454        assert_eq!(trade.maker_address.as_deref(), Some("0xmaker"));
455        assert_eq!(trade.maker_orders.len(), 1);
456        assert_eq!(trade.maker_orders[0].order_id, "mo-1");
457        assert_eq!(trade.maker_orders[0].matched_amount, "50");
458        assert_eq!(trade.maker_orders[0].side, OrderSide::Buy);
459        assert_eq!(trade.trader_side.as_deref(), Some("TAKER"));
460    }
461
462    #[test]
463    fn list_trades_response_deserializes() {
464        let json = r#"{
465            "data": [{
466                "id": "t1",
467                "taker_order_id": "o1",
468                "market": "0xcond",
469                "asset_id": "0xasset",
470                "side": "BUY",
471                "size": "100",
472                "fee_rate_bps": "0",
473                "price": "0.55",
474                "status": "MATCHED",
475                "match_time": "1700000000",
476                "outcome": "Yes",
477                "owner": "0x0000000000000000000000000000000000000001",
478                "transaction_hash": "0xhash"
479            }],
480            "next_cursor": "abc123"
481        }"#;
482        let resp: ListTradesResponse = serde_json::from_str(json).unwrap();
483        assert_eq!(resp.data.len(), 1);
484        assert_eq!(resp.data[0].id, "t1");
485        assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
486    }
487
488    #[test]
489    fn list_trades_response_empty() {
490        let json = r#"{"data": [], "next_cursor": null}"#;
491        let resp: ListTradesResponse = serde_json::from_str(json).unwrap();
492        assert!(resp.data.is_empty());
493        assert!(resp.next_cursor.is_none());
494    }
495
496    #[test]
497    fn builder_trade_deserialization() {
498        let json = r#"{
499            "id": "bt-1",
500            "tradeType": "LIMIT",
501            "takerOrderHash": "0xhash",
502            "builder": "0xbuilder",
503            "market": "0xcond",
504            "assetId": "0xtoken",
505            "side": "BUY",
506            "size": "100",
507            "sizeUsdc": "55.00",
508            "price": "0.55",
509            "status": "MATCHED",
510            "outcome": "Yes",
511            "outcomeIndex": 0,
512            "owner": "0xowner",
513            "maker": "0xmaker",
514            "transactionHash": "0xtxhash",
515            "matchTime": "1700000000",
516            "bucketIndex": 5,
517            "fee": "0.01",
518            "feeUsdc": "0.55",
519            "err_msg": null,
520            "createdAt": "2024-01-01T00:00:00Z",
521            "updatedAt": "2024-01-01T00:00:01Z"
522        }"#;
523        let bt: BuilderTrade = serde_json::from_str(json).unwrap();
524        assert_eq!(bt.id, "bt-1");
525        assert_eq!(bt.trade_type, "LIMIT");
526        assert_eq!(bt.builder, "0xbuilder");
527        assert_eq!(bt.size_usdc, "55.00");
528        assert_eq!(bt.outcome_index, 0);
529        assert_eq!(bt.bucket_index, Some(5));
530        assert!(bt.err_msg.is_none());
531        assert_eq!(bt.created_at.as_deref(), Some("2024-01-01T00:00:00Z"));
532    }
533
534    #[test]
535    fn builder_trade_with_error() {
536        let json = r#"{
537            "id": "bt-2",
538            "tradeType": "MARKET",
539            "takerOrderHash": "0x",
540            "builder": "0x",
541            "market": "0x",
542            "assetId": "0x",
543            "side": "SELL",
544            "size": "0",
545            "sizeUsdc": "0",
546            "price": "0",
547            "status": "FAILED",
548            "outcome": "No",
549            "outcomeIndex": 1,
550            "owner": "0x",
551            "maker": "0x",
552            "transactionHash": "0x",
553            "matchTime": "0",
554            "fee": "0",
555            "feeUsdc": "0",
556            "err_msg": "insufficient balance",
557            "createdAt": null,
558            "updatedAt": null
559        }"#;
560        let bt: BuilderTrade = serde_json::from_str(json).unwrap();
561        assert_eq!(bt.err_msg.as_deref(), Some("insufficient balance"));
562        assert!(bt.bucket_index.is_none());
563        assert!(bt.created_at.is_none());
564    }
565
566    #[test]
567    fn list_builder_trades_response_deserializes() {
568        let json = r#"{
569            "data": [{
570                "id": "bt-1",
571                "tradeType": "LIMIT",
572                "takerOrderHash": "0x",
573                "builder": "0x",
574                "market": "0x",
575                "assetId": "0x",
576                "side": "BUY",
577                "size": "100",
578                "sizeUsdc": "55",
579                "price": "0.55",
580                "status": "MATCHED",
581                "outcome": "Yes",
582                "outcomeIndex": 0,
583                "owner": "0x",
584                "maker": "0x",
585                "transactionHash": "0x",
586                "matchTime": "0",
587                "fee": "0",
588                "feeUsdc": "0",
589                "createdAt": null,
590                "updatedAt": null
591            }],
592            "next_cursor": "cursor123"
593        }"#;
594        let resp: ListBuilderTradesResponse = serde_json::from_str(json).unwrap();
595        assert_eq!(resp.data.len(), 1);
596        assert_eq!(resp.data[0].id, "bt-1");
597        assert_eq!(resp.next_cursor.as_deref(), Some("cursor123"));
598    }
599
600    #[test]
601    fn balance_allowance_response_deserializes() {
602        let json = r#"{"balance": "141171137", "allowances": {"0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E": "999999"}}"#;
603        let resp: BalanceAllowanceResponse = serde_json::from_str(json).unwrap();
604        assert_eq!(resp.balance, "141171137");
605        assert_eq!(
606            resp.allowances
607                .get("0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E")
608                .unwrap(),
609            "999999"
610        );
611    }
612}