Skip to main content

polyoxide_clob/api/
account.rs

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