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(¶ms);
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(¶ms);
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(¶ms, 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"), ×tamp.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 ¶ms {
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}