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")]
128pub enum IncomingSmError {
129    #[error("Violated fee policy. Offer amount {offer_amount} Payment amount: {payment_amount}")]
130    ViolatedFeePolicy {
131        offer_amount: Amount,
132        payment_amount: Amount,
133    },
134    #[error("Invalid offer. Offer hash: {offer_hash} Payment hash: {payment_hash}")]
135    InvalidOffer {
136        offer_hash: sha256::Hash,
137        payment_hash: sha256::Hash,
138    },
139    #[error("Timed out fetching the offer")]
140    TimeoutFetchingOffer { payment_hash: sha256::Hash },
141    #[error("Error fetching the contract {payment_hash}. Error: {error_message}")]
142    FetchContractError {
143        payment_hash: sha256::Hash,
144        error_message: String,
145    },
146    #[error("Invalid preimage. Contract: {contract:?}")]
147    InvalidPreimage {
148        contract: Box<IncomingContractAccount>,
149    },
150    #[error("There was a failure when funding the contract: {error_message}")]
151    FailedToFundContract { error_message: String },
152    #[error("Failed to parse the amount from the invoice: {invoice}")]
153    AmountError { invoice: Bolt11Invoice },
154    // New variants have to be appended: the `Encodable` derive assigns indices by
155    // declaration order and this type is persisted inside `RefundSubmitted` and
156    // `FundingFailed` states, so inserting one mid-enum makes existing client
157    // databases fail to decode.
158    #[error(
159        "A contract already exists for payment hash {payment_hash}, funding it again would credit the existing account"
160    )]
161    ContractAlreadyExists { payment_hash: sha256::Hash },
162}
163
164#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
165pub struct FundingOfferState {
166    pub txid: TransactionId,
167}
168
169impl FundingOfferState {
170    fn transitions(
171        &self,
172        global_context: &DynGlobalClientContext,
173    ) -> Vec<StateTransition<IncomingStateMachine>> {
174        let txid = self.txid;
175        vec![StateTransition::new(
176            Self::await_funding_success(global_context.clone(), txid),
177            |_dbtx, result, old_state| {
178                Box::pin(async { Self::transition_funding_success(result, old_state) })
179            },
180        )]
181    }
182
183    async fn await_funding_success(
184        global_context: DynGlobalClientContext,
185        txid: TransactionId,
186    ) -> Result<(), IncomingSmError> {
187        global_context
188            .await_tx_accepted(txid)
189            .await
190            .map_err(|error_message| IncomingSmError::FailedToFundContract { error_message })
191    }
192
193    fn transition_funding_success(
194        result: Result<(), IncomingSmError>,
195        old_state: IncomingStateMachine,
196    ) -> IncomingStateMachine {
197        let txid = match old_state.state {
198            IncomingSmStates::FundingOffer(refund) => refund.txid,
199            _ => panic!("Invalid state transition"),
200        };
201
202        match result {
203            Ok(()) => IncomingStateMachine {
204                common: old_state.common,
205                state: IncomingSmStates::DecryptingPreimage(DecryptingPreimageState { txid }),
206            },
207            Err(error) => IncomingStateMachine {
208                common: old_state.common,
209                state: IncomingSmStates::FundingFailed { error },
210            },
211        }
212    }
213}
214
215#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
216pub struct DecryptingPreimageState {
217    txid: TransactionId,
218}
219
220impl DecryptingPreimageState {
221    fn transitions(
222        common: &IncomingSmCommon,
223        global_context: &DynGlobalClientContext,
224        context: &LightningClientContext,
225    ) -> Vec<StateTransition<IncomingStateMachine>> {
226        let success_context = global_context.clone();
227        let gateway_context = context.clone();
228
229        vec![StateTransition::new(
230            Self::await_preimage_decryption(success_context.clone(), common.contract_id),
231            move |dbtx, result, old_state| {
232                let gateway_context = gateway_context.clone();
233                let success_context = success_context.clone();
234                Box::pin(Self::transition_incoming_contract_funded(
235                    result,
236                    old_state,
237                    dbtx,
238                    success_context,
239                    gateway_context,
240                ))
241            },
242        )]
243    }
244
245    async fn await_preimage_decryption(
246        global_context: DynGlobalClientContext,
247        contract_id: ContractId,
248    ) -> Result<Preimage, IncomingSmError> {
249        loop {
250            debug!("Awaiting preimage decryption for contract {contract_id:?}");
251            match global_context
252                .module_api()
253                .wait_preimage_decrypted(contract_id)
254                .await
255            {
256                Ok((incoming_contract_account, preimage)) => {
257                    if let Some(preimage) = preimage {
258                        debug!("Preimage decrypted for contract {contract_id:?}");
259                        return Ok(preimage);
260                    }
261
262                    info!("Invalid preimage for contract {contract_id:?}");
263                    return Err(IncomingSmError::InvalidPreimage {
264                        contract: Box::new(incoming_contract_account),
265                    });
266                }
267                Err(error) => {
268                    warn!(
269                        "Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying..."
270                    );
271                }
272            }
273
274            sleep(Duration::from_secs(1)).await;
275        }
276    }
277
278    async fn transition_incoming_contract_funded(
279        result: Result<Preimage, IncomingSmError>,
280        old_state: IncomingStateMachine,
281        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
282        global_context: DynGlobalClientContext,
283        context: LightningClientContext,
284    ) -> IncomingStateMachine {
285        assert_matches!(old_state.state, IncomingSmStates::DecryptingPreimage(_));
286
287        match result {
288            Ok(preimage) => {
289                let contract_id = old_state.common.contract_id;
290                let payment_hash = old_state.common.payment_hash;
291                set_payment_result(
292                    &mut dbtx.module_tx(),
293                    payment_hash,
294                    PayType::Internal(old_state.common.operation_id),
295                    contract_id,
296                    Amount::from_msats(0),
297                )
298                .await;
299
300                // client_ctx is None for the gateway since it does not emit the client events
301                if let Some(ref client_ctx) = context.client_ctx {
302                    client_ctx
303                        .log_event(
304                            &mut dbtx.module_tx(),
305                            crate::events::SendPaymentUpdateEvent {
306                                operation_id: old_state.common.operation_id,
307                                status: crate::events::SendPaymentStatus::Success(preimage.0),
308                            },
309                        )
310                        .await;
311                }
312
313                IncomingStateMachine {
314                    common: old_state.common,
315                    state: IncomingSmStates::Preimage(preimage),
316                }
317            }
318            Err(IncomingSmError::InvalidPreimage { contract }) => {
319                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
320                    .await
321            }
322            Err(e) => IncomingStateMachine {
323                common: old_state.common,
324                state: IncomingSmStates::Failure(format!(
325                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
326                )),
327            },
328        }
329    }
330
331    async fn refund_incoming_contract(
332        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
333        global_context: DynGlobalClientContext,
334        context: LightningClientContext,
335        old_state: IncomingStateMachine,
336        contract: Box<IncomingContractAccount>,
337    ) -> IncomingStateMachine {
338        debug!("Refunding incoming contract {contract:?}");
339        let claim_input = contract.claim();
340        let client_input = ClientInput::<LightningInput> {
341            input: claim_input,
342            amounts: Amounts::new_bitcoin(contract.amount),
343            keys: vec![context.redeem_key],
344        };
345
346        let change_range = global_context
347            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
348            .await
349            .expect("Cannot claim input, additional funding needed");
350        debug!("Refunded incoming contract {contract:?} with {change_range:?}");
351
352        // client_ctx is None for the gateway since it does not emit the client events
353        if let Some(ref client_ctx) = context.client_ctx {
354            client_ctx
355                .log_event(
356                    &mut dbtx.module_tx(),
357                    crate::events::SendPaymentUpdateEvent {
358                        operation_id: old_state.common.operation_id,
359                        status: crate::events::SendPaymentStatus::Refunded,
360                    },
361                )
362                .await;
363        }
364
365        IncomingStateMachine {
366            common: old_state.common,
367            state: IncomingSmStates::RefundSubmitted {
368                out_points: change_range.into_iter().collect(),
369                error: IncomingSmError::InvalidPreimage { contract },
370            },
371        }
372    }
373}
374
375#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
376pub struct AwaitingPreimageDecryption {
377    txid: TransactionId,
378}
379
380#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
381pub struct PreimageState {
382    preimage: Preimage,
383}
384
385#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
386pub struct RefundSuccessState {
387    refund_txid: TransactionId,
388}