Skip to main content

gateway_cli/
main.rs

1#![deny(clippy::pedantic, clippy::nursery)]
2
3mod config_commands;
4mod ecash_commands;
5mod general_commands;
6mod lightning_commands;
7mod onchain_commands;
8
9use std::collections::BTreeMap;
10
11use bitcoin::Txid;
12use bitcoin::address::NetworkUnchecked;
13use clap::{CommandFactory, Parser, Subcommand};
14use config_commands::ConfigCommands;
15use ecash_commands::EcashCommands;
16use fedimint_connectors::ConnectorRegistry;
17use fedimint_connectors::error::ServerError;
18use fedimint_core::PeerId;
19use fedimint_core::config::FederationId;
20use fedimint_core::invite_code::InviteCode;
21use fedimint_core::util::SafeUrl;
22use fedimint_gateway_common::{
23    ChannelInfo, CloseChannelsWithPeerResponse, CreateOfferResponse, FederationConfig,
24    FederationInfo, GatewayBalances, GatewayFedConfig, GatewayInfo, GetInvoiceResponse,
25    ListTransactionsResponse, MnemonicResponse, PayOfferResponse, PaymentLogResponse,
26    PaymentSummaryResponse, ReceiveEcashResponse, SpendEcashResponse, WithdrawResponse,
27};
28use fedimint_ln_common::client::GatewayApi;
29use fedimint_logging::TracingSetup;
30use general_commands::GeneralCommands;
31use lightning_commands::LightningCommands;
32use onchain_commands::OnchainCommands;
33use serde::Serialize;
34
35/// Unified output type for all gateway-cli commands.
36///
37/// This enum uses `#[serde(untagged)]` to serialize each variant directly
38/// as its inner type, maintaining backward compatibility with existing
39/// JSON output formats while providing type safety in the code.
40#[derive(Serialize)]
41#[serde(untagged)]
42pub enum CliOutput {
43    // General commands
44    Info(GatewayInfo),
45    Balances(GatewayBalances),
46    Federation(FederationInfo),
47    Mnemonic(MnemonicResponse),
48    PaymentLog(PaymentLogResponse),
49    PaymentSummary(PaymentSummaryResponse),
50    InviteCodes(BTreeMap<FederationId, BTreeMap<PeerId, (String, InviteCode)>>),
51    PasswordHash(String),
52
53    // Lightning commands
54    Invoice {
55        invoice: String,
56    },
57    Preimage {
58        preimage: String,
59    },
60    FundingTxid {
61        funding_txid: Txid,
62    },
63    Channels(Vec<ChannelInfo>),
64    CloseChannels(CloseChannelsWithPeerResponse),
65    InvoiceDetails(Option<GetInvoiceResponse>),
66    Transactions(ListTransactionsResponse),
67    Offer(CreateOfferResponse),
68    OfferPayment(PayOfferResponse),
69
70    // Ecash commands
71    DepositAddress {
72        address: bitcoin::Address<NetworkUnchecked>,
73    },
74    DepositRecheck(serde_json::Value),
75    PeginTxid {
76        txid: Txid,
77    },
78    Withdraw(WithdrawResponse),
79    SpendEcash(SpendEcashResponse),
80    ReceiveEcash(ReceiveEcashResponse),
81
82    // Onchain commands
83    OnchainAddress {
84        address: String,
85    },
86    SendOnchainTxid {
87        txid: Txid,
88    },
89
90    // Config commands
91    Config(GatewayFedConfig),
92    FederationConfigs(Vec<FederationConfig>),
93
94    // No output (for commands that succeed silently)
95    #[serde(skip)]
96    Empty,
97}
98
99/// Type alias for CLI command results using `ServerError` for precise error
100/// classification
101pub type CliOutputResult = Result<CliOutput, ServerError>;
102
103/// Machine-readable error codes for programmatic error handling.
104///
105/// These codes allow agents and scripts to handle errors programmatically
106/// without parsing error message strings.
107#[derive(Debug, Clone, Copy, Serialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ErrorCode {
110    /// Authentication failed (wrong password, missing auth)
111    AuthFailed,
112    /// Could not connect to gateway server
113    ConnectionFailed,
114    /// Invalid command arguments or request format
115    InvalidInput,
116    /// Requested resource not found (federation, invoice, etc.)
117    NotFound,
118    /// Gateway is not in correct state for operation
119    InvalidState,
120    /// Operation timed out
121    Timeout,
122    /// Internal gateway or client error
123    Internal,
124    /// Unknown/unclassified error
125    Unknown,
126}
127
128/// Exit codes for the CLI process.
129///
130/// These provide semantic meaning to the exit status, allowing scripts
131/// to handle different error categories appropriately.
132#[derive(Debug, Clone, Copy)]
133#[repr(i32)]
134pub enum ExitCode {
135    Success = 0,
136    GeneralError = 1,
137    ConnectionError = 2,
138    AuthError = 3,
139    InvalidInput = 4,
140    NotFound = 5,
141    Timeout = 6,
142}
143
144impl From<ErrorCode> for ExitCode {
145    fn from(code: ErrorCode) -> Self {
146        match code {
147            ErrorCode::AuthFailed => Self::AuthError,
148            ErrorCode::ConnectionFailed => Self::ConnectionError,
149            ErrorCode::InvalidInput => Self::InvalidInput,
150            ErrorCode::NotFound => Self::NotFound,
151            ErrorCode::Timeout => Self::Timeout,
152            ErrorCode::InvalidState | ErrorCode::Internal | ErrorCode::Unknown => {
153                Self::GeneralError
154            }
155        }
156    }
157}
158
159/// Structured error type for CLI output.
160///
161/// This provides machine-readable error information in JSON format,
162/// making it easier for agents and scripts to handle errors programmatically.
163#[derive(Debug, Serialize)]
164pub struct CliError {
165    /// Human-readable error message
166    pub error: String,
167
168    /// Machine-readable error code for programmatic handling
169    pub code: ErrorCode,
170
171    /// The immediate cause of the error, if available
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub cause: Option<String>,
174}
175
176impl CliError {
177    /// Create a new `CliError` from a `ServerError`.
178    ///
179    /// This extracts the error message, classifies the error code,
180    /// and captures the immediate cause.
181    fn from_server_error(err: &ServerError) -> Self {
182        let error = err.to_string();
183        let code = Self::classify_server_error(err);
184        let cause = std::error::Error::source(err).map(ToString::to_string);
185
186        Self { error, code, cause }
187    }
188
189    /// Classify a `ServerError` into an appropriate `ErrorCode`.
190    const fn classify_server_error(err: &ServerError) -> ErrorCode {
191        match err {
192            // Connection and transport errors
193            ServerError::Connection(_) | ServerError::Transport(_) => ErrorCode::ConnectionFailed,
194
195            // Invalid input errors
196            ServerError::InvalidPeerId { .. }
197            | ServerError::InvalidPeerUrl { .. }
198            | ServerError::InvalidEndpoint(_)
199            | ServerError::InvalidRpcId(_) => ErrorCode::InvalidInput,
200
201            // Internal errors (response parsing, server errors, client errors)
202            ServerError::InvalidRequest(_)
203            | ServerError::ResponseDeserialization(_)
204            | ServerError::InvalidResponse(_)
205            | ServerError::ServerError(_)
206            | ServerError::InternalClientError(_) => ErrorCode::Internal,
207
208            // Condition failures (often "not found" scenarios)
209            ServerError::ConditionFailed(_) => ErrorCode::NotFound,
210
211            // Catch-all for future ServerError variants (enum is non_exhaustive)
212            _ => ErrorCode::Unknown,
213        }
214    }
215}
216
217#[derive(Parser)]
218#[command(version)]
219struct Cli {
220    /// The address of the gateway webserver
221    #[clap(long, short, default_value = "http://127.0.0.1:80")]
222    address: SafeUrl,
223
224    /// The command to execute
225    #[command(subcommand)]
226    command: Commands,
227
228    /// Password for authenticated requests to the gateway
229    #[clap(long)]
230    rpcpassword: Option<String>,
231}
232
233#[derive(Subcommand)]
234enum Commands {
235    #[command(flatten)]
236    General(GeneralCommands),
237    #[command(subcommand)]
238    Lightning(LightningCommands),
239    #[command(subcommand)]
240    Ecash(EcashCommands),
241    #[command(subcommand)]
242    Onchain(OnchainCommands),
243    #[command(subcommand)]
244    Cfg(ConfigCommands),
245    Completion {
246        shell: clap_complete::Shell,
247    },
248}
249
250#[tokio::main]
251async fn main() {
252    if let Err(err) = TracingSetup::default().init() {
253        let cli_err = CliError {
254            error: format!("Failed to initialize logging: {err}"),
255            code: ErrorCode::Internal,
256            cause: None,
257        };
258        print_response(&cli_err);
259        std::process::exit(ExitCode::GeneralError as i32);
260    }
261
262    if let Err(err) = run().await {
263        let cli_err = CliError::from_server_error(&err);
264        let exit_code = ExitCode::from(cli_err.code);
265        print_response(&cli_err);
266        std::process::exit(exit_code as i32);
267    }
268}
269
270async fn run() -> CliOutputResult {
271    let cli = Cli::parse();
272    let connector_registry = ConnectorRegistry::build_from_client_defaults()
273        .with_env_var_overrides()
274        .map_err(ServerError::InternalClientError)?
275        .bind()
276        .await
277        .map_err(ServerError::InternalClientError)?;
278    let client = GatewayApi::new(cli.rpcpassword, connector_registry);
279
280    let output = match cli.command {
281        Commands::General(general_command) => general_command.handle(&client, &cli.address).await?,
282        Commands::Lightning(lightning_command) => {
283            lightning_command.handle(&client, &cli.address).await?
284        }
285        Commands::Ecash(ecash_command) => ecash_command.handle(&client, &cli.address).await?,
286        Commands::Onchain(onchain_command) => onchain_command.handle(&client, &cli.address).await?,
287        Commands::Cfg(config_commands) => config_commands.handle(&client, &cli.address).await?,
288        Commands::Completion { shell } => {
289            clap_complete::generate(
290                shell,
291                &mut Cli::command(),
292                "gateway-cli",
293                &mut std::io::stdout(),
294            );
295            return Ok(CliOutput::Empty);
296        }
297    };
298
299    // Only print output for non-empty results
300    if !matches!(output, CliOutput::Empty) {
301        print_response(&output);
302    }
303
304    Ok(output)
305}
306
307fn print_response<T: Serialize>(val: T) {
308    println!(
309        "{}",
310        serde_json::to_string_pretty(&val).expect("Cannot serialize")
311    );
312}