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::{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_DEPOSIT_ADDRESS, API_DEPOSITS, 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
64pub use bpx_api_types as types;
66
67pub 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
81pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
83
84pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
86
87pub type BpxHeaders = reqwest::header::HeaderMap;
89
90#[derive(Debug, Clone)]
92pub struct BpxClient {
93 signing_key: Option<SigningKey>,
94 base_url: Url,
95 #[cfg_attr(not(feature = "ws"), allow(dead_code))]
96 ws_url: Url,
97 client: reqwest::Client,
98}
99
100impl std::ops::Deref for BpxClient {
101 type Target = reqwest::Client;
102
103 fn deref(&self) -> &Self::Target {
104 &self.client
105 }
106}
107
108impl std::ops::DerefMut for BpxClient {
109 fn deref_mut(&mut self) -> &mut Self::Target {
110 &mut self.client
111 }
112}
113
114impl AsRef<reqwest::Client> for BpxClient {
115 fn as_ref(&self) -> &reqwest::Client {
116 &self.client
117 }
118}
119
120impl BpxClient {
122 pub fn builder() -> BpxClientBuilder {
123 BpxClientBuilder::new()
124 }
125
126 pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
131 BpxClientBuilder::new()
132 .base_url(base_url)
133 .secret(secret)
134 .headers(headers.unwrap_or_default())
135 .build()
136 }
137
138 #[cfg(feature = "ws")]
140 #[deprecated(note = "Use BpxClient::builder() instead to configure the client with a custom websocket URL.")]
141 pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
142 BpxClientBuilder::new()
143 .base_url(base_url)
144 .ws_url(ws_url)
145 .secret(secret)
146 .headers(headers.unwrap_or_default())
147 .build()
148 }
149
150 async fn process_response(res: Response) -> Result<Response> {
155 if let Err(e) = res.error_for_status_ref() {
156 let err_text = res.text().await?;
157 let err = Error::BpxApiError {
158 status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
159 message: err_text.into(),
160 };
161 return Err(err);
162 }
163 Ok(res)
164 }
165
166 pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
168 let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
169 tracing::debug!(?req, "GET request");
170 let res = self.client.execute(req).await?;
171 Self::process_response(res).await
172 }
173
174 pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
176 let req = self.build_and_maybe_sign_request(url, Method::POST, Some(&payload))?;
177 tracing::debug!(?req, "POST request");
178 let res = self.client.execute(req).await?;
179 Self::process_response(res).await
180 }
181
182 pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
184 let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
185 tracing::debug!(?req, "DELETE request");
186 let res = self.client.execute(req).await?;
187 Self::process_response(res).await
188 }
189
190 pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
192 let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
193 tracing::debug!(?req, "PATCH request");
194 let res = self.client.execute(req).await?;
195 Self::process_response(res).await
196 }
197
198 pub const fn client(&self) -> &reqwest::Client {
200 &self.client
201 }
202}
203
204impl BpxClient {
206 fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
212 &self,
213 url: U,
214 method: Method,
215 payload: Option<&P>,
216 ) -> Result<Request> {
217 let url = url.into_url()?;
218 let instruction = match url.path() {
219 API_CAPITAL if method == Method::GET => "balanceQuery",
220 API_DEPOSITS if method == Method::GET => "depositQueryAll",
221 API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
222 API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
223 API_WITHDRAWALS if method == Method::POST => "withdraw",
224 API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
225 API_ORDER if method == Method::GET => "orderQuery",
226 API_ORDER if method == Method::POST => "orderExecute",
227 API_ORDER if method == Method::DELETE => "orderCancel",
228 API_ORDERS if method == Method::GET => "orderQueryAll",
229 API_ORDERS if method == Method::DELETE => "orderCancelAll",
230 API_RFQ if method == Method::POST => "rfqSubmit",
231 API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
232 API_FUTURES_POSITION if method == Method::GET => "positionQuery",
233 API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
234 API_COLLATERAL if method == Method::GET => "collateralQuery",
235 API_ACCOUNT if method == Method::GET => "accountQuery",
236 API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
237 API_ACCOUNT_MAX_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
238 API_ACCOUNT if method == Method::PATCH => "accountUpdate",
239 API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
240 API_FILLS_HISTORY if method == Method::GET => "fillHistoryQueryAll",
241 _ => {
242 let req = self.client().request(method, url);
243 if let Some(payload) = payload {
244 return Ok(req.json(payload).build()?);
245 } else {
246 return Ok(req.build()?);
247 }
248 }
249 };
250
251 let Some(signing_key) = &self.signing_key else {
252 return Err(Error::NotAuthenticated);
253 };
254
255 let query_params = url.query_pairs().collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
256 let body_params = if let Some(payload) = payload {
257 let s = serde_json::to_value(payload)?;
258 match s {
259 Value::Object(map) => map
260 .into_iter()
261 .map(|(k, v)| (k, v.to_string()))
262 .collect::<BTreeMap<_, _>>(),
263 _ => return Err(Error::InvalidRequest("payload must be a JSON object".into())),
264 }
265 } else {
266 BTreeMap::new()
267 };
268
269 let timestamp = now_millis();
270 let mut signee = format!("instruction={instruction}");
271 for (k, v) in query_params {
272 signee.push_str(&format!("&{k}={v}"));
273 }
274 for (k, v) in body_params {
275 let v = v.trim_start_matches('"').trim_end_matches('"');
276 signee.push_str(&format!("&{k}={v}"));
277 }
278 signee.push_str(&format!("×tamp={timestamp}&window={DEFAULT_WINDOW}"));
279 tracing::debug!("signee: {}", signee);
280
281 let signature: Signature = signing_key.sign(signee.as_bytes());
282 let signature = STANDARD.encode(signature.to_bytes());
283
284 let mut req = self.client().request(method, url);
285 if let Some(payload) = payload {
286 req = req.json(payload);
287 }
288 let mut req = req.build()?;
289 req.headers_mut().insert(SIGNATURE_HEADER, signature.parse()?);
290 req.headers_mut()
291 .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
292 req.headers_mut()
293 .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
294 if matches!(req.method(), &Method::POST | &Method::DELETE) {
295 req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
296 }
297 Ok(req)
298 }
299}
300
301#[derive(Debug, Default)]
302pub struct BpxClientBuilder {
303 base_url: Option<String>,
304 ws_url: Option<String>,
305 secret: Option<String>,
306 headers: Option<BpxHeaders>,
307}
308
309impl BpxClientBuilder {
310 pub fn new() -> Self {
311 Default::default()
312 }
313
314 pub fn base_url(mut self, base_url: impl ToString) -> Self {
323 self.base_url = Some(base_url.to_string());
324 self
325 }
326
327 pub fn ws_url(mut self, ws_url: impl ToString) -> Self {
336 self.ws_url = Some(ws_url.to_string());
337 self
338 }
339
340 pub fn secret(mut self, secret: impl ToString) -> Self {
349 self.secret = Some(secret.to_string());
350 self
351 }
352
353 pub fn headers(mut self, headers: BpxHeaders) -> Self {
362 self.headers = Some(headers);
363 self
364 }
365
366 pub fn build(self) -> Result<BpxClient> {
371 let base_url = self
372 .base_url
373 .map(Cow::from)
374 .unwrap_or_else(|| Cow::Borrowed(BACKPACK_API_BASE_URL));
375
376 let base_url = Url::parse(&base_url).map_err(|e| Error::UrlParseError(e.to_string().into()))?;
377
378 let ws_url = self
379 .ws_url
380 .map(Cow::from)
381 .unwrap_or_else(|| Cow::Borrowed(BACKPACK_WS_URL));
382
383 let ws_url = Url::parse(&ws_url).map_err(|e| Error::UrlParseError(e.to_string().into()))?;
384
385 let signing_key = if let Some(secret) = self.secret {
386 Some(
387 STANDARD
388 .decode(secret)?
389 .try_into()
390 .map(|s| SigningKey::from_bytes(&s))
391 .map_err(|_| Error::SecretKey)?,
392 )
393 } else {
394 None
395 };
396
397 let mut header_map = BpxHeaders::new();
398 if let Some(headers) = self.headers {
399 header_map.extend(headers);
400 }
401
402 header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
403 if let Some(signing_key) = &signing_key {
404 let verifier = signing_key.verifying_key();
405 header_map.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
406 }
407
408 let client = BpxClient {
409 signing_key,
410 base_url,
411 ws_url,
412 client: reqwest::Client::builder()
413 .user_agent(API_USER_AGENT)
414 .default_headers(header_map)
415 .build()?,
416 };
417
418 Ok(client)
419 }
420}
421
422fn now_millis() -> u64 {
424 SystemTime::now()
425 .duration_since(UNIX_EPOCH)
426 .expect("Time went backwards")
427 .as_millis() as u64
428}