use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use crate::core::{
Credentials, ExchangeResult, ExchangeError,
};
#[cfg(feature = "starknet")]
use starknet_crypto::{sign, get_public_key, rfc6979_generate_k, FieldElement};
const JWT_LIFETIME_SECS: u64 = 300;
const JWT_SAFETY_MARGIN_SECS: u64 = 30;
#[derive(Debug, Clone)]
struct JwtToken {
token: String,
issued_at: u64,
expires_at: u64,
}
impl JwtToken {
fn new_now(token: String) -> Self {
let now = current_timestamp_secs();
Self {
token,
issued_at: now,
expires_at: now + JWT_LIFETIME_SECS,
}
}
fn new_with_expiry(token: String, expires_at: u64) -> Self {
Self {
token,
issued_at: current_timestamp_secs(),
expires_at,
}
}
fn is_valid(&self) -> bool {
let now = current_timestamp_secs();
self.expires_at > now + JWT_SAFETY_MARGIN_SECS
}
fn is_expired(&self) -> bool {
let now = current_timestamp_secs();
now >= self.expires_at
}
fn secs_until_expiry(&self) -> u64 {
let now = current_timestamp_secs();
self.expires_at.saturating_sub(now)
}
fn age_secs(&self) -> u64 {
let now = current_timestamp_secs();
now.saturating_sub(self.issued_at)
}
}
fn current_timestamp_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn current_timestamp_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[derive(Clone)]
pub struct ParadexAuth {
jwt_token: Arc<RwLock<Option<JwtToken>>>,
#[allow(dead_code)]
account_address: Option<String>,
private_key: Option<String>,
time_offset_ms: Arc<RwLock<i64>>,
}
impl ParadexAuth {
pub fn new(credentials: &Credentials) -> ExchangeResult<Self> {
let jwt_token = if !credentials.api_key.is_empty() {
Some(JwtToken::new_now(credentials.api_key.clone()))
} else {
None
};
let (private_key, account_address) = if !credentials.api_secret.is_empty() {
let pk = Some(credentials.api_secret.clone());
let addr = credentials.passphrase.as_ref().and_then(|p| {
serde_json::from_str::<serde_json::Value>(p).ok()
.and_then(|v| v.get("account_address").and_then(|a| a.as_str()).map(|s| s.to_string()))
});
(pk, addr)
} else {
(None, None)
};
Ok(Self {
jwt_token: Arc::new(RwLock::new(jwt_token)),
account_address,
private_key,
time_offset_ms: Arc::new(RwLock::new(0)),
})
}
pub async fn sync_time(&self, server_time_ms: i64) {
let local_time = current_timestamp_millis() as i64;
let mut offset = self.time_offset_ms.write().await;
*offset = server_time_ms - local_time;
}
pub async fn get_timestamp(&self) -> u64 {
let local = current_timestamp_millis() as i64;
let offset = *self.time_offset_ms.read().await;
(local + offset) as u64
}
pub async fn set_jwt_token(&self, token: String) {
let mut jwt = self.jwt_token.write().await;
*jwt = Some(JwtToken::new_now(token));
}
pub async fn set_jwt_token_with_expiry(&self, token: String, expires_at: u64) {
let mut jwt = self.jwt_token.write().await;
*jwt = Some(JwtToken::new_with_expiry(token, expires_at));
}
pub async fn get_jwt_token(&self) -> ExchangeResult<String> {
let jwt = self.jwt_token.read().await;
jwt.as_ref()
.map(|t| t.token.clone())
.ok_or_else(|| ExchangeError::Auth(
"JWT token not set. Paradex requires authentication.".to_string()
))
}
pub async fn is_token_valid(&self) -> bool {
let jwt = self.jwt_token.read().await;
jwt.as_ref().is_some_and(|t| t.is_valid())
}
pub async fn is_token_expired(&self) -> bool {
let jwt = self.jwt_token.read().await;
jwt.as_ref().is_none_or(|t| t.is_expired())
}
pub async fn secs_until_expiry(&self) -> u64 {
let jwt = self.jwt_token.read().await;
jwt.as_ref().map_or(0, |t| t.secs_until_expiry())
}
pub async fn token_age_secs(&self) -> u64 {
let jwt = self.jwt_token.read().await;
jwt.as_ref().map_or(0, |t| t.age_secs())
}
#[cfg(feature = "starknet")]
pub fn sign_auth_request(&self, timestamp: u64) -> ExchangeResult<(String, String)> {
let private_key_hex = self.private_key.as_ref().ok_or_else(|| {
ExchangeError::Auth(
"StarkNet private key not configured. Provide api_secret as hex private key."
.to_string(),
)
})?;
let private_key = FieldElement::from_hex_be(private_key_hex)
.map_err(|e| ExchangeError::Auth(format!("Invalid StarkNet key: {}", e)))?;
let public_key = get_public_key(&private_key);
let message = FieldElement::from(timestamp);
let k = rfc6979_generate_k(&message, &private_key, None);
let signature = sign(&private_key, &message, &k)
.map_err(|e| ExchangeError::Auth(format!("StarkNet sign failed: {}", e)))?;
Ok((
format!("{:#x}", public_key),
format!("{:#x},{:#x}", signature.r, signature.s),
))
}
pub async fn refresh_if_needed(
&self,
_http_client: &reqwest::Client,
_base_url: &str,
) -> ExchangeResult<bool> {
if self.is_token_valid().await {
return Ok(false);
}
#[cfg(feature = "starknet")]
{
let timestamp_secs = self.get_timestamp().await / 1000;
let (public_key_hex, sig_str) = self.sign_auth_request(timestamp_secs)?;
let signature_header = {
let parts: Vec<&str> = sig_str.splitn(2, ',').collect();
if parts.len() == 2 {
format!("[{}, {}]", parts[0], parts[1])
} else {
format!("[{}]", sig_str)
}
};
let response = _http_client
.post(format!("{}/v1/auth", _base_url))
.header("PARADEX-STARKNET-ACCOUNT", &public_key_hex)
.header("PARADEX-STARKNET-SIGNATURE", &signature_header)
.header("PARADEX-TIMESTAMP", timestamp_secs.to_string())
.send()
.await
.map_err(|e| ExchangeError::Network(e.to_string()))?;
let body: serde_json::Value = response
.json()
.await
.map_err(|e| ExchangeError::Parse(e.to_string()))?;
let jwt = body["jwt_token"]
.as_str()
.ok_or_else(|| {
ExchangeError::Parse("Missing jwt_token in auth response".to_string())
})?;
let expires_at = body["expires_at"].as_u64().unwrap_or(0);
if expires_at > 0 {
self.set_jwt_token_with_expiry(jwt.to_string(), expires_at).await;
} else {
self.set_jwt_token(jwt.to_string()).await;
}
return Ok(true);
}
#[cfg(not(feature = "starknet"))]
Err(ExchangeError::Auth(
"JWT token expired. Paradex requires StarkNet signing for token refresh \
(enable the `starknet` feature or obtain a new JWT token externally \
and call set_jwt_token() to update it)."
.to_string(),
))
}
pub async fn sign_request(
&self,
_method: &str,
_endpoint: &str,
_body: &str,
) -> ExchangeResult<HashMap<String, String>> {
if !self.is_token_valid().await {
return Err(ExchangeError::Auth(
format!(
"JWT token expired or expiring soon ({}s remaining). \
Call refresh_if_needed() before signing.",
self.secs_until_expiry().await
)
));
}
let jwt = self.get_jwt_token().await?;
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), format!("Bearer {}", jwt));
headers.insert("Content-Type".to_string(), "application/json".to_string());
Ok(headers)
}
pub async fn sign_request_with_refresh(
&self,
method: &str,
endpoint: &str,
body: &str,
http_client: &reqwest::Client,
base_url: &str,
) -> ExchangeResult<HashMap<String, String>> {
self.refresh_if_needed(http_client, base_url).await?;
self.sign_request(method, endpoint, body).await
}
pub async fn get_timestamp_header(&self) -> u64 {
self.get_timestamp().await
}
pub fn has_private_key(&self) -> bool {
self.private_key.is_some()
}
pub fn has_account_address(&self) -> bool {
self.account_address.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_jwt_token() {
let credentials = Credentials::new("test_jwt_token", "");
let auth = ParadexAuth::new(&credentials).unwrap();
let token = auth.get_jwt_token().await.unwrap();
assert_eq!(token, "test_jwt_token");
}
#[tokio::test]
async fn test_sign_request() {
let credentials = Credentials::new("test_jwt_token", "");
let auth = ParadexAuth::new(&credentials).unwrap();
let headers = auth.sign_request("GET", "/account", "").await.unwrap();
assert!(headers.contains_key("Authorization"));
assert_eq!(
headers.get("Authorization"),
Some(&"Bearer test_jwt_token".to_string())
);
}
#[tokio::test]
async fn test_set_jwt_token() {
let credentials = Credentials::new("", "");
let auth = ParadexAuth::new(&credentials).unwrap();
assert!(auth.get_jwt_token().await.is_err());
auth.set_jwt_token("new_token".to_string()).await;
let token = auth.get_jwt_token().await.unwrap();
assert_eq!(token, "new_token");
}
#[tokio::test]
async fn test_token_validity() {
let credentials = Credentials::new("test_token", "");
let auth = ParadexAuth::new(&credentials).unwrap();
assert!(auth.is_token_valid().await);
assert!(!auth.is_token_expired().await);
let remaining = auth.secs_until_expiry().await;
assert!(remaining > JWT_SAFETY_MARGIN_SECS);
assert!(remaining <= JWT_LIFETIME_SECS);
}
#[tokio::test]
async fn test_token_with_explicit_expiry() {
let credentials = Credentials::new("", "");
let auth = ParadexAuth::new(&credentials).unwrap();
let future_expiry = current_timestamp_secs() + 600; auth.set_jwt_token_with_expiry("token_with_expiry".to_string(), future_expiry)
.await;
assert!(auth.is_token_valid().await);
let remaining = auth.secs_until_expiry().await;
assert!(remaining > 500); }
#[tokio::test]
async fn test_expired_token() {
let credentials = Credentials::new("", "");
let auth = ParadexAuth::new(&credentials).unwrap();
let past_expiry = current_timestamp_secs().saturating_sub(60); auth.set_jwt_token_with_expiry("expired_token".to_string(), past_expiry)
.await;
assert!(!auth.is_token_valid().await);
assert!(auth.is_token_expired().await);
assert_eq!(auth.secs_until_expiry().await, 0);
}
#[tokio::test]
async fn test_sign_request_fails_on_expired_token() {
let credentials = Credentials::new("", "");
let auth = ParadexAuth::new(&credentials).unwrap();
let past_expiry = current_timestamp_secs().saturating_sub(60);
auth.set_jwt_token_with_expiry("expired_token".to_string(), past_expiry)
.await;
let result = auth.sign_request("GET", "/account", "").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expired"));
}
#[tokio::test]
async fn test_no_token_fails_sign() {
let credentials = Credentials::new("", "");
let auth = ParadexAuth::new(&credentials).unwrap();
assert!(!auth.is_token_valid().await);
let result = auth.sign_request("GET", "/account", "").await;
assert!(result.is_err());
}
}