1use std::time::Duration;
27
28use bitcoin::secp256k1;
29use fedimint_api_client::api::FederationError;
30use fedimint_client_module::OperationLookupError;
31use fedimint_client_module::module::OutPointRange;
32use fedimint_client_module::transaction::{
33 ClientInput, ClientInputBundle, ClientOutput, ClientOutputBundle, TransactionBuilder,
34};
35use fedimint_core::core::OperationId;
36use fedimint_core::module::{Amounts, CommonModuleInit};
37use fedimint_core::task::sleep;
38use fedimint_core::util::FmtCompact as _;
39use fedimint_core::{Amount, OutPoint};
40use fedimint_lnv2_common::contracts::{OutgoingContract, PaymentImage};
41use fedimint_lnv2_common::{
42 LightningCommonInit, LightningInput, LightningInputV0, LightningOutput, LightningOutputV0,
43 OutgoingWitness, tweak,
44};
45use fedimint_logging::LOG_CLIENT_MODULE_LNV2;
46use secp256k1::schnorr::Signature;
47use secp256k1::{Keypair, PublicKey, SecretKey, ecdh};
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50use thiserror::Error;
51use tracing::debug;
52
53use crate::api::LightningFederationApi;
54use crate::{LightningClientModule, LightningOperationMeta};
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct CreateHtlcOperationMeta {
60 pub funding_outpoint: OutPoint,
64 pub contract: OutgoingContract,
65 pub change_outpoint_range: OutPointRange,
66 pub custom_meta: Value,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SpendHtlcOperationMeta {
75 pub htlc_outpoint: OutPoint,
77 pub contract: OutgoingContract,
78 pub change_outpoint_range: OutPointRange,
79 pub custom_meta: Value,
80}
81
82#[derive(Error, Debug, Clone, Eq, PartialEq)]
85pub enum HtlcError {
86 #[error("The amount must be greater than zero")]
87 InvalidAmount,
88 #[error("The expiration delta must be greater than zero and must not overflow the block count")]
89 InvalidExpirationDelta,
90 #[error("Failed to request the consensus block count")]
91 FailedToRequestBlockCount(String),
92 #[error("The federation has not yet reached consensus on a block count")]
93 NoBlockCountConsensus,
94 #[error("Failed to submit the transaction")]
95 FailedToSubmitTransaction(String),
96 #[error("The keypair does not match the contract's claim public key")]
97 ClaimKeyMismatch,
98 #[error("The preimage does not match the contract's payment image")]
99 InvalidPreimage,
100 #[error("The contract's refund key is not derived from our static module key")]
101 RefundKeyMismatch,
102 #[error("A different contract is funded at this outpoint")]
103 ContractMismatch,
104 #[error("No unresolved contract is funded at this outpoint")]
105 ContractNotFound,
106 #[error("The contract has already expired")]
107 Expired,
108 #[error("The contract only expires in {0} more blocks")]
109 NotExpired(u64),
110 #[error("The forfeit signature is invalid for this contract")]
111 InvalidForfeitSignature,
112 #[error("The federation returned an invalid preimage")]
113 InvalidPreimageReturned,
114 #[error("Failed to query the federation")]
115 FederationApiError(String),
116 #[error("No operation with this id exists")]
117 UnknownOperation,
118 #[error("The operation is not a direct HTLC operation")]
119 NotAnHtlcOperation,
120 #[error("The transaction was rejected: {0}")]
121 TransactionRejected(String),
122 #[error("Failed to mint the ecash: {0}")]
123 FailedToMintEcash(String),
124}
125
126#[derive(Clone)]
131struct HtlcSpend {
132 outpoint: OutPoint,
133 contract: OutgoingContract,
134 witness: OutgoingWitness,
135 keypair: Keypair,
136 custom_meta: Value,
137}
138
139fn is_transient(error: &FederationError) -> bool {
142 error.get_general_error().is_none()
143 && error
144 .get_peer_errors()
145 .all(|(_, peer_error)| !peer_error.is_unusual())
146}
147
148impl HtlcSpend {
149 fn operation_id(&self) -> OperationId {
153 let action = match self.witness {
154 OutgoingWitness::Claim(..) => "lnv2-htlc-claim",
155 OutgoingWitness::Refund => "lnv2-htlc-refund",
156 OutgoingWitness::Cancel(..) => "lnv2-htlc-cancel",
157 };
158
159 OperationId::from_encodable(&(action, self.outpoint))
160 }
161
162 fn operation_meta(&self, change_outpoint_range: OutPointRange) -> LightningOperationMeta {
164 let meta = SpendHtlcOperationMeta {
165 htlc_outpoint: self.outpoint,
166 contract: self.contract.clone(),
167 change_outpoint_range,
168 custom_meta: self.custom_meta.clone(),
169 };
170
171 match self.witness {
172 OutgoingWitness::Claim(..) => LightningOperationMeta::ClaimHtlc(meta),
173 OutgoingWitness::Refund => LightningOperationMeta::RefundHtlc(meta),
174 OutgoingWitness::Cancel(..) => LightningOperationMeta::CancelHtlc(meta),
175 }
176 }
177}
178
179impl LightningClientModule {
180 pub async fn create_htlc(
200 &self,
201 amount: Amount,
202 payment_image: PaymentImage,
203 claim_pk: PublicKey,
204 expiration_delta: u64,
205 custom_meta: Value,
206 ) -> Result<(OperationId, OutPoint, OutgoingContract), HtlcError> {
207 if amount == Amount::ZERO {
208 return Err(HtlcError::InvalidAmount);
209 }
210
211 if expiration_delta == 0 {
212 return Err(HtlcError::InvalidExpirationDelta);
213 }
214
215 let expiration = self
216 .consensus_block_count()
217 .await?
218 .checked_add(expiration_delta)
219 .ok_or(HtlcError::InvalidExpirationDelta)?;
220
221 let (ephemeral_tweak, ephemeral_pk) = tweak::generate(self.keypair.public_key());
222
223 let refund_keypair = SecretKey::from_slice(&ephemeral_tweak)
224 .expect("32 bytes, within curve order")
225 .keypair(secp256k1::SECP256K1);
226
227 let contract = OutgoingContract {
228 payment_image,
229 amount,
230 expiration,
231 claim_pk,
232 refund_pk: refund_keypair.public_key(),
233 ephemeral_pk,
234 };
235
236 let operation_id = OperationId::from_encodable(&("lnv2-htlc-create", contract.clone()));
237
238 let client_output = ClientOutput::<LightningOutput> {
239 output: LightningOutput::V0(LightningOutputV0::Outgoing(contract.clone())),
240 amounts: Amounts::new_bitcoin(contract.amount),
241 };
242
243 let client_outputs = self
244 .client_ctx
245 .make_client_outputs(ClientOutputBundle::new_no_sm(vec![client_output]));
246
247 let transaction = TransactionBuilder::new().with_outputs(client_outputs);
248
249 let contract_clone = contract.clone();
250
251 let change_range = self
252 .client_ctx
253 .finalize_and_submit_transaction(
254 operation_id,
255 LightningCommonInit::KIND.as_str(),
256 move |change_outpoint_range: OutPointRange| {
257 LightningOperationMeta::CreateHtlc(CreateHtlcOperationMeta {
258 funding_outpoint: OutPoint {
261 txid: change_outpoint_range.txid,
262 out_idx: 0,
263 },
264 contract: contract_clone.clone(),
265 change_outpoint_range,
266 custom_meta: custom_meta.clone(),
267 })
268 },
269 transaction,
270 )
271 .await
272 .map_err(|e| HtlcError::FailedToSubmitTransaction(e.to_string()))?;
273
274 let funding_outpoint = OutPoint {
275 txid: change_range.txid,
276 out_idx: 0,
277 };
278
279 Ok((operation_id, funding_outpoint, contract))
280 }
281
282 pub async fn claim_htlc(
297 &self,
298 outpoint: OutPoint,
299 contract: OutgoingContract,
300 claim_keypair: Keypair,
301 preimage: [u8; 32],
302 custom_meta: Value,
303 ) -> Result<OperationId, HtlcError> {
304 if claim_keypair.public_key() != contract.claim_pk {
305 return Err(HtlcError::ClaimKeyMismatch);
306 }
307
308 if !contract.verify_preimage(&preimage) {
309 return Err(HtlcError::InvalidPreimage);
310 }
311
312 let spend = HtlcSpend {
313 outpoint,
314 contract,
315 witness: OutgoingWitness::Claim(preimage),
316 keypair: claim_keypair,
317 custom_meta,
318 };
319
320 if let Some(operation_id) = self.existing_htlc_spend(&spend).await? {
321 return Ok(operation_id);
322 }
323
324 self.consensus_block_count().await?;
325
326 if self
327 .funded_contract_expiration(outpoint, &spend.contract)
328 .await?
329 == 0
330 {
331 return Err(HtlcError::Expired);
332 }
333
334 self.spend_htlc(spend).await
335 }
336
337 pub async fn refund_htlc(
341 &self,
342 outpoint: OutPoint,
343 contract: OutgoingContract,
344 custom_meta: Value,
345 ) -> Result<OperationId, HtlcError> {
346 let refund_keypair = self
347 .recover_htlc_refund_keypair(&contract)
348 .ok_or(HtlcError::RefundKeyMismatch)?;
349
350 let spend = HtlcSpend {
351 outpoint,
352 contract,
353 witness: OutgoingWitness::Refund,
354 keypair: refund_keypair,
355 custom_meta,
356 };
357
358 if let Some(operation_id) = self.existing_htlc_spend(&spend).await? {
359 return Ok(operation_id);
360 }
361
362 let remaining_blocks = self
363 .funded_contract_expiration(outpoint, &spend.contract)
364 .await?;
365
366 if remaining_blocks > 0 {
367 return Err(HtlcError::NotExpired(remaining_blocks));
368 }
369
370 self.spend_htlc(spend).await
371 }
372
373 pub async fn cancel_htlc(
379 &self,
380 outpoint: OutPoint,
381 contract: OutgoingContract,
382 forfeit_signature: Signature,
383 custom_meta: Value,
384 ) -> Result<OperationId, HtlcError> {
385 if !contract.verify_forfeit_signature(&forfeit_signature) {
386 return Err(HtlcError::InvalidForfeitSignature);
387 }
388
389 let refund_keypair = self
390 .recover_htlc_refund_keypair(&contract)
391 .ok_or(HtlcError::RefundKeyMismatch)?;
392
393 let spend = HtlcSpend {
394 outpoint,
395 contract,
396 witness: OutgoingWitness::Cancel(forfeit_signature),
397 keypair: refund_keypair,
398 custom_meta,
399 };
400
401 if let Some(operation_id) = self.existing_htlc_spend(&spend).await? {
402 return Ok(operation_id);
403 }
404
405 self.funded_contract_expiration(outpoint, &spend.contract)
406 .await?;
407
408 self.spend_htlc(spend).await
409 }
410
411 pub async fn consensus_block_count(&self) -> Result<u64, HtlcError> {
423 let consensus_block_count = self
424 .module_api
425 .consensus_block_count()
426 .await
427 .map_err(|e| HtlcError::FailedToRequestBlockCount(e.to_string()))?;
428
429 if consensus_block_count == 0 {
430 return Err(HtlcError::NoBlockCountConsensus);
431 }
432
433 Ok(consensus_block_count)
434 }
435
436 pub fn create_htlc_forfeit_signature(
442 contract: &OutgoingContract,
443 claim_keypair: &Keypair,
444 ) -> Result<Signature, HtlcError> {
445 if claim_keypair.public_key() != contract.claim_pk {
446 return Err(HtlcError::ClaimKeyMismatch);
447 }
448
449 Ok(claim_keypair.sign_schnorr(contract.forfeit_message()))
450 }
451
452 pub async fn await_htlc_funded(
468 &self,
469 outpoint: OutPoint,
470 contract: &OutgoingContract,
471 ) -> Result<u64, HtlcError> {
472 self.consensus_block_count().await?;
473
474 loop {
475 match self.module_api.outgoing_contract_expiration(outpoint).await {
476 Ok(Some((contract_id, remaining_blocks))) => {
477 if contract_id != contract.contract_id() {
478 return Err(HtlcError::ContractMismatch);
479 }
480
481 return Ok(remaining_blocks);
482 }
483 Ok(None) => {}
484 Err(error) if is_transient(&error) => {
485 debug!(
486 target: LOG_CLIENT_MODULE_LNV2,
487 err = %error.fmt_compact(),
488 "Failed to query the direct HTLC funding, retrying"
489 );
490 }
491 Err(error) => return Err(HtlcError::FederationApiError(error.to_string())),
492 }
493
494 sleep(Duration::from_secs(1)).await;
495 }
496 }
497
498 pub async fn await_htlc_resolution(
503 &self,
504 outpoint: OutPoint,
505 contract: &OutgoingContract,
506 ) -> Result<Option<[u8; 32]>, HtlcError> {
507 match self
508 .module_api
509 .await_preimage(outpoint, contract.expiration)
510 .await
511 {
512 Some(preimage) => {
513 if contract.verify_preimage(&preimage) {
514 Ok(Some(preimage))
515 } else {
516 Err(HtlcError::InvalidPreimageReturned)
517 }
518 }
519 None => Ok(None),
520 }
521 }
522
523 pub async fn await_htlc_operation_settled(
526 &self,
527 operation_id: OperationId,
528 ) -> Result<(), HtlcError> {
529 let operation = self
530 .client_ctx
531 .get_operation(operation_id)
532 .await
533 .map_err(|_| HtlcError::UnknownOperation)?;
534
535 if operation.operation_module_kind() != LightningCommonInit::KIND.as_str() {
536 return Err(HtlcError::NotAnHtlcOperation);
537 }
538
539 let change_outpoint_range = match operation.meta::<LightningOperationMeta>() {
540 LightningOperationMeta::CreateHtlc(meta) => meta.change_outpoint_range,
541 LightningOperationMeta::ClaimHtlc(meta)
542 | LightningOperationMeta::RefundHtlc(meta)
543 | LightningOperationMeta::CancelHtlc(meta) => meta.change_outpoint_range,
544 LightningOperationMeta::Send(..)
545 | LightningOperationMeta::Receive(..)
546 | LightningOperationMeta::LnurlReceive(..) => {
547 return Err(HtlcError::NotAnHtlcOperation);
548 }
549 };
550
551 self.client_ctx
552 .transaction_updates(operation_id)
553 .await
554 .await_tx_accepted(change_outpoint_range.txid)
555 .await
556 .map_err(HtlcError::TransactionRejected)?;
557
558 self.client_ctx
559 .await_primary_module_outputs(operation_id, change_outpoint_range.into_iter().collect())
560 .await
561 .map_err(|e| HtlcError::FailedToMintEcash(e.to_string()))?;
562
563 Ok(())
564 }
565
566 async fn existing_htlc_spend(
576 &self,
577 spend: &HtlcSpend,
578 ) -> Result<Option<OperationId>, HtlcError> {
579 let operation_id = spend.operation_id();
580
581 let operation = match self.client_ctx.get_operation(operation_id).await {
582 Ok(operation) => operation,
583 Err(OperationLookupError::NotFound(..)) => return Ok(None),
584 Err(..) => return Err(HtlcError::NotAnHtlcOperation),
585 };
586
587 let contract = match operation.meta::<LightningOperationMeta>() {
588 LightningOperationMeta::ClaimHtlc(meta)
589 | LightningOperationMeta::RefundHtlc(meta)
590 | LightningOperationMeta::CancelHtlc(meta) => meta.contract,
591 LightningOperationMeta::CreateHtlc(..)
592 | LightningOperationMeta::Send(..)
593 | LightningOperationMeta::Receive(..)
594 | LightningOperationMeta::LnurlReceive(..) => {
595 return Err(HtlcError::NotAnHtlcOperation);
596 }
597 };
598
599 if contract != spend.contract {
600 return Err(HtlcError::ContractMismatch);
601 }
602
603 Ok(Some(operation_id))
604 }
605
606 async fn spend_htlc(&self, spend: HtlcSpend) -> Result<OperationId, HtlcError> {
611 let operation_id = spend.operation_id();
612
613 let client_input = ClientInput::<LightningInput> {
614 input: LightningInput::V0(LightningInputV0::Outgoing(
615 spend.outpoint,
616 spend.witness.clone(),
617 )),
618 amounts: Amounts::new_bitcoin(spend.contract.amount),
619 keys: vec![spend.keypair],
620 };
621
622 let client_inputs = self
623 .client_ctx
624 .make_client_inputs(ClientInputBundle::new_no_sm(vec![client_input]));
625
626 let transaction = TransactionBuilder::new().with_inputs(client_inputs);
627
628 self.client_ctx
629 .finalize_and_submit_transaction(
630 operation_id,
631 LightningCommonInit::KIND.as_str(),
632 move |change_outpoint_range: OutPointRange| {
633 spend.operation_meta(change_outpoint_range)
634 },
635 transaction,
636 )
637 .await
638 .map_err(|e| HtlcError::FailedToSubmitTransaction(e.to_string()))?;
639
640 Ok(operation_id)
641 }
642
643 async fn funded_contract_expiration(
653 &self,
654 outpoint: OutPoint,
655 contract: &OutgoingContract,
656 ) -> Result<u64, HtlcError> {
657 let (contract_id, remaining_blocks) = self
658 .module_api
659 .outgoing_contract_expiration(outpoint)
660 .await
661 .map_err(|e| HtlcError::FederationApiError(e.to_string()))?
662 .ok_or(HtlcError::ContractNotFound)?;
663
664 if contract_id != contract.contract_id() {
665 return Err(HtlcError::ContractMismatch);
666 }
667
668 Ok(remaining_blocks)
669 }
670
671 fn recover_htlc_refund_keypair(&self, contract: &OutgoingContract) -> Option<Keypair> {
674 let ephemeral_tweak =
675 ecdh::SharedSecret::new(&contract.ephemeral_pk, &self.keypair.secret_key());
676
677 let refund_keypair = SecretKey::from_slice(&ephemeral_tweak.secret_bytes())
678 .expect("32 bytes, within curve order")
679 .keypair(secp256k1::SECP256K1);
680
681 (refund_keypair.public_key() == contract.refund_pk).then_some(refund_keypair)
682 }
683}