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