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 Send {
33 invoice: Bolt11Invoice,
34 #[arg(long)]
35 gateway: Option<SafeUrl>,
36 },
37 AwaitSend { operation_id: OperationId },
39 Receive {
43 amount: Amount,
44 #[arg(long)]
45 gateway: Option<SafeUrl>,
46 },
47 AwaitReceive { operation_id: OperationId },
49 #[command(subcommand)]
51 Lnurl(LnurlOpts),
52 #[command(subcommand)]
54 Gateways(GatewaysOpts),
55 #[command(subcommand)]
58 Htlc(HtlcOpts),
59}
60
61#[derive(Clone, Subcommand, Serialize)]
62enum HtlcOpts {
63 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 NewClaimKeypair {
78 #[arg(long)]
81 secret_key_file: PathBuf,
82 },
83 AwaitFunded {
86 funding_txid: TransactionId,
87 #[arg(long, default_value_t = 0)]
88 out_idx: u64,
89 contract: String,
90 },
91 Claim {
93 funding_txid: TransactionId,
94 #[arg(long, default_value_t = 0)]
95 out_idx: u64,
96 contract: String,
97 #[arg(long)]
99 claim_sk_file: PathBuf,
100 #[arg(long)]
102 preimage_file: PathBuf,
103 },
104 Forfeit {
107 contract: String,
108 #[arg(long)]
110 claim_sk_file: PathBuf,
111 },
112 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 {
123 funding_txid: TransactionId,
124 #[arg(long, default_value_t = 0)]
125 out_idx: u64,
126 contract: String,
127 },
128 AwaitResolution {
131 funding_txid: TransactionId,
132 #[arg(long, default_value_t = 0)]
133 out_idx: u64,
134 contract: String,
135 },
136 AwaitSettled { operation_id: OperationId },
139}
140
141#[derive(Clone, Subcommand, Serialize)]
142enum LnurlOpts {
143 Generate {
145 recurringd: SafeUrl,
146 #[arg(long)]
147 gateway: Option<SafeUrl>,
148 },
149}
150
151#[derive(Clone, Subcommand, Serialize)]
152enum GatewaysOpts {
153 Map,
157 Select {
159 #[arg(long)]
160 invoice: Option<Bolt11Invoice>,
161 },
162 List {
164 #[arg(long)]
165 peer: Option<PeerId>,
166 },
167 Add { gateway: SafeUrl },
169 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
385const 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 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
431fn 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#[derive(Debug, thiserror::Error)]
467pub(crate) enum CliCommandError {
468 #[error(transparent)]
470 Htlc(#[from] crate::htlc::HtlcError),
471
472 #[error("Specify exactly one of --payment-hash and --payment-point")]
474 InvalidPaymentImage,
475
476 #[error("Failed to parse the contract JSON: {0}")]
478 InvalidContract(#[source] serde_json::Error),
479
480 #[cfg(not(target_family = "wasm"))]
482 #[error(transparent)]
483 SecretInput(#[from] fedimint_core::util::SecretInputError),
484
485 #[cfg(target_family = "wasm")]
487 #[error("Secret input files are not supported on this platform")]
488 UnsupportedSecretInput,
489
490 #[error("Could not create the secret key file: {0}")]
492 SecretKeyFileCreate(#[source] std::io::Error),
493
494 #[error("Could not write the secret key file")]
496 SecretKeyFileWrite,
497
498 #[error("The claim secret key is not a valid hex secret key")]
500 InvalidClaimSecretKey,
501
502 #[error("The preimage is not valid hex")]
504 InvalidPreimageHex,
505
506 #[error("The preimage must be exactly 32 bytes")]
508 InvalidPreimageLength,
509
510 #[error(transparent)]
512 Send(#[from] SendPaymentError),
513
514 #[error(transparent)]
516 OperationLookup(#[from] OperationLookupError),
517
518 #[error(transparent)]
520 Receive(#[from] ReceiveError),
521
522 #[error(transparent)]
524 GenerateLnurl(#[from] GenerateLnurlError),
525
526 #[error(transparent)]
528 SelectGateway(#[from] SelectGatewayError),
529
530 #[error(transparent)]
532 ListGateways(#[from] ListGatewaysError),
533
534 #[error(transparent)]
536 Federation(#[from] FederationError),
537
538 #[error("Admin auth not set")]
541 AdminAuthNotSet,
542}