Skip to main content

fedimint_lnv2_client/
htlc.rs

1//! # Direct HTLC API
2//!
3//! This module exposes the outgoing contract of the `LNv2` module as a plain
4//! HTLC between two clients of the same federation, without any Lightning
5//! gateway involvement. This enables atomic swaps with counterparties outside
6//! the federation's Lightning infrastructure, e.g. swaps with other chains or
7//! currencies.
8//!
9//! The funder locks ecash to the counterparty's claim key via
10//! [`LightningClientModule::create_htlc`]. The counterparty verifies the
11//! funding via [`LightningClientModule::await_htlc_funded`] and resolves the
12//! HTLC with the preimage via [`LightningClientModule::claim_htlc`], which
13//! reveals the preimage to the funder via
14//! [`LightningClientModule::await_htlc_resolution`]. A failed HTLC is either
15//! cancelled cooperatively before its expiration with the counterparty's
16//! forfeit signature via [`LightningClientModule::cancel_htlc`], or refunded
17//! unilaterally after its expiration via
18//! [`LightningClientModule::refund_htlc`].
19//!
20//! All coordination data - the contract, its funding outpoint and the forfeit
21//! signature - is exchanged between the two parties out of band; the CLI and
22//! the JSON representation of this data are documented in
23//! `docs/lightning_module_v2.md`. The contract expiration is measured in
24//! block count as tracked by the federation's consensus, not in unix time.
25
26use 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/// Metadata of an operation funding a direct HTLC via
57/// [`LightningClientModule::create_htlc`].
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct CreateHtlcOperationMeta {
60    /// The outpoint of the contract in the funding transaction; share this
61    /// together with `contract` with the counterparty so they can verify and
62    /// claim the HTLC.
63    pub funding_outpoint: OutPoint,
64    pub contract: OutgoingContract,
65    pub change_outpoint_range: OutPointRange,
66    pub custom_meta: Value,
67}
68
69/// Metadata of an operation spending a direct HTLC: a claim via
70/// [`LightningClientModule::claim_htlc`], a refund via
71/// [`LightningClientModule::refund_htlc`] or a cancellation via
72/// [`LightningClientModule::cancel_htlc`].
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SpendHtlcOperationMeta {
75    /// The outpoint the contract was funded at.
76    pub htlc_outpoint: OutPoint,
77    pub contract: OutgoingContract,
78    pub change_outpoint_range: OutPointRange,
79    pub custom_meta: Value,
80}
81
82/// Errors of the direct HTLC operations; see
83/// [`LightningClientModule::create_htlc`].
84#[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/// The parameters of a transaction spending a direct HTLC; see
127/// [`LightningClientModule::spend_htlc`]. The witness is the action taken on
128/// the contract, from which the operation id and the operation meta of the
129/// spend are derived.
130#[derive(Clone)]
131struct HtlcSpend {
132    outpoint: OutPoint,
133    contract: OutgoingContract,
134    witness: OutgoingWitness,
135    keypair: Keypair,
136    custom_meta: Value,
137}
138
139/// Whether a federation error is caused by connection failures only, such that
140/// retrying the request may succeed.
141fn 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    /// The operation id of the spend, which is deterministic per action and
150    /// outpoint so that every action on a contract is recorded only once and
151    /// retrying it yields the recorded operation.
152    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    /// The operation meta of the spend for the given change outpoint range.
163    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    /// Fund a direct HTLC: an outgoing contract of `amount` locked to the
181    /// counterparty's `claim_pk`, without any gateway involvement. The
182    /// counterparty can claim the contract with the preimage of
183    /// `payment_image` via [`Self::claim_htlc`] until `expiration_delta`
184    /// blocks from now; afterwards we can reclaim it via
185    /// [`Self::refund_htlc`], or earlier via [`Self::cancel_htlc`] if the
186    /// counterparty provides a forfeit signature.
187    ///
188    /// Returns the contract and the outpoint it is funded at; share both with
189    /// the counterparty so they can verify the funding via
190    /// [`Self::await_htlc_funded`] and claim the contract. Await the
191    /// acceptance of the funding transaction via
192    /// [`Self::await_htlc_operation_settled`] and the counterparty's claim
193    /// via [`Self::await_htlc_resolution`], which yields the preimage.
194    ///
195    /// The contract's refund key is derived from our static module key via
196    /// the ephemeral key committed in the contract, so refunding and
197    /// cancelling only require the contract itself, not this operation's
198    /// local state.
199    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                        // The contract is the only output submitted before the
259                        // change, which is always appended after it.
260                        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    /// Claim a direct HTLC funded at `outpoint` with the preimage of its
283    /// payment image; the ecash is issued to our wallet. The claiming
284    /// transaction has to be signed by the contract's claim key, hence the
285    /// `claim_keypair`.
286    ///
287    /// Since the preimage becomes public with the claiming transaction the
288    /// caller has to ensure sufficient time until the contract's expiration
289    /// remains; this method only rejects contracts that have already expired.
290    /// Await the issuance of the ecash via
291    /// [`Self::await_htlc_operation_settled`].
292    ///
293    /// Retrying a claim, refund or cancellation that has already been
294    /// recorded returns the recorded operation, whether or not its
295    /// transaction has been accepted yet.
296    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    /// Refund a direct HTLC we created via [`Self::create_htlc`] after its
338    /// expiration; the ecash is issued back to our wallet. Await the issuance
339    /// via [`Self::await_htlc_operation_settled`].
340    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    /// Cancel a direct HTLC we created via [`Self::create_htlc`] before its
374    /// expiration using the counterparty's `forfeit_signature`, created via
375    /// [`Self::create_htlc_forfeit_signature`]; the ecash is issued back to
376    /// our wallet. Await the issuance via
377    /// [`Self::await_htlc_operation_settled`].
378    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    /// The federation's current consensus block count, against which direct
412    /// HTLC expirations are measured.
413    ///
414    /// Returns [`HtlcError::NoBlockCountConsensus`] while the federation has
415    /// not yet committed its first block count votes, during which the
416    /// reported count would be zero while the actual chain tip may already
417    /// be at a positive height; an expiration computed against the zero
418    /// count would be overtaken the moment the first votes are committed.
419    /// For the same reason [`Self::create_htlc`], [`Self::claim_htlc`] and
420    /// [`Self::await_htlc_funded`] refuse to operate until this method
421    /// succeeds.
422    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    /// Create a forfeit signature for a direct HTLC locked to our
437    /// `claim_keypair`, which allows its funder to reclaim the contract
438    /// before its expiration via [`Self::cancel_htlc`]. This is how the
439    /// claiming side fails an HTLC cooperatively; it is a pure offline
440    /// operation.
441    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    /// Wait until the given contract is funded at `outpoint` and return the
453    /// number of blocks remaining until its expiration. This is how the
454    /// claiming side verifies the funder's claim that the HTLC exists before
455    /// taking any action of its own, e.g. sending funds on another chain.
456    ///
457    /// This method polls the federation indefinitely, retrying connection
458    /// failures, so callers should enforce their own timeout; note that it
459    /// will also never resolve if the contract has already been claimed,
460    /// refunded or cancelled again. Any other federation error, e.g. one of a
461    /// federation that does not support direct HTLCs, is returned.
462    ///
463    /// Returns [`HtlcError::NoBlockCountConsensus`] while the federation has
464    /// not yet reached consensus on a block count, as the remaining blocks
465    /// would be measured against a count of zero and overstate the contract's
466    /// lifetime; see [`Self::consensus_block_count`].
467    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    /// Wait until the direct HTLC funded at `outpoint` is either claimed, in
499    /// which case the preimage is returned, or expired unclaimed, in which
500    /// case `None` is returned and the funder can safely refund the contract
501    /// via [`Self::refund_htlc`].
502    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    /// Wait until the transaction of a direct HTLC operation is accepted by
524    /// the federation and any ecash it issues has been minted.
525    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    /// Return the operation id of `spend` if it has already been recorded,
567    /// such that retrying a spend, e.g. after its caller was interrupted
568    /// while the transaction is in flight, yields the original operation.
569    /// Callers have to check this before they verify the contract against
570    /// the federation, since that check fails once the recorded spend has
571    /// resolved the contract. The retry's custom meta is ignored.
572    ///
573    /// Concurrent calls for the same spend may both miss the recorded
574    /// operation, in which case recording the second one fails.
575    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    /// Submit a transaction spending a direct HTLC and record it under a new
607    /// operation. Callers have to check for an existing operation via
608    /// [`Self::existing_htlc_spend`] and verify the contract against the
609    /// federation via [`Self::funded_contract_expiration`] first.
610    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    /// Verify that `contract` is the unresolved contract funded at `outpoint`
644    /// and return the number of blocks remaining until its expiration, which
645    /// is zero once it has expired.
646    ///
647    /// Every spend of a contract has to pass this check before it is
648    /// recorded, since spends are recorded under operation ids derived from
649    /// the outpoint alone: a spend recorded with a mismatched contract would
650    /// be rejected by the federation while making every later attempt with
651    /// the correct contract fail against the recorded operation.
652    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    /// Recover the refund keypair of a direct HTLC created by this client
672    /// from the contract's ephemeral key and our static module key.
673    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}