Skip to main content

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).map_err(|e| {
349                ccxt_core::Error::from(ccxt_core::ParseError::invalid_format(
350                    "request params",
351                    format!("JSON serialization failed: {}", e),
352                ))
353            })?
354        } else {
355            String::new()
356        };
357
358        // Sign the request (Bybit uses hex encoding)
359        let signature = auth.sign(&timestamp, recv_window, &sign_params);
360
361        // Step 4: Add authentication headers
362        let mut headers = HeaderMap::new();
363        auth.add_auth_headers(&mut headers, &timestamp, &signature, recv_window);
364        headers.insert("Content-Type", HeaderValue::from_static("application/json"));
365
366        // Step 5: Execute HTTP request based on method
367        let urls = self.bybit.urls();
368        let full_url = format!("{}{}", urls.rest, self.endpoint);
369
370        match self.method {
371            HttpMethod::Get => {
372                // Build query string for GET requests
373                let query_string = build_query_string(&self.params);
374                let url = if query_string.is_empty() {
375                    full_url
376                } else {
377                    format!("{}?{}", full_url, query_string)
378                };
379                self.bybit.base().http_client.get(&url, Some(headers)).await
380            }
381            HttpMethod::Post => {
382                // Use explicit body if provided, otherwise use params as JSON
383                let body = if let Some(b) = self.body {
384                    b
385                } else {
386                    serde_json::to_value(&self.params).map_err(|e| {
387                        Error::from(ParseError::invalid_format(
388                            "data",
389                            format!("Failed to serialize request body: {}", e),
390                        ))
391                    })?
392                };
393                self.bybit
394                    .base()
395                    .http_client
396                    .post(&full_url, Some(headers), Some(body))
397                    .await
398            }
399            HttpMethod::Delete => {
400                // Use explicit body if provided, otherwise use params as JSON
401                let body = if let Some(b) = self.body {
402                    b
403                } else {
404                    serde_json::to_value(&self.params).map_err(|e| {
405                        Error::from(ParseError::invalid_format(
406                            "data",
407                            format!("Failed to serialize request body: {}", e),
408                        ))
409                    })?
410                };
411                self.bybit
412                    .base()
413                    .http_client
414                    .delete(&full_url, Some(headers), Some(body))
415                    .await
416            }
417        }
418    }
419}
420
421/// Converts HttpMethod to string representation.
422#[allow(dead_code)]
423fn method_to_string(method: HttpMethod) -> String {
424    match method {
425        HttpMethod::Get => "GET".to_string(),
426        HttpMethod::Post => "POST".to_string(),
427        HttpMethod::Delete => "DELETE".to_string(),
428    }
429}
430
431/// Builds a URL-encoded query string from parameters.
432///
433/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
434fn build_query_string(params: &BTreeMap<String, String>) -> String {
435    params
436        .iter()
437        .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
438        .collect::<Vec<_>>()
439        .join("&")
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use ccxt_core::ExchangeConfig;
446
447    #[test]
448    fn test_http_method_default() {
449        assert_eq!(HttpMethod::default(), HttpMethod::Get);
450    }
451
452    #[test]
453    fn test_builder_construction() {
454        let config = ExchangeConfig::default();
455        let bybit = Bybit::new(config).unwrap();
456
457        let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/account/wallet-balance");
458
459        assert_eq!(builder.endpoint, "/v5/account/wallet-balance");
460        assert_eq!(builder.method, HttpMethod::Get);
461        assert!(builder.params.is_empty());
462        assert!(builder.body.is_none());
463    }
464
465    #[test]
466    fn test_builder_method_chaining() {
467        let config = ExchangeConfig::default();
468        let bybit = Bybit::new(config).unwrap();
469
470        let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/order/create")
471            .method(HttpMethod::Post)
472            .param("category", "spot")
473            .param("symbol", "BTCUSDT");
474
475        assert_eq!(builder.method, HttpMethod::Post);
476        assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
477        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
478    }
479
480    #[test]
481    fn test_builder_param() {
482        let config = ExchangeConfig::default();
483        let bybit = Bybit::new(config).unwrap();
484
485        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
486            .param("string_param", "value")
487            .param("int_param", 123)
488            .param("float_param", 45.67);
489
490        assert_eq!(
491            builder.params.get("string_param"),
492            Some(&"value".to_string())
493        );
494        assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
495        assert_eq!(
496            builder.params.get("float_param"),
497            Some(&"45.67".to_string())
498        );
499    }
500
501    #[test]
502    fn test_builder_optional_param_some() {
503        let config = ExchangeConfig::default();
504        let bybit = Bybit::new(config).unwrap();
505
506        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
507            .optional_param("limit", Some(100u32))
508            .optional_param("startTime", Some(1234567890i64));
509
510        assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
511        assert_eq!(
512            builder.params.get("startTime"),
513            Some(&"1234567890".to_string())
514        );
515    }
516
517    #[test]
518    fn test_builder_optional_param_none() {
519        let config = ExchangeConfig::default();
520        let bybit = Bybit::new(config).unwrap();
521
522        let none_value: Option<u32> = None;
523        let builder =
524            BybitSignedRequestBuilder::new(&bybit, "/test").optional_param("limit", none_value);
525
526        assert!(builder.params.get("limit").is_none());
527    }
528
529    #[test]
530    fn test_builder_params_bulk() {
531        let config = ExchangeConfig::default();
532        let bybit = Bybit::new(config).unwrap();
533
534        let mut params = BTreeMap::new();
535        params.insert("category".to_string(), "spot".to_string());
536        params.insert("symbol".to_string(), "BTCUSDT".to_string());
537
538        let builder = BybitSignedRequestBuilder::new(&bybit, "/test").params(params);
539
540        assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
541        assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
542    }
543
544    #[test]
545    fn test_builder_body() {
546        let config = ExchangeConfig::default();
547        let bybit = Bybit::new(config).unwrap();
548
549        let body = serde_json::json!({
550            "category": "spot",
551            "symbol": "BTCUSDT"
552        });
553
554        let builder = BybitSignedRequestBuilder::new(&bybit, "/test").body(body.clone());
555
556        assert_eq!(builder.body, Some(body));
557    }
558
559    #[test]
560    fn test_builder_all_http_methods() {
561        let config = ExchangeConfig::default();
562        let bybit = Bybit::new(config).unwrap();
563
564        // Test all HTTP methods can be set
565        let get_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Get);
566        assert_eq!(get_builder.method, HttpMethod::Get);
567
568        let post_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Post);
569        assert_eq!(post_builder.method, HttpMethod::Post);
570
571        let delete_builder =
572            BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Delete);
573        assert_eq!(delete_builder.method, HttpMethod::Delete);
574    }
575
576    #[test]
577    fn test_builder_parameter_ordering() {
578        let config = ExchangeConfig::default();
579        let bybit = Bybit::new(config).unwrap();
580
581        // Add parameters in non-alphabetical order
582        let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
583            .param("zebra", "z")
584            .param("apple", "a")
585            .param("mango", "m");
586
587        // BTreeMap should maintain alphabetical order
588        let keys: Vec<_> = builder.params.keys().collect();
589        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
590    }
591
592    #[test]
593    fn test_method_to_string() {
594        assert_eq!(method_to_string(HttpMethod::Get), "GET");
595        assert_eq!(method_to_string(HttpMethod::Post), "POST");
596        assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
597    }
598
599    #[test]
600    fn test_build_query_string() {
601        let mut params = BTreeMap::new();
602        params.insert("category".to_string(), "spot".to_string());
603        params.insert("symbol".to_string(), "BTCUSDT".to_string());
604        params.insert("limit".to_string(), "50".to_string());
605
606        let query = build_query_string(&params);
607
608        // BTreeMap maintains alphabetical order
609        assert_eq!(query, "category=spot&limit=50&symbol=BTCUSDT");
610    }
611
612    #[test]
613    fn test_build_query_string_empty() {
614        let params = BTreeMap::new();
615        let query = build_query_string(&params);
616        assert!(query.is_empty());
617    }
618
619    #[test]
620    fn test_build_query_string_with_special_chars() {
621        let mut params = BTreeMap::new();
622        params.insert("symbol".to_string(), "BTC/USDT".to_string());
623
624        let query = build_query_string(&params);
625
626        // Should URL-encode the slash
627        assert_eq!(query, "symbol=BTC%2FUSDT");
628    }
629}
630
631#[cfg(test)]
632mod property_tests {
633    use super::*;
634    use ccxt_core::ExchangeConfig;
635    use proptest::prelude::*;
636
637    // Strategy to generate valid parameter keys (alphanumeric, non-empty)
638    fn param_key_strategy() -> impl Strategy<Value = String> {
639        "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
640    }
641
642    // Strategy to generate valid parameter values
643    fn param_value_strategy() -> impl Strategy<Value = String> {
644        "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
645    }
646
647    // Strategy to generate a BTreeMap of parameters
648    fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
649        proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
650    }
651
652    proptest! {
653        #![proptest_config(ProptestConfig::with_cases(100))]
654
655        /// **Feature: exchange-signed-request-refactor, Property 1: Fluent API Method Chaining**
656        ///
657        /// *For any* sequence of builder method calls (param, optional_param, params, method),
658        /// each method SHALL return Self allowing further method chaining, and the final execute()
659        /// call SHALL consume the builder.
660        ///
661        /// **Validates: Requirements 3.1**
662        #[test]
663        fn prop_fluent_api_method_chaining(
664            params in params_strategy(),
665            other_key in param_key_strategy(),
666            other_value in param_value_strategy()
667        ) {
668            let config = ExchangeConfig::default();
669            let bybit = Bybit::new(config).unwrap();
670
671            // Build with method chaining
672            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
673                .method(HttpMethod::Post)
674                .params(params.clone())
675                .param(&other_key, &other_value)
676                .optional_param("optional", Some("value"));
677
678            // Verify all parameters are present
679            for (key, value) in &params {
680                prop_assert_eq!(builder.params.get(key), Some(value));
681            }
682            prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
683            prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
684            prop_assert_eq!(builder.method, HttpMethod::Post);
685        }
686
687        /// **Feature: exchange-signed-request-refactor, Property 5: Parameter Methods Correctly Populate Params**
688        ///
689        /// *For any* combination of param(), optional_param(), and params() calls:
690        /// - param(key, value) SHALL always add the key-value pair
691        /// - optional_param(key, Some(value)) SHALL add the key-value pair
692        /// - optional_param(key, None) SHALL NOT add any key-value pair
693        /// - params(map) SHALL add all key-value pairs from the map
694        ///
695        /// **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8**
696        #[test]
697        fn prop_optional_param_conditional_addition(
698            key in param_key_strategy(),
699            value in proptest::option::of(param_value_strategy()),
700            other_key in param_key_strategy(),
701            other_value in param_value_strategy()
702        ) {
703            let config = ExchangeConfig::default();
704            let bybit = Bybit::new(config).unwrap();
705
706            // Build with optional parameter
707            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
708                .param(&other_key, &other_value)
709                .optional_param(&key, value.clone());
710
711            // Property: If value is Some, parameter should be present with correct value
712            // If value is None, parameter should NOT be present
713            match value {
714                Some(v) => {
715                    prop_assert!(
716                        builder.params.contains_key(&key),
717                        "Parameter {} should be present when value is Some",
718                        key
719                    );
720                    prop_assert_eq!(
721                        builder.params.get(&key).unwrap(),
722                        &v,
723                        "Parameter {} should have correct value",
724                        key
725                    );
726                }
727                None => {
728                    // Only check if key is different from other_key
729                    if key != other_key {
730                        prop_assert!(
731                            !builder.params.contains_key(&key),
732                            "Parameter {} should NOT be present when value is None",
733                            key
734                        );
735                    }
736                }
737            }
738
739            // Property: Other parameters should always be present
740            prop_assert!(
741                builder.params.contains_key(&other_key),
742                "Other parameter {} should always be present",
743                other_key
744            );
745            prop_assert_eq!(
746                builder.params.get(&other_key).unwrap(),
747                &other_value,
748                "Other parameter {} should have correct value",
749                other_key
750            );
751        }
752
753        /// **Feature: exchange-signed-request-refactor, Property 6: HTTP Method Determines Parameter Location**
754        ///
755        /// *For any* signed request:
756        /// - GET requests SHALL have parameters in the URL query string
757        /// - POST requests SHALL have parameters in the JSON request body
758        /// - The signing input SHALL use the appropriate format based on HTTP method
759        ///
760        /// **Validates: Requirements 3.8**
761        #[test]
762        fn prop_http_method_determines_param_location(
763            params in params_strategy(),
764            method in prop_oneof![
765                Just(HttpMethod::Get),
766                Just(HttpMethod::Post),
767                Just(HttpMethod::Delete)
768            ]
769        ) {
770            let config = ExchangeConfig::default();
771            let bybit = Bybit::new(config).unwrap();
772
773            let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
774                .method(method)
775                .params(params.clone());
776
777            // Verify method is set correctly
778            prop_assert_eq!(builder.method, method);
779
780            // Verify all params are stored
781            for (key, value) in &params {
782                prop_assert_eq!(builder.params.get(key), Some(value));
783            }
784        }
785    }
786}