rusty_oms/exchanges/
bithumb.rs

1//! Bithumb exchange implementation
2//!
3//! This module provides order management functionality for the Bithumb exchange,
4//! a major Korean cryptocurrency exchange.
5
6use std::{env, time::SystemTime, time::UNIX_EPOCH};
7
8use anyhow::Result;
9use async_trait::async_trait;
10use hmac::{Hmac, Mac};
11use reqwest::{Client, header};
12// Remove unused import
13use rusty_common::collections::FxHashMap;
14use rusty_model::{enums::OrderSide, trading_order::Order};
15use serde::{Deserialize, Serialize};
16use sha2::Sha512;
17use smallvec::SmallVec;
18// use smartstring::alias::String; // Using std::string::String instead
19
20use crate::execution_engine::Exchange;
21
22type HmacSha512 = Hmac<Sha512>;
23
24/// Bithumb exchange integration for order execution
25pub struct BithumbExchange {
26    client: Client,
27    api_key: std::string::String,
28    secret_key: std::string::String,
29    api_url: std::string::String,
30}
31
32impl BithumbExchange {
33    /// Create a new Bithumb exchange client
34    #[must_use]
35    pub fn new() -> Self {
36        // Load API keys from environment
37        let api_key =
38            env::var("BITHUMB_API_KEY").expect("BITHUMB_API_KEY not found in environment");
39        let secret_key =
40            env::var("BITHUMB_SECRET_KEY").expect("BITHUMB_SECRET_KEY not found in environment");
41
42        Self {
43            client: Client::new(),
44            api_key,
45            secret_key,
46            api_url: "https://api.bithumb.com".into(),
47        }
48    }
49}
50
51impl Default for BithumbExchange {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl BithumbExchange {
58    /// Create a new Bithumb exchange client with provided credentials
59    #[must_use]
60    pub fn with_credentials(api_key: std::string::String, secret_key: std::string::String) -> Self {
61        Self {
62            client: Client::new(),
63            api_key,
64            secret_key,
65            api_url: "https://api.bithumb.com".into(),
66        }
67    }
68
69    /// Create authentication parameters for API requests
70    fn create_auth_params(
71        &self,
72        endpoint: &str,
73        params: &mut FxHashMap<String, String>,
74    ) -> Result<header::HeaderMap> {
75        // Generate nonce
76        let nonce = SystemTime::now()
77            .duration_since(UNIX_EPOCH)
78            .unwrap()
79            .as_millis()
80            .to_string();
81
82        // Create parameters String for signature
83        let mut query_string = format!("{endpoint}{nonce}");
84        let mut sorted_keys: SmallVec<[_; 16]> = params.keys().collect();
85        sorted_keys.sort();
86
87        for key in sorted_keys {
88            let value = params.get(key).unwrap();
89            query_string.push_str(&format!("{key}{value}"));
90        }
91
92        // Generate HMAC signature
93        let mut mac = HmacSha512::new_from_slice(self.secret_key.as_bytes())
94            .expect("HMAC initialization failed");
95        mac.update(query_string.as_bytes());
96        let signature = hex::encode(mac.finalize().into_bytes());
97
98        // Create headers
99        let mut headers = header::HeaderMap::new();
100        headers.insert("Api-Key", header::HeaderValue::from_str(&self.api_key)?);
101        headers.insert("Api-Sign", header::HeaderValue::from_str(&signature)?);
102        headers.insert("Api-Nonce", header::HeaderValue::from_str(&nonce)?);
103        headers.insert(
104            header::CONTENT_TYPE,
105            header::HeaderValue::from_static("application/x-www-form-urlencoded"),
106        );
107
108        Ok(headers)
109    }
110}
111
112/// Bithumb order placement request
113#[derive(Debug, Serialize)]
114#[allow(dead_code)]
115struct PlaceOrderRequest {
116    order_currency: std::string::String,
117    payment_currency: std::string::String,
118    units: std::string::String,
119    price: std::string::String,
120    #[serde(rename = "type")]
121    order_type: std::string::String,
122}
123
124/// Bithumb order response
125#[derive(Debug, Deserialize)]
126#[allow(dead_code)]
127struct OrderResponse {
128    status: std::string::String,
129    order_id: std::string::String,
130    data: Option<Vec<std::string::String>>,
131}
132
133#[async_trait]
134impl Exchange for BithumbExchange {
135    // Add any required methods from the Exchange trait here
136    async fn send_order(&self, order: Order) -> crate::Result<()> {
137        // Use the symbol from the order
138        let symbol = &order.symbol;
139
140        // Determine the correct currencies from symbol
141        let parts: SmallVec<[&str; 2]> = symbol.split('_').collect();
142        let (order_currency, payment_currency): (std::string::String, std::string::String) =
143            if parts.len() >= 2 {
144                (parts[0].into(), parts[1].into())
145            } else {
146                (symbol.to_string(), "KRW".into())
147            };
148
149        // Convert Order to Bithumb order request
150        // Default to limit order if order_type is not available
151        let order_type = match order.side {
152            OrderSide::Buy => "bid",
153            OrderSide::Sell => "ask",
154        };
155
156        // Create request parameters
157        let mut params = FxHashMap::default();
158        params.insert("order_currency".into(), order_currency.clone());
159        params.insert("payment_currency".into(), payment_currency.clone());
160        params.insert("units".into(), order.quantity.to_string());
161        params.insert(
162            "price".into(),
163            order.price.map_or_else(|| "0".into(), |p| p.to_string()),
164        );
165        params.insert("type".into(), order_type.into());
166
167        // Endpoint for placing orders
168        let endpoint = "/trade/place";
169
170        // Get authentication headers
171        let headers = match self.create_auth_params(endpoint, &mut params) {
172            Ok(h) => h,
173            Err(e) => {
174                return Err(crate::OmsError::Exchange(
175                    format!("Authentication error: {e}").into(),
176                ));
177            }
178        };
179
180        // Send the request
181        let url = format!("{}{}", self.api_url, endpoint);
182        let response = match self
183            .client
184            .post(&url)
185            .headers(headers)
186            .form(&params)
187            .send()
188            .await
189        {
190            Ok(r) => r,
191            Err(e) => {
192                return Err(crate::OmsError::Exchange(
193                    format!("Request error: {e}").into(),
194                ));
195            }
196        };
197
198        // Parse response
199        let response_body = match response.text().await {
200            Ok(b) => b,
201            Err(e) => {
202                return Err(crate::OmsError::Exchange(
203                    format!("Failed to get response body: {e}").into(),
204                ));
205            }
206        };
207
208        let mut response_bytes = response_body.into_bytes();
209        let order_response: OrderResponse = match simd_json::from_slice(&mut response_bytes) {
210            Ok(r) => r,
211            Err(e) => {
212                return Err(crate::OmsError::Exchange(
213                    format!("Failed to parse response: {e}").into(),
214                ));
215            }
216        };
217
218        // Check status
219        if order_response.status != "0000" {
220            return Err(crate::OmsError::Exchange(
221                format!("Order placement failed: {}", order_response.status).into(),
222            ));
223        }
224
225        Ok(())
226    }
227
228    async fn cancel_order(&self, order_id: std::string::String) -> crate::Result<()> {
229        // Extract symbol from order_id (assuming it's part of the order_id)
230        let parts: SmallVec<[&str; 3]> = order_id.split(':').collect();
231        let (true_order_id, order_currency, payment_currency) = if parts.len() >= 3 {
232            (parts[0].into(), parts[1].into(), parts[2].into())
233        } else {
234            return Err(crate::OmsError::Exchange("Invalid order_id format".into()));
235        };
236
237        // Create request parameters
238        let mut params = FxHashMap::default();
239        params.insert("order_currency".into(), order_currency);
240        params.insert("payment_currency".into(), payment_currency);
241        params.insert("order_id".into(), true_order_id);
242
243        // Endpoint for cancelling orders
244        let endpoint = "/trade/cancel";
245
246        // Get authentication headers
247        let headers = match self.create_auth_params(endpoint, &mut params) {
248            Ok(h) => h,
249            Err(e) => {
250                return Err(crate::OmsError::Exchange(
251                    format!("Authentication error: {e}").into(),
252                ));
253            }
254        };
255
256        // Send the request
257        let url = format!("{}{}", self.api_url, endpoint);
258        let response = match self
259            .client
260            .post(&url)
261            .headers(headers)
262            .form(&params)
263            .send()
264            .await
265        {
266            Ok(r) => r,
267            Err(e) => {
268                return Err(crate::OmsError::Exchange(
269                    format!("Request error: {e}").into(),
270                ));
271            }
272        };
273
274        // Parse response
275        let response_body = match response.text().await {
276            Ok(b) => b,
277            Err(e) => {
278                return Err(crate::OmsError::Exchange(
279                    format!("Failed to get response body: {e}").into(),
280                ));
281            }
282        };
283
284        let mut response_bytes = response_body.into_bytes();
285        let cancel_response: OrderResponse = match simd_json::from_slice(&mut response_bytes) {
286            Ok(r) => r,
287            Err(e) => {
288                return Err(crate::OmsError::Exchange(
289                    format!("Failed to parse response: {e}").into(),
290                ));
291            }
292        };
293
294        // Check status
295        if cancel_response.status != "0000" {
296            return Err(crate::OmsError::Exchange(
297                format!("Order cancellation failed: {}", cancel_response.status).into(),
298            ));
299        }
300
301        Ok(())
302    }
303
304    async fn get_order_status(
305        &self,
306        order_id: std::string::String,
307    ) -> crate::Result<std::string::String> {
308        // Extract symbol from order_id (assuming it's part of the order_id)
309        let parts: SmallVec<[&str; 3]> = order_id.split(':').collect();
310        let (true_order_id, order_currency, payment_currency) = if parts.len() >= 3 {
311            (parts[0].into(), parts[1].into(), parts[2].into())
312        } else {
313            return Err(crate::OmsError::Exchange("Invalid order_id format".into()));
314        };
315
316        // Create request parameters
317        let mut params = FxHashMap::default();
318        params.insert("order_currency".into(), order_currency);
319        params.insert("payment_currency".into(), payment_currency);
320        params.insert("order_id".into(), true_order_id);
321
322        // Endpoint for getting order details
323        let endpoint = "/info/order_detail";
324
325        // Get authentication headers
326        let headers = match self.create_auth_params(endpoint, &mut params) {
327            Ok(h) => h,
328            Err(e) => {
329                return Err(crate::OmsError::Exchange(
330                    format!("Authentication error: {e}").into(),
331                ));
332            }
333        };
334
335        // Send the request
336        let url = format!("{}{}", self.api_url, endpoint);
337        let response = match self
338            .client
339            .post(&url)
340            .headers(headers)
341            .form(&params)
342            .send()
343            .await
344        {
345            Ok(r) => r,
346            Err(e) => {
347                return Err(crate::OmsError::Exchange(
348                    format!("Request error: {e}").into(),
349                ));
350            }
351        };
352
353        // Parse response to get order status
354        let response_body = match response.text().await {
355            Ok(b) => b,
356            Err(e) => {
357                return Err(crate::OmsError::Exchange(
358                    format!("Failed to get response body: {e}").into(),
359                ));
360            }
361        };
362
363        #[derive(Debug, Deserialize)]
364        struct OrderDetailResponse {
365            status: std::string::String,
366            data: OrderDetail,
367        }
368
369        #[derive(Debug, Deserialize)]
370        struct OrderDetail {
371            order_status: std::string::String,
372        }
373
374        let mut response_bytes = response_body.into_bytes();
375        let detail_response: OrderDetailResponse = match simd_json::from_slice(&mut response_bytes)
376        {
377            Ok(r) => r,
378            Err(e) => {
379                return Err(crate::OmsError::Exchange(
380                    format!("Failed to parse response: {e}").into(),
381                ));
382            }
383        };
384
385        // Check status
386        if detail_response.status != "0000" {
387            return Err(crate::OmsError::Exchange(
388                format!("Failed to get order status: {}", detail_response.status).into(),
389            ));
390        }
391
392        Ok(detail_response.data.order_status)
393    }
394}
395
396/// Comprehensive test suite for Bithumb HMAC signature generation
397///
398/// Tests the specific Bithumb signature algorithm:
399/// 1. endpoint + nonce + `sorted_params`
400/// 2. HMAC-SHA512 signature
401/// 3. Hex encoding
402///
403/// ```sh
404/// cargo test test_bithumb_hmac
405/// ```
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    /// Test Bithumb signature generation with known test vectors
411    #[test]
412    fn test_bithumb_hmac_signature_generation() {
413        // Create test instance with known credentials
414        let api_key = "test_api_key".to_string();
415        let secret_key = "test_secret_key".to_string();
416        let exchange = BithumbExchange::with_credentials(api_key.clone(), secret_key);
417
418        // Test parameters (simulating a trade order)
419        let mut params = FxHashMap::default();
420        params.insert("order_currency".to_string(), "BTC".to_string());
421        params.insert("payment_currency".to_string(), "KRW".to_string());
422        params.insert("units".to_string(), "0.001".to_string());
423        params.insert("price".to_string(), "84000000".to_string());
424        params.insert("type".to_string(), "bid".to_string());
425
426        let endpoint = "/trade/place";
427
428        // Test that signature generation works
429        let result = exchange.create_auth_params(endpoint, &mut params);
430        assert!(result.is_ok(), "HMAC signature generation should succeed");
431
432        let headers = result.unwrap();
433
434        // Verify required headers are present
435        assert!(
436            headers.contains_key("Api-Key"),
437            "Api-Key header should be present"
438        );
439        assert!(
440            headers.contains_key("Api-Sign"),
441            "Api-Sign header should be present"
442        );
443        assert!(
444            headers.contains_key("Api-Nonce"),
445            "Api-Nonce header should be present"
446        );
447
448        // Verify header values
449        let api_key_header = headers.get("Api-Key").unwrap().to_str().unwrap();
450        assert_eq!(api_key_header, api_key, "Api-Key should match provided key");
451
452        let signature = headers.get("Api-Sign").unwrap().to_str().unwrap();
453        assert_eq!(
454            signature.len(),
455            128,
456            "HMAC-SHA512 hex signature should be 128 characters"
457        );
458        assert!(
459            signature.chars().all(|c| c.is_ascii_hexdigit()),
460            "Signature should be valid hex"
461        );
462
463        let nonce = headers.get("Api-Nonce").unwrap().to_str().unwrap();
464        assert!(!nonce.is_empty(), "Nonce should not be empty");
465        assert!(
466            nonce.parse::<u64>().is_ok(),
467            "Nonce should be a valid timestamp"
468        );
469    }
470
471    /// Test Bithumb signature consistency (same input = same output with fixed nonce)
472    #[test]
473    fn test_bithumb_signature_consistency() {
474        use hmac::{Hmac, Mac};
475        use sha2::Sha512;
476
477        let secret_key = "consistency_test_secret";
478        let endpoint = "/trade/place";
479        let nonce = "1234567890123"; // Fixed nonce for reproducible test
480
481        // Test parameters
482        let mut sorted_params = vec![
483            ("order_currency", "BTC"),
484            ("payment_currency", "KRW"),
485            ("price", "84000000"),
486            ("type", "bid"),
487            ("units", "0.001"),
488        ];
489        sorted_params.sort_by_key(|&(k, _)| k);
490
491        // Build query string exactly like Bithumb implementation
492        let mut query_string = format!("{endpoint}{nonce}");
493        for (key, value) in sorted_params {
494            query_string.push_str(&format!("{key}{value}"));
495        }
496
497        // Expected query string: "/trade/place1234567890123order_currencyBTCpayment_currencyKRWprice84000000typebidunits0.001"
498        let expected_query = "/trade/place1234567890123order_currencyBTCpayment_currencyKRWprice84000000typebidunits0.001";
499        assert_eq!(
500            query_string, expected_query,
501            "Query string should match expected format"
502        );
503
504        // Generate HMAC-SHA512 signature
505        type HmacSha512 = Hmac<Sha512>;
506        let mut mac = HmacSha512::new_from_slice(secret_key.as_bytes())
507            .expect("HMAC initialization should succeed");
508        mac.update(query_string.as_bytes());
509        let signature = hex::encode(mac.finalize().into_bytes());
510
511        // Verify signature format
512        assert_eq!(
513            signature.len(),
514            128,
515            "SHA512 hex signature should be 128 characters"
516        );
517        assert!(
518            signature.chars().all(|c| c.is_ascii_hexdigit()),
519            "Signature should be valid hex"
520        );
521
522        // Test consistency - same input should produce same signature
523        let mut mac2 = HmacSha512::new_from_slice(secret_key.as_bytes()).unwrap();
524        mac2.update(query_string.as_bytes());
525        let signature2 = hex::encode(mac2.finalize().into_bytes());
526        assert_eq!(
527            signature, signature2,
528            "HMAC signatures should be consistent"
529        );
530    }
531
532    /// Test Bithumb signature with empty parameters
533    #[test]
534    fn test_bithumb_signature_empty_params() {
535        let api_key = "test_key".to_string();
536        let secret_key = "test_secret".to_string();
537        let exchange = BithumbExchange::with_credentials(api_key, secret_key);
538
539        let mut empty_params = FxHashMap::default();
540        let endpoint = "/info/balance";
541
542        let result = exchange.create_auth_params(endpoint, &mut empty_params);
543        assert!(result.is_ok(), "Empty parameters should still work");
544
545        let headers = result.unwrap();
546        let signature = headers.get("Api-Sign").unwrap().to_str().unwrap();
547        assert_eq!(
548            signature.len(),
549            128,
550            "Signature should still be 128 characters"
551        );
552    }
553
554    /// Test Bithumb signature parameter sorting (critical for security)
555    #[test]
556    fn test_bithumb_parameter_sorting() {
557        let secret_key = "sort_test_secret";
558        let endpoint = "/test/endpoint";
559        let nonce = "9876543210987";
560
561        // Parameters in different orders should produce same signature
562        let params1 = vec![("zebra", "last"), ("alpha", "first"), ("beta", "second")];
563
564        let params2 = vec![("alpha", "first"), ("zebra", "last"), ("beta", "second")];
565
566        // Generate signatures for both parameter orders
567        let sig1 = generate_test_signature(secret_key, endpoint, nonce, &params1);
568        let sig2 = generate_test_signature(secret_key, endpoint, nonce, &params2);
569
570        assert_eq!(
571            sig1, sig2,
572            "Parameter order should not affect signature due to sorting"
573        );
574    }
575
576    /// Test Bithumb signature sensitivity to parameter changes
577    #[test]
578    fn test_bithumb_signature_sensitivity() {
579        let secret_key = "sensitivity_test_secret";
580        let endpoint = "/trade/place";
581        let nonce = "1111111111111";
582
583        let base_params = vec![("symbol", "BTC_KRW"), ("amount", "0.001")];
584        let changed_params = vec![("symbol", "BTC_KRW"), ("amount", "0.002")]; // Changed amount
585
586        let base_signature = generate_test_signature(secret_key, endpoint, nonce, &base_params);
587        let changed_signature =
588            generate_test_signature(secret_key, endpoint, nonce, &changed_params);
589
590        assert_ne!(
591            base_signature, changed_signature,
592            "Small parameter changes should produce different signatures"
593        );
594    }
595
596    /// Test with realistic Bithumb trading parameters
597    #[test]
598    fn test_bithumb_realistic_trading_params() {
599        let api_key = "realistic_test_key".to_string();
600        let secret_key = "realistic_test_secret_key_with_sufficient_length".to_string();
601        let exchange = BithumbExchange::with_credentials(api_key, secret_key);
602
603        // Realistic BTC/KRW trading parameters
604        let mut params = FxHashMap::default();
605        params.insert("order_currency".to_string(), "BTC".to_string());
606        params.insert("payment_currency".to_string(), "KRW".to_string());
607        params.insert("units".to_string(), "0.001".to_string());
608        params.insert("price".to_string(), "84500000".to_string()); // ~84.5M KRW per BTC
609        params.insert("type".to_string(), "bid".to_string());
610
611        let endpoint = "/trade/place";
612
613        let result = exchange.create_auth_params(endpoint, &mut params);
614        assert!(result.is_ok(), "Realistic trading parameters should work");
615
616        let headers = result.unwrap();
617        let signature = headers.get("Api-Sign").unwrap().to_str().unwrap();
618
619        // Validate signature properties
620        assert_eq!(
621            signature.len(),
622            128,
623            "Signature should be 128 hex characters"
624        );
625        assert!(
626            signature.chars().all(|c| c.is_ascii_hexdigit()),
627            "Signature should be valid hex"
628        );
629        assert!(
630            signature.chars().any(|c| c.is_ascii_lowercase()),
631            "Hex should be lowercase"
632        );
633    }
634
635    /// Helper function to generate test signatures
636    fn generate_test_signature(
637        secret_key: &str,
638        endpoint: &str,
639        nonce: &str,
640        params: &[(&str, &str)],
641    ) -> String {
642        use hmac::{Hmac, Mac};
643        use sha2::Sha512;
644
645        // Sort parameters like Bithumb implementation
646        let mut sorted_params = params.to_vec();
647        sorted_params.sort_by_key(|&(k, _)| k);
648
649        // Build query string
650        let mut query_string = format!("{endpoint}{nonce}");
651        for (key, value) in sorted_params {
652            query_string.push_str(&format!("{key}{value}"));
653        }
654
655        // Generate HMAC-SHA512 signature
656        type HmacSha512 = Hmac<Sha512>;
657        let mut mac = HmacSha512::new_from_slice(secret_key.as_bytes())
658            .expect("HMAC initialization should succeed");
659        mac.update(query_string.as_bytes());
660        hex::encode(mac.finalize().into_bytes())
661    }
662
663    /// Performance test for Bithumb signature generation
664    #[test]
665    fn test_bithumb_signature_performance() {
666        let api_key = "perf_test_key".to_string();
667        let secret_key = "perf_test_secret_key_for_performance_testing".to_string();
668        let exchange = BithumbExchange::with_credentials(api_key, secret_key);
669
670        let mut params = FxHashMap::default();
671        params.insert("order_currency".to_string(), "BTC".to_string());
672        params.insert("payment_currency".to_string(), "KRW".to_string());
673        params.insert("units".to_string(), "0.001".to_string());
674        params.insert("price".to_string(), "84000000".to_string());
675        params.insert("type".to_string(), "bid".to_string());
676
677        let endpoint = "/trade/place";
678
679        let start = std::time::Instant::now();
680        for _ in 0..1000 {
681            let mut test_params = params.clone();
682            let result = exchange.create_auth_params(endpoint, &mut test_params);
683            assert!(result.is_ok(), "Signature generation should succeed");
684        }
685        let duration = start.elapsed();
686
687        // Should complete 1000 signature generations in reasonable time (< 200ms)
688        assert!(
689            duration.as_millis() < 200,
690            "Bithumb signature performance regression detected: {duration:?}"
691        );
692    }
693}