Skip to main content

ccxt_exchanges/binance/
signed_request.rs

1//! Signed request builder for Binance API.
2//!
3//! This module provides a builder pattern for creating authenticated Binance API requests,
4//! encapsulating the common signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! The `SignedRequestBuilder` eliminates code duplication by centralizing:
9//! - Credential validation
10//! - Timestamp generation
11//! - Parameter signing with HMAC-SHA256
12//! - Authentication header injection
13//! - HTTP request execution
14//!
15//! # Example
16//!
17//! ```no_run
18//! # use ccxt_exchanges::binance::Binance;
19//! # use ccxt_exchanges::binance::signed_request::HttpMethod;
20//! # use ccxt_core::ExchangeConfig;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let binance = Binance::new(ExchangeConfig::default())?;
23//!
24//! // Simple GET request
25//! let data = binance.signed_request("/api/v3/account")
26//!     .execute()
27//!     .await?;
28//!
29//! // POST request with parameters
30//! let data = binance.signed_request("/api/v3/order")
31//!     .method(HttpMethod::Post)
32//!     .param("symbol", "BTCUSDT")
33//!     .param("side", "BUY")
34//!     .param("type", "MARKET")
35//!     .param("quantity", "0.001")
36//!     .execute()
37//!     .await?;
38//! # Ok(())
39//! # }
40//! ```
41
42use super::Binance;
43use super::error::BinanceApiError;
44use super::rate_limiter::RateLimitInfo;
45use ccxt_core::Result;
46use reqwest::header::HeaderMap;
47use serde_json::Value;
48use std::collections::BTreeMap;
49
50/// HTTP request methods supported by the signed request builder.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum HttpMethod {
53    /// GET request - parameters in query string
54    #[default]
55    Get,
56    /// POST request - parameters in query string (Binance requires query string, not JSON body)
57    Post,
58    /// PUT request - parameters in query string (Binance requires query string, not JSON body)
59    Put,
60    /// DELETE request - parameters in query string (Binance requires query string, not JSON body)
61    Delete,
62}
63
64/// Builder for creating authenticated Binance API requests.
65///
66/// This builder encapsulates the common signing workflow:
67/// 1. Credential validation
68/// 2. Timestamp generation
69/// 3. Parameter signing with HMAC-SHA256
70/// 4. Authentication header injection
71/// 5. HTTP request execution
72///
73/// # Example
74///
75/// ```no_run
76/// # use ccxt_exchanges::binance::Binance;
77/// # use ccxt_exchanges::binance::signed_request::HttpMethod;
78/// # use ccxt_core::ExchangeConfig;
79/// # async fn example() -> ccxt_core::Result<()> {
80/// let binance = Binance::new(ExchangeConfig::default())?;
81///
82/// let data = binance.signed_request("/api/v3/account")
83///     .param("symbol", "BTCUSDT")
84///     .optional_param("limit", Some(100))
85///     .execute()
86///     .await?;
87/// # Ok(())
88/// # }
89/// ```
90pub struct SignedRequestBuilder<'a> {
91    /// Reference to the Binance exchange instance
92    binance: &'a Binance,
93    /// Request parameters to be signed
94    params: BTreeMap<String, String>,
95    /// API endpoint URL
96    endpoint: String,
97    /// HTTP method for the request
98    method: HttpMethod,
99}
100
101impl<'a> SignedRequestBuilder<'a> {
102    /// Creates a new signed request builder.
103    ///
104    /// # Arguments
105    ///
106    /// * `binance` - Reference to the Binance exchange instance
107    /// * `endpoint` - Full API endpoint URL
108    ///
109    /// # Example
110    ///
111    /// ```no_run
112    /// # use ccxt_exchanges::binance::Binance;
113    /// # use ccxt_exchanges::binance::signed_request::SignedRequestBuilder;
114    /// # use ccxt_core::ExchangeConfig;
115    /// let binance = Binance::new(ExchangeConfig::default()).unwrap();
116    /// let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
117    /// ```
118    pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
119        Self {
120            binance,
121            params: BTreeMap::new(),
122            endpoint: endpoint.into(),
123            method: HttpMethod::default(),
124        }
125    }
126
127    /// Sets the HTTP method for the request.
128    ///
129    /// Default is GET.
130    ///
131    /// # Arguments
132    ///
133    /// * `method` - The HTTP method to use
134    ///
135    /// # Example
136    ///
137    /// ```no_run
138    /// # use ccxt_exchanges::binance::Binance;
139    /// # use ccxt_exchanges::binance::signed_request::HttpMethod;
140    /// # use ccxt_core::ExchangeConfig;
141    /// # async fn example() -> ccxt_core::Result<()> {
142    /// let binance = Binance::new(ExchangeConfig::default())?;
143    /// let data = binance.signed_request("/api/v3/order")
144    ///     .method(HttpMethod::Post)
145    ///     .param("symbol", "BTCUSDT")
146    ///     .execute()
147    ///     .await?;
148    /// # Ok(())
149    /// # }
150    /// ```
151    pub fn method(mut self, method: HttpMethod) -> Self {
152        self.method = method;
153        self
154    }
155
156    /// Adds a required parameter.
157    ///
158    /// # Arguments
159    ///
160    /// * `key` - Parameter name
161    /// * `value` - Parameter value (will be converted to string)
162    ///
163    /// # Example
164    ///
165    /// ```no_run
166    /// # use ccxt_exchanges::binance::Binance;
167    /// # use ccxt_core::ExchangeConfig;
168    /// # async fn example() -> ccxt_core::Result<()> {
169    /// let binance = Binance::new(ExchangeConfig::default())?;
170    /// let data = binance.signed_request("/api/v3/myTrades")
171    ///     .param("symbol", "BTCUSDT")
172    ///     .param("limit", 100)
173    ///     .execute()
174    ///     .await?;
175    /// # Ok(())
176    /// # }
177    /// ```
178    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
179        self.params.insert(key.into(), value.to_string());
180        self
181    }
182
183    /// Adds an optional parameter (only if value is Some).
184    ///
185    /// # Arguments
186    ///
187    /// * `key` - Parameter name
188    /// * `value` - Optional parameter value
189    ///
190    /// # Example
191    ///
192    /// ```no_run
193    /// # use ccxt_exchanges::binance::Binance;
194    /// # use ccxt_core::ExchangeConfig;
195    /// # async fn example() -> ccxt_core::Result<()> {
196    /// let binance = Binance::new(ExchangeConfig::default())?;
197    /// let since: Option<i64> = Some(1234567890);
198    /// let limit: Option<u32> = None;
199    ///
200    /// let data = binance.signed_request("/api/v3/myTrades")
201    ///     .param("symbol", "BTCUSDT")
202    ///     .optional_param("startTime", since)
203    ///     .optional_param("limit", limit)  // Won't be added since it's None
204    ///     .execute()
205    ///     .await?;
206    /// # Ok(())
207    /// # }
208    /// ```
209    pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
210        if let Some(v) = value {
211            self.params.insert(key.into(), v.to_string());
212        }
213        self
214    }
215
216    /// Adds multiple parameters from a BTreeMap.
217    ///
218    /// # Arguments
219    ///
220    /// * `params` - Map of parameter key-value pairs
221    ///
222    /// # Example
223    ///
224    /// ```no_run
225    /// # use ccxt_exchanges::binance::Binance;
226    /// # use ccxt_core::ExchangeConfig;
227    /// # use std::collections::BTreeMap;
228    /// # async fn example() -> ccxt_core::Result<()> {
229    /// let binance = Binance::new(ExchangeConfig::default())?;
230    /// let mut params = BTreeMap::new();
231    /// params.insert("symbol".to_string(), "BTCUSDT".to_string());
232    /// params.insert("side".to_string(), "BUY".to_string());
233    ///
234    /// let data = binance.signed_request("/api/v3/order")
235    ///     .params(params)
236    ///     .execute()
237    ///     .await?;
238    /// # Ok(())
239    /// # }
240    /// ```
241    pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
242        self.params.extend(params);
243        self
244    }
245
246    /// Merges parameters from a JSON Value object.
247    ///
248    /// Only string, number, and boolean values are supported.
249    /// Nested objects and arrays are ignored.
250    ///
251    /// # Arguments
252    ///
253    /// * `params` - Optional JSON Value containing parameters
254    ///
255    /// # Example
256    ///
257    /// ```no_run
258    /// # use ccxt_exchanges::binance::Binance;
259    /// # use ccxt_core::ExchangeConfig;
260    /// # use serde_json::json;
261    /// # async fn example() -> ccxt_core::Result<()> {
262    /// let binance = Binance::new(ExchangeConfig::default())?;
263    /// let extra_params = Some(json!({
264    ///     "orderId": "12345",
265    ///     "fromId": 67890
266    /// }));
267    ///
268    /// let data = binance.signed_request("/api/v3/myTrades")
269    ///     .param("symbol", "BTCUSDT")
270    ///     .merge_json_params(extra_params)
271    ///     .execute()
272    ///     .await?;
273    /// # Ok(())
274    /// # }
275    /// ```
276    pub fn merge_json_params(mut self, params: Option<Value>) -> Self {
277        if let Some(Value::Object(map)) = params {
278            for (key, value) in map {
279                let string_value = match value {
280                    Value::String(s) => s,
281                    Value::Number(n) => n.to_string(),
282                    Value::Bool(b) => b.to_string(),
283                    _ => continue, // Skip arrays, objects, and null
284                };
285                self.params.insert(key, string_value);
286            }
287        }
288        self
289    }
290
291    /// Executes the signed request and returns the response.
292    ///
293    /// This method:
294    /// 1. Validates that credentials are configured
295    /// 2. Gets the signing timestamp (using cached offset if available)
296    /// 3. Signs the parameters with HMAC-SHA256
297    /// 4. Adds authentication headers
298    /// 5. Executes the HTTP request
299    /// 6. If a timestamp error is detected, resyncs time and retries once
300    ///
301    /// # Returns
302    ///
303    /// Returns the raw `serde_json::Value` response for further parsing.
304    ///
305    /// # Errors
306    ///
307    /// - Returns authentication error if credentials are missing
308    /// - Returns network error if the request fails
309    /// - Returns parse error if response parsing fails
310    ///
311    /// # Example
312    ///
313    /// ```no_run
314    /// # use ccxt_exchanges::binance::Binance;
315    /// # use ccxt_core::ExchangeConfig;
316    /// # async fn example() -> ccxt_core::Result<()> {
317    /// let binance = Binance::new(ExchangeConfig::default())?;
318    /// let data = binance.signed_request("/api/v3/account")
319    ///     .execute()
320    ///     .await?;
321    /// println!("Response: {:?}", data);
322    /// # Ok(())
323    /// # }
324    /// ```
325    pub async fn execute(self) -> Result<Value> {
326        // Step 1: Check rate limiter - wait if needed
327        if let Some(wait_duration) = self.binance.rate_limiter().wait_duration() {
328            tokio::time::sleep(wait_duration).await;
329        }
330
331        // Step 2: Validate credentials
332        self.binance.check_required_credentials()?;
333
334        // Step 3: Execute the request
335        match self.execute_inner().await {
336            Ok(result) => Ok(result),
337            Err(err) => {
338                // Step 4: If timestamp error, resync time and retry once
339                if self.binance.is_timestamp_error(&err) {
340                    tracing::warn!("Timestamp error detected, resyncing time and retrying");
341                    if self.binance.sync_time().await.is_ok() {
342                        return self.execute_inner().await;
343                    }
344                }
345                Err(err)
346            }
347        }
348    }
349
350    /// Internal execution logic that can be called multiple times for retry.
351    async fn execute_inner(&self) -> Result<Value> {
352        // Get signing timestamp
353        let timestamp = self.binance.get_signing_timestamp().await?;
354
355        // Get auth and sign parameters
356        let auth = self.binance.get_auth()?;
357        let signed_params = auth.sign_with_timestamp(
358            &self.params,
359            timestamp,
360            Some(self.binance.options().recv_window),
361        )?;
362
363        // Add authentication headers
364        let mut headers = HeaderMap::new();
365        auth.add_auth_headers_reqwest(&mut headers);
366
367        // Execute HTTP request based on method
368        // Note: Binance API requires all signed requests to use query string parameters,
369        // not JSON body, even for POST/PUT/DELETE requests.
370        let query_string = build_query_string(&signed_params);
371        let url = if query_string.is_empty() {
372            self.endpoint.clone()
373        } else {
374            format!("{}?{}", self.endpoint, query_string)
375        };
376
377        let result = match self.method {
378            HttpMethod::Get => {
379                self.binance
380                    .base()
381                    .http_client
382                    .get(&url, Some(headers))
383                    .await
384            }
385            HttpMethod::Post => {
386                self.binance
387                    .base()
388                    .http_client
389                    .post(&url, Some(headers), None)
390                    .await
391            }
392            HttpMethod::Put => {
393                self.binance
394                    .base()
395                    .http_client
396                    .put(&url, Some(headers), None)
397                    .await
398            }
399            HttpMethod::Delete => {
400                self.binance
401                    .base()
402                    .http_client
403                    .delete(&url, Some(headers), None)
404                    .await
405            }
406        };
407
408        // Handle HTTP errors - try to extract Binance-specific error details
409        let result = match result {
410            Ok(value) => value,
411            Err(err) => {
412                // Try to extract Binance error code/msg from the error message
413                // The HTTP layer now embeds exchange error codes in Exchange errors
414                if let ccxt_core::error::Error::Exchange(ref exchange_err) = err {
415                    let err_str = exchange_err.to_string();
416                    // Check for IP ban (HTTP 418)
417                    if err_str.contains("IP banned") || err_str.contains("418") {
418                        self.binance
419                            .rate_limiter()
420                            .set_ip_banned(std::time::Duration::from_secs(7200));
421                    }
422                    // Check for rate limit errors
423                    if let ccxt_core::error::Error::RateLimit { .. } = err {
424                        // Rate limit info is already handled by the HTTP layer
425                    }
426                }
427                return Err(err);
428            }
429        };
430
431        // Update rate limiter from response headers
432        if let Some(resp_headers) = result.get("responseHeaders") {
433            let rate_info = RateLimitInfo::from_headers(resp_headers);
434            if rate_info.has_data() {
435                self.binance.rate_limiter().update(rate_info);
436            }
437        }
438
439        // Check for Binance API error in response body
440        // Binance may return HTTP 200 with an error in the JSON body: {"code": -1121, "msg": "..."}
441        if let Some(api_error) = BinanceApiError::from_json(&result) {
442            // Check for IP ban (HTTP 418)
443            if api_error.is_ip_banned() {
444                self.binance
445                    .rate_limiter()
446                    .set_ip_banned(std::time::Duration::from_secs(7200)); // 2 hours default
447            }
448            return Err(api_error.into());
449        }
450
451        Ok(result)
452    }
453}
454
455/// Builds a URL-encoded query string from parameters.
456///
457/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
458/// Both keys and values are URL-encoded to handle special characters.
459pub(crate) fn build_query_string(params: &BTreeMap<String, String>) -> String {
460    params
461        .iter()
462        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
463        .collect::<Vec<_>>()
464        .join("&")
465}
466
467/// Lightweight request builder for API-key-only endpoints (no signature).
468///
469/// Used for listen key operations and other endpoints that require only
470/// the `X-MBX-APIKEY` header but no HMAC signature.
471///
472/// Includes rate limit checking, response header parsing, Binance error
473/// detection, and IP ban handling.
474pub(crate) struct ApiKeyRequestBuilder<'a> {
475    binance: &'a Binance,
476    endpoint: String,
477    method: HttpMethod,
478    query_params: BTreeMap<String, String>,
479}
480
481impl<'a> ApiKeyRequestBuilder<'a> {
482    /// Creates a new API key request builder.
483    pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
484        Self {
485            binance,
486            endpoint: endpoint.into(),
487            method: HttpMethod::default(),
488            query_params: BTreeMap::new(),
489        }
490    }
491
492    /// Sets the HTTP method.
493    pub fn method(mut self, method: HttpMethod) -> Self {
494        self.method = method;
495        self
496    }
497
498    /// Adds a query parameter.
499    #[allow(dead_code)]
500    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
501        self.query_params.insert(key.into(), value.to_string());
502        self
503    }
504
505    /// Executes the request with API key authentication.
506    pub async fn execute(self) -> Result<Value> {
507        // Check rate limiter
508        if let Some(wait_duration) = self.binance.rate_limiter().wait_duration() {
509            tokio::time::sleep(wait_duration).await;
510        }
511
512        // Validate credentials
513        self.binance.check_required_credentials()?;
514
515        // Build URL with query params
516        let query_string = build_query_string(&self.query_params);
517        let url = if query_string.is_empty() {
518            self.endpoint.clone()
519        } else {
520            format!("{}?{}", self.endpoint, query_string)
521        };
522
523        // Add API key header only (no signature)
524        let mut headers = HeaderMap::new();
525        let auth = self.binance.get_auth()?;
526        auth.add_auth_headers_reqwest(&mut headers);
527
528        // Execute HTTP request
529        let result = match self.method {
530            HttpMethod::Get => {
531                self.binance
532                    .base()
533                    .http_client
534                    .get(&url, Some(headers))
535                    .await
536            }
537            HttpMethod::Post => {
538                self.binance
539                    .base()
540                    .http_client
541                    .post(&url, Some(headers), None)
542                    .await
543            }
544            HttpMethod::Put => {
545                self.binance
546                    .base()
547                    .http_client
548                    .put(&url, Some(headers), None)
549                    .await
550            }
551            HttpMethod::Delete => {
552                self.binance
553                    .base()
554                    .http_client
555                    .delete(&url, Some(headers), None)
556                    .await
557            }
558        };
559
560        let result = match result {
561            Ok(value) => value,
562            Err(err) => {
563                // Check for IP ban in exchange errors
564                if let ccxt_core::error::Error::Exchange(ref exchange_err) = err {
565                    let err_str = exchange_err.to_string();
566                    if err_str.contains("IP banned") || err_str.contains("418") {
567                        self.binance
568                            .rate_limiter()
569                            .set_ip_banned(std::time::Duration::from_secs(7200));
570                    }
571                }
572                return Err(err);
573            }
574        };
575
576        // Update rate limiter from response headers
577        if let Some(resp_headers) = result.get("responseHeaders") {
578            let rate_info = RateLimitInfo::from_headers(resp_headers);
579            if rate_info.has_data() {
580                self.binance.rate_limiter().update(rate_info);
581            }
582        }
583
584        // Check for Binance API error in response body
585        if let Some(api_error) = BinanceApiError::from_json(&result) {
586            if api_error.is_ip_banned() {
587                self.binance
588                    .rate_limiter()
589                    .set_ip_banned(std::time::Duration::from_secs(7200));
590            }
591            return Err(api_error.into());
592        }
593
594        Ok(result)
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use ccxt_core::ExchangeConfig;
602
603    #[test]
604    fn test_http_method_default() {
605        assert_eq!(HttpMethod::default(), HttpMethod::Get);
606    }
607
608    #[test]
609    fn test_builder_construction() {
610        let config = ExchangeConfig::default();
611        let binance = Binance::new(config).unwrap();
612
613        let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
614
615        assert_eq!(builder.endpoint, "https://api.binance.com/api/v3/account");
616        assert_eq!(builder.method, HttpMethod::Get);
617        assert!(builder.params.is_empty());
618    }
619
620    #[test]
621    fn test_builder_method_chaining() {
622        let config = ExchangeConfig::default();
623        let binance = Binance::new(config).unwrap();
624
625        let builder = SignedRequestBuilder::new(&binance, "/api/v3/order")
626            .method(HttpMethod::Post)
627            .param("symbol", "BTCUSDT")
628            .param("side", "BUY");
629
630        assert_eq!(builder.method, HttpMethod::Post);
631        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
632        assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
633    }
634
635    #[test]
636    fn test_builder_param() {
637        let config = ExchangeConfig::default();
638        let binance = Binance::new(config).unwrap();
639
640        let builder = SignedRequestBuilder::new(&binance, "/test")
641            .param("string_param", "value")
642            .param("int_param", 123)
643            .param("float_param", 45.67);
644
645        assert_eq!(
646            builder.params.get("string_param"),
647            Some(&"value".to_string())
648        );
649        assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
650        assert_eq!(
651            builder.params.get("float_param"),
652            Some(&"45.67".to_string())
653        );
654    }
655
656    #[test]
657    fn test_builder_optional_param_some() {
658        let config = ExchangeConfig::default();
659        let binance = Binance::new(config).unwrap();
660
661        let builder = SignedRequestBuilder::new(&binance, "/test")
662            .optional_param("limit", Some(100u32))
663            .optional_param("since", Some(1234567890i64));
664
665        assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
666        assert_eq!(builder.params.get("since"), Some(&"1234567890".to_string()));
667    }
668
669    #[test]
670    fn test_builder_optional_param_none() {
671        let config = ExchangeConfig::default();
672        let binance = Binance::new(config).unwrap();
673
674        let none_value: Option<u32> = None;
675        let builder =
676            SignedRequestBuilder::new(&binance, "/test").optional_param("limit", none_value);
677
678        assert!(!builder.params.contains_key("limit"));
679    }
680
681    #[test]
682    fn test_builder_params_bulk() {
683        let config = ExchangeConfig::default();
684        let binance = Binance::new(config).unwrap();
685
686        let mut params = BTreeMap::new();
687        params.insert("symbol".to_string(), "BTCUSDT".to_string());
688        params.insert("side".to_string(), "BUY".to_string());
689
690        let builder = SignedRequestBuilder::new(&binance, "/test").params(params);
691
692        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
693        assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
694    }
695
696    #[test]
697    fn test_builder_merge_json_params() {
698        let config = ExchangeConfig::default();
699        let binance = Binance::new(config).unwrap();
700
701        let json_params = Some(serde_json::json!({
702            "orderId": "12345",
703            "fromId": 67890,
704            "active": true,
705            "nested": {"ignored": "value"},
706            "array": [1, 2, 3]
707        }));
708
709        let builder = SignedRequestBuilder::new(&binance, "/test").merge_json_params(json_params);
710
711        assert_eq!(builder.params.get("orderId"), Some(&"12345".to_string()));
712        assert_eq!(builder.params.get("fromId"), Some(&"67890".to_string()));
713        assert_eq!(builder.params.get("active"), Some(&"true".to_string()));
714        // Nested objects and arrays should be ignored
715        assert!(!builder.params.contains_key("nested"));
716        assert!(!builder.params.contains_key("array"));
717    }
718
719    #[test]
720    fn test_builder_merge_json_params_none() {
721        let config = ExchangeConfig::default();
722        let binance = Binance::new(config).unwrap();
723
724        let builder = SignedRequestBuilder::new(&binance, "/test")
725            .param("existing", "value")
726            .merge_json_params(None);
727
728        assert_eq!(builder.params.get("existing"), Some(&"value".to_string()));
729        assert_eq!(builder.params.len(), 1);
730    }
731
732    #[test]
733    fn test_build_query_string() {
734        let mut params = BTreeMap::new();
735        params.insert("symbol".to_string(), "BTCUSDT".to_string());
736        params.insert("side".to_string(), "BUY".to_string());
737        params.insert("amount".to_string(), "1.5".to_string());
738
739        let query = build_query_string(&params);
740
741        // BTreeMap maintains alphabetical order
742        assert_eq!(query, "amount=1.5&side=BUY&symbol=BTCUSDT");
743    }
744
745    #[test]
746    fn test_build_query_string_empty() {
747        let params = BTreeMap::new();
748        let query = build_query_string(&params);
749        assert!(query.is_empty());
750    }
751
752    #[test]
753    fn test_builder_all_http_methods() {
754        let config = ExchangeConfig::default();
755        let binance = Binance::new(config).unwrap();
756
757        // Test all HTTP methods can be set
758        let get_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Get);
759        assert_eq!(get_builder.method, HttpMethod::Get);
760
761        let post_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Post);
762        assert_eq!(post_builder.method, HttpMethod::Post);
763
764        let put_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Put);
765        assert_eq!(put_builder.method, HttpMethod::Put);
766
767        let delete_builder =
768            SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Delete);
769        assert_eq!(delete_builder.method, HttpMethod::Delete);
770    }
771
772    #[test]
773    fn test_builder_parameter_ordering() {
774        let config = ExchangeConfig::default();
775        let binance = Binance::new(config).unwrap();
776
777        // Add parameters in non-alphabetical order
778        let builder = SignedRequestBuilder::new(&binance, "/test")
779            .param("zebra", "z")
780            .param("apple", "a")
781            .param("mango", "m");
782
783        // BTreeMap should maintain alphabetical order
784        let keys: Vec<_> = builder.params.keys().collect();
785        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
786    }
787}
788
789#[cfg(test)]
790mod property_tests {
791    use super::*;
792    use ccxt_core::ExchangeConfig;
793    use proptest::prelude::*;
794
795    // Strategy to generate valid parameter keys (alphanumeric, non-empty)
796    fn param_key_strategy() -> impl Strategy<Value = String> {
797        "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
798    }
799
800    // Strategy to generate valid parameter values
801    fn param_value_strategy() -> impl Strategy<Value = String> {
802        "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
803    }
804
805    // Strategy to generate a BTreeMap of parameters
806    fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
807        proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
808    }
809
810    proptest! {
811        #![proptest_config(ProptestConfig::with_cases(100))]
812
813        /// **Feature: binance-signed-request-refactor, Property 2: Signed Request Contains Required Fields**
814        ///
815        /// *For any* executed signed request with valid credentials, the signed parameters SHALL contain:
816        /// - A `timestamp` field with a valid millisecond timestamp
817        /// - A `signature` field with a 64-character hex string (HMAC-SHA256)
818        /// - A `recvWindow` field matching the configured value
819        ///
820        /// **Validates: Requirements 1.3, 1.4, 1.5, 1.6**
821        #[test]
822        fn prop_signed_request_contains_required_fields(
823            params in params_strategy(),
824            recv_window in 1000u64..60000u64
825        ) {
826            // Create a Binance instance with credentials
827            let config = ExchangeConfig {
828                api_key: Some("test_api_key".to_string().into()),
829                secret: Some("test_secret_key".to_string().into()),
830                ..Default::default()
831            };
832
833            let options = super::super::BinanceOptions {
834                recv_window,
835                ..Default::default()
836            };
837
838            let binance = super::super::Binance::new_with_options(config, options).expect("Failed to create Binance");
839
840            // Get auth and sign parameters manually to verify the signing logic
841            let auth = binance.get_auth().expect("Failed to get auth");
842            let timestamp = 1234567890000i64; // Fixed timestamp for testing
843
844            let signed_params = auth.sign_with_timestamp(&params, timestamp, Some(recv_window)).expect("Sign timestamp failed");
845
846            // Property 1: timestamp field exists and matches
847            prop_assert!(signed_params.contains_key("timestamp"));
848            prop_assert_eq!(signed_params.get("timestamp").expect("Missing timestamp"), &timestamp.to_string());
849
850            // Property 2: signature field exists and is 64 hex characters
851            prop_assert!(signed_params.contains_key("signature"));
852            let signature = signed_params.get("signature").expect("Missing signature");
853            prop_assert_eq!(signature.len(), 64, "Signature should be 64 hex characters");
854            prop_assert!(signature.chars().all(|c| c.is_ascii_hexdigit()), "Signature should be hex");
855
856            // Property 3: recvWindow field exists and matches configured value
857            prop_assert!(signed_params.contains_key("recvWindow"));
858            prop_assert_eq!(signed_params.get("recvWindow").expect("Missing recvWindow"), &recv_window.to_string());
859
860            // Property 4: All original parameters are preserved
861            for (key, value) in &params {
862                prop_assert!(signed_params.contains_key(key), "Original param {} should be preserved", key);
863                prop_assert_eq!(signed_params.get(key).expect("Missing param"), value, "Original param {} value should match", key);
864            }
865        }
866
867        /// **Feature: binance-signed-request-refactor, Property 5: Optional Parameters Conditional Addition**
868        ///
869        /// *For any* call to `optional_param(key, value)`:
870        /// - If `value` is `Some(v)`, the parameter SHALL be added with key and v.to_string()
871        /// - If `value` is `None`, the parameter SHALL NOT be added
872        ///
873        /// **Validates: Requirements 2.2**
874        #[test]
875        fn prop_optional_param_conditional_addition(
876            key in param_key_strategy(),
877            value in proptest::option::of(param_value_strategy()),
878            other_key in param_key_strategy().prop_filter("other_key must differ from key", |k| k != "z"),
879            other_value in param_value_strategy()
880        ) {
881            // Skip if keys are the same to avoid overwrite conflicts
882            prop_assume!(key != other_key);
883
884            let config = ExchangeConfig::default();
885            let binance = super::super::Binance::new(config).expect("Failed to create Binance");
886
887            // Build with optional parameter
888            let builder = SignedRequestBuilder::new(&binance, "/test")
889                .param(&other_key, &other_value)
890                .optional_param(&key, value.clone());
891
892            // Property: If value is Some, parameter should be present with correct value
893            // If value is None, parameter should NOT be present
894            match value {
895                Some(v) => {
896                    prop_assert!(
897                        builder.params.contains_key(&key),
898                        "Parameter {} should be present when value is Some",
899                        key
900                    );
901                    prop_assert_eq!(
902                        builder.params.get(&key).expect("Missing param"),
903                        &v,
904                        "Parameter {} should have correct value",
905                        key
906                    );
907                }
908                None => {
909                    // Only check if key is different from other_key
910                    if key != other_key {
911                        prop_assert!(
912                            !builder.params.contains_key(&key),
913                            "Parameter {} should NOT be present when value is None",
914                            key
915                        );
916                    }
917                }
918            }
919
920            // Property: Other parameters should always be present
921            prop_assert!(
922                builder.params.contains_key(&other_key),
923                "Other parameter {} should always be present",
924                other_key
925            );
926            prop_assert_eq!(
927                builder.params.get(&other_key).expect("Missing other param"),
928                &other_value,
929                "Other parameter {} should have correct value",
930                other_key
931            );
932        }
933    }
934}