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;
12pub mod 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::error::{
26    ClientModuleError, OperationLookupError, TransactionSubmitError,
27};
28use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
29use fedimint_client_module::module::recovery::NoModuleBackup;
30use fedimint_client_module::module::{ClientContext, ClientModule, OutPointRange};
31use fedimint_client_module::oplog::UpdateStreamOrOutcome;
32use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
33use fedimint_client_module::transaction::{
34    ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote, FeeQuoteRequest,
35    TransactionBuilder, max_affordable_send_amount,
36};
37use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
38use fedimint_core::config::FederationId;
39use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
40use fedimint_core::db::{DatabaseTransaction, IDatabaseTransactionOpsCoreTyped};
41use fedimint_core::encoding::{Decodable, Encodable};
42use fedimint_core::module::{
43    Amounts, ApiAuth, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
44};
45use fedimint_core::secp256k1::SECP256K1;
46use fedimint_core::task::TaskGroup;
47use fedimint_core::time::duration_since_epoch;
48use fedimint_core::util::{FmtCompact as _, SafeUrl};
49use fedimint_core::{Amount, PeerId, apply, async_trait_maybe_send};
50use fedimint_derive_secret::{ChildId, DerivableSecret};
51use fedimint_lnv2_common::config::LightningClientConfig;
52use fedimint_lnv2_common::contracts::{IncomingContract, OutgoingContract, PaymentImage};
53use fedimint_lnv2_common::gateway_api::{
54    GatewayConnection, MAX_INVOICE_EXPIRY_SECS, PaymentFee, RealGatewayConnection, RoutingInfo,
55};
56use fedimint_lnv2_common::{
57    Bolt11InvoiceDescription, GatewayApi, KIND, LightningCommonInit, LightningInvoice,
58    LightningModuleTypes, LightningOutput, LightningOutputV0, lnurl, tweak,
59};
60use fedimint_logging::LOG_CLIENT_MODULE_LNV2;
61use futures::StreamExt;
62use lightning_invoice::{Bolt11Invoice, Currency};
63use secp256k1::{Keypair, Scalar, SecretKey, ecdh};
64use serde::{Deserialize, Serialize};
65use serde_json::Value;
66use strum::IntoEnumIterator as _;
67use thiserror::Error;
68use tpe::{AggregateDecryptionKey, derive_agg_dk};
69use tracing::warn;
70
71use crate::api::LightningFederationApi;
72use crate::events::SendPaymentEvent;
73use crate::receive_sm::{ReceiveSMCommon, ReceiveSMState, ReceiveStateMachine};
74use crate::send_sm::{SendSMCommon, SendSMState, SendStateMachine};
75
76/// Number of blocks until outgoing lightning contracts times out and user
77/// client can refund it unilaterally
78const EXPIRATION_DELTA_LIMIT: u64 = 1440;
79
80/// A two hour buffer in case either the client or gateway go offline
81const CONTRACT_CONFIRMATION_BUFFER: u64 = 12;
82
83#[allow(clippy::large_enum_variant)]
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum LightningOperationMeta {
86    Send(SendOperationMeta),
87    Receive(ReceiveOperationMeta),
88    LnurlReceive(LnurlReceiveOperationMeta),
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SendOperationMeta {
93    pub change_outpoint_range: OutPointRange,
94    pub gateway: SafeUrl,
95    pub contract: OutgoingContract,
96    pub invoice: LightningInvoice,
97    pub custom_meta: Value,
98}
99
100impl SendOperationMeta {
101    /// Calculate the absolute fee paid to the gateway on success.
102    pub fn gateway_fee(&self) -> Amount {
103        match &self.invoice {
104            LightningInvoice::Bolt11(invoice) => self.contract.amount.saturating_sub(
105                Amount::from_msats(invoice.amount_milli_satoshis().expect("Invoice has amount")),
106            ),
107        }
108    }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ReceiveOperationMeta {
113    pub gateway: SafeUrl,
114    pub contract: IncomingContract,
115    pub invoice: LightningInvoice,
116    pub custom_meta: Value,
117}
118
119impl ReceiveOperationMeta {
120    /// Calculate the absolute fee paid to the gateway on success.
121    pub fn gateway_fee(&self) -> Amount {
122        match &self.invoice {
123            LightningInvoice::Bolt11(invoice) => {
124                Amount::from_msats(invoice.amount_milli_satoshis().expect("Invoice has amount"))
125                    .saturating_sub(self.contract.commitment.amount)
126            }
127        }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct LnurlReceiveOperationMeta {
133    pub contract: IncomingContract,
134    pub custom_meta: Value,
135}
136
137#[cfg_attr(doc, aquamarine::aquamarine)]
138/// The state of an operation sending a payment over lightning.
139///
140/// ```mermaid
141/// graph LR
142/// classDef virtual fill:#fff,stroke-dasharray: 5 5
143///
144///     Funding -- funding transaction is rejected --> Rejected
145///     Funding -- funding transaction is accepted --> Funded
146///     Funded -- payment is confirmed  --> Success
147///     Funded -- payment attempt expires --> Refunding
148///     Funded -- gateway cancels payment attempt --> Refunding
149///     Refunding -- payment is confirmed --> Success
150///     Refunding -- ecash is minted --> Refunded
151///     Refunding -- minting ecash fails --> Failure
152/// ```
153/// The transition from Refunding to Success is only possible if the gateway
154/// misbehaves.
155#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
156pub enum SendOperationState {
157    /// We are funding the contract to incentivize the gateway.
158    Funding,
159    /// We are waiting for the gateway to complete the payment.
160    Funded,
161    /// The payment was successful.
162    Success([u8; 32]),
163    /// The payment has failed and we are refunding the contract.
164    Refunding,
165    /// The payment has been refunded.
166    Refunded,
167    /// Either a programming error has occurred or the federation is malicious.
168    Failure,
169}
170
171/// The final state of an operation sending a payment over lightning.
172#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
173pub enum FinalSendOperationState {
174    /// The payment was successful. Carries the payment preimage proving
175    /// the gateway settled the invoice, serialized as a lowercase hex string.
176    Success(#[serde(with = "fedimint_core::hex::serde")] [u8; 32]),
177    /// The payment has been refunded.
178    Refunded,
179    /// Either a programming error has occurred or the federation is malicious.
180    Failure,
181}
182
183pub type SendResult = Result<OperationId, SendPaymentError>;
184
185#[cfg_attr(doc, aquamarine::aquamarine)]
186/// The state of an operation receiving a payment over lightning.
187///
188/// ```mermaid
189/// graph LR
190/// classDef virtual fill:#fff,stroke-dasharray: 5 5
191///
192///     Pending -- payment is confirmed --> Claiming
193///     Pending -- invoice expires --> Expired
194///     Claiming -- ecash is minted --> Claimed
195///     Claiming -- minting ecash fails --> Failure
196/// ```
197#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
198pub enum ReceiveOperationState {
199    /// We are waiting for the payment.
200    Pending,
201    /// The payment request has expired.
202    Expired,
203    /// The payment has been confirmed and we are issuing the ecash.
204    Claiming,
205    /// The payment has been successful.
206    Claimed,
207    /// Either a programming error has occurred or the federation is malicious.
208    Failure,
209    /// The contract is worth less than the federation charges to claim it, so
210    /// it was left unclaimed. Reachable without any payment request of ours:
211    /// anyone can address an incoming contract to a published lnurl key.
212    Uneconomical,
213}
214
215/// The final state of an operation receiving a payment over lightning.
216#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
217pub enum FinalReceiveOperationState {
218    /// The payment request has expired.
219    Expired,
220    /// The payment has been successful.
221    Claimed,
222    /// Either a programming error has occurred or the federation is malicious.
223    Failure,
224    /// The contract is worth less than the federation charges to claim it, so
225    /// it was left unclaimed.
226    Uneconomical,
227}
228
229pub type ReceiveResult = Result<(Bolt11Invoice, OperationId), ReceiveError>;
230
231#[derive(Clone)]
232pub struct LightningClientInit {
233    pub gateway_conn: Option<Arc<dyn GatewayConnection + Send + Sync>>,
234    pub custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
235}
236
237impl std::fmt::Debug for LightningClientInit {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("LightningClientInit")
240            .field("gateway_conn", &self.gateway_conn)
241            .field("custom_meta_fn", &"<function>")
242            .finish()
243    }
244}
245
246impl Default for LightningClientInit {
247    fn default() -> Self {
248        LightningClientInit {
249            gateway_conn: None,
250            custom_meta_fn: Arc::new(|| Value::Null),
251        }
252    }
253}
254
255impl ModuleInit for LightningClientInit {
256    type Common = LightningCommonInit;
257
258    async fn dump_database(
259        &self,
260        _dbtx: &mut DatabaseTransaction<'_>,
261        _prefix_names: Vec<String>,
262    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
263        Box::new(BTreeMap::new().into_iter())
264    }
265}
266
267#[apply(async_trait_maybe_send!)]
268impl ClientModuleInit for LightningClientInit {
269    type Module = LightningClientModule;
270
271    fn supported_api_versions(&self) -> MultiApiVersion {
272        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
273            .expect("no version conflicts")
274    }
275
276    async fn init(
277        &self,
278        args: &ClientModuleInitArgs<Self>,
279    ) -> Result<Self::Module, ClientModuleError> {
280        let gateway_conn = if let Some(gateway_conn) = self.gateway_conn.clone() {
281            gateway_conn
282        } else {
283            let api = GatewayApi::new(None, args.connector_registry.clone());
284            Arc::new(RealGatewayConnection { api })
285        };
286        Ok(LightningClientModule::new(
287            *args.federation_id(),
288            args.cfg().clone(),
289            args.notifier().clone(),
290            args.context(),
291            args.module_api().clone(),
292            args.module_root_secret(),
293            gateway_conn,
294            self.custom_meta_fn.clone(),
295            args.admin_auth().cloned(),
296            args.task_group(),
297            args.client_span(),
298        ))
299    }
300
301    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
302        Some(
303            DbKeyPrefix::iter()
304                .map(|p| p as u8)
305                .chain(
306                    DbKeyPrefix::ExternalReservedStart as u8
307                        ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
308                )
309                .collect(),
310        )
311    }
312}
313
314#[derive(Debug, Clone)]
315pub struct LightningClientContext {
316    federation_id: FederationId,
317    gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
318    pub(crate) client_ctx: ClientContext<LightningClientModule>,
319}
320
321impl Context for LightningClientContext {
322    const KIND: Option<ModuleKind> = Some(KIND);
323}
324
325#[derive(Debug, Clone)]
326pub struct LightningClientModule {
327    federation_id: FederationId,
328    cfg: LightningClientConfig,
329    notifier: ModuleNotifier<LightningClientStateMachines>,
330    client_ctx: ClientContext<Self>,
331    module_api: DynModuleApi,
332    keypair: Keypair,
333    lnurl_keypair: Keypair,
334    gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
335    #[allow(unused)] // The field is only used by the cli feature
336    admin_auth: Option<ApiAuth>,
337}
338
339#[apply(async_trait_maybe_send!)]
340impl ClientModule for LightningClientModule {
341    type Init = LightningClientInit;
342    type Common = LightningModuleTypes;
343    type Backup = NoModuleBackup;
344    type ModuleStateMachineContext = LightningClientContext;
345    type States = LightningClientStateMachines;
346
347    fn context(&self) -> Self::ModuleStateMachineContext {
348        LightningClientContext {
349            federation_id: self.federation_id,
350            gateway_conn: self.gateway_conn.clone(),
351            client_ctx: self.client_ctx.clone(),
352        }
353    }
354
355    fn input_fee(
356        &self,
357        amounts: &Amounts,
358        _input: &<Self::Common as ModuleCommon>::Input,
359    ) -> Option<Amounts> {
360        Some(Amounts::new_bitcoin(
361            self.cfg.fee_consensus.fee(amounts.expect_only_bitcoin()),
362        ))
363    }
364
365    fn output_fee(
366        &self,
367        amounts: &Amounts,
368        _output: &<Self::Common as ModuleCommon>::Output,
369    ) -> Option<Amounts> {
370        Some(Amounts::new_bitcoin(
371            self.cfg.fee_consensus.fee(amounts.expect_only_bitcoin()),
372        ))
373    }
374
375    #[cfg(feature = "cli")]
376    async fn handle_cli_command(
377        &self,
378        args: &[std::ffi::OsString],
379    ) -> Result<serde_json::Value, ClientModuleError> {
380        cli::handle_cli_command(self, args)
381            .await
382            .map_err(ClientModuleError::other)
383    }
384}
385
386impl LightningClientModule {
387    #[allow(clippy::too_many_arguments)]
388    fn new(
389        federation_id: FederationId,
390        cfg: LightningClientConfig,
391        notifier: ModuleNotifier<LightningClientStateMachines>,
392        client_ctx: ClientContext<Self>,
393        module_api: DynModuleApi,
394        module_root_secret: &DerivableSecret,
395        gateway_conn: Arc<dyn GatewayConnection + Send + Sync>,
396        custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
397        admin_auth: Option<ApiAuth>,
398        task_group: &TaskGroup,
399        client_span: &tracing::Span,
400    ) -> Self {
401        let module = Self {
402            federation_id,
403            cfg,
404            notifier,
405            client_ctx,
406            module_api,
407            keypair: module_root_secret
408                .child_key(ChildId(0))
409                .to_secp_key(SECP256K1),
410            lnurl_keypair: module_root_secret
411                .child_key(ChildId(1))
412                .to_secp_key(SECP256K1),
413            gateway_conn,
414            admin_auth,
415        };
416
417        module.spawn_receive_lnurl_task(custom_meta_fn, task_group, client_span);
418
419        module.spawn_gateway_map_update_task(task_group, client_span);
420
421        module
422    }
423
424    fn spawn_gateway_map_update_task(&self, task_group: &TaskGroup, client_span: &tracing::Span) {
425        let module = self.clone();
426        let api = self.module_api.clone();
427
428        task_group.spawn_cancellable_with_span(
429            client_span.clone(),
430            "gateway_map_update_task",
431            async move {
432                api.wait_for_initialized_connections().await;
433                module.update_gateway_map().await;
434            },
435        );
436    }
437
438    async fn update_gateway_map(&self) {
439        // Update the mapping from lightning node public keys to gateway api
440        // endpoints maintained in the module database. When paying an invoice this
441        // enables the client to select the gateway that has created the invoice,
442        // if possible, such that the payment does not go over lightning, reducing
443        // fees and latency.
444
445        if let Ok(gateways) = self.module_api.gateways().await {
446            let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
447
448            for gateway in gateways {
449                if let Ok(Some(routing_info)) = self
450                    .gateway_conn
451                    .routing_info(gateway.clone(), &self.federation_id)
452                    .await
453                {
454                    dbtx.insert_entry(&GatewayKey(routing_info.lightning_public_key), &gateway)
455                        .await;
456                }
457            }
458
459            if let Err(e) = dbtx.commit_tx_result().await {
460                warn!("Failed to commit the updated gateway mapping to the database: {e}");
461            }
462        }
463    }
464
465    /// Selects an available gateway by querying the federation's registered
466    /// gateways, checking if one of them match the invoice's payee public
467    /// key, then queries the gateway for `RoutingInfo` to determine if it is
468    /// online.
469    pub async fn select_gateway(
470        &self,
471        invoice: Option<Bolt11Invoice>,
472    ) -> Result<(SafeUrl, RoutingInfo), SelectGatewayError> {
473        let gateways = self
474            .module_api
475            .gateways()
476            .await
477            .map_err(|e| SelectGatewayError::FailedToRequestGateways(e.to_string()))?;
478
479        if gateways.is_empty() {
480            return Err(SelectGatewayError::NoGatewaysAvailable);
481        }
482
483        if let Some(invoice) = invoice
484            && let Some(gateway) = self
485                .client_ctx
486                .module_db()
487                .begin_transaction_nc()
488                .await
489                .get_value(&GatewayKey(invoice.recover_payee_pub_key()))
490                .await
491                .filter(|gateway| gateways.contains(gateway))
492            && let Ok(Some(routing_info)) = self.routing_info(&gateway).await
493        {
494            return Ok((gateway, routing_info));
495        }
496
497        for gateway in gateways {
498            if let Ok(Some(routing_info)) = self.routing_info(&gateway).await {
499                return Ok((gateway, routing_info));
500            }
501        }
502
503        Err(SelectGatewayError::GatewaysUnresponsive)
504    }
505
506    /// Sends a request to each peer for their registered gateway list and
507    /// returns a `Vec<SafeUrl` of all registered gateways to the client.
508    pub async fn list_gateways(
509        &self,
510        peer: Option<PeerId>,
511    ) -> Result<Vec<SafeUrl>, ListGatewaysError> {
512        if let Some(peer) = peer {
513            self.module_api
514                .gateways_from_peer(peer)
515                .await
516                .map_err(|_| ListGatewaysError::FailedToListGateways)
517        } else {
518            self.module_api
519                .gateways()
520                .await
521                .map_err(|_| ListGatewaysError::FailedToListGateways)
522        }
523    }
524
525    /// Requests the `RoutingInfo`, including fee information, from the gateway
526    /// available at the `SafeUrl`.
527    pub async fn routing_info(
528        &self,
529        gateway: &SafeUrl,
530    ) -> Result<Option<RoutingInfo>, RoutingInfoError> {
531        self.gateway_conn
532            .routing_info(gateway.clone(), &self.federation_id)
533            .await
534            .map_err(|_| RoutingInfoError::FailedToRequestRoutingInfo)
535    }
536
537    /// Pay an invoice. For testing you can optionally specify a gateway to
538    /// route with, otherwise a gateway will be selected automatically. If the
539    /// invoice was created by a gateway connected to our federation, the same
540    /// gateway will be selected to allow for a direct ecash swap. Otherwise we
541    /// select a random online gateway.
542    ///
543    /// The fee for this payment may depend on the selected gateway but
544    /// will be limited to one and a half percent plus one hundred satoshis.
545    /// This fee accounts for the fee charged by the gateway as well as
546    /// the additional fee required to reliably route this payment over
547    /// lightning if necessary. Since the gateway has been vetted by at least
548    /// one guardian we trust it to set a reasonable fee and only enforce a
549    /// rather high limit.
550    ///
551    /// The absolute fee for a payment can be calculated from the operation meta
552    /// to be shown to the user in the transaction history.
553    pub async fn send(
554        &self,
555        invoice: Bolt11Invoice,
556        gateway: Option<SafeUrl>,
557        custom_meta: Value,
558    ) -> Result<OperationId, SendPaymentError> {
559        let (amount, operation_id) = self.validate_send_invoice(&invoice).await?;
560
561        let (gateway_api, routing_info) = self.resolve_send_gateway(&invoice, gateway).await?;
562
563        self.fund_outgoing_contract(
564            invoice,
565            amount,
566            operation_id,
567            gateway_api,
568            routing_info,
569            custom_meta,
570        )
571        .await
572    }
573
574    /// Pays `invoice` through `gateway` at the terms the caller already
575    /// checked.
576    ///
577    /// [`Self::send`] reads the gateway's terms itself, so a caller that showed
578    /// the user a fee and asked for approval has no guarantee the funded
579    /// contract carries that fee: the gateway may change its schedule between
580    /// the two reads. This variant takes the `send_fee` and `expiration_delta`
581    /// the caller obtained from [`RoutingInfo::send_parameters`] and refuses
582    /// with [`SendWithTermsError::TermsChanged`], before funding anything, if
583    /// the gateway currently reports different terms. A cheaper fee counts
584    /// as changed too, so the funded contract always matches what was
585    /// approved. This binds the funded contract to the approved terms, not
586    /// the gateway's acceptance: a gateway that raises its schedule after the
587    /// check can still refuse the contract, in which case the payment is
588    /// refunded. The error carries the current terms for re-quoting.
589    pub async fn send_with_terms(
590        &self,
591        invoice: Bolt11Invoice,
592        gateway: SafeUrl,
593        send_fee: PaymentFee,
594        expiration_delta: u64,
595        custom_meta: Value,
596    ) -> Result<OperationId, SendWithTermsError> {
597        let (amount, operation_id) = self.validate_send_invoice(&invoice).await?;
598
599        let (gateway_api, routing_info) =
600            self.resolve_send_gateway(&invoice, Some(gateway)).await?;
601
602        let current = routing_info.send_parameters(&invoice);
603
604        if current != (send_fee, expiration_delta) {
605            return Err(SendWithTermsError::TermsChanged {
606                send_fee: current.0,
607                expiration_delta: current.1,
608            });
609        }
610
611        let operation_id = self
612            .fund_outgoing_contract(
613                invoice,
614                amount,
615                operation_id,
616                gateway_api,
617                routing_info,
618                custom_meta,
619            )
620            .await?;
621
622        Ok(operation_id)
623    }
624
625    /// Checks that `invoice` is payable by this client and has not been
626    /// attempted before, returning its amount in millisatoshis and the
627    /// operation id a payment of it uses.
628    async fn validate_send_invoice(
629        &self,
630        invoice: &Bolt11Invoice,
631    ) -> Result<(u64, OperationId), SendPaymentError> {
632        let amount = invoice
633            .amount_milli_satoshis()
634            .ok_or(SendPaymentError::InvoiceMissingAmount)?;
635
636        if invoice.is_expired() {
637            return Err(SendPaymentError::InvoiceExpired);
638        }
639
640        if self.cfg.network != invoice.currency().into() {
641            return Err(SendPaymentError::WrongCurrency {
642                invoice_currency: invoice.currency(),
643                federation_currency: self.cfg.network.into(),
644            });
645        }
646
647        // The attempt index is fixed at `0` so the operation id matches the one
648        // older clients derived for the first payment attempt, ensuring an
649        // already-paid or in-flight invoice is still detected after an upgrade.
650        let operation_id = OperationId::from_encodable(&(invoice.clone(), 0u64));
651
652        if self.client_ctx.operation_exists(operation_id).await {
653            return Err(SendPaymentError::DuplicatePaymentAttempt(operation_id));
654        }
655
656        Ok((amount, operation_id))
657    }
658
659    /// Resolves the gateway to pay `invoice` through and its current routing
660    /// info: the given one, or an automatically selected one when `None`.
661    async fn resolve_send_gateway(
662        &self,
663        invoice: &Bolt11Invoice,
664        gateway: Option<SafeUrl>,
665    ) -> Result<(SafeUrl, RoutingInfo), SendPaymentError> {
666        match gateway {
667            Some(gateway_api) => Ok((
668                gateway_api.clone(),
669                self.routing_info(&gateway_api)
670                    .await
671                    .map_err(|e| SendPaymentError::FailedToConnectToGateway(e.to_string()))?
672                    .ok_or(SendPaymentError::FederationNotSupported)?,
673            )),
674            None => self
675                .select_gateway(Some(invoice.clone()))
676                .await
677                .map_err(SendPaymentError::SelectGateway),
678        }
679    }
680
681    /// Funds an outgoing contract for `invoice` at the terms in `routing_info`
682    /// and starts the state machine that hands it to `gateway_api`.
683    #[allow(clippy::too_many_lines)]
684    async fn fund_outgoing_contract(
685        &self,
686        invoice: Bolt11Invoice,
687        amount: u64,
688        operation_id: OperationId,
689        gateway_api: SafeUrl,
690        routing_info: RoutingInfo,
691        custom_meta: Value,
692    ) -> Result<OperationId, SendPaymentError> {
693        let (ephemeral_tweak, ephemeral_pk) = tweak::generate(self.keypair.public_key());
694
695        let refund_keypair = SecretKey::from_slice(&ephemeral_tweak)
696            .expect("32 bytes, within curve order")
697            .keypair(secp256k1::SECP256K1);
698
699        let (send_fee, expiration_delta) = routing_info.send_parameters(&invoice);
700
701        if !send_fee.is_within(&PaymentFee::SEND_FEE_LIMIT) {
702            return Err(SendPaymentError::GatewayFeeExceedsLimit);
703        }
704
705        if EXPIRATION_DELTA_LIMIT < expiration_delta {
706            return Err(SendPaymentError::GatewayExpirationExceedsLimit);
707        }
708
709        let consensus_block_count = self
710            .module_api
711            .consensus_block_count()
712            .await
713            .map_err(|e| SendPaymentError::FailedToRequestBlockCount(e.to_string()))?;
714
715        let contract = OutgoingContract {
716            payment_image: PaymentImage::Hash(*invoice.payment_hash()),
717            amount: send_fee.add_to(amount),
718            expiration: consensus_block_count + expiration_delta + CONTRACT_CONFIRMATION_BUFFER,
719            claim_pk: routing_info.module_public_key,
720            refund_pk: refund_keypair.public_key(),
721            ephemeral_pk,
722        };
723
724        let contract_clone = contract.clone();
725        let gateway_api_clone = gateway_api.clone();
726        let invoice_clone = invoice.clone();
727
728        let client_output = ClientOutput::<LightningOutput> {
729            output: LightningOutput::V0(LightningOutputV0::Outgoing(contract.clone())),
730            amounts: Amounts::new_bitcoin(contract.amount),
731        };
732
733        let client_output_sm = ClientOutputSM::<LightningClientStateMachines> {
734            state_machines: Arc::new(move |range: OutPointRange| {
735                vec![LightningClientStateMachines::Send(SendStateMachine {
736                    common: SendSMCommon {
737                        operation_id,
738                        outpoint: range.into_iter().next().unwrap(),
739                        contract: contract_clone.clone(),
740                        gateway_api: Some(gateway_api_clone.clone()),
741                        invoice: Some(LightningInvoice::Bolt11(invoice_clone.clone())),
742                        refund_keypair,
743                    },
744                    state: SendSMState::Funding,
745                })]
746            }),
747        };
748
749        let client_output = self.client_ctx.make_client_outputs(ClientOutputBundle::new(
750            vec![client_output],
751            vec![client_output_sm],
752        ));
753
754        let transaction = TransactionBuilder::new().with_outputs(client_output);
755
756        self.client_ctx
757            .finalize_and_submit_transaction(
758                operation_id,
759                LightningCommonInit::KIND.as_str(),
760                move |change_outpoint_range| {
761                    LightningOperationMeta::Send(SendOperationMeta {
762                        change_outpoint_range,
763                        gateway: gateway_api.clone(),
764                        contract: contract.clone(),
765                        invoice: LightningInvoice::Bolt11(invoice.clone()),
766                        custom_meta: custom_meta.clone(),
767                    })
768                },
769                transaction,
770            )
771            .await
772            .map_err(|e| SendPaymentError::FailedToFundPayment(e.fmt_compact().to_string()))?;
773
774        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
775
776        self.client_ctx
777            .log_event(
778                &mut dbtx,
779                SendPaymentEvent {
780                    operation_id,
781                    amount: Amount::from_msats(amount),
782                    fee: send_fee.fee(amount),
783                },
784            )
785            .await;
786
787        dbtx.commit_tx().await;
788
789        Ok(operation_id)
790    }
791
792    /// The status of a previous [`Self::send`] for this invoice.
793    ///
794    /// Callers deciding whether an invoice is safe to pay through another
795    /// mechanism must treat anything but [`InvoiceSendStatus::NotAttempted`]
796    /// as a potential double payment.
797    pub async fn get_invoice_send_status(
798        &self,
799        invoice: &Bolt11Invoice,
800    ) -> Result<InvoiceSendStatus, OperationLookupError> {
801        // Send only creates attempt index 0 nowadays, but older clients
802        // allocated a fresh index per retry, so scan for the latest attempt.
803        // No new attempt was ever allocated after a success, so the latest
804        // attempt alone determines the status.
805        let mut last_op = None;
806
807        for attempt in 0_u64.. {
808            let operation_id = OperationId::from_encodable(&(invoice.clone(), attempt));
809
810            if !self.client_ctx.operation_exists(operation_id).await {
811                break;
812            }
813
814            last_op = Some(operation_id);
815        }
816
817        let Some(operation_id) = last_op else {
818            return Ok(InvoiceSendStatus::NotAttempted);
819        };
820
821        if self.client_ctx.has_active_states(operation_id).await {
822            return Ok(InvoiceSendStatus::InFlight(operation_id));
823        }
824
825        // The operation is finished; replaying its (already terminated) update
826        // stream yields the cached outcome without blocking.
827        let mut stream = self
828            .subscribe_send_operation_state_updates(operation_id)
829            .await?
830            .into_stream();
831
832        while let Some(state) = stream.next().await {
833            if let SendOperationState::Success(_) = state {
834                return Ok(InvoiceSendStatus::Succeeded(operation_id));
835            }
836        }
837
838        Ok(InvoiceSendStatus::Failed(operation_id))
839    }
840
841    /// Subscribe to all state updates of the send operation.
842    pub async fn subscribe_send_operation_state_updates(
843        &self,
844        operation_id: OperationId,
845    ) -> Result<UpdateStreamOrOutcome<SendOperationState>, OperationLookupError> {
846        let operation = self.client_ctx.get_operation(operation_id).await?;
847        let mut stream = self.notifier.subscribe(operation_id).await;
848        let client_ctx = self.client_ctx.clone();
849        let module_api = self.module_api.clone();
850
851        Ok(self.client_ctx.outcome_or_updates(&operation, operation_id, |state| match state {
852                SendOperationState::Funding
853                | SendOperationState::Funded
854                | SendOperationState::Refunding => false,
855                SendOperationState::Success(_)
856                | SendOperationState::Refunded
857                | SendOperationState::Failure => true,
858            }, move || {
859            stream! {
860                loop {
861                    if let Some(LightningClientStateMachines::Send(state)) = stream.next().await {
862                        match state.state {
863                            SendSMState::Funding => yield SendOperationState::Funding,
864                            SendSMState::Funded => yield SendOperationState::Funded,
865                            SendSMState::Success(preimage) => {
866                                // the preimage has been verified by the state machine previously
867                                assert!(state.common.contract.verify_preimage(&preimage));
868
869                                yield SendOperationState::Success(preimage);
870                                return;
871                            },
872                            SendSMState::Refunding(out_points) => {
873                                yield SendOperationState::Refunding;
874
875                                if client_ctx.await_primary_module_outputs(operation_id, out_points.clone()).await.is_ok() {
876                                    yield SendOperationState::Refunded;
877                                    return;
878                                }
879
880                                // The gateway may have incorrectly claimed the outgoing contract thereby causing
881                                // our refund transaction to be rejected. Therefore, we check one last time if
882                                // the preimage is available before we enter the failure state.
883                                if let Some(preimage) = module_api.await_preimage(
884                                    state.common.outpoint,
885                                    0
886                                ).await
887                                    && state.common.contract.verify_preimage(&preimage) {
888                                        yield SendOperationState::Success(preimage);
889                                        return;
890                                    }
891
892                                yield SendOperationState::Failure;
893                                return;
894                            },
895                            SendSMState::Rejected(..) => {
896                                yield SendOperationState::Failure;
897                                return;
898                            },
899                        }
900                    }
901                }
902            }
903        }))
904    }
905
906    /// Await the final state of the send operation.
907    pub async fn await_final_send_operation_state(
908        &self,
909        operation_id: OperationId,
910    ) -> Result<FinalSendOperationState, OperationLookupError> {
911        let mut stream = self
912            .subscribe_send_operation_state_updates(operation_id)
913            .await?
914            .into_stream();
915
916        let mut final_state = None;
917
918        while let Some(state) = stream.next().await {
919            match state {
920                SendOperationState::Success(preimage) => {
921                    final_state = Some(FinalSendOperationState::Success(preimage));
922                }
923                SendOperationState::Refunded => {
924                    final_state = Some(FinalSendOperationState::Refunded);
925                }
926                SendOperationState::Failure => final_state = Some(FinalSendOperationState::Failure),
927                _ => {}
928            }
929        }
930
931        Ok(final_state.expect("Stream contains one final state"))
932    }
933
934    /// Request an invoice. For testing you can optionally specify a gateway to
935    /// generate the invoice, otherwise a random online gateway will be selected
936    /// automatically.
937    ///
938    /// The total fee for this payment may depend on the chosen gateway but
939    /// will be limited to half of one percent plus fifty satoshis. Since the
940    /// selected gateway has been vetted by at least one guardian we trust it to
941    /// set a reasonable fee and only enforce a rather high limit.
942    ///
943    /// The absolute fee for a payment can be calculated from the operation meta
944    /// to be shown to the user in the transaction history.
945    pub async fn receive(
946        &self,
947        amount: Amount,
948        expiry_secs: u32,
949        description: Bolt11InvoiceDescription,
950        gateway: Option<SafeUrl>,
951        custom_meta: Value,
952    ) -> Result<(Bolt11Invoice, OperationId), ReceiveError> {
953        if expiry_secs > MAX_INVOICE_EXPIRY_SECS {
954            return Err(ReceiveError::InvoiceExpiryTooLong);
955        }
956
957        let (gateway, routing_info) = self.resolve_receive_gateway(gateway).await?;
958
959        self.receive_with_routing_info(
960            amount,
961            expiry_secs,
962            description,
963            gateway,
964            routing_info,
965            custom_meta,
966        )
967        .await
968    }
969
970    /// Requests an invoice from `gateway` at the receive fee the caller
971    /// already checked.
972    ///
973    /// [`Self::receive`] reads the gateway's fee itself, so a caller that
974    /// showed the user a fee has no guarantee the incoming contract is
975    /// created at that fee: the gateway may change its schedule between the
976    /// two reads. This variant takes the `receive_fee` the caller obtained
977    /// from [`RoutingInfo::receive_fee`] and refuses with
978    /// [`ReceiveWithTermsError::TermsChanged`], before creating anything, if
979    /// the gateway currently reports a different fee. A cheaper fee counts as
980    /// changed too, so the contract always matches what was approved; the
981    /// error carries the current fee for re-quoting.
982    pub async fn receive_with_terms(
983        &self,
984        amount: Amount,
985        expiry_secs: u32,
986        description: Bolt11InvoiceDescription,
987        gateway: SafeUrl,
988        receive_fee: PaymentFee,
989        custom_meta: Value,
990    ) -> Result<(Bolt11Invoice, OperationId), ReceiveWithTermsError> {
991        if expiry_secs > MAX_INVOICE_EXPIRY_SECS {
992            return Err(ReceiveError::InvoiceExpiryTooLong.into());
993        }
994
995        let (gateway, routing_info) = self.resolve_receive_gateway(Some(gateway)).await?;
996
997        if routing_info.receive_fee != receive_fee {
998            return Err(ReceiveWithTermsError::TermsChanged {
999                receive_fee: routing_info.receive_fee,
1000            });
1001        }
1002
1003        let (invoice, operation_id) = self
1004            .receive_with_routing_info(
1005                amount,
1006                expiry_secs,
1007                description,
1008                gateway,
1009                routing_info,
1010                custom_meta,
1011            )
1012            .await?;
1013
1014        Ok((invoice, operation_id))
1015    }
1016
1017    /// Resolves the gateway to request an invoice from and its current
1018    /// routing info: the given one, or an automatically selected one when
1019    /// `None`.
1020    async fn resolve_receive_gateway(
1021        &self,
1022        gateway: Option<SafeUrl>,
1023    ) -> Result<(SafeUrl, RoutingInfo), ReceiveError> {
1024        match gateway {
1025            Some(gateway) => {
1026                let routing_info = self
1027                    .routing_info(&gateway)
1028                    .await
1029                    .map_err(|e| ReceiveError::FailedToConnectToGateway(e.to_string()))?
1030                    .ok_or(ReceiveError::FederationNotSupported)?;
1031
1032                if !routing_info.receive_enabled {
1033                    return Err(ReceiveError::ReceiveDisabled);
1034                }
1035
1036                Ok((gateway, routing_info))
1037            }
1038            None => self
1039                .select_receive_gateway()
1040                .await
1041                .map_err(ReceiveError::SelectGateway),
1042        }
1043    }
1044
1045    /// Selects the first registered gateway that is online and currently
1046    /// accepts incoming payments for this federation. A gateway that answers
1047    /// but has turned receives off is skipped rather than handed an invoice
1048    /// request it would refuse.
1049    async fn select_receive_gateway(&self) -> Result<(SafeUrl, RoutingInfo), SelectGatewayError> {
1050        let gateways = self
1051            .module_api
1052            .gateways()
1053            .await
1054            .map_err(|e| SelectGatewayError::FailedToRequestGateways(e.to_string()))?;
1055
1056        if gateways.is_empty() {
1057            return Err(SelectGatewayError::NoGatewaysAvailable);
1058        }
1059
1060        let mut any_responded = false;
1061
1062        for gateway in gateways {
1063            if let Ok(Some(routing_info)) = self.routing_info(&gateway).await {
1064                any_responded = true;
1065
1066                if routing_info.receive_enabled {
1067                    return Ok((gateway, routing_info));
1068                }
1069            }
1070        }
1071
1072        if any_responded {
1073            Err(SelectGatewayError::NoGatewayAcceptsReceives)
1074        } else {
1075            Err(SelectGatewayError::GatewaysUnresponsive)
1076        }
1077    }
1078
1079    /// Creates the incoming contract locked to a public key derived from our
1080    /// static module public key, fetches its invoice from `gateway` at the
1081    /// terms in `routing_info`, then starts the receive operation.
1082    async fn receive_with_routing_info(
1083        &self,
1084        amount: Amount,
1085        expiry_secs: u32,
1086        description: Bolt11InvoiceDescription,
1087        gateway: SafeUrl,
1088        routing_info: RoutingInfo,
1089        custom_meta: Value,
1090    ) -> Result<(Bolt11Invoice, OperationId), ReceiveError> {
1091        let recipient_static_pk = self.keypair.public_key();
1092
1093        let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_static_pk);
1094
1095        let encryption_seed = ephemeral_tweak
1096            .consensus_hash::<sha256::Hash>()
1097            .to_byte_array();
1098
1099        let preimage = encryption_seed
1100            .consensus_hash::<sha256::Hash>()
1101            .to_byte_array();
1102
1103        if !routing_info
1104            .receive_fee
1105            .is_within(&PaymentFee::RECEIVE_FEE_LIMIT)
1106        {
1107            return Err(ReceiveError::GatewayFeeExceedsLimit);
1108        }
1109
1110        let contract_amount = routing_info.receive_fee.subtract_from(amount.msats);
1111
1112        // Quoting the claim against this federation's fee consensus is exact, where a
1113        // fixed floor is either too permissive or too strict depending on how the
1114        // federation is configured.
1115        if !self.is_worth_claiming(contract_amount).await {
1116            return Err(ReceiveError::AmountTooSmall);
1117        }
1118
1119        let expiration = duration_since_epoch()
1120            .as_secs()
1121            .saturating_add(u64::from(expiry_secs));
1122
1123        let claim_pk = recipient_static_pk
1124            .mul_tweak(
1125                secp256k1::SECP256K1,
1126                &Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order"),
1127            )
1128            .expect("Tweak is valid");
1129
1130        let contract = IncomingContract::new(
1131            self.cfg.tpe_agg_pk,
1132            encryption_seed,
1133            preimage,
1134            PaymentImage::Hash(preimage.consensus_hash()),
1135            contract_amount,
1136            expiration,
1137            claim_pk,
1138            routing_info.module_public_key,
1139            ephemeral_pk,
1140        );
1141
1142        let invoice = self
1143            .gateway_conn
1144            .bolt11_invoice(
1145                gateway.clone(),
1146                self.federation_id,
1147                contract.clone(),
1148                amount,
1149                description,
1150                expiry_secs,
1151            )
1152            .await
1153            .map_err(|e| ReceiveError::FailedToConnectToGateway(e.to_string()))?;
1154
1155        if invoice.payment_hash() != &preimage.consensus_hash() {
1156            return Err(ReceiveError::InvalidInvoice);
1157        }
1158
1159        if invoice.amount_milli_satoshis() != Some(amount.msats) {
1160            return Err(ReceiveError::IncorrectInvoiceAmount);
1161        }
1162
1163        let operation_id = self
1164            .receive_incoming_contract(
1165                self.keypair.secret_key(),
1166                contract.clone(),
1167                LightningOperationMeta::Receive(ReceiveOperationMeta {
1168                    gateway,
1169                    contract,
1170                    invoice: LightningInvoice::Bolt11(invoice.clone()),
1171                    custom_meta,
1172                }),
1173            )
1174            .await
1175            .expect("The contract has been generated with our public key");
1176
1177        Ok((invoice, operation_id))
1178    }
1179
1180    /// Computes the federation fee a `receive` of `amount` would incur, without
1181    /// submitting anything.
1182    ///
1183    /// When the incoming contract is claimed, the client submits a transaction
1184    /// with a single Lightning input worth the contract amount; the primary
1185    /// module balances it by minting the change credited to the wallet. This
1186    /// quotes the fee of that transaction — the Lightning input fee, the mint
1187    /// output fees, and any sub-denomination dust — via the shared,
1188    /// module-agnostic fee quote.
1189    ///
1190    /// The gateway's off-chain Lightning fee is deliberately excluded: this is
1191    /// only the fee of the on-federation transaction. For that reason the quote
1192    /// is taken on `amount` directly (rather than the gateway-reduced contract
1193    /// amount), and no gateway round-trip is needed.
1194    pub async fn receive_fee_quote(
1195        &self,
1196        amount: Amount,
1197    ) -> Result<FeeQuote, TransactionSubmitError> {
1198        self.client_ctx
1199            .fee_quote(
1200                OperationId::new_random(),
1201                FeeQuoteRequest {
1202                    input_amount: Amounts::new_bitcoin(amount),
1203                    output_amount: Amounts::ZERO,
1204                    input_fee: Amounts::new_bitcoin(self.cfg.fee_consensus.fee(amount)),
1205                    output_fee: Amounts::ZERO,
1206                },
1207            )
1208            .await
1209    }
1210
1211    /// Whether an incoming contract worth `amount` is worth claiming, i.e.
1212    /// whether claiming it would leave the wallet better off than leaving it.
1213    ///
1214    /// This is the computed counterpart of a fixed dust limit: it prices the
1215    /// actual claim - the lightning input fee at this federation's fee
1216    /// consensus, the change the primary module has to mint, and the
1217    /// sub-denomination remainder that cannot be minted at all - rather than
1218    /// assuming a floor. A quote that fails outright is the same verdict: the
1219    /// claim spends the contract as the transaction's only input, so a fee
1220    /// larger than the contract leaves a transaction that cannot be balanced.
1221    async fn is_worth_claiming(&self, amount: Amount) -> bool {
1222        match self.receive_fee_quote(amount).await {
1223            Ok(quote) => quote.total().get_bitcoin() < amount,
1224            Err(_) => false,
1225        }
1226    }
1227
1228    /// Computes the federation fee a `send` funding an outgoing contract worth
1229    /// `amount` would incur, without submitting anything.
1230    ///
1231    /// When a payment is sent, the client submits a transaction with a single
1232    /// Lightning output (the outgoing contract) worth `amount`; the primary
1233    /// module balances it by spending ecash to fund the contract and minting
1234    /// any change. This quotes the fee of that transaction — the Lightning
1235    /// output fee, the mint input fees on the funding notes, any mint change
1236    /// output fees, and sub-denomination dust — via the shared, module-agnostic
1237    /// fee quote.
1238    ///
1239    /// The gateway's off-chain Lightning fee is deliberately excluded: it is
1240    /// part of the contract `amount` the gateway claims, not the on-federation
1241    /// transaction fee. So `amount` is the full outgoing contract value
1242    /// (`send_fee.add_to(invoice_amount)`).
1243    pub async fn send_fee_quote(&self, amount: Amount) -> Result<FeeQuote, TransactionSubmitError> {
1244        self.client_ctx
1245            .fee_quote(
1246                OperationId::new_random(),
1247                FeeQuoteRequest {
1248                    input_amount: Amounts::ZERO,
1249                    output_amount: Amounts::new_bitcoin(amount),
1250                    input_fee: Amounts::ZERO,
1251                    output_fee: Amounts::new_bitcoin(self.cfg.fee_consensus.fee(amount)),
1252                },
1253            )
1254            .await
1255    }
1256
1257    /// Computes the largest invoice amount the client can pay in full out of
1258    /// `balance`, i.e. the amount to request an invoice for in order to spend
1259    /// (close to) the entire balance.
1260    ///
1261    /// Paying an invoice deducts two kinds of fee from the balance:
1262    /// - the *gateway* fee, which is added on top of the invoice amount to form
1263    ///   the outgoing contract (`send_fee.add_to(invoice_amount)`), and
1264    /// - the *federation* fee of funding that contract — the Lightning output
1265    ///   fee, the mint input fees on the funding notes, the mint output fees on
1266    ///   any change, and sub-denomination dust — as quoted by
1267    ///   [`Self::send_fee_quote`].
1268    ///
1269    /// `balance` is the client's current Bitcoin balance (e.g. from
1270    /// `Client::get_balance_for_btc`). `gateway` optionally pins the gateway
1271    /// whose fee schedule to use; if `None` one is selected automatically, the
1272    /// same way [`Self::send`] does when no gateway is given. The gateway's
1273    /// *default* send fee is used — the higher of its two send fees, applied to
1274    /// a Lightning swap rather than a direct fedimint-to-fedimint swap — so the
1275    /// returned amount stays payable even when the eventual invoice is routed
1276    /// over Lightning.
1277    ///
1278    /// The maximum payable amount is found by binary search over the real fee
1279    /// quote (see [`max_affordable_send_amount`]) rather than a closed form,
1280    /// because the federation fee is stepwise in the amount. The quote is
1281    /// point-in-time and moves with the balance, exactly like
1282    /// [`Self::send_fee_quote`]; the eventual [`Self::send`] remains the source
1283    /// of truth and may still fail if balance or gateway state changes in
1284    /// between.
1285    ///
1286    /// Returns [`SpendableAmountError::BalanceTooLow`] if the balance cannot
1287    /// cover even the smallest payable amount plus fees, and
1288    /// [`SpendableAmountError::Quote`] if the fee probe itself failed; only
1289    /// the first of the two says the wallet is short. Any LNURL
1290    /// `minSendable`/`maxSendable` bounds are the caller's responsibility to
1291    /// apply. The remaining variants of [`SpendableAmountError`] are
1292    /// gateway-side, and none of them means the wallet is short.
1293    pub async fn spendable_amount(
1294        &self,
1295        balance: Amount,
1296        gateway: Option<SafeUrl>,
1297    ) -> Result<Amount, SpendableAmountError> {
1298        let routing_info = match gateway {
1299            Some(gateway) => self
1300                .routing_info(&gateway)
1301                .await?
1302                .ok_or(SpendableAmountError::FederationNotSupported)?,
1303            None => self.select_gateway(None).await?.1,
1304        };
1305
1306        // The default (Lightning-swap) send fee is the higher of the gateway's
1307        // two send fees, so using it keeps the result payable even if the
1308        // eventual invoice is routed over Lightning instead of settled by a
1309        // direct swap.
1310        let send_fee = routing_info.send_fee_default;
1311
1312        if !send_fee.is_within(&PaymentFee::SEND_FEE_LIMIT) {
1313            return Err(SpendableAmountError::SendFeeExceedsLimit {
1314                fee: send_fee,
1315                limit: PaymentFee::SEND_FEE_LIMIT,
1316            });
1317        }
1318
1319        max_affordable_send_amount(
1320            balance,
1321            Amount::from_msats(1),
1322            balance,
1323            |invoice_amount: Amount| send_fee.add_to(invoice_amount.msats),
1324            |contract_amount: Amount| self.send_fee_quote(contract_amount),
1325        )
1326        .await
1327        .map_err(SpendableAmountError::Quote)?
1328        .ok_or(SpendableAmountError::BalanceTooLow { balance })
1329    }
1330
1331    // Receive an incoming contract locked to a public key derived from our
1332    // static module public key.
1333    async fn receive_incoming_contract(
1334        &self,
1335        sk: SecretKey,
1336        contract: IncomingContract,
1337        operation_meta: LightningOperationMeta,
1338    ) -> Option<OperationId> {
1339        let operation_id = OperationId::from_encodable(&contract.clone());
1340
1341        let (claim_keypair, agg_decryption_key) = self.recover_contract_keys(sk, &contract)?;
1342
1343        let receive_sm = LightningClientStateMachines::Receive(ReceiveStateMachine {
1344            common: ReceiveSMCommon {
1345                operation_id,
1346                contract: contract.clone(),
1347                claim_keypair,
1348                agg_decryption_key,
1349            },
1350            state: ReceiveSMState::Pending,
1351        });
1352
1353        // this may only fail if the operation id is already in use, in which case we
1354        // ignore the error such that the method is idempotent
1355        self.client_ctx
1356            .manual_operation_start(
1357                operation_id,
1358                LightningCommonInit::KIND.as_str(),
1359                operation_meta,
1360                vec![self.client_ctx.make_dyn_state(receive_sm)],
1361            )
1362            .await
1363            .ok();
1364
1365        Some(operation_id)
1366    }
1367
1368    fn recover_contract_keys(
1369        &self,
1370        sk: SecretKey,
1371        contract: &IncomingContract,
1372    ) -> Option<(Keypair, AggregateDecryptionKey)> {
1373        let tweak = ecdh::SharedSecret::new(&contract.commitment.ephemeral_pk, &sk);
1374
1375        let encryption_seed = tweak
1376            .secret_bytes()
1377            .consensus_hash::<sha256::Hash>()
1378            .to_byte_array();
1379
1380        let claim_keypair = sk
1381            .mul_tweak(&Scalar::from_be_bytes(tweak.secret_bytes()).expect("Within curve order"))
1382            .expect("Tweak is valid")
1383            .keypair(secp256k1::SECP256K1);
1384
1385        if claim_keypair.public_key() != contract.commitment.claim_pk {
1386            return None; // The claim key is not derived from our pk
1387        }
1388
1389        let agg_decryption_key = derive_agg_dk(&self.cfg.tpe_agg_pk, &encryption_seed);
1390
1391        if !contract.verify_agg_decryption_key(&self.cfg.tpe_agg_pk, &agg_decryption_key) {
1392            return None; // The decryption key is not derived from our pk
1393        }
1394
1395        contract.decrypt_preimage(&agg_decryption_key)?;
1396
1397        Some((claim_keypair, agg_decryption_key))
1398    }
1399
1400    /// Subscribe to all state updates of the receive operation.
1401    pub async fn subscribe_receive_operation_state_updates(
1402        &self,
1403        operation_id: OperationId,
1404    ) -> Result<UpdateStreamOrOutcome<ReceiveOperationState>, OperationLookupError> {
1405        let operation = self.client_ctx.get_operation(operation_id).await?;
1406        let mut stream = self.notifier.subscribe(operation_id).await;
1407        let client_ctx = self.client_ctx.clone();
1408
1409        Ok(self.client_ctx.outcome_or_updates(&operation, operation_id, |state| match state {
1410                ReceiveOperationState::Pending | ReceiveOperationState::Claiming => false,
1411                ReceiveOperationState::Expired
1412                | ReceiveOperationState::Claimed
1413                | ReceiveOperationState::Failure
1414                | ReceiveOperationState::Uneconomical => true,
1415            }, move || {
1416            stream! {
1417                loop {
1418                    if let Some(LightningClientStateMachines::Receive(state)) = stream.next().await {
1419                        match state.state {
1420                            ReceiveSMState::Pending => yield ReceiveOperationState::Pending,
1421                            ReceiveSMState::Claiming(out_points) => {
1422                                yield ReceiveOperationState::Claiming;
1423
1424                                if client_ctx.await_primary_module_outputs(operation_id, out_points).await.is_ok() {
1425                                    yield ReceiveOperationState::Claimed;
1426                                } else {
1427                                    yield ReceiveOperationState::Failure;
1428                                }
1429                                return;
1430                            },
1431                            ReceiveSMState::Expired => {
1432                                yield ReceiveOperationState::Expired;
1433                                return;
1434                            }
1435                            ReceiveSMState::Uneconomical => {
1436                                yield ReceiveOperationState::Uneconomical;
1437                                return;
1438                            }
1439                        }
1440                    }
1441                }
1442            }
1443        }))
1444    }
1445
1446    /// Await the final state of the receive operation.
1447    pub async fn await_final_receive_operation_state(
1448        &self,
1449        operation_id: OperationId,
1450    ) -> Result<FinalReceiveOperationState, OperationLookupError> {
1451        let mut stream = self
1452            .subscribe_receive_operation_state_updates(operation_id)
1453            .await?
1454            .into_stream();
1455
1456        let mut final_state = None;
1457
1458        while let Some(state) = stream.next().await {
1459            match state {
1460                ReceiveOperationState::Expired => {
1461                    final_state = Some(FinalReceiveOperationState::Expired);
1462                }
1463                ReceiveOperationState::Claimed => {
1464                    final_state = Some(FinalReceiveOperationState::Claimed);
1465                }
1466                ReceiveOperationState::Failure => {
1467                    final_state = Some(FinalReceiveOperationState::Failure);
1468                }
1469                ReceiveOperationState::Uneconomical => {
1470                    final_state = Some(FinalReceiveOperationState::Uneconomical);
1471                }
1472                _ => {}
1473            }
1474        }
1475
1476        Ok(final_state.expect("Stream contains one final state"))
1477    }
1478
1479    /// Generate an lnurl for the client. You can optionally specify a gateway
1480    /// to use for testing purposes.
1481    pub async fn generate_lnurl(
1482        &self,
1483        recurringd: SafeUrl,
1484        gateway: Option<SafeUrl>,
1485    ) -> Result<String, GenerateLnurlError> {
1486        let gateways = if let Some(gateway) = gateway {
1487            vec![gateway]
1488        } else {
1489            let gateways = self
1490                .module_api
1491                .gateways()
1492                .await
1493                .map_err(|e| GenerateLnurlError::FailedToRequestGateways(e.to_string()))?;
1494
1495            if gateways.is_empty() {
1496                return Err(GenerateLnurlError::NoGatewaysAvailable);
1497            }
1498
1499            gateways
1500        };
1501
1502        let payload = fedimint_core::base32::encode_prefixed(
1503            fedimint_core::base32::FEDIMINT_PREFIX,
1504            &lnurl::LnurlRequest {
1505                federation_id: self.federation_id,
1506                recipient_pk: self.lnurl_keypair.public_key(),
1507                aggregate_pk: self.cfg.tpe_agg_pk,
1508                gateways,
1509            },
1510        );
1511
1512        Ok(fedimint_lnurl::encode_lnurl(&format!(
1513            "{recurringd}pay/{payload}"
1514        )))
1515    }
1516
1517    fn spawn_receive_lnurl_task(
1518        &self,
1519        custom_meta_fn: Arc<dyn Fn() -> Value + Send + Sync>,
1520        task_group: &TaskGroup,
1521        client_span: &tracing::Span,
1522    ) {
1523        let module = self.clone();
1524        let api = self.module_api.clone();
1525
1526        task_group.spawn_cancellable_with_span(
1527            client_span.clone(),
1528            "receive_lnurl_task",
1529            async move {
1530                api.wait_for_initialized_connections().await;
1531                loop {
1532                    module.receive_lnurl(custom_meta_fn()).await;
1533                }
1534            },
1535        );
1536    }
1537
1538    async fn receive_lnurl(&self, custom_meta: Value) {
1539        // Read the stream cursor with a short-lived transaction. It must NOT stay open
1540        // across the long-poll below: RocksDB's optimistic transactions validate a
1541        // commit against bounded memtable history, so a transaction held open
1542        // for minutes fails with `DatabaseError::SnapshotTooOld` once enough
1543        // concurrent writes flush that history — not because anything conflicted,
1544        // but because the backend can no longer tell — and the panicking
1545        // `commit_tx()` then killed this task permanently, silently
1546        // stalling every future receive for the lifetime of the process. Long-lived
1547        // clients (daemons) hit this reproducibly under concurrent lnv2 activity.
1548        // See <https://github.com/fedimint/fedimint/issues/8872>.
1549        let stream_index = self
1550            .client_ctx
1551            .module_db()
1552            .begin_transaction_nc()
1553            .await
1554            .get_value(&IncomingContractStreamIndexKey)
1555            .await
1556            .unwrap_or(0);
1557
1558        let (contracts, next_index) = self
1559            .module_api
1560            .await_incoming_contracts(stream_index, 128)
1561            .await;
1562
1563        for contract in &contracts {
1564            // The stream carries every incoming contract the federation funded, and
1565            // anyone can address one to a published lnurl key. Check that it is ours
1566            // before quoting - recovering the keys is local arithmetic, the quote
1567            // reads the wallet - and skip the contracts the claim fee would swallow,
1568            // so that unsolicited dust never becomes an operation in the first place.
1569            if self
1570                .recover_contract_keys(self.lnurl_keypair.secret_key(), contract)
1571                .is_none()
1572            {
1573                continue;
1574            }
1575
1576            if !self.is_worth_claiming(contract.commitment.amount).await {
1577                warn!(
1578                    target: LOG_CLIENT_MODULE_LNV2,
1579                    amount = %contract.commitment.amount,
1580                    "Ignoring incoming contract, its amount does not cover the claim fee"
1581                );
1582
1583                continue;
1584            }
1585
1586            if let Some(operation_id) = self
1587                .receive_incoming_contract(
1588                    self.lnurl_keypair.secret_key(),
1589                    contract.clone(),
1590                    LightningOperationMeta::LnurlReceive(LnurlReceiveOperationMeta {
1591                        contract: contract.clone(),
1592                        custom_meta: custom_meta.clone(),
1593                    }),
1594                )
1595                .await
1596            {
1597                self.await_final_receive_operation_state(operation_id)
1598                    .await
1599                    .ok();
1600            }
1601        }
1602
1603        // Advance the cursor in its own short transaction. This is the only writer of
1604        // this key and it runs in a single sequential loop, so there is no concurrent
1605        // writer to guard against; and because this transaction is short-lived — opened
1606        // after the long-poll and committed immediately — it cannot hit the
1607        // `SnapshotTooOld` failure that a transaction held open across the long-poll
1608        // would, so a plain `commit_tx()` is safe. Ordering is unchanged: the cursor
1609        // only moves after the batch above was processed, and a crash in between
1610        // re-fetches the same batch on the next iteration.
1611        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1612
1613        dbtx.insert_entry(&IncomingContractStreamIndexKey, &next_index)
1614            .await;
1615
1616        dbtx.commit_tx().await;
1617    }
1618}
1619
1620#[derive(Error, Debug, Clone, Eq, PartialEq)]
1621pub enum SelectGatewayError {
1622    #[error("Failed to request gateways")]
1623    FailedToRequestGateways(String),
1624    #[error("No gateways are available")]
1625    NoGatewaysAvailable,
1626    #[error("All gateways failed to respond")]
1627    GatewaysUnresponsive,
1628    #[error("No online gateway currently accepts incoming payments for this federation")]
1629    NoGatewayAcceptsReceives,
1630}
1631
1632/// The status of the latest send attempt for an invoice, derived from the
1633/// operation log.
1634#[derive(Debug, Clone, Eq, PartialEq)]
1635pub enum InvoiceSendStatus {
1636    /// No payment operation exists for this invoice.
1637    NotAttempted,
1638    /// A payment operation is still being driven to completion.
1639    InFlight(OperationId),
1640    /// The payment succeeded; paying the invoice again would be a double
1641    /// payment.
1642    Succeeded(OperationId),
1643    /// The payment failed and any funds were refunded.
1644    Failed(OperationId),
1645}
1646
1647#[derive(Error, Debug, Clone, Eq, PartialEq)]
1648pub enum SendPaymentError {
1649    #[error("Invoice is missing an amount")]
1650    InvoiceMissingAmount,
1651    #[error("Invoice has expired")]
1652    InvoiceExpired,
1653    #[error("Payment attempt is duplicate")]
1654    DuplicatePaymentAttempt(OperationId),
1655    #[error(transparent)]
1656    SelectGateway(SelectGatewayError),
1657    #[error("Failed to connect to gateway")]
1658    FailedToConnectToGateway(String),
1659    #[error("Gateway does not support this federation")]
1660    FederationNotSupported,
1661    #[error("Gateway fee exceeds the allowed limit")]
1662    GatewayFeeExceedsLimit,
1663    #[error("Gateway expiration time exceeds the allowed limit")]
1664    GatewayExpirationExceedsLimit,
1665    #[error("Failed to request block count")]
1666    FailedToRequestBlockCount(String),
1667    #[error("Failed to fund the payment")]
1668    FailedToFundPayment(String),
1669    #[error("Invoice is for a different currency")]
1670    WrongCurrency {
1671        invoice_currency: Currency,
1672        federation_currency: Currency,
1673    },
1674}
1675
1676/// A failure of [`LightningClientModule::send_with_terms`].
1677#[derive(Error, Debug, Clone, Eq, PartialEq)]
1678#[non_exhaustive]
1679pub enum SendWithTermsError {
1680    /// The gateway's current send terms differ from the ones the caller
1681    /// checked. Carries the current terms so the caller can re-quote.
1682    #[error("Gateway's send terms changed since they were checked")]
1683    TermsChanged {
1684        send_fee: PaymentFee,
1685        expiration_delta: u64,
1686    },
1687    /// Any failure [`LightningClientModule::send`] can report.
1688    #[error(transparent)]
1689    Send(#[from] SendPaymentError),
1690}
1691
1692#[derive(Error, Debug, Clone, Eq, PartialEq)]
1693pub enum ReceiveError {
1694    #[error(transparent)]
1695    SelectGateway(SelectGatewayError),
1696    #[error("Failed to connect to gateway")]
1697    FailedToConnectToGateway(String),
1698    #[error("Gateway does not support this federation")]
1699    FederationNotSupported,
1700    #[error("Gateway does not currently accept incoming payments for this federation")]
1701    ReceiveDisabled,
1702    #[error("Gateway fee exceeds the allowed limit")]
1703    GatewayFeeExceedsLimit,
1704    #[error("Amount is too small to cover fees")]
1705    AmountTooSmall,
1706    #[error("Gateway returned an invalid invoice")]
1707    InvalidInvoice,
1708    #[error("Gateway returned an invoice with incorrect amount")]
1709    IncorrectInvoiceAmount,
1710    #[error("Requested invoice expiry exceeds the maximum of one day")]
1711    InvoiceExpiryTooLong,
1712}
1713
1714/// A failure of [`LightningClientModule::receive_with_terms`].
1715#[derive(Error, Debug, Clone, Eq, PartialEq)]
1716#[non_exhaustive]
1717pub enum ReceiveWithTermsError {
1718    /// The gateway's current receive fee differs from the one the caller
1719    /// checked. Carries the current fee so the caller can re-quote.
1720    #[error("Gateway's receive fee changed since it was checked")]
1721    TermsChanged { receive_fee: PaymentFee },
1722    /// Any failure [`LightningClientModule::receive`] can report.
1723    #[error(transparent)]
1724    Receive(#[from] ReceiveError),
1725}
1726
1727#[derive(Error, Debug, Clone, Eq, PartialEq)]
1728pub enum GenerateLnurlError {
1729    #[error("No gateways are available")]
1730    NoGatewaysAvailable,
1731    #[error("Failed to request gateways")]
1732    FailedToRequestGateways(String),
1733}
1734
1735#[derive(Error, Debug, Clone, Eq, PartialEq)]
1736pub enum ListGatewaysError {
1737    #[error("Failed to request gateways")]
1738    FailedToListGateways,
1739}
1740
1741#[derive(Error, Debug, Clone, Eq, PartialEq)]
1742pub enum RoutingInfoError {
1743    #[error("Failed to request routing info")]
1744    FailedToRequestRoutingInfo,
1745}
1746
1747/// A failure to work out the largest invoice amount the client could pay in
1748/// full.
1749///
1750/// The answer depends on a gateway's fee schedule as well as on the balance,
1751/// so it can be missing either because no schedule could be obtained or
1752/// because the balance does not reach the smallest payable amount once both
1753/// fees are applied.
1754#[derive(Debug, Error)]
1755#[non_exhaustive]
1756pub enum SpendableAmountError {
1757    /// The caller named a gateway and its routing information could not be
1758    /// fetched, so there is no fee schedule to compute against.
1759    #[error(transparent)]
1760    RoutingInfo(#[from] RoutingInfoError),
1761
1762    /// The caller named a gateway and it answered, but it does not serve this
1763    /// federation.
1764    #[error("The gateway does not support this federation")]
1765    FederationNotSupported,
1766
1767    /// The caller named no gateway and none could be picked automatically, so
1768    /// there is no fee schedule to compute against.
1769    #[error(transparent)]
1770    SelectGateway(#[from] SelectGatewayError),
1771
1772    /// The gateway's default send fee is above the limit this module accepts,
1773    /// so an amount computed against it would not be payable through it.
1774    #[error("The gateway's default send fee {fee} exceeds the limit {limit}")]
1775    SendFeeExceedsLimit {
1776        /// The default send fee the gateway announced.
1777        fee: PaymentFee,
1778        /// The largest default send fee this module accepts.
1779        limit: PaymentFee,
1780    },
1781
1782    /// The fee probe failed for a reason unrelated to the balance.
1783    #[error("The fee quote for the payment failed")]
1784    Quote(#[source] TransactionSubmitError),
1785
1786    /// The balance cannot cover the smallest payable amount plus the gateway
1787    /// and federation fees, so there is no amount to send.
1788    #[error("The balance {balance} is too low to send any amount after fees")]
1789    BalanceTooLow {
1790        /// The balance the answer was computed against.
1791        balance: Amount,
1792    },
1793}
1794
1795#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
1796pub enum LightningClientStateMachines {
1797    Send(SendStateMachine),
1798    Receive(ReceiveStateMachine),
1799}
1800
1801impl IntoDynInstance for LightningClientStateMachines {
1802    type DynType = DynState;
1803
1804    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
1805        DynState::from_typed(instance_id, self)
1806    }
1807}
1808
1809impl State for LightningClientStateMachines {
1810    type ModuleContext = LightningClientContext;
1811
1812    fn transitions(
1813        &self,
1814        context: &Self::ModuleContext,
1815        global_context: &DynGlobalClientContext,
1816    ) -> Vec<StateTransition<Self>> {
1817        match self {
1818            LightningClientStateMachines::Send(state) => {
1819                sm_enum_variant_translation!(
1820                    state.transitions(context, global_context),
1821                    LightningClientStateMachines::Send
1822                )
1823            }
1824            LightningClientStateMachines::Receive(state) => {
1825                sm_enum_variant_translation!(
1826                    state.transitions(context, global_context),
1827                    LightningClientStateMachines::Receive
1828                )
1829            }
1830        }
1831    }
1832
1833    fn operation_id(&self) -> OperationId {
1834        match self {
1835            LightningClientStateMachines::Send(state) => state.operation_id(),
1836            LightningClientStateMachines::Receive(state) => state.operation_id(),
1837        }
1838    }
1839}