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