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