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