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