Skip to main content

fedimint_ln_client/
pay.rs

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