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, error, 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(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        global_context: &DynGlobalClientContext,
185    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
186        let invoice = self.invoice.clone();
187        let receiving_key = self.receiving_key;
188        let global_context = global_context.clone();
189        vec![StateTransition::new(
190            Self::await_incoming_contract_account(invoice, global_context.clone()),
191            move |dbtx, contract, old_state| {
192                Box::pin(Self::transition_funded(
193                    old_state,
194                    receiving_key,
195                    contract,
196                    dbtx,
197                    global_context.clone(),
198                ))
199            },
200        )]
201    }
202
203    async fn await_incoming_contract_account(
204        invoice: Bolt11Invoice,
205        global_context: DynGlobalClientContext,
206    ) -> Result<IncomingContractAccount, LightningReceiveError> {
207        let contract_id = (*invoice.payment_hash()).into();
208        loop {
209            // Consider time before the api call to account for network delays
210            let now_epoch = fedimint_core::time::duration_since_epoch();
211            match get_incoming_contract(global_context.module_api(), contract_id).await {
212                Ok(Some(incoming_contract_account)) => {
213                    match incoming_contract_account.contract.decrypted_preimage {
214                        DecryptedPreimage::Pending => {
215                            // Previously we would time out here but we may miss a payment if we do
216                            // so
217                            info!("Waiting for preimage decryption for contract {contract_id}");
218                        }
219                        DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
220                        DecryptedPreimage::Invalid => {
221                            return Err(LightningReceiveError::InvalidPreimage);
222                        }
223                    }
224                }
225                Ok(None) => {
226                    // only when we are sure that the invoice is still pending that we can
227                    // check for a timeout
228                    const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_secs(60);
229                    if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
230                        return Err(LightningReceiveError::Timeout);
231                    }
232                    debug!("Still waiting preimage decryption for contract {contract_id}");
233                }
234                Err(error) => {
235                    error.report_if_unusual("Awaiting incoming contract");
236                    debug!(
237                        target: LOG_CLIENT_MODULE_LN,
238                        err = %error.fmt_compact(),
239                        "External LN payment retryable error waiting for preimage decryption"
240                    );
241                }
242            }
243            sleep(RETRY_DELAY).await;
244        }
245    }
246
247    async fn transition_funded(
248        old_state: LightningReceiveStateMachine,
249        receiving_key: ReceivingKey,
250        result: Result<IncomingContractAccount, LightningReceiveError>,
251        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
252        global_context: DynGlobalClientContext,
253    ) -> LightningReceiveStateMachine {
254        match result {
255            Ok(contract) => {
256                match receiving_key {
257                    ReceivingKey::Personal(keypair) => {
258                        let change_range =
259                            Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
260                                .await;
261                        LightningReceiveStateMachine {
262                            operation_id: old_state.operation_id,
263                            state: LightningReceiveStates::Funded(LightningReceiveFunded {
264                                txid: change_range.txid(),
265                                out_points: change_range.into_iter().collect(),
266                            }),
267                        }
268                    }
269                    ReceivingKey::External(_) => {
270                        // Claim successful
271                        LightningReceiveStateMachine {
272                            operation_id: old_state.operation_id,
273                            state: LightningReceiveStates::Success(vec![]),
274                        }
275                    }
276                }
277            }
278            Err(e) => LightningReceiveStateMachine {
279                operation_id: old_state.operation_id,
280                state: LightningReceiveStates::Canceled(e),
281            },
282        }
283    }
284
285    async fn claim_incoming_contract(
286        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
287        contract: IncomingContractAccount,
288        keypair: Keypair,
289        global_context: DynGlobalClientContext,
290    ) -> OutPointRange {
291        let input = contract.claim();
292        let client_input = ClientInput::<LightningInput> {
293            input,
294            amounts: Amounts::new_bitcoin(contract.amount),
295            keys: vec![keypair],
296        };
297
298        global_context
299            .claim_inputs(
300                dbtx,
301                // The input of the refund tx is managed by this state machine, so no new state
302                // machines need to be created
303                ClientInputBundle::new_no_sm(vec![client_input]),
304            )
305            .await
306            .expect("Cannot claim input, additional funding needed")
307    }
308}
309
310fn has_invoice_expired(
311    invoice: &Bolt11Invoice,
312    now_epoch: Duration,
313    clock_skew_tolerance: Duration,
314) -> bool {
315    assert!(now_epoch >= clock_skew_tolerance);
316    // tolerate some clock skew
317    invoice.would_expire(now_epoch - clock_skew_tolerance)
318}
319
320pub async fn get_incoming_contract(
321    module_api: DynModuleApi,
322    contract_id: fedimint_ln_common::contracts::ContractId,
323) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
324    match module_api.fetch_contract(contract_id).await {
325        Ok(Some(contract)) => {
326            if let FundedContract::Incoming(incoming) = contract.contract {
327                Ok(Some(IncomingContractAccount {
328                    amount: contract.amount,
329                    contract: incoming.contract,
330                }))
331            } else {
332                Err(fedimint_api_client::api::FederationError::general(
333                    ACCOUNT_ENDPOINT,
334                    contract_id,
335                    anyhow::anyhow!("Contract {contract_id} is not an incoming contract"),
336                ))
337            }
338        }
339        Ok(None) => Ok(None),
340        Err(e) => Err(e),
341    }
342}
343
344#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
345pub struct LightningReceiveFunded {
346    txid: TransactionId,
347    out_points: Vec<OutPoint>,
348}
349
350impl LightningReceiveFunded {
351    fn transitions(
352        &self,
353        global_context: &DynGlobalClientContext,
354    ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
355        let out_points = self.out_points.clone();
356        vec![StateTransition::new(
357            Self::await_claim_success(global_context.clone(), self.txid),
358            move |_dbtx, result, old_state| {
359                let out_points = out_points.clone();
360                Box::pin(
361                    async move { Self::transition_claim_success(&result, &old_state, out_points) },
362                )
363            },
364        )]
365    }
366
367    async fn await_claim_success(
368        global_context: DynGlobalClientContext,
369        txid: TransactionId,
370    ) -> Result<(), String> {
371        // No network calls are done here, we just await other state machines, so no
372        // retry logic is needed
373        global_context.await_tx_accepted(txid).await
374    }
375
376    fn transition_claim_success(
377        result: &Result<(), String>,
378        old_state: &LightningReceiveStateMachine,
379        out_points: Vec<OutPoint>,
380    ) -> LightningReceiveStateMachine {
381        match result {
382            Ok(()) => {
383                // Claim successful
384                LightningReceiveStateMachine {
385                    operation_id: old_state.operation_id,
386                    state: LightningReceiveStates::Success(out_points),
387                }
388            }
389            Err(_) => {
390                // Claim rejection
391                LightningReceiveStateMachine {
392                    operation_id: old_state.operation_id,
393                    state: LightningReceiveStates::Canceled(LightningReceiveError::ClaimRejected),
394                }
395            }
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use bitcoin::hashes::{Hash, sha256};
403    use fedimint_core::secp256k1::{Secp256k1, SecretKey};
404    use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
405
406    use super::*;
407
408    #[test]
409    fn test_invoice_expiration() -> anyhow::Result<()> {
410        let now = fedimint_core::time::duration_since_epoch();
411        let one_second = Duration::from_secs(1);
412        for expiration in [one_second, Duration::from_secs(3600)] {
413            for tolerance in [one_second, Duration::from_secs(60)] {
414                let invoice = invoice(now, expiration)?;
415                assert!(!has_invoice_expired(&invoice, now - one_second, tolerance));
416                assert!(!has_invoice_expired(&invoice, now, tolerance));
417                assert!(!has_invoice_expired(&invoice, now + expiration, tolerance));
418                assert!(!has_invoice_expired(
419                    &invoice,
420                    now + expiration + tolerance - one_second,
421                    tolerance
422                ));
423                assert!(has_invoice_expired(
424                    &invoice,
425                    now + expiration + tolerance,
426                    tolerance
427                ));
428                assert!(has_invoice_expired(
429                    &invoice,
430                    now + expiration + tolerance + one_second,
431                    tolerance
432                ));
433            }
434        }
435        Ok(())
436    }
437
438    fn invoice(now_epoch: Duration, expiry_time: Duration) -> anyhow::Result<Bolt11Invoice> {
439        let ctx = Secp256k1::new();
440        let secret_key = SecretKey::new(&mut rand::thread_rng());
441        Ok(InvoiceBuilder::new(Currency::Regtest)
442            .description(String::new())
443            .payment_hash(sha256::Hash::hash(&[0; 32]))
444            .duration_since_epoch(now_epoch)
445            .min_final_cltv_expiry_delta(0)
446            .payment_secret(PaymentSecret([0; 32]))
447            .amount_milli_satoshis(1000)
448            .expiry_time(expiry_time)
449            .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &secret_key))?)
450    }
451}