1use base64::{Engine, engine::general_purpose::STANDARD};
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
67pub use bpx_api_types as types;
69
70pub use error::{Error, Result};
72
73const API_USER_AGENT: &str = "bpx-rust-client";
74const API_KEY_HEADER: &str = "X-API-Key";
75
76const DEFAULT_WINDOW: u32 = 5000;
77
78const SIGNATURE_HEADER: &str = "X-Signature";
79const TIMESTAMP_HEADER: &str = "X-Timestamp";
80const WINDOW_HEADER: &str = "X-Window";
81
82const JSON_CONTENT: &str = "application/json; charset=utf-8";
83
84pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
86
87pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
89
90pub type BpxHeaders = reqwest::header::HeaderMap;
92
93#[derive(Debug, Clone)]
95pub struct BpxClient {
96 signing_key: Option<SigningKey>,
97 verifying_key: Option<VerifyingKey>,
98 base_url: Url,
99 #[cfg_attr(not(feature = "ws"), allow(dead_code))]
100 ws_url: Url,
101 client: reqwest::Client,
102}
103
104impl std::ops::Deref for BpxClient {
105 type Target = reqwest::Client;
106
107 fn deref(&self) -> &Self::Target {
108 &self.client
109 }
110}
111
112impl std::ops::DerefMut for BpxClient {
113 fn deref_mut(&mut self) -> &mut Self::Target {
114 &mut self.client
115 }
116}
117
118impl AsRef<reqwest::Client> for BpxClient {
119 fn as_ref(&self) -> &reqwest::Client {
120 &self.client
121 }
122}
123
124impl BpxClient {
126 pub fn builder() -> BpxClientBuilder {
127 BpxClientBuilder::new()
128 }
129
130 pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
135 BpxClientBuilder::new()
136 .base_url(base_url)
137 .secret(secret)
138 .headers(headers.unwrap_or_default())
139 .build()
140 }
141
142 #[cfg(feature = "ws")]
144 #[deprecated(
145 note = "Use BpxClient::builder() instead to configure the client with a custom websocket URL."
146 )]
147 pub fn init_with_ws(
148 base_url: String,
149 ws_url: String,
150 secret: &str,
151 headers: Option<BpxHeaders>,
152 ) -> Result<Self> {
153 BpxClientBuilder::new()
154 .base_url(base_url)
155 .ws_url(ws_url)
156 .secret(secret)
157 .headers(headers.unwrap_or_default())
158 .build()
159 }
160
161 async fn process_response(res: Response) -> Result<Response> {
166 if let Err(e) = res.error_for_status_ref() {
167 let err_text = res.text().await?;
168 let err = Error::BpxApiError {
169 status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
170 message: err_text.into(),
171 };
172 return Err(err);
173 }
174 Ok(res)
175 }
176
177 pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
179 let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
180 tracing::debug!(?req, "GET request");
181 let res = self.client.execute(req).await?;
182 Self::process_response(res).await
183 }
184
185 pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
187 let req = self.build_and_maybe_sign_request(url, Method::POST, Some(&payload))?;
188 tracing::debug!(?req, "POST request");
189 let res = self.client.execute(req).await?;
190 Self::process_response(res).await
191 }
192
193 pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
195 let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
196 tracing::debug!(?req, "DELETE request");
197 let res = self.client.execute(req).await?;
198 Self::process_response(res).await
199 }
200
201 pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
203 let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
204 tracing::debug!(?req, "PATCH request");
205 let res = self.client.execute(req).await?;
206 Self::process_response(res).await
207 }
208
209 pub const fn verifying_key(&self) -> Option<&VerifyingKey> {
212 self.verifying_key.as_ref()
213 }
214
215 pub const fn client(&self) -> &reqwest::Client {
217 &self.client
218 }
219}
220
221impl BpxClient {
223 fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
229 &self,
230 url: U,
231 method: Method,
232 payload: Option<&P>,
233 ) -> Result<Request> {
234 let url = url.into_url()?;
235 let instruction = match url.path() {
236 API_CAPITAL if method == Method::GET => "balanceQuery",
237 API_DEPOSITS if method == Method::GET => "depositQueryAll",
238 API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
239 API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
240 API_WITHDRAWALS if method == Method::POST => "withdraw",
241 API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
242 API_ORDER if method == Method::GET => "orderQuery",
243 API_ORDER if method == Method::POST => "orderExecute",
244 API_ORDER if method == Method::DELETE => "orderCancel",
245 API_ORDERS if method == Method::GET => "orderQueryAll",
246 API_ORDERS if method == Method::DELETE => "orderCancelAll",
247 API_RFQ if method == Method::POST => "rfqSubmit",
248 API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
249 API_FUTURES_POSITION if method == Method::GET => "positionQuery",
250 API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
251 API_COLLATERAL if method == Method::GET => "collateralQuery",
252 API_ACCOUNT if method == Method::GET => "accountQuery",
253 API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
254 API_ACCOUNT_MAX_ORDER if method == Method::GET => "maxOrderQuantity",
255 API_ACCOUNT_MAX_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
256 API_ACCOUNT if method == Method::PATCH => "accountUpdate",
257 API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
258 API_FILLS_HISTORY if method == Method::GET => "fillHistoryQueryAll",
259 _ => {
260 let req = self.client().request(method, url);
261 if let Some(payload) = payload {
262 return Ok(req.json(payload).build()?);
263 } else {
264 return Ok(req.build()?);
265 }
266 }
267 };
268
269 let Some(signing_key) = &self.signing_key else {
270 return Err(Error::NotAuthenticated);
271 };
272
273 let query_params = url
274 .query_pairs()
275 .collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
276 let body_params = if let Some(payload) = payload {
277 let s = serde_json::to_value(payload)?;
278 match s {
279 Value::Object(map) => map
280 .into_iter()
281 .map(|(k, v)| (k, v.to_string()))
282 .collect::<BTreeMap<_, _>>(),
283 _ => {
284 return Err(Error::InvalidRequest(
285 "payload must be a JSON object".into(),
286 ));
287 }
288 }
289 } else {
290 BTreeMap::new()
291 };
292
293 let timestamp = now_millis();
294 let mut signee = format!("instruction={instruction}");
295 for (k, v) in query_params {
296 signee.push_str(&format!("&{k}={v}"));
297 }
298 for (k, v) in body_params {
299 let v = v.trim_start_matches('"').trim_end_matches('"');
300 signee.push_str(&format!("&{k}={v}"));
301 }
302 signee.push_str(&format!("×tamp={timestamp}&window={DEFAULT_WINDOW}"));
303 tracing::debug!("signee: {}", signee);
304
305 let signature: Signature = signing_key.sign(signee.as_bytes());
306 let signature = STANDARD.encode(signature.to_bytes());
307
308 let mut req = self.client().request(method, url);
309 if let Some(payload) = payload {
310 req = req.json(payload);
311 }
312 let mut req = req.build()?;
313 req.headers_mut()
314 .insert(SIGNATURE_HEADER, signature.parse()?);
315 req.headers_mut()
316 .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
317 req.headers_mut()
318 .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
319 if matches!(req.method(), &Method::POST | &Method::DELETE) {
320 req.headers_mut()
321 .insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
322 }
323 Ok(req)
324 }
325}
326
327#[derive(Debug, Default)]
328pub struct BpxClientBuilder {
329 base_url: Option<String>,
330 ws_url: Option<String>,
331 secret: Option<String>,
332 headers: Option<BpxHeaders>,
333}
334
335impl BpxClientBuilder {
336 pub fn new() -> Self {
337 Default::default()
338 }
339
340 pub fn base_url(mut self, base_url: impl ToString) -> Self {
349 self.base_url = Some(base_url.to_string());
350 self
351 }
352
353 #[cfg(feature = "ws")]
362 pub fn ws_url(mut self, ws_url: impl ToString) -> Self {
363 self.ws_url = Some(ws_url.to_string());
364 self
365 }
366
367 pub fn secret(mut self, secret: impl ToString) -> Self {
376 self.secret = Some(secret.to_string());
377 self
378 }
379
380 pub fn headers(mut self, headers: BpxHeaders) -> Self {
389 self.headers = Some(headers);
390 self
391 }
392
393 pub fn build(self) -> Result<BpxClient> {
398 let base_url = self.base_url.as_deref().unwrap_or(BACKPACK_API_BASE_URL);
399 let base_url = Url::parse(base_url)?;
400
401 let ws_url = self.ws_url.as_deref().unwrap_or(BACKPACK_WS_URL);
402 let ws_url = Url::parse(ws_url)?;
403
404 let signing_key = if let Some(secret) = self.secret {
405 Some(
406 STANDARD
407 .decode(secret)?
408 .try_into()
409 .map(|s| SigningKey::from_bytes(&s))
410 .map_err(|_| Error::SecretKey)?,
411 )
412 } else {
413 None
414 };
415 let verifying_key = signing_key.as_ref().map(|s| s.verifying_key());
416
417 let mut header_map = BpxHeaders::new();
418 if let Some(headers) = self.headers {
419 header_map.extend(headers);
420 }
421
422 header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
423 if let Some(signing_key) = &signing_key {
424 let verifier = signing_key.verifying_key();
425 header_map.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
426 }
427
428 let client = BpxClient {
429 signing_key,
430 verifying_key,
431 base_url,
432 ws_url,
433 client: reqwest::Client::builder()
434 .user_agent(API_USER_AGENT)
435 .default_headers(header_map)
436 .build()?,
437 };
438
439 Ok(client)
440 }
441}
442
443fn now_millis() -> u64 {
445 SystemTime::now()
446 .duration_since(UNIX_EPOCH)
447 .expect("Time went backwards")
448 .as_millis() as u64
449}