Skip to main content

fedimint_ln_client/
pay.rs

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