Skip to main content

fedimint_ln_client/
receive.rs

1use std::time::Duration;
2
3use fedimint_api_client::api::DynModuleApi;
4use fedimint_client_module::DynGlobalClientContext;
5use fedimint_client_module::module::OutPointRange;
6use fedimint_client_module::sm::{ClientSMDatabaseTransaction, DynState, State, StateTransition};
7use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
8use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, OperationId};
9use fedimint_core::encoding::{Decodable, Encodable};
10use fedimint_core::module::Amounts;
11use fedimint_core::secp256k1::Keypair;
12use fedimint_core::task::sleep;
13use fedimint_core::util::FmtCompact as _;
14use fedimint_core::{OutPoint, TransactionId};
15use fedimint_ln_common::LightningInput;
16use fedimint_ln_common::contracts::incoming::IncomingContractAccount;
17use fedimint_ln_common::contracts::{DecryptedPreimage, FundedContract};
18use fedimint_ln_common::federation_endpoint_constants::ACCOUNT_ENDPOINT;
19use fedimint_logging::LOG_CLIENT_MODULE_LN;
20use lightning_invoice::Bolt11Invoice;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use tracing::{debug, info};
24
25use crate::api::LnFederationApi;
26use crate::{LightningClientContext, ReceivingKey};
27
28const RETRY_DELAY: Duration = Duration::from_secs(1);
29
30#[cfg_attr(doc, aquamarine::aquamarine)]
31/// State machine that waits on the receipt of a Lightning payment.
32///
33/// ```mermaid
34/// graph LR
35/// classDef virtual fill:#fff,stroke-dasharray: 5 5
36///
37///     SubmittedOffer -- await transaction rejection --> Canceled
38///     SubmittedOffer -- await invoice confirmation --> ConfirmedInvoice
39///     ConfirmedInvoice -- await contract creation + decryption  --> Funded
40///     ConfirmedInvoice -- await offer timeout --> Canceled
41///     Funded -- await claim tx acceptance --> Success
42///     Funded -- await claim tx rejection --> Canceled
43/// ```
44#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
45pub enum LightningReceiveStates {
46    SubmittedOffer(LightningReceiveSubmittedOffer),
47    Canceled(LightningReceiveError),
48    ConfirmedInvoice(LightningReceiveConfirmedInvoice),
49    Funded(LightningReceiveFunded),
50    Success(Vec<OutPoint>),
51}
52
53#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
54pub struct LightningReceiveStateMachine {
55    pub operation_id: OperationId,
56    pub state: LightningReceiveStates,
57}
58
59impl State for LightningReceiveStateMachine {
60    type ModuleContext = LightningClientContext;
61
62    fn transitions(
63        &self,
64        context: &Self::ModuleContext,
65        global_context: &DynGlobalClientContext,
66    ) -> Vec<StateTransition<Self>> {
67        match &self.state {
68            LightningReceiveStates::SubmittedOffer(submitted_offer) => {
69                submitted_offer.transitions(global_context)
70            }
71            LightningReceiveStates::ConfirmedInvoice(confirmed_invoice) => {
72                confirmed_invoice.transitions(context, global_context)
73            }
74            LightningReceiveStates::Funded(funded) => funded.transitions(global_context),
75            LightningReceiveStates::Success(_) | LightningReceiveStates::Canceled(_) => {
76                vec![]
77            }
78        }
79    }
80
81    fn operation_id(&self) -> fedimint_core::core::OperationId {
82        self.operation_id
83    }
84}
85
86impl IntoDynInstance for LightningReceiveStateMachine {
87    type DynType = DynState;
88
89    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
90        DynState::from_typed(instance_id, self)
91    }
92}
93
94/// Old version of `LightningReceiveSubmittedOffer`, used for migrations
95#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
96pub struct LightningReceiveSubmittedOfferV0 {
97    pub offer_txid: TransactionId,
98    pub invoice: Bolt11Invoice,
99    pub payment_keypair: Keypair,
100}
101
102#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
103pub struct LightningReceiveSubmittedOffer {
104    pub offer_txid: TransactionId,
105    pub invoice: Bolt11Invoice,
106    pub receiving_key: ReceivingKey,
107}
108
109#[derive(
110    Error, Clone, Debug, Serialize, Deserialize, Encodable, Decodable, Eq, PartialEq, Hash,
111)]
112#[serde(rename_all = "snake_case")]
113pub enum LightningReceiveError {
114    #[error("Offer transaction was rejected")]
115    Rejected,
116    #[error("Incoming Lightning invoice was not paid within the timeout")]
117    Timeout,
118    #[error("Claim transaction was rejected")]
119    ClaimRejected,
120    #[error("The decrypted preimage was invalid")]
121    InvalidPreimage,
122}
123
124impl LightningReceiveSubmittedOffer {
125    fn transitions(
126        &self,
127        global_context: &DynGlobalClientContext,
128    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
129        let global_context = global_context.clone();
130        let txid = self.offer_txid;
131        let invoice = self.invoice.clone();
132        let receiving_key = self.receiving_key;
133        vec![StateTransition::new(
134            Self::await_invoice_confirmation(global_context, txid),
135            move |_dbtx, result, old_state| {
136                let invoice = invoice.clone();
137                Box::pin(async move {
138                    Self::transition_confirmed_invoice(&result, &old_state, invoice, receiving_key)
139                })
140            },
141        )]
142    }
143
144    async fn await_invoice_confirmation(
145        global_context: DynGlobalClientContext,
146        txid: TransactionId,
147    ) -> Result<(), String> {
148        // No network calls are done here, we just await other state machines, so no
149        // retry logic is needed
150        global_context.await_tx_accepted(txid).await
151    }
152
153    fn transition_confirmed_invoice(
154        result: &Result<(), String>,
155        old_state: &LightningReceiveStateMachine,
156        invoice: Bolt11Invoice,
157        receiving_key: ReceivingKey,
158    ) -> LightningReceiveStateMachine {
159        match result {
160            Ok(()) => LightningReceiveStateMachine {
161                operation_id: old_state.operation_id,
162                state: LightningReceiveStates::ConfirmedInvoice(LightningReceiveConfirmedInvoice {
163                    invoice,
164                    receiving_key,
165                }),
166            },
167            Err(_) => LightningReceiveStateMachine {
168                operation_id: old_state.operation_id,
169                state: LightningReceiveStates::Canceled(LightningReceiveError::Rejected),
170            },
171        }
172    }
173}
174
175#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
176pub struct LightningReceiveConfirmedInvoice {
177    pub(crate) invoice: Bolt11Invoice,
178    pub(crate) receiving_key: ReceivingKey,
179}
180
181impl LightningReceiveConfirmedInvoice {
182    fn transitions(
183        &self,
184        context: &LightningClientContext,
185        global_context: &DynGlobalClientContext,
186    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
187        let invoice = self.invoice.clone();
188        let receiving_key = self.receiving_key;
189        let global_context = global_context.clone();
190        let context = context.clone();
191        vec![StateTransition::new(
192            Self::await_incoming_contract_account(invoice, global_context.clone()),
193            move |dbtx, contract, old_state| {
194                let context = context.clone();
195                Box::pin(Self::transition_funded(
196                    old_state,
197                    receiving_key,
198                    contract,
199                    dbtx,
200                    global_context.clone(),
201                    context,
202                ))
203            },
204        )]
205    }
206
207    async fn await_incoming_contract_account(
208        invoice: Bolt11Invoice,
209        global_context: DynGlobalClientContext,
210    ) -> Result<IncomingContractAccount, LightningReceiveError> {
211        let contract_id = (*invoice.payment_hash()).into();
212        loop {
213            // Consider time before the api call to account for network delays
214            let now_epoch = fedimint_core::time::duration_since_epoch();
215            match get_incoming_contract(global_context.module_api(), contract_id).await {
216                Ok(Some(incoming_contract_account)) => {
217                    match incoming_contract_account.contract.decrypted_preimage {
218                        DecryptedPreimage::Pending => {
219                            // Previously we would time out here but we may miss a payment if we do
220                            // so
221                            info!("Waiting for preimage decryption for contract {contract_id}");
222                        }
223                        DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
224                        DecryptedPreimage::Invalid => {
225                            return Err(LightningReceiveError::InvalidPreimage);
226                        }
227                    }
228                }
229                Ok(None) => {
230                    // only when we are sure that the invoice is still pending that we can
231                    // check for a timeout
232                    const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_mins(1);
233                    if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
234                        return Err(LightningReceiveError::Timeout);
235                    }
236                    debug!("Still waiting preimage decryption for contract {contract_id}");
237                }
238                Err(error) => {
239                    error.report_if_unusual("Awaiting incoming contract");
240                    debug!(
241                        target: LOG_CLIENT_MODULE_LN,
242                        err = %error.fmt_compact(),
243                        "External LN payment retryable error waiting for preimage decryption"
244                    );
245                }
246            }
247            sleep(RETRY_DELAY).await;
248        }
249    }
250
251    async fn transition_funded(
252        old_state: LightningReceiveStateMachine,
253        receiving_key: ReceivingKey,
254        result: Result<IncomingContractAccount, LightningReceiveError>,
255        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
256        global_context: DynGlobalClientContext,
257        context: LightningClientContext,
258    ) -> LightningReceiveStateMachine {
259        match result {
260            Ok(contract) => {
261                // None for the gateway since it does not emit the client events
262                if let Some(ref client_ctx) = context.client_ctx {
263                    client_ctx
264                        .log_event(
265                            &mut dbtx.module_tx(),
266                            crate::events::ReceivePaymentEvent {
267                                operation_id: old_state.operation_id,
268                                amount: contract.amount,
269                            },
270                        )
271                        .await;
272                }
273
274                match receiving_key {
275                    ReceivingKey::Personal(keypair) => {
276                        let change_range =
277                            Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
278                                .await;
279                        LightningReceiveStateMachine {
280                            operation_id: old_state.operation_id,
281                            state: LightningReceiveStates::Funded(LightningReceiveFunded {
282                                txid: change_range.txid(),
283                                out_points: change_range.into_iter().collect(),
284                            }),
285                        }
286                    }
287                    ReceivingKey::External(_) => {
288                        // Claim successful
289                        LightningReceiveStateMachine {
290                            operation_id: old_state.operation_id,
291                            state: LightningReceiveStates::Success(vec![]),
292                        }
293                    }
294                }
295            }
296            Err(e) => LightningReceiveStateMachine {
297                operation_id: old_state.operation_id,
298                state: LightningReceiveStates::Canceled(e),
299            },
300        }
301    }
302
303    async fn claim_incoming_contract(
304        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
305        contract: IncomingContractAccount,
306        keypair: Keypair,
307        global_context: DynGlobalClientContext,
308    ) -> OutPointRange {
309        let input = contract.claim();
310        let client_input = ClientInput::<LightningInput> {
311            input,
312            amounts: Amounts::new_bitcoin(contract.amount),
313            keys: vec![keypair],
314        };
315
316        global_context
317            .claim_inputs(
318                dbtx,
319                // The input of the refund tx is managed by this state machine, so no new state
320                // machines need to be created
321                ClientInputBundle::new_no_sm(vec![client_input]),
322            )
323            .await
324            .expect("Cannot claim input, additional funding needed")
325    }
326}
327
328fn has_invoice_expired(
329    invoice: &Bolt11Invoice,
330    now_epoch: Duration,
331    clock_skew_tolerance: Duration,
332) -> bool {
333    assert!(now_epoch >= clock_skew_tolerance);
334    // tolerate some clock skew
335    invoice.would_expire(now_epoch.checked_sub(clock_skew_tolerance).unwrap())
336}
337
338pub async fn get_incoming_contract(
339    module_api: DynModuleApi,
340    contract_id: fedimint_ln_common::contracts::ContractId,
341) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
342    match module_api.fetch_contract(contract_id).await {
343        Ok(Some(contract)) => {
344            if let FundedContract::Incoming(incoming) = contract.contract {
345                Ok(Some(IncomingContractAccount {
346                    amount: contract.amount,
347                    contract: incoming.contract,
348                }))
349            } else {
350                Err(fedimint_api_client::api::FederationError::general(
351                    ACCOUNT_ENDPOINT,
352                    contract_id,
353                    fedimint_api_client::api::FederationGeneralError::UnexpectedResponse {
354                        message: format!("Contract {contract_id} is not an incoming contract"),
355                    },
356                ))
357            }
358        }
359        Ok(None) => Ok(None),
360        Err(e) => Err(e),
361    }
362}
363
364#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
365pub struct LightningReceiveFunded {
366    txid: TransactionId,
367    out_points: Vec<OutPoint>,
368}
369
370impl LightningReceiveFunded {
371    fn transitions(
372        &self,
373        global_context: &DynGlobalClientContext,
374    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
375        let out_points = self.out_points.clone();
376        vec![StateTransition::new(
377            Self::await_claim_success(global_context.clone(), self.txid),
378            move |_dbtx, result, old_state| {
379                let out_points = out_points.clone();
380                Box::pin(
381                    async move { Self::transition_claim_success(&result, &old_state, out_points) },
382                )
383            },
384        )]
385    }
386
387    async fn await_claim_success(
388        global_context: DynGlobalClientContext,
389        txid: TransactionId,
390    ) -> Result<(), String> {
391        // No network calls are done here, we just await other state machines, so no
392        // retry logic is needed
393        global_context.await_tx_accepted(txid).await
394    }
395
396    fn transition_claim_success(
397        result: &Result<(), String>,
398        old_state: &LightningReceiveStateMachine,
399        out_points: Vec<OutPoint>,
400    ) -> LightningReceiveStateMachine {
401        match result {
402            Ok(()) => {
403                // Claim successful
404                LightningReceiveStateMachine {
405                    operation_id: old_state.operation_id,
406                    state: LightningReceiveStates::Success(out_points),
407                }
408            }
409            Err(_) => {
410                // Claim rejection
411                LightningReceiveStateMachine {
412                    operation_id: old_state.operation_id,
413                    state: LightningReceiveStates::Canceled(LightningReceiveError::ClaimRejected),
414                }
415            }
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests;