use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use reqwest::{header::CONTENT_TYPE, IntoUrl, Method, Request, Response, StatusCode};
use routes::{
capital::{API_CAPITAL, API_DEPOSITS, API_DEPOSIT_ADDRESS, API_WITHDRAWALS},
order::{API_ORDER, API_ORDERS},
rfq::{API_RFQ, API_RFQ_QUOTE},
user::API_USER_2FA,
};
use serde::Serialize;
use std::{
collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH},
};
pub mod error;
mod routes;
#[cfg(feature = "ws")]
mod ws;
pub use bpx_api_types as types;
pub use error::{Error, Result};
const API_USER_AGENT: &str = "bpx-rust-client";
const API_KEY_HEADER: &str = "X-API-Key";
const DEFAULT_WINDOW: u32 = 5000;
const SIGNATURE_HEADER: &str = "X-Signature";
const TIMESTAMP_HEADER: &str = "X-Timestamp";
const WINDOW_HEADER: &str = "X-Window";
const JSON_CONTENT: &str = "application/json; charset=utf-8";
pub const BACKPACK_API_BASE_URL: &str = "https://api.backpack.exchange";
pub const BACKPACK_WS_URL: &str = "wss://ws.backpack.exchange";
pub type BpxHeaders = reqwest::header::HeaderMap;
#[derive(Debug, Clone)]
pub struct BpxClient {
signer: SigningKey,
verifier: VerifyingKey,
base_url: String,
ws_url: Option<String>,
client: reqwest::Client,
}
impl std::ops::Deref for BpxClient {
type Target = reqwest::Client;
fn deref(&self) -> &Self::Target {
&self.client
}
}
impl std::ops::DerefMut for BpxClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.client
}
}
impl AsRef<reqwest::Client> for BpxClient {
fn as_ref(&self) -> &reqwest::Client {
&self.client
}
}
impl BpxClient {
pub fn init(base_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
Self::init_internal(base_url, None, secret, headers)
}
#[cfg(feature = "ws")]
pub fn init_with_ws(base_url: String, ws_url: String, secret: &str, headers: Option<BpxHeaders>) -> Result<Self> {
Self::init_internal(base_url, Some(ws_url), secret, headers)
}
fn init_internal(
base_url: String,
ws_url: Option<String>,
secret: &str,
headers: Option<BpxHeaders>,
) -> Result<Self> {
let signer = STANDARD
.decode(secret)?
.try_into()
.map(|s| SigningKey::from_bytes(&s))
.map_err(|_| Error::SecretKey)?;
let verifier = signer.verifying_key();
let mut headers = headers.unwrap_or_default();
headers.insert(API_KEY_HEADER, STANDARD.encode(verifier).parse()?);
headers.insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
let client = reqwest::Client::builder()
.user_agent(API_USER_AGENT)
.default_headers(headers)
.build()?;
Ok(BpxClient {
signer,
verifier,
base_url,
ws_url,
client,
})
}
pub fn create_headers() -> BpxHeaders {
reqwest::header::HeaderMap::new()
}
async fn process_response(res: Response) -> Result<Response> {
if let Err(e) = res.error_for_status_ref() {
let err_text = res.text().await?;
let err = Error::BpxApiError {
status_code: e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
message: err_text,
};
return Err(err);
}
Ok(res)
}
pub async fn get<U: IntoUrl>(&self, url: U) -> Result<Response> {
let mut req = self.client.get(url).build()?;
tracing::debug!("req: {:?}", req);
self.sign(&mut req)?;
let res = self.client.execute(req).await?;
Self::process_response(res).await
}
pub async fn post<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
let mut req = self.client.post(url).json(&payload).build()?;
tracing::debug!("req: {:?}", req);
self.sign(&mut req)?;
let res = self.client.execute(req).await?;
Self::process_response(res).await
}
pub async fn delete<P: Serialize, U: IntoUrl>(&self, url: U, payload: P) -> Result<Response> {
let mut req = self.client.delete(url).json(&payload).build()?;
tracing::debug!("req: {:?}", req);
self.sign(&mut req)?;
let res = self.client.execute(req).await?;
Self::process_response(res).await
}
pub fn verifier(&self) -> &VerifyingKey {
&self.verifier
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
}
impl BpxClient {
fn sign(&self, req: &mut Request) -> Result<()> {
let instruction = match req.url().path() {
API_CAPITAL if req.method() == Method::GET => "balanceQuery",
API_DEPOSITS if req.method() == Method::GET => "depositQueryAll",
API_DEPOSIT_ADDRESS if req.method() == Method::GET => "depositAddressQuery",
API_WITHDRAWALS if req.method() == Method::GET => "withdrawalQueryAll",
API_WITHDRAWALS if req.method() == Method::POST => "withdraw",
API_USER_2FA if req.method() == Method::POST => "issueTwoFactorToken",
API_ORDER if req.method() == Method::GET => "orderQuery",
API_ORDER if req.method() == Method::POST => "orderExecute",
API_ORDER if req.method() == Method::DELETE => "orderCancel",
API_ORDERS if req.method() == Method::GET => "orderQueryAll",
API_ORDERS if req.method() == Method::DELETE => "orderCancelAll",
API_RFQ if req.method() == Method::POST => "rfqSubmit",
API_RFQ_QUOTE if req.method() == Method::POST => "quoteSubmit",
_ => return Ok(()), };
let query_params = req
.url()
.query_pairs()
.map(|(x, y)| (x.into_owned(), y.into_owned()))
.collect::<BTreeMap<String, String>>();
let body_params = if let Some(b) = req.body() {
let s = std::str::from_utf8(b.as_bytes().unwrap_or_default())?;
serde_json::from_str::<BTreeMap<String, String>>(s)?
} else {
BTreeMap::new()
};
let timestamp = now_millis();
let mut signee = format!("instruction={instruction}");
for (k, v) in query_params {
signee.push_str(&format!("&{k}={v}"));
}
for (k, v) in body_params {
signee.push_str(&format!("&{k}={v}"));
}
signee.push_str(&format!("×tamp={timestamp}&window={DEFAULT_WINDOW}"));
tracing::debug!("signee: {}", signee);
let signature: Signature = self.signer.sign(signee.as_bytes());
let signature = STANDARD.encode(signature.to_bytes());
req.headers_mut().insert(SIGNATURE_HEADER, signature.parse()?);
req.headers_mut()
.insert(TIMESTAMP_HEADER, timestamp.to_string().parse()?);
req.headers_mut()
.insert(WINDOW_HEADER, DEFAULT_WINDOW.to_string().parse()?);
if matches!(req.method(), &Method::POST | &Method::DELETE) {
req.headers_mut().insert(CONTENT_TYPE, JSON_CONTENT.parse()?);
}
Ok(())
}
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_millis() as u64
}