1use 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
66pub use bpx_api_types as types;
68
69pub 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
83pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
85
86pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
88
89pub type BpxHeaders = reqwest::header::HeaderMap;
91
92#[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
122impl BpxClient {
124 pub fn builder() -> BpxClientBuilder {
125 BpxClientBuilder::new()
126 }
127
128 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 #[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 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 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 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 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 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 pub const fn client(&self) -> &reqwest::Client {
209 &self.client
210 }
211}
212
213impl BpxClient {
215 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!("×tamp={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 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 #[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 pub fn secret(mut self, secret: impl ToString) -> Self {
367 self.secret = Some(secret.to_string());
368 self
369 }
370
371 pub fn headers(mut self, headers: BpxHeaders) -> Self {
380 self.headers = Some(headers);
381 self
382 }
383
384 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 = Url::parse(base_url)?;
391
392 let ws_url = self.ws_url.as_deref().unwrap_or(BACKPACK_WS_URL);
393 let ws_url = Url::parse(ws_url)?;
394
395 let signing_key = if let Some(secret) = self.secret {
396 Some(
397 STANDARD
398 .decode(secret)?
399 .try_into()
400 .map(|s| SigningKey::from_bytes(&s))
401 .map_err(|_| Error::SecretKey)?,
402 )
403 } else {
404 None
405 };
406
407 let mut header_map = BpxHeaders::new();
408 if let Some(headers) = self.headers {
409 header_map.extend(headers);
410 }
411
412 header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
413 if let Some(signing_key) = &signing_key {
414 let verifier = signing_key.verifying_key();
415 header_map.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
416 }
417
418 let client = BpxClient {
419 signing_key,
420 base_url,
421 ws_url,
422 client: reqwest::Client::builder()
423 .user_agent(API_USER_AGENT)
424 .default_headers(header_map)
425 .build()?,
426 };
427
428 Ok(client)
429 }
430}
431
432fn now_millis() -> u64 {
434 SystemTime::now()
435 .duration_since(UNIX_EPOCH)
436 .expect("Time went backwards")
437 .as_millis() as u64
438}