1use crate::error::{EMSError, Result as EMSResult};
13use reqwest::Client;
14use rust_decimal::Decimal;
15use rusty_common::SmartString;
16use rusty_common::auth::exchanges::bithumb::BithumbAuth;
17use serde::{Deserialize, Serialize};
18use simd_json;
19use smallvec::SmallVec;
20use std::sync::Arc;
21use std::time::Duration;
22
23#[derive(Debug, Clone)]
25pub struct BithumbRestClient {
26 auth: Arc<BithumbAuth>,
28 client: Arc<Client>,
30 base_url: SmartString,
32}
33
34#[derive(Debug, Clone, Deserialize)]
36pub struct BithumbAccount {
37 pub currency: SmartString,
39 pub balance: SmartString,
41 pub locked: SmartString,
43 pub avg_buy_price: SmartString,
45 pub avg_buy_price_modified: bool,
47 pub unit_currency: SmartString,
49}
50
51#[derive(Debug, Clone, Deserialize)]
53pub struct BithumbOrderInfo {
54 pub uuid: SmartString,
56 pub side: SmartString,
58 pub ord_type: SmartString,
60 pub price: SmartString,
62 pub state: SmartString,
64 pub market: SmartString,
66 pub created_at: SmartString,
68 pub volume: SmartString,
70 pub remaining_volume: SmartString,
72 pub reserved_fee: SmartString,
74 pub remaining_fee: SmartString,
76 pub paid_fee: SmartString,
78 pub locked: SmartString,
80 pub executed_volume: SmartString,
82 pub trades_count: u64,
84 #[serde(default)]
86 pub trades: Vec<BithumbTrade>,
87}
88
89#[derive(Debug, Clone, Deserialize)]
91pub struct BithumbTrade {
92 pub market: SmartString,
94 pub uuid: SmartString,
96 pub price: SmartString,
98 pub volume: SmartString,
100 pub funds: SmartString,
102 pub side: SmartString,
104 pub created_at: SmartString,
106}
107
108#[derive(Debug, Clone, Serialize)]
110pub struct BithumbOrderRequest {
111 pub market: SmartString,
113 pub side: SmartString,
115 pub order_type: SmartString,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub price: Option<SmartString>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub volume: Option<SmartString>,
123}
124
125#[derive(Debug, Clone, Deserialize)]
127pub struct BithumbOrderResponse {
128 pub uuid: SmartString,
130 pub market: SmartString,
132 pub side: SmartString,
134 pub ord_type: SmartString,
136 #[serde(default)]
138 pub price: SmartString,
139 #[serde(default)]
141 pub volume: SmartString,
142 pub created_at: SmartString,
144}
145
146#[derive(Debug, Clone, Deserialize)]
148pub struct BithumbDepositAddress {
149 pub currency: SmartString,
151 pub deposit_address: SmartString,
153 pub secondary_address: Option<SmartString>,
155 pub net_type: Option<SmartString>,
157}
158
159#[derive(Debug, Clone, Deserialize)]
161pub struct BithumbDeposit {
162 #[serde(rename = "type")]
164 pub transaction_type: SmartString,
165 pub uuid: SmartString,
167 pub currency: SmartString,
169 pub txid: Option<SmartString>,
171 pub state: SmartString,
173 pub created_at: SmartString,
175 pub amount: SmartString,
177 pub fee: SmartString,
179}
180
181#[derive(Debug, Clone, Deserialize)]
183pub struct BithumbWithdrawal {
184 #[serde(rename = "type")]
186 pub transaction_type: SmartString,
187 pub uuid: SmartString,
189 pub currency: SmartString,
191 pub txid: Option<SmartString>,
193 pub state: SmartString,
195 pub created_at: SmartString,
197 pub amount: SmartString,
199 pub fee: SmartString,
201 pub address: Option<SmartString>,
203 pub secondary_address: Option<SmartString>,
205}
206
207#[derive(Debug, Deserialize)]
209pub struct BithumbErrorResponse {
210 pub error: BithumbError,
212}
213
214#[derive(Debug, Deserialize)]
216pub struct BithumbError {
217 pub name: SmartString,
219 pub message: SmartString,
221}
222
223impl BithumbRestClient {
224 #[must_use]
226 pub fn new(auth: Arc<BithumbAuth>) -> Self {
227 let client = Client::builder()
228 .timeout(Duration::from_secs(30))
229 .pool_max_idle_per_host(10)
230 .pool_idle_timeout(Some(Duration::from_secs(30)))
231 .build()
232 .expect("Failed to build HTTP client");
233
234 Self {
235 auth,
236 client: Arc::new(client),
237 base_url: "https://api.bithumb.com/v1".into(),
238 }
239 }
240
241 fn handle_api_error(status: u16, error_response: BithumbErrorResponse) -> EMSError {
243 let error_name = &error_response.error.name;
244 let error_message = &error_response.error.message;
245
246 match error_name.as_str() {
247 "invalid_access_key" | "invalid_secret_key" | "jwt_verification" => {
248 EMSError::auth(format!("Authentication failed: {error_message}"))
249 }
250 "too_many_requests" => {
251 EMSError::rate_limit(
252 format!("Rate limit exceeded: {error_message}"),
253 Some(60000), )
255 }
256 "insufficient_funds" | "insufficient_funds_bid" | "insufficient_funds_ask" => {
257 EMSError::insufficient_balance(format!("Insufficient balance: {error_message}"))
258 }
259 "invalid_parameter" | "invalid_query_format" | "invalid_order_id" => {
260 EMSError::invalid_params(format!("Invalid parameters: {error_message}"))
261 }
262 "market_does_not_exist" | "order_not_found" => {
263 EMSError::instrument_not_found(format!("Not found: {error_message}"))
264 }
265 "order_already_canceled" | "order_cancel_error" => {
266 EMSError::order_cancellation(format!("Order cancellation error: {error_message}"))
267 }
268 _ => EMSError::exchange_api(
269 "Bithumb",
270 i32::from(status),
271 "API Error",
272 Some(format!("{error_name}: {error_message}")),
273 ),
274 }
275 }
276
277 async fn get_authenticated<T>(
279 &self,
280 endpoint: &str,
281 query_params: Option<&[(&str, &str)]>,
282 ) -> EMSResult<T>
283 where
284 T: for<'de> Deserialize<'de>,
285 {
286 let headers = self
287 .auth
288 .generate_headers("GET", endpoint, query_params)
289 .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
290
291 let mut url = format!("{}{}", self.base_url, endpoint);
292 if let Some(params) = query_params
293 && !params.is_empty()
294 {
295 url.push('?');
296 for (i, (key, value)) in params.iter().enumerate() {
297 if i > 0 {
298 url.push('&');
299 }
300 url.push_str(key);
301 url.push('=');
302 url.push_str(value);
303 }
304 }
305
306 let mut request = self.client.get(&url);
307 for (key, value) in &headers {
308 request = request.header(key.as_str(), value.as_str());
309 }
310
311 let response = request
312 .send()
313 .await
314 .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
315
316 let status = response.status();
317 let response_text = response
318 .text()
319 .await
320 .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
321
322 if status.is_success() {
323 let mut response_bytes = response_text.into_bytes();
324 simd_json::from_slice(&mut response_bytes).map_err(|e| {
325 EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
326 })
327 } else {
328 let mut error_bytes = response_text.into_bytes();
329 let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
330 .map_err(|e| {
331 EMSError::exchange_api(
332 "Bithumb",
333 i32::from(status.as_u16()),
334 "Failed to parse error response",
335 Some(e.to_string()),
336 )
337 })?;
338
339 Err(Self::handle_api_error(status.as_u16(), error_response))
340 }
341 }
342
343 async fn post_authenticated<T, R>(&self, endpoint: &str, body: &T) -> EMSResult<R>
345 where
346 T: Serialize,
347 R: for<'de> Deserialize<'de>,
348 {
349 let body_string = simd_json::to_string(body).map_err(|e| {
350 EMSError::exchange_api(
351 "Bithumb",
352 -1,
353 "JSON serialization failed",
354 Some(e.to_string()),
355 )
356 })?;
357
358 let params: &[(&str, &str)] = &[];
361
362 let headers = self
363 .auth
364 .generate_headers("POST", endpoint, Some(params))
365 .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
366
367 let url = format!("{}{}", self.base_url, endpoint);
368 let mut request = self.client.post(&url);
369
370 for (key, value) in &headers {
371 request = request.header(key.as_str(), value.as_str());
372 }
373
374 let response = request
375 .body(body_string)
376 .send()
377 .await
378 .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
379
380 let status = response.status();
381 let response_text = response
382 .text()
383 .await
384 .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
385
386 if status.is_success() {
387 let mut response_bytes = response_text.into_bytes();
388 simd_json::from_slice(&mut response_bytes).map_err(|e| {
389 EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
390 })
391 } else {
392 let mut error_bytes = response_text.into_bytes();
393 let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
394 .map_err(|e| {
395 EMSError::exchange_api(
396 "Bithumb",
397 i32::from(status.as_u16()),
398 "Failed to parse error response",
399 Some(e.to_string()),
400 )
401 })?;
402
403 Err(Self::handle_api_error(status.as_u16(), error_response))
404 }
405 }
406
407 async fn delete_authenticated<T>(
409 &self,
410 endpoint: &str,
411 query_params: Option<&[(&str, &str)]>,
412 ) -> EMSResult<T>
413 where
414 T: for<'de> Deserialize<'de>,
415 {
416 let headers = self
417 .auth
418 .generate_headers("DELETE", endpoint, query_params)
419 .map_err(|e| EMSError::auth(format!("Header generation failed: {e}")))?;
420
421 let mut url = format!("{}{}", self.base_url, endpoint);
422 if let Some(params) = query_params
423 && !params.is_empty()
424 {
425 url.push('?');
426 for (i, (key, value)) in params.iter().enumerate() {
427 if i > 0 {
428 url.push('&');
429 }
430 url.push_str(key);
431 url.push('=');
432 url.push_str(value);
433 }
434 }
435
436 let mut request = self.client.delete(&url);
437 for (key, value) in &headers {
438 request = request.header(key.as_str(), value.as_str());
439 }
440
441 let response = request
442 .send()
443 .await
444 .map_err(|e| EMSError::connection(format!("Request failed: {e}")))?;
445
446 let status = response.status();
447 let response_text = response
448 .text()
449 .await
450 .map_err(|e| EMSError::connection(format!("Failed to read response: {e}")))?;
451
452 if status.is_success() {
453 let mut response_bytes = response_text.into_bytes();
454 simd_json::from_slice(&mut response_bytes).map_err(|e| {
455 EMSError::exchange_api("Bithumb", -1, "JSON parsing failed", Some(e.to_string()))
456 })
457 } else {
458 let mut error_bytes = response_text.into_bytes();
459 let error_response: BithumbErrorResponse = simd_json::from_slice(&mut error_bytes)
460 .map_err(|e| {
461 EMSError::exchange_api(
462 "Bithumb",
463 i32::from(status.as_u16()),
464 "Failed to parse error response",
465 Some(e.to_string()),
466 )
467 })?;
468
469 Err(Self::handle_api_error(status.as_u16(), error_response))
470 }
471 }
472
473 pub async fn get_accounts(&self) -> EMSResult<SmallVec<[BithumbAccount; 16]>> {
475 let accounts: Vec<BithumbAccount> = self.get_authenticated("/accounts", None).await?;
476 Ok(accounts.into_iter().collect())
477 }
478
479 pub async fn get_account_balance(&self, currency: &str) -> EMSResult<Option<BithumbAccount>> {
481 let accounts = self.get_accounts().await?;
482 Ok(accounts
483 .into_iter()
484 .find(|account| account.currency == currency))
485 }
486
487 pub async fn place_order(
489 &self,
490 order_request: BithumbOrderRequest,
491 ) -> EMSResult<BithumbOrderResponse> {
492 self.post_authenticated("/orders", &order_request).await
493 }
494
495 pub async fn get_order(&self, uuid: &str) -> EMSResult<BithumbOrderInfo> {
497 let params = [("uuid", uuid)];
498 self.get_authenticated("/order", Some(¶ms)).await
499 }
500
501 #[allow(clippy::too_many_arguments)]
503 pub async fn get_orders(
504 &self,
505 market: Option<&str>,
506 state: Option<&str>,
507 states: Option<&[&str]>,
508 uuids: Option<&[&str]>,
509 page: Option<u32>,
510 limit: Option<u32>,
511 order_by: Option<&str>,
512 ) -> EMSResult<SmallVec<[BithumbOrderInfo; 32]>> {
513 let mut params = Vec::new();
514
515 if let Some(m) = market {
516 params.push(("market", m));
517 }
518
519 if let Some(s) = state {
520 params.push(("state", s));
521 }
522
523 if let Some(states_list) = states {
524 for state in states_list {
525 params.push(("states[]", state));
526 }
527 }
528
529 if let Some(uuids_list) = uuids {
530 for uuid in uuids_list {
531 params.push(("uuids[]", uuid));
532 }
533 }
534
535 let page_str = page.map(|p| p.to_string());
537 let limit_str = limit.map(|l| l.to_string());
538
539 if let Some(ref p) = page_str {
540 params.push(("page", p.as_str()));
541 }
542
543 if let Some(ref l) = limit_str {
544 params.push(("limit", l.as_str()));
545 }
546
547 if let Some(ob) = order_by {
548 params.push(("order_by", ob));
549 }
550
551 let orders: Vec<BithumbOrderInfo> =
552 self.get_authenticated("/orders", Some(¶ms)).await?;
553 Ok(orders.into_iter().collect())
554 }
555
556 pub async fn cancel_order(&self, uuid: &str) -> EMSResult<BithumbOrderInfo> {
558 let params = [("uuid", uuid)];
559 self.delete_authenticated("/order", Some(¶ms)).await
560 }
561
562 pub async fn get_order_trades(&self, uuid: &str) -> EMSResult<SmallVec<[BithumbTrade; 32]>> {
564 let params = [("uuid", uuid)];
565 let trades: Vec<BithumbTrade> = self
566 .get_authenticated("/order/trades", Some(¶ms))
567 .await?;
568 Ok(trades.into_iter().collect())
569 }
570
571 pub async fn get_trades(
573 &self,
574 market: Option<&str>,
575 uuids: Option<&[&str]>,
576 limit: Option<u32>,
577 page: Option<u32>,
578 order_by: Option<&str>,
579 ) -> EMSResult<SmallVec<[BithumbTrade; 32]>> {
580 let mut params = Vec::new();
581
582 if let Some(m) = market {
583 params.push(("market", m));
584 }
585
586 if let Some(uuids_list) = uuids {
587 for uuid in uuids_list {
588 params.push(("uuids[]", uuid));
589 }
590 }
591
592 let limit_str = limit.map(|l| l.to_string());
594 let page_str = page.map(|p| p.to_string());
595
596 if let Some(ref l) = limit_str {
597 params.push(("limit", l.as_str()));
598 }
599
600 if let Some(ref p) = page_str {
601 params.push(("page", p.as_str()));
602 }
603
604 if let Some(ob) = order_by {
605 params.push(("order_by", ob));
606 }
607
608 let trades: Vec<BithumbTrade> = self.get_authenticated("/trades", Some(¶ms)).await?;
609 Ok(trades.into_iter().collect())
610 }
611
612 pub async fn get_deposit_addresses(&self) -> EMSResult<SmallVec<[BithumbDepositAddress; 16]>> {
614 let addresses: Vec<BithumbDepositAddress> = self
615 .get_authenticated("/deposits/coin_addresses", None)
616 .await?;
617 Ok(addresses.into_iter().collect())
618 }
619
620 pub async fn get_deposit_address(&self, currency: &str) -> EMSResult<BithumbDepositAddress> {
622 let params = [("currency", currency)];
623 self.get_authenticated("/deposits/coin_address", Some(¶ms))
624 .await
625 }
626
627 #[allow(clippy::too_many_arguments)]
629 pub async fn get_deposits(
630 &self,
631 currency: Option<&str>,
632 state: Option<&str>,
633 uuids: Option<&[&str]>,
634 txids: Option<&[&str]>,
635 limit: Option<u32>,
636 page: Option<u32>,
637 order_by: Option<&str>,
638 ) -> EMSResult<SmallVec<[BithumbDeposit; 32]>> {
639 let mut params = Vec::new();
640
641 if let Some(c) = currency {
642 params.push(("currency", c));
643 }
644
645 if let Some(s) = state {
646 params.push(("state", s));
647 }
648
649 if let Some(uuids_list) = uuids {
650 for uuid in uuids_list {
651 params.push(("uuids[]", uuid));
652 }
653 }
654
655 if let Some(txids_list) = txids {
656 for txid in txids_list {
657 params.push(("txids[]", txid));
658 }
659 }
660
661 let limit_str = limit.map(|l| l.to_string());
663 let page_str = page.map(|p| p.to_string());
664
665 if let Some(ref l) = limit_str {
666 params.push(("limit", l.as_str()));
667 }
668
669 if let Some(ref p) = page_str {
670 params.push(("page", p.as_str()));
671 }
672
673 if let Some(ob) = order_by {
674 params.push(("order_by", ob));
675 }
676
677 let deposits: Vec<BithumbDeposit> =
678 self.get_authenticated("/deposits", Some(¶ms)).await?;
679 Ok(deposits.into_iter().collect())
680 }
681
682 #[allow(clippy::too_many_arguments)]
684 pub async fn get_withdrawals(
685 &self,
686 currency: Option<&str>,
687 state: Option<&str>,
688 uuids: Option<&[&str]>,
689 txids: Option<&[&str]>,
690 limit: Option<u32>,
691 page: Option<u32>,
692 order_by: Option<&str>,
693 ) -> EMSResult<SmallVec<[BithumbWithdrawal; 32]>> {
694 let mut params = Vec::new();
695
696 if let Some(c) = currency {
697 params.push(("currency", c));
698 }
699
700 if let Some(s) = state {
701 params.push(("state", s));
702 }
703
704 if let Some(uuids_list) = uuids {
705 for uuid in uuids_list {
706 params.push(("uuids[]", uuid));
707 }
708 }
709
710 if let Some(txids_list) = txids {
711 for txid in txids_list {
712 params.push(("txids[]", txid));
713 }
714 }
715
716 let limit_str = limit.map(|l| l.to_string());
718 let page_str = page.map(|p| p.to_string());
719
720 if let Some(ref l) = limit_str {
721 params.push(("limit", l.as_str()));
722 }
723
724 if let Some(ref p) = page_str {
725 params.push(("page", p.as_str()));
726 }
727
728 if let Some(ob) = order_by {
729 params.push(("order_by", ob));
730 }
731
732 let withdrawals: Vec<BithumbWithdrawal> =
733 self.get_authenticated("/withdraws", Some(¶ms)).await?;
734 Ok(withdrawals.into_iter().collect())
735 }
736
737 pub async fn get_total_balance_krw(&self) -> EMSResult<Decimal> {
739 let accounts = self.get_accounts().await?;
740 let mut total = Decimal::ZERO;
741
742 for account in accounts {
743 if account.currency == "KRW" {
744 let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
745 EMSError::exchange_api(
746 "Bithumb",
747 -1,
748 "Failed to parse balance",
749 Some(e.to_string()),
750 )
751 })?;
752 total += balance;
753 } else {
754 let balance = Decimal::from_str_exact(&account.balance).map_err(|e| {
757 EMSError::exchange_api(
758 "Bithumb",
759 -1,
760 "Failed to parse balance",
761 Some(e.to_string()),
762 )
763 })?;
764 let avg_price = Decimal::from_str_exact(&account.avg_buy_price).map_err(|e| {
765 EMSError::exchange_api(
766 "Bithumb",
767 -1,
768 "Failed to parse avg price",
769 Some(e.to_string()),
770 )
771 })?;
772
773 total += balance * avg_price;
775 }
776 }
777
778 Ok(total)
779 }
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use rusty_common::auth::exchanges::bithumb::BithumbAuth;
786
787 fn create_test_client() -> BithumbRestClient {
788 let auth = Arc::new(BithumbAuth::new("test_key".into(), "test_secret".into()));
789 BithumbRestClient::new(auth)
790 }
791
792 #[test]
793 fn test_client_creation() {
794 let client = create_test_client();
795 assert_eq!(client.base_url, "https://api.bithumb.com/v1");
796 }
797
798 #[test]
799 fn test_error_handling() {
800 let error_response = BithumbErrorResponse {
801 error: BithumbError {
802 name: "too_many_requests".into(),
803 message: "Rate limit exceeded".into(),
804 },
805 };
806
807 let ems_error = BithumbRestClient::handle_api_error(429, error_response);
808 assert!(matches!(ems_error, EMSError::RateLimitExceeded { .. }));
809 }
810
811 #[test]
812 fn test_authentication_error_mapping() {
813 let error_response = BithumbErrorResponse {
814 error: BithumbError {
815 name: "invalid_access_key".into(),
816 message: "Invalid API key".into(),
817 },
818 };
819
820 let ems_error = BithumbRestClient::handle_api_error(401, error_response);
821 assert!(matches!(ems_error, EMSError::AuthenticationError(_)));
822 }
823
824 #[test]
825 fn test_insufficient_balance_error_mapping() {
826 let error_response = BithumbErrorResponse {
827 error: BithumbError {
828 name: "insufficient_funds".into(),
829 message: "Not enough balance".into(),
830 },
831 };
832
833 let ems_error = BithumbRestClient::handle_api_error(400, error_response);
834 assert!(matches!(ems_error, EMSError::InsufficientBalance(_)));
835 }
836
837 #[test]
838 fn test_order_request_serialization() {
839 let order_request = BithumbOrderRequest {
840 market: "KRW-BTC".into(),
841 side: "bid".into(),
842 order_type: "limit".into(),
843 price: Some("50000000".into()),
844 volume: Some("0.001".into()),
845 };
846
847 let json = simd_json::to_string(&order_request).unwrap();
848 assert!(json.contains("KRW-BTC"));
849 assert!(json.contains("bid"));
850 assert!(json.contains("limit"));
851 }
852
853 #[test]
854 fn test_order_response_deserialization() {
855 let json = r#"{
856 "uuid": "123e4567-e89b-12d3-a456-426614174000",
857 "market": "KRW-BTC",
858 "side": "bid",
859 "ord_type": "limit",
860 "price": "50000000",
861 "volume": "0.001",
862 "created_at": "2023-01-01T00:00:00Z"
863 }"#;
864
865 let mut bytes = json.as_bytes().to_vec();
866 let response: BithumbOrderResponse = simd_json::from_slice(&mut bytes).unwrap();
867
868 assert_eq!(response.market, "KRW-BTC");
869 assert_eq!(response.side, "bid");
870 assert_eq!(response.ord_type, "limit");
871 }
872}