use crate::auth::Signer;
use crate::errors::{ErrorCategory, IndodaxError};
use reqwest::{Client, RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use std::collections::{BTreeMap, HashMap};
use tokio::sync::Mutex;
use web_time::{Duration, Instant};
#[cfg(target_arch = "wasm32")]
async fn sleep(duration: Duration) {
let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| {
web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve,
duration.as_millis() as i32,
)
.unwrap();
};
let p = js_sys::Promise::new(&mut cb);
wasm_bindgen_futures::JsFuture::from(p).await.unwrap();
}
#[cfg(not(target_arch = "wasm32"))]
use tokio::time::sleep;
const PRIVATE_V1_URL: &str = "https://indodax.com/tapi";
const PRIVATE_V2_BASE: &str = "https://tapi.btcapi.net";
const WS_TOKEN_URL: &str = "https://indodax.com/api/private_ws/v1/generate_token";
const MAX_RETRIES: u32 = 3;
#[cfg(not(target_arch = "wasm32"))]
fn public_base_url() -> String {
"https://indodax.com".to_owned()
}
#[cfg(not(target_arch = "wasm32"))]
fn api_base_url() -> String {
"https://api.indodax.com".to_owned()
}
#[cfg(target_arch = "wasm32")]
fn public_base_url() -> String {
let base = option_env!("INDODAX_PUBLIC_BASE_URL").unwrap_or("/api/indodax");
if base.starts_with("http://") || base.starts_with("https://") {
return base.to_owned();
}
let origin = web_sys::window()
.and_then(|window| window.location().origin().ok())
.unwrap_or_default();
format!("{origin}{base}")
}
#[cfg(target_arch = "wasm32")]
fn api_base_url() -> String {
public_base_url()
}
#[derive(Debug)]
struct RateLimiterState {
tokens: u64,
last_refill: Instant,
}
#[derive(Debug)]
struct RateLimiter {
capacity: u64,
refill_per_sec: u64,
state: Mutex<RateLimiterState>,
}
impl RateLimiter {
fn new(capacity: u64, refill_per_sec: u64) -> Self {
Self {
capacity,
refill_per_sec,
state: Mutex::new(RateLimiterState {
tokens: capacity,
last_refill: Instant::now(),
}),
}
}
fn from_env() -> Self {
#[cfg(not(target_arch = "wasm32"))]
let rps = std::env::var("INDODAX_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5)
.max(1);
#[cfg(target_arch = "wasm32")]
let rps = 5;
Self::new(rps, rps)
}
async fn acquire(&self) {
loop {
let mut state = self.state.lock().await;
let elapsed = state.last_refill.elapsed();
if elapsed >= Duration::from_secs(1) {
let secs = elapsed.as_secs();
let capped = secs.min(60); let add = self.refill_per_sec * capped;
state.tokens = state.tokens.saturating_add(add).min(self.capacity);
state.last_refill += Duration::from_secs(capped);
}
if state.tokens > 0 {
state.tokens -= 1;
return;
}
let elapsed_ms = elapsed.as_millis().min(u128::from(u64::MAX)) as u64;
let wait = if elapsed_ms < 1000 {
Duration::from_millis(1000 - elapsed_ms)
} else {
Duration::from_millis(50)
};
drop(state);
sleep(wait.max(Duration::from_millis(10))).await;
}
}
}
#[derive(Debug)]
pub struct IndodaxClient {
http: Client,
signer: Option<Signer>,
rate_limiter: RateLimiter,
ws_token: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct IndodaxV1Response<T> {
pub success: i32,
#[serde(rename = "return")]
pub return_data: Option<T>,
pub error: Option<String>,
pub error_code: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct IndodaxV2Response<T> {
pub data: Option<T>,
pub code: Option<i64>,
pub error: Option<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TvHistoryResponse {
#[serde(rename = "t")]
pub time: Vec<u64>,
#[serde(rename = "o")]
pub open: Vec<f64>,
#[serde(rename = "h")]
pub high: Vec<f64>,
#[serde(rename = "l")]
pub low: Vec<f64>,
#[serde(rename = "c")]
pub close: Vec<f64>,
#[serde(rename = "v")]
pub volume: Vec<f64>,
#[serde(rename = "s")]
pub status: String,
#[serde(rename = "nextTime", skip_serializing_if = "Option::is_none")]
pub next_time: Option<u64>,
}
impl IndodaxClient {
pub fn new(signer: Option<Signer>) -> Result<Self, IndodaxError> {
let builder = Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder
.user_agent(format!(
"{}/{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
))
.timeout(Duration::from_secs(30))
.pool_max_idle_per_host(2);
let http = builder
.build()
.map_err(|e| IndodaxError::Other(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self {
http,
signer,
rate_limiter: RateLimiter::from_env(),
ws_token: None,
})
}
pub fn with_ws_token(mut self, token: Option<String>) -> Self {
self.ws_token = token;
self
}
pub fn signer(&self) -> Option<&Signer> {
self.signer.as_ref()
}
pub fn ws_token(&self) -> Option<&str> {
self.ws_token.as_deref()
}
pub fn http_client(&self) -> &Client {
&self.http
}
pub async fn get_tradingview_history(
&self,
symbol: &str,
timeframe: &str,
from: u64,
to: u64,
) -> Result<serde_json::Value, IndodaxError> {
let from_str = from.to_string();
let to_str = to.to_string();
let params = [
("symbol", symbol),
("tf", timeframe),
("from", &from_str),
("to", &to_str),
];
self.public_get_v2("/tradingview/history_v2", ¶ms).await
}
pub async fn get_webdata(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
let path = format!("/api/webdata/{}", pair);
self.public_get_v2(&path, &[("lang", "indonesia")]).await
}
pub async fn get_chatroom_history(&self) -> Result<serde_json::Value, IndodaxError> {
self.public_get_v2("/api/v2/chatroom/history", &[]).await
}
pub async fn get_pairs_v2(
&self,
pair: Option<&str>,
) -> Result<serde_json::Value, IndodaxError> {
if let Some(p) = pair {
self.public_get_v2("/api/pairs_v2", &[("pair", p)]).await
} else {
self.public_get_v2("/api/pairs_v2", &[]).await
}
}
pub async fn get_tv_search(&self) -> Result<serde_json::Value, IndodaxError> {
self.public_get_v2("/tradingview/search_v2", &[]).await
}
pub async fn get_terminal_trade(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
let path = format!("/terminal-trading/trade?pair={}", pair);
self.public_get_v2(&path, &[]).await
}
pub async fn get_terminal_market_data(
&self,
pair: &str,
) -> Result<serde_json::Value, IndodaxError> {
let path = format!("/terminal-trading/market/data?pair={}", pair);
self.public_get_v2(&path, &[]).await
}
pub async fn get_terminal_market_category(&self) -> Result<serde_json::Value, IndodaxError> {
self.public_get_v2("/terminal-trading/market/category", &[])
.await
}
pub async fn get_onramp_config(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
let url = format!(
"{}/deposit-idr/v1/onramp/config?pair={}",
api_base_url(),
pair
);
let resp = self.retry_get(&url).await?;
self.handle_response(resp).await
}
pub async fn get_news(&self, asset: &str, page: u32) -> Result<String, IndodaxError> {
let url = format!("{}/news?page={}&asset={}", public_base_url(), page, asset);
let resp = self.retry_get(&url).await?;
Ok(resp.text().await?)
}
pub async fn public_get<T: DeserializeOwned>(&self, path: &str) -> Result<T, IndodaxError> {
let url = format!("{}{}", public_base_url(), path);
let resp = self.retry_get(&url).await?;
self.handle_response(resp).await
}
pub async fn countdown_cancel_all(
&self,
pair: Option<&str>,
countdown_time: u64,
) -> Result<serde_json::Value, IndodaxError> {
let signer = self.signer.as_ref().ok_or_else(|| {
IndodaxError::Config("API credentials required for countdown cancel all".into())
})?;
let mut body_parts: Vec<String> = vec![format!("countdownTime={}", countdown_time)];
if let Some(p) = pair {
body_parts.push(format!("pair={}", p));
}
let body = body_parts.join("&");
let (payload, signature) = signer.sign_v1(&body)?;
let url = format!("{}/countdownCancelAll", PRIVATE_V1_URL);
let req = self
.http
.post(&url)
.header("Key", signer.api_key())
.header("Sign", &signature)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(payload);
let resp = self.send_with_retry(req).await?;
self.handle_v1_response(resp).await
}
pub async fn generate_ws_token(&self) -> Result<(String, String), IndodaxError> {
let signer = self.signer.as_ref().ok_or_else(|| {
IndodaxError::Config("API credentials required for WebSocket token generation".into())
})?;
let nonce = signer.next_nonce_str();
let (_, signature) = signer.sign_v1(&nonce)?;
let req = self
.http
.post(WS_TOKEN_URL)
.header("Key", signer.api_key())
.header("Sign", &signature)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(format!("nonce={}", nonce));
let resp = self.send_with_retry(req).await?;
let body_text = resp.text().await?;
let val: serde_json::Value = serde_json::from_str(&body_text)?;
let token = val
.get("token")
.and_then(|t| t.as_str())
.or_else(|| {
val.get("data")
.and_then(|d| d.get("token"))
.and_then(|t| t.as_str())
})
.or_else(|| {
val.get("return")
.and_then(|r| r.get("connToken"))
.and_then(|t| t.as_str())
})
.map(|t| t.to_string());
let channel = val
.get("channel")
.and_then(|c| c.as_str())
.or_else(|| {
val.get("data")
.and_then(|d| d.get("channel"))
.and_then(|c| c.as_str())
})
.or_else(|| {
val.get("return")
.and_then(|r| r.get("channel"))
.and_then(|c| c.as_str())
})
.map(|c| c.to_string());
match (token, channel) {
(Some(t), Some(c)) => Ok((t, c)),
(Some(t), None) => Ok((t, "private:orders".to_string())), _ => Err(IndodaxError::WsToken(format!(
"No token or channel in response: {}",
body_text
))),
}
}
pub async fn public_get_v2<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, IndodaxError> {
let url = format!("{}{}", public_base_url(), path);
let resp = self.retry_get_with_params(&url, params).await?;
self.handle_response(resp).await
}
async fn handle_v1_response<T: DeserializeOwned>(
&self,
resp: Response,
) -> Result<T, IndodaxError> {
let body_text = resp.text().await?;
let envelope: IndodaxV1Response<T> = serde_json::from_str(&body_text).map_err(|e| {
IndodaxError::Parse(format!(
"Failed to parse response: {} (body: {})",
e, body_text
))
})?;
if envelope.success == 1 {
envelope.return_data.ok_or_else(|| {
IndodaxError::Parse("API returned success but no 'return' data".into())
})
} else {
Err(IndodaxError::api(
envelope.error.unwrap_or_else(|| "Unknown error".into()),
match envelope.error_code.as_deref() {
Some("invalid_credentials") => ErrorCategory::Authentication,
Some("rate_limit") => ErrorCategory::RateLimit,
Some(c) if c.contains("invalid") => ErrorCategory::Validation,
_ => ErrorCategory::Unknown,
},
envelope.error_code,
))
}
}
pub async fn private_post_v1<T: DeserializeOwned>(
&self,
method: &str,
params: &HashMap<String, String>,
) -> Result<T, IndodaxError> {
let signer = self.signer.as_ref().ok_or_else(|| {
IndodaxError::Config("API credentials required for private endpoints".into())
})?;
let mut full_params: BTreeMap<String, String> =
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
full_params.insert("method".into(), method.to_string());
full_params.insert("nonce".into(), signer.next_nonce_str());
let body = serde_urlencoded_str(&full_params);
let (_, signature) = signer.sign_v1(&body)?;
let resp = self
.retry_post(PRIVATE_V1_URL, &body, signer.api_key(), &signature)
.await?;
self.handle_v1_response(resp).await
}
pub async fn private_get_v2<T: DeserializeOwned>(
&self,
path: &str,
params: &HashMap<String, String>,
) -> Result<T, IndodaxError> {
let signer = self.signer.as_ref().ok_or_else(|| {
IndodaxError::Config("API credentials required for private endpoints".into())
})?;
let mut qs_parts: Vec<String> =
params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
let timestamp = crate::now_millis();
qs_parts.push(format!("timestamp={}", timestamp));
qs_parts.push("recvWindow=5000".to_string());
qs_parts.sort();
let query_string = qs_parts.join("&");
let signature = signer.sign_v2(&query_string)?;
let url = format!("{}{}?{}", PRIVATE_V2_BASE, path, query_string);
let req = self
.http
.get(&url)
.header("X-APIKEY", signer.api_key())
.header("Sign", &signature)
.header("Accept", "application/json")
.header("Content-Type", "application/json");
let resp = self.send_with_retry(req).await?;
let body_text = resp.text().await?;
let envelope: IndodaxV2Response<T> = serde_json::from_str(&body_text).map_err(|e| {
IndodaxError::Parse(format!(
"Failed to parse v2 response: {} (body: {})",
e, body_text
))
})?;
if let Some(data) = envelope.data {
Ok(data)
} else if let Some(error) = envelope.error {
Err(IndodaxError::api(error, ErrorCategory::Unknown, None))
} else {
Ok(serde_json::from_str(&body_text)?)
}
}
async fn retry_get(&self, url: &str) -> Result<Response, IndodaxError> {
let req = self.http.get(url);
self.send_with_retry(req).await
}
async fn retry_get_with_params(
&self,
url: &str,
params: &[(&str, &str)],
) -> Result<Response, IndodaxError> {
let req = self.http.get(url).query(params);
self.send_with_retry(req).await
}
async fn retry_post(
&self,
url: &str,
body: &str,
api_key: &str,
signature: &str,
) -> Result<Response, IndodaxError> {
let req = self
.http
.post(url)
.header("Key", api_key)
.header("Sign", signature)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body.to_string());
self.send_with_retry(req).await
}
async fn send_with_retry(&self, builder: RequestBuilder) -> Result<Response, IndodaxError> {
self.rate_limiter.acquire().await;
let mut last_err = None;
let mut total_retries = 0u32;
let mut backoff_count = 0u32;
let mut current_builder = Some(builder);
while total_retries <= MAX_RETRIES {
if backoff_count > 0 {
sleep(Duration::from_millis(500 * 2u64.pow(backoff_count - 1))).await;
}
let req = if total_retries == 0 {
let b = current_builder.take().unwrap();
current_builder = b.try_clone();
b.build()
} else {
Ok(match ¤t_builder {
Some(b) => {
let retry_req = b.try_clone().map(|b| b.build());
match retry_req {
Some(Ok(r)) => r,
_ => {
return Err(last_err.unwrap_or_else(|| {
IndodaxError::Other("Request not cloneable for retry".into())
}))
}
}
}
None => {
return Err(last_err.unwrap_or_else(|| {
IndodaxError::Other("Request not cloneable for retry".into())
}))
}
})
}
.map_err(|e| IndodaxError::Other(format!("Failed to build request: {}", e)))?;
match self.http.execute(req).await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
if status == StatusCode::TOO_MANY_REQUESTS {
let retry_after = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs);
if let Some(delay) = retry_after {
sleep(delay).await;
backoff_count = 0;
} else {
backoff_count += 1;
}
total_retries += 1;
last_err = Some(IndodaxError::api(
format!("Rate limited (HTTP {})", status.as_u16()),
ErrorCategory::RateLimit,
None,
));
if current_builder.is_none() {
return Err(last_err.unwrap());
}
continue;
}
if status.is_server_error() {
total_retries += 1;
backoff_count += 1;
last_err = Some(IndodaxError::api(
format!("Server error (HTTP {})", status.as_u16()),
ErrorCategory::Server,
None,
));
if current_builder.is_none() {
return Err(last_err.unwrap());
}
continue;
}
last_err = Some(IndodaxError::api(
format!("HTTP {}", status.as_u16()),
ErrorCategory::Unknown,
None,
));
break;
}
Err(e) => {
total_retries += 1;
backoff_count += 1;
let is_retryable = e.is_timeout() || {
#[cfg(not(target_arch = "wasm32"))]
{
e.is_connect()
}
#[cfg(target_arch = "wasm32")]
{
false
}
};
if is_retryable && current_builder.is_some() {
last_err = Some(IndodaxError::Http(e));
continue;
}
return Err(IndodaxError::Http(e));
}
}
}
Err(last_err.unwrap_or_else(|| IndodaxError::Other("Max retries exceeded".into())))
}
async fn handle_response<T: DeserializeOwned>(
&self,
resp: Response,
) -> Result<T, IndodaxError> {
let body_text = resp.text().await?;
serde_json::from_str(&body_text).map_err(|e| {
IndodaxError::Parse(format!(
"Failed to parse response: {} (body: {})",
e, body_text
))
})
}
}
fn serde_urlencoded_str(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(k, v)| {
format!(
"{}={}",
url::form_urlencoded::byte_serialize(k.as_bytes()).collect::<String>(),
url::form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
)
})
.collect::<Vec<_>>()
.join("&")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::Signer;
#[test]
fn test_indodax_client_new_with_signer() {
let signer = Signer::new("key", "secret");
let client = IndodaxClient::new(Some(signer)).unwrap();
assert!(client.signer().is_some());
}
#[test]
fn test_indodax_client_new_without_signer() {
let client = IndodaxClient::new(None).unwrap();
assert!(client.signer().is_none());
}
#[test]
fn test_indodax_client_signer() {
let signer = Signer::new("mykey", "mysecret");
let client = IndodaxClient::new(Some(signer)).unwrap();
let s = client.signer().unwrap();
assert_eq!(s.api_key(), "mykey");
}
#[test]
fn test_indodax_v1_response_success() {
let json = serde_json::json!({
"success": 1,
"return": {"balance": {"btc": "1.0"}},
"error": null,
"error_code": null
});
let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
assert_eq!(resp.success, 1);
assert!(resp.return_data.is_some());
assert!(resp.error.is_none());
}
#[test]
fn test_indodax_v1_response_failure() {
let json = serde_json::json!({
"success": 0,
"return": null,
"error": "Invalid credentials",
"error_code": "invalid_credentials"
});
let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
assert_eq!(resp.success, 0);
assert!(resp.return_data.is_none());
assert!(resp.error.is_some());
assert!(resp.error_code.is_some());
}
#[test]
fn test_indodax_v2_response_success() {
let json = serde_json::json!({
"data": {"name": "test"},
"code": null,
"error": null
});
let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
assert!(resp.data.is_some());
assert!(resp.error.is_none());
}
#[test]
fn test_indodax_v2_response_error() {
let json = serde_json::json!({
"data": null,
"code": 400,
"error": "Bad request"
});
let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
assert!(resp.data.is_none());
assert!(resp.error.is_some());
assert!(resp.code.is_some());
}
#[test]
fn test_serde_urlencoded_str_single() {
let mut params = std::collections::BTreeMap::new();
params.insert("method".into(), "getInfo".into());
params.insert("nonce".into(), "12345".into());
let result = serde_urlencoded_str(¶ms);
assert!(result.contains("method=getInfo"));
assert!(result.contains("nonce=12345"));
}
#[test]
fn test_serde_urlencoded_str_empty() {
let params = std::collections::BTreeMap::new();
let result = serde_urlencoded_str(¶ms);
assert_eq!(result, "");
}
#[test]
fn test_serde_urlencoded_str_special_chars() {
let mut params = std::collections::BTreeMap::new();
params.insert("key with space".into(), "value&more".into());
let result = serde_urlencoded_str(¶ms);
assert!(result.contains("%20") || result.contains("+"));
}
#[test]
fn test_public_base_url() {
assert!(!public_base_url().is_empty());
}
#[test]
fn test_private_v1_url() {
assert!(PRIVATE_V1_URL.contains("indodax.com/tapi"));
}
#[test]
fn test_private_v2_base() {
assert!(PRIVATE_V2_BASE.contains("tapi.btcapi.net"));
}
#[test]
fn test_max_retries_constant() {
assert_eq!(MAX_RETRIES, 3);
}
#[test]
fn test_indodax_v1_response_debug() {
let resp: IndodaxV1Response<serde_json::Value> = IndodaxV1Response {
success: 1,
return_data: Some(serde_json::json!({})),
error: None,
error_code: None,
};
let debug_str = format!("{:?}", resp);
assert!(debug_str.contains("success"));
}
#[test]
fn test_indodax_v2_response_debug() {
let resp: IndodaxV2Response<serde_json::Value> = IndodaxV2Response {
data: Some(serde_json::json!({})),
code: None,
error: None,
};
let debug_str = format!("{:?}", resp);
assert!(debug_str.contains("data"));
}
#[test]
fn test_rate_limiter_from_env_default() {
let rl = RateLimiter::from_env();
assert!(rl.capacity > 0);
assert!(rl.refill_per_sec > 0);
}
#[tokio::test]
async fn test_rate_limiter_acquire_single() {
let rl = RateLimiter::new(5, 5);
rl.acquire().await;
let state = rl.state.lock().await;
assert_eq!(state.tokens, 4);
}
#[tokio::test]
async fn test_rate_limiter_token_exhaustion_refills() {
let rl = RateLimiter::new(3, 10);
for _ in 0..3 {
rl.acquire().await;
}
{
let state = rl.state.lock().await;
assert_eq!(state.tokens, 0);
}
{
let mut state = rl.state.lock().await;
state.last_refill = Instant::now() - Duration::from_secs(1);
}
rl.acquire().await;
let state = rl.state.lock().await;
assert_eq!(state.tokens, 2);
}
#[tokio::test]
async fn test_rate_limiter_refill_capped_at_capacity() {
let rl = RateLimiter::new(5, 100);
for _ in 0..5 {
rl.acquire().await;
}
{
let state = rl.state.lock().await;
assert_eq!(state.tokens, 0);
}
{
let mut state = rl.state.lock().await;
state.last_refill = Instant::now() - Duration::from_secs(10);
}
rl.acquire().await;
let state = rl.state.lock().await;
assert_eq!(state.tokens, 4);
}
#[test]
fn test_rate_limiter_new_custom() {
let rl = RateLimiter::new(25, 25);
assert_eq!(rl.capacity, 25);
assert_eq!(rl.refill_per_sec, 25);
}
}