Skip to main content

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