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 ccxt_core::{Error, ParseError, Result};
44use reqwest::header::HeaderMap;
45use serde_json::Value;
46use std::collections::BTreeMap;
47
48/// HTTP request methods supported by the signed request builder.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum HttpMethod {
51    /// GET request - parameters in query string
52    #[default]
53    Get,
54    /// POST request - parameters in JSON body
55    Post,
56    /// PUT request - parameters in JSON body
57    Put,
58    /// DELETE request - parameters in JSON body
59    Delete,
60}
61
62/// Builder for creating authenticated Binance API requests.
63///
64/// This builder encapsulates the common signing workflow:
65/// 1. Credential validation
66/// 2. Timestamp generation
67/// 3. Parameter signing with HMAC-SHA256
68/// 4. Authentication header injection
69/// 5. HTTP request execution
70///
71/// # Example
72///
73/// ```no_run
74/// # use ccxt_exchanges::binance::Binance;
75/// # use ccxt_exchanges::binance::signed_request::HttpMethod;
76/// # use ccxt_core::ExchangeConfig;
77/// # async fn example() -> ccxt_core::Result<()> {
78/// let binance = Binance::new(ExchangeConfig::default())?;
79///
80/// let data = binance.signed_request("/api/v3/account")
81///     .param("symbol", "BTCUSDT")
82///     .optional_param("limit", Some(100))
83///     .execute()
84///     .await?;
85/// # Ok(())
86/// # }
87/// ```
88pub struct SignedRequestBuilder<'a> {
89    /// Reference to the Binance exchange instance
90    binance: &'a Binance,
91    /// Request parameters to be signed
92    params: BTreeMap<String, String>,
93    /// API endpoint URL
94    endpoint: String,
95    /// HTTP method for the request
96    method: HttpMethod,
97}
98
99impl<'a> SignedRequestBuilder<'a> {
100    /// Creates a new signed request builder.
101    ///
102    /// # Arguments
103    ///
104    /// * `binance` - Reference to the Binance exchange instance
105    /// * `endpoint` - Full API endpoint URL
106    ///
107    /// # Example
108    ///
109    /// ```no_run
110    /// # use ccxt_exchanges::binance::Binance;
111    /// # use ccxt_exchanges::binance::signed_request::SignedRequestBuilder;
112    /// # use ccxt_core::ExchangeConfig;
113    /// let binance = Binance::new(ExchangeConfig::default()).unwrap();
114    /// let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
115    /// ```
116    pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
117        Self {
118            binance,
119            params: BTreeMap::new(),
120            endpoint: endpoint.into(),
121            method: HttpMethod::default(),
122        }
123    }
124
125    /// Sets the HTTP method for the request.
126    ///
127    /// Default is GET.
128    ///
129    /// # Arguments
130    ///
131    /// * `method` - The HTTP method to use
132    ///
133    /// # Example
134    ///
135    /// ```no_run
136    /// # use ccxt_exchanges::binance::Binance;
137    /// # use ccxt_exchanges::binance::signed_request::HttpMethod;
138    /// # use ccxt_core::ExchangeConfig;
139    /// # async fn example() -> ccxt_core::Result<()> {
140    /// let binance = Binance::new(ExchangeConfig::default())?;
141    /// let data = binance.signed_request("/api/v3/order")
142    ///     .method(HttpMethod::Post)
143    ///     .param("symbol", "BTCUSDT")
144    ///     .execute()
145    ///     .await?;
146    /// # Ok(())
147    /// # }
148    /// ```
149    pub fn method(mut self, method: HttpMethod) -> Self {
150        self.method = method;
151        self
152    }
153
154    /// Adds a required parameter.
155    ///
156    /// # Arguments
157    ///
158    /// * `key` - Parameter name
159    /// * `value` - Parameter value (will be converted to string)
160    ///
161    /// # Example
162    ///
163    /// ```no_run
164    /// # use ccxt_exchanges::binance::Binance;
165    /// # use ccxt_core::ExchangeConfig;
166    /// # async fn example() -> ccxt_core::Result<()> {
167    /// let binance = Binance::new(ExchangeConfig::default())?;
168    /// let data = binance.signed_request("/api/v3/myTrades")
169    ///     .param("symbol", "BTCUSDT")
170    ///     .param("limit", 100)
171    ///     .execute()
172    ///     .await?;
173    /// # Ok(())
174    /// # }
175    /// ```
176    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
177        self.params.insert(key.into(), value.to_string());
178        self
179    }
180
181    /// Adds an optional parameter (only if value is Some).
182    ///
183    /// # Arguments
184    ///
185    /// * `key` - Parameter name
186    /// * `value` - Optional parameter value
187    ///
188    /// # Example
189    ///
190    /// ```no_run
191    /// # use ccxt_exchanges::binance::Binance;
192    /// # use ccxt_core::ExchangeConfig;
193    /// # async fn example() -> ccxt_core::Result<()> {
194    /// let binance = Binance::new(ExchangeConfig::default())?;
195    /// let since: Option<i64> = Some(1234567890);
196    /// let limit: Option<u32> = None;
197    ///
198    /// let data = binance.signed_request("/api/v3/myTrades")
199    ///     .param("symbol", "BTCUSDT")
200    ///     .optional_param("startTime", since)
201    ///     .optional_param("limit", limit)  // Won't be added since it's None
202    ///     .execute()
203    ///     .await?;
204    /// # Ok(())
205    /// # }
206    /// ```
207    pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
208        if let Some(v) = value {
209            self.params.insert(key.into(), v.to_string());
210        }
211        self
212    }
213
214    /// Adds multiple parameters from a BTreeMap.
215    ///
216    /// # Arguments
217    ///
218    /// * `params` - Map of parameter key-value pairs
219    ///
220    /// # Example
221    ///
222    /// ```no_run
223    /// # use ccxt_exchanges::binance::Binance;
224    /// # use ccxt_core::ExchangeConfig;
225    /// # use std::collections::BTreeMap;
226    /// # async fn example() -> ccxt_core::Result<()> {
227    /// let binance = Binance::new(ExchangeConfig::default())?;
228    /// let mut params = BTreeMap::new();
229    /// params.insert("symbol".to_string(), "BTCUSDT".to_string());
230    /// params.insert("side".to_string(), "BUY".to_string());
231    ///
232    /// let data = binance.signed_request("/api/v3/order")
233    ///     .params(params)
234    ///     .execute()
235    ///     .await?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
240        self.params.extend(params);
241        self
242    }
243
244    /// Merges parameters from a JSON Value object.
245    ///
246    /// Only string, number, and boolean values are supported.
247    /// Nested objects and arrays are ignored.
248    ///
249    /// # Arguments
250    ///
251    /// * `params` - Optional JSON Value containing parameters
252    ///
253    /// # Example
254    ///
255    /// ```no_run
256    /// # use ccxt_exchanges::binance::Binance;
257    /// # use ccxt_core::ExchangeConfig;
258    /// # use serde_json::json;
259    /// # async fn example() -> ccxt_core::Result<()> {
260    /// let binance = Binance::new(ExchangeConfig::default())?;
261    /// let extra_params = Some(json!({
262    ///     "orderId": "12345",
263    ///     "fromId": 67890
264    /// }));
265    ///
266    /// let data = binance.signed_request("/api/v3/myTrades")
267    ///     .param("symbol", "BTCUSDT")
268    ///     .merge_json_params(extra_params)
269    ///     .execute()
270    ///     .await?;
271    /// # Ok(())
272    /// # }
273    /// ```
274    pub fn merge_json_params(mut self, params: Option<Value>) -> Self {
275        if let Some(Value::Object(map)) = params {
276            for (key, value) in map {
277                let string_value = match value {
278                    Value::String(s) => s,
279                    Value::Number(n) => n.to_string(),
280                    Value::Bool(b) => b.to_string(),
281                    _ => continue, // Skip arrays, objects, and null
282                };
283                self.params.insert(key, string_value);
284            }
285        }
286        self
287    }
288
289    /// Executes the signed request and returns the response.
290    ///
291    /// This method:
292    /// 1. Validates that credentials are configured
293    /// 2. Gets the signing timestamp (using cached offset if available)
294    /// 3. Signs the parameters with HMAC-SHA256
295    /// 4. Adds authentication headers
296    /// 5. Executes the HTTP request
297    ///
298    /// # Returns
299    ///
300    /// Returns the raw `serde_json::Value` response for further parsing.
301    ///
302    /// # Errors
303    ///
304    /// - Returns authentication error if credentials are missing
305    /// - Returns network error if the request fails
306    /// - Returns parse error if response parsing fails
307    ///
308    /// # Example
309    ///
310    /// ```no_run
311    /// # use ccxt_exchanges::binance::Binance;
312    /// # use ccxt_core::ExchangeConfig;
313    /// # async fn example() -> ccxt_core::Result<()> {
314    /// let binance = Binance::new(ExchangeConfig::default())?;
315    /// let data = binance.signed_request("/api/v3/account")
316    ///     .execute()
317    ///     .await?;
318    /// println!("Response: {:?}", data);
319    /// # Ok(())
320    /// # }
321    /// ```
322    pub async fn execute(self) -> Result<Value> {
323        // Step 1: Validate credentials
324        self.binance.check_required_credentials()?;
325
326        // Step 2: Get signing timestamp
327        let timestamp = self.binance.get_signing_timestamp().await?;
328
329        // Step 3: Get auth and sign parameters
330        let auth = self.binance.get_auth()?;
331        let signed_params = auth.sign_with_timestamp(
332            &self.params,
333            timestamp,
334            Some(self.binance.options().recv_window),
335        )?;
336
337        // Step 4: Add authentication headers
338        let mut headers = HeaderMap::new();
339        auth.add_auth_headers_reqwest(&mut headers);
340
341        // Step 5: Execute HTTP request based on method
342        match self.method {
343            HttpMethod::Get => {
344                // Build query string for GET requests
345                let query_string = build_query_string(&signed_params);
346                let url = if query_string.is_empty() {
347                    self.endpoint
348                } else {
349                    format!("{}?{}", self.endpoint, query_string)
350                };
351                self.binance
352                    .base()
353                    .http_client
354                    .get(&url, Some(headers))
355                    .await
356            }
357            HttpMethod::Post => {
358                // Serialize parameters as JSON body for POST requests
359                let body = serde_json::to_value(&signed_params).map_err(|e| {
360                    Error::from(ParseError::invalid_format(
361                        "data",
362                        format!("Failed to serialize request body: {}", e),
363                    ))
364                })?;
365                self.binance
366                    .base()
367                    .http_client
368                    .post(&self.endpoint, Some(headers), Some(body))
369                    .await
370            }
371            HttpMethod::Put => {
372                // Serialize parameters as JSON body for PUT requests
373                let body = serde_json::to_value(&signed_params).map_err(|e| {
374                    Error::from(ParseError::invalid_format(
375                        "data",
376                        format!("Failed to serialize request body: {}", e),
377                    ))
378                })?;
379                self.binance
380                    .base()
381                    .http_client
382                    .put(&self.endpoint, Some(headers), Some(body))
383                    .await
384            }
385            HttpMethod::Delete => {
386                // Serialize parameters as JSON body for DELETE requests
387                let body = serde_json::to_value(&signed_params).map_err(|e| {
388                    Error::from(ParseError::invalid_format(
389                        "data",
390                        format!("Failed to serialize request body: {}", e),
391                    ))
392                })?;
393                self.binance
394                    .base()
395                    .http_client
396                    .delete(&self.endpoint, Some(headers), Some(body))
397                    .await
398            }
399        }
400    }
401}
402
403/// Builds a URL-encoded query string from parameters.
404///
405/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
406fn build_query_string(params: &BTreeMap<String, String>) -> String {
407    params
408        .iter()
409        .map(|(k, v)| format!("{}={}", k, v))
410        .collect::<Vec<_>>()
411        .join("&")
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use ccxt_core::ExchangeConfig;
418
419    #[test]
420    fn test_http_method_default() {
421        assert_eq!(HttpMethod::default(), HttpMethod::Get);
422    }
423
424    #[test]
425    fn test_builder_construction() {
426        let config = ExchangeConfig::default();
427        let binance = Binance::new(config).unwrap();
428
429        let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
430
431        assert_eq!(builder.endpoint, "https://api.binance.com/api/v3/account");
432        assert_eq!(builder.method, HttpMethod::Get);
433        assert!(builder.params.is_empty());
434    }
435
436    #[test]
437    fn test_builder_method_chaining() {
438        let config = ExchangeConfig::default();
439        let binance = Binance::new(config).unwrap();
440
441        let builder = SignedRequestBuilder::new(&binance, "/api/v3/order")
442            .method(HttpMethod::Post)
443            .param("symbol", "BTCUSDT")
444            .param("side", "BUY");
445
446        assert_eq!(builder.method, HttpMethod::Post);
447        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
448        assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
449    }
450
451    #[test]
452    fn test_builder_param() {
453        let config = ExchangeConfig::default();
454        let binance = Binance::new(config).unwrap();
455
456        let builder = SignedRequestBuilder::new(&binance, "/test")
457            .param("string_param", "value")
458            .param("int_param", 123)
459            .param("float_param", 45.67);
460
461        assert_eq!(
462            builder.params.get("string_param"),
463            Some(&"value".to_string())
464        );
465        assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
466        assert_eq!(
467            builder.params.get("float_param"),
468            Some(&"45.67".to_string())
469        );
470    }
471
472    #[test]
473    fn test_builder_optional_param_some() {
474        let config = ExchangeConfig::default();
475        let binance = Binance::new(config).unwrap();
476
477        let builder = SignedRequestBuilder::new(&binance, "/test")
478            .optional_param("limit", Some(100u32))
479            .optional_param("since", Some(1234567890i64));
480
481        assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
482        assert_eq!(builder.params.get("since"), Some(&"1234567890".to_string()));
483    }
484
485    #[test]
486    fn test_builder_optional_param_none() {
487        let config = ExchangeConfig::default();
488        let binance = Binance::new(config).unwrap();
489
490        let none_value: Option<u32> = None;
491        let builder =
492            SignedRequestBuilder::new(&binance, "/test").optional_param("limit", none_value);
493
494        assert!(builder.params.get("limit").is_none());
495    }
496
497    #[test]
498    fn test_builder_params_bulk() {
499        let config = ExchangeConfig::default();
500        let binance = Binance::new(config).unwrap();
501
502        let mut params = BTreeMap::new();
503        params.insert("symbol".to_string(), "BTCUSDT".to_string());
504        params.insert("side".to_string(), "BUY".to_string());
505
506        let builder = SignedRequestBuilder::new(&binance, "/test").params(params);
507
508        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
509        assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
510    }
511
512    #[test]
513    fn test_builder_merge_json_params() {
514        let config = ExchangeConfig::default();
515        let binance = Binance::new(config).unwrap();
516
517        let json_params = Some(serde_json::json!({
518            "orderId": "12345",
519            "fromId": 67890,
520            "active": true,
521            "nested": {"ignored": "value"},
522            "array": [1, 2, 3]
523        }));
524
525        let builder = SignedRequestBuilder::new(&binance, "/test").merge_json_params(json_params);
526
527        assert_eq!(builder.params.get("orderId"), Some(&"12345".to_string()));
528        assert_eq!(builder.params.get("fromId"), Some(&"67890".to_string()));
529        assert_eq!(builder.params.get("active"), Some(&"true".to_string()));
530        // Nested objects and arrays should be ignored
531        assert!(builder.params.get("nested").is_none());
532        assert!(builder.params.get("array").is_none());
533    }
534
535    #[test]
536    fn test_builder_merge_json_params_none() {
537        let config = ExchangeConfig::default();
538        let binance = Binance::new(config).unwrap();
539
540        let builder = SignedRequestBuilder::new(&binance, "/test")
541            .param("existing", "value")
542            .merge_json_params(None);
543
544        assert_eq!(builder.params.get("existing"), Some(&"value".to_string()));
545        assert_eq!(builder.params.len(), 1);
546    }
547
548    #[test]
549    fn test_build_query_string() {
550        let mut params = BTreeMap::new();
551        params.insert("symbol".to_string(), "BTCUSDT".to_string());
552        params.insert("side".to_string(), "BUY".to_string());
553        params.insert("amount".to_string(), "1.5".to_string());
554
555        let query = build_query_string(&params);
556
557        // BTreeMap maintains alphabetical order
558        assert_eq!(query, "amount=1.5&side=BUY&symbol=BTCUSDT");
559    }
560
561    #[test]
562    fn test_build_query_string_empty() {
563        let params = BTreeMap::new();
564        let query = build_query_string(&params);
565        assert!(query.is_empty());
566    }
567
568    #[test]
569    fn test_builder_all_http_methods() {
570        let config = ExchangeConfig::default();
571        let binance = Binance::new(config).unwrap();
572
573        // Test all HTTP methods can be set
574        let get_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Get);
575        assert_eq!(get_builder.method, HttpMethod::Get);
576
577        let post_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Post);
578        assert_eq!(post_builder.method, HttpMethod::Post);
579
580        let put_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Put);
581        assert_eq!(put_builder.method, HttpMethod::Put);
582
583        let delete_builder =
584            SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Delete);
585        assert_eq!(delete_builder.method, HttpMethod::Delete);
586    }
587
588    #[test]
589    fn test_builder_parameter_ordering() {
590        let config = ExchangeConfig::default();
591        let binance = Binance::new(config).unwrap();
592
593        // Add parameters in non-alphabetical order
594        let builder = SignedRequestBuilder::new(&binance, "/test")
595            .param("zebra", "z")
596            .param("apple", "a")
597            .param("mango", "m");
598
599        // BTreeMap should maintain alphabetical order
600        let keys: Vec<_> = builder.params.keys().collect();
601        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
602    }
603}
604
605#[cfg(test)]
606mod property_tests {
607    use super::*;
608    use ccxt_core::ExchangeConfig;
609    use proptest::prelude::*;
610
611    // Strategy to generate valid parameter keys (alphanumeric, non-empty)
612    fn param_key_strategy() -> impl Strategy<Value = String> {
613        "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
614    }
615
616    // Strategy to generate valid parameter values
617    fn param_value_strategy() -> impl Strategy<Value = String> {
618        "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
619    }
620
621    // Strategy to generate a BTreeMap of parameters
622    fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
623        proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
624    }
625
626    proptest! {
627        #![proptest_config(ProptestConfig::with_cases(100))]
628
629        /// **Feature: binance-signed-request-refactor, Property 2: Signed Request Contains Required Fields**
630        ///
631        /// *For any* executed signed request with valid credentials, the signed parameters SHALL contain:
632        /// - A `timestamp` field with a valid millisecond timestamp
633        /// - A `signature` field with a 64-character hex string (HMAC-SHA256)
634        /// - A `recvWindow` field matching the configured value
635        ///
636        /// **Validates: Requirements 1.3, 1.4, 1.5, 1.6**
637        #[test]
638        fn prop_signed_request_contains_required_fields(
639            params in params_strategy(),
640            recv_window in 1000u64..60000u64
641        ) {
642            // Create a Binance instance with credentials
643            let mut config = ExchangeConfig::default();
644            config.api_key = Some("test_api_key".to_string().into());
645            config.secret = Some("test_secret_key".to_string().into());
646
647            let mut options = super::super::BinanceOptions::default();
648            options.recv_window = recv_window;
649
650            let binance = super::super::Binance::new_with_options(config, options).unwrap();
651
652            // Get auth and sign parameters manually to verify the signing logic
653            let auth = binance.get_auth().unwrap();
654            let timestamp = 1234567890000i64; // Fixed timestamp for testing
655
656            let signed_params = auth.sign_with_timestamp(&params, timestamp, Some(recv_window)).unwrap();
657
658            // Property 1: timestamp field exists and matches
659            prop_assert!(signed_params.contains_key("timestamp"));
660            prop_assert_eq!(signed_params.get("timestamp").unwrap(), &timestamp.to_string());
661
662            // Property 2: signature field exists and is 64 hex characters
663            prop_assert!(signed_params.contains_key("signature"));
664            let signature = signed_params.get("signature").unwrap();
665            prop_assert_eq!(signature.len(), 64, "Signature should be 64 hex characters");
666            prop_assert!(signature.chars().all(|c| c.is_ascii_hexdigit()), "Signature should be hex");
667
668            // Property 3: recvWindow field exists and matches configured value
669            prop_assert!(signed_params.contains_key("recvWindow"));
670            prop_assert_eq!(signed_params.get("recvWindow").unwrap(), &recv_window.to_string());
671
672            // Property 4: All original parameters are preserved
673            for (key, value) in &params {
674                prop_assert!(signed_params.contains_key(key), "Original param {} should be preserved", key);
675                prop_assert_eq!(signed_params.get(key).unwrap(), value, "Original param {} value should match", key);
676            }
677        }
678
679        /// **Feature: binance-signed-request-refactor, Property 5: Optional Parameters Conditional Addition**
680        ///
681        /// *For any* call to `optional_param(key, value)`:
682        /// - If `value` is `Some(v)`, the parameter SHALL be added with key and v.to_string()
683        /// - If `value` is `None`, the parameter SHALL NOT be added
684        ///
685        /// **Validates: Requirements 2.2**
686        #[test]
687        fn prop_optional_param_conditional_addition(
688            key in param_key_strategy(),
689            value in proptest::option::of(param_value_strategy()),
690            other_key in param_key_strategy().prop_filter("other_key must differ from key", |k| k != "z"),
691            other_value in param_value_strategy()
692        ) {
693            // Skip if keys are the same to avoid overwrite conflicts
694            prop_assume!(key != other_key);
695
696            let config = ExchangeConfig::default();
697            let binance = super::super::Binance::new(config).unwrap();
698
699            // Build with optional parameter
700            let builder = SignedRequestBuilder::new(&binance, "/test")
701                .param(&other_key, &other_value)
702                .optional_param(&key, value.clone());
703
704            // Property: If value is Some, parameter should be present with correct value
705            // If value is None, parameter should NOT be present
706            match value {
707                Some(v) => {
708                    prop_assert!(
709                        builder.params.contains_key(&key),
710                        "Parameter {} should be present when value is Some",
711                        key
712                    );
713                    prop_assert_eq!(
714                        builder.params.get(&key).unwrap(),
715                        &v,
716                        "Parameter {} should have correct value",
717                        key
718                    );
719                }
720                None => {
721                    // Only check if key is different from other_key
722                    if key != other_key {
723                        prop_assert!(
724                            !builder.params.contains_key(&key),
725                            "Parameter {} should NOT be present when value is None",
726                            key
727                        );
728                    }
729                }
730            }
731
732            // Property: Other parameters should always be present
733            prop_assert!(
734                builder.params.contains_key(&other_key),
735                "Other parameter {} should always be present",
736                other_key
737            );
738            prop_assert_eq!(
739                builder.params.get(&other_key).unwrap(),
740                &other_value,
741                "Other parameter {} should have correct value",
742                other_key
743            );
744        }
745    }
746}