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