1#[cfg(feature = "rest")]
2use crate::mode::rest::RestInit;
3#[cfg(feature = "rpc")]
4use crate::mode::rpc::RpcInit;
5use crate::types::*;
6use agent_first_data::OutputFormat;
7use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
8use std::collections::BTreeMap;
9use std::io::Write;
10
11pub struct CliError {
16 pub message: String,
17 pub hint: Option<String>,
18}
19
20impl From<String> for CliError {
21 fn from(message: String) -> Self {
22 Self {
23 message,
24 hint: None,
25 }
26 }
27}
28
29pub enum Mode {
30 Cli(Box<CliRequest>),
31 Pipe(PipeInit),
32 Interactive(InteractiveInit),
33 #[cfg(feature = "rpc")]
34 Rpc(RpcInit),
35 #[cfg(not(feature = "rpc"))]
36 Rpc(RpcStub),
37 #[cfg(feature = "rest")]
38 Rest(RestInit),
39 Data(DataOp),
40}
41
42#[cfg(not(feature = "rpc"))]
43pub struct RpcStub;
44
45#[cfg_attr(not(feature = "backup"), allow(dead_code))]
46pub struct DataOp {
47 pub kind: DataOpKind,
48 pub data_dir: Option<String>,
49 pub output: agent_first_data::OutputFormat,
50}
51
52#[cfg_attr(not(feature = "backup"), allow(dead_code))]
53pub enum DataOpKind {
54 GlobalBackup {
55 output_path: Option<String>,
56 extra_dirs: Vec<(String, String)>,
57 },
58 GlobalRestore {
59 archive_path: String,
60 overwrite: bool,
61 pg_url_secret: Option<String>,
62 extra_dirs: Vec<(String, String)>,
63 },
64 NetworkBackup {
65 network: Network,
66 output_path: Option<String>,
67 wallet: Option<String>,
68 },
69 NetworkRestore {
70 network: Network,
71 archive_path: String,
72 overwrite: bool,
73 pg_url_secret: Option<String>,
74 },
75}
76
77pub struct CliRequest {
78 pub input: Input,
79 pub output: OutputFormat,
80 pub log: Vec<String>,
81 pub data_dir: Option<String>,
82 pub rpc_endpoint: Option<String>,
83 #[cfg_attr(not(feature = "rpc"), allow(dead_code))]
84 pub rpc_secret: Option<String>,
85 pub startup_argv: Vec<String>,
86 pub startup_args: serde_json::Value,
87 pub startup_requested: bool,
88 pub dry_run: bool,
89}
90
91pub struct PipeInit {
92 pub output: OutputFormat,
93 pub log: Vec<String>,
94 pub data_dir: Option<String>,
95 pub startup_argv: Vec<String>,
96 pub startup_args: serde_json::Value,
97 pub startup_requested: bool,
98}
99
100#[allow(dead_code)]
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum InteractiveFrontend {
103 Interactive,
104 Tui,
105}
106
107#[allow(dead_code)]
108#[derive(Clone)]
109pub struct InteractiveInit {
110 pub frontend: InteractiveFrontend,
111 pub output: OutputFormat,
112 pub log: Vec<String>,
113 pub data_dir: Option<String>,
114 pub rpc_endpoint: Option<String>,
115 pub rpc_secret: Option<String>,
116}
117
118fn parse_memo_kv(s: &str) -> Result<(String, String), String> {
125 match s.split_once('=') {
126 Some((k, v)) => {
127 if k.is_empty() {
128 return Err("memo key must not be empty".into());
129 }
130 Ok((k.to_string(), v.to_string()))
131 }
132 None => Ok(("note".to_string(), s.to_string())),
133 }
134}
135
136fn memo_vec_to_map(v: Vec<(String, String)>) -> Option<BTreeMap<String, String>> {
137 if v.is_empty() {
138 None
139 } else {
140 Some(v.into_iter().collect())
141 }
142}
143
144#[derive(clap::Args, Clone)]
149struct CommonSendArgs {
150 #[arg(long)]
152 wallet: Option<String>,
153 #[arg(long = "onchain-memo")]
155 onchain_memo: Option<String>,
156 #[arg(long = "local-memo", value_parser = parse_memo_kv)]
158 local_memo: Vec<(String, String)>,
159}
160
161#[derive(clap::Args, Clone)]
162struct CommonReceiveArgs {
163 #[arg(long)]
165 wallet: Option<String>,
166 #[arg(long)]
168 wait: bool,
169 #[arg(long = "wait-timeout-s")]
171 wait_timeout_s: Option<u64>,
172 #[arg(long = "wait-poll-interval-ms")]
174 wait_poll_interval_ms: Option<u64>,
175 #[arg(long = "qr-svg-file", default_value_t = false)]
177 qr_svg_file: bool,
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
185enum RuntimeMode {
186 Cli,
187 Pipe,
188 Interactive,
189 Tui,
190 Rpc,
191 #[cfg(feature = "rest")]
192 Rest,
193}
194
195#[derive(Parser)]
196#[command(
197 name = "afpay",
198 bin_name = "afpay",
199 version,
200 about = "Agent-first cryptocurrency micropayment tool"
201)]
202pub struct AfpayCli {
203 #[arg(long, value_enum, default_value_t = RuntimeMode::Cli)]
205 mode: RuntimeMode,
206
207 #[arg(long = "rpc-endpoint")]
209 rpc_endpoint: Option<String>,
210
211 #[arg(long = "rpc-listen", default_value = "127.0.0.1:9400")]
213 rpc_listen: String,
214
215 #[arg(long = "rpc-secret")]
217 rpc_secret: Option<String>,
218
219 #[arg(long = "rest-listen", default_value = "127.0.0.1:9401")]
221 rest_listen: String,
222
223 #[arg(long = "rest-api-key")]
225 rest_api_key: Option<String>,
226
227 #[arg(long = "public-listen")]
229 public_listen: bool,
230
231 #[arg(long = "data-dir")]
233 data_dir: Option<String>,
234
235 #[arg(long, default_value = "json")]
237 output: String,
238
239 #[arg(long = "log", value_delimiter = ',')]
241 log: Vec<String>,
242
243 #[arg(long)]
245 dry_run: bool,
246
247 #[command(subcommand)]
248 command: Option<PayCommand>,
249}
250
251#[derive(Subcommand)]
252enum PayCommand {
253 Global {
255 #[command(subcommand)]
256 action: GlobalCommand,
257 },
258 Cashu {
260 #[command(subcommand)]
261 action: CashuCommand,
262 },
263 Ln {
265 #[command(subcommand)]
266 action: LnCommand,
267 },
268 Sol {
270 #[command(subcommand)]
271 action: SolCommand,
272 },
273 Evm {
275 #[command(subcommand)]
276 action: EvmCommand,
277 },
278 Btc {
280 #[command(subcommand)]
281 action: BtcCommand,
282 },
283 Wallet {
285 #[command(subcommand)]
286 action: WalletTopAction,
287 },
288 Balance {
290 #[arg(long)]
292 wallet: Option<String>,
293 #[arg(long, value_enum)]
295 network: Option<CliNetwork>,
296 #[arg(long = "cashu-check")]
298 cashu_check: bool,
299 },
300 #[command(name = "history")]
302 History {
303 #[command(subcommand)]
304 action: HistoryAction,
305 },
306 Limit {
308 #[command(subcommand)]
309 action: LimitAction,
310 },
311}
312
313fn parse_extra_dir(s: &str) -> Result<(String, String), String> {
314 match s.split_once('=') {
315 Some((label, path)) if !label.is_empty() && !path.is_empty() => {
316 Ok((label.to_string(), path.to_string()))
317 }
318 _ => Err(format!("expected label=/path, got: {s}")),
319 }
320}
321
322#[derive(Subcommand)]
323enum GlobalCommand {
324 Limit {
326 #[command(subcommand)]
327 action: GlobalLimitAction,
328 },
329 Config {
331 #[command(subcommand)]
332 action: GlobalConfigAction,
333 },
334 Backup {
336 #[arg(long)]
338 output: Option<String>,
339 #[arg(long = "extra-dir", value_parser = parse_extra_dir)]
341 extra_dir: Vec<(String, String)>,
342 },
343 Restore {
345 archive: String,
347 #[arg(long = "dangerously-overwrite")]
349 dangerously_overwrite: bool,
350 #[arg(long = "pg-url-secret")]
352 pg_url_secret: Option<String>,
353 #[arg(long = "extra-dir", value_parser = parse_extra_dir)]
355 extra_dir: Vec<(String, String)>,
356 },
357}
358
359#[derive(Subcommand)]
360enum GlobalConfigAction {
361 Show,
363 Set {
365 #[arg(long, value_delimiter = ',')]
367 log: Option<Vec<String>>,
368 },
369}
370
371#[derive(Subcommand)]
372enum GlobalLimitAction {
373 Add {
375 #[arg(long)]
377 window: String,
378 #[arg(long)]
380 max_spend: u64,
381 },
382}
383
384#[derive(Subcommand)]
386enum SimpleWalletConfigAction {
387 Show,
389 Set {
391 #[arg(long)]
393 label: Option<String>,
394 },
395}
396
397#[derive(Subcommand)]
399enum SolWalletConfigAction {
400 Show,
402 Set {
404 #[arg(long)]
406 label: Option<String>,
407 #[arg(long = "rpc-endpoint")]
409 rpc_endpoint: Vec<String>,
410 },
411 #[command(name = "token-add")]
413 TokenAdd {
414 #[arg(long)]
416 symbol: String,
417 #[arg(long)]
419 address: String,
420 #[arg(long, default_value_t = 6)]
422 decimals: u8,
423 },
424 #[command(name = "token-remove")]
426 TokenRemove {
427 #[arg(long)]
429 symbol: String,
430 },
431}
432
433#[derive(Subcommand)]
435enum EvmWalletConfigAction {
436 Show,
438 Set {
440 #[arg(long)]
442 label: Option<String>,
443 #[arg(long = "rpc-endpoint")]
445 rpc_endpoint: Vec<String>,
446 #[arg(long = "chain-id")]
448 chain_id: Option<u64>,
449 },
450 #[command(name = "token-add")]
452 TokenAdd {
453 #[arg(long)]
455 symbol: String,
456 #[arg(long)]
458 address: String,
459 #[arg(long, default_value_t = 6)]
461 decimals: u8,
462 },
463 #[command(name = "token-remove")]
465 TokenRemove {
466 #[arg(long)]
468 symbol: String,
469 },
470}
471
472#[derive(Subcommand)]
474enum SimpleLimitAction {
475 Add {
477 #[arg(long)]
479 window: String,
480 #[arg(long)]
482 max_spend: u64,
483 },
484}
485
486#[derive(Subcommand)]
488enum TokenLimitAction {
489 Add {
491 #[arg(long)]
493 token: Option<String>,
494 #[arg(long)]
496 window: String,
497 #[arg(long)]
499 max_spend: u64,
500 },
501}
502
503#[derive(Subcommand)]
504enum CashuCommand {
505 #[command(name = "send")]
507 Send {
508 #[arg(long = "amount-sats")]
510 amount_sats: u64,
511 #[arg(long = "cashu-mint")]
513 mint_url: Vec<String>,
514 #[command(flatten)]
515 common: CommonSendArgs,
516 #[arg(long, hide = true)]
518 to: Option<String>,
519 },
520 #[command(name = "receive")]
522 Receive {
523 token: String,
525 #[arg(long)]
527 wallet: Option<String>,
528 },
529 #[command(name = "send-to-ln")]
531 SendToLn {
532 #[arg(long)]
534 to: String,
535 #[command(flatten)]
536 common: CommonSendArgs,
537 },
538 #[command(name = "receive-from-ln")]
540 ReceiveFromLn {
541 #[arg(long = "amount-sats")]
543 amount_sats: Option<u64>,
544 #[arg(long = "onchain-memo")]
546 onchain_memo: Option<String>,
547 #[command(flatten)]
548 common: CommonReceiveArgs,
549 },
550 #[command(name = "receive-from-ln-claim")]
552 ReceiveFromLnClaim {
553 #[arg(long)]
555 wallet: String,
556 #[arg(long = "ln-quote-id")]
558 ln_quote_id: String,
559 },
560 Balance {
562 #[arg(long)]
564 wallet: Option<String>,
565 #[arg(long)]
567 check: bool,
568 },
569 Wallet {
571 #[command(subcommand)]
572 action: CashuWalletAction,
573 },
574 Limit {
576 #[arg(long)]
578 wallet: Option<String>,
579 #[command(subcommand)]
580 action: SimpleLimitAction,
581 },
582 Config {
584 #[arg(long)]
586 wallet: String,
587 #[command(subcommand)]
588 action: SimpleWalletConfigAction,
589 },
590 Backup {
592 #[arg(long)]
594 output: Option<String>,
595 #[arg(long)]
597 wallet: Option<String>,
598 },
599 Restore {
601 archive: String,
603 #[arg(long = "dangerously-overwrite")]
605 dangerously_overwrite: bool,
606 #[arg(long = "pg-url-secret")]
608 pg_url_secret: Option<String>,
609 },
610}
611
612#[derive(Subcommand)]
613enum CashuWalletAction {
614 Create {
616 #[arg(long = "cashu-mint")]
618 mint_url: String,
619 #[arg(long)]
621 label: Option<String>,
622 #[arg(long = "mnemonic-secret")]
624 mnemonic_secret: Option<String>,
625 },
626 Close {
628 #[arg(long)]
630 wallet: String,
631 #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
633 dangerously_skip_balance_check_and_may_lose_money: bool,
634 },
635 List,
637 #[command(name = "dangerously-show-seed")]
639 ShowSeed {
640 #[arg(long)]
642 wallet: String,
643 },
644 Restore {
646 #[arg(long)]
648 wallet: String,
649 },
650}
651
652#[derive(Subcommand)]
653enum LnCommand {
654 Wallet {
656 #[command(subcommand)]
657 action: LnWalletAction,
658 },
659 #[command(name = "send")]
661 Send {
662 #[arg(long)]
664 to: String,
665 #[arg(long = "amount-sats")]
667 amount_sats: Option<u64>,
668 #[command(flatten)]
669 common: CommonSendArgs,
670 },
671 #[command(name = "receive")]
673 Receive {
674 #[arg(long = "amount-sats")]
676 amount_sats: Option<u64>,
677 #[command(flatten)]
678 common: CommonReceiveArgs,
679 },
680 Balance {
682 #[arg(long)]
684 wallet: Option<String>,
685 },
686 Limit {
688 #[arg(long)]
690 wallet: Option<String>,
691 #[command(subcommand)]
692 action: SimpleLimitAction,
693 },
694 Config {
696 #[arg(long)]
698 wallet: String,
699 #[command(subcommand)]
700 action: SimpleWalletConfigAction,
701 },
702 Backup {
704 #[arg(long)]
706 output: Option<String>,
707 #[arg(long)]
709 wallet: Option<String>,
710 },
711 Restore {
713 archive: String,
715 #[arg(long = "dangerously-overwrite")]
717 dangerously_overwrite: bool,
718 #[arg(long = "pg-url-secret")]
720 pg_url_secret: Option<String>,
721 },
722}
723
724#[derive(Subcommand)]
725enum SolCommand {
726 Wallet {
728 #[command(subcommand)]
729 action: SolWalletAction,
730 },
731 #[command(name = "send")]
733 Send {
734 #[arg(long)]
736 to: String,
737 #[arg(long)]
739 amount: u64,
740 #[arg(long)]
742 token: String,
743 #[arg(long)]
745 reference: Option<String>,
746 #[command(flatten)]
747 common: CommonSendArgs,
748 },
749 #[command(name = "receive")]
751 Receive {
752 #[arg(long = "onchain-memo")]
754 onchain_memo: Option<String>,
755 #[arg(long = "min-confirmations")]
757 min_confirmations: Option<u32>,
758 #[arg(long)]
760 reference: Option<String>,
761 #[command(flatten)]
762 common: CommonReceiveArgs,
763 },
764 Balance {
766 #[arg(long)]
768 wallet: Option<String>,
769 },
770 Limit {
772 #[arg(long)]
774 wallet: Option<String>,
775 #[command(subcommand)]
776 action: TokenLimitAction,
777 },
778 Config {
780 #[arg(long)]
782 wallet: String,
783 #[command(subcommand)]
784 action: SolWalletConfigAction,
785 },
786 Backup {
788 #[arg(long)]
790 output: Option<String>,
791 #[arg(long)]
793 wallet: Option<String>,
794 },
795 Restore {
797 archive: String,
799 #[arg(long = "dangerously-overwrite")]
801 dangerously_overwrite: bool,
802 #[arg(long = "pg-url-secret")]
804 pg_url_secret: Option<String>,
805 },
806}
807
808#[derive(Subcommand)]
809enum SolWalletAction {
810 Create {
812 #[arg(long = "sol-rpc-endpoint", required = true)]
814 sol_rpc_endpoint: Vec<String>,
815 #[arg(long)]
817 label: Option<String>,
818 },
819 Close {
821 #[arg(long)]
823 wallet: String,
824 #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
826 dangerously_skip_balance_check_and_may_lose_money: bool,
827 },
828 List,
830 #[command(name = "dangerously-show-seed")]
832 ShowSeed {
833 #[arg(long)]
835 wallet: String,
836 },
837}
838
839#[derive(Subcommand)]
840enum EvmCommand {
841 Wallet {
843 #[command(subcommand)]
844 action: EvmWalletAction,
845 },
846 #[command(name = "send")]
848 Send {
849 #[arg(long)]
851 to: String,
852 #[arg(long)]
854 amount: u64,
855 #[arg(long)]
857 token: String,
858 #[command(flatten)]
859 common: CommonSendArgs,
860 },
861 #[command(name = "receive")]
863 Receive {
864 #[arg(long = "onchain-memo")]
866 onchain_memo: Option<String>,
867 #[arg(long = "min-confirmations")]
869 min_confirmations: Option<u32>,
870 #[command(flatten)]
871 common: CommonReceiveArgs,
872 },
873 Balance {
875 #[arg(long)]
877 wallet: Option<String>,
878 },
879 Limit {
881 #[arg(long)]
883 wallet: Option<String>,
884 #[command(subcommand)]
885 action: TokenLimitAction,
886 },
887 Config {
889 #[arg(long)]
891 wallet: String,
892 #[command(subcommand)]
893 action: EvmWalletConfigAction,
894 },
895 Backup {
897 #[arg(long)]
899 output: Option<String>,
900 #[arg(long)]
902 wallet: Option<String>,
903 },
904 Restore {
906 archive: String,
908 #[arg(long = "dangerously-overwrite")]
910 dangerously_overwrite: bool,
911 #[arg(long = "pg-url-secret")]
913 pg_url_secret: Option<String>,
914 },
915}
916
917#[derive(Subcommand)]
918enum EvmWalletAction {
919 Create {
921 #[arg(long = "evm-rpc-endpoint", required = true)]
923 evm_rpc_endpoint: Vec<String>,
924 #[arg(long = "chain-id", default_value_t = 8453)]
926 chain_id: u64,
927 #[arg(long)]
929 label: Option<String>,
930 },
931 Close {
933 #[arg(long)]
935 wallet: String,
936 #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
938 dangerously_skip_balance_check_and_may_lose_money: bool,
939 },
940 List,
942 #[command(name = "dangerously-show-seed")]
944 ShowSeed {
945 #[arg(long)]
947 wallet: String,
948 },
949}
950
951#[derive(Subcommand)]
952enum BtcCommand {
953 Wallet {
955 #[command(subcommand)]
956 action: BtcWalletAction,
957 },
958 #[command(name = "send")]
960 Send {
961 #[arg(long)]
963 to: String,
964 #[arg(long = "amount-sats")]
966 amount_sats: u64,
967 #[command(flatten)]
968 common: CommonSendArgs,
969 },
970 #[command(name = "receive")]
972 Receive {
973 #[arg(long = "wait-sync-limit")]
975 wait_sync_limit: Option<usize>,
976 #[command(flatten)]
977 common: CommonReceiveArgs,
978 },
979 Balance {
981 #[arg(long)]
983 wallet: Option<String>,
984 },
985 Limit {
987 #[arg(long)]
989 wallet: Option<String>,
990 #[command(subcommand)]
991 action: SimpleLimitAction,
992 },
993 Config {
995 #[arg(long)]
997 wallet: String,
998 #[command(subcommand)]
999 action: SimpleWalletConfigAction,
1000 },
1001 Backup {
1003 #[arg(long)]
1005 output: Option<String>,
1006 #[arg(long)]
1008 wallet: Option<String>,
1009 },
1010 Restore {
1012 archive: String,
1014 #[arg(long = "dangerously-overwrite")]
1016 dangerously_overwrite: bool,
1017 #[arg(long = "pg-url-secret")]
1019 pg_url_secret: Option<String>,
1020 },
1021}
1022
1023#[derive(Subcommand)]
1024enum BtcWalletAction {
1025 Create {
1027 #[arg(long = "btc-network", default_value = "mainnet")]
1029 btc_network: String,
1030 #[arg(long = "btc-address-type", default_value = "taproot")]
1032 btc_address_type: String,
1033 #[arg(long = "btc-backend", value_enum)]
1035 btc_backend: Option<CliBtcBackend>,
1036 #[arg(long = "btc-esplora-url")]
1038 btc_esplora_url: Option<String>,
1039 #[arg(long = "btc-core-url")]
1041 btc_core_url: Option<String>,
1042 #[arg(long = "btc-core-auth-secret")]
1044 btc_core_auth_secret: Option<String>,
1045 #[arg(long = "btc-electrum-url")]
1047 btc_electrum_url: Option<String>,
1048 #[arg(long = "mnemonic-secret")]
1050 mnemonic_secret: Option<String>,
1051 #[arg(long)]
1053 label: Option<String>,
1054 },
1055 Close {
1057 #[arg(long)]
1059 wallet: String,
1060 #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
1062 dangerously_skip_balance_check_and_may_lose_money: bool,
1063 },
1064 List,
1066 #[command(name = "dangerously-show-seed")]
1068 ShowSeed {
1069 #[arg(long)]
1071 wallet: String,
1072 },
1073}
1074
1075#[derive(Subcommand)]
1076enum LnWalletAction {
1077 Create {
1079 #[arg(long, value_enum)]
1081 backend: CliLnBackend,
1082 #[arg(long = "nwc-uri-secret")]
1084 nwc_uri_secret: Option<String>,
1085 #[arg(long)]
1087 endpoint: Option<String>,
1088 #[arg(long = "password-secret")]
1090 password_secret: Option<String>,
1091 #[arg(long = "admin-key-secret")]
1093 admin_key_secret: Option<String>,
1094 #[arg(long)]
1096 label: Option<String>,
1097 },
1098 Close {
1100 #[arg(long)]
1102 wallet: String,
1103 #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
1105 dangerously_skip_balance_check_and_may_lose_money: bool,
1106 },
1107 List,
1109 #[command(name = "dangerously-show-seed")]
1111 ShowSeed {
1112 #[arg(long)]
1114 wallet: String,
1115 },
1116}
1117
1118#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
1119enum CliLnBackend {
1120 Nwc,
1121 Phoenixd,
1122 Lnbits,
1123}
1124
1125impl From<CliLnBackend> for LnWalletBackend {
1126 fn from(value: CliLnBackend) -> Self {
1127 match value {
1128 CliLnBackend::Nwc => LnWalletBackend::Nwc,
1129 CliLnBackend::Phoenixd => LnWalletBackend::Phoenixd,
1130 CliLnBackend::Lnbits => LnWalletBackend::Lnbits,
1131 }
1132 }
1133}
1134
1135#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
1136enum CliBtcBackend {
1137 Esplora,
1138 #[value(name = "core-rpc")]
1139 CoreRpc,
1140 Electrum,
1141}
1142
1143impl From<CliBtcBackend> for BtcBackend {
1144 fn from(value: CliBtcBackend) -> Self {
1145 match value {
1146 CliBtcBackend::Esplora => BtcBackend::Esplora,
1147 CliBtcBackend::CoreRpc => BtcBackend::CoreRpc,
1148 CliBtcBackend::Electrum => BtcBackend::Electrum,
1149 }
1150 }
1151}
1152
1153#[derive(Subcommand)]
1154enum WalletTopAction {
1155 List {
1157 #[arg(long, value_enum)]
1159 network: Option<CliNetwork>,
1160 },
1161}
1162
1163#[derive(Subcommand)]
1164enum HistoryAction {
1165 List {
1167 #[arg(long)]
1169 wallet: Option<String>,
1170 #[arg(long, value_enum)]
1172 network: Option<CliNetwork>,
1173 #[arg(long = "onchain-memo")]
1175 onchain_memo: Option<String>,
1176 #[arg(long, default_value_t = 20)]
1178 limit: usize,
1179 #[arg(long, default_value_t = 0)]
1181 offset: usize,
1182 #[arg(long = "since-epoch-s")]
1184 since_epoch_s: Option<u64>,
1185 #[arg(long = "until-epoch-s")]
1187 until_epoch_s: Option<u64>,
1188 },
1189 Status {
1191 #[arg(long = "transaction-id")]
1193 transaction_id: String,
1194 },
1195 Update {
1197 #[arg(long)]
1199 wallet: Option<String>,
1200 #[arg(long, value_enum)]
1202 network: Option<CliNetwork>,
1203 #[arg(long, default_value_t = 200)]
1205 limit: usize,
1206 },
1207}
1208
1209#[derive(Subcommand)]
1210enum LimitAction {
1211 Remove {
1213 #[arg(long)]
1215 rule_id: String,
1216 },
1217 List,
1219}
1220
1221#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
1222enum CliNetwork {
1223 Ln,
1224 Sol,
1225 Evm,
1226 Cashu,
1227 Btc,
1228}
1229
1230impl From<CliNetwork> for Network {
1231 fn from(c: CliNetwork) -> Self {
1232 match c {
1233 CliNetwork::Ln => Network::Ln,
1234 CliNetwork::Sol => Network::Sol,
1235 CliNetwork::Evm => Network::Evm,
1236 CliNetwork::Cashu => Network::Cashu,
1237 CliNetwork::Btc => Network::Btc,
1238 }
1239 }
1240}
1241
1242#[derive(Parser)]
1247#[command(no_binary_name = true, name = "afpay")]
1248struct SubcommandParser {
1249 #[command(subcommand)]
1250 command: PayCommand,
1251}
1252
1253#[cfg(any(feature = "interactive", test))]
1256pub fn parse_subcommand(args: &[&str], id: &str) -> Result<Input, String> {
1257 let parsed = SubcommandParser::try_parse_from(args).map_err(|e| e.to_string())?;
1258 command_to_input(parsed.command, id)
1259}
1260
1261#[cfg(feature = "interactive")]
1263pub fn subcommand_help(args: &[&str]) -> String {
1264 match SubcommandParser::try_parse_from(args) {
1265 Ok(_) => String::new(),
1266 Err(e) => e.to_string(),
1267 }
1268}
1269
1270#[cfg(feature = "interactive")]
1272#[derive(Debug, Clone)]
1273pub struct ArgInfo {
1274 pub long: String,
1276 pub help: String,
1278 pub required: bool,
1280 pub is_flag: bool,
1282 pub positional_index: Option<usize>,
1284}
1285
1286#[cfg(feature = "interactive")]
1293pub fn subcommand_args(path: &[&str]) -> Vec<ArgInfo> {
1294 use clap::CommandFactory;
1295 let root = SubcommandParser::command();
1296 let cmd = walk_subcommands(&root, path);
1297 let Some(cmd) = cmd else {
1298 return vec![];
1299 };
1300 cmd.get_arguments()
1301 .filter(|a| !a.is_hide_set())
1302 .filter(|a| {
1303 let id = a.get_id().as_str();
1304 id != "help" && id != "version"
1305 })
1306 .map(|a| {
1307 let long = a
1308 .get_long()
1309 .map(|s| s.to_string())
1310 .unwrap_or_else(|| a.get_id().to_string());
1311 let help = a.get_help().map(|s| s.to_string()).unwrap_or_default();
1312 let required = a.is_required_set();
1313 let is_flag = !a.get_action().takes_values();
1314 let positional_index = a.get_index();
1315 ArgInfo {
1316 long,
1317 help,
1318 required,
1319 is_flag,
1320 positional_index,
1321 }
1322 })
1323 .collect()
1324}
1325
1326#[cfg(feature = "interactive")]
1327fn walk_subcommands<'a>(cmd: &'a clap::Command, path: &[&str]) -> Option<&'a clap::Command> {
1328 if path.is_empty() {
1329 return Some(cmd);
1330 }
1331 for sub in cmd.get_subcommands() {
1332 if sub.get_name() == path[0] {
1333 return walk_subcommands(sub, &path[1..]);
1334 }
1335 }
1336 None
1337}
1338
1339pub fn parse_args() -> Result<Mode, CliError> {
1344 let raw: Vec<String> = std::env::args().collect();
1345 let startup_requested = raw.iter().any(|a| a == "--log");
1346
1347 if raw.iter().any(|a| a == "--help" || a == "-h") {
1349 let subcommand_path: Vec<&str> = raw[1..]
1350 .iter()
1351 .take_while(|a| !a.starts_with('-'))
1352 .map(|s| s.as_str())
1353 .collect();
1354 let cmd = AfpayCli::command();
1355 let _ = writeln!(
1356 std::io::stdout(),
1357 "{}",
1358 agent_first_data::cli_render_help(&cmd, &subcommand_path)
1359 );
1360 std::process::exit(0);
1361 }
1362 if raw.iter().any(|a| a == "--help-markdown") {
1364 let subcommand_path: Vec<&str> = raw[1..]
1365 .iter()
1366 .take_while(|a| !a.starts_with('-'))
1367 .map(|s| s.as_str())
1368 .collect();
1369 let cmd = AfpayCli::command();
1370 let _ = writeln!(
1371 std::io::stdout(),
1372 "{}",
1373 agent_first_data::cli_render_help_markdown(&cmd, &subcommand_path)
1374 );
1375 std::process::exit(0);
1376 }
1377
1378 let cli = match AfpayCli::try_parse_from(&raw) {
1379 Ok(c) => c,
1380 Err(e) => {
1381 use clap::error::ErrorKind;
1382 if matches!(e.kind(), ErrorKind::DisplayVersion) {
1383 let _ = writeln!(std::io::stdout(), "{e}");
1384 std::process::exit(0);
1385 }
1386 return Err(e.to_string().into());
1387 }
1388 };
1389 let output = agent_first_data::cli_parse_output(&cli.output).map_err(CliError::from)?;
1390 let log = agent_first_data::cli_parse_log_filters(&cli.log);
1391 let startup_args = build_startup_args(&cli);
1392
1393 match cli.mode {
1394 RuntimeMode::Pipe => {
1395 return Ok(Mode::Pipe(PipeInit {
1396 output,
1397 log,
1398 data_dir: cli.data_dir,
1399 startup_argv: raw.clone(),
1400 startup_args,
1401 startup_requested,
1402 }));
1403 }
1404 RuntimeMode::Interactive => {
1405 let (rpc_endpoint, rpc_secret) =
1406 resolve_rpc_args(cli.rpc_endpoint, cli.rpc_secret, cli.data_dir.as_deref());
1407 return Ok(Mode::Interactive(InteractiveInit {
1408 frontend: InteractiveFrontend::Interactive,
1409 output,
1410 log,
1411 data_dir: cli.data_dir,
1412 rpc_endpoint,
1413 rpc_secret,
1414 }));
1415 }
1416 RuntimeMode::Tui => {
1417 let (rpc_endpoint, rpc_secret) =
1418 resolve_rpc_args(cli.rpc_endpoint, cli.rpc_secret, cli.data_dir.as_deref());
1419 return Ok(Mode::Interactive(InteractiveInit {
1420 frontend: InteractiveFrontend::Tui,
1421 output,
1422 log,
1423 data_dir: cli.data_dir,
1424 rpc_endpoint,
1425 rpc_secret,
1426 }));
1427 }
1428 RuntimeMode::Rpc => {
1429 #[cfg(feature = "rpc")]
1430 {
1431 return Ok(Mode::Rpc(RpcInit {
1432 listen: cli.rpc_listen,
1433 rpc_secret: cli.rpc_secret,
1434 allow_public_listen: cli.public_listen,
1435 log,
1436 data_dir: cli.data_dir,
1437 startup_argv: raw.clone(),
1438 startup_args: startup_args.clone(),
1439 startup_requested,
1440 }));
1441 }
1442 #[cfg(not(feature = "rpc"))]
1443 {
1444 return Ok(Mode::Rpc(RpcStub));
1445 }
1446 }
1447 #[cfg(feature = "rest")]
1448 RuntimeMode::Rest => {
1449 return Ok(Mode::Rest(RestInit {
1450 listen: cli.rest_listen,
1451 api_key: cli.rest_api_key,
1452 allow_public_listen: cli.public_listen,
1453 log,
1454 data_dir: cli.data_dir,
1455 startup_argv: raw.clone(),
1456 startup_args: startup_args.clone(),
1457 startup_requested,
1458 }));
1459 }
1460 RuntimeMode::Cli => {}
1461 }
1462
1463 let command = match cli.command {
1464 Some(PayCommand::Global {
1465 action:
1466 GlobalCommand::Backup {
1467 output: out,
1468 extra_dir,
1469 },
1470 }) => {
1471 return Ok(Mode::Data(DataOp {
1472 kind: DataOpKind::GlobalBackup {
1473 output_path: out,
1474 extra_dirs: extra_dir,
1475 },
1476 data_dir: cli.data_dir,
1477 output,
1478 }));
1479 }
1480 Some(PayCommand::Global {
1481 action:
1482 GlobalCommand::Restore {
1483 archive,
1484 dangerously_overwrite,
1485 pg_url_secret,
1486 extra_dir,
1487 },
1488 }) => {
1489 return Ok(Mode::Data(DataOp {
1490 kind: DataOpKind::GlobalRestore {
1491 archive_path: archive,
1492 overwrite: dangerously_overwrite,
1493 pg_url_secret,
1494 extra_dirs: extra_dir,
1495 },
1496 data_dir: cli.data_dir,
1497 output,
1498 }));
1499 }
1500 Some(PayCommand::Cashu {
1501 action:
1502 CashuCommand::Backup {
1503 output: out,
1504 wallet,
1505 },
1506 }) => {
1507 return Ok(Mode::Data(DataOp {
1508 kind: DataOpKind::NetworkBackup {
1509 network: Network::Cashu,
1510 output_path: out,
1511 wallet,
1512 },
1513 data_dir: cli.data_dir,
1514 output,
1515 }));
1516 }
1517 Some(PayCommand::Cashu {
1518 action:
1519 CashuCommand::Restore {
1520 archive,
1521 dangerously_overwrite,
1522 pg_url_secret,
1523 },
1524 }) => {
1525 return Ok(Mode::Data(DataOp {
1526 kind: DataOpKind::NetworkRestore {
1527 network: Network::Cashu,
1528 archive_path: archive,
1529 overwrite: dangerously_overwrite,
1530 pg_url_secret,
1531 },
1532 data_dir: cli.data_dir,
1533 output,
1534 }));
1535 }
1536 Some(PayCommand::Ln {
1537 action:
1538 LnCommand::Backup {
1539 output: out,
1540 wallet,
1541 },
1542 }) => {
1543 return Ok(Mode::Data(DataOp {
1544 kind: DataOpKind::NetworkBackup {
1545 network: Network::Ln,
1546 output_path: out,
1547 wallet,
1548 },
1549 data_dir: cli.data_dir,
1550 output,
1551 }));
1552 }
1553 Some(PayCommand::Ln {
1554 action:
1555 LnCommand::Restore {
1556 archive,
1557 dangerously_overwrite,
1558 pg_url_secret,
1559 },
1560 }) => {
1561 return Ok(Mode::Data(DataOp {
1562 kind: DataOpKind::NetworkRestore {
1563 network: Network::Ln,
1564 archive_path: archive,
1565 overwrite: dangerously_overwrite,
1566 pg_url_secret,
1567 },
1568 data_dir: cli.data_dir,
1569 output,
1570 }));
1571 }
1572 Some(PayCommand::Sol {
1573 action:
1574 SolCommand::Backup {
1575 output: out,
1576 wallet,
1577 },
1578 }) => {
1579 return Ok(Mode::Data(DataOp {
1580 kind: DataOpKind::NetworkBackup {
1581 network: Network::Sol,
1582 output_path: out,
1583 wallet,
1584 },
1585 data_dir: cli.data_dir,
1586 output,
1587 }));
1588 }
1589 Some(PayCommand::Sol {
1590 action:
1591 SolCommand::Restore {
1592 archive,
1593 dangerously_overwrite,
1594 pg_url_secret,
1595 },
1596 }) => {
1597 return Ok(Mode::Data(DataOp {
1598 kind: DataOpKind::NetworkRestore {
1599 network: Network::Sol,
1600 archive_path: archive,
1601 overwrite: dangerously_overwrite,
1602 pg_url_secret,
1603 },
1604 data_dir: cli.data_dir,
1605 output,
1606 }));
1607 }
1608 Some(PayCommand::Evm {
1609 action:
1610 EvmCommand::Backup {
1611 output: out,
1612 wallet,
1613 },
1614 }) => {
1615 return Ok(Mode::Data(DataOp {
1616 kind: DataOpKind::NetworkBackup {
1617 network: Network::Evm,
1618 output_path: out,
1619 wallet,
1620 },
1621 data_dir: cli.data_dir,
1622 output,
1623 }));
1624 }
1625 Some(PayCommand::Evm {
1626 action:
1627 EvmCommand::Restore {
1628 archive,
1629 dangerously_overwrite,
1630 pg_url_secret,
1631 },
1632 }) => {
1633 return Ok(Mode::Data(DataOp {
1634 kind: DataOpKind::NetworkRestore {
1635 network: Network::Evm,
1636 archive_path: archive,
1637 overwrite: dangerously_overwrite,
1638 pg_url_secret,
1639 },
1640 data_dir: cli.data_dir,
1641 output,
1642 }));
1643 }
1644 Some(PayCommand::Btc {
1645 action:
1646 BtcCommand::Backup {
1647 output: out,
1648 wallet,
1649 },
1650 }) => {
1651 return Ok(Mode::Data(DataOp {
1652 kind: DataOpKind::NetworkBackup {
1653 network: Network::Btc,
1654 output_path: out,
1655 wallet,
1656 },
1657 data_dir: cli.data_dir,
1658 output,
1659 }));
1660 }
1661 Some(PayCommand::Btc {
1662 action:
1663 BtcCommand::Restore {
1664 archive,
1665 dangerously_overwrite,
1666 pg_url_secret,
1667 },
1668 }) => {
1669 return Ok(Mode::Data(DataOp {
1670 kind: DataOpKind::NetworkRestore {
1671 network: Network::Btc,
1672 archive_path: archive,
1673 overwrite: dangerously_overwrite,
1674 pg_url_secret,
1675 },
1676 data_dir: cli.data_dir,
1677 output,
1678 }));
1679 }
1680 Some(cmd) => cmd,
1681 None => {
1682 return Err("no subcommand provided; run with --help for usage"
1683 .to_string()
1684 .into())
1685 }
1686 };
1687
1688 let request_id =
1689 crate::store::wallet::generate_request_identifier().map_err(|e| e.to_string())?;
1690 let input = command_to_input(command, &request_id)?;
1691
1692 let (rpc_endpoint, rpc_secret) =
1693 resolve_rpc_args(cli.rpc_endpoint, cli.rpc_secret, cli.data_dir.as_deref());
1694
1695 Ok(Mode::Cli(Box::new(CliRequest {
1696 input,
1697 output,
1698 log,
1699 data_dir: cli.data_dir,
1700 rpc_endpoint,
1701 rpc_secret,
1702 startup_argv: raw,
1703 startup_args,
1704 startup_requested,
1705 dry_run: cli.dry_run,
1706 })))
1707}
1708
1709fn validate_sol_address(to: &str) -> Result<(), String> {
1714 if to.starts_with("0x") {
1715 return Err(format!(
1716 "invalid Solana address '{to}': looks like an EVM address (0x prefix). \
1717 Solana addresses are base58-encoded"
1718 ));
1719 }
1720 if !(32..=44).contains(&to.len()) {
1721 return Err(format!(
1722 "invalid Solana address '{to}': expected 32-44 base58 characters, got {}",
1723 to.len()
1724 ));
1725 }
1726 if let Some(bad) = to
1728 .chars()
1729 .find(|c| !c.is_ascii_alphanumeric() || *c == '0' || *c == 'O' || *c == 'I' || *c == 'l')
1730 {
1731 return Err(format!(
1732 "invalid Solana address '{to}': illegal base58 character '{bad}'"
1733 ));
1734 }
1735 Ok(())
1736}
1737
1738fn validate_evm_address(to: &str) -> Result<(), String> {
1739 if !to.starts_with("0x") {
1740 return Err(format!("invalid EVM address '{to}': must start with 0x"));
1741 }
1742 let hex_part = &to[2..];
1743 if hex_part.len() != 40 {
1744 return Err(format!(
1745 "invalid EVM address '{to}': expected 0x + 40 hex characters, got 0x + {}",
1746 hex_part.len()
1747 ));
1748 }
1749 if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
1750 return Err(format!(
1751 "invalid EVM address '{to}': contains non-hex characters"
1752 ));
1753 }
1754 Ok(())
1755}
1756
1757fn validate_bolt11(to: &str) -> Result<(), String> {
1758 let lower = to.to_lowercase();
1759 if !lower.starts_with("lnbc")
1760 && !lower.starts_with("lntb")
1761 && !lower.starts_with("lnbcrt")
1762 && !lower.starts_with("lno1")
1763 {
1764 return Err(format!(
1765 "invalid Lightning invoice/offer '{to}': must start with lnbc, lntb, lnbcrt, or lno1"
1766 ));
1767 }
1768 Ok(())
1769}
1770
1771fn validate_token_not_contract(token: &str) -> Result<(), String> {
1772 if token.starts_with("0x")
1773 || (token.len() > 40 && token.chars().all(|c| c.is_ascii_alphanumeric()))
1774 {
1775 return Err(format!(
1776 "raw contract address not accepted for --token; register it first: \
1777 afpay <network> config --wallet <id> token-add --symbol <name> --address {token}"
1778 ));
1779 }
1780 Ok(())
1781}
1782
1783fn command_to_input(cmd: PayCommand, id: &str) -> Result<Input, String> {
1784 match cmd {
1785 PayCommand::Global { action } => match action {
1786 GlobalCommand::Limit { action } => match action {
1787 GlobalLimitAction::Add { window, max_spend } => {
1788 let window_s = parse_window(&window)?;
1789 Ok(Input::LimitAdd {
1790 id: id.to_string(),
1791 limit: SpendLimit {
1792 rule_id: None,
1793 scope: SpendScope::GlobalUsdCents,
1794 network: None,
1795 wallet: None,
1796 window_s,
1797 max_spend,
1798 token: None,
1799 },
1800 })
1801 }
1802 },
1803 GlobalCommand::Config { action } => match action {
1804 GlobalConfigAction::Show => Ok(Input::ConfigShow { id: id.to_string() }),
1805 GlobalConfigAction::Set { log } => Ok(Input::Config(ConfigPatch {
1806 data_dir: None,
1807 log,
1808 exchange_rate: None,
1809 afpay_rpc: None,
1810 providers: None,
1811 })),
1812 },
1813 GlobalCommand::Backup { .. } | GlobalCommand::Restore { .. } => {
1814 unreachable!("global backup/restore handled before dispatch")
1815 }
1816 },
1817 PayCommand::Cashu { action } => cashu_command_to_input(action, id),
1818 PayCommand::Ln { action } => ln_command_to_input(action, id),
1819 PayCommand::Sol { action } => sol_command_to_input(action, id),
1820 PayCommand::Evm { action } => evm_command_to_input(action, id),
1821 PayCommand::Btc { action } => btc_command_to_input(action, id),
1822 PayCommand::Wallet { action } => match action {
1823 WalletTopAction::List { network } => Ok(Input::WalletList {
1824 id: id.to_string(),
1825 network: network.map(Into::into),
1826 }),
1827 },
1828 PayCommand::Balance {
1829 wallet,
1830 network,
1831 cashu_check,
1832 } => Ok(Input::Balance {
1833 id: id.to_string(),
1834 wallet: wallet.filter(|s| !s.is_empty()),
1835 network: network.map(Into::into),
1836 check: cashu_check,
1837 }),
1838 PayCommand::History { action } => match action {
1839 HistoryAction::List {
1840 wallet,
1841 network,
1842 onchain_memo,
1843 limit,
1844 offset,
1845 since_epoch_s,
1846 until_epoch_s,
1847 } => Ok(Input::HistoryList {
1848 id: id.to_string(),
1849 wallet,
1850 network: network.map(Into::into),
1851 onchain_memo,
1852 limit: Some(limit),
1853 offset: Some(offset),
1854 since_epoch_s,
1855 until_epoch_s,
1856 }),
1857 HistoryAction::Status { transaction_id } => Ok(Input::HistoryStatus {
1858 id: id.to_string(),
1859 transaction_id,
1860 }),
1861 HistoryAction::Update {
1862 wallet,
1863 network,
1864 limit,
1865 } => Ok(Input::HistoryUpdate {
1866 id: id.to_string(),
1867 wallet,
1868 network: network.map(Into::into),
1869 limit: Some(limit),
1870 }),
1871 },
1872 PayCommand::Limit { action } => match action {
1873 LimitAction::Remove { rule_id } => Ok(Input::LimitRemove {
1874 id: id.to_string(),
1875 rule_id,
1876 }),
1877 LimitAction::List => Ok(Input::LimitList { id: id.to_string() }),
1878 },
1879 }
1880}
1881
1882fn simple_config_to_input(
1883 wallet: String,
1884 action: SimpleWalletConfigAction,
1885 id: &str,
1886) -> Result<Input, String> {
1887 match action {
1888 SimpleWalletConfigAction::Show => Ok(Input::WalletConfigShow {
1889 id: id.to_string(),
1890 wallet,
1891 }),
1892 SimpleWalletConfigAction::Set { label } => Ok(Input::WalletConfigSet {
1893 id: id.to_string(),
1894 wallet,
1895 label,
1896 rpc_endpoints: vec![],
1897 chain_id: None,
1898 }),
1899 }
1900}
1901
1902fn sol_config_to_input(
1903 wallet: String,
1904 action: SolWalletConfigAction,
1905 id: &str,
1906) -> Result<Input, String> {
1907 match action {
1908 SolWalletConfigAction::Show => Ok(Input::WalletConfigShow {
1909 id: id.to_string(),
1910 wallet,
1911 }),
1912 SolWalletConfigAction::Set {
1913 label,
1914 rpc_endpoint,
1915 } => Ok(Input::WalletConfigSet {
1916 id: id.to_string(),
1917 wallet,
1918 label,
1919 rpc_endpoints: rpc_endpoint,
1920 chain_id: None,
1921 }),
1922 SolWalletConfigAction::TokenAdd {
1923 symbol,
1924 address,
1925 decimals,
1926 } => Ok(Input::WalletConfigTokenAdd {
1927 id: id.to_string(),
1928 wallet,
1929 symbol,
1930 address,
1931 decimals,
1932 }),
1933 SolWalletConfigAction::TokenRemove { symbol } => Ok(Input::WalletConfigTokenRemove {
1934 id: id.to_string(),
1935 wallet,
1936 symbol,
1937 }),
1938 }
1939}
1940
1941fn evm_config_to_input(
1942 wallet: String,
1943 action: EvmWalletConfigAction,
1944 id: &str,
1945) -> Result<Input, String> {
1946 match action {
1947 EvmWalletConfigAction::Show => Ok(Input::WalletConfigShow {
1948 id: id.to_string(),
1949 wallet,
1950 }),
1951 EvmWalletConfigAction::Set {
1952 label,
1953 rpc_endpoint,
1954 chain_id,
1955 } => Ok(Input::WalletConfigSet {
1956 id: id.to_string(),
1957 wallet,
1958 label,
1959 rpc_endpoints: rpc_endpoint,
1960 chain_id,
1961 }),
1962 EvmWalletConfigAction::TokenAdd {
1963 symbol,
1964 address,
1965 decimals,
1966 } => Ok(Input::WalletConfigTokenAdd {
1967 id: id.to_string(),
1968 wallet,
1969 symbol,
1970 address,
1971 decimals,
1972 }),
1973 EvmWalletConfigAction::TokenRemove { symbol } => Ok(Input::WalletConfigTokenRemove {
1974 id: id.to_string(),
1975 wallet,
1976 symbol,
1977 }),
1978 }
1979}
1980
1981fn simple_limit_to_input(
1982 network: Network,
1983 wallet: Option<String>,
1984 action: SimpleLimitAction,
1985 id: &str,
1986) -> Result<Input, String> {
1987 match action {
1988 SimpleLimitAction::Add { window, max_spend } => {
1989 let window_s = parse_window(&window)?;
1990 let (scope, wallet) = match wallet {
1991 Some(w) => (SpendScope::Wallet, Some(w)),
1992 None => (SpendScope::Network, None),
1993 };
1994 Ok(Input::LimitAdd {
1995 id: id.to_string(),
1996 limit: SpendLimit {
1997 rule_id: None,
1998 scope,
1999 network: Some(network.to_string()),
2000 wallet,
2001 window_s,
2002 max_spend,
2003 token: None,
2004 },
2005 })
2006 }
2007 }
2008}
2009
2010fn token_limit_to_input(
2011 network: Network,
2012 wallet: Option<String>,
2013 action: TokenLimitAction,
2014 id: &str,
2015) -> Result<Input, String> {
2016 match action {
2017 TokenLimitAction::Add {
2018 token,
2019 window,
2020 max_spend,
2021 } => {
2022 let window_s = parse_window(&window)?;
2023 let (scope, wallet) = match wallet {
2024 Some(w) => (SpendScope::Wallet, Some(w)),
2025 None => (SpendScope::Network, None),
2026 };
2027 Ok(Input::LimitAdd {
2028 id: id.to_string(),
2029 limit: SpendLimit {
2030 rule_id: None,
2031 scope,
2032 network: Some(network.to_string()),
2033 wallet,
2034 window_s,
2035 max_spend,
2036 token,
2037 },
2038 })
2039 }
2040 }
2041}
2042
2043fn cashu_command_to_input(cmd: CashuCommand, id: &str) -> Result<Input, String> {
2044 match cmd {
2045 CashuCommand::Send {
2046 common,
2047 amount_sats,
2048 mint_url,
2049 to,
2050 } => {
2051 if to.is_some() {
2052 return Err("cashu send generates a P2P cashu token — it does not send to an address. To pay a Lightning invoice, use: cashu send-to-ln --to <bolt11>".to_string());
2053 }
2054 Ok(Input::CashuSend {
2055 id: id.to_string(),
2056 wallet: common.wallet.filter(|s| !s.is_empty()),
2057 amount: Amount {
2058 value: amount_sats,
2059 token: "sats".to_string(),
2060 },
2061 onchain_memo: common.onchain_memo,
2062 local_memo: memo_vec_to_map(common.local_memo),
2063 mints: if mint_url.is_empty() {
2064 None
2065 } else {
2066 Some(mint_url)
2067 },
2068 })
2069 }
2070 CashuCommand::Receive { wallet, token } => Ok(Input::CashuReceive {
2071 id: id.to_string(),
2072 wallet: wallet.filter(|s| !s.is_empty()),
2073 token,
2074 }),
2075 CashuCommand::SendToLn { common, to } => Ok(Input::Send {
2076 id: id.to_string(),
2077 wallet: common.wallet.filter(|s| !s.is_empty()),
2078 network: Some(Network::Cashu),
2079 to,
2080 onchain_memo: common.onchain_memo,
2081 local_memo: memo_vec_to_map(common.local_memo),
2082 mints: None,
2083 }),
2084 CashuCommand::ReceiveFromLn {
2085 common,
2086 amount_sats,
2087 onchain_memo,
2088 } => {
2089 let resolved = amount_sats.map(|v| Amount {
2090 value: v,
2091 token: "sats".to_string(),
2092 });
2093 Ok(Input::Receive {
2094 id: id.to_string(),
2095 wallet: common.wallet.filter(|s| !s.is_empty()).unwrap_or_default(),
2096 network: Some(Network::Cashu),
2097 amount: resolved,
2098 onchain_memo,
2099 wait_until_paid: common.wait,
2100 wait_timeout_s: common.wait_timeout_s,
2101 wait_poll_interval_ms: common.wait_poll_interval_ms,
2102 wait_sync_limit: None,
2103 write_qr_svg_file: common.qr_svg_file,
2104 min_confirmations: None,
2105 reference: None,
2106 })
2107 }
2108 CashuCommand::ReceiveFromLnClaim {
2109 wallet,
2110 ln_quote_id,
2111 } => Ok(Input::ReceiveClaim {
2112 id: id.to_string(),
2113 wallet,
2114 quote_id: ln_quote_id,
2115 }),
2116 CashuCommand::Balance { wallet, check } => Ok(Input::Balance {
2117 id: id.to_string(),
2118 wallet: wallet.filter(|s| !s.is_empty()),
2119 network: Some(Network::Cashu),
2120 check,
2121 }),
2122 CashuCommand::Wallet { action } => match action {
2123 CashuWalletAction::Create {
2124 mint_url,
2125 label,
2126 mnemonic_secret,
2127 } => Ok(Input::WalletCreate {
2128 id: id.to_string(),
2129 network: Network::Cashu,
2130 label,
2131 mint_url: Some(mint_url),
2132 rpc_endpoints: vec![],
2133 chain_id: None,
2134 mnemonic_secret,
2135 btc_esplora_url: None,
2136 btc_network: None,
2137 btc_address_type: None,
2138 btc_backend: None,
2139 btc_core_url: None,
2140 btc_core_auth_secret: None,
2141 btc_electrum_url: None,
2142 }),
2143 CashuWalletAction::Close {
2144 wallet,
2145 dangerously_skip_balance_check_and_may_lose_money,
2146 } => Ok(Input::WalletClose {
2147 id: id.to_string(),
2148 wallet,
2149 dangerously_skip_balance_check_and_may_lose_money,
2150 }),
2151 CashuWalletAction::List => Ok(Input::WalletList {
2152 id: id.to_string(),
2153 network: Some(Network::Cashu),
2154 }),
2155 CashuWalletAction::ShowSeed { wallet } => Ok(Input::WalletShowSeed {
2156 id: id.to_string(),
2157 wallet,
2158 }),
2159 CashuWalletAction::Restore { wallet } => Ok(Input::Restore {
2160 id: id.to_string(),
2161 wallet,
2162 }),
2163 },
2164 CashuCommand::Limit { wallet, action } => {
2165 simple_limit_to_input(Network::Cashu, wallet, action, id)
2166 }
2167 CashuCommand::Config { wallet, action } => simple_config_to_input(wallet, action, id),
2168 CashuCommand::Backup { .. } | CashuCommand::Restore { .. } => {
2169 unreachable!("cashu backup/restore handled before dispatch")
2170 }
2171 }
2172}
2173
2174fn ln_command_to_input(cmd: LnCommand, id: &str) -> Result<Input, String> {
2175 match cmd {
2176 LnCommand::Wallet { action } => match action {
2177 LnWalletAction::Create {
2178 backend,
2179 nwc_uri_secret,
2180 endpoint,
2181 password_secret,
2182 admin_key_secret,
2183 label,
2184 } => {
2185 let backend_code: LnWalletBackend = backend.into();
2186 let request = match backend_code {
2187 LnWalletBackend::Nwc => LnWalletCreateRequest {
2188 backend: backend_code,
2189 label,
2190 nwc_uri_secret: Some(
2191 nwc_uri_secret.ok_or("--nwc-uri-secret is required for nwc backend")?,
2192 ),
2193 endpoint: None,
2194 password_secret: None,
2195 admin_key_secret: None,
2196 },
2197 LnWalletBackend::Phoenixd => LnWalletCreateRequest {
2198 backend: backend_code,
2199 label,
2200 nwc_uri_secret: None,
2201 endpoint: Some(
2202 endpoint.ok_or("--endpoint is required for phoenixd backend")?,
2203 ),
2204 password_secret: Some(
2205 password_secret
2206 .ok_or("--password-secret is required for phoenixd backend")?,
2207 ),
2208 admin_key_secret: None,
2209 },
2210 LnWalletBackend::Lnbits => LnWalletCreateRequest {
2211 backend: backend_code,
2212 label,
2213 nwc_uri_secret: None,
2214 endpoint: Some(
2215 endpoint.ok_or("--endpoint is required for lnbits backend")?,
2216 ),
2217 password_secret: None,
2218 admin_key_secret: Some(
2219 admin_key_secret
2220 .ok_or("--admin-key-secret is required for lnbits backend")?,
2221 ),
2222 },
2223 };
2224
2225 Ok(Input::LnWalletCreate {
2226 id: id.to_string(),
2227 request,
2228 })
2229 }
2230 LnWalletAction::Close {
2231 wallet,
2232 dangerously_skip_balance_check_and_may_lose_money,
2233 } => Ok(Input::WalletClose {
2234 id: id.to_string(),
2235 wallet,
2236 dangerously_skip_balance_check_and_may_lose_money,
2237 }),
2238 LnWalletAction::List => Ok(Input::WalletList {
2239 id: id.to_string(),
2240 network: Some(Network::Ln),
2241 }),
2242 LnWalletAction::ShowSeed { wallet } => Ok(Input::WalletShowSeed {
2243 id: id.to_string(),
2244 wallet,
2245 }),
2246 },
2247 LnCommand::Send {
2248 common,
2249 to,
2250 amount_sats,
2251 } => {
2252 if common.onchain_memo.is_some() {
2253 return Err(
2254 "--onchain-memo is not supported for ln; use --local-memo for bookkeeping"
2255 .into(),
2256 );
2257 }
2258 validate_bolt11(&to)?;
2259 let to = if is_bolt12_offer(&to) {
2260 let amt = amount_sats
2261 .ok_or("--amount-sats is required when sending to a bolt12 offer")?;
2262 format!("{to}?amount={amt}")
2263 } else {
2264 if amount_sats.is_some() {
2265 return Err(
2266 "--amount-sats is not accepted for bolt11 invoices; the invoice encodes the amount".into(),
2267 );
2268 }
2269 to
2270 };
2271 Ok(Input::Send {
2272 id: id.to_string(),
2273 wallet: common.wallet.filter(|s| !s.is_empty()),
2274 network: Some(Network::Ln),
2275 to,
2276 onchain_memo: None,
2277 local_memo: memo_vec_to_map(common.local_memo),
2278 mints: None,
2279 })
2280 }
2281 LnCommand::Receive {
2282 common,
2283 amount_sats,
2284 } => Ok(Input::Receive {
2285 id: id.to_string(),
2286 wallet: common.wallet.filter(|s| !s.is_empty()).unwrap_or_default(),
2287 network: Some(Network::Ln),
2288 amount: amount_sats.map(|v| Amount {
2289 value: v,
2290 token: "sats".to_string(),
2291 }),
2292 onchain_memo: None,
2293 wait_until_paid: common.wait,
2294 wait_timeout_s: common.wait_timeout_s,
2295 wait_poll_interval_ms: common.wait_poll_interval_ms,
2296 wait_sync_limit: None,
2297 write_qr_svg_file: common.qr_svg_file,
2298 min_confirmations: None,
2299 reference: None,
2300 }),
2301 LnCommand::Balance { wallet } => Ok(Input::Balance {
2302 id: id.to_string(),
2303 wallet: wallet.filter(|s| !s.is_empty()),
2304 network: Some(Network::Ln),
2305 check: false,
2306 }),
2307 LnCommand::Limit { wallet, action } => {
2308 simple_limit_to_input(Network::Ln, wallet, action, id)
2309 }
2310 LnCommand::Config { wallet, action } => simple_config_to_input(wallet, action, id),
2311 LnCommand::Backup { .. } | LnCommand::Restore { .. } => {
2312 unreachable!("ln backup/restore handled before dispatch")
2313 }
2314 }
2315}
2316
2317fn sol_command_to_input(cmd: SolCommand, id: &str) -> Result<Input, String> {
2318 match cmd {
2319 SolCommand::Wallet { action } => match action {
2320 SolWalletAction::Create {
2321 sol_rpc_endpoint,
2322 label,
2323 } => Ok(Input::WalletCreate {
2324 id: id.to_string(),
2325 network: Network::Sol,
2326 label,
2327 mint_url: None,
2328 rpc_endpoints: sol_rpc_endpoint,
2329 chain_id: None,
2330 mnemonic_secret: None,
2331 btc_esplora_url: None,
2332 btc_network: None,
2333 btc_address_type: None,
2334 btc_backend: None,
2335 btc_core_url: None,
2336 btc_core_auth_secret: None,
2337 btc_electrum_url: None,
2338 }),
2339 SolWalletAction::Close {
2340 wallet,
2341 dangerously_skip_balance_check_and_may_lose_money,
2342 } => Ok(Input::WalletClose {
2343 id: id.to_string(),
2344 wallet,
2345 dangerously_skip_balance_check_and_may_lose_money,
2346 }),
2347 SolWalletAction::List => Ok(Input::WalletList {
2348 id: id.to_string(),
2349 network: Some(Network::Sol),
2350 }),
2351 SolWalletAction::ShowSeed { wallet } => Ok(Input::WalletShowSeed {
2352 id: id.to_string(),
2353 wallet,
2354 }),
2355 },
2356 SolCommand::Send {
2357 common,
2358 to,
2359 amount,
2360 token,
2361 reference,
2362 } => {
2363 validate_sol_address(&to)?;
2364 validate_token_not_contract(&token)?;
2365 let mut target = format!("solana:{to}?amount={amount}&token={token}");
2366 if let Some(ref r) = reference {
2367 target.push_str(&format!("&reference={r}"));
2368 }
2369 Ok(Input::Send {
2370 id: id.to_string(),
2371 wallet: common.wallet.filter(|s| !s.is_empty()),
2372 network: Some(Network::Sol),
2373 to: target,
2374 onchain_memo: common.onchain_memo,
2375 local_memo: memo_vec_to_map(common.local_memo),
2376 mints: None,
2377 })
2378 }
2379 SolCommand::Receive {
2380 common,
2381 onchain_memo,
2382 min_confirmations,
2383 reference,
2384 } => Ok(Input::Receive {
2385 id: id.to_string(),
2386 wallet: common.wallet.filter(|s| !s.is_empty()).unwrap_or_default(),
2387 network: Some(Network::Sol),
2388 amount: None,
2389 onchain_memo: onchain_memo.filter(|s| !s.trim().is_empty()),
2390 wait_until_paid: common.wait,
2391 wait_timeout_s: common.wait_timeout_s,
2392 wait_poll_interval_ms: common.wait_poll_interval_ms,
2393 wait_sync_limit: None,
2394 write_qr_svg_file: common.qr_svg_file,
2395 min_confirmations,
2396 reference,
2397 }),
2398 SolCommand::Balance { wallet } => Ok(Input::Balance {
2399 id: id.to_string(),
2400 wallet: wallet.filter(|s| !s.is_empty()),
2401 network: Some(Network::Sol),
2402 check: false,
2403 }),
2404 SolCommand::Limit { wallet, action } => {
2405 token_limit_to_input(Network::Sol, wallet, action, id)
2406 }
2407 SolCommand::Config { wallet, action } => sol_config_to_input(wallet, action, id),
2408 SolCommand::Backup { .. } | SolCommand::Restore { .. } => {
2409 unreachable!("sol backup/restore handled before dispatch")
2410 }
2411 }
2412}
2413
2414fn evm_command_to_input(cmd: EvmCommand, id: &str) -> Result<Input, String> {
2415 match cmd {
2416 EvmCommand::Wallet { action } => match action {
2417 EvmWalletAction::Create {
2418 evm_rpc_endpoint,
2419 chain_id,
2420 label,
2421 } => Ok(Input::WalletCreate {
2422 id: id.to_string(),
2423 network: Network::Evm,
2424 label,
2425 mint_url: None,
2426 rpc_endpoints: evm_rpc_endpoint,
2427 chain_id: Some(chain_id),
2428 mnemonic_secret: None,
2429 btc_esplora_url: None,
2430 btc_network: None,
2431 btc_address_type: None,
2432 btc_backend: None,
2433 btc_core_url: None,
2434 btc_core_auth_secret: None,
2435 btc_electrum_url: None,
2436 }),
2437 EvmWalletAction::Close {
2438 wallet,
2439 dangerously_skip_balance_check_and_may_lose_money,
2440 } => Ok(Input::WalletClose {
2441 id: id.to_string(),
2442 wallet,
2443 dangerously_skip_balance_check_and_may_lose_money,
2444 }),
2445 EvmWalletAction::List => Ok(Input::WalletList {
2446 id: id.to_string(),
2447 network: Some(Network::Evm),
2448 }),
2449 EvmWalletAction::ShowSeed { wallet } => Ok(Input::WalletShowSeed {
2450 id: id.to_string(),
2451 wallet,
2452 }),
2453 },
2454 EvmCommand::Send {
2455 common,
2456 to,
2457 amount,
2458 token,
2459 } => {
2460 validate_evm_address(&to)?;
2461 validate_token_not_contract(&token)?;
2462 let target = format!("ethereum:{to}?amount={amount}&token={token}");
2463 Ok(Input::Send {
2464 id: id.to_string(),
2465 wallet: common.wallet.filter(|s| !s.is_empty()),
2466 network: Some(Network::Evm),
2467 to: target,
2468 onchain_memo: common.onchain_memo,
2469 local_memo: memo_vec_to_map(common.local_memo),
2470 mints: None,
2471 })
2472 }
2473 EvmCommand::Receive {
2474 common,
2475 onchain_memo,
2476 min_confirmations,
2477 } => {
2478 if common.wait {
2479 return Err(
2480 "evm receive --wait requires --amount; use unified receive command".into(),
2481 );
2482 }
2483 Ok(Input::Receive {
2484 id: id.to_string(),
2485 wallet: common.wallet.filter(|s| !s.is_empty()).unwrap_or_default(),
2486 network: Some(Network::Evm),
2487 amount: None,
2488 onchain_memo,
2489 wait_until_paid: common.wait,
2490 wait_timeout_s: common.wait_timeout_s,
2491 wait_poll_interval_ms: common.wait_poll_interval_ms,
2492 wait_sync_limit: None,
2493 write_qr_svg_file: false,
2494 min_confirmations,
2495 reference: None,
2496 })
2497 }
2498 EvmCommand::Balance { wallet } => Ok(Input::Balance {
2499 id: id.to_string(),
2500 wallet: wallet.filter(|s| !s.is_empty()),
2501 network: Some(Network::Evm),
2502 check: false,
2503 }),
2504 EvmCommand::Limit { wallet, action } => {
2505 token_limit_to_input(Network::Evm, wallet, action, id)
2506 }
2507 EvmCommand::Config { wallet, action } => evm_config_to_input(wallet, action, id),
2508 EvmCommand::Backup { .. } | EvmCommand::Restore { .. } => {
2509 unreachable!("evm backup/restore handled before dispatch")
2510 }
2511 }
2512}
2513
2514fn btc_command_to_input(cmd: BtcCommand, id: &str) -> Result<Input, String> {
2515 match cmd {
2516 BtcCommand::Wallet { action } => match action {
2517 BtcWalletAction::Create {
2518 label,
2519 btc_network,
2520 btc_address_type,
2521 btc_esplora_url,
2522 btc_backend,
2523 btc_core_url,
2524 btc_core_auth_secret,
2525 btc_electrum_url,
2526 mnemonic_secret,
2527 } => Ok(Input::WalletCreate {
2528 id: id.to_string(),
2529 network: Network::Btc,
2530 label,
2531 mint_url: None,
2532 rpc_endpoints: vec![],
2533 chain_id: None,
2534 mnemonic_secret,
2535 btc_esplora_url,
2536 btc_network: Some(btc_network),
2537 btc_address_type: Some(btc_address_type),
2538 btc_backend: btc_backend.map(Into::into),
2539 btc_core_url,
2540 btc_core_auth_secret,
2541 btc_electrum_url,
2542 }),
2543 BtcWalletAction::Close {
2544 wallet,
2545 dangerously_skip_balance_check_and_may_lose_money,
2546 } => Ok(Input::WalletClose {
2547 id: id.to_string(),
2548 wallet,
2549 dangerously_skip_balance_check_and_may_lose_money,
2550 }),
2551 BtcWalletAction::List => Ok(Input::WalletList {
2552 id: id.to_string(),
2553 network: Some(Network::Btc),
2554 }),
2555 BtcWalletAction::ShowSeed { wallet } => Ok(Input::WalletShowSeed {
2556 id: id.to_string(),
2557 wallet,
2558 }),
2559 },
2560 BtcCommand::Send {
2561 common,
2562 to,
2563 amount_sats,
2564 } => {
2565 let target = format!("bitcoin:{to}?amount={amount_sats}");
2566 Ok(Input::Send {
2567 id: id.to_string(),
2568 wallet: common.wallet.filter(|s| !s.is_empty()),
2569 network: Some(Network::Btc),
2570 to: target,
2571 onchain_memo: common.onchain_memo,
2572 local_memo: memo_vec_to_map(common.local_memo),
2573 mints: None,
2574 })
2575 }
2576 BtcCommand::Receive {
2577 common,
2578 wait_sync_limit,
2579 } => Ok(Input::Receive {
2580 id: id.to_string(),
2581 wallet: common.wallet.filter(|s| !s.is_empty()).unwrap_or_default(),
2582 network: Some(Network::Btc),
2583 amount: None,
2584 onchain_memo: None,
2585 wait_until_paid: common.wait,
2586 wait_timeout_s: common.wait_timeout_s,
2587 wait_poll_interval_ms: common.wait_poll_interval_ms,
2588 wait_sync_limit,
2589 write_qr_svg_file: false,
2590 min_confirmations: None,
2591 reference: None,
2592 }),
2593 BtcCommand::Balance { wallet } => Ok(Input::Balance {
2594 id: id.to_string(),
2595 wallet: wallet.filter(|s| !s.is_empty()),
2596 network: Some(Network::Btc),
2597 check: false,
2598 }),
2599 BtcCommand::Limit { wallet, action } => {
2600 simple_limit_to_input(Network::Btc, wallet, action, id)
2601 }
2602 BtcCommand::Config { wallet, action } => simple_config_to_input(wallet, action, id),
2603 BtcCommand::Backup { .. } | BtcCommand::Restore { .. } => {
2604 unreachable!("btc backup/restore handled before dispatch")
2605 }
2606 }
2607}
2608
2609fn parse_window(s: &str) -> Result<u64, String> {
2610 let (num_str, multiplier) = if let Some(n) = s.strip_suffix('d') {
2611 (n, 86400u64)
2612 } else if let Some(n) = s.strip_suffix('h') {
2613 (n, 3600u64)
2614 } else if let Some(n) = s.strip_suffix('m') {
2615 (n, 60u64)
2616 } else {
2617 return Err(format!(
2618 "invalid window '{s}': expected suffix m (minutes), h (hours), or d (days)"
2619 ));
2620 };
2621 let num: u64 = num_str
2622 .parse()
2623 .map_err(|_| format!("invalid window number '{num_str}'"))?;
2624 if num == 0 {
2625 return Err("window cannot be zero".to_string());
2626 }
2627 Ok(num.saturating_mul(multiplier))
2628}
2629
2630fn resolve_rpc_args(
2632 cli_endpoint: Option<String>,
2633 cli_secret: Option<String>,
2634 data_dir: Option<&str>,
2635) -> (Option<String>, Option<String>) {
2636 if cli_endpoint.is_some() {
2637 return (cli_endpoint, cli_secret);
2638 }
2639 let dir = data_dir
2640 .map(|s| s.to_string())
2641 .unwrap_or_else(|| RuntimeConfig::default().data_dir);
2642 let config = RuntimeConfig::load_from_dir(&dir).unwrap_or_default();
2643 if config.rpc_endpoint.is_some() {
2644 return (config.rpc_endpoint, cli_secret.or(config.rpc_secret));
2645 }
2646 (None, cli_secret)
2647}
2648
2649fn build_startup_args(cli: &AfpayCli) -> serde_json::Value {
2650 serde_json::json!({
2651 "mode": format!("{:?}", cli.mode),
2652 "output": cli.output,
2653 "data_dir": cli.data_dir,
2654 "rpc_endpoint": cli.rpc_endpoint,
2655 "rpc_listen": cli.rpc_listen,
2656 "rest_listen": cli.rest_listen,
2657 "public_listen": cli.public_listen,
2658 })
2659}
2660
2661#[cfg(test)]
2662#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2663mod tests {
2664 use super::*;
2665
2666 #[test]
2667 fn parse_window_minutes() {
2668 assert_eq!(parse_window("30m").unwrap(), 1800);
2669 }
2670
2671 #[test]
2672 fn parse_tui_runtime_mode() {
2673 let cli = AfpayCli::try_parse_from(["afpay", "--mode", "tui", "wallet", "list"])
2674 .expect("tui mode should parse");
2675 assert_eq!(cli.mode, RuntimeMode::Tui);
2676 }
2677
2678 #[test]
2679 fn parse_window_hours() {
2680 assert_eq!(parse_window("1h").unwrap(), 3600);
2681 assert_eq!(parse_window("24h").unwrap(), 86400);
2682 }
2683
2684 #[test]
2685 fn parse_window_days() {
2686 assert_eq!(parse_window("7d").unwrap(), 604800);
2687 }
2688
2689 #[test]
2690 fn parse_window_rejects_invalid() {
2691 assert!(parse_window("0h").is_err());
2692 assert!(parse_window("abc").is_err());
2693 assert!(parse_window("10s").is_err());
2694 }
2695
2696 #[test]
2697 fn parse_limit_add_network_scope() {
2698 let input = parse_subcommand(
2699 &[
2700 "cashu",
2701 "limit",
2702 "add",
2703 "--window",
2704 "1h",
2705 "--max-spend",
2706 "10000",
2707 ],
2708 "t_limit_1",
2709 )
2710 .expect("cashu limit add should parse");
2711
2712 match input {
2713 Input::LimitAdd { limit, .. } => {
2714 assert_eq!(limit.scope, SpendScope::Network);
2715 assert_eq!(limit.network.as_deref(), Some("cashu"));
2716 assert_eq!(limit.window_s, 3600);
2717 assert_eq!(limit.max_spend, 10000);
2718 assert!(limit.token.is_none());
2719 }
2720 other => panic!("unexpected input: {other:?}"),
2721 }
2722 }
2723
2724 #[test]
2725 fn parse_limit_add_global_usd_cents_scope() {
2726 let input = parse_subcommand(
2727 &[
2728 "global",
2729 "limit",
2730 "add",
2731 "--window",
2732 "24h",
2733 "--max-spend",
2734 "50000",
2735 ],
2736 "t_limit_2",
2737 )
2738 .expect("global limit add should parse");
2739
2740 match input {
2741 Input::LimitAdd { limit, .. } => {
2742 assert_eq!(limit.scope, SpendScope::GlobalUsdCents);
2743 assert_eq!(limit.window_s, 86400);
2744 assert_eq!(limit.max_spend, 50000);
2745 assert!(limit.token.is_none());
2746 }
2747 other => panic!("unexpected input: {other:?}"),
2748 }
2749 }
2750
2751 #[test]
2752 fn parse_limit_add_network_scope_with_token() {
2753 let input = parse_subcommand(
2754 &[
2755 "evm",
2756 "limit",
2757 "add",
2758 "--token",
2759 "usdc",
2760 "--window",
2761 "24h",
2762 "--max-spend",
2763 "100000000",
2764 ],
2765 "t_limit_2b",
2766 )
2767 .expect("evm limit add with token should parse");
2768
2769 match input {
2770 Input::LimitAdd { limit, .. } => {
2771 assert_eq!(limit.scope, SpendScope::Network);
2772 assert_eq!(limit.network.as_deref(), Some("evm"));
2773 assert_eq!(limit.token.as_deref(), Some("usdc"));
2774 assert_eq!(limit.max_spend, 100000000);
2775 }
2776 other => panic!("unexpected input: {other:?}"),
2777 }
2778 }
2779
2780 #[test]
2781 fn parse_limit_add_wallet_scope() {
2782 let input = parse_subcommand(
2783 &[
2784 "cashu",
2785 "limit",
2786 "--wallet",
2787 "w_abc",
2788 "add",
2789 "--window",
2790 "30m",
2791 "--max-spend",
2792 "5000",
2793 ],
2794 "t_limit_4",
2795 )
2796 .expect("cashu limit --wallet add should parse");
2797
2798 match input {
2799 Input::LimitAdd { limit, .. } => {
2800 assert_eq!(limit.scope, SpendScope::Wallet);
2801 assert_eq!(limit.network.as_deref(), Some("cashu"));
2802 assert_eq!(limit.wallet.as_deref(), Some("w_abc"));
2803 assert_eq!(limit.window_s, 1800);
2804 assert_eq!(limit.max_spend, 5000);
2805 }
2806 other => panic!("unexpected input: {other:?}"),
2807 }
2808 }
2809
2810 #[test]
2811 fn parse_limit_remove() {
2812 let input = parse_subcommand(&["limit", "remove", "--rule-id", "r_1a2b3c4d"], "t_limit_3")
2813 .expect("limit remove should parse");
2814
2815 match input {
2816 Input::LimitRemove { rule_id, .. } => {
2817 assert_eq!(rule_id, "r_1a2b3c4d");
2818 }
2819 other => panic!("unexpected input: {other:?}"),
2820 }
2821 }
2822
2823 #[test]
2824 fn parse_limit_list() {
2825 let input =
2826 parse_subcommand(&["limit", "list"], "t_limit_4").expect("limit list should parse");
2827 assert!(matches!(input, Input::LimitList { .. }));
2828 }
2829
2830 #[test]
2831 fn parse_ln_receive_wallet_optional() {
2832 let input = parse_subcommand(&["ln", "receive", "--amount-sats", "100"], "t_1")
2833 .expect("ln receive should parse without --wallet");
2834
2835 match input {
2836 Input::Receive { wallet, amount, .. } => {
2837 assert_eq!(wallet, "");
2838 assert_eq!(amount.expect("amount").value, 100);
2839 }
2840 other => panic!("unexpected input: {other:?}"),
2841 }
2842 }
2843
2844 #[test]
2845 fn parse_cashu_receive_from_ln_wallet_optional() {
2846 let input = parse_subcommand(&["cashu", "receive-from-ln", "--amount-sats", "100"], "t_2")
2847 .expect("cashu receive-from-ln should parse without --wallet");
2848
2849 match input {
2850 Input::Receive {
2851 wallet,
2852 network,
2853 amount,
2854 ..
2855 } => {
2856 assert_eq!(wallet, "");
2857 assert_eq!(network, Some(Network::Cashu));
2858 assert_eq!(amount.expect("amount").value, 100);
2859 }
2860 other => panic!("unexpected input: {other:?}"),
2861 }
2862 }
2863
2864 #[test]
2865 fn parse_cashu_send_mint_url() {
2866 let input = parse_subcommand(
2867 &[
2868 "cashu",
2869 "send",
2870 "--amount-sats",
2871 "100",
2872 "--cashu-mint",
2873 "https://mint-a.example",
2874 "--cashu-mint",
2875 "https://mint-b.example",
2876 ],
2877 "t_cashu_1",
2878 )
2879 .expect("cashu send --mint-url should parse");
2880
2881 match input {
2882 Input::CashuSend { mints, amount, .. } => {
2883 assert_eq!(amount.value, 100);
2884 assert_eq!(
2885 mints,
2886 Some(vec![
2887 "https://mint-a.example".to_string(),
2888 "https://mint-b.example".to_string()
2889 ])
2890 );
2891 }
2892 other => panic!("unexpected input: {other:?}"),
2893 }
2894 }
2895
2896 #[test]
2897 fn parse_cashu_send_legacy_mint_flag_rejected() {
2898 let err = parse_subcommand(
2899 &[
2900 "cashu",
2901 "send",
2902 "--amount-sats",
2903 "100",
2904 "--mint",
2905 "https://mint-a.example",
2906 ],
2907 "t_cashu_2",
2908 )
2909 .expect_err("legacy --mint should be rejected");
2910
2911 assert!(err.contains("--mint"));
2912 }
2913
2914 #[test]
2915 fn parse_cashu_send_to_flag_hints_send_to_ln() {
2916 let err = parse_subcommand(
2917 &["cashu", "send", "--amount-sats", "100", "--to", "lnbc1..."],
2918 "t_hint",
2919 )
2920 .expect_err("--to on cashu send should be rejected with hint");
2921 assert!(
2922 err.contains("send-to-ln"),
2923 "should suggest send-to-ln: {err}"
2924 );
2925 }
2926
2927 #[test]
2928 fn parse_cashu_wallet_create_with_mnemonic_secret() {
2929 let input = parse_subcommand(
2930 &[
2931 "cashu",
2932 "wallet",
2933 "create",
2934 "--cashu-mint",
2935 "https://mint.example",
2936 "--mnemonic-secret",
2937 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
2938 ],
2939 "t_cashu_create_1",
2940 )
2941 .expect("cashu wallet create --mnemonic-secret should parse");
2942
2943 match input {
2944 Input::WalletCreate {
2945 network,
2946 mint_url,
2947 mnemonic_secret,
2948 ..
2949 } => {
2950 assert_eq!(network, Network::Cashu);
2951 assert_eq!(mint_url.as_deref(), Some("https://mint.example"));
2952 assert_eq!(
2953 mnemonic_secret.as_deref(),
2954 Some("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
2955 );
2956 }
2957 other => panic!("unexpected input: {other:?}"),
2958 }
2959 }
2960
2961 #[test]
2962 fn parse_sol_wallet_create_sol_rpc_endpoint() {
2963 let input = parse_subcommand(
2964 &[
2965 "sol",
2966 "wallet",
2967 "create",
2968 "--sol-rpc-endpoint",
2969 "https://api.mainnet-beta.solana.com",
2970 ],
2971 "t_sol_create_1",
2972 )
2973 .expect("sol wallet create --sol-rpc-endpoint should parse");
2974
2975 match input {
2976 Input::WalletCreate {
2977 network,
2978 rpc_endpoints,
2979 mint_url,
2980 ..
2981 } => {
2982 assert_eq!(network, Network::Sol);
2983 assert!(mint_url.is_none());
2984 assert_eq!(rpc_endpoints, vec!["https://api.mainnet-beta.solana.com"]);
2985 }
2986 other => panic!("unexpected input: {other:?}"),
2987 }
2988 }
2989
2990 #[test]
2991 fn parse_sol_wallet_create_multiple_sol_rpc_endpoints() {
2992 let input = parse_subcommand(
2993 &[
2994 "sol",
2995 "wallet",
2996 "create",
2997 "--sol-rpc-endpoint",
2998 "https://rpc-a.example",
2999 "--sol-rpc-endpoint",
3000 "https://rpc-b.example",
3001 ],
3002 "t_sol_create_3",
3003 )
3004 .expect("sol wallet create with repeated --sol-rpc-endpoint should parse");
3005
3006 match input {
3007 Input::WalletCreate {
3008 network,
3009 rpc_endpoints,
3010 mint_url,
3011 ..
3012 } => {
3013 assert_eq!(network, Network::Sol);
3014 assert!(mint_url.is_none());
3015 assert_eq!(
3016 rpc_endpoints,
3017 vec!["https://rpc-a.example", "https://rpc-b.example"]
3018 );
3019 }
3020 other => panic!("unexpected input: {other:?}"),
3021 }
3022 }
3023
3024 #[test]
3025 fn parse_sol_wallet_create_legacy_rpc_endpoint_rejected() {
3026 let err = parse_subcommand(
3027 &[
3028 "sol",
3029 "wallet",
3030 "create",
3031 "--rpc-endpoint",
3032 "https://api.mainnet-beta.solana.com",
3033 ],
3034 "t_sol_create_2",
3035 )
3036 .expect_err("legacy --rpc-endpoint should be rejected for sol wallet create");
3037
3038 assert!(err.contains("--rpc-endpoint"));
3039 }
3040
3041 #[test]
3042 fn parse_sol_receive_qr_svg_file() {
3043 let input = parse_subcommand(
3044 &["sol", "receive", "--wallet", "w_12345678", "--qr-svg-file"],
3045 "t_sol_1",
3046 )
3047 .expect("sol receive --qr-svg-file should parse");
3048
3049 match input {
3050 Input::Receive {
3051 wallet,
3052 network,
3053 write_qr_svg_file,
3054 ..
3055 } => {
3056 assert_eq!(wallet, "w_12345678");
3057 assert_eq!(network, Some(Network::Sol));
3058 assert!(write_qr_svg_file);
3059 }
3060 other => panic!("unexpected input: {other:?}"),
3061 }
3062 }
3063
3064 #[test]
3065 fn parse_sol_receive_wait_with_onchain_memo() {
3066 let input = parse_subcommand(
3067 &[
3068 "sol",
3069 "receive",
3070 "--wallet",
3071 "w_12345678",
3072 "--onchain-memo",
3073 "order:ord_123",
3074 "--wait",
3075 "--wait-timeout-s",
3076 "15",
3077 ],
3078 "t_sol_1b",
3079 )
3080 .expect("sol receive --onchain-memo --wait should parse");
3081
3082 match input {
3083 Input::Receive {
3084 wallet,
3085 network,
3086 onchain_memo,
3087 wait_until_paid,
3088 wait_timeout_s,
3089 ..
3090 } => {
3091 assert_eq!(wallet, "w_12345678");
3092 assert_eq!(network, Some(Network::Sol));
3093 assert_eq!(onchain_memo.as_deref(), Some("order:ord_123"));
3094 assert!(wait_until_paid);
3095 assert_eq!(wait_timeout_s, Some(15));
3096 }
3097 other => panic!("unexpected input: {other:?}"),
3098 }
3099 }
3100
3101 #[test]
3102 fn parse_history_list_with_onchain_memo_filter() {
3103 let input = parse_subcommand(
3104 &[
3105 "history",
3106 "list",
3107 "--wallet",
3108 "w_12345678",
3109 "--onchain-memo",
3110 "order:ord_123",
3111 "--limit",
3112 "50",
3113 ],
3114 "t_hist_1",
3115 )
3116 .expect("history list --onchain-memo should parse");
3117
3118 match input {
3119 Input::HistoryList {
3120 wallet,
3121 onchain_memo,
3122 limit,
3123 ..
3124 } => {
3125 assert_eq!(wallet.as_deref(), Some("w_12345678"));
3126 assert_eq!(onchain_memo.as_deref(), Some("order:ord_123"));
3127 assert_eq!(limit, Some(50));
3128 }
3129 other => panic!("unexpected input: {other:?}"),
3130 }
3131 }
3132
3133 #[test]
3134 fn parse_history_update_with_scope() {
3135 let input = parse_subcommand(
3136 &[
3137 "history",
3138 "update",
3139 "--wallet",
3140 "w_12345678",
3141 "--network",
3142 "btc",
3143 "--limit",
3144 "120",
3145 ],
3146 "t_hist_up_1",
3147 )
3148 .expect("history update with scope should parse");
3149
3150 match input {
3151 Input::HistoryUpdate {
3152 wallet,
3153 network,
3154 limit,
3155 ..
3156 } => {
3157 assert_eq!(wallet.as_deref(), Some("w_12345678"));
3158 assert_eq!(network, Some(Network::Btc));
3159 assert_eq!(limit, Some(120));
3160 }
3161 other => panic!("unexpected input: {other:?}"),
3162 }
3163 }
3164
3165 #[test]
3166 fn parse_history_update_defaults_limit() {
3167 let input = parse_subcommand(&["history", "update"], "t_hist_up_2")
3168 .expect("history update should parse");
3169 match input {
3170 Input::HistoryUpdate {
3171 wallet,
3172 network,
3173 limit,
3174 ..
3175 } => {
3176 assert_eq!(wallet, None);
3177 assert_eq!(network, None);
3178 assert_eq!(limit, Some(200));
3179 }
3180 other => panic!("unexpected input: {other:?}"),
3181 }
3182 }
3183
3184 #[test]
3185 fn parse_sol_wallet_dangerously_show_seed() {
3186 let input = parse_subcommand(
3187 &[
3188 "sol",
3189 "wallet",
3190 "dangerously-show-seed",
3191 "--wallet",
3192 "w_sol",
3193 ],
3194 "t_sol_2",
3195 )
3196 .expect("sol wallet dangerously-show-seed should parse");
3197
3198 match input {
3199 Input::WalletShowSeed { wallet, .. } => assert_eq!(wallet, "w_sol"),
3200 other => panic!("unexpected input: {other:?}"),
3201 }
3202 }
3203
3204 #[test]
3205 fn parse_ln_wallet_dangerously_show_seed() {
3206 let input = parse_subcommand(
3207 &["ln", "wallet", "dangerously-show-seed", "--wallet", "w_ln"],
3208 "t_ln_1",
3209 )
3210 .expect("ln wallet dangerously-show-seed should parse");
3211
3212 match input {
3213 Input::WalletShowSeed { wallet, .. } => assert_eq!(wallet, "w_ln"),
3214 other => panic!("unexpected input: {other:?}"),
3215 }
3216 }
3217
3218 #[test]
3219 fn parse_sol_wallet_legacy_show_seed_rejected() {
3220 let err = parse_subcommand(
3221 &["sol", "wallet", "show-seed", "--wallet", "w_sol"],
3222 "t_sol_3",
3223 )
3224 .expect_err("legacy sol wallet show-seed should be rejected");
3225 assert!(err.contains("show-seed"));
3226 }
3227
3228 #[test]
3229 fn parse_ln_send_sets_network_hint() {
3230 let input = parse_subcommand(
3231 &[
3232 "ln",
3233 "send",
3234 "--to",
3235 "lnbc1example",
3236 "--local-memo",
3237 "hello",
3238 ],
3239 "t_3",
3240 )
3241 .expect("ln send should parse");
3242
3243 match input {
3244 Input::Send { network, .. } => {
3245 assert_eq!(network, Some(Network::Ln));
3246 }
3247 other => panic!("unexpected input: {other:?}"),
3248 }
3249 }
3250
3251 #[test]
3252 fn parse_cashu_send_amount() {
3253 let input = parse_subcommand(&["cashu", "send", "--amount-sats", "500"], "t_unified_1")
3254 .expect("cashu send --amount-sats should parse");
3255
3256 match input {
3257 Input::CashuSend { amount, .. } => {
3258 assert_eq!(amount.value, 500);
3259 assert_eq!(amount.token, "sats");
3260 }
3261 other => panic!("unexpected input: {other:?}"),
3262 }
3263 }
3264
3265 #[test]
3266 fn parse_sol_send_token_required() {
3267 let input = parse_subcommand(
3268 &[
3269 "sol",
3270 "send",
3271 "--to",
3272 "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
3273 "--amount",
3274 "1000000",
3275 "--token",
3276 "native",
3277 ],
3278 "t_unified_2",
3279 )
3280 .expect("sol send --amount --token should parse");
3281
3282 match input {
3283 Input::Send { to, .. } => {
3284 assert!(to.contains("amount=1000000"));
3285 assert!(to.contains("token=native"));
3286 }
3287 other => panic!("unexpected input: {other:?}"),
3288 }
3289 }
3290
3291 #[test]
3292 fn parse_evm_send_token_required() {
3293 let input = parse_subcommand(
3294 &[
3295 "evm",
3296 "send",
3297 "--to",
3298 "0x1234567890abcdef1234567890abcdef12345678",
3299 "--amount",
3300 "1000000000",
3301 "--token",
3302 "native",
3303 ],
3304 "t_unified_3",
3305 )
3306 .expect("evm send --amount --token should parse");
3307
3308 match input {
3309 Input::Send { to, .. } => {
3310 assert!(to.contains("amount=1000000000"));
3311 assert!(to.contains("token=native"));
3312 }
3313 other => panic!("unexpected input: {other:?}"),
3314 }
3315 }
3316
3317 #[test]
3318 fn parse_ln_receive_amount() {
3319 let input = parse_subcommand(&["ln", "receive", "--amount-sats", "1000"], "t_unified_4")
3320 .expect("ln receive --amount-sats should parse");
3321
3322 match input {
3323 Input::Receive { amount, .. } => {
3324 let a = amount.expect("amount should be set");
3325 assert_eq!(a.value, 1000);
3326 assert_eq!(a.token, "sats");
3327 }
3328 other => panic!("unexpected input: {other:?}"),
3329 }
3330 }
3331 #[test]
3332 fn parse_cashu_receive_from_ln_claim_hidden_still_works() {
3333 let input = parse_subcommand(
3334 &[
3335 "cashu",
3336 "receive-from-ln-claim",
3337 "--wallet",
3338 "w_abc",
3339 "--ln-quote-id",
3340 "ph_456",
3341 ],
3342 "t_claim_5",
3343 )
3344 .expect("hidden cashu receive-from-ln-claim should still parse");
3345 match input {
3346 Input::ReceiveClaim {
3347 wallet, quote_id, ..
3348 } => {
3349 assert_eq!(wallet, "w_abc");
3350 assert_eq!(quote_id, "ph_456");
3351 }
3352 other => panic!("unexpected input: {other:?}"),
3353 }
3354 }
3355
3356 #[test]
3357 fn parse_cashu_wallet_restore() {
3358 let input = parse_subcommand(
3359 &["cashu", "wallet", "restore", "--wallet", "w_cashu1"],
3360 "t_wr_1",
3361 )
3362 .expect("cashu wallet restore should parse");
3363 match input {
3364 Input::Restore { wallet, .. } => {
3365 assert_eq!(wallet, "w_cashu1");
3366 }
3367 other => panic!("unexpected input: {other:?}"),
3368 }
3369 }
3370
3371 #[test]
3376 fn parse_balance_with_cashu_check() {
3377 let input = parse_subcommand(&["balance", "--cashu-check"], "t_bal_1")
3378 .expect("balance --cashu-check should parse");
3379 match input {
3380 Input::Balance { check, wallet, .. } => {
3381 assert!(check);
3382 assert!(wallet.is_none());
3383 }
3384 other => panic!("unexpected input: {other:?}"),
3385 }
3386 }
3387
3388 #[test]
3389 fn parse_balance_with_wallet() {
3390 let input = parse_subcommand(&["balance", "--wallet", "w_abc"], "t_bal_2")
3391 .expect("balance --wallet should parse");
3392 match input {
3393 Input::Balance { wallet, check, .. } => {
3394 assert_eq!(wallet.as_deref(), Some("w_abc"));
3395 assert!(!check);
3396 }
3397 other => panic!("unexpected input: {other:?}"),
3398 }
3399 }
3400
3401 #[test]
3406 fn parse_ln_receive_without_amount_for_bolt12() {
3407 let input = parse_subcommand(&["ln", "receive"], "t_bolt12_1")
3408 .expect("ln receive without --amount should parse (bolt12 offer)");
3409 match input {
3410 Input::Receive {
3411 network, amount, ..
3412 } => {
3413 assert_eq!(network, Some(Network::Ln));
3414 assert!(amount.is_none(), "amount should be None for bolt12 offer");
3415 }
3416 other => panic!("unexpected input: {other:?}"),
3417 }
3418 }
3419
3420 #[test]
3421 fn parse_ln_send_bolt12_requires_amount() {
3422 let err = parse_subcommand(&["ln", "send", "--to", "lno1abc123"], "t_bolt12_3")
3423 .expect_err("ln send to bolt12 without --amount-sats should error");
3424 assert!(
3425 err.contains("amount-sats"),
3426 "error should mention amount-sats: {err}"
3427 );
3428 }
3429
3430 #[test]
3431 fn parse_ln_send_bolt12_with_amount() {
3432 let input = parse_subcommand(
3433 &["ln", "send", "--to", "lno1abc123", "--amount-sats", "500"],
3434 "t_bolt12_4",
3435 )
3436 .expect("ln send to bolt12 with --amount-sats should parse");
3437 match input {
3438 Input::Send { to, network, .. } => {
3439 assert_eq!(network, Some(Network::Ln));
3440 assert!(to.contains("lno1abc123"), "to should contain offer");
3441 assert!(to.contains("?amount=500"), "to should encode amount");
3442 }
3443 other => panic!("unexpected input: {other:?}"),
3444 }
3445 }
3446
3447 #[test]
3448 fn parse_ln_send_bolt12_case_insensitive() {
3449 let input = parse_subcommand(
3450 &[
3451 "ln",
3452 "send",
3453 "--to",
3454 "LNO1UPPERCASE",
3455 "--amount-sats",
3456 "100",
3457 ],
3458 "t_bolt12_5",
3459 )
3460 .expect("uppercase LNO1 should be accepted");
3461 match input {
3462 Input::Send { to, .. } => {
3463 assert!(
3464 to.contains("?amount=100"),
3465 "uppercase offer should get amount appended: {to}"
3466 );
3467 }
3468 other => panic!("unexpected input: {other:?}"),
3469 }
3470 }
3471
3472 #[test]
3473 fn parse_ln_send_bolt11_rejects_amount_sats() {
3474 let err = parse_subcommand(
3475 &["ln", "send", "--to", "lnbc1abc", "--amount-sats", "100"],
3476 "t_bolt12_8",
3477 )
3478 .expect_err("ln send to bolt11 with --amount-sats should error");
3479 assert!(
3480 err.contains("not accepted"),
3481 "error should reject amount for bolt11: {err}"
3482 );
3483 }
3484}