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/// 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(context, 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        context: &LightningClientContext,
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        let context = context.clone();
190        vec![StateTransition::new(
191            Self::await_incoming_contract_account(invoice, global_context.clone()),
192            move |dbtx, contract, old_state| {
193                let context = context.clone();
194                Box::pin(Self::transition_funded(
195                    old_state,
196                    receiving_key,
197                    contract,
198                    dbtx,
199                    global_context.clone(),
200                    context,
201                ))
202            },
203        )]
204    }
205
206    async fn await_incoming_contract_account(
207        invoice: Bolt11Invoice,
208        global_context: DynGlobalClientContext,
209    ) -> Result<IncomingContractAccount, LightningReceiveError> {
210        let contract_id = (*invoice.payment_hash()).into();
211        loop {
212            // Consider time before the api call to account for network delays
213            let now_epoch = fedimint_core::time::duration_since_epoch();
214            match get_incoming_contract(global_context.module_api(), contract_id).await {
215                Ok(Some(incoming_contract_account)) => {
216                    match incoming_contract_account.contract.decrypted_preimage {
217                        DecryptedPreimage::Pending => {
218                            // Previously we would time out here but we may miss a payment if we do
219                            // so
220                            info!("Waiting for preimage decryption for contract {contract_id}");
221                        }
222                        DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
223                        DecryptedPreimage::Invalid => {
224                            return Err(LightningReceiveError::InvalidPreimage);
225                        }
226                    }
227                }
228                Ok(None) => {
229                    // only when we are sure that the invoice is still pending that we can
230                    // check for a timeout
231                    const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_mins(1);
232                    if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
233                        return Err(LightningReceiveError::Timeout);
234                    }
235                    debug!("Still waiting preimage decryption for contract {contract_id}");
236                }
237                Err(error) => {
238                    error.report_if_unusual("Awaiting incoming contract");
239                    debug!(
240                        target: LOG_CLIENT_MODULE_LN,
241                        err = %error.fmt_compact(),
242                        "External LN payment retryable error waiting for preimage decryption"
243                    );
244                }
245            }
246            sleep(RETRY_DELAY).await;
247        }
248    }
249
250    async fn transition_funded(
251        old_state: LightningReceiveStateMachine,
252        receiving_key: ReceivingKey,
253        result: Result<IncomingContractAccount, LightningReceiveError>,
254        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
255        global_context: DynGlobalClientContext,
256        context: LightningClientContext,
257    ) -> LightningReceiveStateMachine {
258        match result {
259            Ok(contract) => {
260                // None for the gateway since it does not emit the client events
261                if let Some(ref client_ctx) = context.client_ctx {
262                    client_ctx
263                        .log_event(
264                            &mut dbtx.module_tx(),
265                            crate::events::ReceivePaymentEvent {
266                                operation_id: old_state.operation_id,
267                                amount: contract.amount,
268                            },
269                        )
270                        .await;
271                }
272
273                match receiving_key {
274                    ReceivingKey::Personal(keypair) => {
275                        let change_range =
276                            Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
277                                .await;
278                        LightningReceiveStateMachine {
279                            operation_id: old_state.operation_id,
280                            state: LightningReceiveStates::Funded(LightningReceiveFunded {
281                                txid: change_range.txid(),
282                                out_points: change_range.into_iter().collect(),
283                            }),
284                        }
285                    }
286                    ReceivingKey::External(_) => {
287                        // Claim successful
288                        LightningReceiveStateMachine {
289                            operation_id: old_state.operation_id,
290                            state: LightningReceiveStates::Success(vec![]),
291                        }
292                    }
293                }
294            }
295            Err(e) => LightningReceiveStateMachine {
296                operation_id: old_state.operation_id,
297                state: LightningReceiveStates::Canceled(e),
298            },
299        }
300    }
301
302    async fn claim_incoming_contract(
303        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
304        contract: IncomingContractAccount,
305        keypair: Keypair,
306        global_context: DynGlobalClientContext,
307    ) -> OutPointRange {
308        let input = contract.claim();
309        let client_input = ClientInput::<LightningInput> {
310            input,
311            amounts: Amounts::new_bitcoin(contract.amount),
312            keys: vec![keypair],
313        };
314
315        global_context
316            .claim_inputs(
317                dbtx,
318                // The input of the refund tx is managed by this state machine, so no new state
319                // machines need to be created
320                ClientInputBundle::new_no_sm(vec![client_input]),
321            )
322            .await
323            .expect("Cannot claim input, additional funding needed")
324    }
325}
326
327fn has_invoice_expired(
328    invoice: &Bolt11Invoice,
329    now_epoch: Duration,
330    clock_skew_tolerance: Duration,
331) -> bool {
332    assert!(now_epoch >= clock_skew_tolerance);
333    // tolerate some clock skew
334    invoice.would_expire(now_epoch.checked_sub(clock_skew_tolerance).unwrap())
335}
336
337pub async fn get_incoming_contract(
338    module_api: DynModuleApi,
339    contract_id: fedimint_ln_common::contracts::ContractId,
340) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
341    match module_api.fetch_contract(contract_id).await {
342        Ok(Some(contract)) => {
343            if let FundedContract::Incoming(incoming) = contract.contract {
344                Ok(Some(IncomingContractAccount {
345                    amount: contract.amount,
346                    contract: incoming.contract,
347                }))
348            } else {
349                Err(fedimint_api_client::api::FederationError::general(
350                    ACCOUNT_ENDPOINT,
351                    contract_id,
352                    fedimint_api_client::api::FederationGeneralError::UnexpectedResponse {
353                        message: format!("Contract {contract_id} is not an incoming contract"),
354                    },
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;