Skip to main content

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::{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(), context.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 success_context = context.clone();
269        let timeout_common = common.clone();
270        let timeout_global_context = global_context.clone();
271        let cancel_context = context.clone();
272        let timeout_context = context.clone();
273        vec![
274            StateTransition::new(
275                Self::gateway_pay_invoice(gateway, payload, context, self.funding_time),
276                move |dbtx, result, old_state| {
277                    let success_context = success_context.clone();
278                    Box::pin(Self::transition_outgoing_contract_execution(
279                        result,
280                        old_state,
281                        contract_id,
282                        dbtx,
283                        payment_hash,
284                        success_common.clone(),
285                        success_context,
286                    ))
287                },
288            ),
289            StateTransition::new(
290                await_contract_cancelled(contract_id, global_context.clone()),
291                move |dbtx, (), old_state| {
292                    let cancel_context = cancel_context.clone();
293                    Box::pin(try_refund_outgoing_contract(
294                        old_state,
295                        common.clone(),
296                        dbtx,
297                        global_context.clone(),
298                        format!("Gateway cancelled contract: {contract_id}"),
299                        cancel_context,
300                    ))
301                },
302            ),
303            StateTransition::new(
304                await_contract_timeout(timeout_global_context.clone(), timelock),
305                move |dbtx, (), old_state| {
306                    let timeout_context = timeout_context.clone();
307                    Box::pin(try_refund_outgoing_contract(
308                        old_state,
309                        timeout_common.clone(),
310                        dbtx,
311                        timeout_global_context.clone(),
312                        format!("Outgoing contract timed out, BlockHeight: {timelock}"),
313                        timeout_context,
314                    ))
315                },
316            ),
317        ]
318    }
319
320    async fn gateway_pay_invoice(
321        gateway: LightningGateway,
322        payload: PayInvoicePayload,
323        context: LightningClientContext,
324        start: SystemTime,
325    ) -> Result<String, GatewayPayError> {
326        const GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(10);
327        const TIMEOUT_DURATION: Duration = Duration::from_mins(3);
328
329        loop {
330            // We do not want to retry until the block timeout, since it will be unintuitive
331            // for users for their payment to succeed after awhile. We will try
332            // to pay the invoice until `TIMEOUT_DURATION` is hit, at which
333            // point this future will block and the user will be able
334            // to claim their funds once the block timeout is hit, or the gateway cancels
335            // the outgoing payment.
336            let elapsed = fedimint_core::time::now()
337                .duration_since(start)
338                .unwrap_or_default();
339            if elapsed > TIMEOUT_DURATION {
340                std::future::pending::<()>().await;
341            }
342
343            match context
344                .gateway_conn
345                .pay_invoice(gateway.clone(), payload.clone())
346                .await
347            {
348                Ok(preimage) => return Ok(preimage),
349                Err(err) => {
350                    match err.clone() {
351                        GatewayPayError::GatewayInternalError {
352                            error_code,
353                            error_message,
354                        } => {
355                            // Retry faster if we could not contact the gateway
356                            if let Some(error_code) = error_code
357                                && error_code == StatusCode::NOT_FOUND.as_u16()
358                            {
359                                warn!(
360                                    %error_message,
361                                    ?payload,
362                                    ?gateway,
363                                    ?RETRY_DELAY,
364                                    "Could not contact gateway"
365                                );
366                                sleep(RETRY_DELAY).await;
367                                continue;
368                            }
369                        }
370                        GatewayPayError::OutgoingContractError => {
371                            return Err(err);
372                        }
373                    }
374
375                    warn!(
376                        err = %err.fmt_compact(),
377                        ?payload,
378                        ?gateway,
379                        ?GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL,
380                        "Gateway Internal Error. Could not complete payment. Trying again..."
381                    );
382                    sleep(GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL).await;
383                }
384            }
385        }
386    }
387
388    async fn transition_outgoing_contract_execution(
389        result: Result<String, GatewayPayError>,
390        old_state: LightningPayStateMachine,
391        contract_id: ContractId,
392        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
393        payment_hash: sha256::Hash,
394        common: LightningPayCommon,
395        context: LightningClientContext,
396    ) -> LightningPayStateMachine {
397        match result {
398            Ok(preimage) => {
399                set_payment_result(
400                    &mut dbtx.module_tx(),
401                    payment_hash,
402                    PayType::Lightning(old_state.common.operation_id),
403                    contract_id,
404                    common.gateway_fee,
405                )
406                .await;
407
408                // client_ctx is None for the gateway since it does not emit the client events
409                if let Some(ref client_ctx) = context.client_ctx
410                    && let Some(preimage_bytes) = fedimint_core::hex::decode(&preimage)
411                        .ok()
412                        .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
413                {
414                    client_ctx
415                        .log_event(
416                            &mut dbtx.module_tx(),
417                            crate::events::SendPaymentUpdateEvent {
418                                operation_id: old_state.common.operation_id,
419                                status: crate::events::SendPaymentStatus::Success(preimage_bytes),
420                            },
421                        )
422                        .await;
423                }
424
425                LightningPayStateMachine {
426                    common: old_state.common,
427                    state: LightningPayStates::Success(preimage),
428                }
429            }
430            Err(e) => LightningPayStateMachine {
431                common: old_state.common,
432                state: LightningPayStates::Failure(e.to_string()),
433            },
434        }
435    }
436}
437
438#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
439// Deprecated: SM skips over this state now and will retry payments until
440// cancellation or timeout
441pub struct LightningPayRefundable {
442    contract_id: ContractId,
443    pub block_timelock: u32,
444    pub error: GatewayPayError,
445}
446
447impl LightningPayRefundable {
448    fn transitions(
449        &self,
450        common: LightningPayCommon,
451        context: LightningClientContext,
452        global_context: DynGlobalClientContext,
453    ) -> Vec<StateTransition<LightningPayStateMachine>> {
454        let contract_id = self.contract_id;
455        let timeout_global_context = global_context.clone();
456        let timeout_common = common.clone();
457        let timelock = self.block_timelock;
458        let cancel_context = context.clone();
459        let timeout_context = context;
460        vec![
461            StateTransition::new(
462                await_contract_cancelled(contract_id, global_context.clone()),
463                move |dbtx, (), old_state| {
464                    let cancel_context = cancel_context.clone();
465                    Box::pin(try_refund_outgoing_contract(
466                        old_state,
467                        common.clone(),
468                        dbtx,
469                        global_context.clone(),
470                        format!("Refundable: Gateway cancelled contract: {contract_id}"),
471                        cancel_context,
472                    ))
473                },
474            ),
475            StateTransition::new(
476                await_contract_timeout(timeout_global_context.clone(), timelock),
477                move |dbtx, (), old_state| {
478                    let timeout_context = timeout_context.clone();
479                    Box::pin(try_refund_outgoing_contract(
480                        old_state,
481                        timeout_common.clone(),
482                        dbtx,
483                        timeout_global_context.clone(),
484                        format!(
485                            "Refundable: Outgoing contract timed out. ContractId: {contract_id} BlockHeight: {timelock}"
486                        ),
487                        timeout_context,
488                    ))
489                },
490            ),
491        ]
492    }
493}
494
495/// Waits for a contract with `contract_id` to be cancelled by the gateway.
496async fn await_contract_cancelled(contract_id: ContractId, global_context: DynGlobalClientContext) {
497    loop {
498        // If we fail to get the contract from the federation, we need to keep retrying
499        // until we successfully do.
500        match global_context
501            .module_api()
502            .wait_outgoing_contract_cancelled(contract_id)
503            .await
504        {
505            Ok(_) => return,
506            Err(error) => {
507                info!(target: LOG_CLIENT_MODULE_LN, err = %error.fmt_compact(), "Error waiting for outgoing contract to be cancelled");
508            }
509        }
510
511        sleep(RETRY_DELAY).await;
512    }
513}
514
515/// Waits until a specific block height at which the contract will be able to be
516/// reclaimed.
517async fn await_contract_timeout(global_context: DynGlobalClientContext, timelock: u32) {
518    global_context
519        .module_api()
520        .wait_block_height(u64::from(timelock))
521        .await;
522}
523
524/// Claims a refund for an expired or cancelled outgoing contract
525///
526/// This can be necessary when the Lightning gateway cannot route the
527/// payment, is malicious or offline. The function returns the out point
528/// of the e-cash output generated as change.
529async fn try_refund_outgoing_contract(
530    old_state: LightningPayStateMachine,
531    common: LightningPayCommon,
532    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
533    global_context: DynGlobalClientContext,
534    error_reason: String,
535    context: LightningClientContext,
536) -> LightningPayStateMachine {
537    let contract_data = common.contract;
538    let (refund_key, refund_input) = (
539        contract_data.recovery_key,
540        contract_data.contract_account.refund(),
541    );
542
543    let refund_client_input = ClientInput::<LightningInput> {
544        input: refund_input,
545        amounts: Amounts::new_bitcoin(contract_data.contract_account.amount),
546        keys: vec![refund_key],
547    };
548
549    let change_range = global_context
550        .claim_inputs(
551            dbtx,
552            // The input of the refund tx is managed by this state machine, so no new state
553            // machines need to be created
554            ClientInputBundle::new_no_sm(vec![refund_client_input]),
555        )
556        .await
557        .expect("Cannot claim input, additional funding needed");
558
559    // client_ctx is None for the gateway since it does not emit the client events
560    if let Some(ref client_ctx) = context.client_ctx {
561        client_ctx
562            .log_event(
563                &mut dbtx.module_tx(),
564                crate::events::SendPaymentUpdateEvent {
565                    operation_id: old_state.common.operation_id,
566                    status: crate::events::SendPaymentStatus::Refunded,
567                },
568            )
569            .await;
570    }
571
572    LightningPayStateMachine {
573        common: old_state.common,
574        state: LightningPayStates::Refund(LightningPayRefund {
575            txid: change_range.txid(),
576            out_points: change_range.into_iter().collect(),
577            error_reason,
578        }),
579    }
580}
581
582#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
583pub struct LightningPayRefund {
584    pub txid: TransactionId,
585    pub out_points: Vec<OutPoint>,
586    pub error_reason: String,
587}
588
589#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
590pub struct PayInvoicePayload {
591    pub federation_id: FederationId,
592    pub contract_id: ContractId,
593    /// Metadata on how to obtain the preimage
594    pub payment_data: PaymentData,
595    pub preimage_auth: sha256::Hash,
596}
597
598impl PayInvoicePayload {
599    fn new(common: LightningPayCommon) -> Self {
600        Self {
601            contract_id: common.contract.contract_account.contract.contract_id(),
602            federation_id: common.federation_id,
603            preimage_auth: common.preimage_auth,
604            payment_data: PaymentData::Invoice(common.invoice),
605        }
606    }
607
608    fn new_pruned(common: LightningPayCommon) -> Self {
609        Self {
610            contract_id: common.contract.contract_account.contract.contract_id(),
611            federation_id: common.federation_id,
612            preimage_auth: common.preimage_auth,
613            payment_data: PaymentData::PrunedInvoice(
614                common.invoice.try_into().expect("Invoice has amount"),
615            ),
616        }
617    }
618}
619
620/// Data needed to pay an invoice, may be the whole invoice or only the required
621/// parts of it.
622#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
623#[serde(rename_all = "snake_case")]
624pub enum PaymentData {
625    Invoice(Bolt11Invoice),
626    PrunedInvoice(PrunedInvoice),
627}
628
629impl PaymentData {
630    pub fn amount(&self) -> Option<Amount> {
631        match self {
632            PaymentData::Invoice(invoice) => {
633                invoice.amount_milli_satoshis().map(Amount::from_msats)
634            }
635            PaymentData::PrunedInvoice(PrunedInvoice { amount, .. }) => Some(*amount),
636        }
637    }
638
639    pub fn destination(&self) -> secp256k1::PublicKey {
640        match self {
641            PaymentData::Invoice(invoice) => invoice
642                .payee_pub_key()
643                .copied()
644                .unwrap_or_else(|| invoice.recover_payee_pub_key()),
645            PaymentData::PrunedInvoice(PrunedInvoice { destination, .. }) => *destination,
646        }
647    }
648
649    pub fn payment_hash(&self) -> sha256::Hash {
650        match self {
651            PaymentData::Invoice(invoice) => *invoice.payment_hash(),
652            PaymentData::PrunedInvoice(PrunedInvoice { payment_hash, .. }) => *payment_hash,
653        }
654    }
655
656    pub fn route_hints(&self) -> Vec<RouteHint> {
657        match self {
658            PaymentData::Invoice(invoice) => {
659                invoice.route_hints().into_iter().map(Into::into).collect()
660            }
661            PaymentData::PrunedInvoice(PrunedInvoice { route_hints, .. }) => route_hints.clone(),
662        }
663    }
664
665    pub fn is_expired(&self) -> bool {
666        self.expiry_timestamp() < duration_since_epoch().as_secs()
667    }
668
669    /// Returns the expiry timestamp in seconds since the UNIX epoch
670    pub fn expiry_timestamp(&self) -> u64 {
671        match self {
672            PaymentData::Invoice(invoice) => invoice.expires_at().map_or(u64::MAX, |t| t.as_secs()),
673            PaymentData::PrunedInvoice(PrunedInvoice {
674                expiry_timestamp, ..
675            }) => *expiry_timestamp,
676        }
677    }
678}