1use std::{env, time::SystemTime, time::UNIX_EPOCH};
7
8use anyhow::Result;
9use async_trait::async_trait;
10use hmac::{Hmac, Mac};
11use reqwest::{Client, header};
12use rusty_common::collections::FxHashMap;
14use rusty_model::{enums::OrderSide, trading_order::Order};
15use serde::{Deserialize, Serialize};
16use sha2::Sha512;
17use smallvec::SmallVec;
18use crate::execution_engine::Exchange;
21
22type HmacSha512 = Hmac<Sha512>;
23
24pub 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 #[must_use]
35 pub fn new() -> Self {
36 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 #[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 fn create_auth_params(
71 &self,
72 endpoint: &str,
73 params: &mut FxHashMap<String, String>,
74 ) -> Result<header::HeaderMap> {
75 let nonce = SystemTime::now()
77 .duration_since(UNIX_EPOCH)
78 .unwrap()
79 .as_millis()
80 .to_string();
81
82 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 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 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#[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#[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 async fn send_order(&self, order: Order) -> crate::Result<()> {
137 let symbol = &order.symbol;
139
140 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 let order_type = match order.side {
152 OrderSide::Buy => "bid",
153 OrderSide::Sell => "ask",
154 };
155
156 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 let endpoint = "/trade/place";
169
170 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 let url = format!("{}{}", self.api_url, endpoint);
182 let response = match self
183 .client
184 .post(&url)
185 .headers(headers)
186 .form(¶ms)
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 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 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 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 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 let endpoint = "/trade/cancel";
245
246 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 let url = format!("{}{}", self.api_url, endpoint);
258 let response = match self
259 .client
260 .post(&url)
261 .headers(headers)
262 .form(¶ms)
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 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 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 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 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 let endpoint = "/info/order_detail";
324
325 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 let url = format!("{}{}", self.api_url, endpoint);
337 let response = match self
338 .client
339 .post(&url)
340 .headers(headers)
341 .form(¶ms)
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 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 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#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
412 fn test_bithumb_hmac_signature_generation() {
413 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 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 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 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 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]
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"; 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 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 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 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 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 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]
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]
556 fn test_bithumb_parameter_sorting() {
557 let secret_key = "sort_test_secret";
558 let endpoint = "/test/endpoint";
559 let nonce = "9876543210987";
560
561 let params1 = vec![("zebra", "last"), ("alpha", "first"), ("beta", "second")];
563
564 let params2 = vec![("alpha", "first"), ("zebra", "last"), ("beta", "second")];
565
566 let sig1 = generate_test_signature(secret_key, endpoint, nonce, ¶ms1);
568 let sig2 = generate_test_signature(secret_key, endpoint, nonce, ¶ms2);
569
570 assert_eq!(
571 sig1, sig2,
572 "Parameter order should not affect signature due to sorting"
573 );
574 }
575
576 #[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")]; 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]
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 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()); 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 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 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 let mut sorted_params = params.to_vec();
647 sorted_params.sort_by_key(|&(k, _)| k);
648
649 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 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 #[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 assert!(
689 duration.as_millis() < 200,
690 "Bithumb signature performance regression detected: {duration:?}"
691 );
692 }
693}