use std::sync::Arc;
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::time::Duration;
use reqwest::Client;
use serde_json::{Value, json};
use tracing::{debug, info};
use crate::cryptocom::auth::{CryptocomCredentials, sign_cryptocom_request};
use crate::cryptocom::rest::unwrap_cryptocom_envelope;
use crate::error::{ExchangeError, Result};
const BASE_URL: &str = "https://api.crypto.com/exchange/v1";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 10;
#[derive(Clone)]
pub struct CryptocomPrivateClient {
http: Client,
base_url: String,
credentials: CryptocomCredentials,
next_id: Arc<AtomicI64>,
nonce_state: Arc<AtomicU64>,
}
impl CryptocomPrivateClient {
pub fn new(credentials: CryptocomCredentials) -> Result<Self> {
Self::with_base_url(credentials, BASE_URL)
}
pub fn with_base_url(
credentials: CryptocomCredentials,
base_url: impl Into<String>,
) -> Result<Self> {
let http = Client::builder()
.timeout(Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS))
.build()
.map_err(|e| ExchangeError::Config(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
http,
base_url: base_url.into(),
credentials,
next_id: Arc::new(AtomicI64::new(1)),
nonce_state: Arc::new(AtomicU64::new(0)),
})
}
fn next_id(&self) -> i64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
}
fn next_nonce(&self) -> i64 {
let now_ms = u64::try_from(chrono::Utc::now().timestamp_millis().max(0)).unwrap_or(0);
self.nonce_state.fetch_max(now_ms, Ordering::SeqCst);
let next = self.nonce_state.fetch_add(1, Ordering::SeqCst);
i64::try_from(next).unwrap_or(i64::MAX)
}
async fn post<T: serde::de::DeserializeOwned>(&self, method: &str, params: Value) -> Result<T> {
let id = self.next_id();
let nonce = self.next_nonce();
let sig = sign_cryptocom_request(
method,
id,
&self.credentials.api_key,
¶ms,
nonce,
&self.credentials.api_secret,
)?;
let body = json!({
"id": id,
"method": method,
"api_key": self.credentials.api_key,
"params": params,
"nonce": nonce,
"sig": sig,
});
debug!(method, id, "Crypto.com private POST");
let url = format!("{}/{method}", self.base_url);
let resp = self.http.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let code = resp.status().as_u16().to_string();
let message = resp
.text()
.await
.unwrap_or_else(|_| String::from("no body"));
return Err(ExchangeError::Api { code, message });
}
let raw: Value = resp.json().await?;
unwrap_cryptocom_envelope(raw)
}
pub async fn get_account_summary(&self, currency: Option<&str>) -> Result<Value> {
let mut params = serde_json::Map::new();
if let Some(c) = currency {
params.insert("currency".into(), Value::String(c.to_string()));
}
self.post("private/get-account-summary", Value::Object(params))
.await
}
#[allow(clippy::too_many_arguments, clippy::similar_names)]
pub async fn place_order(
&self,
instrument: &str,
side: &str, order_type: &str, quantity: &str,
price: Option<&str>,
) -> Result<Value> {
info!(
instrument,
side,
order_type,
quantity,
?price,
"Crypto.com place order"
);
let mut params = json!({
"instrument_name": instrument,
"side": side,
"type": order_type,
"quantity": quantity,
});
if let Some(p) = price {
params["price"] = Value::String(p.to_string());
}
self.post("private/create-order", params).await
}
pub async fn cancel_order(&self, instrument: &str, order_id: &str) -> Result<Value> {
info!(instrument, order_id, "Crypto.com cancel order");
self.post(
"private/cancel-order",
json!({"instrument_name": instrument, "order_id": order_id}),
)
.await
}
pub async fn cancel_all_orders(&self, instrument: &str) -> Result<Value> {
info!(instrument, "Crypto.com cancel ALL open orders");
self.post(
"private/cancel-all-orders",
json!({"instrument_name": instrument}),
)
.await
}
pub async fn get_open_orders(&self, instrument: Option<&str>) -> Result<Value> {
let mut params = serde_json::Map::new();
if let Some(i) = instrument {
params.insert("instrument_name".into(), Value::String(i.to_string()));
}
self.post("private/get-open-orders", Value::Object(params))
.await
}
pub async fn get_order_detail(&self, order_id: &str) -> Result<Value> {
self.post("private/get-order-detail", json!({"order_id": order_id}))
.await
}
pub async fn get_trades(&self, instrument: Option<&str>) -> Result<Value> {
let mut params = serde_json::Map::new();
if let Some(i) = instrument {
params.insert("instrument_name".into(), Value::String(i.to_string()));
}
self.post("private/get-trades", Value::Object(params)).await
}
pub async fn get_deposit_address(&self, currency: &str) -> Result<Value> {
self.post("private/get-deposit-address", json!({"currency": currency}))
.await
}
pub async fn create_withdrawal(
&self,
currency: &str,
amount: &str,
address: &str,
) -> Result<Value> {
info!(currency, amount, address, "Crypto.com create withdrawal");
self.post(
"private/create-withdrawal",
json!({
"currency": currency,
"amount": amount,
"address": address,
}),
)
.await
}
pub async fn get_withdrawal_history(&self, currency: &str) -> Result<Value> {
self.post(
"private/get-withdrawal-history",
json!({"currency": currency}),
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sim_client(base_url: &str) -> CryptocomPrivateClient {
CryptocomPrivateClient::with_base_url(
CryptocomCredentials::new("sim-key", "sim-secret"),
base_url,
)
.expect("client build")
}
#[test]
fn nonce_is_strictly_increasing_across_calls() {
let c = sim_client("http://example.invalid");
let mut prev = 0_i64;
for _ in 0..1000 {
let n = c.next_nonce();
assert!(n > prev, "nonce did not increase: {prev} -> {n}");
prev = n;
}
}
#[test]
fn id_is_strictly_increasing_across_calls() {
let c = sim_client("http://example.invalid");
let a = c.next_id();
let b = c.next_id();
let c2 = c.next_id();
assert!(a < b && b < c2);
}
}