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