diff --git a/ccxt-core/src/lib.rs b/ccxt-core/src/lib.rs index 8b0be89..9944607 100644 --- a/ccxt-core/src/lib.rs +++ b/ccxt-core/src/lib.rs @@ -95,6 +95,7 @@ pub mod parser_utils; pub mod precision; pub mod rate_limiter; pub mod retry_strategy; +pub mod signed_request; pub mod symbol; pub mod time; /// Exchange trait hierarchy for modular capability composition diff --git a/ccxt-core/src/signed_request.rs b/ccxt-core/src/signed_request.rs new file mode 100644 index 0000000..cbfaafa --- /dev/null +++ b/ccxt-core/src/signed_request.rs @@ -0,0 +1,358 @@ +//! Generic signed request builder for authenticated API requests. +//! +//! This module provides a reusable infrastructure for building authenticated +//! requests across different cryptocurrency exchanges. Each exchange implements +//! the [`SigningStrategy`] trait to define its specific signing behavior. +//! +//! # Architecture +//! +//! - [`SignedRequestBuilder`]: Generic builder handling parameter management and request execution +//! - [`SigningStrategy`]: Trait for exchange-specific signing (timestamp, signature, headers) +//! - [`HasHttpClient`]: Trait for exchanges that can execute HTTP requests +//! - [`SigningContext`]: Data structure containing all signing-related information +//! +//! # Example +//! +//! ```rust,ignore +//! // Exchange implements HasHttpClient and provides a SigningStrategy +//! let response = exchange +//! .signed_request("/api/v3/account") +//! .param("symbol", "BTCUSDT") +//! .optional_param("limit", Some(100)) +//! .execute() +//! .await?; +//! ``` + +use crate::error::Result; +use crate::http_client::HttpClient; +use async_trait::async_trait; +use reqwest::header::HeaderMap; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// HTTP request methods supported by signed requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HttpMethod { + /// GET request - parameters in query string + #[default] + Get, + /// POST request - parameters in JSON body + Post, + /// PUT request - parameters in JSON body + Put, + /// DELETE request - parameters in JSON body + Delete, +} + +impl HttpMethod { + /// Convert to uppercase string representation. + pub fn as_str(&self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + } + } +} + +/// Context containing all data needed for request signing. +#[derive(Debug, Clone)] +pub struct SigningContext { + /// HTTP method for the request. + pub method: HttpMethod, + /// API endpoint path (e.g., "/api/v3/account"). + pub endpoint: String, + /// Request parameters (BTreeMap for deterministic ordering). + pub params: BTreeMap, + /// Optional JSON body for POST/PUT/DELETE. + pub body: Option, + /// Timestamp string (format varies by exchange). + pub timestamp: String, + /// Computed signature (set by SigningStrategy). + pub signature: Option, +} + +impl SigningContext { + /// Create a new signing context. + pub fn new(method: HttpMethod, endpoint: String) -> Self { + Self { + method, + endpoint, + params: BTreeMap::new(), + body: None, + timestamp: String::new(), + signature: None, + } + } +} + +/// Trait for exchange-specific request signing strategies. +/// +/// Implementors define how to: +/// - Generate timestamps in exchange-specific format +/// - Compute request signatures +/// - Add authentication headers +#[async_trait] +pub trait SigningStrategy: Send + Sync { + /// Prepare the request for signing. + /// + /// This method should: + /// 1. Generate timestamp in exchange-specific format + /// 2. Compute signature based on method, path, params, body + /// 3. Update ctx.timestamp and ctx.signature + /// 4. Optionally modify ctx.params (e.g., add timestamp param) + async fn prepare_request(&self, ctx: &mut SigningContext) -> Result<()>; + + /// Add authentication headers to the request. + /// + /// Called after prepare_request(). Should add all required + /// authentication headers (API key, signature, timestamp, etc.) + fn add_auth_headers(&self, headers: &mut HeaderMap, ctx: &SigningContext); +} + +/// Trait for exchanges that can execute HTTP requests. +pub trait HasHttpClient { + /// Get reference to the HTTP client. + fn http_client(&self) -> &HttpClient; + + /// Get the base URL for API requests. + fn base_url(&self) -> &'static str; +} + +/// Generic builder for creating authenticated API requests. +/// +/// This builder encapsulates the common workflow: +/// 1. Parameter building with fluent API +/// 2. Request signing via [`SigningStrategy`] +/// 3. HTTP execution via [`HasHttpClient`] +pub struct SignedRequestBuilder<'a, E, S> +where + E: HasHttpClient, + S: SigningStrategy, +{ + exchange: &'a E, + strategy: S, + params: BTreeMap, + body: Option, + endpoint: String, + method: HttpMethod, +} + +impl<'a, E, S> SignedRequestBuilder<'a, E, S> +where + E: HasHttpClient, + S: SigningStrategy, +{ + /// Create a new signed request builder. + pub fn new(exchange: &'a E, strategy: S, endpoint: impl Into) -> Self { + Self { + exchange, + strategy, + params: BTreeMap::new(), + body: None, + endpoint: endpoint.into(), + method: HttpMethod::default(), + } + } + + /// Set the HTTP method (default: GET). + pub fn method(mut self, method: HttpMethod) -> Self { + self.method = method; + self + } + + /// Add a required parameter. + pub fn param(mut self, key: impl Into, value: &dyn ToString) -> Self { + self.params.insert(key.into(), value.to_string()); + self + } + + /// Add an optional parameter (only if Some). + pub fn optional_param(mut self, key: impl Into, value: Option) -> Self { + if let Some(v) = value { + self.params.insert(key.into(), v.to_string()); + } + self + } + + /// Add multiple parameters from a BTreeMap. + pub fn params(mut self, params: BTreeMap) -> Self { + self.params.extend(params); + self + } + + /// Set the request body (for POST/PUT/DELETE). + pub fn body(mut self, body: Value) -> Self { + self.body = Some(body); + self + } + + /// Merge parameters from a JSON Value object. + /// + /// Only string, number, and boolean values are supported. + /// Nested objects and arrays are ignored. + pub fn merge_json_params(mut self, params: Option) -> Self { + if let Some(Value::Object(map)) = params { + for (key, value) in map { + let string_value = match value { + Value::String(s) => s, + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + _ => continue, + }; + self.params.insert(key, string_value); + } + } + self + } + + /// Execute the signed request. + /// + /// # Steps + /// 1. Create SigningContext from builder state + /// 2. Call strategy.prepare_request() to sign + /// 3. Build headers via strategy.add_auth_headers() + /// 4. Execute HTTP request via exchange.http_client() + pub async fn execute(self) -> Result { + // Create signing context + let mut ctx = SigningContext { + method: self.method, + endpoint: self.endpoint.clone(), + params: self.params, + body: self.body, + timestamp: String::new(), + signature: None, + }; + + // Prepare request (generate timestamp, compute signature) + self.strategy.prepare_request(&mut ctx).await?; + + // Build headers + let mut headers = HeaderMap::new(); + self.strategy.add_auth_headers(&mut headers, &ctx); + + // Build URL + let base_url = self.exchange.base_url(); + let full_url = format!("{base_url}{}", self.endpoint); + + // Execute request based on method + let client = self.exchange.http_client(); + + match self.method { + HttpMethod::Get => { + let query_string = build_query_string(&ctx.params); + let url = if query_string.is_empty() { + full_url + } else { + format!("{full_url}?{query_string}") + }; + client.get(&url, Some(headers)).await + } + HttpMethod::Post => { + let body = ctx.body.unwrap_or_else(|| { + serde_json::to_value(&ctx.params).unwrap_or(Value::Object(Map::default())) + }); + client.post(&full_url, Some(headers), Some(body)).await + } + HttpMethod::Put => { + let body = ctx.body.unwrap_or_else(|| { + serde_json::to_value(&ctx.params).unwrap_or(Value::Object(Map::default())) + }); + client.put(&full_url, Some(headers), Some(body)).await + } + HttpMethod::Delete => { + let body = ctx.body.unwrap_or_else(|| { + serde_json::to_value(&ctx.params).unwrap_or(Value::Object(Map::default())) + }); + client.delete(&full_url, Some(headers), Some(body)).await + } + } + } +} + +/// Build URL-encoded query string from parameters. +/// +/// Parameters are sorted alphabetically (BTreeMap ordering). +pub fn build_query_string(params: &BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{k}={}", urlencoding::encode(v))) + .collect::>() + .join("&") +} + +/// Build query string without URL encoding (for signing). +pub fn build_query_string_raw(params: &BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_http_method_default() { + assert_eq!(HttpMethod::default(), HttpMethod::Get); + } + + #[test] + fn test_http_method_as_str() { + assert_eq!(HttpMethod::Get.as_str(), "GET"); + assert_eq!(HttpMethod::Post.as_str(), "POST"); + assert_eq!(HttpMethod::Put.as_str(), "PUT"); + assert_eq!(HttpMethod::Delete.as_str(), "DELETE"); + } + + #[test] + fn test_signing_context_new() { + let ctx = SigningContext::new(HttpMethod::Post, "/api/test".to_string()); + assert_eq!(ctx.method, HttpMethod::Post); + assert_eq!(ctx.endpoint, "/api/test"); + assert!(ctx.params.is_empty()); + assert!(ctx.body.is_none()); + assert!(ctx.timestamp.is_empty()); + assert!(ctx.signature.is_none()); + } + + #[test] + fn test_build_query_string() { + let mut params = BTreeMap::new(); + params.insert("symbol".to_string(), "BTCUSDT".to_string()); + params.insert("side".to_string(), "BUY".to_string()); + params.insert("amount".to_string(), "1.5".to_string()); + + let query = build_query_string(¶ms); + // BTreeMap maintains alphabetical order + assert_eq!(query, "amount=1.5&side=BUY&symbol=BTCUSDT"); + } + + #[test] + fn test_build_query_string_empty() { + let params = BTreeMap::new(); + let query = build_query_string(¶ms); + assert!(query.is_empty()); + } + + #[test] + fn test_build_query_string_with_special_chars() { + let mut params = BTreeMap::new(); + params.insert("symbol".to_string(), "BTC/USDT".to_string()); + + let query = build_query_string(¶ms); + assert_eq!(query, "symbol=BTC%2FUSDT"); + } + + #[test] + fn test_build_query_string_raw() { + let mut params = BTreeMap::new(); + params.insert("symbol".to_string(), "BTC/USDT".to_string()); + + let query = build_query_string_raw(¶ms); + assert_eq!(query, "symbol=BTC/USDT"); + } +} diff --git a/ccxt-exchanges/src/binance/exchange_impl.rs b/ccxt-exchanges/src/binance/exchange_impl.rs index b9f17dc..c67858d 100644 --- a/ccxt-exchanges/src/binance/exchange_impl.rs +++ b/ccxt-exchanges/src/binance/exchange_impl.rs @@ -1185,3 +1185,15 @@ mod tests { } } } + +// Implement HasHttpClient trait for generic SignedRequestBuilder support +impl ccxt_core::signed_request::HasHttpClient for Binance { + fn http_client(&self) -> &ccxt_core::http_client::HttpClient { + &self.base().http_client + } + + fn base_url(&self) -> &'static str { + // Return empty string since Binance uses full URLs in endpoints + "" + } +} diff --git a/ccxt-exchanges/src/binance/mod.rs b/ccxt-exchanges/src/binance/mod.rs index 93083ec..fd183b2 100644 --- a/ccxt-exchanges/src/binance/mod.rs +++ b/ccxt-exchanges/src/binance/mod.rs @@ -18,6 +18,7 @@ pub mod options; pub mod parser; pub mod rest; pub mod signed_request; +pub mod signing_strategy; pub mod symbol; pub mod time_sync; pub mod urls; diff --git a/ccxt-exchanges/src/binance/signing_strategy.rs b/ccxt-exchanges/src/binance/signing_strategy.rs new file mode 100644 index 0000000..cf6be49 --- /dev/null +++ b/ccxt-exchanges/src/binance/signing_strategy.rs @@ -0,0 +1,113 @@ +//! Binance signing strategy for the generic SignedRequestBuilder. +//! +//! Implements the SigningStrategy trait for Binance-specific authentication. + +use crate::binance::Binance; +use async_trait::async_trait; +use ccxt_core::Result; +use ccxt_core::signed_request::{SigningContext, SigningStrategy}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use std::sync::Arc; + +/// Binance signing strategy implementing the generic SigningStrategy trait. +/// +/// Handles Binance-specific signing requirements: +/// - Millisecond timestamp with server time offset +/// - HMAC-SHA256 signature (hex encoded) +/// - X-MBX-APIKEY header for authentication +pub struct BinanceSigningStrategy { + /// Reference to the Binance exchange for accessing auth and time sync + binance: Arc, +} + +impl BinanceSigningStrategy { + /// Create a new Binance signing strategy. + pub fn new(binance: Arc) -> Self { + Self { binance } + } + + /// Create from a reference (clones the Arc internally). + pub fn from_ref(binance: &Arc) -> Self { + Self { + binance: binance.clone(), + } + } +} + +#[async_trait] +impl SigningStrategy for BinanceSigningStrategy { + async fn prepare_request(&self, ctx: &mut SigningContext) -> Result<()> { + // Step 1: Validate credentials + self.binance.check_required_credentials()?; + + // Step 2: Get signing timestamp (with server time offset) + let timestamp = self.binance.get_signing_timestamp().await?; + ctx.timestamp = timestamp.to_string(); + + // Step 3: Add timestamp and recvWindow to params + ctx.params + .insert("timestamp".to_string(), ctx.timestamp.clone()); + ctx.params.insert( + "recvWindow".to_string(), + self.binance.options().recv_window.to_string(), + ); + + // Step 4: Get auth and compute signature + let auth = self.binance.get_auth()?; + let query_string = build_signing_string(&ctx.params); + let signature = auth.sign(&query_string)?; + + // Step 5: Add signature to params + ctx.signature = Some(signature.clone()); + ctx.params.insert("signature".to_string(), signature); + + Ok(()) + } + + fn add_auth_headers(&self, headers: &mut HeaderMap, _ctx: &SigningContext) { + // Add X-MBX-APIKEY header + if let Ok(auth) = self.binance.get_auth() { + if let Ok(header_name) = HeaderName::from_bytes(b"X-MBX-APIKEY") { + if let Ok(header_value) = HeaderValue::from_str(auth.api_key()) { + headers.insert(header_name, header_value); + } + } + } + } +} + +/// Build query string for signing (without URL encoding). +fn build_signing_string(params: &std::collections::BTreeMap) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&") +} + +#[cfg(test)] +mod tests { + use super::*; + use ccxt_core::ExchangeConfig; + + #[test] + fn test_signing_strategy_creation() { + let config = ExchangeConfig::default(); + let binance = Arc::new(Binance::new(config).unwrap()); + let strategy = BinanceSigningStrategy::new(binance); + // Strategy created successfully + assert!(true); + } + + #[test] + fn test_build_signing_string() { + let mut params = std::collections::BTreeMap::new(); + params.insert("symbol".to_string(), "BTCUSDT".to_string()); + params.insert("side".to_string(), "BUY".to_string()); + params.insert("timestamp".to_string(), "1234567890".to_string()); + + let query = build_signing_string(¶ms); + // BTreeMap maintains alphabetical order + assert_eq!(query, "side=BUY&symbol=BTCUSDT×tamp=1234567890"); + } +} diff --git a/ccxt-exchanges/src/okx/exchange_impl.rs b/ccxt-exchanges/src/okx/exchange_impl.rs index 619f3d3..510ec40 100644 --- a/ccxt-exchanges/src/okx/exchange_impl.rs +++ b/ccxt-exchanges/src/okx/exchange_impl.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use ccxt_core::{ Result, exchange::{Capability, Exchange, ExchangeCapabilities}, + signed_request::HasHttpClient, types::{ Amount, Balance, Market, Ohlcv, Order, OrderBook, OrderSide, OrderType, Price, Ticker, Timeframe, Trade, @@ -17,6 +18,20 @@ use std::sync::Arc; use super::Okx; +// ==================== HasHttpClient Implementation ==================== + +impl HasHttpClient for Okx { + fn http_client(&self) -> &ccxt_core::http_client::HttpClient { + &self.base().http_client + } + + fn base_url(&self) -> &'static str { + // Return empty string - OKX's signed_request builder handles full URL construction + // using self.okx.urls().rest + "" + } +} + #[async_trait] impl Exchange for Okx { // ==================== Metadata ====================