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 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 {
38 payment_info: String,
40 #[clap(long, conflicts_with = "all")]
42 amount: Option<Amount>,
43 #[clap(long, default_value = "false")]
48 all: bool,
49 #[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 AwaitInvoice {
59 operation_id: OperationId,
61 },
62 AwaitPay {
64 operation_id: OperationId,
66 },
67 ListGateways {
69 #[clap(long, default_value = "false")]
71 no_update: bool,
72 },
73 #[clap(subcommand)]
75 Lnurl(LnurlCommands),
76}
77
78#[derive(Subcommand, Serialize)]
79enum LnurlCommands {
80 Register {
82 server_url: SafeUrl,
84 #[clap(long)]
86 meta: Option<String>,
87 #[clap(long, default_value = "Fedimint LNURL Pay")]
89 description: String,
90 },
91 List,
93 Invoices { payment_code_idx: u64 },
95 InvoiceDetails { operation_id: OperationId },
97 AwaitInvoicePaid {
99 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 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 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 "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 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}