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