Skip to main content

fedimint_lnv2_client/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::missing_panics_doc)]
4#![allow(clippy::module_name_repetitions)]
5#![allow(clippy::must_use_candidate)]
6
7pub use fedimint_lnv2_common as common;
8
9mod api;
10#[cfg(feature = "cli")]
11mod cli;
12mod db;
13pub mod events;
14mod receive_sm;
15mod send_sm;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::sync::Arc;
19
20use async_stream::stream;
21use bitcoin::hashes::{Hash, sha256};
22use bitcoin::secp256k1;
23use db::{DbKeyPrefix, GatewayKey, IncomingContractStreamIndexKey};
24use fedimint_api_client::api::DynModuleApi;
25use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
26use fedimint_client_module::module::recovery::NoModuleBackup;
27use fedimint_client_module::module::{ClientContext, ClientModule, OutPointRange};
28use fedimint_client_module::oplog::UpdateStreamOrOutcome;
29use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
30use fedimint_client_module::transaction::{
31    ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
32};
33use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
34use fedimint_core::config::FederationId;
35use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
36use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped};
37use fedimint_core::encoding::{Decodable, Encodable};
38use fedimint_core::module::{
39    Amounts, ApiAuth, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
40};
41use fedimint_core::secp256k1::SECP256K1;
42use fedimint_core::task::TaskGroup;
43use fedimint_core::time::duration_since_epoch;
44use fedimint_core::util::SafeUrl;
45use fedimint_core::{Amount, PeerId, apply, async_trait_maybe_send};
46use fedimint_derive_secret::{ChildId, DerivableSecret};
47use fedimint_lnv2_common::config::LightningClientConfig;
48use fedimint_lnv2_common::contracts::{IncomingContract, OutgoingContract, PaymentImage};
49use fedimint_lnv2_common::gateway_api::{
50    GatewayConnection, PaymentFee, RealGatewayConnection, RoutingInfo,
51};
52use fedimint_lnv2_common::{
53    Bolt11InvoiceDescription, GatewayApi, KIND, LightningCommonInit, LightningInvoice,
54    LightningModuleTypes, LightningOutput, LightningOutputV0, MINIMUM_INCOMING_CONTRACT_AMOUNT,
55    lnurl, tweak,
56};
57use futures::StreamExt;
58use lightning_invoice::{Bolt11Invoice, Currency};
59use secp256k1::{Keypair, PublicKey, Scalar, SecretKey, ecdh};
60use serde::{Deserialize, Serialize};
61use serde_json::Value;
62use strum::IntoEnumIterator as _;
63use thiserror::Error;
64use tpe::{AggregateDecryptionKey, derive_agg_dk};
65use tracing::warn;
66
67use crate::api::LightningFederationApi;
68use crate::events::SendPaymentEvent;
69use crate::receive_sm::{ReceiveSMCommon, ReceiveSMState, ReceiveStateMachine};
70use crate::send_sm::{SendSMCommon, SendSMState, SendStateMachine};
71
72/// Number of blocks until outgoing lightning contracts times out and user
73/// client can refund it unilaterally
74const EXPIRATION_DELTA_LIMIT: u64 = 1440;
75
76/// A two hour buffer in case either the client or gateway go offline
77const CONTRACT_CONFIRMATION_BUFFER: u64 = 12;
78
79#[allow(clippy::large_enum_variant)]
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub enum LightningOperationMeta {
82    Send(SendOperationMeta),
83    Receive(ReceiveOperationMeta),
84    LnurlReceive(LnurlReceiveOperationMeta),
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SendOperationMeta {
89    pub change_outpoint_range: OutPointRange,
90    pub gateway: SafeUrl,
91    pub contract: OutgoingContract,
92    pub invoice: LightningInvoice,
93    pub custom_meta: Value,
94}
95
96impl SendOperationMeta {
97    /// Calculate the absolute fee paid to the gateway on success.
98    pub fn gateway_fee(&self) -> Amount {
99        match &self.invoice {
100            LightningInvoice::Bolt11(invoice) => self.contract.amount.saturating_sub(
101                Amount::from_msats(invoice.amount_milli_satoshis().expect("Invoice has amount")),
102            ),
103        }
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ReceiveOperationMeta {
109    pub gateway: SafeUrl,
110    pub contract: IncomingContract,
111    pub invoice: LightningInvoice,
112    pub custom_meta: Value,
113}
114
115impl ReceiveOperationMeta {
116    /// Calculate the absolute fee paid to the gateway on success.
117    pub fn gateway_fee(&self) -> Amount {
118        match &self.invoice {
119            LightningInvoice::Bolt11(invoice) => {
120                Amount::from_msats(invoice.amount_milli_satoshis().expect("Invoice has amount"))
121                    .saturating_sub(self.contract.commitment.amount)
122            }
123        }
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct LnurlReceiveOperationMeta {
129    pub contract: IncomingContract,
130    pub custom_meta: Value,
131}
132
133#[cfg_attr(doc, aquamarine::aquamarine)]
134/// The state of an operation sending a payment over lightning.
135///
136/// ```mermaid
137/// graph LR
138/// classDef virtual fill:#fff,stroke-dasharray: 5 5
139///
140///     Funding -- funding transaction is rejected --> Rejected
141///     Funding -- funding transaction is accepted --> Funded
142///     Funded -- payment is confirmed  --> Success
143///     Funded -- payment attempt expires --> Refunding
144///     Funded -- gateway cancels payment attempt --> Refunding
145///     Refunding -- payment is confirmed --> Success
146///     Refunding -- ecash is minted --> Refunded
147///     Refunding -- minting ecash fails --> Failure
148/// ```
149/// The transition from Refunding to Success is only possible if the gateway
150/// misbehaves.
151#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
152pub enum SendOperationState {
153    /// We are funding the contract to incentivize the gateway.
154    Funding,
155    /// We are waiting for the gateway to complete the payment.
156    Funded,
157    /// The payment was successful.
158    Success([u8; 32]),
159    /// The payment has failed and we are refunding the contract.
160    Refunding,
161    /// The payment has been refunded.
162    Refunded,
163    /// Either a programming error has occurred or the federation is malicious.
164    Failure,
165}
166
167/// The final state of an operation sending a payment over lightning.
168#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
169pub enum FinalSendOperationState {
170    /// The payment was successful. Carries the payment preimage proving
171    /// the gateway settled the invoice, serialized as a lowercase hex string.
172    Success(#[serde(with = "fedimint_core::hex::serde")] [u8; 32]),
173    /// The payment has been refunded.
174    Refunded,
175    /// Either a programming error has occurred or the federation is malicious.
176    Failure,
177}
178
179pub type SendResult = Result<OperationId, SendPaymentError>;
180
181#[cfg_attr(doc, aquamarine::aquamarine)]
182/// The state of an operation receiving a payment over lightning.
183///
184/// ```mermaid
185/// graph LR
186/// classDef virtual fill:#fff,stroke-dasharray: 5 5
187///
188///     Pending -- payment is confirmed --> Claiming
189///     Pending -- invoice expires --> Expired
190///     Claiming -- ecash is minted --> Claimed
191///     Claiming -- minting ecash fails --> Failure
192/// ```
193#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
194pub enum ReceiveOperationState {
195    /// We are waiting for the payment.
196    Pending,
197    /// The payment request has expired.
198    Expired,
199    /// The payment has been confirmed and we are issuing the ecash.
200    Claiming,
201    /// The payment has been successful.
202    Claimed,
203    /// Either a programming error has occurred or the federation is malicious.
204    Failure,
205}
206
207/// The final state of an operation receiving a payment over lightning.
208#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
209pub enum FinalReceiveOperationState {
210    /// The payment request has expired.
211    Expired,
212    /// The payment has been successful.
213    Claimed,
214    /// Either a programming error has occurred or the federation is malicious.
215    Failure,
216}
217
218pub type ReceiveResult = Result<(Bolt11Invoice, OperationId), ReceiveError>;
219
220#[derive(Clone)]
221pub struct LightningClientInit {
222    pub gateway_conn: Option<Arc<dyn GatewayConnection + Send + Sync>>,
223    pub custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
224}
225
226impl std::fmt::Debug for LightningClientInit {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.debug_struct("LightningClientInit")
229            .field("gateway_conn", &self.gateway_conn)
230            .field("custom_meta_fn", &"<function>")
231            .finish()
232    }
233}
234
235impl Default for LightningClientInit {
236    fn default() -> Self {
237        LightningClientInit {
238            gateway_conn: None,
239            custom_meta_fn: Arc::new(|| Value::Null),
240        }
241    }
242}
243
244impl ModuleInit for LightningClientInit {
245    type Common = LightningCommonInit;
246
247    async fn dump_database(
248        &self,
249        _dbtx: &mut DatabaseTransaction<'_>,
250        _prefix_names: Vec<String>,
251    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
252        Box::new(BTreeMap::new().into_iter())
253    }
254}
255
256#[apply(async_trait_maybe_send!)]
257impl ClientModuleInit for LightningClientInit {
258    type Module = LightningClientModule;
259
260    fn supported_api_versions(&self) -> MultiApiVersion {
261        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
262            .expect("no version conflicts")
263    }
264
265    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
266        let gateway_conn = if let Some(gateway_conn) = self.gateway_conn.clone() {
267            gateway_conn
268        } else {
269            let api = GatewayApi::new(None, args.connector_registry.clone());
270            Arc::new(RealGatewayConnection { api })
271        };
272        Ok(LightningClientModule::new(
273            *args.federation_id(),
274            args.cfg().clone(),
275            args.notifier().clone(),
276            args.context(),
277            args.module_api().clone(),
278            args.module_root_secret(),
279            gateway_conn,
280            self.custom_meta_fn.clone(),
281            args.admin_auth().cloned(),
282            args.task_group(),
283            args.client_span(),
284        ))
285    }
286
287    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
288        Some(
289            DbKeyPrefix::iter()
290                .map(|p| p as u8)
291                .chain(
292                    DbKeyPrefix::ExternalReservedStart as u8
293                        ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
294                )
295                .collect(),
296        )
297    }
298}
299
300#[derive(Debug, Clone)]
301pub struct LightningClientContext {
302    federation_id: FederationId,
303    gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
304    pub(crate) client_ctx: ClientContext<LightningClientModule>,
305}
306
307impl Context for LightningClientContext {
308    const KIND: Option<ModuleKind> = Some(KIND);
309}
310
311#[derive(Debug, Clone)]
312pub struct LightningClientModule {
313    federation_id: FederationId,
314    cfg: LightningClientConfig,
315    notifier: ModuleNotifier<LightningClientStateMachines>,
316    client_ctx: ClientContext<Self>,
317    module_api: DynModuleApi,
318    keypair: Keypair,
319    lnurl_keypair: Keypair,
320    gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
321    #[allow(unused)] // The field is only used by the cli feature
322    admin_auth: Option<ApiAuth>,
323}
324
325#[apply(async_trait_maybe_send!)]
326impl ClientModule for LightningClientModule {
327    type Init = LightningClientInit;
328    type Common = LightningModuleTypes;
329    type Backup = NoModuleBackup;
330    type ModuleStateMachineContext = LightningClientContext;
331    type States = LightningClientStateMachines;
332
333    fn context(&self) -> Self::ModuleStateMachineContext {
334        LightningClientContext {
335            federation_id: self.federation_id,
336            gateway_conn: self.gateway_conn.clone(),
337            client_ctx: self.client_ctx.clone(),
338        }
339    }
340
341    fn input_fee(
342        &self,
343        amounts: &Amounts,
344        _input: &<Self::Common as ModuleCommon>::Input,
345    ) -> Option<Amounts> {
346        Some(Amounts::new_bitcoin(
347            self.cfg.fee_consensus.fee(amounts.expect_only_bitcoin()),
348        ))
349    }
350
351    fn output_fee(
352        &self,
353        amounts: &Amounts,
354        _output: &<Self::Common as ModuleCommon>::Output,
355    ) -> Option<Amounts> {
356        Some(Amounts::new_bitcoin(
357            self.cfg.fee_consensus.fee(amounts.expect_only_bitcoin()),
358        ))
359    }
360
361    #[cfg(feature = "cli")]
362    async fn handle_cli_command(
363        &self,
364        args: &[std::ffi::OsString],
365    ) -> anyhow::Result<serde_json::Value> {
366        cli::handle_cli_command(self, args).await
367    }
368}
369
370impl LightningClientModule {
371    #[allow(clippy::too_many_arguments)]
372    fn new(
373        federation_id: FederationId,
374        cfg: LightningClientConfig,
375        notifier: ModuleNotifier<LightningClientStateMachines>,
376        client_ctx: ClientContext<Self>,
377        module_api: DynModuleApi,
378        module_root_secret: &DerivableSecret,
379        gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
380        custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
381        admin_auth: Option<ApiAuth>,
382        task_group: &TaskGroup,
383        client_span: &tracing::Span,
384    ) -> Self {
385        let module = Self {
386            federation_id,
387            cfg,
388            notifier,
389            client_ctx,
390            module_api,
391            keypair: module_root_secret
392                .child_key(ChildId(0))
393                .to_secp_key(SECP256K1),
394            lnurl_keypair: module_root_secret
395                .child_key(ChildId(1))
396                .to_secp_key(SECP256K1),
397            gateway_conn,
398            admin_auth,
399        };
400
401        module.spawn_receive_lnurl_task(custom_meta_fn, task_group, client_span);
402
403        module.spawn_gateway_map_update_task(task_group, client_span);
404
405        module
406    }
407
408    fn spawn_gateway_map_update_task(&self, task_group: &TaskGroup, client_span: &tracing::Span) {
409        let module = self.clone();
410        let api = self.module_api.clone();
411
412        task_group.spawn_cancellable_with_span(
413            client_span.clone(),
414            "gateway_map_update_task",
415            async move {
416                api.wait_for_initialized_connections().await;
417                module.update_gateway_map().await;
418            },
419        );
420    }
421
422    async fn update_gateway_map(&self) {
423        // Update the mapping from lightning node public keys to gateway api
424        // endpoints maintained in the module database. When paying an invoice this
425        // enables the client to select the gateway that has created the invoice,
426        // if possible, such that the payment does not go over lightning, reducing
427        // fees and latency.
428
429        if let Ok(gateways) = self.module_api.gateways().await {
430            let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
431
432            for gateway in gateways {
433                if let Ok(Some(routing_info)) = self
434                    .gateway_conn
435                    .routing_info(gateway.clone(), &self.federation_id)
436                    .await
437                {
438                    dbtx.insert_entry(&GatewayKey(routing_info.lightning_public_key), &gateway)
439                        .await;
440                }
441            }
442
443            if let Err(e) = dbtx.commit_tx_result().await {
444                warn!("Failed to commit the updated gateway mapping to the database: {e}");
445            }
446        }
447    }
448
449    /// Selects an available gateway by querying the federation's registered
450    /// gateways, checking if one of them match the invoice's payee public
451    /// key, then queries the gateway for `RoutingInfo` to determine if it is
452    /// online.
453    pub async fn select_gateway(
454        &self,
455        invoice: Option<Bolt11Invoice>,
456    ) -> Result<(SafeUrl, RoutingInfo), SelectGatewayError> {
457        let gateways = self
458            .module_api
459            .gateways()
460            .await
461            .map_err(|e| SelectGatewayError::FailedToRequestGateways(e.to_string()))?;
462
463        if gateways.is_empty() {
464            return Err(SelectGatewayError::NoGatewaysAvailable);
465        }
466
467        if let Some(invoice) = invoice
468            && let Some(gateway) = self
469                .client_ctx
470                .module_db()
471                .begin_transaction_nc()
472                .await
473                .get_value(&GatewayKey(invoice.recover_payee_pub_key()))
474                .await
475                .filter(|gateway| gateways.contains(gateway))
476            && let Ok(Some(routing_info)) = self.routing_info(&gateway).await
477        {
478            return Ok((gateway, routing_info));
479        }
480
481        for gateway in gateways {
482            if let Ok(Some(routing_info)) = self.routing_info(&gateway).await {
483                return Ok((gateway, routing_info));
484            }
485        }
486
487        Err(SelectGatewayError::GatewaysUnresponsive)
488    }
489
490    /// Sends a request to each peer for their registered gateway list and
491    /// returns a `Vec<SafeUrl` of all registered gateways to the client.
492    pub async fn list_gateways(
493        &self,
494        peer: Option<PeerId>,
495    ) -> Result<Vec<SafeUrl>, ListGatewaysError> {
496        if let Some(peer) = peer {
497            self.module_api
498                .gateways_from_peer(peer)
499                .await
500                .map_err(|_| ListGatewaysError::FailedToListGateways)
501        } else {
502            self.module_api
503                .gateways()
504                .await
505                .map_err(|_| ListGatewaysError::FailedToListGateways)
506        }
507    }
508
509    /// Requests the `RoutingInfo`, including fee information, from the gateway
510    /// available at the `SafeUrl`.
511    pub async fn routing_info(
512        &self,
513        gateway: &SafeUrl,
514    ) -> Result<Option<RoutingInfo>, RoutingInfoError> {
515        self.gateway_conn
516            .routing_info(gateway.clone(), &self.federation_id)
517            .await
518            .map_err(|_| RoutingInfoError::FailedToRequestRoutingInfo)
519    }
520
521    /// Pay an invoice. For testing you can optionally specify a gateway to
522    /// route with, otherwise a gateway will be selected automatically. If the
523    /// invoice was created by a gateway connected to our federation, the same
524    /// gateway will be selected to allow for a direct ecash swap. Otherwise we
525    /// select a random online gateway.
526    ///
527    /// The fee for this payment may depend on the selected gateway but
528    /// will be limited to one and a half percent plus one hundred satoshis.
529    /// This fee accounts for the fee charged by the gateway as well as
530    /// the additional fee required to reliably route this payment over
531    /// lightning if necessary. Since the gateway has been vetted by at least
532    /// one guardian we trust it to set a reasonable fee and only enforce a
533    /// rather high limit.
534    ///
535    /// The absolute fee for a payment can be calculated from the operation meta
536    /// to be shown to the user in the transaction history.
537    #[allow(clippy::too_many_lines)]
538    pub async fn send(
539        &self,
540        invoice: Bolt11Invoice,
541        gateway: Option<SafeUrl>,
542        custom_meta: Value,
543    ) -> Result<OperationId, SendPaymentError> {
544        let amount = invoice
545            .amount_milli_satoshis()
546            .ok_or(SendPaymentError::InvoiceMissingAmount)?;
547
548        if invoice.is_expired() {
549            return Err(SendPaymentError::InvoiceExpired);
550        }
551
552        if self.cfg.network != invoice.currency().into() {
553            return Err(SendPaymentError::WrongCurrency {
554                invoice_currency: invoice.currency(),
555                federation_currency: self.cfg.network.into(),
556            });
557        }
558
559        // The attempt index is fixed at `0` so the operation id matches the one
560        // older clients derived for the first payment attempt, ensuring an
561        // already-paid or in-flight invoice is still detected after an upgrade.
562        let operation_id = OperationId::from_encodable(&(invoice.clone(), 0u64));
563
564        if self.client_ctx.operation_exists(operation_id).await {
565            return Err(SendPaymentError::DuplicatePaymentAttempt(operation_id));
566        }
567
568        let (ephemeral_tweak, ephemeral_pk) = tweak::generate(self.keypair.public_key());
569
570        let refund_keypair = SecretKey::from_slice(&ephemeral_tweak)
571            .expect("32 bytes, within curve order")
572            .keypair(secp256k1::SECP256K1);
573
574        let (gateway_api, routing_info) = match gateway {
575            Some(gateway_api) => (
576                gateway_api.clone(),
577                self.routing_info(&gateway_api)
578                    .await
579                    .map_err(|e| SendPaymentError::FailedToConnectToGateway(e.to_string()))?
580                    .ok_or(SendPaymentError::FederationNotSupported)?,
581            ),
582            None => self
583                .select_gateway(Some(invoice.clone()))
584                .await
585                .map_err(SendPaymentError::SelectGateway)?,
586        };
587
588        let (send_fee, expiration_delta) = routing_info.send_parameters(&invoice);
589
590        if !send_fee.le(&PaymentFee::SEND_FEE_LIMIT) {
591            return Err(SendPaymentError::GatewayFeeExceedsLimit);
592        }
593
594        if EXPIRATION_DELTA_LIMIT < expiration_delta {
595            return Err(SendPaymentError::GatewayExpirationExceedsLimit);
596        }
597
598        let consensus_block_count = self
599            .module_api
600            .consensus_block_count()
601            .await
602            .map_err(|e| SendPaymentError::FailedToRequestBlockCount(e.to_string()))?;
603
604        let contract = OutgoingContract {
605            payment_image: PaymentImage::Hash(*invoice.payment_hash()),
606            amount: send_fee.add_to(amount),
607            expiration: consensus_block_count + expiration_delta + CONTRACT_CONFIRMATION_BUFFER,
608            claim_pk: routing_info.module_public_key,
609            refund_pk: refund_keypair.public_key(),
610            ephemeral_pk,
611        };
612
613        let contract_clone = contract.clone();
614        let gateway_api_clone = gateway_api.clone();
615        let invoice_clone = invoice.clone();
616
617        let client_output = ClientOutput::<LightningOutput> {
618            output: LightningOutput::V0(LightningOutputV0::Outgoing(contract.clone())),
619            amounts: Amounts::new_bitcoin(contract.amount),
620        };
621
622        let client_output_sm = ClientOutputSM::<LightningClientStateMachines> {
623            state_machines: Arc::new(move |range: OutPointRange| {
624                vec![LightningClientStateMachines::Send(SendStateMachine {
625                    common: SendSMCommon {
626                        operation_id,
627                        outpoint: range.into_iter().next().unwrap(),
628                        contract: contract_clone.clone(),
629                        gateway_api: Some(gateway_api_clone.clone()),
630                        invoice: Some(LightningInvoice::Bolt11(invoice_clone.clone())),
631                        refund_keypair,
632                    },
633                    state: SendSMState::Funding,
634                })]
635            }),
636        };
637
638        let client_output = self.client_ctx.make_client_outputs(ClientOutputBundle::new(
639            vec![client_output],
640            vec![client_output_sm],
641        ));
642
643        let transaction = TransactionBuilder::new().with_outputs(client_output);
644
645        self.client_ctx
646            .finalize_and_submit_transaction(
647                operation_id,
648                LightningCommonInit::KIND.as_str(),
649                move |change_outpoint_range| {
650                    LightningOperationMeta::Send(SendOperationMeta {
651                        change_outpoint_range,
652                        gateway: gateway_api.clone(),
653                        contract: contract.clone(),
654                        invoice: LightningInvoice::Bolt11(invoice.clone()),
655                        custom_meta: custom_meta.clone(),
656                    })
657                },
658                transaction,
659            )
660            .await
661            .map_err(|e| SendPaymentError::FailedToFundPayment(e.to_string()))?;
662
663        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
664
665        self.client_ctx
666            .log_event(
667                &mut dbtx,
668                SendPaymentEvent {
669                    operation_id,
670                    amount: Amount::from_msats(amount),
671                    fee: send_fee.fee(amount),
672                },
673            )
674            .await;
675
676        dbtx.commit_tx().await;
677
678        Ok(operation_id)
679    }
680
681    /// Subscribe to all state updates of the send operation.
682    pub async fn subscribe_send_operation_state_updates(
683        &self,
684        operation_id: OperationId,
685    ) -> anyhow::Result<UpdateStreamOrOutcome<SendOperationState>> {
686        let operation = self.client_ctx.get_operation(operation_id).await?;
687        let mut stream = self.notifier.subscribe(operation_id).await;
688        let client_ctx = self.client_ctx.clone();
689        let module_api = self.module_api.clone();
690
691        Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
692            stream! {
693                loop {
694                    if let Some(LightningClientStateMachines::Send(state)) = stream.next().await {
695                        match state.state {
696                            SendSMState::Funding => yield SendOperationState::Funding,
697                            SendSMState::Funded => yield SendOperationState::Funded,
698                            SendSMState::Success(preimage) => {
699                                // the preimage has been verified by the state machine previously
700                                assert!(state.common.contract.verify_preimage(&preimage));
701
702                                yield SendOperationState::Success(preimage);
703                                return;
704                            },
705                            SendSMState::Refunding(out_points) => {
706                                yield SendOperationState::Refunding;
707
708                                if client_ctx.await_primary_module_outputs(operation_id, out_points.clone()).await.is_ok() {
709                                    yield SendOperationState::Refunded;
710                                    return;
711                                }
712
713                                // The gateway may have incorrectly claimed the outgoing contract thereby causing
714                                // our refund transaction to be rejected. Therefore, we check one last time if
715                                // the preimage is available before we enter the failure state.
716                                if let Some(preimage) = module_api.await_preimage(
717                                    state.common.outpoint,
718                                    0
719                                ).await
720                                    && state.common.contract.verify_preimage(&preimage) {
721                                        yield SendOperationState::Success(preimage);
722                                        return;
723                                    }
724
725                                yield SendOperationState::Failure;
726                                return;
727                            },
728                            SendSMState::Rejected(..) => {
729                                yield SendOperationState::Failure;
730                                return;
731                            },
732                        }
733                    }
734                }
735            }
736        }))
737    }
738
739    /// Await the final state of the send operation.
740    pub async fn await_final_send_operation_state(
741        &self,
742        operation_id: OperationId,
743    ) -> anyhow::Result<FinalSendOperationState> {
744        let mut stream = self
745            .subscribe_send_operation_state_updates(operation_id)
746            .await?
747            .into_stream();
748
749        let mut final_state = None;
750
751        while let Some(state) = stream.next().await {
752            match state {
753                SendOperationState::Success(preimage) => {
754                    final_state = Some(FinalSendOperationState::Success(preimage));
755                }
756                SendOperationState::Refunded => {
757                    final_state = Some(FinalSendOperationState::Refunded);
758                }
759                SendOperationState::Failure => final_state = Some(FinalSendOperationState::Failure),
760                _ => {}
761            }
762        }
763
764        Ok(final_state.expect("Stream contains one final state"))
765    }
766
767    /// Request an invoice. For testing you can optionally specify a gateway to
768    /// generate the invoice, otherwise a random online gateway will be selected
769    /// automatically.
770    ///
771    /// The total fee for this payment may depend on the chosen gateway but
772    /// will be limited to half of one percent plus fifty satoshis. Since the
773    /// selected gateway has been vetted by at least one guardian we trust it to
774    /// set a reasonable fee and only enforce a rather high limit.
775    ///
776    /// The absolute fee for a payment can be calculated from the operation meta
777    /// to be shown to the user in the transaction history.
778    pub async fn receive(
779        &self,
780        amount: Amount,
781        expiry_secs: u32,
782        description: Bolt11InvoiceDescription,
783        gateway: Option<SafeUrl>,
784        custom_meta: Value,
785    ) -> Result<(Bolt11Invoice, OperationId), ReceiveError> {
786        let (gateway, contract, invoice) = self
787            .create_contract_and_fetch_invoice(
788                self.keypair.public_key(),
789                amount,
790                expiry_secs,
791                description,
792                gateway,
793            )
794            .await?;
795
796        let operation_id = self
797            .receive_incoming_contract(
798                self.keypair.secret_key(),
799                contract.clone(),
800                LightningOperationMeta::Receive(ReceiveOperationMeta {
801                    gateway,
802                    contract,
803                    invoice: LightningInvoice::Bolt11(invoice.clone()),
804                    custom_meta,
805                }),
806            )
807            .await
808            .expect("The contract has been generated with our public key");
809
810        Ok((invoice, operation_id))
811    }
812
813    /// Computes the federation fee a `receive` of `amount` would incur, without
814    /// submitting anything.
815    ///
816    /// When the incoming contract is claimed, the client submits a transaction
817    /// with a single Lightning input worth the contract amount; the primary
818    /// module balances it by minting the change credited to the wallet. This
819    /// quotes the fee of that transaction — the Lightning input fee, the mint
820    /// output fees, and any sub-denomination dust — via the shared,
821    /// module-agnostic fee quote.
822    ///
823    /// The gateway's off-chain Lightning fee is deliberately excluded: this is
824    /// only the fee of the on-federation transaction. For that reason the quote
825    /// is taken on `amount` directly (rather than the gateway-reduced contract
826    /// amount), and no gateway round-trip is needed.
827    pub async fn receive_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
828        self.client_ctx
829            .fee_quote(
830                OperationId::new_random(),
831                FeeQuoteRequest {
832                    input_amount: Amounts::new_bitcoin(amount),
833                    output_amount: Amounts::ZERO,
834                    input_fee: Amounts::new_bitcoin(self.cfg.fee_consensus.fee(amount)),
835                    output_fee: Amounts::ZERO,
836                },
837            )
838            .await
839    }
840
841    /// Computes the federation fee a `send` funding an outgoing contract worth
842    /// `amount` would incur, without submitting anything.
843    ///
844    /// When a payment is sent, the client submits a transaction with a single
845    /// Lightning output (the outgoing contract) worth `amount`; the primary
846    /// module balances it by spending ecash to fund the contract and minting
847    /// any change. This quotes the fee of that transaction — the Lightning
848    /// output fee, the mint input fees on the funding notes, any mint change
849    /// output fees, and sub-denomination dust — via the shared, module-agnostic
850    /// fee quote.
851    ///
852    /// The gateway's off-chain Lightning fee is deliberately excluded: it is
853    /// part of the contract `amount` the gateway claims, not the on-federation
854    /// transaction fee. So `amount` is the full outgoing contract value
855    /// (`send_fee.add_to(invoice_amount)`).
856    pub async fn send_fee_quote(&self, amount: Amount) -> anyhow::Result<FeeQuote> {
857        self.client_ctx
858            .fee_quote(
859                OperationId::new_random(),
860                FeeQuoteRequest {
861                    input_amount: Amounts::ZERO,
862                    output_amount: Amounts::new_bitcoin(amount),
863                    input_fee: Amounts::ZERO,
864                    output_fee: Amounts::new_bitcoin(self.cfg.fee_consensus.fee(amount)),
865                },
866            )
867            .await
868    }
869
870    /// Create an incoming contract locked to a public key derived from the
871    /// recipient's static module public key and fetches the corresponding
872    /// invoice.
873    async fn create_contract_and_fetch_invoice(
874        &self,
875        recipient_static_pk: PublicKey,
876        amount: Amount,
877        expiry_secs: u32,
878        description: Bolt11InvoiceDescription,
879        gateway: Option<SafeUrl>,
880    ) -> Result<(SafeUrl, IncomingContract, Bolt11Invoice), ReceiveError> {
881        let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_static_pk);
882
883        let encryption_seed = ephemeral_tweak
884            .consensus_hash::<sha256::Hash>()
885            .to_byte_array();
886
887        let preimage = encryption_seed
888            .consensus_hash::<sha256::Hash>()
889            .to_byte_array();
890
891        let (gateway, routing_info) = match gateway {
892            Some(gateway) => (
893                gateway.clone(),
894                self.routing_info(&gateway)
895                    .await
896                    .map_err(|e| ReceiveError::FailedToConnectToGateway(e.to_string()))?
897                    .ok_or(ReceiveError::FederationNotSupported)?,
898            ),
899            None => self
900                .select_gateway(None)
901                .await
902                .map_err(ReceiveError::SelectGateway)?,
903        };
904
905        if !routing_info.receive_fee.le(&PaymentFee::RECEIVE_FEE_LIMIT) {
906            return Err(ReceiveError::GatewayFeeExceedsLimit);
907        }
908
909        let contract_amount = routing_info.receive_fee.subtract_from(amount.msats);
910
911        if contract_amount < MINIMUM_INCOMING_CONTRACT_AMOUNT {
912            return Err(ReceiveError::AmountTooSmall);
913        }
914
915        let expiration = duration_since_epoch()
916            .as_secs()
917            .saturating_add(u64::from(expiry_secs));
918
919        let claim_pk = recipient_static_pk
920            .mul_tweak(
921                secp256k1::SECP256K1,
922                &Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order"),
923            )
924            .expect("Tweak is valid");
925
926        let contract = IncomingContract::new(
927            self.cfg.tpe_agg_pk,
928            encryption_seed,
929            preimage,
930            PaymentImage::Hash(preimage.consensus_hash()),
931            contract_amount,
932            expiration,
933            claim_pk,
934            routing_info.module_public_key,
935            ephemeral_pk,
936        );
937
938        let invoice = self
939            .gateway_conn
940            .bolt11_invoice(
941                gateway.clone(),
942                self.federation_id,
943                contract.clone(),
944                amount,
945                description,
946                expiry_secs,
947            )
948            .await
949            .map_err(|e| ReceiveError::FailedToConnectToGateway(e.to_string()))?;
950
951        if invoice.payment_hash() != &preimage.consensus_hash() {
952            return Err(ReceiveError::InvalidInvoice);
953        }
954
955        if invoice.amount_milli_satoshis() != Some(amount.msats) {
956            return Err(ReceiveError::IncorrectInvoiceAmount);
957        }
958
959        Ok((gateway, contract, invoice))
960    }
961
962    // Receive an incoming contract locked to a public key derived from our
963    // static module public key.
964    async fn receive_incoming_contract(
965        &self,
966        sk: SecretKey,
967        contract: IncomingContract,
968        operation_meta: LightningOperationMeta,
969    ) -> Option<OperationId> {
970        let operation_id = OperationId::from_encodable(&contract.clone());
971
972        let (claim_keypair, agg_decryption_key) = self.recover_contract_keys(sk, &contract)?;
973
974        let receive_sm = LightningClientStateMachines::Receive(ReceiveStateMachine {
975            common: ReceiveSMCommon {
976                operation_id,
977                contract: contract.clone(),
978                claim_keypair,
979                agg_decryption_key,
980            },
981            state: ReceiveSMState::Pending,
982        });
983
984        // this may only fail if the operation id is already in use, in which case we
985        // ignore the error such that the method is idempotent
986        self.client_ctx
987            .manual_operation_start(
988                operation_id,
989                LightningCommonInit::KIND.as_str(),
990                operation_meta,
991                vec![self.client_ctx.make_dyn_state(receive_sm)],
992            )
993            .await
994            .ok();
995
996        Some(operation_id)
997    }
998
999    fn recover_contract_keys(
1000        &self,
1001        sk: SecretKey,
1002        contract: &IncomingContract,
1003    ) -> Option<(Keypair, AggregateDecryptionKey)> {
1004        let tweak = ecdh::SharedSecret::new(&contract.commitment.ephemeral_pk, &sk);
1005
1006        let encryption_seed = tweak
1007            .secret_bytes()
1008            .consensus_hash::<sha256::Hash>()
1009            .to_byte_array();
1010
1011        let claim_keypair = sk
1012            .mul_tweak(&Scalar::from_be_bytes(tweak.secret_bytes()).expect("Within curve order"))
1013            .expect("Tweak is valid")
1014            .keypair(secp256k1::SECP256K1);
1015
1016        if claim_keypair.public_key() != contract.commitment.claim_pk {
1017            return None; // The claim key is not derived from our pk
1018        }
1019
1020        let agg_decryption_key = derive_agg_dk(&self.cfg.tpe_agg_pk, &encryption_seed);
1021
1022        if !contract.verify_agg_decryption_key(&self.cfg.tpe_agg_pk, &agg_decryption_key) {
1023            return None; // The decryption key is not derived from our pk
1024        }
1025
1026        contract.decrypt_preimage(&agg_decryption_key)?;
1027
1028        Some((claim_keypair, agg_decryption_key))
1029    }
1030
1031    /// Subscribe to all state updates of the receive operation.
1032    pub async fn subscribe_receive_operation_state_updates(
1033        &self,
1034        operation_id: OperationId,
1035    ) -> anyhow::Result<UpdateStreamOrOutcome<ReceiveOperationState>> {
1036        let operation = self.client_ctx.get_operation(operation_id).await?;
1037        let mut stream = self.notifier.subscribe(operation_id).await;
1038        let client_ctx = self.client_ctx.clone();
1039
1040        Ok(self.client_ctx.outcome_or_updates(operation, operation_id, move || {
1041            stream! {
1042                loop {
1043                    if let Some(LightningClientStateMachines::Receive(state)) = stream.next().await {
1044                        match state.state {
1045                            ReceiveSMState::Pending => yield ReceiveOperationState::Pending,
1046                            ReceiveSMState::Claiming(out_points) => {
1047                                yield ReceiveOperationState::Claiming;
1048
1049                                if client_ctx.await_primary_module_outputs(operation_id, out_points).await.is_ok() {
1050                                    yield ReceiveOperationState::Claimed;
1051                                } else {
1052                                    yield ReceiveOperationState::Failure;
1053                                }
1054                                return;
1055                            },
1056                            ReceiveSMState::Expired => {
1057                                yield ReceiveOperationState::Expired;
1058                                return;
1059                            }
1060                        }
1061                    }
1062                }
1063            }
1064        }))
1065    }
1066
1067    /// Await the final state of the receive operation.
1068    pub async fn await_final_receive_operation_state(
1069        &self,
1070        operation_id: OperationId,
1071    ) -> anyhow::Result<FinalReceiveOperationState> {
1072        let mut stream = self
1073            .subscribe_receive_operation_state_updates(operation_id)
1074            .await?
1075            .into_stream();
1076
1077        let mut final_state = None;
1078
1079        while let Some(state) = stream.next().await {
1080            match state {
1081                ReceiveOperationState::Expired => {
1082                    final_state = Some(FinalReceiveOperationState::Expired);
1083                }
1084                ReceiveOperationState::Claimed => {
1085                    final_state = Some(FinalReceiveOperationState::Claimed);
1086                }
1087                ReceiveOperationState::Failure => {
1088                    final_state = Some(FinalReceiveOperationState::Failure);
1089                }
1090                _ => {}
1091            }
1092        }
1093
1094        Ok(final_state.expect("Stream contains one final state"))
1095    }
1096
1097    /// Generate an lnurl for the client. You can optionally specify a gateway
1098    /// to use for testing purposes.
1099    pub async fn generate_lnurl(
1100        &self,
1101        recurringd: SafeUrl,
1102        gateway: Option<SafeUrl>,
1103    ) -> Result<String, GenerateLnurlError> {
1104        let gateways = if let Some(gateway) = gateway {
1105            vec![gateway]
1106        } else {
1107            let gateways = self
1108                .module_api
1109                .gateways()
1110                .await
1111                .map_err(|e| GenerateLnurlError::FailedToRequestGateways(e.to_string()))?;
1112
1113            if gateways.is_empty() {
1114                return Err(GenerateLnurlError::NoGatewaysAvailable);
1115            }
1116
1117            gateways
1118        };
1119
1120        let payload = fedimint_core::base32::encode_prefixed(
1121            fedimint_core::base32::FEDIMINT_PREFIX,
1122            &lnurl::LnurlRequest {
1123                federation_id: self.federation_id,
1124                recipient_pk: self.lnurl_keypair.public_key(),
1125                aggregate_pk: self.cfg.tpe_agg_pk,
1126                gateways,
1127            },
1128        );
1129
1130        Ok(fedimint_lnurl::encode_lnurl(&format!(
1131            "{recurringd}pay/{payload}"
1132        )))
1133    }
1134
1135    fn spawn_receive_lnurl_task(
1136        &self,
1137        custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
1138        task_group: &TaskGroup,
1139        client_span: &tracing::Span,
1140    ) {
1141        let module = self.clone();
1142        let api = self.module_api.clone();
1143
1144        task_group.spawn_cancellable_with_span(
1145            client_span.clone(),
1146            "receive_lnurl_task",
1147            async move {
1148                api.wait_for_initialized_connections().await;
1149                loop {
1150                    module.receive_lnurl(custom_meta_fn()).await;
1151                }
1152            },
1153        );
1154    }
1155
1156    async fn receive_lnurl(&self, custom_meta: Value) {
1157        // Read the stream cursor with a short-lived transaction. It must NOT stay open
1158        // across the long-poll below: RocksDB's optimistic transactions validate a
1159        // commit against bounded memtable history, so a transaction held open
1160        // for minutes fails with a spurious `WriteConflict` once enough
1161        // concurrent writes flush that history — and the panicking
1162        // `commit_tx()` then killed this task permanently, silently
1163        // stalling every future receive for the lifetime of the process. Long-lived
1164        // clients (daemons) hit this reproducibly under concurrent lnv2 activity.
1165        let stream_index = self
1166            .client_ctx
1167            .module_db()
1168            .begin_transaction_nc()
1169            .await
1170            .get_value(&IncomingContractStreamIndexKey)
1171            .await
1172            .unwrap_or(0);
1173
1174        let (contracts, next_index) = self
1175            .module_api
1176            .await_incoming_contracts(stream_index, 128)
1177            .await;
1178
1179        for contract in &contracts {
1180            if let Some(operation_id) = self
1181                .receive_incoming_contract(
1182                    self.lnurl_keypair.secret_key(),
1183                    contract.clone(),
1184                    LightningOperationMeta::LnurlReceive(LnurlReceiveOperationMeta {
1185                        contract: contract.clone(),
1186                        custom_meta: custom_meta.clone(),
1187                    }),
1188                )
1189                .await
1190            {
1191                self.await_final_receive_operation_state(operation_id)
1192                    .await
1193                    .ok();
1194            }
1195        }
1196
1197        // Advance the cursor in its own short transaction, retrying transient write
1198        // conflicts. The closure re-reads the current value and only ever moves the
1199        // cursor FORWARD, so a concurrent writer can never be rewound by a stale
1200        // `next_index` (a same-key conflict makes the loser retry and re-read).
1201        // Ordering is unchanged: the cursor only moves after the batch above
1202        // was processed, and a crash in between re-fetches the same batch on
1203        // the next iteration exactly as it did when the write shared the read's
1204        // transaction.
1205        self.client_ctx
1206            .module_db()
1207            .autocommit(
1208                |dbtx, _| {
1209                    Box::pin(async move {
1210                        let current = dbtx
1211                            .get_value(&IncomingContractStreamIndexKey)
1212                            .await
1213                            .unwrap_or(0);
1214
1215                        if current < next_index {
1216                            dbtx.insert_entry(&IncomingContractStreamIndexKey, &next_index)
1217                                .await;
1218                        }
1219
1220                        Result::<(), ()>::Ok(())
1221                    })
1222                },
1223                None,
1224            )
1225            .await
1226            .expect("Will never return an error");
1227    }
1228}
1229
1230#[derive(Error, Debug, Clone, Eq, PartialEq)]
1231pub enum SelectGatewayError {
1232    #[error("Failed to request gateways")]
1233    FailedToRequestGateways(String),
1234    #[error("No gateways are available")]
1235    NoGatewaysAvailable,
1236    #[error("All gateways failed to respond")]
1237    GatewaysUnresponsive,
1238}
1239
1240#[derive(Error, Debug, Clone, Eq, PartialEq)]
1241pub enum SendPaymentError {
1242    #[error("Invoice is missing an amount")]
1243    InvoiceMissingAmount,
1244    #[error("Invoice has expired")]
1245    InvoiceExpired,
1246    #[error("Payment attempt is duplicate")]
1247    DuplicatePaymentAttempt(OperationId),
1248    #[error(transparent)]
1249    SelectGateway(SelectGatewayError),
1250    #[error("Failed to connect to gateway")]
1251    FailedToConnectToGateway(String),
1252    #[error("Gateway does not support this federation")]
1253    FederationNotSupported,
1254    #[error("Gateway fee exceeds the allowed limit")]
1255    GatewayFeeExceedsLimit,
1256    #[error("Gateway expiration time exceeds the allowed limit")]
1257    GatewayExpirationExceedsLimit,
1258    #[error("Failed to request block count")]
1259    FailedToRequestBlockCount(String),
1260    #[error("Failed to fund the payment")]
1261    FailedToFundPayment(String),
1262    #[error("Invoice is for a different currency")]
1263    WrongCurrency {
1264        invoice_currency: Currency,
1265        federation_currency: Currency,
1266    },
1267}
1268
1269#[derive(Error, Debug, Clone, Eq, PartialEq)]
1270pub enum ReceiveError {
1271    #[error(transparent)]
1272    SelectGateway(SelectGatewayError),
1273    #[error("Failed to connect to gateway")]
1274    FailedToConnectToGateway(String),
1275    #[error("Gateway does not support this federation")]
1276    FederationNotSupported,
1277    #[error("Gateway fee exceeds the allowed limit")]
1278    GatewayFeeExceedsLimit,
1279    #[error("Amount is too small to cover fees")]
1280    AmountTooSmall,
1281    #[error("Gateway returned an invalid invoice")]
1282    InvalidInvoice,
1283    #[error("Gateway returned an invoice with incorrect amount")]
1284    IncorrectInvoiceAmount,
1285}
1286
1287#[derive(Error, Debug, Clone, Eq, PartialEq)]
1288pub enum GenerateLnurlError {
1289    #[error("No gateways are available")]
1290    NoGatewaysAvailable,
1291    #[error("Failed to request gateways")]
1292    FailedToRequestGateways(String),
1293}
1294
1295#[derive(Error, Debug, Clone, Eq, PartialEq)]
1296pub enum ListGatewaysError {
1297    #[error("Failed to request gateways")]
1298    FailedToListGateways,
1299}
1300
1301#[derive(Error, Debug, Clone, Eq, PartialEq)]
1302pub enum RoutingInfoError {
1303    #[error("Failed to request routing info")]
1304    FailedToRequestRoutingInfo,
1305}
1306
1307#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
1308pub enum LightningClientStateMachines {
1309    Send(SendStateMachine),
1310    Receive(ReceiveStateMachine),
1311}
1312
1313impl IntoDynInstance for LightningClientStateMachines {
1314    type DynType = DynState;
1315
1316    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
1317        DynState::from_typed(instance_id, self)
1318    }
1319}
1320
1321impl State for LightningClientStateMachines {
1322    type ModuleContext = LightningClientContext;
1323
1324    fn transitions(
1325        &self,
1326        context: &Self::ModuleContext,
1327        global_context: &DynGlobalClientContext,
1328    ) -> Vec<StateTransition<Self>> {
1329        match self {
1330            LightningClientStateMachines::Send(state) => {
1331                sm_enum_variant_translation!(
1332                    state.transitions(context, global_context),
1333                    LightningClientStateMachines::Send
1334                )
1335            }
1336            LightningClientStateMachines::Receive(state) => {
1337                sm_enum_variant_translation!(
1338                    state.transitions(context, global_context),
1339                    LightningClientStateMachines::Receive
1340                )
1341            }
1342        }
1343    }
1344
1345    fn operation_id(&self) -> OperationId {
1346        match self {
1347            LightningClientStateMachines::Send(state) => state.operation_id(),
1348            LightningClientStateMachines::Receive(state) => state.operation_id(),
1349        }
1350    }
1351}