1use 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 vault::{API_VAULT_MINT, API_VAULT_MINTS_HISTORY, API_VAULT_REDEEM, API_VAULT_REDEEMS_HISTORY},
52};
53use serde::Serialize;
54use serde_json::Value;
55use std::{
56 borrow::Cow,
57 collections::BTreeMap,
58 time::{Duration, SystemTime, UNIX_EPOCH},
59};
60
61pub mod error;
62
63mod routes;
64
65#[cfg(feature = "ws")]
66mod ws;
67
68pub use bpx_api_types as types;
70
71pub use error::{Error, Result};
73
74use crate::routes::rfq::{API_RFQ_ACCEPT, API_RFQ_CANCEL, API_RFQ_REFRESH};
75
76const API_USER_AGENT: &str = "bpx-rust-client";
77const API_KEY_HEADER: &str = "X-API-Key";
78
79const DEFAULT_WINDOW: u32 = 5000;
80
81const SIGNATURE_HEADER: &str = "X-Signature";
82const TIMESTAMP_HEADER: &str = "X-Timestamp";
83const WINDOW_HEADER: &str = "X-Window";
84
85const JSON_CONTENT: &str = "application/json; charset=utf-8";
86
87pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
89
90pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
92
93pub type BpxHeaders = reqwest::header::HeaderMap;
95
96#[derive(Debug, Clone)]
98pub struct BpxClient {
99 signing_key: Option<SigningKey>,
100 verifying_key: Option<VerifyingKey>,
101 base_url: Url,
102 #[cfg_attr(not(feature = "ws"), allow(dead_code))]
103 ws_url: Url,
104 client: reqwest::Client,
105}
106
107impl std::ops::Deref for BpxClient {
108 type Target = reqwest::Client;
109
110 fn deref(&self) -> &Self::Target {
111 &self.client
112 }
113}
114
115impl std::ops::DerefMut for BpxClient {
116 fn deref_mut(&mut self) -> &mut Self::Target {
117 &mut self.client
118 }
119}
120
121impl AsRef<reqwest::Client> for BpxClient {
122 fn as_ref(&self) -> &reqwest::Client {
123 &self.client
124 }
125}
126
127impl BpxClient {
129 pub fn builder() -> BpxClientBuilder {
130 BpxClientBuilder::new()
131 }
132
133 pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
138 BpxClientBuilder::new()
139 .base_url(base_url)
140 .secret(secret)
141 .headers(headers.unwrap_or_default())
142 .build()
143 }
144
145 #[cfg(feature = "ws")]
147 #[deprecated(
148 note = "Use BpxClient::builder() instead to configure the client with a custom websocket URL."
149 )]
150 pub fn init_with_ws(
151 base_url: String,
152 ws_url: String,
153 secret: &str,
154 headers: Option<BpxHeaders>,
155 ) -> Result<Self> {
156 BpxClientBuilder::new()
157 .base_url(base_url)
158 .ws_url(ws_url)
159 .secret(secret)
160 .headers(headers.unwrap_or_default())
161 .build()
162 }
163
164 async fn process_response(res: Response) -> Result<Response> {
169 if let Err(e) = res.error_for_status_ref() {
170 let err_text = res.text().await?;
171 let err = Error::BpxApiError {
172 status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
173 message: err_text.into(),
174 };
175 return Err(err);
176 }
177 Ok(res)
178 }
179
180 pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
182 let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
183 tracing::debug!(?req, "GET request");
184 self.execute(req).await
185 }
186
187 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 self.execute(req).await
192 }
193
194 pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
196 let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
197 tracing::debug!(?req, "DELETE request");
198 self.execute(req).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 self.execute(req).await
206 }
207
208 pub async fn execute(&self, request: Request) -> Result<Response> {
209 let res = self.client.execute(request).await?;
210 Self::process_response(res).await
211 }
212
213 pub const fn verifying_key(&self) -> Option<&VerifyingKey> {
216 self.verifying_key.as_ref()
217 }
218
219 pub const fn client(&self) -> &reqwest::Client {
221 &self.client
222 }
223
224 pub fn base_url(&self) -> &Url {
225 &self.base_url
226 }
227}
228
229impl BpxClient {
231 fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
237 &self,
238 url: U,
239 method: Method,
240 payload: Option<&P>,
241 ) -> Result<Request> {
242 let url = url.into_url()?;
243 let instruction = match url.path() {
244 API_CAPITAL if method == Method::GET => "balanceQuery",
245 API_DEPOSITS if method == Method::GET => "depositQueryAll",
246 API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
247 API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
248 API_WITHDRAWALS if method == Method::POST => "withdraw",
249 API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
250 API_ORDER if method == Method::GET => "orderQuery",
251 API_ORDER if method == Method::POST => "orderExecute",
252 API_ORDER if method == Method::DELETE => "orderCancel",
253 API_ORDERS if method == Method::GET => "orderQueryAll",
254 API_ORDERS if method == Method::POST => "orderExecute",
255 API_ORDERS if method == Method::DELETE => "orderCancelAll",
256 API_RFQ if method == Method::POST => "rfqSubmit",
257 API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
258 API_RFQ_ACCEPT if method == Method::POST => "quoteAccept",
259 API_RFQ_CANCEL if method == Method::POST => "rfqCancel",
260 API_RFQ_REFRESH if method == Method::POST => "rfqRefresh",
261 API_FUTURES_POSITION if method == Method::GET => "positionQuery",
262 API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
263 API_COLLATERAL if method == Method::GET => "collateralQuery",
264 API_ACCOUNT if method == Method::GET => "accountQuery",
265 API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
266 API_ACCOUNT_MAX_ORDER if method == Method::GET => "maxOrderQuantity",
267 API_ACCOUNT_MAX_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
268 API_ACCOUNT if method == Method::PATCH => "accountUpdate",
269 API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
270 API_FILLS_HISTORY if method == Method::GET => "fillHistoryQueryAll",
271 API_VAULT_MINT if method == Method::POST => "vaultMint",
272 API_VAULT_REDEEM if method == Method::POST => "vaultRedeemRequest",
273 API_VAULT_REDEEM if method == Method::DELETE => "vaultRedeemCancel",
274 API_VAULT_MINTS_HISTORY if method == Method::GET => "vaultMintHistoryQueryAll",
275 API_VAULT_REDEEMS_HISTORY if method == Method::GET => "vaultRedeemHistoryQueryAll",
276 _ => {
277 let req = self.client().request(method, url);
278 if let Some(payload) = payload {
279 return Ok(req.json(payload).build()?);
280 } else {
281 return Ok(req.build()?);
282 }
283 }
284 };
285
286 self.build_signed_request(url, method, instruction, payload)
287 }
288
289 pub fn build_signed_request<P: Serialize, U: IntoUrl>(
294 &self,
295 url: U,
296 method: Method,
297 instruction: &str,
298 payload: Option<&P>,
299 ) -> Result<Request> {
300 let url = url.into_url()?;
301
302 let signing_key = self.signing_key.as_ref().ok_or(Error::NotAuthenticated)?;
303
304 let query_params = url
305 .query_pairs()
306 .collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
307
308 let mut signee = if let Some(payload) = payload {
309 let value = serde_json::to_value(payload)?;
310 build_signee_query_and_payload(instruction, value, &query_params)?
311 } else {
312 build_signee_query(instruction, &query_params)
313 };
314
315 let timestamp = now_millis();
316 signee.push_str(&format!("×tamp={timestamp}&window={DEFAULT_WINDOW}"));
317 tracing::debug!("signee: {}", signee);
318
319 let signature: Signature = signing_key.sign(signee.as_bytes());
320 let signature = Base64::encode_string(&signature.to_bytes());
321
322 let mut req = self.client().request(method, url);
323 if let Some(payload) = payload {
324 req = req.json(payload);
325 }
326 let mut req = req.build()?;
327 req.headers_mut()
328 .insert(SIGNATURE_HEADER, signature.parse()?);
329 req.headers_mut()
330 .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
331 req.headers_mut()
332 .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
333 if matches!(req.method(), &Method::POST | &Method::DELETE) {
334 req.headers_mut()
335 .insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
336 }
337 Ok(req)
338 }
339}
340
341fn build_signee_query_and_payload(
342 instruction: &str,
343 payload: serde_json::Value,
344 query_params: &BTreeMap<Cow<'_, str>, Cow<'_, str>>,
345) -> Result<String> {
346 match payload {
347 Value::Object(map) => {
348 let body_params = map
349 .into_iter()
350 .map(|(k, v)| (k, v.to_string()))
351 .collect::<BTreeMap<_, _>>();
352 let mut signee = build_signee_query(instruction, query_params);
353 for (k, v) in body_params {
354 let v = v.trim_start_matches('"').trim_end_matches('"');
355 signee.push_str(&format!("&{k}={v}"));
356 }
357 Ok(signee)
358 }
359 Value::Array(array) => array
360 .into_iter()
361 .map(|item| build_signee_query_and_payload(instruction, item, query_params))
362 .collect::<Result<Vec<_>>>()
363 .map(|parts| parts.join("&")),
364 _ => Err(Error::InvalidRequest(
365 "payload must be a JSON object".into(),
366 )),
367 }
368}
369
370fn build_signee_query(
371 instruction: &str,
372 query_params: &BTreeMap<Cow<'_, str>, Cow<'_, str>>,
373) -> String {
374 let mut signee = format!("instruction={instruction}");
375 for (k, v) in query_params {
376 signee.push_str(&format!("&{k}={v}"));
377 }
378 signee
379}
380
381#[derive(Debug, Default)]
382pub struct BpxClientBuilder {
383 base_url: Option<String>,
384 ws_url: Option<String>,
385 secret: Option<String>,
386 headers: Option<BpxHeaders>,
387 timeout: Option<u64>,
388}
389
390impl BpxClientBuilder {
391 pub fn new() -> Self {
392 Default::default()
393 }
394
395 pub fn base_url(mut self, base_url: impl ToString) -> Self {
404 self.base_url = Some(base_url.to_string());
405 self
406 }
407
408 #[cfg(feature = "ws")]
417 pub fn ws_url(mut self, ws_url: impl ToString) -> Self {
418 self.ws_url = Some(ws_url.to_string());
419 self
420 }
421
422 pub fn secret(mut self, secret: impl ToString) -> Self {
431 self.secret = Some(secret.to_string());
432 self
433 }
434
435 pub fn headers(mut self, headers: BpxHeaders) -> Self {
444 self.headers = Some(headers);
445 self
446 }
447
448 pub fn timeout(mut self, timeout: u64) -> Self {
457 self.timeout = Some(timeout);
458 self
459 }
460
461 pub fn build(self) -> Result<BpxClient> {
466 let base_url = self.base_url.as_deref().unwrap_or(BACKPACK_API_BASE_URL);
467 let base_url = Url::parse(base_url)?;
468
469 let ws_url = self.ws_url.as_deref().unwrap_or(BACKPACK_WS_URL);
470 let ws_url = Url::parse(ws_url)?;
471
472 let signing_key = if let Some(secret) = self.secret {
473 Some(
474 Base64::decode_vec(&secret)?
475 .try_into()
476 .map(|s| SigningKey::from_bytes(&s))
477 .map_err(|_| Error::SecretKey)?,
478 )
479 } else {
480 None
481 };
482 let verifying_key = signing_key.as_ref().map(|s| s.verifying_key());
483
484 let mut header_map = BpxHeaders::new();
485 if let Some(headers) = self.headers {
486 header_map.extend(headers);
487 }
488
489 header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
490 if let Some(signing_key) = &signing_key {
491 let verifier = signing_key.verifying_key();
492 header_map.insert(
493 API_KEY_HEADER,
494 Base64::encode_string(&verifier.to_bytes()).parse()?,
495 );
496 }
497
498 let client = BpxClient {
499 signing_key,
500 verifying_key,
501 base_url,
502 ws_url,
503 client: reqwest::Client::builder()
504 .user_agent(API_USER_AGENT)
505 .default_headers(header_map)
506 .timeout(Duration::from_secs(self.timeout.unwrap_or(30)))
507 .build()?,
508 };
509
510 Ok(client)
511 }
512}
513
514fn now_millis() -> u64 {
516 SystemTime::now()
517 .duration_since(UNIX_EPOCH)
518 .expect("Time went backwards")
519 .as_millis() as u64
520}