Skip to main content

indodax_cli/commands/
funding.rs

1use crate::client::IndodaxClient;
2use crate::commands::helpers;
3use crate::output::CommandOutput;
4use anyhow::Result;
5use std::collections::HashMap;
6
7#[derive(Debug, clap::Subcommand)]
8pub enum FundingCommand {
9    #[command(name = "withdraw-fee", about = "Check withdrawal fee for a currency")]
10    WithdrawFee {
11        #[arg(short, long)]
12        currency: String,
13        #[arg(short, long, help = "Blockchain network (optional)")]
14        network: Option<String>,
15    },
16
17    #[command(name = "withdraw", about = "Withdraw cryptocurrency")]
18    Withdraw {
19        #[arg(short, long)]
20        currency: String,
21        #[arg(short, long, help = "Amount to withdraw")]
22        amount: f64,
23        #[arg(long, help = "Crypto destination address (or Indodax username if --username is set)")]
24        address: String,
25        #[arg(long, help = "Withdraw to Indodax username instead of blockchain")]
26        username: bool,
27        #[arg(long, help = "Memo/tag (for currencies that require it)")]
28        memo: Option<String>,
29        #[arg(long, help = "Blockchain network")]
30        network: Option<String>,
31        #[arg(long, help = "Callback URL for withdrawal confirmation")]
32        callback_url: Option<String>,
33    },
34
35    #[command(name = "serve-callback", about = "Start a temporary HTTP server to handle Indodax withdrawal callback")]
36    ServeCallback {
37        #[arg(short, long, default_value = "8080")]
38        port: u16,
39        #[arg(short, long, help = "When true, auto-confirms all callback requests. When false, prompts for each request.", default_value = "false")]
40        auto_ok: bool,
41        #[arg(long, help = "Listen address (default: 127.0.0.1). Use 0.0.0.0 for network access")]
42        listen: Option<String>,
43    },
44}
45
46pub async fn execute(
47    client: &IndodaxClient,
48    config: &crate::config::IndodaxConfig,
49    cmd: &FundingCommand,
50    output_format: crate::output::OutputFormat,
51) -> Result<CommandOutput> {
52    match cmd {
53        FundingCommand::WithdrawFee { currency, network } => {
54            withdraw_fee(client, currency, network.as_deref()).await
55        }
56        FundingCommand::Withdraw { currency, amount, address, username, memo, network, callback_url } => {
57            let cb_url = callback_url.as_deref().or(config.callback_url.as_deref());
58            withdraw(client, currency, *amount, address, *username, memo.as_deref(), network.as_deref(), cb_url).await
59        }
60        FundingCommand::ServeCallback { port, auto_ok, listen } => {
61            serve_callback(*port, *auto_ok, listen.as_deref(), output_format).await
62        }
63    }
64}
65
66async fn withdraw_fee(
67    client: &IndodaxClient,
68    currency: &str,
69    network: Option<&str>,
70) -> Result<CommandOutput> {
71    let mut params = HashMap::new();
72    params.insert("currency".into(), currency.to_string());
73    if let Some(n) = network {
74        params.insert("network".into(), n.to_string());
75    }
76
77    let data: serde_json::Value =
78        client.private_post_v1("withdrawFee", &params).await?;
79
80    let (headers, rows) = helpers::flatten_json_to_table(&data);
81    Ok(CommandOutput::new(data, headers, rows))
82}
83
84async fn withdraw(
85    client: &IndodaxClient,
86    currency: &str,
87    amount: f64,
88    address: &str,
89    to_username: bool,
90    memo: Option<&str>,
91    network: Option<&str>,
92    callback_url: Option<&str>,
93) -> Result<CommandOutput> {
94    if currency.is_empty() {
95        return Err(anyhow::anyhow!("Currency cannot be empty"));
96    }
97    if address.is_empty() {
98        return Err(anyhow::anyhow!("Address cannot be empty"));
99    }
100    if amount <= 0.0 || !amount.is_finite() {
101        return Err(anyhow::anyhow!(
102            "Amount must be positive and finite, got {}",
103            amount
104        ));
105    }
106
107    let params = helpers::build_withdraw_params(currency, amount, address, to_username, memo, network, callback_url);
108
109    let data: serde_json::Value =
110        client.private_post_v1("withdrawCoin", &params).await?;
111
112    let headers = vec!["Field".into(), "Value".into()];
113    let mut rows: Vec<Vec<String>> = Vec::new();
114    if let serde_json::Value::Object(ref map) = data {
115        for (k, v) in map {
116            rows.push(vec![k.clone(), helpers::value_to_string(v)]);
117        }
118    }
119
120    let dest_label = if to_username {
121        format!("user {}", address)
122    } else {
123        address.to_string()
124    };
125
126    Ok(CommandOutput::new(data, headers, rows)
127        .with_addendum(format!("Withdrew {} {} to {}", amount, currency, dest_label)))
128}
129
130async fn serve_callback(
131    port: u16,
132    auto_ok: bool,
133    listen: Option<&str>,
134    output_format: crate::output::OutputFormat,
135) -> Result<CommandOutput> {
136    use axum::{routing::post, Router};
137    use colored::Colorize;
138    use std::net::SocketAddr;
139
140    let app = Router::new().route(
141        "/callback",
142        post(move |body: String| async move {
143            if output_format == crate::output::OutputFormat::Json {
144                println!(
145                    "{}",
146                    serde_json::json!({
147                        "event": "callback_received",
148                        "body": body,
149                        "auto_ok": auto_ok
150                    })
151                );
152            } else {
153                eprintln!("\n{} Incoming Callback Request", ">>>".green());
154                eprintln!("{}: {}", "Body".bold(), body);
155            }
156
157            if auto_ok {
158                if output_format == crate::output::OutputFormat::Json {
159                    println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
160                } else {
161                    eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
162                }
163                "ok".to_string()
164            } else {
165                if output_format == crate::output::OutputFormat::Json {
166                    eprintln!("{}", "Waiting for manual confirmation (check stderr)...".yellow());
167                } else {
168                    eprintln!("{} Waiting for manual confirmation...", "???".yellow());
169                }
170                eprintln!(
171                    "{} Type 'ok' to confirm, or anything else to cancel:",
172                    ">>>".green()
173                );
174                let input = tokio::task::spawn_blocking(|| {
175                    let mut buf = String::new();
176                    std::io::stdin().read_line(&mut buf).unwrap_or_default();
177                    buf.trim().to_lowercase()
178                })
179                .await
180                .unwrap_or_default();
181                if input == "ok" {
182                    if output_format == crate::output::OutputFormat::Json {
183                        println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
184                    } else {
185                        eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
186                    }
187                    "ok".to_string()
188                } else {
189                    if output_format == crate::output::OutputFormat::Json {
190                        println!("{}", serde_json::json!({"event": "callback_response", "response": "cancel"}));
191                    } else {
192                        eprintln!("{} Sent response: {}", "<<<".blue(), "cancel".bold());
193                    }
194                    "cancel".to_string()
195                }
196            }
197        }),
198    );
199
200    let ip = listen.unwrap_or("127.0.0.1");
201    let addr: SocketAddr = ip
202        .parse()
203        .map(|ip: std::net::IpAddr| SocketAddr::new(ip, port))
204        .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], port)));
205    eprintln!("\n{}", "Indodax Callback Server".bold().underline());
206    eprintln!("{}: {}", "Listening on".cyan(), addr);
207    eprintln!(
208        "{}: {}",
209        "Auto-confirm".cyan(),
210        if auto_ok {
211            "ENABLED (returns 'ok')"
212        } else {
213            "DISABLED"
214        }
215    );
216    eprintln!("{}\n", "Press Ctrl+C to stop".dimmed());
217
218    let listener = tokio::net::TcpListener::bind(addr).await?;
219    axum::serve(listener, app).await?;
220
221    Ok(CommandOutput::new_empty())
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_funding_command_variants() {
230        let _cmd1 = FundingCommand::WithdrawFee { 
231            currency: "btc".into(), 
232            network: Some("BTC".into()) 
233        };
234        let _cmd2 = FundingCommand::Withdraw { 
235            currency: "btc".into(), 
236            amount: 0.5, 
237            address: "addr123".into(), 
238            username: false, 
239            memo: None, 
240            network: Some("BTC".into()), 
241            callback_url: None 
242        };
243        let _cmd3 = FundingCommand::ServeCallback { 
244            port: 8080, 
245            auto_ok: true,
246            listen: None,
247        };
248    }
249
250    #[test]
251    fn test_funding_command_withdraw_to_username() {
252        let cmd = FundingCommand::Withdraw { 
253            currency: "btc".into(), 
254            amount: 0.5, 
255            address: "user123".into(), 
256            username: true, 
257            memo: None, 
258            network: None, 
259            callback_url: None 
260        };
261        match cmd {
262            FundingCommand::Withdraw { username, .. } => {
263                assert!(username);
264            }
265            _ => assert!(false, "Expected Withdraw command, got {:?}", cmd),
266        }
267    }
268
269    #[test]
270    fn test_funding_command_serve_callback_defaults() {
271        let cmd = FundingCommand::ServeCallback { 
272            port: 8080, 
273            auto_ok: true,
274            listen: None,
275        };
276        match cmd {
277            FundingCommand::ServeCallback { port, auto_ok, .. } => {
278                assert_eq!(port, 8080);
279                assert!(auto_ok);
280            }
281            _ => assert!(false, "Expected ServeCallback command, got {:?}", cmd),
282        }
283    }
284
285    #[test]
286    fn test_funding_command_withdraw_fee_no_network() {
287        let cmd = FundingCommand::WithdrawFee { 
288            currency: "eth".into(), 
289            network: None 
290        };
291        match cmd {
292            FundingCommand::WithdrawFee { network, .. } => {
293                assert!(network.is_none());
294            }
295            _ => assert!(false, "Expected WithdrawFee command, got {:?}", cmd),
296        }
297    }
298
299    #[test]
300    fn test_funding_command_with_memo() {
301        let cmd = FundingCommand::Withdraw { 
302            currency: "xrp".into(), 
303            amount: 100.0, 
304            address: "rAddress".into(), 
305            username: false, 
306            memo: Some("123456".into()), 
307            network: None, 
308            callback_url: None 
309        };
310        match cmd {
311            FundingCommand::Withdraw { memo, .. } => {
312                assert_eq!(memo, Some("123456".into()));
313            }
314            _ => assert!(false, "Expected Withdraw command, got {:?}", cmd),
315        }
316    }
317}