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