fedimint_ln_client/
cli.rs

1use std::collections::BTreeMap;
2use std::time::UNIX_EPOCH;
3use std::{ffi, iter};
4
5use anyhow::{Context as _, bail};
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)]
42        amount: Option<Amount>,
43        /// Invoice comment/description, used on lnurl
44        #[clap(long)]
45        lnurl_comment: Option<String>,
46        /// Will return immediately after funding the payment
47        #[clap(long, action)]
48        finish_in_background: bool,
49        #[clap(long)]
50        gateway_id: Option<PublicKey>,
51        #[clap(long, default_value = "false")]
52        force_internal: bool,
53    },
54    /// Register and manage LNURLs
55    #[clap(subcommand)]
56    Lnurl(LnurlCommands),
57}
58
59#[derive(Subcommand, Serialize)]
60enum LnurlCommands {
61    /// Register a new LNURL payment code with a specific LNURL server
62    Register {
63        /// The LNURL server to register with
64        server_url: SafeUrl,
65        /// Set LNURL meta data, see LUD-06 for more details on the format
66        #[clap(long)]
67        meta: Option<String>,
68        ///Shrthand for setting the short description in the LNURL meta data
69        #[clap(long, default_value = "Fedimint LNURL Pay")]
70        description: String,
71    },
72    /// List all LNURLs registered
73    List,
74    /// List all invoices and their operation ids generated for a LNURL
75    Invoices { payment_code_idx: u64 },
76    /// List details for a specific invoice by operation id
77    InvoiceDetails { operation_id: OperationId },
78    /// Await a LNURL-triggered lightning receive operation to complete
79    AwaitInvoicePaid {
80        /// The operation ID of the receive operation to await
81        operation_id: OperationId,
82    },
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub struct LnInvoiceResponse {
88    pub operation_id: OperationId,
89    pub invoice: String,
90}
91
92pub(crate) async fn handle_cli_command(
93    module: &super::LightningClientModule,
94    args: &[ffi::OsString],
95) -> anyhow::Result<serde_json::Value> {
96    let opts = Opts::parse_from(iter::once(&ffi::OsString::from("meta")).chain(args.iter()));
97
98    Ok(match opts {
99        Opts::Invoice {
100            amount,
101            description,
102            expiry_time,
103            gateway_id,
104            force_internal,
105        } => {
106            let ln_gateway = module.get_gateway(gateway_id, force_internal).await?;
107
108            let desc = Description::new(description)?;
109            let (operation_id, invoice, _) = module
110                .create_bolt11_invoice(
111                    amount,
112                    Bolt11InvoiceDescription::Direct(&desc),
113                    expiry_time,
114                    (),
115                    ln_gateway,
116                )
117                .await?;
118            serde_json::to_value(LnInvoiceResponse {
119                operation_id,
120                invoice: invoice.to_string(),
121            })
122            .expect("Can't fail")
123        }
124        Opts::Pay {
125            payment_info,
126            amount,
127            finish_in_background,
128            lnurl_comment,
129            gateway_id,
130            force_internal,
131        } => {
132            let bolt11 = crate::get_invoice(&payment_info, amount, lnurl_comment).await?;
133            info!("Paying invoice: {bolt11}");
134            let ln_gateway = module.get_gateway(gateway_id, force_internal).await?;
135
136            let OutgoingLightningPayment {
137                payment_type,
138                contract_id,
139                fee,
140            } = module.pay_bolt11_invoice(ln_gateway, bolt11, ()).await?;
141            let operation_id = payment_type.operation_id();
142            info!(
143                "Gateway fee: {fee}, payment operation id: {}",
144                operation_id.fmt_short()
145            );
146            if finish_in_background {
147                module
148                    .wait_for_ln_payment(payment_type, contract_id, true)
149                    .await?;
150                info!("Payment will finish in background, use await-ln-pay to get the result");
151                serde_json::json! {
152                    {
153                        "operation_id": operation_id,
154                        "payment_type": payment_type.payment_type(),
155                        "contract_id": contract_id,
156                        "fee": fee,
157                    }
158                }
159            } else {
160                module
161                    .wait_for_ln_payment(payment_type, contract_id, false)
162                    .await?
163                    .context("expected a response")?
164            }
165        }
166        Opts::Lnurl(LnurlCommands::Register {
167            server_url,
168            meta,
169            description,
170        }) => {
171            let meta = meta.unwrap_or_else(|| {
172                serde_json::to_string(&json!([["text/plain", description]]))
173                    .expect("serialization can't fail")
174            });
175            let recurring_payment_code = module
176                .register_recurring_payment_code(RecurringPaymentProtocol::LNURL, server_url, &meta)
177                .await?;
178            json!({
179                "lnurl": recurring_payment_code.code,
180            })
181        }
182        Opts::Lnurl(LnurlCommands::List) => {
183            let codes: BTreeMap<u64, serde_json::Value> = module
184                .list_recurring_payment_codes()
185                .await
186                .into_iter()
187                .map(|(idx, code)| {
188                    let root_public_key = PaymentCodeRootKey(code.root_keypair.public_key());
189                    let recurring_payment_code_id = root_public_key.to_payment_code_id();
190                    let creation_timestamp = code
191                        .creation_time
192                        .duration_since(UNIX_EPOCH)
193                        .expect("Time went backwards")
194                        .as_secs();
195                    let code_json = json!({
196                        "lnurl": code.code,
197                        // TODO: use time_to_iso8601
198                        "creation_timestamp": creation_timestamp,
199                        "root_public_key": root_public_key,
200                        "recurring_payment_code_id": recurring_payment_code_id,
201                        "recurringd_api": code.recurringd_api,
202                        "last_derivation_index": code.last_derivation_index,
203                    });
204                    (idx, code_json)
205                })
206                .collect();
207
208            json!({
209                "codes": codes,
210            })
211        }
212        Opts::Lnurl(LnurlCommands::Invoices { payment_code_idx }) => {
213            // TODO: wait for background sync to complete
214            let invoices = module
215                .list_recurring_payment_code_invoices(payment_code_idx)
216                .await
217                .context("Unknown payment code index")?
218                .into_iter()
219                .map(|(idx, operation_id)| {
220                    let invoice = json!({
221                        "operation_id": operation_id,
222                    });
223                    (idx, invoice)
224                })
225                .collect::<BTreeMap<_, _>>();
226            json!({
227                "invoices": invoices,
228            })
229        }
230        Opts::Lnurl(LnurlCommands::InvoiceDetails { operation_id }) => {
231            let LightningOperationMetaVariant::RecurringPaymentReceive(operation_meta) = module
232                .client_ctx
233                .get_operation(operation_id)
234                .await?
235                .meta::<LightningOperationMeta>()
236                .variant
237            else {
238                bail!("Operation is not a recurring lightning receive");
239            };
240
241            json!({
242                "payment_code_id": operation_meta.payment_code_id,
243                "invoice": operation_meta.invoice,
244                "amount_msat": operation_meta.invoice.amount_milli_satoshis(),
245            })
246        }
247        Opts::Lnurl(LnurlCommands::AwaitInvoicePaid { operation_id }) => {
248            let LightningOperationMetaVariant::RecurringPaymentReceive(operation_meta) = module
249                .client_ctx
250                .get_operation(operation_id)
251                .await?
252                .meta::<LightningOperationMeta>()
253                .variant
254            else {
255                bail!("Operation is not a recurring lightning receive")
256            };
257            let mut stream = module
258                .subscribe_ln_recurring_receive(operation_id)
259                .await?
260                .into_stream();
261            while let Some(update) = stream.next().await {
262                debug!(?update, "Await invoice state update");
263                match update {
264                    LnReceiveState::Claimed => {
265                        let amount_msat = operation_meta.invoice.amount_milli_satoshis();
266                        return Ok(json!({
267                            "payment_code_id": operation_meta.payment_code_id,
268                            "invoice": operation_meta.invoice,
269                            "amount_msat": amount_msat,
270                        }));
271                    }
272                    LnReceiveState::Canceled { reason } => {
273                        return Err(reason.into());
274                    }
275                    _ => {}
276                }
277            }
278            unreachable!("Stream should not end without an outcome");
279        }
280    })
281}