fedimint_ln_client/
pay.rs

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