Skip to main content

fedimint_ln_client/
incoming.rs

1//! # Incoming State Machine
2//!
3//! This shared state machine is used by clients
4//! that want to pay other clients within the federation
5//!
6//! It's applied in two places:
7//!   - `fedimint-ln-client` for internal payments without involving the gateway
8//!   - `gateway` for receiving payments into the federation
9
10use core::fmt;
11use std::time::Duration;
12
13use assert_matches::assert_matches;
14use bitcoin::hashes::sha256;
15use fedimint_client_module::DynGlobalClientContext;
16use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
17use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
18use fedimint_core::core::OperationId;
19use fedimint_core::encoding::{Decodable, Encodable};
20use fedimint_core::module::Amounts;
21use fedimint_core::runtime::sleep;
22use fedimint_core::{Amount, OutPoint, TransactionId};
23use fedimint_ln_common::LightningInput;
24use fedimint_ln_common::contracts::incoming::IncomingContractAccount;
25use fedimint_ln_common::contracts::{ContractId, Preimage};
26use lightning_invoice::Bolt11Invoice;
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29use tracing::{debug, info, warn};
30
31use crate::api::LnFederationApi;
32use crate::{LightningClientContext, PayType, set_payment_result};
33
34#[cfg_attr(doc, aquamarine::aquamarine)]
35/// State machine that executes a transaction between two users
36/// within a federation. This creates and funds an incoming contract
37/// based on an existing offer within the federation.
38///
39/// ```mermaid
40/// graph LR
41/// classDef virtual fill:#fff,stroke-dasharray: 5 5
42///
43///    FundingOffer -- funded incoming contract --> DecryptingPreimage
44///    FundingOffer -- funding incoming contract failed --> FundingFailed
45///    DecryptingPreimage -- successfully decrypted preimage --> Preimage
46///    DecryptingPreimage -- invalid preimage --> RefundSubmitted
47///    DecryptingPreimage -- error decrypting preimage --> Failure
48/// ```
49#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
50pub enum IncomingSmStates {
51    FundingOffer(FundingOfferState),
52    DecryptingPreimage(DecryptingPreimageState),
53    Preimage(Preimage),
54    RefundSubmitted {
55        out_points: Vec<OutPoint>,
56        error: IncomingSmError,
57    },
58    FundingFailed {
59        error: IncomingSmError,
60    },
61    Failure(String),
62}
63
64impl fmt::Display for IncomingSmStates {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            IncomingSmStates::FundingOffer(_) => write!(f, "FundingOffer"),
68            IncomingSmStates::DecryptingPreimage(_) => write!(f, "DecryptingPreimage"),
69            IncomingSmStates::Preimage(_) => write!(f, "Preimage"),
70            IncomingSmStates::RefundSubmitted { .. } => write!(f, "RefundSubmitted"),
71            IncomingSmStates::FundingFailed { .. } => write!(f, "FundingFailed"),
72            IncomingSmStates::Failure(_) => write!(f, "Failure"),
73        }
74    }
75}
76
77#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
78pub struct IncomingSmCommon {
79    pub operation_id: OperationId,
80    pub contract_id: ContractId,
81    pub payment_hash: sha256::Hash,
82}
83
84#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
85pub struct IncomingStateMachine {
86    pub common: IncomingSmCommon,
87    pub state: IncomingSmStates,
88}
89
90impl fmt::Display for IncomingStateMachine {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(
93            f,
94            "Incoming State Machine Operation ID: {:?} State: {}",
95            self.common.operation_id, self.state
96        )
97    }
98}
99
100impl State for IncomingStateMachine {
101    type ModuleContext = LightningClientContext;
102
103    fn transitions(
104        &self,
105        context: &Self::ModuleContext,
106        global_context: &DynGlobalClientContext,
107    ) -> Vec<fedimint_client_module::sm::StateTransition<Self>> {
108        match &self.state {
109            IncomingSmStates::FundingOffer(state) => state.transitions(global_context),
110            IncomingSmStates::DecryptingPreimage(_state) => {
111                DecryptingPreimageState::transitions(&self.common, global_context, context)
112            }
113            _ => {
114                vec![]
115            }
116        }
117    }
118
119    fn operation_id(&self) -> fedimint_core::core::OperationId {
120        self.common.operation_id
121    }
122}
123
124#[derive(
125    Error, Debug, Serialize, Deserialize, Encodable, Decodable, Hash, Clone, Eq, PartialEq,
126)]
127#[serde(rename_all = "snake_case")]
128#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
129#[cfg_attr(feature = "uniffi", uniffi(flat_error))]
130pub enum IncomingSmError {
131    #[error("Violated fee policy. Offer amount {offer_amount} Payment amount: {payment_amount}")]
132    ViolatedFeePolicy {
133        offer_amount: Amount,
134        payment_amount: Amount,
135    },
136    #[error("Invalid offer. Offer hash: {offer_hash} Payment hash: {payment_hash}")]
137    InvalidOffer {
138        offer_hash: sha256::Hash,
139        payment_hash: sha256::Hash,
140    },
141    #[error("Timed out fetching the offer")]
142    TimeoutFetchingOffer { payment_hash: sha256::Hash },
143    #[error("Error fetching the contract {payment_hash}. Error: {error_message}")]
144    FetchContractError {
145        payment_hash: sha256::Hash,
146        error_message: String,
147    },
148    #[error("Invalid preimage. Contract: {contract:?}")]
149    InvalidPreimage {
150        contract: Box<IncomingContractAccount>,
151    },
152    #[error("There was a failure when funding the contract: {error_message}")]
153    FailedToFundContract { error_message: String },
154    #[error("Failed to parse the amount from the invoice: {invoice}")]
155    AmountError { invoice: Bolt11Invoice },
156}
157
158#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
159pub struct FundingOfferState {
160    pub txid: TransactionId,
161}
162
163impl FundingOfferState {
164    fn transitions(
165        &self,
166        global_context: &DynGlobalClientContext,
167    ) -> Vec<StateTransition<IncomingStateMachine>> {
168        let txid = self.txid;
169        vec![StateTransition::new(
170            Self::await_funding_success(global_context.clone(), txid),
171            |_dbtx, result, old_state| {
172                Box::pin(async { Self::transition_funding_success(result, old_state) })
173            },
174        )]
175    }
176
177    async fn await_funding_success(
178        global_context: DynGlobalClientContext,
179        txid: TransactionId,
180    ) -> Result<(), IncomingSmError> {
181        global_context
182            .await_tx_accepted(txid)
183            .await
184            .map_err(|error_message| IncomingSmError::FailedToFundContract { error_message })
185    }
186
187    fn transition_funding_success(
188        result: Result<(), IncomingSmError>,
189        old_state: IncomingStateMachine,
190    ) -> IncomingStateMachine {
191        let txid = match old_state.state {
192            IncomingSmStates::FundingOffer(refund) => refund.txid,
193            _ => panic!("Invalid state transition"),
194        };
195
196        match result {
197            Ok(()) => IncomingStateMachine {
198                common: old_state.common,
199                state: IncomingSmStates::DecryptingPreimage(DecryptingPreimageState { txid }),
200            },
201            Err(error) => IncomingStateMachine {
202                common: old_state.common,
203                state: IncomingSmStates::FundingFailed { error },
204            },
205        }
206    }
207}
208
209#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
210pub struct DecryptingPreimageState {
211    txid: TransactionId,
212}
213
214impl DecryptingPreimageState {
215    fn transitions(
216        common: &IncomingSmCommon,
217        global_context: &DynGlobalClientContext,
218        context: &LightningClientContext,
219    ) -> Vec<StateTransition<IncomingStateMachine>> {
220        let success_context = global_context.clone();
221        let gateway_context = context.clone();
222
223        vec![StateTransition::new(
224            Self::await_preimage_decryption(success_context.clone(), common.contract_id),
225            move |dbtx, result, old_state| {
226                let gateway_context = gateway_context.clone();
227                let success_context = success_context.clone();
228                Box::pin(Self::transition_incoming_contract_funded(
229                    result,
230                    old_state,
231                    dbtx,
232                    success_context,
233                    gateway_context,
234                ))
235            },
236        )]
237    }
238
239    async fn await_preimage_decryption(
240        global_context: DynGlobalClientContext,
241        contract_id: ContractId,
242    ) -> Result<Preimage, IncomingSmError> {
243        loop {
244            debug!("Awaiting preimage decryption for contract {contract_id:?}");
245            match global_context
246                .module_api()
247                .wait_preimage_decrypted(contract_id)
248                .await
249            {
250                Ok((incoming_contract_account, preimage)) => {
251                    if let Some(preimage) = preimage {
252                        debug!("Preimage decrypted for contract {contract_id:?}");
253                        return Ok(preimage);
254                    }
255
256                    info!("Invalid preimage for contract {contract_id:?}");
257                    return Err(IncomingSmError::InvalidPreimage {
258                        contract: Box::new(incoming_contract_account),
259                    });
260                }
261                Err(error) => {
262                    warn!(
263                        "Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying..."
264                    );
265                }
266            }
267
268            sleep(Duration::from_secs(1)).await;
269        }
270    }
271
272    async fn transition_incoming_contract_funded(
273        result: Result<Preimage, IncomingSmError>,
274        old_state: IncomingStateMachine,
275        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
276        global_context: DynGlobalClientContext,
277        context: LightningClientContext,
278    ) -> IncomingStateMachine {
279        assert_matches!(old_state.state, IncomingSmStates::DecryptingPreimage(_));
280
281        match result {
282            Ok(preimage) => {
283                let contract_id = old_state.common.contract_id;
284                let payment_hash = old_state.common.payment_hash;
285                set_payment_result(
286                    &mut dbtx.module_tx(),
287                    payment_hash,
288                    PayType::Internal(old_state.common.operation_id),
289                    contract_id,
290                    Amount::from_msats(0),
291                )
292                .await;
293
294                // client_ctx is None for the gateway since it does not emit the client events
295                if let Some(ref client_ctx) = context.client_ctx {
296                    client_ctx
297                        .log_event(
298                            &mut dbtx.module_tx(),
299                            crate::events::SendPaymentUpdateEvent {
300                                operation_id: old_state.common.operation_id,
301                                status: crate::events::SendPaymentStatus::Success(preimage.0),
302                            },
303                        )
304                        .await;
305                }
306
307                IncomingStateMachine {
308                    common: old_state.common,
309                    state: IncomingSmStates::Preimage(preimage),
310                }
311            }
312            Err(IncomingSmError::InvalidPreimage { contract }) => {
313                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
314                    .await
315            }
316            Err(e) => IncomingStateMachine {
317                common: old_state.common,
318                state: IncomingSmStates::Failure(format!(
319                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
320                )),
321            },
322        }
323    }
324
325    async fn refund_incoming_contract(
326        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
327        global_context: DynGlobalClientContext,
328        context: LightningClientContext,
329        old_state: IncomingStateMachine,
330        contract: Box<IncomingContractAccount>,
331    ) -> IncomingStateMachine {
332        debug!("Refunding incoming contract {contract:?}");
333        let claim_input = contract.claim();
334        let client_input = ClientInput::<LightningInput> {
335            input: claim_input,
336            amounts: Amounts::new_bitcoin(contract.amount),
337            keys: vec![context.redeem_key],
338        };
339
340        let change_range = global_context
341            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
342            .await
343            .expect("Cannot claim input, additional funding needed");
344        debug!("Refunded incoming contract {contract:?} with {change_range:?}");
345
346        // client_ctx is None for the gateway since it does not emit the client events
347        if let Some(ref client_ctx) = context.client_ctx {
348            client_ctx
349                .log_event(
350                    &mut dbtx.module_tx(),
351                    crate::events::SendPaymentUpdateEvent {
352                        operation_id: old_state.common.operation_id,
353                        status: crate::events::SendPaymentStatus::Refunded,
354                    },
355                )
356                .await;
357        }
358
359        IncomingStateMachine {
360            common: old_state.common,
361            state: IncomingSmStates::RefundSubmitted {
362                out_points: change_range.into_iter().collect(),
363                error: IncomingSmError::InvalidPreimage { contract },
364            },
365        }
366    }
367}
368
369#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
370pub struct AwaitingPreimageDecryption {
371    txid: TransactionId,
372}
373
374#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
375pub struct PreimageState {
376    preimage: Preimage,
377}
378
379#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
380pub struct RefundSuccessState {
381    refund_txid: TransactionId,
382}