Skip to main content

fedimint_ln_client/
cli.rs

1use std::collections::BTreeMap;
2use std::time::UNIX_EPOCH;
3use std::{ffi, iter};
4
5use clap::{Parser, Subcommand};
6use fedimint_api_client::api::FederationError;
7use fedimint_client_module::error::{ModuleLookupError, OperationLookupError};
8use fedimint_core::Amount;
9use fedimint_core::core::OperationId;
10use fedimint_core::secp256k1::PublicKey;
11use fedimint_core::util::SafeUrl;
12use futures::StreamExt;
13use lightning_invoice::{Bolt11InvoiceDescription, CreationError, Description};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use tracing::{debug, info};
17
18use crate::receive::LightningReceiveError;
19use crate::recurring::api::RecurringdApiError;
20use crate::recurring::{PaymentCodeRootKey, RecurringPaymentProtocol};
21use crate::{
22    CreateBolt11InvoiceError, GatewaySelectionError, LightningOperationMeta,
23    LightningOperationMetaVariant, LnReceiveState, LnSubscribeError, OutgoingLightningPayment,
24    PayBolt11InvoiceError, PaymentInfoError, SpendableAmountError,
25};
26
27#[derive(Parser, Serialize)]
28enum Opts {
29    /// Create a lightning invoice to receive payment via gateway
30    Invoice {
31        amount: Amount,
32        #[clap(long, default_value = "")]
33        description: String,
34        #[clap(long)]
35        expiry_time: Option<u64>,
36        #[clap(long)]
37        gateway_id: Option<PublicKey>,
38        #[clap(long, default_value = "false")]
39        force_internal: bool,
40    },
41    /// Pay a lightning invoice or lnurl via a gateway
42    Pay {
43        /// Lightning invoice or lnurl
44        payment_info: String,
45        /// Amount to pay, used for lnurl
46        #[clap(long, conflicts_with = "all")]
47        amount: Option<Amount>,
48        /// Spend as much of the balance as possible after gateway and
49        /// federation fees, capped at the recipient's LNURL maxSendable limit.
50        /// Only valid for LNURL/Lightning Address payments, where the sender
51        /// chooses the amount.
52        #[clap(long, default_value = "false")]
53        all: bool,
54        /// Invoice comment/description, used on lnurl
55        #[clap(long)]
56        lnurl_comment: Option<String>,
57        #[clap(long)]
58        gateway_id: Option<PublicKey>,
59        #[clap(long, default_value = "false")]
60        force_internal: bool,
61    },
62    /// Wait for incoming invoice to be paid
63    AwaitInvoice {
64        /// The operation ID of the invoice operation to await
65        operation_id: OperationId,
66    },
67    /// Wait for a lightning payment to complete
68    AwaitPay {
69        /// The operation ID of the payment operation to await
70        operation_id: OperationId,
71    },
72    /// List registered gateways
73    ListGateways {
74        /// Don't fetch the registered gateways from the federation
75        #[clap(long, default_value = "false")]
76        no_update: bool,
77    },
78    /// Register and manage LNURLs
79    #[clap(subcommand)]
80    Lnurl(LnurlCommands),
81}
82
83#[derive(Subcommand, Serialize)]
84enum LnurlCommands {
85    /// Register a new LNURL payment code with a specific LNURL server
86    Register {
87        /// The LNURL server to register with
88        server_url: SafeUrl,
89        /// Set LNURL meta data, see LUD-06 for more details on the format
90        #[clap(long)]
91        meta: Option<String>,
92        ///Shrthand for setting the short description in the LNURL meta data
93        #[clap(long, default_value = "Fedimint LNURL Pay")]
94        description: String,
95    },
96    /// List all LNURLs registered
97    List,
98    /// List all invoices and their operation ids generated for a LNURL
99    Invoices { payment_code_idx: u64 },
100    /// List details for a specific invoice by operation id
101    InvoiceDetails { operation_id: OperationId },
102    /// Await a LNURL-triggered lightning receive operation to complete
103    AwaitInvoicePaid {
104        /// The operation ID of the receive operation to await
105        operation_id: OperationId,
106    },
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct LnInvoiceResponse {
112    pub operation_id: OperationId,
113    pub invoice: String,
114}
115
116pub(crate) async fn handle_cli_command(
117    module: &super::LightningClientModule,
118    args: &[ffi::OsString],
119) -> Result<serde_json::Value, CliCommandError> {
120    let opts = Opts::parse_from(iter::once(&ffi::OsString::from("meta")).chain(args.iter()));
121
122    Ok(match opts {
123        Opts::Invoice {
124            amount,
125            description,
126            expiry_time,
127            gateway_id,
128            force_internal,
129        } => {
130            let ln_gateway = module.get_gateway(gateway_id, force_internal).await?;
131
132            let desc = Description::new(description)?;
133            let (operation_id, invoice, _) = module
134                .create_bolt11_invoice(
135                    amount,
136                    Bolt11InvoiceDescription::Direct(desc),
137                    expiry_time,
138                    (),
139                    ln_gateway,
140                )
141                .await?;
142            serde_json::to_value(LnInvoiceResponse {
143                operation_id,
144                invoice: invoice.to_string(),
145            })
146            .expect("Can't fail")
147        }
148        Opts::Pay {
149            payment_info,
150            amount,
151            all,
152            lnurl_comment,
153            gateway_id,
154            force_internal,
155        } => {
156            // Resolve the gateway up front so the same one both prices `--all`
157            // and settles the payment.
158            let ln_gateway = module.get_gateway(gateway_id, force_internal).await?;
159
160            let payment_info = crate::PaymentInfo::parse(&payment_info).await?;
161
162            let amount = if all {
163                let crate::PaymentInfo::Lnurl(pay_response) = &payment_info else {
164                    return Err(CliCommandError::AllRequiresLnurl);
165                };
166
167                let gateway = ln_gateway
168                    .clone()
169                    .ok_or(CliCommandError::AllRequiresGateway)?;
170                let balance = module.client_ctx.get_balance_for_btc().await?;
171                let spendable = module.spendable_amount(balance, Some(gateway)).await?;
172
173                // The endpoint only issues invoices within
174                // [minSendable, maxSendable].
175                let max_sendable = Amount::from_msats(pay_response.max_sendable);
176                let min_sendable = Amount::from_msats(pay_response.min_sendable);
177                let capped = spendable.min(max_sendable);
178                if capped < min_sendable {
179                    return Err(CliCommandError::BelowMinSendable {
180                        capped,
181                        min_sendable,
182                    });
183                }
184                if capped < spendable {
185                    info!(
186                        "Balance supports sending {spendable}, but the recipient's LNURL maxSendable caps the payment at {capped}"
187                    );
188                } else {
189                    info!("Spending entire balance, requesting invoice for {capped}");
190                }
191                Some(capped)
192            } else {
193                amount
194            };
195
196            let bolt11 = payment_info.get_invoice(amount, lnurl_comment).await?;
197            info!("Paying invoice: {bolt11}");
198
199            let OutgoingLightningPayment {
200                payment_type,
201                contract_id: _,
202                fee,
203            } = module.pay_bolt11_invoice(ln_gateway, bolt11, ()).await?;
204            let operation_id = payment_type.operation_id();
205            info!(
206                "Gateway fee: {fee}, payment operation id: {}",
207                operation_id.fmt_short()
208            );
209            let outcome = module.await_outgoing_payment(operation_id).await?;
210            serde_json::to_value(outcome).expect("cant fail")
211        }
212        Opts::AwaitInvoice { operation_id } => {
213            let mut updates = module
214                .subscribe_ln_receive(operation_id)
215                .await?
216                .into_stream();
217            while let Some(update) = updates.next().await {
218                debug!(?update, "Await invoice state update");
219                match update {
220                    LnReceiveState::Claimed => {
221                        return Ok(json!({
222                            "status": "paid"
223                        }));
224                    }
225                    LnReceiveState::Canceled { reason } => {
226                        return Err(reason.into());
227                    }
228                    _ => {}
229                }
230            }
231            unreachable!("Stream should not end without an outcome");
232        }
233        Opts::AwaitPay { operation_id } => {
234            let outcome = module.await_outgoing_payment(operation_id).await?;
235            serde_json::to_value(outcome).expect("serialization can't fail")
236        }
237        Opts::ListGateways { no_update } => {
238            if !no_update {
239                module.update_gateway_cache().await?;
240            }
241            let gateways = module.list_gateways().await;
242            if gateways.is_empty() {
243                return Ok(
244                    serde_json::to_value(Vec::<String>::new()).expect("serialization can't fail")
245                );
246            }
247            json!(&gateways)
248        }
249        Opts::Lnurl(LnurlCommands::Register {
250            server_url,
251            meta,
252            description,
253        }) => {
254            let meta = meta.unwrap_or_else(|| {
255                serde_json::to_string(&json!([["text/plain", description]]))
256                    .expect("serialization can't fail")
257            });
258            let recurring_payment_code = module
259                .register_recurring_payment_code(RecurringPaymentProtocol::LNURL, server_url, &meta)
260                .await?;
261            json!({
262                "lnurl": recurring_payment_code.code,
263            })
264        }
265        Opts::Lnurl(LnurlCommands::List) => {
266            let codes: BTreeMap<u64, serde_json::Value> = module
267                .list_recurring_payment_codes()
268                .await
269                .into_iter()
270                .map(|(idx, code)| {
271                    let root_public_key = PaymentCodeRootKey(code.root_keypair.public_key());
272                    let recurring_payment_code_id = root_public_key.to_payment_code_id();
273                    let creation_timestamp = code
274                        .creation_time
275                        .duration_since(UNIX_EPOCH)
276                        .expect("Time went backwards")
277                        .as_secs();
278                    let code_json = json!({
279                        "lnurl": code.code,
280                        // TODO: use time_to_iso8601
281                        "creation_timestamp": creation_timestamp,
282                        "root_public_key": root_public_key,
283                        "recurring_payment_code_id": recurring_payment_code_id,
284                        "recurringd_api": code.recurringd_api,
285                        "last_derivation_index": code.last_derivation_index,
286                    });
287                    (idx, code_json)
288                })
289                .collect();
290
291            json!({
292                "codes": codes,
293            })
294        }
295        Opts::Lnurl(LnurlCommands::Invoices { payment_code_idx }) => {
296            // TODO: wait for background sync to complete
297            let invoices = module
298                .list_recurring_payment_code_invoices(payment_code_idx)
299                .await
300                .ok_or(CliCommandError::UnknownPaymentCodeIndex)?
301                .into_iter()
302                .map(|(idx, operation_id)| {
303                    let invoice = json!({
304                        "operation_id": operation_id,
305                    });
306                    (idx, invoice)
307                })
308                .collect::<BTreeMap<_, _>>();
309            json!({
310                "invoices": invoices,
311            })
312        }
313        Opts::Lnurl(LnurlCommands::InvoiceDetails { operation_id }) => {
314            let LightningOperationMetaVariant::RecurringPaymentReceive(operation_meta) = module
315                .client_ctx
316                .get_operation(operation_id)
317                .await?
318                .meta::<LightningOperationMeta>()
319                .variant
320            else {
321                return Err(CliCommandError::NotRecurringReceive);
322            };
323
324            json!({
325                "payment_code_id": operation_meta.payment_code_id,
326                "invoice": operation_meta.invoice,
327                "amount_msat": operation_meta.invoice.amount_milli_satoshis(),
328            })
329        }
330        Opts::Lnurl(LnurlCommands::AwaitInvoicePaid { operation_id }) => {
331            let LightningOperationMetaVariant::RecurringPaymentReceive(operation_meta) = module
332                .client_ctx
333                .get_operation(operation_id)
334                .await?
335                .meta::<LightningOperationMeta>()
336                .variant
337            else {
338                return Err(CliCommandError::NotRecurringReceive);
339            };
340            let mut stream = module
341                .subscribe_ln_recurring_receive(operation_id)
342                .await?
343                .into_stream();
344            while let Some(update) = stream.next().await {
345                debug!(?update, "Await invoice state update");
346                match update {
347                    LnReceiveState::Claimed => {
348                        let amount_msat = operation_meta.invoice.amount_milli_satoshis();
349                        return Ok(json!({
350                            "payment_code_id": operation_meta.payment_code_id,
351                            "invoice": operation_meta.invoice,
352                            "amount_msat": amount_msat,
353                        }));
354                    }
355                    LnReceiveState::Canceled { reason } => {
356                        return Err(reason.into());
357                    }
358                    _ => {}
359                }
360            }
361            unreachable!("Stream should not end without an outcome");
362        }
363    })
364}
365
366/// A failure of an `ln` module command.
367#[derive(Debug, thiserror::Error)]
368pub(crate) enum CliCommandError {
369    /// No gateway could be selected.
370    #[error(transparent)]
371    GatewaySelection(#[from] GatewaySelectionError),
372
373    /// The invoice description is not valid.
374    #[error(transparent)]
375    InvoiceDescription(#[from] CreationError),
376
377    /// The invoice could not be created.
378    #[error(transparent)]
379    CreateInvoice(#[from] CreateBolt11InvoiceError),
380
381    /// The payment information could not be parsed or resolved to an
382    /// invoice.
383    #[error(transparent)]
384    PaymentInfo(#[from] PaymentInfoError),
385
386    /// `--all` was given for a fixed-amount invoice.
387    #[error("--all is only valid for LNURL/Lightning Address payments, not fixed-amount invoices")]
388    AllRequiresLnurl,
389
390    /// `--all` was given without a gateway to price the payment with.
391    #[error("--all requires a gateway to price the payment; internal payments are not supported")]
392    AllRequiresGateway,
393
394    /// The client's bitcoin balance could not be read.
395    #[error(transparent)]
396    Balance(#[from] ModuleLookupError),
397
398    /// The spendable amount could not be computed.
399    #[error(transparent)]
400    SpendableAmount(#[from] SpendableAmountError),
401
402    /// The balance cannot cover the least the recipient accepts.
403    #[error(
404        "--all can send at most {capped}, but the recipient requires at least \
405         {min_sendable} (LNURL minSendable)"
406    )]
407    BelowMinSendable {
408        capped: Amount,
409        min_sendable: Amount,
410    },
411
412    /// The invoice could not be paid.
413    #[error(transparent)]
414    Pay(#[from] PayBolt11InvoiceError),
415
416    /// An operation's updates could not be followed.
417    #[error(transparent)]
418    Subscribe(#[from] LnSubscribeError),
419
420    /// The incoming payment was canceled.
421    #[error(transparent)]
422    Receive(#[from] LightningReceiveError),
423
424    /// The federation could not be asked for its gateways.
425    #[error(transparent)]
426    Federation(#[from] FederationError),
427
428    /// The LNURL server did not register the payment code.
429    #[error(transparent)]
430    RecurringdApi(#[from] RecurringdApiError),
431
432    /// No LNURL payment code has this index.
433    #[error("Unknown payment code index")]
434    UnknownPaymentCodeIndex,
435
436    /// The operation could not be looked up.
437    #[error(transparent)]
438    OperationLookup(#[from] OperationLookupError),
439
440    /// The operation is not an LNURL-triggered receive.
441    #[error("Operation is not a recurring lightning receive")]
442    NotRecurringReceive,
443}