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