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", ¶ms).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", ¶ms).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}