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::general_purpose::STANDARD, Engine};
37use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
38use reqwest::{header::CONTENT_TYPE, IntoUrl, Method, Request, Response, StatusCode};
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_DEPOSITS, API_DEPOSIT_ADDRESS, 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    signer: SigningKey,
94    verifier: VerifyingKey,
95    base_url: String,
96    #[allow(dead_code)]
97    ws_url: Option<String>,
98    client: reqwest::Client,
99}
100
101impl std::ops::Deref for BpxClient {
102    type Target = reqwest::Client;
103
104    fn deref(&self) -> &Self::Target {
105        &self.client
106    }
107}
108
109impl std::ops::DerefMut for BpxClient {
110    fn deref_mut(&mut self) -> &mut Self::Target {
111        &mut self.client
112    }
113}
114
115impl AsRef<reqwest::Client> for BpxClient {
116    fn as_ref(&self) -> &reqwest::Client {
117        &self.client
118    }
119}
120
121// Public functions.
122impl BpxClient {
123    /// Initializes a new client with the given base URL, API secret, and optional headers.
124    ///
125    /// This sets up the signing and verification keys, and creates a `reqwest` client
126    /// with default headers including the API key and content type.
127    pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
128        Self::init_internal(base_url, None, secret, headers)
129    }
130
131    /// Initializes a new client with WebSocket support.
132    #[cfg(feature = "ws")]
133    pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
134        Self::init_internal(base_url, Some(ws_url), secret, headers)
135    }
136
137    /// Internal helper function for client initialization.
138    fn init_internal(
139        base_url: String,
140        ws_url: Option<String>,
141        secret: &str,
142        headers: Option<BpxHeaders>,
143    ) -> Result<Self> {
144        let signer = STANDARD
145            .decode(secret)?
146            .try_into()
147            .map(|s| SigningKey::from_bytes(&s))
148            .map_err(|_| Error::SecretKey)?;
149
150        let verifier = signer.verifying_key();
151
152        let mut headers = headers.unwrap_or_default();
153        headers.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
154        headers.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
155
156        let client = reqwest::Client::builder()
157            .user_agent(API_USER_AGENT)
158            .default_headers(headers)
159            .build()?;
160
161        Ok(BpxClient {
162            signer,
163            verifier,
164            base_url,
165            ws_url,
166            client,
167        })
168    }
169
170    /// Creates a new, empty `BpxHeaders` instance.
171    pub fn create_headers() -> BpxHeaders {
172        reqwest::header::HeaderMap::new()
173    }
174
175    /// Processes the response to check for HTTP errors and extracts
176    /// the response content.
177    ///
178    /// Returns a custom error if the status code is non-2xx.
179    async fn process_response(res: Response) -> Result<Response> {
180        if let Err(e) = res.error_for_status_ref() {
181            let err_text = res.text().await?;
182            let err = Error::BpxApiError {
183                status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
184                message: err_text.into(),
185            };
186            return Err(err);
187        }
188        Ok(res)
189    }
190
191    /// Sends a GET request to the specified URL and signs it before execution.
192    pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
193        let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
194        tracing::debug!("req: {:?}", req);
195        let res = self.client.execute(req).await?;
196        Self::process_response(res).await
197    }
198
199    /// Sends a POST request with a JSON payload to the specified URL and signs it.
200    pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
201        let req = self.build_and_maybe_sign_request(url, Method::POST, Some(&payload))?;
202        tracing::debug!("req: {:?}", req);
203        let res = self.client.execute(req).await?;
204        Self::process_response(res).await
205    }
206
207    /// Sends a DELETE request with a JSON payload to the specified URL and signs it.
208    pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
209        let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
210        tracing::debug!("req: {:?}", req);
211        let res = self.client.execute(req).await?;
212        Self::process_response(res).await
213    }
214
215    /// Sends a PATCH request with a JSON payload to the specified URL and signs it.
216    pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
217        let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
218        tracing::debug!("req: {:?}", req);
219        let res = self.client.execute(req).await?;
220        Self::process_response(res).await
221    }
222
223    /// Returns a reference to the `VerifyingKey` used for request verification.
224    pub const fn verifier(&self) -> &VerifyingKey {
225        &self.verifier
226    }
227
228    /// Returns a reference to the underlying HTTP client.
229    pub const fn client(&self) -> &reqwest::Client {
230        &self.client
231    }
232}
233
234// Private functions.
235impl BpxClient {
236    /// Signs a request by generating a signature from the request details
237    /// and appending necessary headers for authentication.
238    ///
239    /// # Arguments
240    /// * `req` - The mutable reference to the request to be signed.
241    fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
242        &self,
243        url: U,
244        method: Method,
245        payload: Option<&P>,
246    ) -> Result<Request> {
247        let url = url.into_url()?;
248        let instruction = match url.path() {
249            API_CAPITAL if method == Method::GET => "balanceQuery",
250            API_DEPOSITS if method == Method::GET => "depositQueryAll",
251            API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
252            API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
253            API_WITHDRAWALS if method == Method::POST => "withdraw",
254            API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
255            API_ORDER if method == Method::GET => "orderQuery",
256            API_ORDER if method == Method::POST => "orderExecute",
257            API_ORDER if method == Method::DELETE => "orderCancel",
258            API_ORDERS if method == Method::GET => "orderQueryAll",
259            API_ORDERS if method == Method::DELETE => "orderCancelAll",
260            API_RFQ if method == Method::POST => "rfqSubmit",
261            API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
262            API_FUTURES_POSITION if method == Method::GET => "positionQuery",
263            API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
264            API_COLLATERAL if method == Method::GET => "collateralQuery",
265            API_ACCOUNT if method == Method::GET => "accountQuery",
266            API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
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            _ => {
272                let req = self.client().request(method, url);
273                if let Some(payload) = payload {
274                    return Ok(req.json(payload).build()?);
275                } else {
276                    return Ok(req.build()?);
277                }
278            }
279        };
280
281        let query_params = url.query_pairs().collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
282        let body_params = if let Some(payload) = payload {
283            let s = serde_json::to_value(payload)?;
284            match s {
285                Value::Object(map) => map
286                    .into_iter()
287                    .map(|(k, v)| (k, v.to_string()))
288                    .collect::<BTreeMap<_, _>>(),
289                _ => return Err(Error::InvalidRequest("payload must be a JSON object".into())),
290            }
291        } else {
292            BTreeMap::new()
293        };
294
295        let timestamp = now_millis();
296        let mut signee = format!("instruction={instruction}");
297        for (k, v) in query_params {
298            signee.push_str(&format!("&{k}={v}"));
299        }
300        for (k, v) in body_params {
301            let v = v.trim_start_matches('"').trim_end_matches('"');
302            signee.push_str(&format!("&{k}={v}"));
303        }
304        signee.push_str(&format!("&timestamp={timestamp}&window={DEFAULT_WINDOW}"));
305        tracing::debug!("signee: {}", signee);
306
307        let signature: Signature = self.signer.sign(signee.as_bytes());
308        let signature = STANDARD.encode(signature.to_bytes());
309
310        let mut req = self.client().request(method, url);
311        if let Some(payload) = payload {
312            req = req.json(payload);
313        }
314        let mut req = req.build()?;
315        req.headers_mut().insert(SIGNATURE_HEADER, signature.parse()?);
316        req.headers_mut()
317            .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
318        req.headers_mut()
319            .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
320        if matches!(req.method(), &Method::POST | &Method::DELETE) {
321            req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
322        }
323        Ok(req)
324    }
325}
326
327/// Returns the current time in milliseconds since UNIX epoch.
328fn now_millis() -> u64 {
329    SystemTime::now()
330        .duration_since(UNIX_EPOCH)
331        .expect("Time went backwards")
332        .as_millis() as u64
333}