fedimint_ln_client/
pay.rs

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