Skip to main content

indodax_cli/
client.rs

1use crate::auth::Signer;
2use crate::errors::{ErrorCategory, IndodaxError};
3use reqwest::{Client, RequestBuilder, Response, StatusCode};
4use serde::de::DeserializeOwned;
5use std::collections::{HashMap, BTreeMap};
6use tokio::sync::Mutex;
7
8use web_time::{Duration, Instant};
9
10#[cfg(target_arch = "wasm32")]
11async fn sleep(duration: Duration) {
12    let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| {
13        web_sys::window()
14            .unwrap()
15            .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, duration.as_millis() as i32)
16            .unwrap();
17    };
18    let p = js_sys::Promise::new(&mut cb);
19    wasm_bindgen_futures::JsFuture::from(p).await.unwrap();
20}
21
22#[cfg(not(target_arch = "wasm32"))]
23use tokio::time::sleep;
24
25const PRIVATE_V1_URL: &str = "https://indodax.com/tapi";
26const PRIVATE_V2_BASE: &str = "https://tapi.btcapi.net";
27const WS_TOKEN_URL: &str = "https://indodax.com/api/private_ws/v1/generate_token";
28const MAX_RETRIES: u32 = 3;
29
30#[cfg(not(target_arch = "wasm32"))]
31fn public_base_url() -> String {
32    "https://indodax.com".to_owned()
33}
34
35#[cfg(not(target_arch = "wasm32"))]
36fn api_base_url() -> String {
37    "https://api.indodax.com".to_owned()
38}
39
40#[cfg(target_arch = "wasm32")]
41fn public_base_url() -> String {
42    let base = option_env!("INDODAX_PUBLIC_BASE_URL").unwrap_or("/api/indodax");
43    if base.starts_with("http://") || base.starts_with("https://") {
44        return base.to_owned();
45    }
46
47    let origin = web_sys::window()
48        .and_then(|window| window.location().origin().ok())
49        .unwrap_or_default();
50
51    format!("{origin}{base}")
52}
53
54#[cfg(target_arch = "wasm32")]
55fn api_base_url() -> String {
56    // For WASM, we usually proxy through the same origin or a specific path
57    public_base_url()
58}
59
60#[derive(Debug)]
61struct RateLimiterState {
62    tokens: u64,
63    last_refill: Instant,
64}
65
66/// Token-bucket rate limiter for proactive 429 avoidance.
67#[derive(Debug)]
68struct RateLimiter {
69    capacity: u64,
70    refill_per_sec: u64,
71    state: Mutex<RateLimiterState>,
72}
73
74impl RateLimiter {
75    fn new(capacity: u64, refill_per_sec: u64) -> Self {
76        Self {
77            capacity,
78            refill_per_sec,
79            state: Mutex::new(RateLimiterState {
80                tokens: capacity,
81                last_refill: Instant::now(),
82            }),
83        }
84    }
85
86    fn from_env() -> Self {
87        #[cfg(not(target_arch = "wasm32"))]
88        let rps = std::env::var("INDODAX_RATE_LIMIT")
89            .ok()
90            .and_then(|v| v.parse::<u64>().ok())
91            .unwrap_or(5)
92            .max(1);
93
94        #[cfg(target_arch = "wasm32")]
95        let rps = 5;
96
97        Self::new(rps, rps)
98    }
99
100    async fn acquire(&self) {
101        loop {
102            let mut state = self.state.lock().await;
103            let elapsed = state.last_refill.elapsed();
104            if elapsed >= Duration::from_secs(1) {
105                let secs = elapsed.as_secs();
106                let capped = secs.min(60); // cap to 60s to guard against clock jumps
107                let add = self.refill_per_sec * capped;
108                state.tokens = state.tokens.saturating_add(add).min(self.capacity);
109                state.last_refill += Duration::from_secs(capped);
110            }
111            if state.tokens > 0 {
112                state.tokens -= 1;
113                return;
114            }
115            let elapsed_ms = elapsed.as_millis().min(u128::from(u64::MAX)) as u64;
116            let wait = if elapsed_ms < 1000 {
117                Duration::from_millis(1000 - elapsed_ms)
118            } else {
119                Duration::from_millis(50)
120            };
121            drop(state);
122            sleep(wait.max(Duration::from_millis(10))).await;
123        }
124    }
125}
126
127#[derive(Debug)]
128pub struct IndodaxClient {
129    http: Client,
130    signer: Option<Signer>,
131    rate_limiter: RateLimiter,
132    ws_token: Option<String>,
133}
134
135#[derive(Debug, serde::Deserialize)]
136pub struct IndodaxV1Response<T> {
137    pub success: i32,
138    #[serde(rename = "return")]
139    pub return_data: Option<T>,
140    pub error: Option<String>,
141    pub error_code: Option<String>,
142}
143
144#[derive(Debug, serde::Deserialize)]
145pub struct IndodaxV2Response<T> {
146    pub data: Option<T>,
147    pub code: Option<i64>,
148    pub error: Option<String>,
149}
150
151#[derive(Debug, serde::Deserialize, serde::Serialize)]
152pub struct TvHistoryResponse {
153    #[serde(rename = "t")]
154    pub time: Vec<u64>,
155    #[serde(rename = "o")]
156    pub open: Vec<f64>,
157    #[serde(rename = "h")]
158    pub high: Vec<f64>,
159    #[serde(rename = "l")]
160    pub low: Vec<f64>,
161    #[serde(rename = "c")]
162    pub close: Vec<f64>,
163    #[serde(rename = "v")]
164    pub volume: Vec<f64>,
165    #[serde(rename = "s")]
166    pub status: String,
167    #[serde(rename = "nextTime", skip_serializing_if = "Option::is_none")]
168    pub next_time: Option<u64>,
169}
170
171impl IndodaxClient {
172    pub fn new(signer: Option<Signer>) -> Result<Self, IndodaxError> {
173        let builder = Client::builder();
174
175        #[cfg(not(target_arch = "wasm32"))]
176        let builder = builder
177            .user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))
178            .timeout(Duration::from_secs(30))
179            .pool_max_idle_per_host(2);
180
181        let http = builder
182            .build()
183            .map_err(|e| IndodaxError::Other(format!("Failed to create HTTP client: {}", e)))?;
184
185        Ok(Self {
186            http,
187            signer,
188            rate_limiter: RateLimiter::from_env(),
189            ws_token: None,
190        })
191    }
192
193    pub fn with_ws_token(mut self, token: Option<String>) -> Self {
194        self.ws_token = token;
195        self
196    }
197
198    pub fn signer(&self) -> Option<&Signer> {
199        self.signer.as_ref()
200    }
201
202    pub fn ws_token(&self) -> Option<&str> {
203        self.ws_token.as_deref()
204    }
205
206    pub fn http_client(&self) -> &Client {
207        &self.http
208    }
209
210    pub async fn get_tradingview_history(
211        &self,
212        symbol: &str,
213        timeframe: &str,
214        from: u64,
215        to: u64,
216    ) -> Result<TvHistoryResponse, IndodaxError> {
217        let from_str = from.to_string();
218        let to_str = to.to_string();
219        let params = [
220            ("symbol", symbol),
221            ("tf", timeframe),
222            ("from", &from_str),
223            ("to", &to_str),
224        ];
225        self.public_get_v2("/tradingview/history_v2", &params).await
226    }
227
228    pub async fn get_webdata(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
229        let path = format!("/api/webdata/{}", pair);
230        self.public_get_v2(&path, &[("lang", "indonesia")]).await
231    }
232
233    pub async fn get_chatroom_history(&self) -> Result<serde_json::Value, IndodaxError> {
234        self.public_get_v2("/api/v2/chatroom/history", &[]).await
235    }
236
237    pub async fn get_pairs_v2(&self, pair: Option<&str>) -> Result<serde_json::Value, IndodaxError> {
238        if let Some(p) = pair {
239            self.public_get_v2("/api/pairs_v2", &[("pair", p)]).await
240        } else {
241            self.public_get_v2("/api/pairs_v2", &[]).await
242        }
243    }
244
245    pub async fn get_tv_search(&self) -> Result<serde_json::Value, IndodaxError> {
246        self.public_get_v2("/tradingview/search_v2", &[]).await
247    }
248
249    pub async fn get_terminal_trade(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
250        let path = format!("/terminal-trading/trade?pair={}", pair);
251        self.public_get_v2(&path, &[]).await
252    }
253
254    pub async fn get_terminal_market_data(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
255        let path = format!("/terminal-trading/market/data?pair={}", pair);
256        self.public_get_v2(&path, &[]).await
257    }
258
259    pub async fn get_terminal_market_category(&self) -> Result<serde_json::Value, IndodaxError> {
260        self.public_get_v2("/terminal-trading/market/category", &[]).await
261    }
262
263    pub async fn get_onramp_config(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
264        let url = format!("{}/deposit-idr/v1/onramp/config?pair={}", api_base_url(), pair);
265        let resp = self.retry_get(&url).await?;
266        self.handle_response(resp).await
267    }
268
269    pub async fn get_news(&self, asset: &str, page: u32) -> Result<String, IndodaxError> {
270        let url = format!("{}/news?page={}&asset={}", public_base_url(), page, asset);
271        let resp = self.retry_get(&url).await?;
272        Ok(resp.text().await?)
273    }
274
275    pub async fn public_get<T: DeserializeOwned>(
276        &self,
277        path: &str,
278    ) -> Result<T, IndodaxError> {
279        let url = format!("{}{}", public_base_url(), path);
280        let resp = self.retry_get(&url).await?;
281        self.handle_response(resp).await
282    }
283
284    pub async fn countdown_cancel_all(
285        &self,
286        pair: Option<&str>,
287        countdown_time: u64,
288    ) -> Result<serde_json::Value, IndodaxError> {
289        let signer = self.signer.as_ref().ok_or_else(|| {
290            IndodaxError::Config("API credentials required for countdown cancel all".into())
291        })?;
292
293        let mut body_parts: Vec<String> = vec![
294            format!("countdownTime={}", countdown_time),
295        ];
296        if let Some(p) = pair {
297            body_parts.push(format!("pair={}", p));
298        }
299
300        let body = body_parts.join("&");
301        let (payload, signature) = signer.sign_v1(&body)?;
302
303        let url = format!("{}/countdownCancelAll", PRIVATE_V1_URL);
304        let req = self
305            .http
306            .post(&url)
307            .header("Key", signer.api_key())
308            .header("Sign", &signature)
309            .header("Content-Type", "application/x-www-form-urlencoded")
310            .body(payload);
311        let resp = self.send_with_retry(req).await?;
312        self.handle_v1_response(resp).await
313    }
314
315    pub async fn generate_ws_token(&self) -> Result<(String, String), IndodaxError> {
316        let signer = self.signer.as_ref().ok_or_else(|| {
317            IndodaxError::Config("API credentials required for WebSocket token generation".into())
318        })?;
319
320        let nonce = signer.next_nonce_str();
321        let (_, signature) = signer.sign_v1(&nonce)?;
322
323        let req = self
324            .http
325            .post(WS_TOKEN_URL)
326            .header("Key", signer.api_key())
327            .header("Sign", &signature)
328            .header("Content-Type", "application/x-www-form-urlencoded")
329            .body(format!("nonce={}", nonce));
330        let resp = self.send_with_retry(req).await?;
331
332        let body_text = resp.text().await?;
333        let val: serde_json::Value = serde_json::from_str(&body_text)?;
334
335        let token = val.get("token")
336            .and_then(|t| t.as_str())
337            .or_else(|| val.get("data").and_then(|d| d.get("token")).and_then(|t| t.as_str()))
338            .or_else(|| val.get("return").and_then(|r| r.get("connToken")).and_then(|t| t.as_str()))
339            .map(|t| t.to_string());
340
341        let channel = val.get("channel")
342            .and_then(|c| c.as_str())
343            .or_else(|| val.get("data").and_then(|d| d.get("channel")).and_then(|c| c.as_str()))
344            .or_else(|| val.get("return").and_then(|r| r.get("channel")).and_then(|c| c.as_str()))
345            .map(|c| c.to_string());
346
347        match (token, channel) {
348            (Some(t), Some(c)) => Ok((t, c)),
349            (Some(t), None) => Ok((t, "private:orders".to_string())), // Fallback channel
350            _ => Err(IndodaxError::WsToken(format!("No token or channel in response: {}", body_text))),
351        }
352    }
353
354    pub async fn public_get_v2<T: DeserializeOwned>(
355        &self,
356        path: &str,
357        params: &[(&str, &str)],
358    ) -> Result<T, IndodaxError> {
359        let url = format!("{}{}", public_base_url(), path);
360        let resp = self.retry_get_with_params(&url, params).await?;
361        self.handle_response(resp).await
362    }
363
364    async fn handle_v1_response<T: DeserializeOwned>(
365        &self,
366        resp: Response,
367    ) -> Result<T, IndodaxError> {
368        let body_text = resp.text().await?;
369        let envelope: IndodaxV1Response<T> = serde_json::from_str(&body_text).map_err(|e| {
370            IndodaxError::Parse(format!(
371                "Failed to parse response: {} (body: {})",
372                e, body_text
373            ))
374        })?;
375
376        if envelope.success == 1 {
377            envelope.return_data.ok_or_else(|| {
378                IndodaxError::Parse("API returned success but no 'return' data".into())
379            })
380        } else {
381            Err(IndodaxError::api(
382                envelope.error.unwrap_or_else(|| "Unknown error".into()),
383                match envelope.error_code.as_deref() {
384                    Some("invalid_credentials") => ErrorCategory::Authentication,
385                    Some("rate_limit") => ErrorCategory::RateLimit,
386                    Some(c) if c.contains("invalid") => ErrorCategory::Validation,
387                    _ => ErrorCategory::Unknown,
388                },
389                envelope.error_code,
390            ))
391        }
392    }
393
394    pub async fn private_post_v1<T: DeserializeOwned>(
395        &self,
396        method: &str,
397        params: &HashMap<String, String>,
398    ) -> Result<T, IndodaxError> {
399        let signer = self.signer.as_ref().ok_or_else(|| {
400            IndodaxError::Config("API credentials required for private endpoints".into())
401        })?;
402
403        let mut full_params: BTreeMap<String, String> = params
404            .iter()
405            .map(|(k, v)| (k.clone(), v.clone()))
406            .collect();
407        
408        full_params.insert("method".into(), method.to_string());
409        full_params.insert("nonce".into(), signer.next_nonce_str());
410
411        let body = serde_urlencoded_str(&full_params);
412        let (_, signature) = signer.sign_v1(&body)?;
413
414        let resp = self
415            .retry_post(PRIVATE_V1_URL, &body, signer.api_key(), &signature)
416            .await?;
417
418        self.handle_v1_response(resp).await
419    }
420
421    pub async fn private_get_v2<T: DeserializeOwned>(
422        &self,
423        path: &str,
424        params: &HashMap<String, String>,
425    ) -> Result<T, IndodaxError> {
426        let signer = self.signer.as_ref().ok_or_else(|| {
427            IndodaxError::Config("API credentials required for private endpoints".into())
428        })?;
429
430        let mut qs_parts: Vec<String> = params
431            .iter()
432            .map(|(k, v)| format!("{}={}", k, v))
433            .collect();
434        let timestamp = crate::now_millis();
435        qs_parts.push(format!("timestamp={}", timestamp));
436        qs_parts.push("recvWindow=5000".to_string());
437        qs_parts.sort();
438        let query_string = qs_parts.join("&");
439
440        let signature = signer.sign_v2(&query_string)?;
441        let url = format!("{}{}?{}", PRIVATE_V2_BASE, path, query_string);
442
443        let req = self
444            .http
445            .get(&url)
446            .header("X-APIKEY", signer.api_key())
447            .header("Sign", &signature)
448            .header("Accept", "application/json")
449            .header("Content-Type", "application/json");
450        let resp = self.send_with_retry(req).await?;
451
452        let body_text = resp.text().await?;
453        let envelope: IndodaxV2Response<T> = serde_json::from_str(&body_text).map_err(|e| {
454            IndodaxError::Parse(format!(
455                "Failed to parse v2 response: {} (body: {})",
456                e, body_text
457            ))
458        })?;
459
460        if let Some(data) = envelope.data {
461            Ok(data)
462        } else if let Some(error) = envelope.error {
463            Err(IndodaxError::api(error, ErrorCategory::Unknown, None))
464        } else {
465            Ok(serde_json::from_str(&body_text)?)
466        }
467    }
468
469    async fn retry_get(&self, url: &str) -> Result<Response, IndodaxError> {
470        let req = self.http.get(url);
471        self.send_with_retry(req).await
472    }
473
474    async fn retry_get_with_params(
475        &self,
476        url: &str,
477        params: &[(&str, &str)],
478    ) -> Result<Response, IndodaxError> {
479        let req = self.http.get(url).query(params);
480        self.send_with_retry(req).await
481    }
482
483    async fn retry_post(
484        &self,
485        url: &str,
486        body: &str,
487        api_key: &str,
488        signature: &str,
489    ) -> Result<Response, IndodaxError> {
490        let req = self
491            .http
492            .post(url)
493            .header("Key", api_key)
494            .header("Sign", signature)
495            .header("Content-Type", "application/x-www-form-urlencoded")
496            .body(body.to_string());
497        self.send_with_retry(req).await
498    }
499
500    async fn send_with_retry(
501        &self,
502        builder: RequestBuilder,
503    ) -> Result<Response, IndodaxError> {
504        self.rate_limiter.acquire().await;
505        let mut last_err = None;
506        let mut total_retries = 0u32;
507        let mut backoff_count = 0u32;
508        let mut current_builder = Some(builder);
509
510        while total_retries <= MAX_RETRIES {
511            if backoff_count > 0 {
512                sleep(Duration::from_millis(500 * 2u64.pow(backoff_count - 1))).await;
513            }
514
515            let req = if total_retries == 0 {
516                let b = current_builder.take().unwrap();
517                current_builder = b.try_clone();
518                b.build()
519            } else {
520                Ok(match &current_builder {
521                    Some(b) => {
522                        let retry_req = b.try_clone().map(|b| b.build());
523                        match retry_req {
524                            Some(Ok(r)) => r,
525                            _ => return Err(last_err.unwrap_or_else(|| IndodaxError::Other("Request not cloneable for retry".into()))),
526                        }
527                    }
528                    None => return Err(last_err.unwrap_or_else(|| IndodaxError::Other("Request not cloneable for retry".into()))),
529                })
530            }.map_err(|e| IndodaxError::Other(format!("Failed to build request: {}", e)))?;
531
532            match self.http.execute(req).await {
533                Ok(resp) => {
534                    let status = resp.status();
535                    if status.is_success() {
536                        return Ok(resp);
537                    }
538
539                    if status == StatusCode::TOO_MANY_REQUESTS {
540                        let retry_after = resp.headers()
541                            .get("Retry-After")
542                            .and_then(|v| v.to_str().ok())
543                            .and_then(|s| s.parse::<u64>().ok())
544                            .map(Duration::from_secs);
545                        if let Some(delay) = retry_after {
546                            sleep(delay).await;
547                            backoff_count = 0;
548                        } else {
549                            backoff_count += 1;
550                        }
551                        total_retries += 1;
552                        last_err = Some(IndodaxError::api(
553                            format!("Rate limited (HTTP {})", status.as_u16()),
554                            ErrorCategory::RateLimit,
555                            None,
556                        ));
557                        
558                        if current_builder.is_none() {
559                             return Err(last_err.unwrap());
560                        }
561                        continue;
562                    }
563
564                    if status.is_server_error() {
565                        total_retries += 1;
566                        backoff_count += 1;
567                        last_err = Some(IndodaxError::api(
568                            format!("Server error (HTTP {})", status.as_u16()),
569                            ErrorCategory::Server,
570                            None,
571                        ));
572                        
573                        if current_builder.is_none() {
574                             return Err(last_err.unwrap());
575                        }
576                        continue;
577                    }
578
579                    last_err = Some(IndodaxError::api(
580                        format!("HTTP {}", status.as_u16()),
581                        ErrorCategory::Unknown,
582                        None,
583                    ));
584                    break;
585                }
586                Err(e) => {
587                    total_retries += 1;
588                    backoff_count += 1;
589                    
590                    let is_retryable = e.is_timeout() || {
591                        #[cfg(not(target_arch = "wasm32"))]
592                        { e.is_connect() }
593                        #[cfg(target_arch = "wasm32")]
594                        { false }
595                    };
596
597                    if is_retryable && current_builder.is_some() {
598                        last_err = Some(IndodaxError::Http(e));
599                        continue;
600                    }
601                    
602                    return Err(IndodaxError::Http(e));
603                }
604            }
605        }
606
607        Err(last_err.unwrap_or_else(|| {
608            IndodaxError::Other("Max retries exceeded".into())
609        }))
610    }
611
612    async fn handle_response<T: DeserializeOwned>(
613        &self,
614        resp: Response,
615    ) -> Result<T, IndodaxError> {
616        let body_text = resp.text().await?;
617        serde_json::from_str(&body_text).map_err(|e| {
618            IndodaxError::Parse(format!(
619                "Failed to parse response: {} (body: {})",
620                e, body_text
621            ))
622        })
623    }
624}
625
626fn serde_urlencoded_str(params: &BTreeMap<String, String>) -> String {
627    params
628        .iter()
629        .map(|(k, v)| {
630            format!(
631                "{}={}",
632                url::form_urlencoded::byte_serialize(k.as_bytes()).collect::<String>(),
633                url::form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
634            )
635        })
636        .collect::<Vec<_>>()
637        .join("&")
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::auth::Signer;
644
645    #[test]
646    fn test_indodax_client_new_with_signer() {
647        let signer = Signer::new("key", "secret");
648        let client = IndodaxClient::new(Some(signer)).unwrap();
649        assert!(client.signer().is_some());
650    }
651
652    #[test]
653    fn test_indodax_client_new_without_signer() {
654        let client = IndodaxClient::new(None).unwrap();
655        assert!(client.signer().is_none());
656    }
657
658    #[test]
659    fn test_indodax_client_signer() {
660        let signer = Signer::new("mykey", "mysecret");
661        let client = IndodaxClient::new(Some(signer)).unwrap();
662        let s = client.signer().unwrap();
663        assert_eq!(s.api_key(), "mykey");
664    }
665
666    #[test]
667    fn test_indodax_v1_response_success() {
668        let json = serde_json::json!({
669            "success": 1,
670            "return": {"balance": {"btc": "1.0"}},
671            "error": null,
672            "error_code": null
673        });
674        let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
675        assert_eq!(resp.success, 1);
676        assert!(resp.return_data.is_some());
677        assert!(resp.error.is_none());
678    }
679
680    #[test]
681    fn test_indodax_v1_response_failure() {
682        let json = serde_json::json!({
683            "success": 0,
684            "return": null,
685            "error": "Invalid credentials",
686            "error_code": "invalid_credentials"
687        });
688        let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
689        assert_eq!(resp.success, 0);
690        assert!(resp.return_data.is_none());
691        assert!(resp.error.is_some());
692        assert!(resp.error_code.is_some());
693    }
694
695    #[test]
696    fn test_indodax_v2_response_success() {
697        let json = serde_json::json!({
698            "data": {"name": "test"},
699            "code": null,
700            "error": null
701        });
702        let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
703        assert!(resp.data.is_some());
704        assert!(resp.error.is_none());
705    }
706
707    #[test]
708    fn test_indodax_v2_response_error() {
709        let json = serde_json::json!({
710            "data": null,
711            "code": 400,
712            "error": "Bad request"
713        });
714        let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
715        assert!(resp.data.is_none());
716        assert!(resp.error.is_some());
717        assert!(resp.code.is_some());
718    }
719
720    #[test]
721    fn test_serde_urlencoded_str_single() {
722        let mut params = std::collections::BTreeMap::new();
723        params.insert("method".into(), "getInfo".into());
724        params.insert("nonce".into(), "12345".into());
725        
726        let result = serde_urlencoded_str(&params);
727        assert!(result.contains("method=getInfo"));
728        assert!(result.contains("nonce=12345"));
729    }
730
731    #[test]
732    fn test_serde_urlencoded_str_empty() {
733        let params = std::collections::BTreeMap::new();
734        let result = serde_urlencoded_str(&params);
735        assert_eq!(result, "");
736    }
737
738    #[test]
739    fn test_serde_urlencoded_str_special_chars() {
740        let mut params = std::collections::BTreeMap::new();
741        params.insert("key with space".into(), "value&more".into());
742        
743        let result = serde_urlencoded_str(&params);
744        // Should be URL encoded
745        assert!(result.contains("%20") || result.contains("+"));
746    }
747
748    #[test]
749    fn test_public_base_url() {
750        // In WASM mode, it might be a relative path like /api/indodax
751        assert!(!public_base_url().is_empty());
752    }
753
754    #[test]
755    fn test_private_v1_url() {
756        assert!(PRIVATE_V1_URL.contains("indodax.com/tapi"));
757    }
758
759    #[test]
760    fn test_private_v2_base() {
761        assert!(PRIVATE_V2_BASE.contains("tapi.btcapi.net"));
762    }
763
764    #[test]
765    fn test_max_retries_constant() {
766        assert_eq!(MAX_RETRIES, 3);
767    }
768
769    #[test]
770    fn test_indodax_v1_response_debug() {
771        let resp: IndodaxV1Response<serde_json::Value> = IndodaxV1Response {
772            success: 1,
773            return_data: Some(serde_json::json!({})),
774            error: None,
775            error_code: None,
776        };
777        let debug_str = format!("{:?}", resp);
778        assert!(debug_str.contains("success"));
779    }
780
781    #[test]
782    fn test_indodax_v2_response_debug() {
783        let resp: IndodaxV2Response<serde_json::Value> = IndodaxV2Response {
784            data: Some(serde_json::json!({})),
785            code: None,
786            error: None,
787        };
788        let debug_str = format!("{:?}", resp);
789        assert!(debug_str.contains("data"));
790    }
791
792    #[test]
793    fn test_rate_limiter_from_env_default() {
794        // Without env var, should default to 10
795        let rl = RateLimiter::from_env();
796        // If INDODAX_RATE_LIMIT is set in environment, test may fail
797        // so we just verify it doesn't panic
798        assert!(rl.capacity > 0);
799        assert!(rl.refill_per_sec > 0);
800    }
801
802    #[tokio::test]
803    async fn test_rate_limiter_acquire_single() {
804        let rl = RateLimiter::new(5, 5);
805        rl.acquire().await;
806        let state = rl.state.lock().await;
807        assert_eq!(state.tokens, 4);
808    }
809
810    #[tokio::test]
811    async fn test_rate_limiter_token_exhaustion_refills() {
812        let rl = RateLimiter::new(3, 10);
813        for _ in 0..3 {
814            rl.acquire().await;
815        }
816        {
817            let state = rl.state.lock().await;
818            assert_eq!(state.tokens, 0);
819        }
820
821        {
822            let mut state = rl.state.lock().await;
823            state.last_refill = Instant::now() - Duration::from_secs(1);
824        }
825
826        rl.acquire().await;
827        let state = rl.state.lock().await;
828        assert_eq!(state.tokens, 2);
829    }
830
831    #[tokio::test]
832    async fn test_rate_limiter_refill_capped_at_capacity() {
833        let rl = RateLimiter::new(5, 100);
834        for _ in 0..5 {
835            rl.acquire().await;
836        }
837        {
838            let state = rl.state.lock().await;
839            assert_eq!(state.tokens, 0);
840        }
841
842        {
843            let mut state = rl.state.lock().await;
844            state.last_refill = Instant::now() - Duration::from_secs(10);
845        }
846
847        rl.acquire().await;
848        let state = rl.state.lock().await;
849        assert_eq!(state.tokens, 4);
850    }
851
852    #[test]
853    fn test_rate_limiter_new_custom() {
854        let rl = RateLimiter::new(25, 25);
855        assert_eq!(rl.capacity, 25);
856        assert_eq!(rl.refill_per_sec, 25);
857    }
858}