bpx_api_client/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
//! Backpack Exchange API Client
//!
//! This module provides the `BpxClient` for interacting with the Backpack Exchange API.
//! It includes functionality for authenticated and public endpoints,
//! along with utilities for error handling, request signing, and response processing.
//!
//! ## Features
//! - Request signing and authentication using ED25519 signatures.
//! - Supports both REST and WebSocket endpoints.
//! - Includes modules for managing capital, orders, trades, and user data.
//!
//! ## Example
//! ```no_run
//! use bpx_api_client::{BACKPACK_API_BASE_URL, BpxClient};
//!
//! #[tokio::main]
//! async fn main() {
//!     let base_url = BACKPACK_API_BASE_URL.to_string();
//!     let secret = "your_api_secret_here";
//!     let headers = None;
//!
//!     let client = BpxClient::init(base_url, secret, headers)
//!         .expect("Failed to initialize Backpack API client");
//!
//!     match client.get_open_orders(Some("SOL_USDC")).await {
//!         Ok(orders) => println!("Open Orders: {:?}", orders),
//!         Err(err) => tracing::error!("Error: {:?}", err),
//!     }
//! }
//! ```

use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use reqwest::{header::CONTENT_TYPE, IntoUrl, Method, Request, Response, StatusCode};
use routes::{
    capital::{API_CAPITAL, API_DEPOSITS, API_DEPOSIT_ADDRESS, API_WITHDRAWALS},
    order::{API_ORDER, API_ORDERS},
    rfq::{API_RFQ, API_RFQ_QUOTE},
    user::API_USER_2FA,
};
use serde::Serialize;
use std::{
    collections::BTreeMap,
    time::{SystemTime, UNIX_EPOCH},
};

pub mod error;

mod routes;

#[cfg(feature = "ws")]
mod ws;

/// Re-export of the Backpack Exchange API types.
pub use bpx_api_types as types;

/// Re-export of the custom `Error` type and `Result` alias for error handling.
pub use error::{Error, Result};

const API_USER_AGENT: &str = "bpx-rust-client";
const API_KEY_HEADER: &str = "X-API-Key";

const DEFAULT_WINDOW: u32 = 5000;

const SIGNATURE_HEADER: &str = "X-Signature";
const TIMESTAMP_HEADER: &str = "X-Timestamp";
const WINDOW_HEADER: &str = "X-Window";

const JSON_CONTENT: &str = "application/json; charset=utf-8";

/// The official base URL for the Backpack Exchange REST API.
pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";

/// The official WebSocket URL for real-time data from the Backpack Exchange.
pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";

/// Type alias for custom HTTP headers passed to `BpxClient` during initialization.
pub type BpxHeaders = reqwest::header::HeaderMap;

/// A client for interacting with the Backpack Exchange API.
#[derive(Debug, Clone)]
pub struct BpxClient {
    signer: SigningKey,
    verifier: VerifyingKey,
    base_url: String,
    ws_url: Option<String>,
    client: reqwest::Client,
}

impl std::ops::Deref for BpxClient {
    type Target = reqwest::Client;

    fn deref(&self) -> &Self::Target {
        &self.client
    }
}

impl std::ops::DerefMut for BpxClient {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.client
    }
}

impl AsRef<reqwest::Client> for BpxClient {
    fn as_ref(&self) -> &reqwest::Client {
        &self.client
    }
}

// Public functions.
impl BpxClient {
    /// Initializes a new client with the given base URL, API secret, and optional headers.
    ///
    /// This sets up the signing and verification keys, and creates a `reqwest` client
    /// with default headers including the API key and content type.
    pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
        Self::init_internal(base_url, None, secret, headers)
    }

    /// Initializes a new client with WebSocket support.
    #[cfg(feature = "ws")]
    pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
        Self::init_internal(base_url, Some(ws_url), secret, headers)
    }

    /// Internal helper function for client initialization.
    fn init_internal(
        base_url: String,
        ws_url: Option<String>,
        secret: &str,
        headers: Option<BpxHeaders>,
    ) -> Result<Self> {
        let signer = STANDARD
            .decode(secret)?
            .try_into()
            .map(|s| SigningKey::from_bytes(&s))
            .map_err(|_| Error::SecretKey)?;

        let verifier = signer.verifying_key();

        let mut headers = headers.unwrap_or_default();
        headers.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
        headers.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);

        let client = reqwest::Client::builder()
            .user_agent(API_USER_AGENT)
            .default_headers(headers)
            .build()?;

        Ok(BpxClient {
            signer,
            verifier,
            base_url,
            ws_url,
            client,
        })
    }

    /// Creates a new, empty `BpxHeaders` instance.
    pub fn create_headers() -> BpxHeaders {
        reqwest::header::HeaderMap::new()
    }

    /// Processes the response to check for HTTP errors and extracts
    /// the response content.
    ///
    /// Returns a custom error if the status code is non-2xx.
    async fn process_response(res: Response) -> Result<Response> {
        if let Err(e) = res.error_for_status_ref() {
            let err_text = res.text().await?;
            let err = Error::BpxApiError {
                status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
                message: err_text,
            };
            return Err(err);
        }
        Ok(res)
    }

    /// Sends a GET request to the specified URL and signs it before execution.
    pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
        let mut req = self.client.get(url).build()?;
        tracing::debug!("req: {:?}", req);
        self.sign(&mut req)?;
        let res = self.client.execute(req).await?;
        Self::process_response(res).await
    }

    /// Sends a POST request with a JSON payload to the specified URL and signs it.
    pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
        let mut req = self.client.post(url).json(&payload).build()?;
        tracing::debug!("req: {:?}", req);
        self.sign(&mut req)?;
        let res = self.client.execute(req).await?;
        Self::process_response(res).await
    }

    /// Sends a DELETE request with a JSON payload to the specified URL and signs it.
    pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
        let mut req = self.client.delete(url).json(&payload).build()?;
        tracing::debug!("req: {:?}", req);
        self.sign(&mut req)?;
        let res = self.client.execute(req).await?;
        Self::process_response(res).await
    }

    /// Returns a reference to the `VerifyingKey` used for request verification.
    pub fn verifier(&self) -> &VerifyingKey {
        &self.verifier
    }

    /// Returns a reference to the underlying HTTP client.
    pub fn client(&self) -> &reqwest::Client {
        &self.client
    }
}

// Private functions.
impl BpxClient {
    /// Signs a request by generating a signature from the request details
    /// and appending necessary headers for authentication.
    ///
    /// # Arguments
    /// * `req` - The mutable reference to the request to be signed.
    fn sign(&self, req: &mut Request) -> Result<()> {
        let instruction = match req.url().path() {
            API_CAPITAL if req.method() == Method::GET => "balanceQuery",
            API_DEPOSITS if req.method() == Method::GET => "depositQueryAll",
            API_DEPOSIT_ADDRESS if req.method() == Method::GET => "depositAddressQuery",
            API_WITHDRAWALS if req.method() == Method::GET => "withdrawalQueryAll",
            API_WITHDRAWALS if req.method() == Method::POST => "withdraw",
            API_USER_2FA if req.method() == Method::POST => "issueTwoFactorToken",
            API_ORDER if req.method() == Method::GET => "orderQuery",
            API_ORDER if req.method() == Method::POST => "orderExecute",
            API_ORDER if req.method() == Method::DELETE => "orderCancel",
            API_ORDERS if req.method() == Method::GET => "orderQueryAll",
            API_ORDERS if req.method() == Method::DELETE => "orderCancelAll",
            API_RFQ if req.method() == Method::POST => "rfqSubmit",
            API_RFQ_QUOTE if req.method() == Method::POST => "quoteSubmit",
            _ => return Ok(()), // Other endpoints don't require signing.
        };

        let query_params = req
            .url()
            .query_pairs()
            .map(|(x, y)| (x.into_owned(), y.into_owned()))
            .collect::<BTreeMap<String, String>>();

        let body_params = if let Some(b) = req.body() {
            let s = std::str::from_utf8(b.as_bytes().unwrap_or_default())?;
            serde_json::from_str::<BTreeMap<String, String>>(s)?
        } else {
            BTreeMap::new()
        };

        let timestamp = now_millis();

        let mut signee = format!("instruction={instruction}");
        for (k, v) in query_params {
            signee.push_str(&format!("&{k}={v}"));
        }
        for (k, v) in body_params {
            signee.push_str(&format!("&{k}={v}"));
        }
        signee.push_str(&format!("&timestamp={timestamp}&window={DEFAULT_WINDOW}"));
        tracing::debug!("signee: {}", signee);

        let signature: Signature = self.signer.sign(signee.as_bytes());
        let signature = STANDARD.encode(signature.to_bytes());

        req.headers_mut().insert(SIGNATURE_HEADER, signature.parse()?);
        req.headers_mut()
            .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
        req.headers_mut()
            .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);

        if matches!(req.method(), &Method::POST | &Method::DELETE) {
            req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
        }

        Ok(())
    }
}

/// Returns the current time in milliseconds since UNIX epoch.
fn now_millis() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("Time went backwards")
        .as_millis() as u64
}