bpx_api_client/
lib.rs

1//! Backpack Exchange API Client
2//!
3//! This module provides the `BpxClient` for interacting with the Backpack Exchange API.
4//! It includes functionality for authenticated and public endpoints,
5//! along with utilities for error handling, request signing, and response processing.
6//!
7//! ## Features
8//! - Request signing and authentication using ED25519 signatures.
9//! - Supports both REST and WebSocket endpoints.
10//! - Includes modules for managing capital, orders, trades, and user data.
11//!
12//! ## Example
13//! ```no_run
14//! # // We depend on tokio only when the `ws` feature is enabled.
15//! # #[cfg(feature = "ws")]
16//! # {
17//! use bpx_api_client::{BACKPACK_API_BASE_URL, BpxClient};
18//!
19//! #[tokio::main]
20//! async fn main() {
21//!     let base_url = BACKPACK_API_BASE_URL.to_string();
22//!     let secret = "your_api_secret_here";
23//!     let headers = None;
24//!
25//!     let client = BpxClient::init(base_url, secret, headers)
26//!         .expect("Failed to initialize Backpack API client");
27//!
28//!     match client.get_open_orders(Some("SOL_USDC")).await {
29//!         Ok(orders) => println!("Open Orders: {:?}", orders),
30//!         Err(err) => tracing::error!("Error: {:?}", err),
31//!     }
32//! }
33//! # }
34//! ```
35
36use base64::{Engine, engine::general_purpose::STANDARD};
37use ed25519_dalek::{Signature, Signer, SigningKey};
38use reqwest::{IntoUrl, Method, Request, Response, StatusCode, Url, header::CONTENT_TYPE};
39use routes::{
40    account::{API_ACCOUNT, API_ACCOUNT_CONVERT_DUST, API_ACCOUNT_MAX_BORROW, API_ACCOUNT_MAX_WITHDRAWAL},
41    borrow_lend::API_BORROW_LEND_POSITIONS,
42    capital::{API_CAPITAL, API_COLLATERAL, API_DEPOSIT_ADDRESS, API_DEPOSITS, API_WITHDRAWALS},
43    futures::API_FUTURES_POSITION,
44    history::API_FILLS_HISTORY,
45    order::{API_ORDER, API_ORDERS},
46    rfq::{API_RFQ, API_RFQ_QUOTE},
47    user::API_USER_2FA,
48};
49use serde::Serialize;
50use serde_json::Value;
51use std::{
52    borrow::Cow,
53    collections::BTreeMap,
54    time::{SystemTime, UNIX_EPOCH},
55};
56
57pub mod error;
58
59mod routes;
60
61#[cfg(feature = "ws")]
62mod ws;
63
64/// Re-export of the Backpack Exchange API types.
65pub use bpx_api_types as types;
66
67/// Re-export of the custom `Error` type and `Result` alias for error handling.
68pub use error::{Error, Result};
69
70const API_USER_AGENT: &str = "bpx-rust-client";
71const API_KEY_HEADER: &str = "X-API-Key";
72
73const DEFAULT_WINDOW: u32 = 5000;
74
75const SIGNATURE_HEADER: &str = "X-Signature";
76const TIMESTAMP_HEADER: &str = "X-Timestamp";
77const WINDOW_HEADER: &str = "X-Window";
78
79const JSON_CONTENT: &str = "application/json; charset=utf-8";
80
81/// The official base URL for the Backpack Exchange REST API.
82pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
83
84/// The official WebSocket URL for real-time data from the Backpack Exchange.
85pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
86
87/// Type alias for custom HTTP headers passed to `BpxClient` during initialization.
88pub type BpxHeaders = reqwest::header::HeaderMap;
89
90/// A client for interacting with the Backpack Exchange API.
91#[derive(Debug, Clone)]
92pub struct BpxClient {
93    signing_key: Option<SigningKey>,
94    base_url: Url,
95    #[cfg_attr(not(feature = "ws"), allow(dead_code))]
96    ws_url: Url,
97    client: reqwest::Client,
98}
99
100impl std::ops::Deref for BpxClient {
101    type Target = reqwest::Client;
102
103    fn deref(&self) -> &Self::Target {
104        &self.client
105    }
106}
107
108impl std::ops::DerefMut for BpxClient {
109    fn deref_mut(&mut self) -> &mut Self::Target {
110        &mut self.client
111    }
112}
113
114impl AsRef<reqwest::Client> for BpxClient {
115    fn as_ref(&self) -> &reqwest::Client {
116        &self.client
117    }
118}
119
120// Public functions.
121impl BpxClient {
122    pub fn builder() -> BpxClientBuilder {
123        BpxClientBuilder::new()
124    }
125
126    /// Initializes a new client with the given base URL, API secret, and optional headers.
127    ///
128    /// This sets up the signing and verification keys, and creates a `reqwest` client
129    /// with default headers including the API key and content type.
130    pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
131        BpxClientBuilder::new()
132            .base_url(base_url)
133            .secret(secret)
134            .headers(headers.unwrap_or_default())
135            .build()
136    }
137
138    /// Initializes a new client with WebSocket support.
139    #[cfg(feature = "ws")]
140    #[deprecated(note = "Use BpxClient::builder() instead to configure the client with a custom websocket URL.")]
141    pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
142        BpxClientBuilder::new()
143            .base_url(base_url)
144            .ws_url(ws_url)
145            .secret(secret)
146            .headers(headers.unwrap_or_default())
147            .build()
148    }
149
150    /// Processes the response to check for HTTP errors and extracts
151    /// the response content.
152    ///
153    /// Returns a custom error if the status code is non-2xx.
154    async fn process_response(res: Response) -> Result<Response> {
155        if let Err(e) = res.error_for_status_ref() {
156            let err_text = res.text().await?;
157            let err = Error::BpxApiError {
158                status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
159                message: err_text.into(),
160            };
161            return Err(err);
162        }
163        Ok(res)
164    }
165
166    /// Sends a GET request to the specified URL and signs it before execution.
167    pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
168        let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
169        tracing::debug!(?req, "GET request");
170        let res = self.client.execute(req).await?;
171        Self::process_response(res).await
172    }
173
174    /// Sends a POST request with a JSON payload to the specified URL and signs it.
175    pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
176        let req = self.build_and_maybe_sign_request(url, Method::POST, Some(&payload))?;
177        tracing::debug!(?req, "POST request");
178        let res = self.client.execute(req).await?;
179        Self::process_response(res).await
180    }
181
182    /// Sends a DELETE request with a JSON payload to the specified URL and signs it.
183    pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
184        let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
185        tracing::debug!(?req, "DELETE request");
186        let res = self.client.execute(req).await?;
187        Self::process_response(res).await
188    }
189
190    /// Sends a PATCH request with a JSON payload to the specified URL and signs it.
191    pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
192        let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
193        tracing::debug!(?req, "PATCH request");
194        let res = self.client.execute(req).await?;
195        Self::process_response(res).await
196    }
197
198    /// Returns a reference to the underlying HTTP client.
199    pub const fn client(&self) -> &reqwest::Client {
200        &self.client
201    }
202}
203
204// Private functions.
205impl BpxClient {
206    /// Signs a request by generating a signature from the request details
207    /// and appending necessary headers for authentication.
208    ///
209    /// # Arguments
210    /// * `req` - The mutable reference to the request to be signed.
211    fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
212        &self,
213        url: U,
214        method: Method,
215        payload: Option<&P>,
216    ) -> Result<Request> {
217        let url = url.into_url()?;
218        let instruction = match url.path() {
219            API_CAPITAL if method == Method::GET => "balanceQuery",
220            API_DEPOSITS if method == Method::GET => "depositQueryAll",
221            API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
222            API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
223            API_WITHDRAWALS if method == Method::POST => "withdraw",
224            API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
225            API_ORDER if method == Method::GET => "orderQuery",
226            API_ORDER if method == Method::POST => "orderExecute",
227            API_ORDER if method == Method::DELETE => "orderCancel",
228            API_ORDERS if method == Method::GET => "orderQueryAll",
229            API_ORDERS if method == Method::DELETE => "orderCancelAll",
230            API_RFQ if method == Method::POST => "rfqSubmit",
231            API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
232            API_FUTURES_POSITION if method == Method::GET => "positionQuery",
233            API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
234            API_COLLATERAL if method == Method::GET => "collateralQuery",
235            API_ACCOUNT if method == Method::GET => "accountQuery",
236            API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
237            API_ACCOUNT_MAX_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
238            API_ACCOUNT if method == Method::PATCH => "accountUpdate",
239            API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
240            API_FILLS_HISTORY if method == Method::GET => "fillHistoryQueryAll",
241            _ => {
242                let req = self.client().request(method, url);
243                if let Some(payload) = payload {
244                    return Ok(req.json(payload).build()?);
245                } else {
246                    return Ok(req.build()?);
247                }
248            }
249        };
250
251        let Some(signing_key) = &self.signing_key else {
252            return Err(Error::NotAuthenticated);
253        };
254
255        let query_params = url.query_pairs().collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
256        let body_params = if let Some(payload) = payload {
257            let s = serde_json::to_value(payload)?;
258            match s {
259                Value::Object(map) => map
260                    .into_iter()
261                    .map(|(k, v)| (k, v.to_string()))
262                    .collect::<BTreeMap<_, _>>(),
263                _ => return Err(Error::InvalidRequest("payload must be a JSON object".into())),
264            }
265        } else {
266            BTreeMap::new()
267        };
268
269        let timestamp = now_millis();
270        let mut signee = format!("instruction={instruction}");
271        for (k, v) in query_params {
272            signee.push_str(&format!("&{k}={v}"));
273        }
274        for (k, v) in body_params {
275            let v = v.trim_start_matches('"').trim_end_matches('"');
276            signee.push_str(&format!("&{k}={v}"));
277        }
278        signee.push_str(&format!("&timestamp={timestamp}&window={DEFAULT_WINDOW}"));
279        tracing::debug!("signee: {}", signee);
280
281        let signature: Signature = signing_key.sign(signee.as_bytes());
282        let signature = STANDARD.encode(signature.to_bytes());
283
284        let mut req = self.client().request(method, url);
285        if let Some(payload) = payload {
286            req = req.json(payload);
287        }
288        let mut req = req.build()?;
289        req.headers_mut().insert(SIGNATURE_HEADER, signature.parse()?);
290        req.headers_mut()
291            .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
292        req.headers_mut()
293            .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
294        if matches!(req.method(), &Method::POST | &Method::DELETE) {
295            req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
296        }
297        Ok(req)
298    }
299}
300
301#[derive(Debug, Default)]
302pub struct BpxClientBuilder {
303    base_url: Option<String>,
304    ws_url: Option<String>,
305    secret: Option<String>,
306    headers: Option<BpxHeaders>,
307}
308
309impl BpxClientBuilder {
310    pub fn new() -> Self {
311        Default::default()
312    }
313
314    /// Sets the base URL for the Backpack Exchange API.
315    /// If not set, defaults to `BACKPACK_API_BASE_URL`.
316    ///
317    /// # Arguments
318    /// * `base_url` - The base URL
319    ///
320    /// # Returns
321    /// * `Self` - The updated builder instance
322    pub fn base_url(mut self, base_url: impl ToString) -> Self {
323        self.base_url = Some(base_url.to_string());
324        self
325    }
326
327    /// Sets the WebSocket URL for the Backpack Exchange API.
328    /// If not set, defaults to `BACKPACK_WS_URL`.
329    ///
330    /// # Arguments
331    /// * `ws_url` - The WebSocket URL
332    ///
333    /// # Returns
334    /// * `Self` - The updated builder instance
335    pub fn ws_url(mut self, ws_url: impl ToString) -> Self {
336        self.ws_url = Some(ws_url.to_string());
337        self
338    }
339
340    /// Sets the API secret for signing requests.
341    /// If not set, the client will be unauthenticated.
342    ///
343    /// # Arguments
344    /// * `secret` - The API secret
345    ///
346    /// # Returns
347    /// * `Self` - The updated builder instance
348    pub fn secret(mut self, secret: impl ToString) -> Self {
349        self.secret = Some(secret.to_string());
350        self
351    }
352
353    /// Sets custom HTTP headers for the client.
354    /// If not set, no additional headers will be included.
355    ///
356    /// # Arguments
357    /// * `headers` - The custom HTTP headers
358    ///
359    /// # Returns
360    /// * `Self` - The updated builder instance
361    pub fn headers(mut self, headers: BpxHeaders) -> Self {
362        self.headers = Some(headers);
363        self
364    }
365
366    /// Builds the `BpxClient` instance with the configured parameters.
367    ///
368    /// # Returns
369    /// * `Result<BpxClient>` - The constructed client or an error if building fails
370    pub fn build(self) -> Result<BpxClient> {
371        let base_url = self
372            .base_url
373            .map(Cow::from)
374            .unwrap_or_else(|| Cow::Borrowed(BACKPACK_API_BASE_URL));
375
376        let base_url = Url::parse(&base_url).map_err(|e| Error::UrlParseError(e.to_string().into()))?;
377
378        let ws_url = self
379            .ws_url
380            .map(Cow::from)
381            .unwrap_or_else(|| Cow::Borrowed(BACKPACK_WS_URL));
382
383        let ws_url = Url::parse(&ws_url).map_err(|e| Error::UrlParseError(e.to_string().into()))?;
384
385        let signing_key = if let Some(secret) = self.secret {
386            Some(
387                STANDARD
388                    .decode(secret)?
389                    .try_into()
390                    .map(|s| SigningKey::from_bytes(&s))
391                    .map_err(|_| Error::SecretKey)?,
392            )
393        } else {
394            None
395        };
396
397        let mut header_map = BpxHeaders::new();
398        if let Some(headers) = self.headers {
399            header_map.extend(headers);
400        }
401
402        header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
403        if let Some(signing_key) = &signing_key {
404            let verifier = signing_key.verifying_key();
405            header_map.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
406        }
407
408        let client = BpxClient {
409            signing_key,
410            base_url,
411            ws_url,
412            client: reqwest::Client::builder()
413                .user_agent(API_USER_AGENT)
414                .default_headers(header_map)
415                .build()?,
416        };
417
418        Ok(client)
419    }
420}
421
422/// Returns the current time in milliseconds since UNIX epoch.
423fn now_millis() -> u64 {
424    SystemTime::now()
425        .duration_since(UNIX_EPOCH)
426        .expect("Time went backwards")
427        .as_millis() as u64
428}