ccxt_exchanges/bybit/
signed_request.rs

1//! Signed request builder for Bybit API.
2//!
3//! This module provides a builder pattern for creating authenticated Bybit API requests,
4//! encapsulating the common signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! The `BybitSignedRequestBuilder` eliminates code duplication by centralizing:
9//! - Credential validation
10//! - Millisecond timestamp generation
11//! - Parameter signing with HMAC-SHA256 (hex encoded)
12//! - Authentication header injection
13//! - HTTP request execution
14//!
15//! # Example
16//!
17//! ```no_run
18//! # use ccxt_exchanges::bybit::Bybit;
19//! # use ccxt_exchanges::bybit::signed_request::HttpMethod;
20//! # use ccxt_core::ExchangeConfig;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let bybit = Bybit::new(ExchangeConfig::default())?;
23//!
24//! // Simple GET request
25//! let data = bybit.signed_request("/v5/account/wallet-balance")
26//!     .param("accountType", "UNIFIED")
27//!     .execute()
28//!     .await?;
29//!
30//! // POST request with body
31//! let data = bybit.signed_request("/v5/order/create")
32//!     .method(HttpMethod::Post)
33//!     .param("category", "spot")
34//!     .param("symbol", "BTCUSDT")
35//!     .param("side", "Buy")
36//!     .execute()
37//!     .await?;
38//! # Ok(())
39//! # }
40//! ```
41
42use super::Bybit;
43use ccxt_core::{Error, ParseError, Result};
44use reqwest::header::{HeaderMap, HeaderValue};
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    /// DELETE request - parameters in JSON body
57    Delete,
58}
59
60/// Builder for creating authenticated Bybit API requests.
61///
62/// This builder encapsulates the common signing workflow:
63/// 1. Credential validation
64/// 2. Millisecond timestamp generation
65/// 3. Parameter signing with HMAC-SHA256 (hex encoded)
66/// 4. Authentication header injection (X-BAPI-* headers)
67/// 5. HTTP request execution
68///
69/// # Bybit Signature Format
70///
71/// Bybit uses a unique signature format:
72/// - Sign string: `timestamp + api_key + recv_window + params`
73/// - For GET: params is the query string
74/// - For POST: params is the JSON body string
75/// - Signature is hex-encoded HMAC-SHA256
76///
77/// # Example
78///
79/// ```no_run
80/// # use ccxt_exchanges::bybit::Bybit;
81/// # use ccxt_exchanges::bybit::signed_request::HttpMethod;
82/// # use ccxt_core::ExchangeConfig;
83/// # async fn example() -> ccxt_core::Result<()> {
84/// let bybit = Bybit::new(ExchangeConfig::default())?;
85///
86/// let data = bybit.signed_request("/v5/account/wallet-balance")
87///     .param("accountType", "UNIFIED")
88///     .optional_param("coin", Some("BTC"))
89///     .execute()
90///     .await?;
91/// # Ok(())
92/// # }
93/// ```
94pub struct BybitSignedRequestBuilder<'a> {
95    /// Reference to the Bybit exchange instance
96    bybit: &'a Bybit,
97    /// Request parameters to be signed
98    params: BTreeMap<String, String>,
99    /// Request body for POST/DELETE requests
100    body: Option<Value>,
101    /// API endpoint path
102    endpoint: String,
103    /// HTTP method for the request
104    method: HttpMethod,
105}
106
107impl<'a> BybitSignedRequestBuilder<'a> {
108    /// Creates a new signed request builder.
109    ///
110    /// # Arguments
111    ///
112    /// * `bybit` - Reference to the Bybit exchange instance
113    /// * `endpoint` - API endpoint path (e.g., "/v5/account/wallet-balance")
114    ///
115    /// # Example
116    ///
117    /// ```no_run
118    /// # use ccxt_exchanges::bybit::Bybit;
119    /// # use ccxt_exchanges::bybit::signed_request::BybitSignedRequestBuilder;
120    /// # use ccxt_core::ExchangeConfig;
121    /// let bybit = Bybit::new(ExchangeConfig::default()).unwrap();
122    /// let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/account/wallet-balance");
123    /// ```
124    pub fn new(bybit: &'a Bybit, endpoint: impl Into<String>) -> Self {
125        Self {
126            bybit,
127            params: BTreeMap::new(),
128            body: None,
129            endpoint: endpoint.into(),
130            method: HttpMethod::default(),
131        }
132    }
133
134    /// Sets the HTTP method for the request.
135    ///
136    /// Default is GET.
137    ///
138    /// # Arguments
139    ///
140    /// * `method` - The HTTP method to use
141    ///
142    /// # Example
143    ///
144    /// ```no_run
145    /// # use ccxt_exchanges::bybit::Bybit;
146    /// # use ccxt_exchanges::bybit::signed_request::HttpMethod;
147    /// # use ccxt_core::ExchangeConfig;
148    /// # async fn example() -> ccxt_core::Result<()> {
149    /// let bybit = Bybit::new(ExchangeConfig::default())?;
150    /// let data = bybit.signed_request("/v5/order/create")
151    ///     .method(HttpMethod::Post)
152    ///     .param("category", "spot")
153    ///     .execute()
154    ///     .await?;
155    /// # Ok(())
156    /// # }
157    /// ```
158    pub fn method(mut self, method: HttpMethod) -> Self {
159        self.method = method;
160        self
161    }
162
163    /// Adds a required parameter.
164    ///
165    /// # Arguments
166    ///
167    /// * `key` - Parameter name
168    /// * `value` - Parameter value (will be converted to string)
169    ///
170    /// # Example
171    ///
172    /// ```no_run
173    /// # use ccxt_exchanges::bybit::Bybit;
174    /// # use ccxt_core::ExchangeConfig;
175    /// # async fn example() -> ccxt_core::Result<()> {
176    /// let bybit = Bybit::new(ExchangeConfig::default())?;
177    /// let data = bybit.signed_request("/v5/order/realtime")
178    ///     .param("category", "spot")
179    ///     .param("symbol", "BTCUSDT")
180    ///     .execute()
181    ///     .await?;
182    /// # Ok(())
183    /// # }
184    /// ```
185    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
186        self.params.insert(key.into(), value.to_string());
187        self
188    }
189
190    /// Adds an optional parameter (only if value is Some).
191    ///
192    /// # Arguments
193    ///
194    /// * `key` - Parameter name
195    /// * `value` - Optional parameter value
196    ///
197    /// # Example
198    ///
199    /// ```no_run
200    /// # use ccxt_exchanges::bybit::Bybit;
201    /// # use ccxt_core::ExchangeConfig;
202    /// # async fn example() -> ccxt_core::Result<()> {
203    /// let bybit = Bybit::new(ExchangeConfig::default())?;
204    /// let since: Option<i64> = Some(1234567890000);
205    /// let limit: Option<u32> = None;
206    ///
207    /// let data = bybit.signed_request("/v5/order/history")
208    ///     .param("category", "spot")
209    ///     .optional_param("startTime", since)
210    ///     .optional_param("limit", limit)  // Won't be added since it's None
211    ///     .execute()
212    ///     .await?;
213    /// # Ok(())
214    /// # }
215    /// ```
216    pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
217        if let Some(v) = value {
218            self.params.insert(key.into(), v.to_string());
219        }
220        self
221    }
222
223    /// Adds multiple parameters from a BTreeMap.
224    ///
225    /// # Arguments
226    ///
227    /// * `params` - Map of parameter key-value pairs
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// # use ccxt_exchanges::bybit::Bybit;
233    /// # use ccxt_core::ExchangeConfig;
234    /// # use std::collections::BTreeMap;
235    /// # async fn example() -> ccxt_core::Result<()> {
236    /// let bybit = Bybit::new(ExchangeConfig::default())?;
237    /// let mut params = BTreeMap::new();
238    /// params.insert("category".to_string(), "spot".to_string());
239    /// params.insert("symbol".to_string(), "BTCUSDT".to_string());
240    ///
241    /// let data = bybit.signed_request("/v5/order/create")
242    ///     .params(params)
243    ///     .execute()
244    ///     .await?;
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
249        self.params.extend(params);
250        self
251    }
252
253    /// Sets the request body for POST/DELETE requests.
254    ///
255    /// # Arguments
256    ///
257    /// * `body` - JSON value to use as request body
258    ///
259    /// # Example
260    ///
261    /// ```no_run
262    /// # use ccxt_exchanges::bybit::Bybit;
263    /// # use ccxt_core::ExchangeConfig;
264    /// # use serde_json::json;
265    /// # async fn example() -> ccxt_core::Result<()> {
266    /// let bybit = Bybit::new(ExchangeConfig::default())?;
267    /// let body = json!({
268    ///     "category": "spot",
269    ///     "symbol": "BTCUSDT",
270    ///     "side": "Buy",
271    ///     "orderType": "Limit"
272    /// });
273    ///
274    /// let data = bybit.signed_request("/v5/order/create")
275    ///     .method(ccxt_exchanges::bybit::signed_request::HttpMethod::Post)
276    ///     .body(body)
277    ///     .execute()
278    ///     .await?;
279    /// # Ok(())
280    /// # }
281    /// ```
282    pub fn body(mut self, body: Value) -> Self {
283        self.body = Some(body);
284        self
285    }
286
287    /// Executes the signed request and returns the response.
288    ///
289    /// This method:
290    /// 1. Validates that credentials are configured
291    /// 2. Gets the current millisecond timestamp
292    /// 3. Signs the request with HMAC-SHA256 (hex encoded)
293    /// 4. Adds authentication headers (X-BAPI-*)
294    /// 5. Executes the HTTP request
295    ///
296    /// # Bybit Signature Details
297    ///
298    /// - Sign string format: `timestamp + api_key + recv_window + params`
299    /// - For GET requests: params is the query string
300    /// - For POST requests: params is the JSON body string
301    /// - Signature is hex-encoded (not Base64 like OKX/Bitget)
302    ///
303    /// # Returns
304    ///
305    /// Returns the raw `serde_json::Value` response for further parsing.
306    ///
307    /// # Errors
308    ///
309    /// - Returns authentication error if credentials are missing
310    /// - Returns network error if the request fails
311    /// - Returns parse error if response parsing fails
312    ///
313    /// # Example
314    ///
315    /// ```no_run
316    /// # use ccxt_exchanges::bybit::Bybit;
317    /// # use ccxt_core::ExchangeConfig;
318    /// # async fn example() -> ccxt_core::Result<()> {
319    /// let bybit = Bybit::new(ExchangeConfig::default())?;
320    /// let data = bybit.signed_request("/v5/account/wallet-balance")
321    ///     .param("accountType", "UNIFIED")
322    ///     .execute()
323    ///     .await?;
324    /// println!("Response: {:?}", data);
325    /// # Ok(())
326    /// # }
327    /// ```
328    pub async fn execute(self) -> Result<Value> {
329        // Step 1: Validate credentials
330        self.bybit.check_required_credentials()?;
331
332        // Step 2: Get current millisecond timestamp
333        let timestamp = chrono::Utc::now().timestamp_millis().to_string();
334
335        // Step 3: Get auth and recv_window
336        let auth = self.bybit.get_auth()?;
337        let recv_window = self.bybit.options().recv_window;
338
339        // Build the sign params based on HTTP method
340        // For GET: sign the query string
341        // For POST/DELETE: sign the JSON body
342        let sign_params = if self.method == HttpMethod::Get {
343            build_query_string(&self.params)
344        } else if let Some(ref body) = self.body {
345            body.to_string()
346        } else if !self.params.is_empty() {
347            // Convert params to JSON for POST/DELETE
348            serde_json::to_string(&self.params).unwrap_or_default()
349        } else {
350            String::new()
351        };
352
353        // Sign the request (Bybit uses hex encoding)
354        let signature = auth.sign(&timestamp, recv_window, &sign_params);
355
356        // Step 4: Add authentication headers
357        let mut headers = HeaderMap::new();
358        auth.add_auth_headers(&mut headers, &timestamp, &signature, recv_window);
359        headers.insert("Content-Type", HeaderValue::from_static("application/json"));
360
361        // Step 5: Execute HTTP request based on method
362        let urls = self.bybit.urls();
363        let full_url = format!("{}{}", urls.rest, self.endpoint);
364
365        match self.method {
366            HttpMethod::Get => {
367                // Build query string for GET requests
368                let query_string = build_query_string(&self.params);
369                let url = if query_string.is_empty() {
370                    full_url
371                } else {
372                    format!("{}?{}", full_url, query_string)
373                };
374                self.bybit.base().http_client.get(&url, Some(headers)).await
375            }
376            HttpMethod::Post => {
377                // Use explicit body if provided, otherwise use params as JSON
378                let body = if let Some(b) = self.body {
379                    b
380                } else {
381                    serde_json::to_value(&self.params).map_err(|e| {
382                        Error::from(ParseError::invalid_format(
383                            "data",
384                            format!("Failed to serialize request body: {}", e),
385                        ))
386                    })?
387                };
388                self.bybit
389                    .base()
390                    .http_client
391                    .post(&full_url, Some(headers), Some(body))
392                    .await
393            }
394            HttpMethod::Delete => {
395                // Use explicit body if provided, otherwise use params as JSON
396                let body = if let Some(b) = self.body {
397                    b
398                } else {
399                    serde_json::to_value(&self.params).map_err(|e| {
400                        Error::from(ParseError::invalid_format(
401                            "data",
402                            format!("Failed to serialize request body: {}", e),
403                        ))
404                    })?
405                };
406                self.bybit
407                    .base()
408                    .http_client
409                    .delete(&full_url, Some(headers), Some(body))
410                    .await
411            }
412        }
413    }
414}
415
416/// Converts HttpMethod to string representation.
417#[allow(dead_code)]
418fn method_to_string(method: HttpMethod) -> String {
419    match method {
420        HttpMethod::Get => "GET".to_string(),
421        HttpMethod::Post => "POST".to_string(),
422        HttpMethod::Delete => "DELETE".to_string(),
423    }
424}
425
426/// Builds a URL-encoded query string from parameters.
427///
428/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
429fn build_query_string(params: &BTreeMap<String, String>) -> String {
430    params
431        .iter()
432        .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
433        .collect::<Vec<_>>()
434        .join("&")
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use ccxt_core::ExchangeConfig;
441
442    #[test]
443    fn test_http_method_default() {
444        assert_eq!(HttpMethod::default(), HttpMethod::Get);
445    }
446
447    #[test]
448    fn test_builder_construction() {
449        let config = ExchangeConfig::default();
450        let bybit = Bybit::new(config).unwrap();
451
452        let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/account/wallet-balance");
453
454        assert_eq!(builder.endpoint, "/v5/account/wallet-balance");
455        assert_eq!(builder.method, HttpMethod::Get);
456        assert!(builder.params.is_empty());
457        assert!(builder.body.is_none());
458    }
459
460    #[test]
461    fn test_builder_method_chaining() {
462        let config = ExchangeConfig::default();
463        let bybit = Bybit::new(config).unwrap();
464
465        let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/order/create")
466            .method(HttpMethod::Post)
467            .param("category", "spot")
468            .param("symbol", "BTCUSDT");
469
470        assert_eq!(builder.method, HttpMethod::Post);
471        assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
472        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
473    }
474
475    #[test]
476    fn test_builder_param() {
477        let config = ExchangeConfig::default();
478        let bybit = Bybit::new(config).unwrap();
479
480        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
481            .param("string_param", "value")
482            .param("int_param", 123)
483            .param("float_param", 45.67);
484
485        assert_eq!(
486            builder.params.get("string_param"),
487            Some(&"value".to_string())
488        );
489        assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
490        assert_eq!(
491            builder.params.get("float_param"),
492            Some(&"45.67".to_string())
493        );
494    }
495
496    #[test]
497    fn test_builder_optional_param_some() {
498        let config = ExchangeConfig::default();
499        let bybit = Bybit::new(config).unwrap();
500
501        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
502            .optional_param("limit", Some(100u32))
503            .optional_param("startTime", Some(1234567890i64));
504
505        assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
506        assert_eq!(
507            builder.params.get("startTime"),
508            Some(&"1234567890".to_string())
509        );
510    }
511
512    #[test]
513    fn test_builder_optional_param_none() {
514        let config = ExchangeConfig::default();
515        let bybit = Bybit::new(config).unwrap();
516
517        let none_value: Option<u32> = None;
518        let builder =
519            BybitSignedRequestBuilder::new(&bybit, "/test").optional_param("limit", none_value);
520
521        assert!(builder.params.get("limit").is_none());
522    }
523
524    #[test]
525    fn test_builder_params_bulk() {
526        let config = ExchangeConfig::default();
527        let bybit = Bybit::new(config).unwrap();
528
529        let mut params = BTreeMap::new();
530        params.insert("category".to_string(), "spot".to_string());
531        params.insert("symbol".to_string(), "BTCUSDT".to_string());
532
533        let builder = BybitSignedRequestBuilder::new(&bybit, "/test").params(params);
534
535        assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
536        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
537    }
538
539    #[test]
540    fn test_builder_body() {
541        let config = ExchangeConfig::default();
542        let bybit = Bybit::new(config).unwrap();
543
544        let body = serde_json::json!({
545            "category": "spot",
546            "symbol": "BTCUSDT"
547        });
548
549        let builder = BybitSignedRequestBuilder::new(&bybit, "/test").body(body.clone());
550
551        assert_eq!(builder.body, Some(body));
552    }
553
554    #[test]
555    fn test_builder_all_http_methods() {
556        let config = ExchangeConfig::default();
557        let bybit = Bybit::new(config).unwrap();
558
559        // Test all HTTP methods can be set
560        let get_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Get);
561        assert_eq!(get_builder.method, HttpMethod::Get);
562
563        let post_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Post);
564        assert_eq!(post_builder.method, HttpMethod::Post);
565
566        let delete_builder =
567            BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Delete);
568        assert_eq!(delete_builder.method, HttpMethod::Delete);
569    }
570
571    #[test]
572    fn test_builder_parameter_ordering() {
573        let config = ExchangeConfig::default();
574        let bybit = Bybit::new(config).unwrap();
575
576        // Add parameters in non-alphabetical order
577        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
578            .param("zebra", "z")
579            .param("apple", "a")
580            .param("mango", "m");
581
582        // BTreeMap should maintain alphabetical order
583        let keys: Vec<_> = builder.params.keys().collect();
584        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
585    }
586
587    #[test]
588    fn test_method_to_string() {
589        assert_eq!(method_to_string(HttpMethod::Get), "GET");
590        assert_eq!(method_to_string(HttpMethod::Post), "POST");
591        assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
592    }
593
594    #[test]
595    fn test_build_query_string() {
596        let mut params = BTreeMap::new();
597        params.insert("category".to_string(), "spot".to_string());
598        params.insert("symbol".to_string(), "BTCUSDT".to_string());
599        params.insert("limit".to_string(), "50".to_string());
600
601        let query = build_query_string(&params);
602
603        // BTreeMap maintains alphabetical order
604        assert_eq!(query, "category=spot&limit=50&symbol=BTCUSDT");
605    }
606
607    #[test]
608    fn test_build_query_string_empty() {
609        let params = BTreeMap::new();
610        let query = build_query_string(&params);
611        assert!(query.is_empty());
612    }
613
614    #[test]
615    fn test_build_query_string_with_special_chars() {
616        let mut params = BTreeMap::new();
617        params.insert("symbol".to_string(), "BTC/USDT".to_string());
618
619        let query = build_query_string(&params);
620
621        // Should URL-encode the slash
622        assert_eq!(query, "symbol=BTC%2FUSDT");
623    }
624}
625
626#[cfg(test)]
627mod property_tests {
628    use super::*;
629    use ccxt_core::ExchangeConfig;
630    use proptest::prelude::*;
631
632    // Strategy to generate valid parameter keys (alphanumeric, non-empty)
633    fn param_key_strategy() -> impl Strategy<Value = String> {
634        "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
635    }
636
637    // Strategy to generate valid parameter values
638    fn param_value_strategy() -> impl Strategy<Value = String> {
639        "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
640    }
641
642    // Strategy to generate a BTreeMap of parameters
643    fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
644        proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
645    }
646
647    proptest! {
648        #![proptest_config(ProptestConfig::with_cases(100))]
649
650        /// **Feature: exchange-signed-request-refactor, Property 1: Fluent API Method Chaining**
651        ///
652        /// *For any* sequence of builder method calls (param, optional_param, params, method),
653        /// each method SHALL return Self allowing further method chaining, and the final execute()
654        /// call SHALL consume the builder.
655        ///
656        /// **Validates: Requirements 3.1**
657        #[test]
658        fn prop_fluent_api_method_chaining(
659            params in params_strategy(),
660            other_key in param_key_strategy(),
661            other_value in param_value_strategy()
662        ) {
663            let config = ExchangeConfig::default();
664            let bybit = Bybit::new(config).unwrap();
665
666            // Build with method chaining
667            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
668                .method(HttpMethod::Post)
669                .params(params.clone())
670                .param(&other_key, &other_value)
671                .optional_param("optional", Some("value"));
672
673            // Verify all parameters are present
674            for (key, value) in &params {
675                prop_assert_eq!(builder.params.get(key), Some(value));
676            }
677            prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
678            prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
679            prop_assert_eq!(builder.method, HttpMethod::Post);
680        }
681
682        /// **Feature: exchange-signed-request-refactor, Property 5: Parameter Methods Correctly Populate Params**
683        ///
684        /// *For any* combination of param(), optional_param(), and params() calls:
685        /// - param(key, value) SHALL always add the key-value pair
686        /// - optional_param(key, Some(value)) SHALL add the key-value pair
687        /// - optional_param(key, None) SHALL NOT add any key-value pair
688        /// - params(map) SHALL add all key-value pairs from the map
689        ///
690        /// **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8**
691        #[test]
692        fn prop_optional_param_conditional_addition(
693            key in param_key_strategy(),
694            value in proptest::option::of(param_value_strategy()),
695            other_key in param_key_strategy(),
696            other_value in param_value_strategy()
697        ) {
698            let config = ExchangeConfig::default();
699            let bybit = Bybit::new(config).unwrap();
700
701            // Build with optional parameter
702            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
703                .param(&other_key, &other_value)
704                .optional_param(&key, value.clone());
705
706            // Property: If value is Some, parameter should be present with correct value
707            // If value is None, parameter should NOT be present
708            match value {
709                Some(v) => {
710                    prop_assert!(
711                        builder.params.contains_key(&key),
712                        "Parameter {} should be present when value is Some",
713                        key
714                    );
715                    prop_assert_eq!(
716                        builder.params.get(&key).unwrap(),
717                        &v,
718                        "Parameter {} should have correct value",
719                        key
720                    );
721                }
722                None => {
723                    // Only check if key is different from other_key
724                    if key != other_key {
725                        prop_assert!(
726                            !builder.params.contains_key(&key),
727                            "Parameter {} should NOT be present when value is None",
728                            key
729                        );
730                    }
731                }
732            }
733
734            // Property: Other parameters should always be present
735            prop_assert!(
736                builder.params.contains_key(&other_key),
737                "Other parameter {} should always be present",
738                other_key
739            );
740            prop_assert_eq!(
741                builder.params.get(&other_key).unwrap(),
742                &other_value,
743                "Other parameter {} should have correct value",
744                other_key
745            );
746        }
747
748        /// **Feature: exchange-signed-request-refactor, Property 6: HTTP Method Determines Parameter Location**
749        ///
750        /// *For any* signed request:
751        /// - GET requests SHALL have parameters in the URL query string
752        /// - POST requests SHALL have parameters in the JSON request body
753        /// - The signing input SHALL use the appropriate format based on HTTP method
754        ///
755        /// **Validates: Requirements 3.8**
756        #[test]
757        fn prop_http_method_determines_param_location(
758            params in params_strategy(),
759            method in prop_oneof![
760                Just(HttpMethod::Get),
761                Just(HttpMethod::Post),
762                Just(HttpMethod::Delete)
763            ]
764        ) {
765            let config = ExchangeConfig::default();
766            let bybit = Bybit::new(config).unwrap();
767
768            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
769                .method(method)
770                .params(params.clone());
771
772            // Verify method is set correctly
773            prop_assert_eq!(builder.method, method);
774
775            // Verify all params are stored
776            for (key, value) in &params {
777                prop_assert_eq!(builder.params.get(key), Some(value));
778            }
779        }
780    }
781}