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 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 {
43 payment_info: String,
45 #[clap(long, conflicts_with = "all")]
47 amount: Option<Amount>,
48 #[clap(long, default_value = "false")]
53 all: bool,
54 #[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 AwaitInvoice {
64 operation_id: OperationId,
66 },
67 AwaitPay {
69 operation_id: OperationId,
71 },
72 ListGateways {
74 #[clap(long, default_value = "false")]
76 no_update: bool,
77 },
78 #[clap(subcommand)]
80 Lnurl(LnurlCommands),
81}
82
83#[derive(Subcommand, Serialize)]
84enum LnurlCommands {
85 Register {
87 server_url: SafeUrl,
89 #[clap(long)]
91 meta: Option<String>,
92 #[clap(long, default_value = "Fedimint LNURL Pay")]
94 description: String,
95 },
96 List,
98 Invoices { payment_code_idx: u64 },
100 InvoiceDetails { operation_id: OperationId },
102 AwaitInvoicePaid {
104 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 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 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 "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 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#[derive(Debug, thiserror::Error)]
368pub(crate) enum CliCommandError {
369 #[error(transparent)]
371 GatewaySelection(#[from] GatewaySelectionError),
372
373 #[error(transparent)]
375 InvoiceDescription(#[from] CreationError),
376
377 #[error(transparent)]
379 CreateInvoice(#[from] CreateBolt11InvoiceError),
380
381 #[error(transparent)]
384 PaymentInfo(#[from] PaymentInfoError),
385
386 #[error("--all is only valid for LNURL/Lightning Address payments, not fixed-amount invoices")]
388 AllRequiresLnurl,
389
390 #[error("--all requires a gateway to price the payment; internal payments are not supported")]
392 AllRequiresGateway,
393
394 #[error(transparent)]
396 Balance(#[from] ModuleLookupError),
397
398 #[error(transparent)]
400 SpendableAmount(#[from] SpendableAmountError),
401
402 #[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 #[error(transparent)]
414 Pay(#[from] PayBolt11InvoiceError),
415
416 #[error(transparent)]
418 Subscribe(#[from] LnSubscribeError),
419
420 #[error(transparent)]
422 Receive(#[from] LightningReceiveError),
423
424 #[error(transparent)]
426 Federation(#[from] FederationError),
427
428 #[error(transparent)]
430 RecurringdApi(#[from] RecurringdApiError),
431
432 #[error("Unknown payment code index")]
434 UnknownPaymentCodeIndex,
435
436 #[error(transparent)]
438 OperationLookup(#[from] OperationLookupError),
439
440 #[error("Operation is not a recurring lightning receive")]
442 NotRecurringReceive,
443}