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