use std::sync::Arc;
use std::time::Duration;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256, Sha512};
use crate::auth::{Credentials, CredentialsProvider};
use crate::error::KrakenError;
use crate::futures::ws::endpoints;
use crate::futures::ws::stream::FuturesStream;
type HmacSha512 = Hmac<Sha512>;
#[derive(Debug, Clone)]
pub struct WsConfig {
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub max_reconnect_attempts: Option<u32>,
pub ping_interval: Duration,
pub pong_timeout: Duration,
}
impl Default for WsConfig {
fn default() -> Self {
Self {
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(60),
max_reconnect_attempts: None, ping_interval: Duration::from_secs(30),
pong_timeout: Duration::from_secs(10),
}
}
}
impl WsConfig {
pub fn builder() -> WsConfigBuilder {
WsConfigBuilder::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct WsConfigBuilder {
config: WsConfig,
}
impl WsConfigBuilder {
pub fn new() -> Self {
Self {
config: WsConfig::default(),
}
}
pub fn reconnect_backoff(mut self, initial: Duration, max: Duration) -> Self {
self.config.initial_backoff = initial;
self.config.max_backoff = max;
self
}
pub fn max_reconnect_attempts(mut self, attempts: u32) -> Self {
self.config.max_reconnect_attempts = Some(attempts);
self
}
pub fn ping_interval(mut self, interval: Duration) -> Self {
self.config.ping_interval = interval;
self
}
pub fn pong_timeout(mut self, timeout: Duration) -> Self {
self.config.pong_timeout = timeout;
self
}
pub fn build(self) -> WsConfig {
self.config
}
}
#[derive(Debug, Clone)]
pub struct FuturesWsClient {
url: String,
config: WsConfig,
}
impl FuturesWsClient {
pub fn new() -> Self {
Self::with_config(WsConfig::default())
}
pub fn with_config(config: WsConfig) -> Self {
Self {
url: endpoints::WS_PUBLIC.to_string(),
config,
}
}
pub fn demo() -> Self {
Self {
url: endpoints::WS_DEMO.to_string(),
config: WsConfig::default(),
}
}
pub fn with_url(url: impl Into<String>) -> Self {
Self {
url: url.into(),
config: WsConfig::default(),
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn config(&self) -> &WsConfig {
&self.config
}
pub async fn connect_public(&self) -> Result<FuturesStream, KrakenError> {
FuturesStream::connect_public(&self.url, self.config.clone()).await
}
pub async fn connect_private(
&self,
credentials: Arc<dyn CredentialsProvider>,
) -> Result<FuturesStream, KrakenError> {
FuturesStream::connect_private(&self.url, self.config.clone(), credentials).await
}
pub async fn connect_public_with_config(
&self,
config: WsConfig,
) -> Result<FuturesStream, KrakenError> {
FuturesStream::connect_public(&self.url, config).await
}
pub async fn connect_private_with_config(
&self,
credentials: Arc<dyn CredentialsProvider>,
config: WsConfig,
) -> Result<FuturesStream, KrakenError> {
FuturesStream::connect_private(&self.url, config, credentials).await
}
}
impl Default for FuturesWsClient {
fn default() -> Self {
Self::new()
}
}
pub fn sign_challenge(credentials: &Credentials, challenge: &str) -> Result<String, KrakenError> {
let secret_decoded = BASE64
.decode(credentials.expose_secret())
.map_err(|_| KrakenError::Auth("API secret must be valid base64.".to_string()))?;
let sha256_hash = Sha256::digest(challenge.as_bytes());
let mut hmac = HmacSha512::new_from_slice(&secret_decoded)
.map_err(|e| KrakenError::Auth(format!("Invalid HMAC key: {e}")))?;
hmac.update(&sha256_hash);
let hmac_result = hmac.finalize().into_bytes();
Ok(BASE64.encode(hmac_result))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sign_challenge() {
let secret = BASE64.encode("test_secret_key");
let credentials = Credentials::new("api_key", secret);
let signed = sign_challenge(&credentials, "123e4567-e89b-12d3-a456-426614174000").unwrap();
assert!(BASE64.decode(&signed).is_ok());
assert_eq!(signed.len(), 88);
}
#[test]
fn test_sign_challenge_consistency() {
let secret = BASE64.encode("my_secret");
let credentials = Credentials::new("key", secret);
let sig1 = sign_challenge(&credentials, "test-challenge").unwrap();
let sig2 = sign_challenge(&credentials, "test-challenge").unwrap();
assert_eq!(sig1, sig2);
}
#[test]
fn test_sign_challenge_different_challenges() {
let secret = BASE64.encode("my_secret");
let credentials = Credentials::new("key", secret);
let sig1 = sign_challenge(&credentials, "challenge-1").unwrap();
let sig2 = sign_challenge(&credentials, "challenge-2").unwrap();
assert_ne!(sig1, sig2);
}
#[test]
fn test_config_builder() {
let config = WsConfig::builder()
.reconnect_backoff(Duration::from_secs(2), Duration::from_secs(120))
.max_reconnect_attempts(5)
.ping_interval(Duration::from_secs(15))
.pong_timeout(Duration::from_secs(5))
.build();
assert_eq!(config.initial_backoff, Duration::from_secs(2));
assert_eq!(config.max_backoff, Duration::from_secs(120));
assert_eq!(config.max_reconnect_attempts, Some(5));
assert_eq!(config.ping_interval, Duration::from_secs(15));
assert_eq!(config.pong_timeout, Duration::from_secs(5));
}
#[test]
fn test_client_urls() {
let client = FuturesWsClient::new();
assert_eq!(client.url(), "wss://futures.kraken.com/ws/v1");
let demo = FuturesWsClient::demo();
assert_eq!(demo.url(), "wss://demo-futures.kraken.com/ws/v1");
let custom = FuturesWsClient::with_url("wss://custom.example.com");
assert_eq!(custom.url(), "wss://custom.example.com");
}
}