fedimint_ln_client/
receive.rs

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