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};
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
73use crate::routes::rfq::{API_RFQ_ACCEPT, API_RFQ_CANCEL, API_RFQ_REFRESH};
74
75const API_USER_AGENT: &str = "bpx-rust-client";
76const API_KEY_HEADER: &str = "X-API-Key";
77
78const DEFAULT_WINDOW: u32 = 5000;
79
80const SIGNATURE_HEADER: &str = "X-Signature";
81const TIMESTAMP_HEADER: &str = "X-Timestamp";
82const WINDOW_HEADER: &str = "X-Window";
83
84const JSON_CONTENT: &str = "application/json; charset=utf-8";
85
86pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
88
89pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
91
92pub type BpxHeaders = reqwest::header::HeaderMap;
94
95#[derive(Debug, Clone)]
97pub struct BpxClient {
98 signing_key: Option<SigningKey>,
99 verifying_key: Option<VerifyingKey>,
100 base_url: Url,
101 #[cfg_attr(not(feature = "ws"), allow(dead_code))]
102 ws_url: Url,
103 client: reqwest::Client,
104}
105
106impl std::ops::Deref for BpxClient {
107 type Target = reqwest::Client;
108
109 fn deref(&self) -> &Self::Target {
110 &self.client
111 }
112}
113
114impl std::ops::DerefMut for BpxClient {
115 fn deref_mut(&mut self) -> &mut Self::Target {
116 &mut self.client
117 }
118}
119
120impl AsRef<reqwest::Client> for BpxClient {
121 fn as_ref(&self) -> &reqwest::Client {
122 &self.client
123 }
124}
125
126impl BpxClient {
128 pub fn builder() -> BpxClientBuilder {
129 BpxClientBuilder::new()
130 }
131
132 pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
137 BpxClientBuilder::new()
138 .base_url(base_url)
139 .secret(secret)
140 .headers(headers.unwrap_or_default())
141 .build()
142 }
143
144 #[cfg(feature = "ws")]
146 #[deprecated(
147 note = "Use BpxClient::builder() instead to configure the client with a custom websocket URL."
148 )]
149 pub fn init_with_ws(
150 base_url: String,
151 ws_url: String,
152 secret: &str,
153 headers: Option<BpxHeaders>,
154 ) -> Result<Self> {
155 BpxClientBuilder::new()
156 .base_url(base_url)
157 .ws_url(ws_url)
158 .secret(secret)
159 .headers(headers.unwrap_or_default())
160 .build()
161 }
162
163 async fn process_response(res: Response) -> Result<Response> {
168 if let Err(e) = res.error_for_status_ref() {
169 let err_text = res.text().await?;
170 let err = Error::BpxApiError {
171 status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
172 message: err_text.into(),
173 };
174 return Err(err);
175 }
176 Ok(res)
177 }
178
179 pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
181 let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
182 tracing::debug!(?req, "GET request");
183 let res = self.client.execute(req).await?;
184 Self::process_response(res).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 let res = self.client.execute(req).await?;
192 Self::process_response(res).await
193 }
194
195 pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
197 let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
198 tracing::debug!(?req, "DELETE request");
199 let res = self.client.execute(req).await?;
200 Self::process_response(res).await
201 }
202
203 pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
205 let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
206 tracing::debug!(?req, "PATCH request");
207 let res = self.client.execute(req).await?;
208 Self::process_response(res).await
209 }
210
211 pub const fn verifying_key(&self) -> Option<&VerifyingKey> {
214 self.verifying_key.as_ref()
215 }
216
217 pub const fn client(&self) -> &reqwest::Client {
219 &self.client
220 }
221}
222
223impl BpxClient {
225 fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
231 &self,
232 url: U,
233 method: Method,
234 payload: Option<&P>,
235 ) -> Result<Request> {
236 let url = url.into_url()?;
237 let instruction = match url.path() {
238 API_CAPITAL if method == Method::GET => "balanceQuery",
239 API_DEPOSITS if method == Method::GET => "depositQueryAll",
240 API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
241 API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
242 API_WITHDRAWALS if method == Method::POST => "withdraw",
243 API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
244 API_ORDER if method == Method::GET => "orderQuery",
245 API_ORDER if method == Method::POST => "orderExecute",
246 API_ORDER if method == Method::DELETE => "orderCancel",
247 API_ORDERS if method == Method::GET => "orderQueryAll",
248 API_ORDERS if method == Method::POST => "orderExecute",
249 API_ORDERS if method == Method::DELETE => "orderCancelAll",
250 API_RFQ if method == Method::POST => "rfqSubmit",
251 API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
252 API_RFQ_ACCEPT if method == Method::POST => "quoteAccept",
253 API_RFQ_CANCEL if method == Method::POST => "rfqCancel",
254 API_RFQ_REFRESH if method == Method::POST => "rfqRefresh",
255 API_FUTURES_POSITION if method == Method::GET => "positionQuery",
256 API_BORROW_LEND_POSITIONS if method == Method::GET => "borrowLendPositionQuery",
257 API_COLLATERAL if method == Method::GET => "collateralQuery",
258 API_ACCOUNT if method == Method::GET => "accountQuery",
259 API_ACCOUNT_MAX_BORROW if method == Method::GET => "maxBorrowQuantity",
260 API_ACCOUNT_MAX_ORDER if method == Method::GET => "maxOrderQuantity",
261 API_ACCOUNT_MAX_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
262 API_ACCOUNT if method == Method::PATCH => "accountUpdate",
263 API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
264 API_FILLS_HISTORY if method == Method::GET => "fillHistoryQueryAll",
265 _ => {
266 let req = self.client().request(method, url);
267 if let Some(payload) = payload {
268 return Ok(req.json(payload).build()?);
269 } else {
270 return Ok(req.build()?);
271 }
272 }
273 };
274
275 let Some(signing_key) = &self.signing_key else {
276 return Err(Error::NotAuthenticated);
277 };
278
279 let query_params = url
280 .query_pairs()
281 .collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
282
283 let mut signee = if let Some(payload) = payload {
284 let value = serde_json::to_value(payload)?;
285 build_signee_query_and_payload(instruction, value, &query_params)?
286 } else {
287 build_signee_query(instruction, &query_params)
288 };
289
290 let timestamp = now_millis();
291 signee.push_str(&format!("×tamp={timestamp}&window={DEFAULT_WINDOW}"));
292 tracing::debug!("signee: {}", signee);
293
294 let signature: Signature = signing_key.sign(signee.as_bytes());
295 let signature = Base64::encode_string(&signature.to_bytes());
296
297 let mut req = self.client().request(method, url);
298 if let Some(payload) = payload {
299 req = req.json(payload);
300 }
301 let mut req = req.build()?;
302 req.headers_mut()
303 .insert(SIGNATURE_HEADER, signature.parse()?);
304 req.headers_mut()
305 .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
306 req.headers_mut()
307 .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
308 if matches!(req.method(), &Method::POST | &Method::DELETE) {
309 req.headers_mut()
310 .insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
311 }
312 Ok(req)
313 }
314}
315
316fn build_signee_query_and_payload(
317 instruction: &str,
318 payload: serde_json::Value,
319 query_params: &BTreeMap<Cow<'_, str>, Cow<'_, str>>,
320) -> Result<String> {
321 match payload {
322 Value::Object(map) => {
323 let body_params = map
324 .into_iter()
325 .map(|(k, v)| (k, v.to_string()))
326 .collect::<BTreeMap<_, _>>();
327 let mut signee = build_signee_query(instruction, query_params);
328 for (k, v) in body_params {
329 let v = v.trim_start_matches('"').trim_end_matches('"');
330 signee.push_str(&format!("&{k}={v}"));
331 }
332 Ok(signee)
333 }
334 Value::Array(array) => array
335 .into_iter()
336 .map(|item| build_signee_query_and_payload(instruction, item, query_params))
337 .collect::<Result<Vec<_>>>()
338 .map(|parts| parts.join("&")),
339 _ => Err(Error::InvalidRequest(
340 "payload must be a JSON object".into(),
341 )),
342 }
343}
344
345fn build_signee_query(
346 instruction: &str,
347 query_params: &BTreeMap<Cow<'_, str>, Cow<'_, str>>,
348) -> String {
349 let mut signee = format!("instruction={instruction}");
350 for (k, v) in query_params {
351 signee.push_str(&format!("&{k}={v}"));
352 }
353 signee
354}
355
356#[derive(Debug, Default)]
357pub struct BpxClientBuilder {
358 base_url: Option<String>,
359 ws_url: Option<String>,
360 secret: Option<String>,
361 headers: Option<BpxHeaders>,
362}
363
364impl BpxClientBuilder {
365 pub fn new() -> Self {
366 Default::default()
367 }
368
369 pub fn base_url(mut self, base_url: impl ToString) -> Self {
378 self.base_url = Some(base_url.to_string());
379 self
380 }
381
382 #[cfg(feature = "ws")]
391 pub fn ws_url(mut self, ws_url: impl ToString) -> Self {
392 self.ws_url = Some(ws_url.to_string());
393 self
394 }
395
396 pub fn secret(mut self, secret: impl ToString) -> Self {
405 self.secret = Some(secret.to_string());
406 self
407 }
408
409 pub fn headers(mut self, headers: BpxHeaders) -> Self {
418 self.headers = Some(headers);
419 self
420 }
421
422 pub fn build(self) -> Result<BpxClient> {
427 let base_url = self.base_url.as_deref().unwrap_or(BACKPACK_API_BASE_URL);
428 let base_url = Url::parse(base_url)?;
429
430 let ws_url = self.ws_url.as_deref().unwrap_or(BACKPACK_WS_URL);
431 let ws_url = Url::parse(ws_url)?;
432
433 let signing_key = if let Some(secret) = self.secret {
434 Some(
435 Base64::decode_vec(&secret)?
436 .try_into()
437 .map(|s| SigningKey::from_bytes(&s))
438 .map_err(|_| Error::SecretKey)?,
439 )
440 } else {
441 None
442 };
443 let verifying_key = signing_key.as_ref().map(|s| s.verifying_key());
444
445 let mut header_map = BpxHeaders::new();
446 if let Some(headers) = self.headers {
447 header_map.extend(headers);
448 }
449
450 header_map.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
451 if let Some(signing_key) = &signing_key {
452 let verifier = signing_key.verifying_key();
453 header_map.insert(
454 API_KEY_HEADER,
455 Base64::encode_string(&verifier.to_bytes()).parse()?,
456 );
457 }
458
459 let client = BpxClient {
460 signing_key,
461 verifying_key,
462 base_url,
463 ws_url,
464 client: reqwest::Client::builder()
465 .user_agent(API_USER_AGENT)
466 .default_headers(header_map)
467 .build()?,
468 };
469
470 Ok(client)
471 }
472}
473
474fn now_millis() -> u64 {
476 SystemTime::now()
477 .duration_since(UNIX_EPOCH)
478 .expect("Time went backwards")
479 .as_millis() as u64
480}