Skip to main content

fedimint_lnv2_client/
cli.rs

1use std::path::{Path, PathBuf};
2use std::{ffi, iter};
3
4use bitcoin::hashes::sha256;
5use bitcoin::secp256k1;
6use clap::{Parser, Subcommand};
7use fedimint_api_client::api::FederationError;
8use fedimint_client_module::error::OperationLookupError;
9use fedimint_core::core::OperationId;
10use fedimint_core::util::SafeUrl;
11use fedimint_core::{Amount, OutPoint, PeerId, TransactionId, hex};
12use fedimint_lnv2_common::contracts::{OutgoingContract, PaymentImage};
13use lightning_invoice::Bolt11Invoice;
14use secp256k1::schnorr::Signature;
15use secp256k1::{Keypair, PublicKey, SecretKey};
16use serde::Serialize;
17use serde_json::Value;
18
19use crate::api::LightningFederationApi;
20use crate::{
21    Bolt11InvoiceDescription, GenerateLnurlError, LightningClientModule, ListGatewaysError,
22    ReceiveError, SelectGatewayError, SendPaymentError,
23};
24
25#[cfg(test)]
26mod tests;
27
28#[derive(Parser, Serialize)]
29enum Opts {
30    /// Pay an invoice. For  testing  you can optionally specify a gateway to
31    /// route with, otherwise a gateway will be selected automatically.
32    Send {
33        invoice: Bolt11Invoice,
34        #[arg(long)]
35        gateway: Option<SafeUrl>,
36    },
37    /// Await the final state of the send operation.
38    AwaitSend { operation_id: OperationId },
39    /// Request an invoice. For testing you can optionally specify a gateway to
40    /// generate the invoice, otherwise a gateway will be selected
41    /// automatically.
42    Receive {
43        amount: Amount,
44        #[arg(long)]
45        gateway: Option<SafeUrl>,
46    },
47    /// Await the final state of the receive operation.
48    AwaitReceive { operation_id: OperationId },
49    /// Lnurl subcommands
50    #[command(subcommand)]
51    Lnurl(LnurlOpts),
52    /// Gateway subcommands
53    #[command(subcommand)]
54    Gateways(GatewaysOpts),
55    /// Direct HTLC subcommands to swap with a counterparty in the same
56    /// federation without a gateway
57    #[command(subcommand)]
58    Htlc(HtlcOpts),
59}
60
61#[derive(Clone, Subcommand, Serialize)]
62enum HtlcOpts {
63    /// Fund an HTLC locked to the counterparty's claim public key. Requires
64    /// exactly one of --payment-hash and --payment-point.
65    Create {
66        amount: Amount,
67        claim_pk: PublicKey,
68        expiration_delta: u64,
69        #[arg(long)]
70        payment_hash: Option<sha256::Hash>,
71        #[arg(long)]
72        payment_point: Option<PublicKey>,
73    },
74    /// Generate a random claim keypair to receive an HTLC with. The secret key
75    /// is written as hex to a new file created with owner-only permissions and
76    /// only the public key is printed.
77    NewClaimKeypair {
78        /// Path of the new file to write the secret key to. An existing file is
79        /// never overwritten.
80        #[arg(long)]
81        secret_key_file: PathBuf,
82    },
83    /// Wait until the contract is funded at the outpoint and print the number
84    /// of blocks remaining until its expiration.
85    AwaitFunded {
86        funding_txid: TransactionId,
87        #[arg(long, default_value_t = 0)]
88        out_idx: u64,
89        contract: String,
90    },
91    /// Claim a funded HTLC with the preimage of its payment image.
92    Claim {
93        funding_txid: TransactionId,
94        #[arg(long, default_value_t = 0)]
95        out_idx: u64,
96        contract: String,
97        /// Read the hex claim secret key from a file, or '-' for stdin.
98        #[arg(long)]
99        claim_sk_file: PathBuf,
100        /// Read the hex preimage from a file, or '-' for stdin.
101        #[arg(long)]
102        preimage_file: PathBuf,
103    },
104    /// Create a forfeit signature to fail an HTLC locked to our claim key
105    /// cooperatively.
106    Forfeit {
107        contract: String,
108        /// Read the hex claim secret key from a file, or '-' for stdin.
109        #[arg(long)]
110        claim_sk_file: PathBuf,
111    },
112    /// Cancel an HTLC we created before its expiration with the
113    /// counterparty's forfeit signature.
114    Cancel {
115        funding_txid: TransactionId,
116        #[arg(long, default_value_t = 0)]
117        out_idx: u64,
118        contract: String,
119        forfeit_signature: Signature,
120    },
121    /// Refund an HTLC we created after its expiration.
122    Refund {
123        funding_txid: TransactionId,
124        #[arg(long, default_value_t = 0)]
125        out_idx: u64,
126        contract: String,
127    },
128    /// Wait until an HTLC we created is claimed, printing the preimage, or
129    /// expired unclaimed, printing null.
130    AwaitResolution {
131        funding_txid: TransactionId,
132        #[arg(long, default_value_t = 0)]
133        out_idx: u64,
134        contract: String,
135    },
136    /// Wait until the transaction of an HTLC operation is accepted and any
137    /// ecash it issues has been minted.
138    AwaitSettled { operation_id: OperationId },
139}
140
141#[derive(Clone, Subcommand, Serialize)]
142enum LnurlOpts {
143    /// Generate a new lnurl.
144    Generate {
145        recurringd: SafeUrl,
146        #[arg(long)]
147        gateway: Option<SafeUrl>,
148    },
149}
150
151#[derive(Clone, Subcommand, Serialize)]
152enum GatewaysOpts {
153    /// Update the mapping from lightning node public keys to gateway api
154    /// endpoints maintained in the module database to optimise gateway
155    /// selection for a given invoice; this command is intended for testing.
156    Map,
157    /// Select an online vetted gateway; this command is intended for testing.
158    Select {
159        #[arg(long)]
160        invoice: Option<Bolt11Invoice>,
161    },
162    /// List all vetted gateways.
163    List {
164        #[arg(long)]
165        peer: Option<PeerId>,
166    },
167    /// Add a vetted gateway.
168    Add { gateway: SafeUrl },
169    /// Remove a vetted gateway.
170    Remove { gateway: SafeUrl },
171}
172
173pub(crate) async fn handle_cli_command(
174    lightning: &LightningClientModule,
175    args: &[ffi::OsString],
176) -> Result<serde_json::Value, CliCommandError> {
177    let opts = Opts::parse_from(iter::once(&ffi::OsString::from("lnv2")).chain(args.iter()));
178
179    let value = match opts {
180        Opts::Send { gateway, invoice } => {
181            json(lightning.send(invoice, gateway, Value::Null).await?)
182        }
183        Opts::AwaitSend { operation_id } => json(
184            lightning
185                .await_final_send_operation_state(operation_id)
186                .await?,
187        ),
188        Opts::Receive { amount, gateway } => json(
189            lightning
190                .receive(
191                    amount,
192                    3600,
193                    Bolt11InvoiceDescription::Direct(String::new()),
194                    gateway,
195                    Value::Null,
196                )
197                .await?,
198        ),
199        Opts::AwaitReceive { operation_id } => json(
200            lightning
201                .await_final_receive_operation_state(operation_id)
202                .await?,
203        ),
204        Opts::Lnurl(lnurl_opts) => match lnurl_opts {
205            LnurlOpts::Generate {
206                recurringd,
207                gateway,
208            } => json(lightning.generate_lnurl(recurringd, gateway).await?),
209        },
210        Opts::Gateways(gateway_opts) => match gateway_opts {
211            #[allow(clippy::unit_arg)]
212            GatewaysOpts::Map => json(lightning.update_gateway_map().await),
213            GatewaysOpts::Select { invoice } => json(lightning.select_gateway(invoice).await?.0),
214            GatewaysOpts::List { peer } => json(lightning.list_gateways(peer).await?),
215            GatewaysOpts::Add { gateway } => {
216                let auth = lightning
217                    .admin_auth
218                    .clone()
219                    .ok_or(CliCommandError::AdminAuthNotSet)?;
220
221                json(lightning.module_api.add_gateway(auth, gateway).await?)
222            }
223            GatewaysOpts::Remove { gateway } => {
224                let auth = lightning
225                    .admin_auth
226                    .clone()
227                    .ok_or(CliCommandError::AdminAuthNotSet)?;
228
229                json(lightning.module_api.remove_gateway(auth, gateway).await?)
230            }
231        },
232        Opts::Htlc(htlc_opts) => handle_htlc_command(lightning, htlc_opts).await?,
233    };
234
235    Ok(value)
236}
237
238#[allow(clippy::too_many_lines)]
239async fn handle_htlc_command(
240    lightning: &LightningClientModule,
241    htlc_opts: HtlcOpts,
242) -> Result<Value, CliCommandError> {
243    let value = match htlc_opts {
244        HtlcOpts::Create {
245            amount,
246            claim_pk,
247            expiration_delta,
248            payment_hash,
249            payment_point,
250        } => {
251            let payment_image = match (payment_hash, payment_point) {
252                (Some(hash), None) => PaymentImage::Hash(hash),
253                (None, Some(point)) => PaymentImage::Point(point),
254                _ => return Err(CliCommandError::InvalidPaymentImage),
255            };
256
257            let (operation_id, outpoint, contract) = lightning
258                .create_htlc(
259                    amount,
260                    payment_image,
261                    claim_pk,
262                    expiration_delta,
263                    Value::Null,
264                )
265                .await?;
266
267            json(serde_json::json!({
268                "operation_id": operation_id,
269                "outpoint": outpoint,
270                "contract": contract,
271            }))
272        }
273        HtlcOpts::NewClaimKeypair { secret_key_file } => {
274            let keypair = Keypair::new(secp256k1::SECP256K1, &mut rand::thread_rng());
275
276            write_secret_key_file(&secret_key_file, &keypair.secret_key())?;
277
278            json(serde_json::json!({
279                "public_key": keypair.public_key(),
280            }))
281        }
282        HtlcOpts::AwaitFunded {
283            funding_txid,
284            out_idx,
285            contract,
286        } => json(
287            lightning
288                .await_htlc_funded(outpoint(funding_txid, out_idx), &parse_contract(&contract)?)
289                .await?,
290        ),
291        HtlcOpts::Claim {
292            funding_txid,
293            out_idx,
294            contract,
295            claim_sk_file,
296            preimage_file,
297        } => {
298            let contract = parse_contract(&contract)?;
299
300            ensure_single_stdin(&claim_sk_file, &preimage_file)?;
301
302            let claim_sk = read_claim_sk(&claim_sk_file)?;
303            let preimage = read_preimage(&preimage_file)?;
304
305            json(
306                lightning
307                    .claim_htlc(
308                        outpoint(funding_txid, out_idx),
309                        contract,
310                        claim_sk.keypair(secp256k1::SECP256K1),
311                        preimage,
312                        Value::Null,
313                    )
314                    .await?,
315            )
316        }
317        HtlcOpts::Forfeit {
318            contract,
319            claim_sk_file,
320        } => {
321            let contract = parse_contract(&contract)?;
322            let claim_sk = read_claim_sk(&claim_sk_file)?;
323
324            json(LightningClientModule::create_htlc_forfeit_signature(
325                &contract,
326                &claim_sk.keypair(secp256k1::SECP256K1),
327            )?)
328        }
329        HtlcOpts::Cancel {
330            funding_txid,
331            out_idx,
332            contract,
333            forfeit_signature,
334        } => json(
335            lightning
336                .cancel_htlc(
337                    outpoint(funding_txid, out_idx),
338                    parse_contract(&contract)?,
339                    forfeit_signature,
340                    Value::Null,
341                )
342                .await?,
343        ),
344        HtlcOpts::Refund {
345            funding_txid,
346            out_idx,
347            contract,
348        } => json(
349            lightning
350                .refund_htlc(
351                    outpoint(funding_txid, out_idx),
352                    parse_contract(&contract)?,
353                    Value::Null,
354                )
355                .await?,
356        ),
357        HtlcOpts::AwaitResolution {
358            funding_txid,
359            out_idx,
360            contract,
361        } => json(
362            lightning
363                .await_htlc_resolution(outpoint(funding_txid, out_idx), &parse_contract(&contract)?)
364                .await?
365                .map(hex::encode),
366        ),
367        HtlcOpts::AwaitSettled { operation_id } => {
368            lightning.await_htlc_operation_settled(operation_id).await?;
369
370            json("settled")
371        }
372    };
373
374    Ok(value)
375}
376
377fn outpoint(txid: TransactionId, out_idx: u64) -> OutPoint {
378    OutPoint { txid, out_idx }
379}
380
381fn parse_contract(contract: &str) -> Result<OutgoingContract, CliCommandError> {
382    serde_json::from_str(contract).map_err(CliCommandError::InvalidContract)
383}
384
385/// The longest secret input we accept: 64 hex characters plus a line ending.
386const MAX_SECRET_HEX_BYTES: usize = 66;
387
388fn read_claim_sk(path: &Path) -> Result<SecretKey, CliCommandError> {
389    let claim_sk = read_secret_hex(path)?;
390
391    // Neither the input nor the parse error may appear in diagnostics.
392    claim_sk
393        .parse()
394        .map_err(|_| CliCommandError::InvalidClaimSecretKey)
395}
396
397fn read_preimage(path: &Path) -> Result<[u8; 32], CliCommandError> {
398    let preimage = read_secret_hex(path)?;
399
400    <[u8; 32]>::try_from(hex::decode(preimage).map_err(|_| CliCommandError::InvalidPreimageHex)?)
401        .map_err(|_| CliCommandError::InvalidPreimageLength)
402}
403
404fn ensure_single_stdin(first: &Path, second: &Path) -> Result<(), CliCommandError> {
405    #[cfg(not(target_family = "wasm"))]
406    fedimint_core::util::ensure_single_stdin([first, second])?;
407
408    #[cfg(target_family = "wasm")]
409    let _ = (first, second);
410
411    Ok(())
412}
413
414fn read_secret_hex(path: &Path) -> Result<String, CliCommandError> {
415    #[cfg(not(target_family = "wasm"))]
416    {
417        Ok(fedimint_core::util::read_secret_file(
418            path,
419            MAX_SECRET_HEX_BYTES,
420        )?)
421    }
422
423    #[cfg(target_family = "wasm")]
424    {
425        let _ = path;
426
427        Err(CliCommandError::UnsupportedSecretInput)
428    }
429}
430
431/// Write the secret key as hex to a new file only its owner can read.
432fn write_secret_key_file(path: &Path, secret_key: &SecretKey) -> Result<(), CliCommandError> {
433    #[cfg(not(target_family = "wasm"))]
434    {
435        use std::io::Write as _;
436
437        let mut options = std::fs::OpenOptions::new();
438
439        options.write(true).create_new(true);
440
441        #[cfg(unix)]
442        std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
443
444        let mut file = options
445            .open(path)
446            .map_err(CliCommandError::SecretKeyFileCreate)?;
447
448        writeln!(file, "{}", secret_key.display_secret())
449            .and_then(|()| file.sync_all())
450            .map_err(|_| CliCommandError::SecretKeyFileWrite)
451    }
452
453    #[cfg(target_family = "wasm")]
454    {
455        let _ = (path, secret_key);
456
457        Err(CliCommandError::UnsupportedSecretInput)
458    }
459}
460
461fn json<T: Serialize>(value: T) -> Value {
462    serde_json::to_value(value).expect("JSON serialization failed")
463}
464
465/// A failure of an `lnv2` module command.
466#[derive(Debug, thiserror::Error)]
467pub(crate) enum CliCommandError {
468    /// The direct HTLC operation failed.
469    #[error(transparent)]
470    Htlc(#[from] crate::htlc::HtlcError),
471
472    /// Exactly one payment image must be specified.
473    #[error("Specify exactly one of --payment-hash and --payment-point")]
474    InvalidPaymentImage,
475
476    /// The supplied contract was not valid JSON.
477    #[error("Failed to parse the contract JSON: {0}")]
478    InvalidContract(#[source] serde_json::Error),
479
480    /// A secret input file could not be read.
481    #[cfg(not(target_family = "wasm"))]
482    #[error(transparent)]
483    SecretInput(#[from] fedimint_core::util::SecretInputError),
484
485    /// Secret input files are not supported on this platform.
486    #[cfg(target_family = "wasm")]
487    #[error("Secret input files are not supported on this platform")]
488    UnsupportedSecretInput,
489
490    /// The claim secret key file could not be created.
491    #[error("Could not create the secret key file: {0}")]
492    SecretKeyFileCreate(#[source] std::io::Error),
493
494    /// The claim secret key could not be written to its file.
495    #[error("Could not write the secret key file")]
496    SecretKeyFileWrite,
497
498    /// The supplied claim secret key was not a valid hex secret key.
499    #[error("The claim secret key is not a valid hex secret key")]
500    InvalidClaimSecretKey,
501
502    /// The supplied preimage was not valid hexadecimal.
503    #[error("The preimage is not valid hex")]
504    InvalidPreimageHex,
505
506    /// The supplied preimage had the wrong length.
507    #[error("The preimage must be exactly 32 bytes")]
508    InvalidPreimageLength,
509
510    /// The payment could not be started.
511    #[error(transparent)]
512    Send(#[from] SendPaymentError),
513
514    /// The operation to await could not be looked up.
515    #[error(transparent)]
516    OperationLookup(#[from] OperationLookupError),
517
518    /// The invoice could not be created.
519    #[error(transparent)]
520    Receive(#[from] ReceiveError),
521
522    /// The LNURL could not be generated.
523    #[error(transparent)]
524    GenerateLnurl(#[from] GenerateLnurlError),
525
526    /// No gateway could be selected.
527    #[error(transparent)]
528    SelectGateway(#[from] SelectGatewayError),
529
530    /// The vetted gateways could not be listed.
531    #[error(transparent)]
532    ListGateways(#[from] ListGatewaysError),
533
534    /// The federation did not serve an admin request.
535    #[error(transparent)]
536    Federation(#[from] FederationError),
537
538    /// The client has no admin credentials, which adding or removing a vetted
539    /// gateway needs.
540    #[error("Admin auth not set")]
541    AdminAuthNotSet,
542}