Skip to main content

agent_first_pay/
args.rs

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
11// ═══════════════════════════════════════════
12// Mode Dispatch Types
13// ═══════════════════════════════════════════
14
15pub 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
118// RpcInit is defined in mode::rpc and re-used here
119
120// ═══════════════════════════════════════════
121// Memo Helpers
122// ═══════════════════════════════════════════
123
124fn 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// ═══════════════════════════════════════════
145// Shared Arg Structs
146// ═══════════════════════════════════════════
147
148#[derive(clap::Args, Clone)]
149struct CommonSendArgs {
150    /// Source wallet ID (auto-selected if omitted)
151    #[arg(long)]
152    wallet: Option<String>,
153    /// On-chain memo (sent with the transaction)
154    #[arg(long = "onchain-memo")]
155    onchain_memo: Option<String>,
156    /// Local bookkeeping annotation (repeatable: --local-memo purpose=donation --local-memo note=coffee)
157    #[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    /// Wallet ID (auto-selected if omitted)
164    #[arg(long)]
165    wallet: Option<String>,
166    /// Wait for payment / matching receive transaction
167    #[arg(long)]
168    wait: bool,
169    /// Timeout in seconds for --wait
170    #[arg(long = "wait-timeout-s")]
171    wait_timeout_s: Option<u64>,
172    /// Poll interval in milliseconds for --wait
173    #[arg(long = "wait-poll-interval-ms")]
174    wait_poll_interval_ms: Option<u64>,
175    /// Write receive QR payload to an SVG file
176    #[arg(long = "qr-svg-file", default_value_t = false)]
177    qr_svg_file: bool,
178}
179
180// ═══════════════════════════════════════════
181// Clap Definitions
182// ═══════════════════════════════════════════
183
184#[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    /// Run mode
204    #[arg(long, value_enum, default_value_t = RuntimeMode::Cli)]
205    mode: RuntimeMode,
206
207    /// Connect to remote RPC daemon (cli mode)
208    #[arg(long = "rpc-endpoint")]
209    rpc_endpoint: Option<String>,
210
211    /// Listen address for RPC daemon (rpc mode)
212    #[arg(long = "rpc-listen", default_value = "127.0.0.1:9400")]
213    rpc_listen: String,
214
215    /// RPC encryption secret
216    #[arg(long = "rpc-secret")]
217    rpc_secret: Option<String>,
218
219    /// Listen address for REST HTTP server (rest mode)
220    #[arg(long = "rest-listen", default_value = "127.0.0.1:9401")]
221    rest_listen: String,
222
223    /// API key for REST bearer authentication (rest mode)
224    #[arg(long = "rest-api-key")]
225    rest_api_key: Option<String>,
226
227    /// Allow binding REST/RPC to non-loopback addresses; use only behind TLS/firewall
228    #[arg(long = "public-listen")]
229    public_listen: bool,
230
231    /// Wallet and data directory
232    #[arg(long = "data-dir")]
233    data_dir: Option<String>,
234
235    /// Output format
236    #[arg(long, default_value = "json")]
237    output: String,
238
239    /// Log filters (comma-separated)
240    #[arg(long = "log", value_delimiter = ',')]
241    log: Vec<String>,
242
243    /// Preview the command without executing it
244    #[arg(long)]
245    dry_run: bool,
246
247    #[command(subcommand)]
248    command: Option<PayCommand>,
249}
250
251#[derive(Subcommand)]
252enum PayCommand {
253    /// Global (cross-network) operations
254    Global {
255        #[command(subcommand)]
256        action: GlobalCommand,
257    },
258    /// Cashu operations
259    Cashu {
260        #[command(subcommand)]
261        action: CashuCommand,
262    },
263    /// Lightning Network operations (NWC, phoenixd, LNbits)
264    Ln {
265        #[command(subcommand)]
266        action: LnCommand,
267    },
268    /// Solana operations
269    Sol {
270        #[command(subcommand)]
271        action: SolCommand,
272    },
273    /// EVM chain operations (Base, Arbitrum)
274    Evm {
275        #[command(subcommand)]
276        action: EvmCommand,
277    },
278    /// Bitcoin on-chain operations
279    Btc {
280        #[command(subcommand)]
281        action: BtcCommand,
282    },
283    /// List all wallets (cross-network)
284    Wallet {
285        #[command(subcommand)]
286        action: WalletTopAction,
287    },
288    /// All wallets balance (cross-network)
289    Balance {
290        /// Wallet ID (omit to show all wallets)
291        #[arg(long)]
292        wallet: Option<String>,
293        /// Filter by network: cashu, ln, sol, evm
294        #[arg(long, value_enum)]
295        network: Option<CliNetwork>,
296        /// Verify cashu proofs against mint (slower but accurate; cashu only)
297        #[arg(long = "cashu-check")]
298        cashu_check: bool,
299    },
300    /// History queries
301    #[command(name = "history")]
302    History {
303        #[command(subcommand)]
304        action: HistoryAction,
305    },
306    /// Spend limit list and remove (cross-network)
307    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    /// Global spend limit (USD cents)
325    Limit {
326        #[command(subcommand)]
327        action: GlobalLimitAction,
328    },
329    /// Global runtime configuration
330    Config {
331        #[command(subcommand)]
332        action: GlobalConfigAction,
333    },
334    /// Back up all data to a .tar.zst archive
335    Backup {
336        /// Output archive path (default: ./afpay-global-{timestamp}.tar.zst)
337        #[arg(long)]
338        output: Option<String>,
339        /// Include an extra directory: --extra-dir label=/path (repeatable)
340        #[arg(long = "extra-dir", value_parser = parse_extra_dir)]
341        extra_dir: Vec<(String, String)>,
342    },
343    /// Restore all data from a .tar.zst archive
344    Restore {
345        /// Path to the backup archive
346        archive: String,
347        /// Clear all existing data before restoring (default: merge)
348        #[arg(long = "dangerously-overwrite")]
349        dangerously_overwrite: bool,
350        /// Override PostgreSQL connection URL for the pg restore step
351        #[arg(long = "pg-url-secret")]
352        pg_url_secret: Option<String>,
353        /// Restore an extra directory: --extra-dir label=/path (repeatable)
354        #[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 current runtime configuration
362    Show,
363    /// Update runtime configuration
364    Set {
365        /// Log filters (comma-separated: startup,cashu,ln,sol,wallet,all,off)
366        #[arg(long, value_delimiter = ',')]
367        log: Option<Vec<String>>,
368    },
369}
370
371#[derive(Subcommand)]
372enum GlobalLimitAction {
373    /// Add a global spend limit (USD cents)
374    Add {
375        /// Time window: e.g. 30m, 1h, 24h, 7d
376        #[arg(long)]
377        window: String,
378        /// Maximum spend in USD cents
379        #[arg(long)]
380        max_spend: u64,
381    },
382}
383
384/// Per-wallet configuration for cashu, ln, btc (label only).
385#[derive(Subcommand)]
386enum SimpleWalletConfigAction {
387    /// Show current wallet configuration
388    Show,
389    /// Update wallet settings
390    Set {
391        /// New label
392        #[arg(long)]
393        label: Option<String>,
394    },
395}
396
397/// Per-wallet configuration for sol (label + rpc-endpoint + token management).
398#[derive(Subcommand)]
399enum SolWalletConfigAction {
400    /// Show current wallet configuration
401    Show,
402    /// Update wallet settings
403    Set {
404        /// New label
405        #[arg(long)]
406        label: Option<String>,
407        /// Replace RPC endpoint(s)
408        #[arg(long = "rpc-endpoint")]
409        rpc_endpoint: Vec<String>,
410    },
411    /// Register a custom token for balance tracking
412    #[command(name = "token-add")]
413    TokenAdd {
414        /// Token symbol (e.g. dai)
415        #[arg(long)]
416        symbol: String,
417        /// Token contract address
418        #[arg(long)]
419        address: String,
420        /// Token decimals
421        #[arg(long, default_value_t = 6)]
422        decimals: u8,
423    },
424    /// Unregister a custom token
425    #[command(name = "token-remove")]
426    TokenRemove {
427        /// Token symbol to remove
428        #[arg(long)]
429        symbol: String,
430    },
431}
432
433/// Per-wallet configuration for evm (label + rpc-endpoint + chain-id + token management).
434#[derive(Subcommand)]
435enum EvmWalletConfigAction {
436    /// Show current wallet configuration
437    Show,
438    /// Update wallet settings
439    Set {
440        /// New label
441        #[arg(long)]
442        label: Option<String>,
443        /// Replace RPC endpoint(s)
444        #[arg(long = "rpc-endpoint")]
445        rpc_endpoint: Vec<String>,
446        /// EVM chain ID
447        #[arg(long = "chain-id")]
448        chain_id: Option<u64>,
449    },
450    /// Register a custom token for balance tracking
451    #[command(name = "token-add")]
452    TokenAdd {
453        /// Token symbol (e.g. dai)
454        #[arg(long)]
455        symbol: String,
456        /// Token contract address
457        #[arg(long)]
458        address: String,
459        /// Token decimals
460        #[arg(long, default_value_t = 6)]
461        decimals: u8,
462    },
463    /// Unregister a custom token
464    #[command(name = "token-remove")]
465    TokenRemove {
466        /// Token symbol to remove
467        #[arg(long)]
468        symbol: String,
469    },
470}
471
472/// Limit actions for cashu, ln, btc (sats-only networks — no --token flag).
473#[derive(Subcommand)]
474enum SimpleLimitAction {
475    /// Add a network or wallet spend limit
476    Add {
477        /// Time window: e.g. 30m, 1h, 24h, 7d
478        #[arg(long)]
479        window: String,
480        /// Maximum spend in base units
481        #[arg(long)]
482        max_spend: u64,
483    },
484}
485
486/// Limit actions for sol, evm (multi-token networks — has --token flag).
487#[derive(Subcommand)]
488enum TokenLimitAction {
489    /// Add a network or wallet spend limit
490    Add {
491        /// Token: native, usdc, usdt
492        #[arg(long)]
493        token: Option<String>,
494        /// Time window: e.g. 30m, 1h, 24h, 7d
495        #[arg(long)]
496        window: String,
497        /// Maximum spend in base units
498        #[arg(long)]
499        max_spend: u64,
500    },
501}
502
503#[derive(Subcommand)]
504enum CashuCommand {
505    /// Send P2P cashu token (outputs token string; for Lightning, use send-to-ln)
506    #[command(name = "send")]
507    Send {
508        /// Amount in sats (base units)
509        #[arg(long = "amount-sats")]
510        amount_sats: u64,
511        /// Restrict to wallets on these mint URLs (tried in order)
512        #[arg(long = "cashu-mint")]
513        mint_url: Vec<String>,
514        #[command(flatten)]
515        common: CommonSendArgs,
516        /// Hidden: catches --to and redirects to send-to-ln
517        #[arg(long, hide = true)]
518        to: Option<String>,
519    },
520    /// Receive cashu token
521    #[command(name = "receive")]
522    Receive {
523        /// Cashu token string
524        token: String,
525        /// Wallet ID (auto-matched from token if omitted)
526        #[arg(long)]
527        wallet: Option<String>,
528    },
529    /// Send cashu to a Lightning invoice
530    #[command(name = "send-to-ln")]
531    SendToLn {
532        /// Lightning invoice (bolt11)
533        #[arg(long)]
534        to: String,
535        #[command(flatten)]
536        common: CommonSendArgs,
537    },
538    /// Create Lightning invoice to receive cashu from LN
539    #[command(name = "receive-from-ln")]
540    ReceiveFromLn {
541        /// Amount in sats (base units)
542        #[arg(long = "amount-sats")]
543        amount_sats: Option<u64>,
544        /// On-chain memo (sent with the transaction)
545        #[arg(long = "onchain-memo")]
546        onchain_memo: Option<String>,
547        #[command(flatten)]
548        common: CommonReceiveArgs,
549    },
550    /// Claim minted tokens from a receive-from-ln quote
551    #[command(name = "receive-from-ln-claim")]
552    ReceiveFromLnClaim {
553        /// Wallet ID
554        #[arg(long)]
555        wallet: String,
556        /// Quote ID / payment hash from deposit
557        #[arg(long = "ln-quote-id")]
558        ln_quote_id: String,
559    },
560    /// Check cashu balance
561    Balance {
562        /// Wallet ID (omit to show all cashu wallets)
563        #[arg(long)]
564        wallet: Option<String>,
565        /// Verify proofs against mint (slower but accurate)
566        #[arg(long)]
567        check: bool,
568    },
569    /// Wallet management
570    Wallet {
571        #[command(subcommand)]
572        action: CashuWalletAction,
573    },
574    /// Spend limit for cashu network or a specific cashu wallet
575    Limit {
576        /// Wallet ID (omit for network-level limit)
577        #[arg(long)]
578        wallet: Option<String>,
579        #[command(subcommand)]
580        action: SimpleLimitAction,
581    },
582    /// Per-wallet configuration
583    Config {
584        /// Wallet ID
585        #[arg(long)]
586        wallet: String,
587        #[command(subcommand)]
588        action: SimpleWalletConfigAction,
589    },
590    /// Back up cashu wallet data to a .tar.zst archive
591    Backup {
592        /// Output archive path (default: ./afpay-cashu-{timestamp}.tar.zst)
593        #[arg(long)]
594        output: Option<String>,
595        /// Wallet ID (omit to back up all cashu wallets)
596        #[arg(long)]
597        wallet: Option<String>,
598    },
599    /// Restore cashu wallet data from a .tar.zst archive
600    Restore {
601        /// Path to the backup archive
602        archive: String,
603        /// Clear existing data before restoring (default: merge)
604        #[arg(long = "dangerously-overwrite")]
605        dangerously_overwrite: bool,
606        /// Override PostgreSQL connection URL for the pg restore step
607        #[arg(long = "pg-url-secret")]
608        pg_url_secret: Option<String>,
609    },
610}
611
612#[derive(Subcommand)]
613enum CashuWalletAction {
614    /// Create a new cashu wallet
615    Create {
616        /// Cashu mint URL
617        #[arg(long = "cashu-mint")]
618        mint_url: String,
619        /// Optional label
620        #[arg(long)]
621        label: Option<String>,
622        /// Existing BIP39 mnemonic secret to restore this wallet
623        #[arg(long = "mnemonic-secret")]
624        mnemonic_secret: Option<String>,
625    },
626    /// Close a zero-balance cashu wallet
627    Close {
628        /// Wallet ID
629        #[arg(long)]
630        wallet: String,
631        /// Dangerously skip balance checks when closing wallet
632        #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
633        dangerously_skip_balance_check_and_may_lose_money: bool,
634    },
635    /// List cashu wallets
636    List,
637    /// Dangerously show wallet seed mnemonic (12 BIP39 words)
638    #[command(name = "dangerously-show-seed")]
639    ShowSeed {
640        /// Wallet ID
641        #[arg(long)]
642        wallet: String,
643    },
644    /// Restore lost proofs from mint (fixes counter/proof sync issues)
645    Restore {
646        /// Wallet ID
647        #[arg(long)]
648        wallet: String,
649    },
650}
651
652#[derive(Subcommand)]
653enum LnCommand {
654    /// Wallet management
655    Wallet {
656        #[command(subcommand)]
657        action: LnWalletAction,
658    },
659    /// Pay a Lightning invoice or BOLT12 offer
660    #[command(name = "send")]
661    Send {
662        /// BOLT11 invoice or BOLT12 offer (lno1…) to pay
663        #[arg(long)]
664        to: String,
665        /// Amount in sats (required for BOLT12 offers, rejected for BOLT11)
666        #[arg(long = "amount-sats")]
667        amount_sats: Option<u64>,
668        #[command(flatten)]
669        common: CommonSendArgs,
670    },
671    /// Create a Lightning invoice (BOLT11) or get a reusable BOLT12 offer
672    #[command(name = "receive")]
673    Receive {
674        /// Amount in sats (omit for BOLT12 offer)
675        #[arg(long = "amount-sats")]
676        amount_sats: Option<u64>,
677        #[command(flatten)]
678        common: CommonReceiveArgs,
679    },
680    /// Check balance
681    Balance {
682        /// Wallet ID (omit to show all ln wallets)
683        #[arg(long)]
684        wallet: Option<String>,
685    },
686    /// Spend limit for ln network or a specific ln wallet
687    Limit {
688        /// Wallet ID (omit for network-level limit)
689        #[arg(long)]
690        wallet: Option<String>,
691        #[command(subcommand)]
692        action: SimpleLimitAction,
693    },
694    /// Per-wallet configuration
695    Config {
696        /// Wallet ID
697        #[arg(long)]
698        wallet: String,
699        #[command(subcommand)]
700        action: SimpleWalletConfigAction,
701    },
702    /// Back up Lightning wallet data to a .tar.zst archive
703    Backup {
704        /// Output archive path (default: ./afpay-ln-{timestamp}.tar.zst)
705        #[arg(long)]
706        output: Option<String>,
707        /// Wallet ID (omit to back up all ln wallets)
708        #[arg(long)]
709        wallet: Option<String>,
710    },
711    /// Restore Lightning wallet data from a .tar.zst archive
712    Restore {
713        /// Path to the backup archive
714        archive: String,
715        /// Clear existing data before restoring (default: merge)
716        #[arg(long = "dangerously-overwrite")]
717        dangerously_overwrite: bool,
718        /// Override PostgreSQL connection URL for the pg restore step
719        #[arg(long = "pg-url-secret")]
720        pg_url_secret: Option<String>,
721    },
722}
723
724#[derive(Subcommand)]
725enum SolCommand {
726    /// Wallet management
727    Wallet {
728        #[command(subcommand)]
729        action: SolWalletAction,
730    },
731    /// Send SOL or SPL token transfer
732    #[command(name = "send")]
733    Send {
734        /// Recipient Solana address (base58)
735        #[arg(long)]
736        to: String,
737        /// Amount in token base units (lamports for SOL, smallest unit for SPL tokens)
738        #[arg(long)]
739        amount: u64,
740        /// Token: "native" for SOL, "usdc", "usdt", or SPL mint address
741        #[arg(long)]
742        token: String,
743        /// Reference key for order binding (base58-encoded 32 bytes, per strain-payment-method-solana)
744        #[arg(long)]
745        reference: Option<String>,
746        #[command(flatten)]
747        common: CommonSendArgs,
748    },
749    /// Show wallet receive address
750    #[command(name = "receive")]
751    Receive {
752        /// On-chain memo to watch for (used with --wait)
753        #[arg(long = "onchain-memo")]
754        onchain_memo: Option<String>,
755        /// Minimum confirmation depth before considering payment settled (requires --wait)
756        #[arg(long = "min-confirmations")]
757        min_confirmations: Option<u32>,
758        /// Reference key to watch for (base58, used with --wait, per strain-payment-method-solana)
759        #[arg(long)]
760        reference: Option<String>,
761        #[command(flatten)]
762        common: CommonReceiveArgs,
763    },
764    /// Check balance
765    Balance {
766        /// Wallet ID (omit to show all sol wallets)
767        #[arg(long)]
768        wallet: Option<String>,
769    },
770    /// Spend limit for sol network or a specific sol wallet
771    Limit {
772        /// Wallet ID (omit for network-level limit)
773        #[arg(long)]
774        wallet: Option<String>,
775        #[command(subcommand)]
776        action: TokenLimitAction,
777    },
778    /// Per-wallet configuration
779    Config {
780        /// Wallet ID
781        #[arg(long)]
782        wallet: String,
783        #[command(subcommand)]
784        action: SolWalletConfigAction,
785    },
786    /// Back up Solana wallet data to a .tar.zst archive
787    Backup {
788        /// Output archive path (default: ./afpay-sol-{timestamp}.tar.zst)
789        #[arg(long)]
790        output: Option<String>,
791        /// Wallet ID (omit to back up all sol wallets)
792        #[arg(long)]
793        wallet: Option<String>,
794    },
795    /// Restore Solana wallet data from a .tar.zst archive
796    Restore {
797        /// Path to the backup archive
798        archive: String,
799        /// Clear existing data before restoring (default: merge)
800        #[arg(long = "dangerously-overwrite")]
801        dangerously_overwrite: bool,
802        /// Override PostgreSQL connection URL for the pg restore step
803        #[arg(long = "pg-url-secret")]
804        pg_url_secret: Option<String>,
805    },
806}
807
808#[derive(Subcommand)]
809enum SolWalletAction {
810    /// Create a new Solana wallet
811    Create {
812        /// Solana JSON-RPC endpoint (repeat to configure failover order)
813        #[arg(long = "sol-rpc-endpoint", required = true)]
814        sol_rpc_endpoint: Vec<String>,
815        /// Optional label
816        #[arg(long)]
817        label: Option<String>,
818    },
819    /// Close a Solana wallet
820    Close {
821        /// Wallet ID
822        #[arg(long)]
823        wallet: String,
824        /// Dangerously skip balance checks when closing wallet
825        #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
826        dangerously_skip_balance_check_and_may_lose_money: bool,
827    },
828    /// List Solana wallets
829    List,
830    /// Dangerously show wallet seed mnemonic (12 BIP39 words)
831    #[command(name = "dangerously-show-seed")]
832    ShowSeed {
833        /// Wallet ID
834        #[arg(long)]
835        wallet: String,
836    },
837}
838
839#[derive(Subcommand)]
840enum EvmCommand {
841    /// Wallet management
842    Wallet {
843        #[command(subcommand)]
844        action: EvmWalletAction,
845    },
846    /// Send native token or ERC-20 token transfer
847    #[command(name = "send")]
848    Send {
849        /// Recipient address (0x...)
850        #[arg(long)]
851        to: String,
852        /// Amount in token base units (wei for ETH, smallest unit for ERC-20)
853        #[arg(long)]
854        amount: u64,
855        /// Token: "native" for chain native, "usdc" or contract address for ERC-20
856        #[arg(long)]
857        token: String,
858        #[command(flatten)]
859        common: CommonSendArgs,
860    },
861    /// Show wallet receive address
862    #[command(name = "receive")]
863    Receive {
864        /// On-chain memo to watch for (used with --wait)
865        #[arg(long = "onchain-memo")]
866        onchain_memo: Option<String>,
867        /// Minimum confirmation depth before considering payment settled (requires --wait)
868        #[arg(long = "min-confirmations")]
869        min_confirmations: Option<u32>,
870        #[command(flatten)]
871        common: CommonReceiveArgs,
872    },
873    /// Check balance
874    Balance {
875        /// Wallet ID (omit to show all evm wallets)
876        #[arg(long)]
877        wallet: Option<String>,
878    },
879    /// Spend limit for evm network or a specific evm wallet
880    Limit {
881        /// Wallet ID (omit for network-level limit)
882        #[arg(long)]
883        wallet: Option<String>,
884        #[command(subcommand)]
885        action: TokenLimitAction,
886    },
887    /// Per-wallet configuration
888    Config {
889        /// Wallet ID
890        #[arg(long)]
891        wallet: String,
892        #[command(subcommand)]
893        action: EvmWalletConfigAction,
894    },
895    /// Back up EVM wallet data to a .tar.zst archive
896    Backup {
897        /// Output archive path (default: ./afpay-evm-{timestamp}.tar.zst)
898        #[arg(long)]
899        output: Option<String>,
900        /// Wallet ID (omit to back up all evm wallets)
901        #[arg(long)]
902        wallet: Option<String>,
903    },
904    /// Restore EVM wallet data from a .tar.zst archive
905    Restore {
906        /// Path to the backup archive
907        archive: String,
908        /// Clear existing data before restoring (default: merge)
909        #[arg(long = "dangerously-overwrite")]
910        dangerously_overwrite: bool,
911        /// Override PostgreSQL connection URL for the pg restore step
912        #[arg(long = "pg-url-secret")]
913        pg_url_secret: Option<String>,
914    },
915}
916
917#[derive(Subcommand)]
918enum EvmWalletAction {
919    /// Create a new EVM chain wallet
920    Create {
921        /// EVM JSON-RPC endpoint (repeat to configure failover order)
922        #[arg(long = "evm-rpc-endpoint", required = true)]
923        evm_rpc_endpoint: Vec<String>,
924        /// Chain ID (default: 8453 = Base)
925        #[arg(long = "chain-id", default_value_t = 8453)]
926        chain_id: u64,
927        /// Optional label
928        #[arg(long)]
929        label: Option<String>,
930    },
931    /// Close an EVM chain wallet
932    Close {
933        /// Wallet ID
934        #[arg(long)]
935        wallet: String,
936        /// Dangerously skip balance checks when closing wallet
937        #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
938        dangerously_skip_balance_check_and_may_lose_money: bool,
939    },
940    /// List EVM chain wallets
941    List,
942    /// Dangerously show wallet seed mnemonic (12 BIP39 words)
943    #[command(name = "dangerously-show-seed")]
944    ShowSeed {
945        /// Wallet ID
946        #[arg(long)]
947        wallet: String,
948    },
949}
950
951#[derive(Subcommand)]
952enum BtcCommand {
953    /// Wallet management
954    Wallet {
955        #[command(subcommand)]
956        action: BtcWalletAction,
957    },
958    /// Send BTC on-chain
959    #[command(name = "send")]
960    Send {
961        /// Recipient Bitcoin address (bc1.../tb1...)
962        #[arg(long)]
963        to: String,
964        /// Amount in satoshis
965        #[arg(long = "amount-sats")]
966        amount_sats: u64,
967        #[command(flatten)]
968        common: CommonSendArgs,
969    },
970    /// Show wallet receive address
971    #[command(name = "receive")]
972    Receive {
973        /// Max history records scanned per poll when resolving tx id
974        #[arg(long = "wait-sync-limit")]
975        wait_sync_limit: Option<usize>,
976        #[command(flatten)]
977        common: CommonReceiveArgs,
978    },
979    /// Check balance
980    Balance {
981        /// Wallet ID (omit to show all btc wallets)
982        #[arg(long)]
983        wallet: Option<String>,
984    },
985    /// Spend limit for btc network or a specific btc wallet
986    Limit {
987        /// Wallet ID (omit for network-level limit)
988        #[arg(long)]
989        wallet: Option<String>,
990        #[command(subcommand)]
991        action: SimpleLimitAction,
992    },
993    /// Per-wallet configuration
994    Config {
995        /// Wallet ID
996        #[arg(long)]
997        wallet: String,
998        #[command(subcommand)]
999        action: SimpleWalletConfigAction,
1000    },
1001    /// Back up Bitcoin wallet data to a .tar.zst archive
1002    Backup {
1003        /// Output archive path (default: ./afpay-btc-{timestamp}.tar.zst)
1004        #[arg(long)]
1005        output: Option<String>,
1006        /// Wallet ID (omit to back up all btc wallets)
1007        #[arg(long)]
1008        wallet: Option<String>,
1009    },
1010    /// Restore Bitcoin wallet data from a .tar.zst archive
1011    Restore {
1012        /// Path to the backup archive
1013        archive: String,
1014        /// Clear existing data before restoring (default: merge)
1015        #[arg(long = "dangerously-overwrite")]
1016        dangerously_overwrite: bool,
1017        /// Override PostgreSQL connection URL for the pg restore step
1018        #[arg(long = "pg-url-secret")]
1019        pg_url_secret: Option<String>,
1020    },
1021}
1022
1023#[derive(Subcommand)]
1024enum BtcWalletAction {
1025    /// Create a new Bitcoin wallet
1026    Create {
1027        /// Bitcoin sub-network: mainnet or signet (default: mainnet)
1028        #[arg(long = "btc-network", default_value = "mainnet")]
1029        btc_network: String,
1030        /// Address type: taproot or segwit (default: taproot)
1031        #[arg(long = "btc-address-type", default_value = "taproot")]
1032        btc_address_type: String,
1033        /// Chain-source backend: esplora (default), core-rpc, electrum
1034        #[arg(long = "btc-backend", value_enum)]
1035        btc_backend: Option<CliBtcBackend>,
1036        /// Custom Esplora API URL
1037        #[arg(long = "btc-esplora-url")]
1038        btc_esplora_url: Option<String>,
1039        /// Bitcoin Core RPC URL (core-rpc backend)
1040        #[arg(long = "btc-core-url")]
1041        btc_core_url: Option<String>,
1042        /// Bitcoin Core RPC auth "user:pass" (core-rpc backend)
1043        #[arg(long = "btc-core-auth-secret")]
1044        btc_core_auth_secret: Option<String>,
1045        /// Electrum server URL (electrum backend)
1046        #[arg(long = "btc-electrum-url")]
1047        btc_electrum_url: Option<String>,
1048        /// Existing BIP39 mnemonic secret to restore wallet
1049        #[arg(long = "mnemonic-secret")]
1050        mnemonic_secret: Option<String>,
1051        /// Optional label
1052        #[arg(long)]
1053        label: Option<String>,
1054    },
1055    /// Close a Bitcoin wallet
1056    Close {
1057        /// Wallet ID
1058        #[arg(long)]
1059        wallet: String,
1060        /// Dangerously skip balance checks when closing wallet
1061        #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
1062        dangerously_skip_balance_check_and_may_lose_money: bool,
1063    },
1064    /// List Bitcoin wallets
1065    List,
1066    /// Dangerously show wallet seed mnemonic (12 BIP39 words)
1067    #[command(name = "dangerously-show-seed")]
1068    ShowSeed {
1069        /// Wallet ID
1070        #[arg(long)]
1071        wallet: String,
1072    },
1073}
1074
1075#[derive(Subcommand)]
1076enum LnWalletAction {
1077    /// Create a new Lightning wallet
1078    Create {
1079        /// Backend: nwc, phoenixd, lnbits
1080        #[arg(long, value_enum)]
1081        backend: CliLnBackend,
1082        /// NWC connection URI secret (for nwc backend)
1083        #[arg(long = "nwc-uri-secret")]
1084        nwc_uri_secret: Option<String>,
1085        /// Endpoint URL (for phoenixd, lnbits)
1086        #[arg(long)]
1087        endpoint: Option<String>,
1088        /// Password secret (for phoenixd)
1089        #[arg(long = "password-secret")]
1090        password_secret: Option<String>,
1091        /// Admin API key secret (for lnbits)
1092        #[arg(long = "admin-key-secret")]
1093        admin_key_secret: Option<String>,
1094        /// Optional label
1095        #[arg(long)]
1096        label: Option<String>,
1097    },
1098    /// Close a Lightning wallet
1099    Close {
1100        /// Wallet ID
1101        #[arg(long)]
1102        wallet: String,
1103        /// Dangerously skip balance checks when closing wallet
1104        #[arg(long = "dangerously-skip-balance-check-and-may-lose-money")]
1105        dangerously_skip_balance_check_and_may_lose_money: bool,
1106    },
1107    /// List Lightning wallets
1108    List,
1109    /// Dangerously show wallet seed (for LN this is backend credential, not mnemonic words)
1110    #[command(name = "dangerously-show-seed")]
1111    ShowSeed {
1112        /// Wallet ID
1113        #[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 all wallets (cross-network)
1156    List {
1157        /// Filter by network: cashu, ln, sol, evm
1158        #[arg(long, value_enum)]
1159        network: Option<CliNetwork>,
1160    },
1161}
1162
1163#[derive(Subcommand)]
1164enum HistoryAction {
1165    /// List history records from local store
1166    List {
1167        /// Filter by wallet ID
1168        #[arg(long)]
1169        wallet: Option<String>,
1170        /// Filter by network: cashu, ln, sol, evm
1171        #[arg(long, value_enum)]
1172        network: Option<CliNetwork>,
1173        /// Filter by exact on-chain memo text
1174        #[arg(long = "onchain-memo")]
1175        onchain_memo: Option<String>,
1176        /// Max results
1177        #[arg(long, default_value_t = 20)]
1178        limit: usize,
1179        /// Offset
1180        #[arg(long, default_value_t = 0)]
1181        offset: usize,
1182        /// Only include records created at or after this epoch second
1183        #[arg(long = "since-epoch-s")]
1184        since_epoch_s: Option<u64>,
1185        /// Only include records created before this epoch second
1186        #[arg(long = "until-epoch-s")]
1187        until_epoch_s: Option<u64>,
1188    },
1189    /// Check history status
1190    Status {
1191        /// Transaction ID
1192        #[arg(long = "transaction-id")]
1193        transaction_id: String,
1194    },
1195    /// Incrementally sync on-chain/backend history into local store
1196    Update {
1197        /// Sync a specific wallet (defaults to all wallets in scope)
1198        #[arg(long)]
1199        wallet: Option<String>,
1200        /// Restrict sync to a single network
1201        #[arg(long, value_enum)]
1202        network: Option<CliNetwork>,
1203        /// Max records to scan per wallet during this incremental sync
1204        #[arg(long, default_value_t = 200)]
1205        limit: usize,
1206    },
1207}
1208
1209#[derive(Subcommand)]
1210enum LimitAction {
1211    /// Remove a spend limit rule by ID
1212    Remove {
1213        /// Rule ID (e.g. r_1a2b3c4d)
1214        #[arg(long)]
1215        rule_id: String,
1216    },
1217    /// List current limit status
1218    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// ═══════════════════════════════════════════
1243// Subcommand Parser (reused by interactive mode)
1244// ═══════════════════════════════════════════
1245
1246#[derive(Parser)]
1247#[command(no_binary_name = true, name = "afpay")]
1248struct SubcommandParser {
1249    #[command(subcommand)]
1250    command: PayCommand,
1251}
1252
1253/// Parse a subcommand from args (e.g. `["cashu", "send", "--amount-sats", "100"]`).
1254/// Used by interactive mode to reuse CLI command definitions.
1255#[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/// Render clap help for the given args (e.g. `&["--help"]`, `&["cashu", "--help"]`).
1262#[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/// Describes a single CLI argument extracted from clap definitions.
1271#[cfg(feature = "interactive")]
1272#[derive(Debug, Clone)]
1273pub struct ArgInfo {
1274    /// Long flag name without `--` prefix (e.g. `"amount-sats"`, `"to"`).
1275    pub long: String,
1276    /// Clap help string for this argument.
1277    pub help: String,
1278    /// Whether the argument is required.
1279    pub required: bool,
1280    /// Whether this is a boolean flag (no value, presence = true).
1281    pub is_flag: bool,
1282    /// Positional argument index (None for named `--` args).
1283    pub positional_index: Option<usize>,
1284}
1285
1286/// Return the list of user-facing arguments for a subcommand path.
1287///
1288/// Example: `subcommand_args(&["cashu", "send"])` returns the args for `afpay cashu send`.
1289///
1290/// Hidden args and internal clap args (`help`, `version`) are excluded.
1291/// Flattened structs (e.g. `CommonSendArgs`) are inlined automatically by clap.
1292#[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
1339// ═══════════════════════════════════════════
1340// Parsing
1341// ═══════════════════════════════════════════
1342
1343pub 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    // --help: recursive plain-text help (all subcommands expanded)
1348    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    // --help-markdown: Markdown for doc generation
1363    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
1709// ═══════════════════════════════════════════
1710// Validation helpers
1711// ═══════════════════════════════════════════
1712
1713fn 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    // Quick base58 character check (Bitcoin alphabet)
1727    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
2630/// Resolve rpc_endpoint/rpc_secret: CLI args take priority, then config.toml.
2631fn 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    // ═══════════════════════════════════════════
3372    // Top-level balance with --cashu-check
3373    // ═══════════════════════════════════════════
3374
3375    #[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    // ═══════════════════════════════════════════
3402    // BOLT12 offer tests
3403    // ═══════════════════════════════════════════
3404
3405    #[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}