o2_sdk/
api.rs

1/// REST API client for O2 Exchange.
2///
3/// Typed wrappers for every REST endpoint from the O2 API reference.
4/// Uses reqwest for HTTP with JSON support.
5use std::any::type_name;
6
7use log::debug;
8use reqwest::Client;
9use serde_json::json;
10
11use crate::config::NetworkConfig;
12use crate::errors::O2Error;
13use crate::models::*;
14
15/// Low-level REST API client for the O2 Exchange.
16#[derive(Debug, Clone)]
17pub struct O2Api {
18    client: Client,
19    config: NetworkConfig,
20}
21
22impl O2Api {
23    /// Create a new API client with the given network configuration.
24    pub fn new(config: NetworkConfig) -> Self {
25        Self {
26            client: Client::new(),
27            config,
28        }
29    }
30
31    /// Parse an API response, detecting error codes and returning typed errors.
32    async fn parse_response<T: serde::de::DeserializeOwned>(
33        &self,
34        response: reqwest::Response,
35    ) -> Result<T, O2Error> {
36        let status = response.status();
37        let text = response.text().await?;
38        let target_type = type_name::<T>();
39        debug!(
40            "api.parse_response status={} target_type={} body_len={}",
41            status,
42            target_type,
43            text.len()
44        );
45
46        if !status.is_success() {
47            debug!(
48                "api.parse_response non_success status={} body={}",
49                status, text
50            );
51            // Try to parse as API error
52            if let Ok(err) = serde_json::from_str::<serde_json::Value>(&text) {
53                if let Some(code) = err.get("code").and_then(|c| c.as_u64()) {
54                    let raw_message = err
55                        .get("message")
56                        .or_else(|| err.get("error"))
57                        .and_then(|m| m.as_str())
58                        .unwrap_or("Unknown error");
59                    // Augment revert messages even on code-based errors —
60                    // the backend sometimes returns code=1000 with revert
61                    // info in the message field.
62                    let message = if raw_message.contains("Revert")
63                        || raw_message.contains("revert")
64                        || raw_message.contains("Panic")
65                    {
66                        let reason = err.get("reason").and_then(|r| r.as_str()).unwrap_or("");
67                        let receipts = err.get("receipts").cloned();
68                        crate::onchain_revert::augment_revert_reason(
69                            raw_message,
70                            reason,
71                            receipts.as_ref(),
72                        )
73                    } else {
74                        raw_message.to_string()
75                    };
76                    return Err(O2Error::from_code(code as u32, message));
77                }
78                if let Some(message) = err
79                    .get("message")
80                    .or_else(|| err.get("error"))
81                    .and_then(|m| m.as_str())
82                {
83                    let raw_reason = err.get("reason").and_then(|r| r.as_str()).unwrap_or("");
84                    let receipts = err.get("receipts").cloned();
85                    let has_receipts = receipts.as_ref().is_some_and(|v| !v.is_null());
86                    let has_revert_evidence = raw_reason.contains("Revert")
87                        || raw_reason.to_lowercase().contains("receipt")
88                        || message.to_lowercase().contains("transaction");
89
90                    // Only classify as OnChainRevert when there's evidence of
91                    // an on-chain transaction.  Plain API errors (e.g. analytics
92                    // 500) should be generic HttpError, not OnChainRevert.
93                    if has_receipts || has_revert_evidence {
94                        let reason = crate::onchain_revert::augment_revert_reason(
95                            message,
96                            raw_reason,
97                            receipts.as_ref(),
98                        );
99                        return Err(O2Error::OnChainRevert {
100                            message: message.to_string(),
101                            reason,
102                            receipts,
103                        });
104                    }
105
106                    return Err(O2Error::HttpError(format!("HTTP {}: {}", status, message)));
107                }
108            }
109            return Err(O2Error::HttpError(format!("HTTP {}: {}", status, text)));
110        }
111
112        match serde_json::from_str(&text) {
113            Ok(parsed) => {
114                debug!("api.parse_response decode_ok target_type={}", target_type);
115                Ok(parsed)
116            }
117            Err(e) => {
118                debug!(
119                    "api.parse_response decode_failed target_type={} error={}",
120                    target_type, e
121                );
122                Err(O2Error::JsonError(format!(
123                    "Failed to parse response: {e}\nBody: {}",
124                    &text[..text.len().min(500)]
125                )))
126            }
127        }
128    }
129
130    // -----------------------------------------------------------------------
131    // Market Data
132    // -----------------------------------------------------------------------
133
134    /// GET /v1/markets - List all markets.
135    pub async fn get_markets(&self) -> Result<MarketsResponse, O2Error> {
136        debug!("api.get_markets");
137        let url = format!("{}/v1/markets", self.config.api_base);
138        let resp = self.client.get(&url).send().await?;
139        self.parse_response(resp).await
140    }
141
142    /// GET /v1/markets/summary - 24-hour market statistics.
143    pub async fn get_market_summary(&self, market_id: &str) -> Result<Vec<MarketSummary>, O2Error> {
144        debug!("api.get_market_summary market_id={}", market_id);
145        let url = format!("{}/v1/markets/summary", self.config.api_base);
146        let resp = self
147            .client
148            .get(&url)
149            .query(&[("market_id", market_id)])
150            .send()
151            .await?;
152        self.parse_response(resp).await
153    }
154
155    /// GET /v1/markets/ticker - Real-time ticker data.
156    pub async fn get_market_ticker(&self, market_id: &str) -> Result<Vec<MarketTicker>, O2Error> {
157        debug!("api.get_market_ticker market_id={}", market_id);
158        let url = format!("{}/v1/markets/ticker", self.config.api_base);
159        let resp = self
160            .client
161            .get(&url)
162            .query(&[("market_id", market_id)])
163            .send()
164            .await?;
165        self.parse_response(resp).await
166    }
167
168    // -----------------------------------------------------------------------
169    // Depth
170    // -----------------------------------------------------------------------
171
172    /// GET /v1/depth - Order book depth.
173    pub async fn get_depth(
174        &self,
175        market_id: &str,
176        precision: u64,
177        limit: Option<usize>,
178    ) -> Result<DepthSnapshot, O2Error> {
179        debug!(
180            "api.get_depth market_id={} precision={} limit={:?}",
181            market_id, precision, limit
182        );
183        let url = format!("{}/v1/depth", self.config.api_base);
184        let precision_str = precision.to_string();
185        let mut pairs: Vec<(&str, String)> = vec![
186            ("market_id", market_id.to_string()),
187            ("precision", precision_str),
188        ];
189        if let Some(lim) = limit {
190            pairs.push(("limit", lim.to_string()));
191        }
192        let resp = self
193            .client
194            .get(&url)
195            .query(
196                &pairs
197                    .iter()
198                    .map(|(k, v)| (*k, v.as_str()))
199                    .collect::<Vec<_>>(),
200            )
201            .send()
202            .await?;
203        let val: serde_json::Value = self.parse_response(resp).await?;
204        // API wraps depth in "orders" or "view" field; unwrap it
205        let depth = val
206            .get("orders")
207            .or_else(|| val.get("view"))
208            .unwrap_or(&val);
209        let mut snapshot: DepthSnapshot = serde_json::from_value(depth.clone())
210            .map_err(|e| O2Error::JsonError(format!("Failed to parse depth: {e}")))?;
211        // Client-side truncation: honour the limit even if the backend
212        // doesn't support it yet.
213        if let Some(lim) = limit {
214            snapshot.bids.truncate(lim);
215            snapshot.asks.truncate(lim);
216        }
217        Ok(snapshot)
218    }
219
220    // -----------------------------------------------------------------------
221    // Trades
222    // -----------------------------------------------------------------------
223
224    /// GET /v1/trades - Recent trade history.
225    pub async fn get_trades(
226        &self,
227        market_id: &str,
228        direction: &str,
229        count: u32,
230        start_timestamp: Option<u64>,
231        start_trade_id: Option<&str>,
232        contract: Option<&str>,
233    ) -> Result<TradesResponse, O2Error> {
234        debug!(
235            "api.get_trades market_id={} direction={} count={} contract={:?}",
236            market_id, direction, count, contract
237        );
238        let url = format!("{}/v1/trades", self.config.api_base);
239        let count_str = count.to_string();
240        let start_timestamp_str = start_timestamp.map(|ts| ts.to_string());
241        let mut query: Vec<(&str, &str)> = vec![
242            ("market_id", market_id),
243            ("direction", direction),
244            ("count", count_str.as_str()),
245        ];
246        if let Some(ts) = start_timestamp_str.as_deref() {
247            query.push(("start_timestamp", ts));
248        }
249        if let Some(tid) = start_trade_id {
250            query.push(("start_trade_id", tid));
251        }
252        if let Some(c) = contract {
253            query.push(("contract", c));
254        }
255        let resp = self.client.get(&url).query(&query).send().await?;
256        self.parse_response(resp).await
257    }
258
259    /// GET /v1/trades_by_account - Trades by account.
260    pub async fn get_trades_by_account(
261        &self,
262        market_id: &str,
263        contract: &str,
264        direction: &str,
265        count: u32,
266        start_timestamp: Option<u64>,
267        start_trade_id: Option<&str>,
268    ) -> Result<TradesResponse, O2Error> {
269        debug!(
270            "api.get_trades_by_account market_id={} contract={} direction={} count={}",
271            market_id, contract, direction, count
272        );
273        let url = format!("{}/v1/trades_by_account", self.config.api_base);
274        let count_str = count.to_string();
275        let start_timestamp_str = start_timestamp.map(|ts| ts.to_string());
276        let mut query: Vec<(&str, &str)> = vec![
277            ("market_id", market_id),
278            ("contract", contract),
279            ("direction", direction),
280            ("count", count_str.as_str()),
281        ];
282        if let Some(ts) = start_timestamp_str.as_deref() {
283            query.push(("start_timestamp", ts));
284        }
285        if let Some(tid) = start_trade_id {
286            query.push(("start_trade_id", tid));
287        }
288        let resp = self.client.get(&url).query(&query).send().await?;
289        self.parse_response(resp).await
290    }
291
292    /// Valid bar resolutions accepted by the API.
293    const VALID_RESOLUTIONS: &'static [&'static str] = &[
294        "1s", "1m", "2m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d",
295        "3d", "1w", "1M", "3M",
296    ];
297
298    /// GET /v1/bars - OHLCV candlestick data.
299    ///
300    /// `from_ts` and `to_ts` are in **milliseconds** (not seconds).
301    /// `resolution` must be one of: `1s`, `1m`, `2m`, `3m`, `5m`, `15m`, `30m`,
302    /// `1h`, `2h`, `4h`, `6h`, `8h`, `12h`, `1d`, `3d`, `1w`, `1M`, `3M`.
303    pub async fn get_bars(
304        &self,
305        market_id: &str,
306        from_ts: u64,
307        to_ts: u64,
308        resolution: &str,
309    ) -> Result<Vec<Bar>, O2Error> {
310        if !Self::VALID_RESOLUTIONS.contains(&resolution) {
311            return Err(O2Error::InvalidRequest(format!(
312                "Invalid bar resolution \"{resolution}\". Valid values: {:?}",
313                Self::VALID_RESOLUTIONS
314            )));
315        }
316        debug!(
317            "api.get_bars market_id={} from_ts={} to_ts={} resolution={}",
318            market_id, from_ts, to_ts, resolution
319        );
320        let url = format!("{}/v1/bars", self.config.api_base);
321        let from_ts_str = from_ts.to_string();
322        let to_ts_str = to_ts.to_string();
323        let resp = self
324            .client
325            .get(&url)
326            .query(&[
327                ("market_id", market_id),
328                ("from", from_ts_str.as_str()),
329                ("to", to_ts_str.as_str()),
330                ("resolution", resolution),
331            ])
332            .send()
333            .await?;
334        let val: serde_json::Value = self.parse_response(resp).await?;
335        let bars_val = val.get("bars").unwrap_or(&val);
336        serde_json::from_value(bars_val.clone())
337            .map_err(|e| O2Error::JsonError(format!("Failed to parse bars: {e}")))
338    }
339
340    // -----------------------------------------------------------------------
341    // Account & Balance
342    // -----------------------------------------------------------------------
343
344    /// POST /v1/accounts - Create a trading account.
345    pub async fn create_account(
346        &self,
347        owner_address: &str,
348    ) -> Result<CreateAccountResponse, O2Error> {
349        debug!("api.create_account owner_address={}", owner_address);
350        let url = format!("{}/v1/accounts", self.config.api_base);
351        let body = json!({
352            "identity": {
353                "Address": owner_address
354            }
355        });
356        let resp = self
357            .client
358            .post(&url)
359            .header("Content-Type", "application/json")
360            .json(&body)
361            .send()
362            .await?;
363        self.parse_response(resp).await
364    }
365
366    /// GET /v1/accounts - Get account info by owner address.
367    pub async fn get_account_by_owner(&self, owner: &str) -> Result<AccountResponse, O2Error> {
368        debug!("api.get_account_by_owner owner={}", owner);
369        let url = format!("{}/v1/accounts", self.config.api_base);
370        let resp = self
371            .client
372            .get(&url)
373            .query(&[("owner", owner)])
374            .send()
375            .await?;
376        self.parse_response(resp).await
377    }
378
379    /// GET /v1/accounts - Get account info by trade_account_id.
380    pub async fn get_account_by_id(
381        &self,
382        trade_account_id: &str,
383    ) -> Result<AccountResponse, O2Error> {
384        debug!(
385            "api.get_account_by_id trade_account_id={}",
386            trade_account_id
387        );
388        let url = format!("{}/v1/accounts", self.config.api_base);
389        let resp = self
390            .client
391            .get(&url)
392            .query(&[("trade_account_id", trade_account_id)])
393            .send()
394            .await?;
395        self.parse_response(resp).await
396    }
397
398    /// GET /v1/balance - Get asset balance.
399    pub async fn get_balance(
400        &self,
401        asset_id: &str,
402        contract: Option<&str>,
403        address: Option<&str>,
404    ) -> Result<BalanceResponse, O2Error> {
405        debug!(
406            "api.get_balance asset_id={} contract={:?} address={:?}",
407            asset_id, contract, address
408        );
409        let url = format!("{}/v1/balance", self.config.api_base);
410        let mut query: Vec<(&str, &str)> = vec![("asset_id", asset_id)];
411        if let Some(c) = contract {
412            query.push(("contract", c));
413        }
414        if let Some(a) = address {
415            query.push(("address", a));
416        }
417        let resp = self.client.get(&url).query(&query).send().await?;
418        self.parse_response(resp).await
419    }
420
421    // -----------------------------------------------------------------------
422    // Orders
423    // -----------------------------------------------------------------------
424
425    /// GET /v1/orders - Get order history.
426    #[allow(clippy::too_many_arguments)]
427    pub async fn get_orders(
428        &self,
429        market_id: &str,
430        contract: &str,
431        direction: &str,
432        count: u32,
433        is_open: Option<bool>,
434        start_timestamp: Option<u64>,
435        start_order_id: Option<&str>,
436    ) -> Result<OrdersResponse, O2Error> {
437        debug!(
438            "api.get_orders market_id={} contract={} direction={} count={} is_open={:?} start_timestamp={:?} start_order_id={:?}",
439            market_id, contract, direction, count, is_open, start_timestamp, start_order_id
440        );
441        let url = format!("{}/v1/orders", self.config.api_base);
442        let count_str = count.to_string();
443        let is_open_str = is_open.map(|open| open.to_string());
444        let start_timestamp_str = start_timestamp.map(|ts| ts.to_string());
445        let mut query: Vec<(&str, &str)> = vec![
446            ("market_id", market_id),
447            ("contract", contract),
448            ("direction", direction),
449            ("count", count_str.as_str()),
450        ];
451        if let Some(open) = is_open_str.as_deref() {
452            query.push(("is_open", open));
453        }
454        if let Some(ts) = start_timestamp_str.as_deref() {
455            query.push(("start_timestamp", ts));
456        }
457        if let Some(oid) = start_order_id {
458            query.push(("start_order_id", oid));
459        }
460        let resp = self.client.get(&url).query(&query).send().await?;
461        self.parse_response(resp).await
462    }
463
464    /// GET /v1/order - Get a single order.
465    pub async fn get_order(&self, market_id: &str, order_id: &str) -> Result<Order, O2Error> {
466        debug!(
467            "api.get_order market_id={} order_id={}",
468            market_id, order_id
469        );
470        let url = format!("{}/v1/order", self.config.api_base);
471        let resp = self
472            .client
473            .get(&url)
474            .query(&[("market_id", market_id), ("order_id", order_id)])
475            .send()
476            .await?;
477        let val: serde_json::Value = self.parse_response(resp).await?;
478        // API wraps order in an "order" key
479        let order_val = val.get("order").unwrap_or(&val);
480        serde_json::from_value(order_val.clone())
481            .map_err(|e| O2Error::JsonError(format!("Failed to parse order: {e}")))
482    }
483
484    // -----------------------------------------------------------------------
485    // Session Management
486    // -----------------------------------------------------------------------
487
488    /// PUT /v1/session - Create or update a trading session.
489    pub async fn create_session(
490        &self,
491        owner_id: &str,
492        request: &SessionRequest,
493    ) -> Result<SessionResponse, O2Error> {
494        debug!(
495            "api.create_session owner_id={} contract_id={} nonce={} expiry={}",
496            owner_id, request.contract_id, request.nonce, request.expiry
497        );
498        let url = format!("{}/v1/session", self.config.api_base);
499        let resp = self
500            .client
501            .put(&url)
502            .header("Content-Type", "application/json")
503            .header("O2-Owner-Id", owner_id)
504            .json(request)
505            .send()
506            .await?;
507        self.parse_response(resp).await
508    }
509
510    /// POST /v1/session/actions - Execute trading actions.
511    pub(crate) async fn submit_actions(
512        &self,
513        owner_id: &str,
514        request: &SessionActionsRequest,
515    ) -> Result<SessionActionsResponse, O2Error> {
516        debug!(
517            "api.submit_actions owner_id={} nonce={} markets={} collect_orders={:?}",
518            owner_id,
519            request.nonce,
520            request.actions.len(),
521            request.collect_orders
522        );
523        let url = format!("{}/v1/session/actions", self.config.api_base);
524        let resp = self
525            .client
526            .post(&url)
527            .header("Content-Type", "application/json")
528            .header("O2-Owner-Id", owner_id)
529            .json(request)
530            .send()
531            .await?;
532        // Reuse standard status/error handling first; this ensures non-2xx
533        // responses are mapped consistently with the rest of the SDK.
534        let val: serde_json::Value = self.parse_response(resp).await?;
535
536        // Parse as Value first for robustness, then extract fields.
537        // The Order struct can have unexpected field types across API versions,
538        // so we parse orders separately with a fallback.
539        let tx_id = val.get("tx_id").and_then(|v| v.as_str()).map(TxId::from);
540        let code = val.get("code").and_then(|v| v.as_u64()).map(|v| v as u32);
541        let message = val
542            .get("message")
543            .and_then(|v| v.as_str())
544            .map(String::from);
545        let reason = val.get("reason").and_then(|v| v.as_str()).map(String::from);
546        let receipts = val.get("receipts").cloned();
547        let orders = val
548            .get("orders")
549            .and_then(|o| serde_json::from_value::<Vec<Order>>(o.clone()).ok());
550
551        let parsed = SessionActionsResponse {
552            tx_id,
553            orders,
554            code,
555            message,
556            reason,
557            receipts,
558        };
559
560        // Check for errors
561        if parsed.is_success() {
562            debug!("api.submit_actions parsed=success tx_id={:?}", parsed.tx_id);
563            Ok(parsed)
564        } else if parsed.is_preflight_error() {
565            let code = parsed.code.unwrap_or(0);
566            let message = parsed.message.unwrap_or_default();
567            debug!(
568                "api.submit_actions parsed=preflight_error code={} message={}",
569                code, message
570            );
571            Err(O2Error::from_code(code, message))
572        } else if parsed.is_onchain_error() {
573            debug!(
574                "api.submit_actions parsed=onchain_error message={:?} reason={:?}",
575                parsed.message, parsed.reason
576            );
577            let message = parsed.message.unwrap_or_default();
578            let raw_reason = parsed.reason.unwrap_or_default();
579            let reason = crate::onchain_revert::augment_revert_reason(
580                &message,
581                &raw_reason,
582                parsed.receipts.as_ref(),
583            );
584            Err(O2Error::OnChainRevert {
585                message,
586                reason,
587                receipts: parsed.receipts,
588            })
589        } else {
590            // Ambiguous — return as-is for caller to handle
591            debug!("api.submit_actions parsed=ambiguous returning_raw_response");
592            Ok(parsed)
593        }
594    }
595
596    // -----------------------------------------------------------------------
597    // Account Operations
598    // -----------------------------------------------------------------------
599
600    /// POST /v1/accounts/withdraw - Withdraw assets.
601    pub async fn withdraw(
602        &self,
603        owner_id: &str,
604        request: &WithdrawRequest,
605    ) -> Result<WithdrawResponse, O2Error> {
606        debug!(
607            "api.withdraw owner_id={} trade_account_id={} asset_id={} amount={} nonce={}",
608            owner_id, request.trade_account_id, request.asset_id, request.amount, request.nonce
609        );
610        let url = format!("{}/v1/accounts/withdraw", self.config.api_base);
611        let resp = self
612            .client
613            .post(&url)
614            .header("Content-Type", "application/json")
615            .header("O2-Owner-Id", owner_id)
616            .json(request)
617            .send()
618            .await?;
619        self.parse_response(resp).await
620    }
621
622    // -----------------------------------------------------------------------
623    // Analytics
624    // -----------------------------------------------------------------------
625
626    /// POST /analytics/v1/whitelist - Whitelist a trading account.
627    pub async fn whitelist_account(
628        &self,
629        trade_account_id: &str,
630    ) -> Result<WhitelistResponse, O2Error> {
631        debug!(
632            "api.whitelist_account trade_account_id={}",
633            trade_account_id
634        );
635        let url = format!("{}/analytics/v1/whitelist", self.config.api_base);
636        let body = WhitelistRequest {
637            trade_account: trade_account_id.to_string(),
638        };
639        let resp = self
640            .client
641            .post(&url)
642            .header("Content-Type", "application/json")
643            .json(&body)
644            .send()
645            .await?;
646        self.parse_response(resp).await
647    }
648
649    /// GET /analytics/v1/referral/code-info - Look up referral code.
650    pub async fn get_referral_info(&self, code: &str) -> Result<ReferralInfo, O2Error> {
651        debug!("api.get_referral_info code={}", code);
652        let url = format!("{}/analytics/v1/referral/code-info", self.config.api_base);
653        let resp = self
654            .client
655            .get(&url)
656            .query(&[("code", code)])
657            .send()
658            .await?;
659        self.parse_response(resp).await
660    }
661
662    // -----------------------------------------------------------------------
663    // Aggregated Endpoints
664    // -----------------------------------------------------------------------
665
666    /// GET /v1/aggregated/assets - List all trading assets.
667    pub async fn get_aggregated_assets(&self) -> Result<AggregatedAssets, O2Error> {
668        debug!("api.get_aggregated_assets");
669        let url = format!("{}/v1/aggregated/assets", self.config.api_base);
670        let resp = self.client.get(&url).send().await?;
671        self.parse_response(resp).await
672    }
673
674    /// GET /v1/aggregated/orderbook - Order book depth by pair name.
675    pub async fn get_aggregated_orderbook(
676        &self,
677        market_pair: &str,
678        depth: u32,
679        level: u32,
680    ) -> Result<AggregatedOrderbook, O2Error> {
681        debug!(
682            "api.get_aggregated_orderbook market_pair={} depth={} level={}",
683            market_pair, depth, level
684        );
685        let url = format!("{}/v1/aggregated/orderbook", self.config.api_base);
686        let depth_str = depth.to_string();
687        let level_str = level.to_string();
688        let resp = self
689            .client
690            .get(&url)
691            .query(&[
692                ("market_pair", market_pair),
693                ("depth", depth_str.as_str()),
694                ("level", level_str.as_str()),
695            ])
696            .send()
697            .await?;
698        self.parse_response(resp).await
699    }
700
701    /// GET /v1/aggregated/coingecko/orderbook - CoinGecko orderbook depth by ticker ID.
702    pub async fn get_aggregated_coingecko_orderbook(
703        &self,
704        ticker_id: &str,
705        depth: u32,
706    ) -> Result<CoingeckoAggregatedOrderbook, O2Error> {
707        debug!(
708            "api.get_aggregated_coingecko_orderbook ticker_id={} depth={}",
709            ticker_id, depth
710        );
711        let url = format!("{}/v1/aggregated/coingecko/orderbook", self.config.api_base);
712        let depth_str = depth.to_string();
713        let resp = self
714            .client
715            .get(&url)
716            .query(&[("ticker_id", ticker_id), ("depth", depth_str.as_str())])
717            .send()
718            .await?;
719        self.parse_response(resp).await
720    }
721
722    /// GET /v1/aggregated/summary - 24-hour stats for all pairs.
723    pub async fn get_aggregated_summary(&self) -> Result<Vec<PairSummary>, O2Error> {
724        debug!("api.get_aggregated_summary");
725        let url = format!("{}/v1/aggregated/summary", self.config.api_base);
726        let resp = self.client.get(&url).send().await?;
727        self.parse_response(resp).await
728    }
729
730    /// GET /v1/aggregated/ticker - Real-time ticker for all pairs.
731    pub async fn get_aggregated_ticker(&self) -> Result<AggregatedTicker, O2Error> {
732        debug!("api.get_aggregated_ticker");
733        let url = format!("{}/v1/aggregated/ticker", self.config.api_base);
734        let resp = self.client.get(&url).send().await?;
735        self.parse_response(resp).await
736    }
737
738    /// GET /v1/aggregated/coingecko/tickers - CoinGecko ticker format.
739    pub async fn get_aggregated_coingecko_tickers(&self) -> Result<Vec<PairTicker>, O2Error> {
740        debug!("api.get_aggregated_coingecko_tickers");
741        let url = format!("{}/v1/aggregated/coingecko/tickers", self.config.api_base);
742        let resp = self.client.get(&url).send().await?;
743        self.parse_response(resp).await
744    }
745
746    /// GET /v1/aggregated/trades - Recent trades for a pair.
747    pub async fn get_aggregated_trades(
748        &self,
749        market_pair: &str,
750    ) -> Result<Vec<AggregatedTrade>, O2Error> {
751        debug!("api.get_aggregated_trades market_pair={}", market_pair);
752        let url = format!("{}/v1/aggregated/trades", self.config.api_base);
753        let resp = self
754            .client
755            .get(&url)
756            .query(&[("market_pair", market_pair)])
757            .send()
758            .await?;
759        self.parse_response(resp).await
760    }
761
762    // -----------------------------------------------------------------------
763    // Faucet
764    // -----------------------------------------------------------------------
765
766    /// Mint tokens to a wallet address via the faucet (testnet/devnet only).
767    pub async fn mint_to_address(&self, address: &str) -> Result<FaucetResponse, O2Error> {
768        debug!("api.mint_to_address address={}", address);
769        let faucet_url = self
770            .config
771            .faucet_url
772            .as_ref()
773            .ok_or_else(|| O2Error::Other("Faucet not available on this network".into()))?;
774
775        let body = json!({ "address": address });
776        let resp = self
777            .client
778            .post(faucet_url)
779            .header("Content-Type", "application/json")
780            .json(&body)
781            .send()
782            .await?;
783        self.parse_response(resp).await
784    }
785
786    /// Mint tokens directly to a trading account contract via the faucet (testnet/devnet only).
787    pub async fn mint_to_contract(&self, contract_id: &str) -> Result<FaucetResponse, O2Error> {
788        debug!("api.mint_to_contract contract_id={}", contract_id);
789        let faucet_url = self
790            .config
791            .faucet_url
792            .as_ref()
793            .ok_or_else(|| O2Error::Other("Faucet not available on this network".into()))?;
794
795        let body = json!({ "contract": contract_id });
796        let resp = self
797            .client
798            .post(faucet_url)
799            .header("Content-Type", "application/json")
800            .json(&body)
801            .send()
802            .await?;
803        self.parse_response(resp).await
804    }
805}