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
84#[allow(clippy::too_many_arguments)]
85async fn withdraw(
86    client: &IndodaxClient,
87    currency: &str,
88    amount: f64,
89    address: &str,
90    to_username: bool,
91    memo: Option<&str>,
92    network: Option<&str>,
93    callback_url: Option<&str>,
94) -> Result<CommandOutput> {
95    if currency.is_empty() {
96        return Err(anyhow::anyhow!("Currency cannot be empty"));
97    }
98    if address.is_empty() {
99        return Err(anyhow::anyhow!("Address cannot be empty"));
100    }
101    if amount <= 0.0 || !amount.is_finite() {
102        return Err(anyhow::anyhow!(
103            "Amount must be positive and finite, got {}",
104            amount
105        ));
106    }
107
108    let params = helpers::build_withdraw_params(currency, amount, address, to_username, memo, network, callback_url);
109
110    let data: serde_json::Value =
111        client.private_post_v1("withdrawCoin", &params).await?;
112
113    let headers = vec!["Field".into(), "Value".into()];
114    let mut rows: Vec<Vec<String>> = Vec::new();
115    if let serde_json::Value::Object(ref map) = data {
116        for (k, v) in map {
117            rows.push(vec![k.clone(), helpers::value_to_string(v)]);
118        }
119    }
120
121    let dest_label = if to_username {
122        format!("user {}", address)
123    } else {
124        address.to_string()
125    };
126
127    Ok(CommandOutput::new(data, headers, rows)
128        .with_addendum(format!("Withdrew {} {} to {}", amount, currency, dest_label)))
129}
130
131async fn serve_callback(
132    port: u16,
133    auto_ok: bool,
134    listen: Option<&str>,
135    output_format: crate::output::OutputFormat,
136) -> Result<CommandOutput> {
137    use axum::{routing::post, Router};
138    use colored::Colorize;
139    use std::net::SocketAddr;
140
141    let app = Router::new().route(
142        "/callback",
143        post(move |body: String| async move {
144            if output_format == crate::output::OutputFormat::Json {
145                println!(
146                    "{}",
147                    serde_json::json!({
148                        "event": "callback_received",
149                        "body": body,
150                        "auto_ok": auto_ok
151                    })
152                );
153            } else {
154                eprintln!("\n{} Incoming Callback Request", ">>>".green());
155                eprintln!("{}: {}", "Body".bold(), body);
156            }
157
158            if auto_ok {
159                if output_format == crate::output::OutputFormat::Json {
160                    println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
161                } else {
162                    eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
163                }
164                "ok".to_string()
165            } else {
166                if output_format == crate::output::OutputFormat::Json {
167                    eprintln!("{}", "Waiting for manual confirmation (check stderr)...".yellow());
168                } else {
169                    eprintln!("{} Waiting for manual confirmation...", "???".yellow());
170                }
171                eprintln!(
172                    "{} Type 'ok' to confirm, or anything else to cancel:",
173                    ">>>".green()
174                );
175                let input = match tokio::task::spawn_blocking(|| {
176                    let mut buf = String::new();
177                    std::io::stdin().read_line(&mut buf).ok()?;
178                    Some(buf.trim().to_lowercase())
179                })
180                .await {
181                    Ok(Some(val)) => val,
182                    Ok(None) => String::new(),
183                    Err(e) => {
184                        eprintln!("[CALLBACK] Warning: stdin read task failed: {}. Defaulting to cancel.", e);
185                        "cancel".to_string()
186                    }
187                };
188                if input == "ok" {
189                    if output_format == crate::output::OutputFormat::Json {
190                        println!("{}", serde_json::json!({"event": "callback_response", "response": "ok"}));
191                    } else {
192                        eprintln!("{} Sent response: {}", "<<<".blue(), "ok".bold());
193                    }
194                    "ok".to_string()
195                } else {
196                    if output_format == crate::output::OutputFormat::Json {
197                        println!("{}", serde_json::json!({"event": "callback_response", "response": "cancel"}));
198                    } else {
199                        eprintln!("{} Sent response: {}", "<<<".blue(), "cancel".bold());
200                    }
201                    "cancel".to_string()
202                }
203            }
204        }),
205    );
206
207    let ip = listen.unwrap_or("127.0.0.1");
208    let addr: SocketAddr = ip
209        .parse()
210        .map(|ip: std::net::IpAddr| SocketAddr::new(ip, port))
211        .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], port)));
212    eprintln!("\n{}", "Indodax Callback Server".bold().underline());
213    eprintln!("{}: {}", "Listening on".cyan(), addr);
214    eprintln!(
215        "{}: {}",
216        "Auto-confirm".cyan(),
217        if auto_ok {
218            "ENABLED (returns 'ok')"
219        } else {
220            "DISABLED"
221        }
222    );
223    eprintln!("{}\n", "Press Ctrl+C to stop".dimmed());
224
225    let listener = tokio::net::TcpListener::bind(addr).await?;
226    axum::serve(listener, app).await?;
227
228    Ok(CommandOutput::new_empty())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_funding_command_variants() {
237        let _cmd1 = FundingCommand::WithdrawFee { 
238            currency: "btc".into(), 
239            network: Some("BTC".into()) 
240        };
241        let _cmd2 = FundingCommand::Withdraw { 
242            currency: "btc".into(), 
243            amount: 0.5, 
244            address: "addr123".into(), 
245            username: false, 
246            memo: None, 
247            network: Some("BTC".into()), 
248            callback_url: None 
249        };
250        let _cmd3 = FundingCommand::ServeCallback { 
251            port: 8080, 
252            auto_ok: true,
253            listen: None,
254        };
255    }
256
257    #[test]
258    fn test_funding_command_withdraw_to_username() {
259        let cmd = FundingCommand::Withdraw { 
260            currency: "btc".into(), 
261            amount: 0.5, 
262            address: "user123".into(), 
263            username: true, 
264            memo: None, 
265            network: None, 
266            callback_url: None 
267        };
268        match cmd {
269            FundingCommand::Withdraw { username, .. } => {
270                assert!(username);
271            }
272            _ => panic!("Expected Withdraw command, got {:?}", cmd),
273        }
274    }
275
276    #[test]
277    fn test_funding_command_serve_callback_defaults() {
278        let cmd = FundingCommand::ServeCallback { 
279            port: 8080, 
280            auto_ok: true,
281            listen: None,
282        };
283        match cmd {
284            FundingCommand::ServeCallback { port, auto_ok, .. } => {
285                assert_eq!(port, 8080);
286                assert!(auto_ok);
287            }
288            _ => panic!("Expected ServeCallback command, got {:?}", cmd),
289        }
290    }
291
292    #[test]
293    fn test_funding_command_withdraw_fee_no_network() {
294        let cmd = FundingCommand::WithdrawFee { 
295            currency: "eth".into(), 
296            network: None 
297        };
298        match cmd {
299            FundingCommand::WithdrawFee { network, .. } => {
300                assert!(network.is_none());
301            }
302            _ => panic!("Expected WithdrawFee command, got {:?}", cmd),
303        }
304    }
305
306    #[test]
307    fn test_funding_command_with_memo() {
308        let cmd = FundingCommand::Withdraw { 
309            currency: "xrp".into(), 
310            amount: 100.0, 
311            address: "rAddress".into(), 
312            username: false, 
313            memo: Some("123456".into()), 
314            network: None, 
315            callback_url: None 
316        };
317        match cmd {
318            FundingCommand::Withdraw { memo, .. } => {
319                assert_eq!(memo, Some("123456".into()));
320            }
321            _ => panic!("Expected Withdraw command, got {:?}", cmd),
322        }
323    }
324}