Skip to main content

fedimint_ln_client/
cli.rs

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