Skip to main content

polyoxide_clob/
request.rs

1use std::marker::PhantomData;
2
3use alloy::primitives::Address;
4use polyoxide_core::{current_timestamp, request::QueryBuilder, HttpClient};
5use reqwest::{Method, Response};
6use serde::de::DeserializeOwned;
7
8use crate::{
9    account::{Credentials, Signer, Wallet},
10    error::ClobError,
11};
12
13/// Authentication mode for requests
14#[derive(Debug, Clone)]
15pub enum AuthMode {
16    None,
17    L1 {
18        wallet: Wallet,
19        nonce: u32,
20        timestamp: u64,
21    },
22    L2 {
23        address: Address,
24        credentials: Credentials,
25        signer: Signer,
26    },
27}
28
29/// Generic request builder for CLOB API
30pub struct Request<T> {
31    pub(crate) http_client: HttpClient,
32    pub(crate) path: String,
33    pub(crate) method: Method,
34    pub(crate) query: Vec<(String, String)>,
35    pub(crate) body: Option<serde_json::Value>,
36    pub(crate) auth: AuthMode,
37    pub(crate) chain_id: u64,
38    pub(crate) _marker: PhantomData<T>,
39}
40
41impl<T> Request<T> {
42    /// Create a new GET request
43    pub(crate) fn get(
44        http_client: HttpClient,
45        path: impl Into<String>,
46        auth: AuthMode,
47        chain_id: u64,
48    ) -> Self {
49        Self {
50            http_client,
51            path: path.into(),
52            method: Method::GET,
53            query: Vec::new(),
54            body: None,
55            auth,
56            chain_id,
57            _marker: PhantomData,
58        }
59    }
60
61    /// Create a new POST request
62    pub(crate) fn post(
63        http_client: HttpClient,
64        path: String,
65        auth: AuthMode,
66        chain_id: u64,
67    ) -> Self {
68        Self {
69            http_client,
70            path,
71            method: Method::POST,
72            query: Vec::new(),
73            body: None,
74            auth,
75            chain_id,
76            _marker: PhantomData,
77        }
78    }
79
80    /// Create a new DELETE request
81    pub(crate) fn delete(
82        http_client: HttpClient,
83        path: impl Into<String>,
84        auth: AuthMode,
85        chain_id: u64,
86    ) -> Self {
87        Self {
88            http_client,
89            path: path.into(),
90            method: Method::DELETE,
91            query: Vec::new(),
92            body: None,
93            auth,
94            chain_id,
95            _marker: PhantomData,
96        }
97    }
98
99    /// Set request body
100    pub fn body<B: serde::Serialize>(mut self, body: &B) -> Result<Self, ClobError> {
101        self.body = Some(serde_json::to_value(body)?);
102        Ok(self)
103    }
104}
105
106impl<T> QueryBuilder for Request<T> {
107    fn add_query(&mut self, key: String, value: String) {
108        self.query.push((key, value));
109    }
110}
111
112impl<T: DeserializeOwned> Request<T> {
113    /// Execute the request and deserialize response
114    pub async fn send(self) -> Result<T, ClobError> {
115        let response = self.send_raw().await?;
116
117        let text = response.text().await?;
118
119        // Deserialize and provide better error context
120        serde_json::from_str(&text).map_err(|e| {
121            tracing::error!("Deserialization failed: {}", e);
122            tracing::error!("Failed to deserialize: {}", text);
123            e.into()
124        })
125    }
126
127    /// Execute the request and return raw response
128    pub async fn send_raw(self) -> Result<Response, ClobError> {
129        let url = self.http_client.base_url.join(&self.path)?;
130
131        let http_client = self.http_client;
132        let path = self.path;
133        let method = self.method;
134        let query = self.query;
135        let body = self.body;
136        let auth = self.auth;
137        let chain_id = self.chain_id;
138        let mut attempt = 0u32;
139
140        loop {
141            http_client.acquire_rate_limit(&path, Some(&method)).await;
142
143            // Build the base request — rebuilt each iteration so auth timestamps are fresh
144            let mut request = match method {
145                Method::GET => http_client.client.get(url.clone()),
146                Method::POST => {
147                    let mut req = http_client.client.post(url.clone());
148                    if let Some(ref body) = body {
149                        req = req.header("Content-Type", "application/json").json(body);
150                    }
151                    req
152                }
153                Method::DELETE => {
154                    let mut req = http_client.client.delete(url.clone());
155                    if let Some(ref body) = body {
156                        req = req.header("Content-Type", "application/json").json(body);
157                    }
158                    req
159                }
160                _ => return Err(ClobError::validation("Unsupported HTTP method")),
161            };
162
163            // Add query parameters
164            if !query.is_empty() {
165                request = request.query(&query);
166            }
167
168            // Add authentication headers (fresh timestamp on each attempt)
169            request = add_auth_headers(request, &auth, &path, &method, &body, chain_id).await?;
170
171            // Execute request
172            let response = request.send().await?;
173            let status = response.status();
174
175            if let Some(backoff) = http_client.should_retry(status, attempt) {
176                attempt += 1;
177                tracing::warn!(
178                    "Rate limited (429) on {}, retry {} after {}ms",
179                    path,
180                    attempt,
181                    backoff.as_millis()
182                );
183                tokio::time::sleep(backoff).await;
184                continue;
185            }
186
187            tracing::debug!("Response status: {}", status);
188
189            if !status.is_success() {
190                let error = ClobError::from_response(response).await;
191                tracing::error!("Request failed: {:?}", error);
192                return Err(error);
193            }
194
195            return Ok(response);
196        }
197    }
198}
199
200/// Add authentication headers based on auth mode (free function for retry loop)
201async fn add_auth_headers(
202    mut request: reqwest::RequestBuilder,
203    auth: &AuthMode,
204    path: &str,
205    method: &Method,
206    body: &Option<serde_json::Value>,
207    chain_id: u64,
208) -> Result<reqwest::RequestBuilder, ClobError> {
209    match auth {
210        AuthMode::None => Ok(request),
211        AuthMode::L1 {
212            wallet,
213            nonce,
214            timestamp,
215        } => {
216            use crate::core::eip712::sign_clob_auth;
217
218            let signature = sign_clob_auth(wallet.signer(), chain_id, *timestamp, *nonce).await?;
219
220            request = request
221                .header("POLY_ADDRESS", format!("{:?}", wallet.address()))
222                .header("POLY_SIGNATURE", signature)
223                .header("POLY_TIMESTAMP", timestamp.to_string())
224                .header("POLY_NONCE", nonce.to_string());
225
226            Ok(request)
227        }
228        AuthMode::L2 {
229            address,
230            credentials,
231            signer,
232        } => {
233            let timestamp = current_timestamp();
234            let body_str = body.as_ref().map(|b| b.to_string());
235            let message =
236                Signer::create_message(timestamp, method.as_str(), path, body_str.as_deref());
237            let signature = signer.sign(&message)?;
238
239            request = request
240                .header("POLY_ADDRESS", format!("{:?}", address))
241                .header("POLY_SIGNATURE", signature)
242                .header("POLY_TIMESTAMP", timestamp.to_string())
243                .header("POLY_API_KEY", &credentials.key)
244                .header("POLY_PASSPHRASE", &credentials.passphrase);
245
246            Ok(request)
247        }
248    }
249}