fedimint_ln_client/
pay.rs

1use std::time::{Duration, SystemTime};
2
3use assert_matches::assert_matches;
4use bitcoin::hashes::sha256;
5use fedimint_client_module::DynGlobalClientContext;
6use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
7use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
8use fedimint_core::config::FederationId;
9use fedimint_core::core::OperationId;
10use fedimint_core::encoding::{Decodable, Encodable};
11use fedimint_core::module::Amounts;
12use fedimint_core::task::sleep;
13use fedimint_core::time::duration_since_epoch;
14use fedimint_core::util::FmtCompact as _;
15use fedimint_core::{Amount, OutPoint, TransactionId, crit, secp256k1};
16use fedimint_ln_common::contracts::outgoing::OutgoingContractData;
17use fedimint_ln_common::contracts::{ContractId, FundedContract, IdentifiableContract};
18use fedimint_ln_common::route_hints::RouteHint;
19use fedimint_ln_common::{LightningGateway, LightningInput, PrunedInvoice};
20use fedimint_logging::LOG_CLIENT_MODULE_LN;
21use futures::future::pending;
22use lightning_invoice::Bolt11Invoice;
23use reqwest::StatusCode;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26use tracing::{error, info, warn};
27
28pub use self::lightningpay::LightningPayStates;
29use crate::api::LnFederationApi;
30use crate::{LightningClientContext, PayType, set_payment_result};
31
32const RETRY_DELAY: Duration = Duration::from_secs(1);
33
34/// `lightningpay` module is needed to suppress the deprecation warning on the
35/// enum declaration. Suppressing the deprecation warning on the enum
36/// declaration is not enough, since the `derive` statement causes it to be
37/// ignored for some reason, so instead the enum declaration is wrapped
38/// in its own module.
39#[allow(deprecated)]
40pub(super) mod lightningpay {
41    use fedimint_core::OutPoint;
42    use fedimint_core::encoding::{Decodable, Encodable};
43
44    use super::{
45        LightningPayCreatedOutgoingLnContract, LightningPayFunded, LightningPayRefund,
46        LightningPayRefundable,
47    };
48
49    #[cfg_attr(doc, aquamarine::aquamarine)]
50    /// State machine that requests the lightning gateway to pay an invoice on
51    /// behalf of a federation client.
52    ///
53    /// ```mermaid
54    /// graph LR
55    /// classDef virtual fill:#fff,stroke-dasharray: 5 5
56    ///
57    ///  CreatedOutgoingLnContract -- await transaction failed --> Canceled
58    ///  CreatedOutgoingLnContract -- await transaction acceptance --> Funded
59    ///  Funded -- await gateway payment success  --> Success
60    ///  Funded -- await gateway cancel payment --> Refund
61    ///  Funded -- await payment timeout --> Refund
62    ///  Funded -- unrecoverable payment error --> Failure
63    ///  Refundable -- gateway issued refunded --> Refund
64    ///  Refundable -- transaction timeout --> Refund
65    /// ```
66    #[allow(clippy::large_enum_variant)]
67    #[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
68    pub enum LightningPayStates {
69        CreatedOutgoingLnContract(LightningPayCreatedOutgoingLnContract),
70        FundingRejected,
71        Funded(LightningPayFunded),
72        Success(String),
73        #[deprecated(
74            since = "0.4.0",
75            note = "Pay State Machine skips over this state and will retry payments until cancellation or timeout"
76        )]
77        Refundable(LightningPayRefundable),
78        Refund(LightningPayRefund),
79        #[deprecated(
80            since = "0.4.0",
81            note = "Pay State Machine does not need to wait for the refund tx to be accepted"
82        )]
83        Refunded(Vec<OutPoint>),
84        Failure(String),
85    }
86}
87
88#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
89pub struct LightningPayCommon {
90    pub operation_id: OperationId,
91    pub federation_id: FederationId,
92    pub contract: OutgoingContractData,
93    pub gateway_fee: Amount,
94    pub preimage_auth: sha256::Hash,
95    pub invoice: lightning_invoice::Bolt11Invoice,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
99pub struct LightningPayStateMachine {
100    pub common: LightningPayCommon,
101    pub state: LightningPayStates,
102}
103
104impl State for LightningPayStateMachine {
105    type ModuleContext = LightningClientContext;
106
107    fn transitions(
108        &self,
109        context: &Self::ModuleContext,
110        global_context: &DynGlobalClientContext,
111    ) -> Vec<StateTransition<Self>> {
112        match &self.state {
113            LightningPayStates::CreatedOutgoingLnContract(created_outgoing_ln_contract) => {
114                created_outgoing_ln_contract.transitions(global_context)
115            }
116            LightningPayStates::Funded(funded) => {
117                funded.transitions(self.common.clone(), context.clone(), global_context.clone())
118            }
119            #[allow(deprecated)]
120            LightningPayStates::Refundable(refundable) => {
121                refundable.transitions(self.common.clone(), global_context.clone())
122            }
123            #[allow(deprecated)]
124            LightningPayStates::Success(_)
125            | LightningPayStates::FundingRejected
126            | LightningPayStates::Refund(_)
127            | LightningPayStates::Refunded(_)
128            | LightningPayStates::Failure(_) => {
129                vec![]
130            }
131        }
132    }
133
134    fn operation_id(&self) -> OperationId {
135        self.common.operation_id
136    }
137}
138
139#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
140pub struct LightningPayCreatedOutgoingLnContract {
141    pub funding_txid: TransactionId,
142    pub contract_id: ContractId,
143    pub gateway: LightningGateway,
144}
145
146impl LightningPayCreatedOutgoingLnContract {
147    fn transitions(
148        &self,
149        global_context: &DynGlobalClientContext,
150    ) -> Vec<StateTransition<LightningPayStateMachine>> {
151        let txid = self.funding_txid;
152        let contract_id = self.contract_id;
153        let success_context = global_context.clone();
154        let gateway = self.gateway.clone();
155        vec![StateTransition::new(
156            Self::await_outgoing_contract_funded(success_context, txid, contract_id),
157            move |_dbtx, result, old_state| {
158                let gateway = gateway.clone();
159                Box::pin(async move {
160                    Self::transition_outgoing_contract_funded(&result, old_state, gateway)
161                })
162            },
163        )]
164    }
165
166    async fn await_outgoing_contract_funded(
167        global_context: DynGlobalClientContext,
168        txid: TransactionId,
169        contract_id: ContractId,
170    ) -> Result<u32, GatewayPayError> {
171        global_context
172            .await_tx_accepted(txid)
173            .await
174            .map_err(|_| GatewayPayError::OutgoingContractError)?;
175
176        match global_context
177            .module_api()
178            .await_contract(contract_id)
179            .await
180            .contract
181        {
182            FundedContract::Outgoing(contract) => Ok(contract.timelock),
183            FundedContract::Incoming(..) => {
184                crit!(target: LOG_CLIENT_MODULE_LN, "Federation returned wrong account type");
185
186                pending().await
187            }
188        }
189    }
190
191    fn transition_outgoing_contract_funded(
192        result: &Result<u32, GatewayPayError>,
193        old_state: LightningPayStateMachine,
194        gateway: LightningGateway,
195    ) -> LightningPayStateMachine {
196        assert_matches!(
197            old_state.state,
198            LightningPayStates::CreatedOutgoingLnContract(_)
199        );
200
201        match result {
202            Ok(timelock) => {
203                // Success case: funding transaction is accepted
204                let common = old_state.common.clone();
205                let payload = if gateway.supports_private_payments {
206                    PayInvoicePayload::new_pruned(common.clone())
207                } else {
208                    PayInvoicePayload::new(common.clone())
209                };
210                LightningPayStateMachine {
211                    common: old_state.common,
212                    state: LightningPayStates::Funded(LightningPayFunded {
213                        payload,
214                        gateway,
215                        timelock: *timelock,
216                        funding_time: fedimint_core::time::now(),
217                    }),
218                }
219            }
220            Err(_) => {
221                // Failure case: funding transaction is rejected
222                LightningPayStateMachine {
223                    common: old_state.common,
224                    state: LightningPayStates::FundingRejected,
225                }
226            }
227        }
228    }
229}
230
231#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
232pub struct LightningPayFunded {
233    pub payload: PayInvoicePayload,
234    pub gateway: LightningGateway,
235    pub timelock: u32,
236    pub funding_time: SystemTime,
237}
238
239#[derive(
240    Error, Debug, Hash, Serialize, Deserialize, Encodable, Decodable, Clone, Eq, PartialEq,
241)]
242#[serde(rename_all = "snake_case")]
243pub enum GatewayPayError {
244    #[error(
245        "Lightning Gateway failed to pay invoice. ErrorCode: {error_code:?} ErrorMessage: {error_message}"
246    )]
247    GatewayInternalError {
248        error_code: Option<u16>,
249        error_message: String,
250    },
251    #[error("OutgoingContract was not created in the federation")]
252    OutgoingContractError,
253}
254
255impl LightningPayFunded {
256    fn transitions(
257        &self,
258        common: LightningPayCommon,
259        context: LightningClientContext,
260        global_context: DynGlobalClientContext,
261    ) -> Vec<StateTransition<LightningPayStateMachine>> {
262        let gateway = self.gateway.clone();
263        let payload = self.payload.clone();
264        let contract_id = self.payload.contract_id;
265        let timelock = self.timelock;
266        let payment_hash = *common.invoice.payment_hash();
267        let success_common = common.clone();
268        let timeout_common = common.clone();
269        let timeout_global_context = global_context.clone();
270        vec![
271            StateTransition::new(
272                Self::gateway_pay_invoice(gateway, payload, context, self.funding_time),
273                move |dbtx, result, old_state| {
274                    Box::pin(Self::transition_outgoing_contract_execution(
275                        result,
276                        old_state,
277                        contract_id,
278                        dbtx,
279                        payment_hash,
280                        success_common.clone(),
281                    ))
282                },
283            ),
284            StateTransition::new(
285                await_contract_cancelled(contract_id, global_context.clone()),
286                move |dbtx, (), old_state| {
287                    Box::pin(try_refund_outgoing_contract(
288                        old_state,
289                        common.clone(),
290                        dbtx,
291                        global_context.clone(),
292                        format!("Gateway cancelled contract: {contract_id}"),
293                    ))
294                },
295            ),
296            StateTransition::new(
297                await_contract_timeout(timeout_global_context.clone(), timelock),
298                move |dbtx, (), old_state| {
299                    Box::pin(try_refund_outgoing_contract(
300                        old_state,
301                        timeout_common.clone(),
302                        dbtx,
303                        timeout_global_context.clone(),
304                        format!("Outgoing contract timed out, BlockHeight: {timelock}"),
305                    ))
306                },
307            ),
308        ]
309    }
310
311    async fn gateway_pay_invoice(
312        gateway: LightningGateway,
313        payload: PayInvoicePayload,
314        context: LightningClientContext,
315        start: SystemTime,
316    ) -> Result<String, GatewayPayError> {
317        const GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(10);
318        const TIMEOUT_DURATION: Duration = Duration::from_secs(180);
319
320        loop {
321            // We do not want to retry until the block timeout, since it will be unintuitive
322            // for users for their payment to succeed after awhile. We will try
323            // to pay the invoice until `TIMEOUT_DURATION` is hit, at which
324            // point this future will block and the user will be able
325            // to claim their funds once the block timeout is hit, or the gateway cancels
326            // the outgoing payment.
327            let elapsed = fedimint_core::time::now()
328                .duration_since(start)
329                .unwrap_or_default();
330            if elapsed > TIMEOUT_DURATION {
331                std::future::pending::<()>().await;
332            }
333
334            match context
335                .gateway_conn
336                .pay_invoice(gateway.clone(), payload.clone())
337                .await
338            {
339                Ok(preimage) => return Ok(preimage),
340                Err(err) => {
341                    match err.clone() {
342                        GatewayPayError::GatewayInternalError {
343                            error_code,
344                            error_message,
345                        } => {
346                            // Retry faster if we could not contact the gateway
347                            if let Some(error_code) = error_code
348                                && error_code == StatusCode::NOT_FOUND.as_u16()
349                            {
350                                warn!(
351                                    %error_message,
352                                    ?payload,
353                                    ?gateway,
354                                    ?RETRY_DELAY,
355                                    "Could not contact gateway"
356                                );
357                                sleep(RETRY_DELAY).await;
358                                continue;
359                            }
360                        }
361                        GatewayPayError::OutgoingContractError => {
362                            return Err(err);
363                        }
364                    }
365
366                    warn!(
367                        err = %err.fmt_compact(),
368                        ?payload,
369                        ?gateway,
370                        ?GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL,
371                        "Gateway Internal Error. Could not complete payment. Trying again..."
372                    );
373                    sleep(GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL).await;
374                }
375            }
376        }
377    }
378
379    async fn transition_outgoing_contract_execution(
380        result: Result<String, GatewayPayError>,
381        old_state: LightningPayStateMachine,
382        contract_id: ContractId,
383        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
384        payment_hash: sha256::Hash,
385        common: LightningPayCommon,
386    ) -> LightningPayStateMachine {
387        match result {
388            Ok(preimage) => {
389                set_payment_result(
390                    &mut dbtx.module_tx(),
391                    payment_hash,
392                    PayType::Lightning(old_state.common.operation_id),
393                    contract_id,
394                    common.gateway_fee,
395                )
396                .await;
397                LightningPayStateMachine {
398                    common: old_state.common,
399                    state: LightningPayStates::Success(preimage),
400                }
401            }
402            Err(e) => LightningPayStateMachine {
403                common: old_state.common,
404                state: LightningPayStates::Failure(e.to_string()),
405            },
406        }
407    }
408}
409
410#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
411// Deprecated: SM skips over this state now and will retry payments until
412// cancellation or timeout
413pub struct LightningPayRefundable {
414    contract_id: ContractId,
415    pub block_timelock: u32,
416    pub error: GatewayPayError,
417}
418
419impl LightningPayRefundable {
420    fn transitions(
421        &self,
422        common: LightningPayCommon,
423        global_context: DynGlobalClientContext,
424    ) -> Vec<StateTransition<LightningPayStateMachine>> {
425        let contract_id = self.contract_id;
426        let timeout_global_context = global_context.clone();
427        let timeout_common = common.clone();
428        let timelock = self.block_timelock;
429        vec![
430            StateTransition::new(
431                await_contract_cancelled(contract_id, global_context.clone()),
432                move |dbtx, (), old_state| {
433                    Box::pin(try_refund_outgoing_contract(
434                        old_state,
435                        common.clone(),
436                        dbtx,
437                        global_context.clone(),
438                        format!("Refundable: Gateway cancelled contract: {contract_id}"),
439                    ))
440                },
441            ),
442            StateTransition::new(
443                await_contract_timeout(timeout_global_context.clone(), timelock),
444                move |dbtx, (), old_state| {
445                    Box::pin(try_refund_outgoing_contract(
446                        old_state,
447                        timeout_common.clone(),
448                        dbtx,
449                        timeout_global_context.clone(),
450                        format!(
451                            "Refundable: Outgoing contract timed out. ContractId: {contract_id} BlockHeight: {timelock}"
452                        ),
453                    ))
454                },
455            ),
456        ]
457    }
458}
459
460/// Waits for a contract with `contract_id` to be cancelled by the gateway.
461async fn await_contract_cancelled(contract_id: ContractId, global_context: DynGlobalClientContext) {
462    loop {
463        // If we fail to get the contract from the federation, we need to keep retrying
464        // until we successfully do.
465        match global_context
466            .module_api()
467            .wait_outgoing_contract_cancelled(contract_id)
468            .await
469        {
470            Ok(_) => return,
471            Err(error) => {
472                info!(target: LOG_CLIENT_MODULE_LN, err = %error.fmt_compact(), "Error waiting for outgoing contract to be cancelled");
473            }
474        }
475
476        sleep(RETRY_DELAY).await;
477    }
478}
479
480/// Waits until a specific block height at which the contract will be able to be
481/// reclaimed.
482async fn await_contract_timeout(global_context: DynGlobalClientContext, timelock: u32) {
483    global_context
484        .module_api()
485        .wait_block_height(u64::from(timelock))
486        .await;
487}
488
489/// Claims a refund for an expired or cancelled outgoing contract
490///
491/// This can be necessary when the Lightning gateway cannot route the
492/// payment, is malicious or offline. The function returns the out point
493/// of the e-cash output generated as change.
494async fn try_refund_outgoing_contract(
495    old_state: LightningPayStateMachine,
496    common: LightningPayCommon,
497    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
498    global_context: DynGlobalClientContext,
499    error_reason: String,
500) -> LightningPayStateMachine {
501    let contract_data = common.contract;
502    let (refund_key, refund_input) = (
503        contract_data.recovery_key,
504        contract_data.contract_account.refund(),
505    );
506
507    let refund_client_input = ClientInput::<LightningInput> {
508        input: refund_input,
509        amounts: Amounts::new_bitcoin(contract_data.contract_account.amount),
510        keys: vec![refund_key],
511    };
512
513    let change_range = global_context
514        .claim_inputs(
515            dbtx,
516            // The input of the refund tx is managed by this state machine, so no new state
517            // machines need to be created
518            ClientInputBundle::new_no_sm(vec![refund_client_input]),
519        )
520        .await
521        .expect("Cannot claim input, additional funding needed");
522
523    LightningPayStateMachine {
524        common: old_state.common,
525        state: LightningPayStates::Refund(LightningPayRefund {
526            txid: change_range.txid(),
527            out_points: change_range.into_iter().collect(),
528            error_reason,
529        }),
530    }
531}
532
533#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
534pub struct LightningPayRefund {
535    pub txid: TransactionId,
536    pub out_points: Vec<OutPoint>,
537    pub error_reason: String,
538}
539
540#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
541pub struct PayInvoicePayload {
542    pub federation_id: FederationId,
543    pub contract_id: ContractId,
544    /// Metadata on how to obtain the preimage
545    pub payment_data: PaymentData,
546    pub preimage_auth: sha256::Hash,
547}
548
549impl PayInvoicePayload {
550    fn new(common: LightningPayCommon) -> Self {
551        Self {
552            contract_id: common.contract.contract_account.contract.contract_id(),
553            federation_id: common.federation_id,
554            preimage_auth: common.preimage_auth,
555            payment_data: PaymentData::Invoice(common.invoice),
556        }
557    }
558
559    fn new_pruned(common: LightningPayCommon) -> Self {
560        Self {
561            contract_id: common.contract.contract_account.contract.contract_id(),
562            federation_id: common.federation_id,
563            preimage_auth: common.preimage_auth,
564            payment_data: PaymentData::PrunedInvoice(
565                common.invoice.try_into().expect("Invoice has amount"),
566            ),
567        }
568    }
569}
570
571/// Data needed to pay an invoice, may be the whole invoice or only the required
572/// parts of it.
573#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
574#[serde(rename_all = "snake_case")]
575pub enum PaymentData {
576    Invoice(Bolt11Invoice),
577    PrunedInvoice(PrunedInvoice),
578}
579
580impl PaymentData {
581    pub fn amount(&self) -> Option<Amount> {
582        match self {
583            PaymentData::Invoice(invoice) => {
584                invoice.amount_milli_satoshis().map(Amount::from_msats)
585            }
586            PaymentData::PrunedInvoice(PrunedInvoice { amount, .. }) => Some(*amount),
587        }
588    }
589
590    pub fn destination(&self) -> secp256k1::PublicKey {
591        match self {
592            PaymentData::Invoice(invoice) => invoice
593                .payee_pub_key()
594                .copied()
595                .unwrap_or_else(|| invoice.recover_payee_pub_key()),
596            PaymentData::PrunedInvoice(PrunedInvoice { destination, .. }) => *destination,
597        }
598    }
599
600    pub fn payment_hash(&self) -> sha256::Hash {
601        match self {
602            PaymentData::Invoice(invoice) => *invoice.payment_hash(),
603            PaymentData::PrunedInvoice(PrunedInvoice { payment_hash, .. }) => *payment_hash,
604        }
605    }
606
607    pub fn route_hints(&self) -> Vec<RouteHint> {
608        match self {
609            PaymentData::Invoice(invoice) => {
610                invoice.route_hints().into_iter().map(Into::into).collect()
611            }
612            PaymentData::PrunedInvoice(PrunedInvoice { route_hints, .. }) => route_hints.clone(),
613        }
614    }
615
616    pub fn is_expired(&self) -> bool {
617        self.expiry_timestamp() < duration_since_epoch().as_secs()
618    }
619
620    /// Returns the expiry timestamp in seconds since the UNIX epoch
621    pub fn expiry_timestamp(&self) -> u64 {
622        match self {
623            PaymentData::Invoice(invoice) => invoice.expires_at().map_or(u64::MAX, |t| t.as_secs()),
624            PaymentData::PrunedInvoice(PrunedInvoice {
625                expiry_timestamp, ..
626            }) => *expiry_timestamp,
627        }
628    }
629}