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")]
113#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
114pub enum LightningReceiveError {
115    #[error("Offer transaction was rejected")]
116    Rejected,
117    #[error("Incoming Lightning invoice was not paid within the timeout")]
118    Timeout,
119    #[error("Claim transaction was rejected")]
120    ClaimRejected,
121    #[error("The decrypted preimage was invalid")]
122    InvalidPreimage,
123}
124
125impl LightningReceiveSubmittedOffer {
126    fn transitions(
127        &self,
128        global_context: &DynGlobalClientContext,
129    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
130        let global_context = global_context.clone();
131        let txid = self.offer_txid;
132        let invoice = self.invoice.clone();
133        let receiving_key = self.receiving_key;
134        vec![StateTransition::new(
135            Self::await_invoice_confirmation(global_context, txid),
136            move |_dbtx, result, old_state| {
137                let invoice = invoice.clone();
138                Box::pin(async move {
139                    Self::transition_confirmed_invoice(&result, &old_state, invoice, receiving_key)
140                })
141            },
142        )]
143    }
144
145    async fn await_invoice_confirmation(
146        global_context: DynGlobalClientContext,
147        txid: TransactionId,
148    ) -> Result<(), String> {
149        // No network calls are done here, we just await other state machines, so no
150        // retry logic is needed
151        global_context.await_tx_accepted(txid).await
152    }
153
154    fn transition_confirmed_invoice(
155        result: &Result<(), String>,
156        old_state: &LightningReceiveStateMachine,
157        invoice: Bolt11Invoice,
158        receiving_key: ReceivingKey,
159    ) -> LightningReceiveStateMachine {
160        match result {
161            Ok(()) => LightningReceiveStateMachine {
162                operation_id: old_state.operation_id,
163                state: LightningReceiveStates::ConfirmedInvoice(LightningReceiveConfirmedInvoice {
164                    invoice,
165                    receiving_key,
166                }),
167            },
168            Err(_) => LightningReceiveStateMachine {
169                operation_id: old_state.operation_id,
170                state: LightningReceiveStates::Canceled(LightningReceiveError::Rejected),
171            },
172        }
173    }
174}
175
176#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
177pub struct LightningReceiveConfirmedInvoice {
178    pub(crate) invoice: Bolt11Invoice,
179    pub(crate) receiving_key: ReceivingKey,
180}
181
182impl LightningReceiveConfirmedInvoice {
183    fn transitions(
184        &self,
185        context: &LightningClientContext,
186        global_context: &DynGlobalClientContext,
187    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
188        let invoice = self.invoice.clone();
189        let receiving_key = self.receiving_key;
190        let global_context = global_context.clone();
191        let context = context.clone();
192        vec![StateTransition::new(
193            Self::await_incoming_contract_account(invoice, global_context.clone()),
194            move |dbtx, contract, old_state| {
195                let context = context.clone();
196                Box::pin(Self::transition_funded(
197                    old_state,
198                    receiving_key,
199                    contract,
200                    dbtx,
201                    global_context.clone(),
202                    context,
203                ))
204            },
205        )]
206    }
207
208    async fn await_incoming_contract_account(
209        invoice: Bolt11Invoice,
210        global_context: DynGlobalClientContext,
211    ) -> Result<IncomingContractAccount, LightningReceiveError> {
212        let contract_id = (*invoice.payment_hash()).into();
213        loop {
214            // Consider time before the api call to account for network delays
215            let now_epoch = fedimint_core::time::duration_since_epoch();
216            match get_incoming_contract(global_context.module_api(), contract_id).await {
217                Ok(Some(incoming_contract_account)) => {
218                    match incoming_contract_account.contract.decrypted_preimage {
219                        DecryptedPreimage::Pending => {
220                            // Previously we would time out here but we may miss a payment if we do
221                            // so
222                            info!("Waiting for preimage decryption for contract {contract_id}");
223                        }
224                        DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
225                        DecryptedPreimage::Invalid => {
226                            return Err(LightningReceiveError::InvalidPreimage);
227                        }
228                    }
229                }
230                Ok(None) => {
231                    // only when we are sure that the invoice is still pending that we can
232                    // check for a timeout
233                    const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_mins(1);
234                    if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
235                        return Err(LightningReceiveError::Timeout);
236                    }
237                    debug!("Still waiting preimage decryption for contract {contract_id}");
238                }
239                Err(error) => {
240                    error.report_if_unusual("Awaiting incoming contract");
241                    debug!(
242                        target: LOG_CLIENT_MODULE_LN,
243                        err = %error.fmt_compact(),
244                        "External LN payment retryable error waiting for preimage decryption"
245                    );
246                }
247            }
248            sleep(RETRY_DELAY).await;
249        }
250    }
251
252    async fn transition_funded(
253        old_state: LightningReceiveStateMachine,
254        receiving_key: ReceivingKey,
255        result: Result<IncomingContractAccount, LightningReceiveError>,
256        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
257        global_context: DynGlobalClientContext,
258        context: LightningClientContext,
259    ) -> LightningReceiveStateMachine {
260        match result {
261            Ok(contract) => {
262                // None for the gateway since it does not emit the client events
263                if let Some(ref client_ctx) = context.client_ctx {
264                    client_ctx
265                        .log_event(
266                            &mut dbtx.module_tx(),
267                            crate::events::ReceivePaymentEvent {
268                                operation_id: old_state.operation_id,
269                                amount: contract.amount,
270                            },
271                        )
272                        .await;
273                }
274
275                match receiving_key {
276                    ReceivingKey::Personal(keypair) => {
277                        let change_range =
278                            Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
279                                .await;
280                        LightningReceiveStateMachine {
281                            operation_id: old_state.operation_id,
282                            state: LightningReceiveStates::Funded(LightningReceiveFunded {
283                                txid: change_range.txid(),
284                                out_points: change_range.into_iter().collect(),
285                            }),
286                        }
287                    }
288                    ReceivingKey::External(_) => {
289                        // Claim successful
290                        LightningReceiveStateMachine {
291                            operation_id: old_state.operation_id,
292                            state: LightningReceiveStates::Success(vec![]),
293                        }
294                    }
295                }
296            }
297            Err(e) => LightningReceiveStateMachine {
298                operation_id: old_state.operation_id,
299                state: LightningReceiveStates::Canceled(e),
300            },
301        }
302    }
303
304    async fn claim_incoming_contract(
305        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
306        contract: IncomingContractAccount,
307        keypair: Keypair,
308        global_context: DynGlobalClientContext,
309    ) -> OutPointRange {
310        let input = contract.claim();
311        let client_input = ClientInput::<LightningInput> {
312            input,
313            amounts: Amounts::new_bitcoin(contract.amount),
314            keys: vec![keypair],
315        };
316
317        global_context
318            .claim_inputs(
319                dbtx,
320                // The input of the refund tx is managed by this state machine, so no new state
321                // machines need to be created
322                ClientInputBundle::new_no_sm(vec![client_input]),
323            )
324            .await
325            .expect("Cannot claim input, additional funding needed")
326    }
327}
328
329fn has_invoice_expired(
330    invoice: &Bolt11Invoice,
331    now_epoch: Duration,
332    clock_skew_tolerance: Duration,
333) -> bool {
334    assert!(now_epoch >= clock_skew_tolerance);
335    // tolerate some clock skew
336    invoice.would_expire(now_epoch.checked_sub(clock_skew_tolerance).unwrap())
337}
338
339pub async fn get_incoming_contract(
340    module_api: DynModuleApi,
341    contract_id: fedimint_ln_common::contracts::ContractId,
342) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
343    match module_api.fetch_contract(contract_id).await {
344        Ok(Some(contract)) => {
345            if let FundedContract::Incoming(incoming) = contract.contract {
346                Ok(Some(IncomingContractAccount {
347                    amount: contract.amount,
348                    contract: incoming.contract,
349                }))
350            } else {
351                Err(fedimint_api_client::api::FederationError::general(
352                    ACCOUNT_ENDPOINT,
353                    contract_id,
354                    anyhow::anyhow!("Contract {contract_id} is not an incoming contract"),
355                ))
356            }
357        }
358        Ok(None) => Ok(None),
359        Err(e) => Err(e),
360    }
361}
362
363#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
364pub struct LightningReceiveFunded {
365    txid: TransactionId,
366    out_points: Vec<OutPoint>,
367}
368
369impl LightningReceiveFunded {
370    fn transitions(
371        &self,
372        global_context: &DynGlobalClientContext,
373    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
374        let out_points = self.out_points.clone();
375        vec![StateTransition::new(
376            Self::await_claim_success(global_context.clone(), self.txid),
377            move |_dbtx, result, old_state| {
378                let out_points = out_points.clone();
379                Box::pin(
380                    async move { Self::transition_claim_success(&result, &old_state, out_points) },
381                )
382            },
383        )]
384    }
385
386    async fn await_claim_success(
387        global_context: DynGlobalClientContext,
388        txid: TransactionId,
389    ) -> Result<(), String> {
390        // No network calls are done here, we just await other state machines, so no
391        // retry logic is needed
392        global_context.await_tx_accepted(txid).await
393    }
394
395    fn transition_claim_success(
396        result: &Result<(), String>,
397        old_state: &LightningReceiveStateMachine,
398        out_points: Vec<OutPoint>,
399    ) -> LightningReceiveStateMachine {
400        match result {
401            Ok(()) => {
402                // Claim successful
403                LightningReceiveStateMachine {
404                    operation_id: old_state.operation_id,
405                    state: LightningReceiveStates::Success(out_points),
406                }
407            }
408            Err(_) => {
409                // Claim rejection
410                LightningReceiveStateMachine {
411                    operation_id: old_state.operation_id,
412                    state: LightningReceiveStates::Canceled(LightningReceiveError::ClaimRejected),
413                }
414            }
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use bitcoin::hashes::{Hash, sha256};
422    use fedimint_core::secp256k1::{Secp256k1, SecretKey};
423    use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
424
425    use super::*;
426
427    #[test]
428    fn test_invoice_expiration() -> anyhow::Result<()> {
429        let now = fedimint_core::time::duration_since_epoch();
430        let one_second = Duration::from_secs(1);
431        for expiration in [one_second, Duration::from_hours(1)] {
432            for tolerance in [one_second, Duration::from_mins(1)] {
433                let invoice = invoice(now, expiration)?;
434                assert!(!has_invoice_expired(
435                    &invoice,
436                    now.checked_sub(one_second).unwrap(),
437                    tolerance
438                ));
439                assert!(!has_invoice_expired(&invoice, now, tolerance));
440                assert!(!has_invoice_expired(&invoice, now + expiration, tolerance));
441                assert!(!has_invoice_expired(
442                    &invoice,
443                    (now + expiration + tolerance)
444                        .checked_sub(one_second)
445                        .unwrap(),
446                    tolerance
447                ));
448                assert!(has_invoice_expired(
449                    &invoice,
450                    now + expiration + tolerance,
451                    tolerance
452                ));
453                assert!(has_invoice_expired(
454                    &invoice,
455                    now + expiration + tolerance + one_second,
456                    tolerance
457                ));
458            }
459        }
460        Ok(())
461    }
462
463    fn invoice(now_epoch: Duration, expiry_time: Duration) -> anyhow::Result<Bolt11Invoice> {
464        let ctx = Secp256k1::new();
465        let secret_key = SecretKey::new(&mut rand::thread_rng());
466        Ok(InvoiceBuilder::new(Currency::Regtest)
467            .description(String::new())
468            .payment_hash(sha256::Hash::hash(&[0; 32]))
469            .duration_since_epoch(now_epoch)
470            .min_final_cltv_expiry_delta(0)
471            .payment_secret(PaymentSecret([0; 32]))
472            .amount_milli_satoshis(1000)
473            .expiry_time(expiry_time)
474            .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &secret_key))?)
475    }
476}