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