indodax-cli 0.1.4

A command-line interface for the Indodax cryptocurrency exchange
Documentation
use crate::client::IndodaxClient;
use crate::commands::helpers;
use crate::output::CommandOutput;
use anyhow::Result;
use std::collections::HashMap;

#[derive(Debug, clap::Subcommand)]
pub enum FundingCommand {
    #[command(name = "withdraw-fee", about = "Check withdrawal fee for a currency")]
    WithdrawFee {
        #[arg(short, long)]
        currency: String,
        #[arg(short, long, help = "Blockchain network (optional)")]
        network: Option<String>,
    },

    #[command(name = "withdraw", about = "Withdraw cryptocurrency")]
    Withdraw {
        #[arg(short, long)]
        currency: String,
        #[arg(short, long, help = "Amount to withdraw")]
        amount: f64,
        #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
        address: String,
        #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
        username: bool,
        #[arg(long, help = "Memo/tag (for currencies that require it)")]
        memo: Option<String>,
        #[arg(long, help = "Blockchain network")]
        network: Option<String>,
        #[arg(long, help = "Callback URL for withdrawal confirmation")]
        callback_url: Option<String>,
    },

    #[command(name = "serve-callback", about = "Start a temporary HTTP server to handle Indodax withdrawal callback")]
    ServeCallback {
        #[arg(short, long, default_value = "8080")]
        port: u16,
        #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
        auto_ok: bool,
        #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
        listen: Option<String>,
    },
}

pub async fn execute(
    client: &IndodaxClient,
    config: &crate::config::IndodaxConfig,
    cmd: &FundingCommand,
    output_format: crate::output::OutputFormat,
) -> Result<CommandOutput> {
    match cmd {
        FundingCommand::WithdrawFee { currency, network } => {
            withdraw_fee(client, currency, network.as_deref()).await
        }
        FundingCommand::Withdraw { currency, amount, address, username, memo, network, callback_url } => {
            let cb_url = callback_url.as_deref().or(config.callback_url.as_deref());
            withdraw(client, currency, *amount, address, *username, memo.as_deref(), network.as_deref(), cb_url).await
        }
        FundingCommand::ServeCallback { port, auto_ok, listen } => {
            serve_callback(*port, *auto_ok, listen.as_deref(), output_format).await
        }
    }
}

async fn withdraw_fee(
    client: &IndodaxClient,
    currency: &str,
    network: Option<&str>,
) -> Result<CommandOutput> {
    let mut params = HashMap::new();
    params.insert("currency".into(), currency.to_string());
    if let Some(n) = network {
        params.insert("network".into(), n.to_string());
    }

    let data: serde_json::Value =
        client.private_post_v1("withdrawFee", &params).await?;

    let (headers, rows) = helpers::flatten_json_to_table(&data);
    Ok(CommandOutput::new(data, headers, rows))
}

async fn withdraw(
    client: &IndodaxClient,
    currency: &str,
    amount: f64,
    address: &str,
    to_username: bool,
    memo: Option<&str>,
    network: Option<&str>,
    callback_url: Option<&str>,
) -> Result<CommandOutput> {
    if currency.is_empty() {
        return Err(anyhow::anyhow!("Currency cannot be empty"));
    }
    if address.is_empty() {
        return Err(anyhow::anyhow!("Address cannot be empty"));
    }
    if amount <= 0.0 || !amount.is_finite() {
        return Err(anyhow::anyhow!(
            "Amount must be positive and finite, got {}",
            amount
        ));
    }

    let params = helpers::build_withdraw_params(currency, amount, address, to_username, memo, network, callback_url);

    let data: serde_json::Value =
        client.private_post_v1("withdrawCoin", &params).await?;

    let headers = vec!["Field".into(), "Value".into()];
    let mut rows: Vec<Vec<String>> = Vec::new();
    if let serde_json::Value::Object(ref map) = data {
        for (k, v) in map {
            rows.push(vec![k.clone(), helpers::value_to_string(v)]);
        }
    }

    let dest_label = if to_username {
        format!("user {}", address)
    } else {
        address.to_string()
    };

    Ok(CommandOutput::new(data, headers, rows)
        .with_addendum(format!("Withdrew {} {} to {}", amount, currency, dest_label)))
}

async fn serve_callback(
    port: u16,
    auto_ok: bool,
    listen: Option<&str>,
    output_format: crate::output::OutputFormat,
) -> Result<CommandOutput> {
    use axum::{routing::post, Router};
    use colored::Colorize;
    use std::net::SocketAddr;

    let app = Router::new().route(
        "/callback",
        post(move |body: String| async move {
            if output_format == crate::output::OutputFormat::Json {
                println!(
                    "{}",
                    serde_json::json!({
                        "event": "callback_received",
                        "body": body,
                        "auto_ok": auto_ok
                    })
                );
            } else {
                eprintln!("\n{} Incoming Callback Request", ">>>".green());
                eprintln!("{}: {}", "Body".bold(), body);
            }

            if auto_ok {
                if output_format == crate::output::OutputFormat::Json {
                    println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
                } else {
                    eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
                }
                "ok".to_string()
            } else {
                if output_format == crate::output::OutputFormat::Json {
                    eprintln!("{}", "Waiting for manual confirmation (check stderr)...".yellow());
                } else {
                    eprintln!("{} Waiting for manual confirmation...", "???".yellow());
                }
                eprintln!(
                    "{} Type 'ok' to confirm, or anything else to cancel:",
                    ">>>".green()
                );
                let input = tokio::task::spawn_blocking(|| {
                    let mut buf = String::new();
                    std::io::stdin().read_line(&mut buf).unwrap_or_default();
                    buf.trim().to_lowercase()
                })
                .await
                .unwrap_or_default();
                if input == "ok" {
                    if output_format == crate::output::OutputFormat::Json {
                        println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
                    } else {
                        eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
                    }
                    "ok".to_string()
                } else {
                    if output_format == crate::output::OutputFormat::Json {
                        println!("{}", serde_json::json!({"event": "callback_response", "response": "cancel"}));
                    } else {
                        eprintln!("{} Sent response: {}", "<<<".blue(), "cancel".bold());
                    }
                    "cancel".to_string()
                }
            }
        }),
    );

    let ip = listen.unwrap_or("127.0.0.1");
    let addr: SocketAddr = ip
        .parse()
        .map(|ip: std::net::IpAddr| SocketAddr::new(ip, port))
        .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], port)));
    eprintln!("\n{}", "Indodax Callback Server".bold().underline());
    eprintln!("{}: {}", "Listening on".cyan(), addr);
    eprintln!(
        "{}: {}",
        "Auto-confirm".cyan(),
        if auto_ok {
            "ENABLED (returns 'ok')"
        } else {
            "DISABLED"
        }
    );
    eprintln!("{}\n", "Press Ctrl+C to stop".dimmed());

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(CommandOutput::new_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_funding_command_variants() {
        let _cmd1 = FundingCommand::WithdrawFee { 
            currency: "btc".into(), 
            network: Some("BTC".into()) 
        };
        let _cmd2 = FundingCommand::Withdraw { 
            currency: "btc".into(), 
            amount: 0.5, 
            address: "addr123".into(), 
            username: false, 
            memo: None, 
            network: Some("BTC".into()), 
            callback_url: None 
        };
        let _cmd3 = FundingCommand::ServeCallback { 
            port: 8080, 
            auto_ok: true,
            listen: None,
        };
    }

    #[test]
    fn test_funding_command_withdraw_to_username() {
        let cmd = FundingCommand::Withdraw { 
            currency: "btc".into(), 
            amount: 0.5, 
            address: "user123".into(), 
            username: true, 
            memo: None, 
            network: None, 
            callback_url: None 
        };
        match cmd {
            FundingCommand::Withdraw { username, .. } => {
                assert!(username);
            }
            _ => assert!(false, "Expected Withdraw command, got {:?}", cmd),
        }
    }

    #[test]
    fn test_funding_command_serve_callback_defaults() {
        let cmd = FundingCommand::ServeCallback { 
            port: 8080, 
            auto_ok: true,
            listen: None,
        };
        match cmd {
            FundingCommand::ServeCallback { port, auto_ok, .. } => {
                assert_eq!(port, 8080);
                assert!(auto_ok);
            }
            _ => assert!(false, "Expected ServeCallback command, got {:?}", cmd),
        }
    }

    #[test]
    fn test_funding_command_withdraw_fee_no_network() {
        let cmd = FundingCommand::WithdrawFee { 
            currency: "eth".into(), 
            network: None 
        };
        match cmd {
            FundingCommand::WithdrawFee { network, .. } => {
                assert!(network.is_none());
            }
            _ => assert!(false, "Expected WithdrawFee command, got {:?}", cmd),
        }
    }

    #[test]
    fn test_funding_command_with_memo() {
        let cmd = FundingCommand::Withdraw { 
            currency: "xrp".into(), 
            amount: 100.0, 
            address: "rAddress".into(), 
            username: false, 
            memo: Some("123456".into()), 
            network: None, 
            callback_url: None 
        };
        match cmd {
            FundingCommand::Withdraw { memo, .. } => {
                assert_eq!(memo, Some("123456".into()));
            }
            _ => assert!(false, "Expected Withdraw command, got {:?}", cmd),
        }
    }
}