use std::fmt::Write;
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use zeroize::ZeroizeOnDrop;
use crate::error::{ExchangeError, Result};
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone, ZeroizeOnDrop)]
pub struct CryptocomCredentials {
pub api_key: String,
pub api_secret: String,
}
impl CryptocomCredentials {
pub fn new(api_key: impl Into<String>, api_secret: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
api_secret: api_secret.into(),
}
}
pub fn from_env() -> Result<Self> {
Ok(Self {
api_key: env("CRYPTOCOM_API_KEY")?,
api_secret: env("CRYPTOCOM_API_SECRET")?,
})
}
}
fn env(key: &str) -> Result<String> {
std::env::var(key).map_err(|_| ExchangeError::Config(format!("{key} not set")))
}
pub fn sign_cryptocom_request(
method: &str,
id: i64,
api_key: &str,
params: &Value,
nonce: i64,
api_secret: &str,
) -> Result<String> {
let params_string = build_params_string(params);
let payload = format!("{method}{id}{api_key}{params_string}{nonce}");
let mut mac = HmacSha256::new_from_slice(api_secret.as_bytes())
.map_err(|e| ExchangeError::Auth(format!("Crypto.com HMAC init failed: {e}")))?;
mac.update(payload.as_bytes());
Ok(hex_encode(&mac.finalize().into_bytes()))
}
#[must_use]
pub fn build_params_string(v: &Value) -> String {
let mut out = String::new();
append_value(&mut out, v, None);
out
}
fn append_value(out: &mut String, v: &Value, label: Option<&str>) {
if let Some(l) = label {
out.push_str(l);
}
match v {
Value::Null => {} Value::Bool(b) => write!(out, "{b}").unwrap(),
Value::Number(n) => write!(out, "{n}").unwrap(),
Value::String(s) => out.push_str(s),
Value::Array(arr) => {
for item in arr {
append_value(out, item, None);
}
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
append_value(out, &map[k], Some(k));
}
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
write!(s, "{b:02x}").unwrap();
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn params_string_sorts_keys_alphabetically() {
let p = json!({
"side": "BUY",
"instrument_name": "ETH_USDT",
"price": "0.025",
});
let s = build_params_string(&p);
assert_eq!(s, "instrument_nameETH_USDTprice0.025sideBUY");
}
#[test]
fn params_string_empty_object() {
assert_eq!(build_params_string(&json!({})), "");
}
#[test]
fn params_string_null_emits_nothing() {
assert_eq!(build_params_string(&Value::Null), "");
}
#[test]
fn params_string_serialises_numbers_and_bools() {
let p = json!({"a": 1, "b": true, "c": 1.5});
assert_eq!(build_params_string(&p), "a1btruec1.5");
}
#[test]
fn params_string_nested_object_recurses_with_label() {
let p = json!({
"outer": {"inner_b": "y", "inner_a": "x"},
"z": "last"
});
assert_eq!(build_params_string(&p), "outerinner_axinner_byzlast");
}
#[test]
fn signature_is_deterministic() {
let a = sign_cryptocom_request(
"private/get-account-summary",
11,
"key1",
&json!({"currency": "BTC"}),
1_700_000_000_000,
"secret",
)
.unwrap();
let b = sign_cryptocom_request(
"private/get-account-summary",
11,
"key1",
&json!({"currency": "BTC"}),
1_700_000_000_000,
"secret",
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn signature_is_sensitive_to_every_input() {
let secret = "secret";
let base = sign_cryptocom_request(
"private/m",
1,
"k",
&json!({"a": "x"}),
1_700_000_000_000,
secret,
)
.unwrap();
let by_method = sign_cryptocom_request(
"private/other",
1,
"k",
&json!({"a": "x"}),
1_700_000_000_000,
secret,
)
.unwrap();
assert_ne!(base, by_method);
let by_id = sign_cryptocom_request(
"private/m",
2,
"k",
&json!({"a": "x"}),
1_700_000_000_000,
secret,
)
.unwrap();
assert_ne!(base, by_id);
let by_key = sign_cryptocom_request(
"private/m",
1,
"k2",
&json!({"a": "x"}),
1_700_000_000_000,
secret,
)
.unwrap();
assert_ne!(base, by_key);
let by_params = sign_cryptocom_request(
"private/m",
1,
"k",
&json!({"a": "y"}),
1_700_000_000_000,
secret,
)
.unwrap();
assert_ne!(base, by_params);
let by_nonce = sign_cryptocom_request(
"private/m",
1,
"k",
&json!({"a": "x"}),
1_700_000_000_001,
secret,
)
.unwrap();
assert_ne!(base, by_nonce);
let by_secret = sign_cryptocom_request(
"private/m",
1,
"k",
&json!({"a": "x"}),
1_700_000_000_000,
"other_secret",
)
.unwrap();
assert_ne!(base, by_secret);
}
#[test]
fn signature_is_64_char_hex() {
let sig = sign_cryptocom_request(
"private/get-account-summary",
1,
"k",
&json!({}),
1_700_000_000_000,
"secret",
)
.unwrap();
assert_eq!(sig.len(), 64);
assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn credentials_round_trip() {
let c = CryptocomCredentials::new("my-key", "my-secret");
assert_eq!(c.api_key, "my-key");
assert_eq!(c.api_secret, "my-secret");
}
}