1use base64::{engine::general_purpose::STANDARD, Engine};
37use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
38use reqwest::{header::CONTENT_TYPE, IntoUrl, Method, Request, Response, StatusCode};
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_DEPOSITS, API_DEPOSIT_ADDRESS, API_WITHDRAWALS},
43 futures::API_FUTURES_POSITION,
44 order::{API_ORDER, API_ORDERS},
45 rfq::{API_RFQ, API_RFQ_QUOTE},
46 user::API_USER_2FA,
47};
48use serde::Serialize;
49use serde_json::Value;
50use std::{
51 borrow::Cow,
52 collections::BTreeMap,
53 time::{SystemTime, UNIX_EPOCH},
54};
55
56pub mod error;
57
58mod routes;
59
60#[cfg(feature = "ws")]
61mod ws;
62
63pub use bpx_api_types as types;
65
66pub use error::{Error, Result};
68
69const API_USER_AGENT: &str = "bpx-rust-client";
70const API_KEY_HEADER: &str = "X-API-Key";
71
72const DEFAULT_WINDOW: u32 = 5000;
73
74const SIGNATURE_HEADER: &str = "X-Signature";
75const TIMESTAMP_HEADER: &str = "X-Timestamp";
76const WINDOW_HEADER: &str = "X-Window";
77
78const JSON_CONTENT: &str = "application/json; charset=utf-8";
79
80pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
82
83pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
85
86pub type BpxHeaders = reqwest::header::HeaderMap;
88
89#[derive(Debug, Clone)]
91pub struct BpxClient {
92 signer: SigningKey,
93 verifier: VerifyingKey,
94 base_url: String,
95 #[allow(dead_code)]
96 ws_url: Option<String>,
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 init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
127 Self::init_internal(base_url, None, secret, headers)
128 }
129
130 #[cfg(feature = "ws")]
132 pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
133 Self::init_internal(base_url, Some(ws_url), secret, headers)
134 }
135
136 fn init_internal(
138 base_url: String,
139 ws_url: Option<String>,
140 secret: &str,
141 headers: Option<BpxHeaders>,
142 ) -> Result<Self> {
143 let signer = STANDARD
144 .decode(secret)?
145 .try_into()
146 .map(|s| SigningKey::from_bytes(&s))
147 .map_err(|_| Error::SecretKey)?;
148
149 let verifier = signer.verifying_key();
150
151 let mut headers = headers.unwrap_or_default();
152 headers.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
153 headers.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
154
155 let client = reqwest::Client::builder()
156 .user_agent(API_USER_AGENT)
157 .default_headers(headers)
158 .build()?;
159
160 Ok(BpxClient {
161 signer,
162 verifier,
163 base_url,
164 ws_url,
165 client,
166 })
167 }
168
169 pub fn create_headers() -> BpxHeaders {
171 reqwest::header::HeaderMap::new()
172 }
173
174 async fn process_response(res: Response) -> Result<Response> {
179 if let Err(e) = res.error_for_status_ref() {
180 let err_text = res.text().await?;
181 let err = Error::BpxApiError {
182 status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
183 message: err_text.into(),
184 };
185 return Err(err);
186 }
187 Ok(res)
188 }
189
190 pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
192 let req = self.build_and_maybe_sign_request::<(), _>(url, Method::GET, None)?;
193 tracing::debug!("req: {:?}", req);
194 let res = self.client.execute(req).await?;
195 Self::process_response(res).await
196 }
197
198 pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
200 let req = self.build_and_maybe_sign_request(url, Method::POST, Some(&payload))?;
201 tracing::debug!("req: {:?}", req);
202 let res = self.client.execute(req).await?;
203 Self::process_response(res).await
204 }
205
206 pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
208 let req = self.build_and_maybe_sign_request(url, Method::DELETE, Some(&payload))?;
209 tracing::debug!("req: {:?}", req);
210 let res = self.client.execute(req).await?;
211 Self::process_response(res).await
212 }
213
214 pub async fn patch<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
216 let req = self.build_and_maybe_sign_request(url, Method::PATCH, Some(&payload))?;
217 tracing::debug!("req: {:?}", req);
218 let res = self.client.execute(req).await?;
219 Self::process_response(res).await
220 }
221
222 pub const fn verifier(&self) -> &VerifyingKey {
224 &self.verifier
225 }
226
227 pub const fn client(&self) -> &reqwest::Client {
229 &self.client
230 }
231}
232
233impl BpxClient {
235 fn build_and_maybe_sign_request<P: Serialize, U: IntoUrl>(
241 &self,
242 url: U,
243 method: Method,
244 payload: Option<&P>,
245 ) -> Result<Request> {
246 let url = url.into_url()?;
247 let instruction = match url.path() {
248 API_CAPITAL if method == Method::GET => "balanceQuery",
249 API_DEPOSITS if method == Method::GET => "depositQueryAll",
250 API_DEPOSIT_ADDRESS if method == Method::GET => "depositAddressQuery",
251 API_WITHDRAWALS if method == Method::GET => "withdrawalQueryAll",
252 API_WITHDRAWALS if method == Method::POST => "withdraw",
253 API_USER_2FA if method == Method::POST => "issueTwoFactorToken",
254 API_ORDER if method == Method::GET => "orderQuery",
255 API_ORDER if method == Method::POST => "orderExecute",
256 API_ORDER if method == Method::DELETE => "orderCancel",
257 API_ORDERS if method == Method::GET => "orderQueryAll",
258 API_ORDERS if method == Method::DELETE => "orderCancelAll",
259 API_RFQ if method == Method::POST => "rfqSubmit",
260 API_RFQ_QUOTE if method == Method::POST => "quoteSubmit",
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_WITHDRAWAL if method == Method::GET => "maxWithdrawalQuantity",
267 API_ACCOUNT if method == Method::PATCH => "accountUpdate",
268 API_ACCOUNT_CONVERT_DUST if method == Method::POST => "convertDust",
269 _ => {
270 let req = self.client().request(method, url);
271 if let Some(payload) = payload {
272 return Ok(req.json(payload).build()?);
273 } else {
274 return Ok(req.build()?);
275 }
276 }
277 };
278
279 let query_params = url.query_pairs().collect::<BTreeMap<Cow<'_, str>, Cow<'_, str>>>();
280 let body_params = if let Some(payload) = payload {
281 let s = serde_json::to_value(payload)?;
282 match s {
283 Value::Object(map) => map
284 .into_iter()
285 .map(|(k, v)| (k, v.to_string()))
286 .collect::<BTreeMap<_, _>>(),
287 _ => return Err(Error::InvalidRequest("payload must be a JSON object".into())),
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 = self.signer.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().insert(SIGNATURE_HEADER, signature.parse()?);
314 req.headers_mut()
315 .insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
316 req.headers_mut()
317 .insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
318 if matches!(req.method(), &Method::POST | &Method::DELETE) {
319 req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
320 }
321 Ok(req)
322 }
323}
324
325fn now_millis() -> u64 {
327 SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .expect("Time went backwards")
330 .as_millis() as u64
331}