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 bitcoin::hashes::sha256;
14use fedimint_client_module::DynGlobalClientContext;
15use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
16use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
17use fedimint_core::core::OperationId;
18use fedimint_core::encoding::{Decodable, Encodable};
19use fedimint_core::module::Amounts;
20use fedimint_core::runtime::sleep;
21use fedimint_core::{Amount, OutPoint, TransactionId};
22use fedimint_ln_common::LightningInput;
23use fedimint_ln_common::contracts::incoming::IncomingContractAccount;
24use fedimint_ln_common::contracts::{ContractId, Preimage};
25use lightning_invoice::Bolt11Invoice;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28use tracing::{debug, error, info, warn};
29
30use crate::api::LnFederationApi;
31use crate::{LightningClientContext, PayType, set_payment_result};
32
33#[cfg_attr(doc, aquamarine::aquamarine)]
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}
154
155#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
156pub struct FundingOfferState {
157    pub txid: TransactionId,
158}
159
160impl FundingOfferState {
161    fn transitions(
162        &self,
163        global_context: &DynGlobalClientContext,
164    ) -> Vec<StateTransition<IncomingStateMachine>> {
165        let txid = self.txid;
166        vec![StateTransition::new(
167            Self::await_funding_success(global_context.clone(), txid),
168            |_dbtx, result, old_state| {
169                Box::pin(async { Self::transition_funding_success(result, old_state) })
170            },
171        )]
172    }
173
174    async fn await_funding_success(
175        global_context: DynGlobalClientContext,
176        txid: TransactionId,
177    ) -> Result<(), IncomingSmError> {
178        global_context
179            .await_tx_accepted(txid)
180            .await
181            .map_err(|error_message| IncomingSmError::FailedToFundContract { error_message })
182    }
183
184    fn transition_funding_success(
185        result: Result<(), IncomingSmError>,
186        old_state: IncomingStateMachine,
187    ) -> IncomingStateMachine {
188        let txid = match old_state.state {
189            IncomingSmStates::FundingOffer(refund) => refund.txid,
190            _ => panic!("Invalid state transition"),
191        };
192
193        match result {
194            Ok(()) => IncomingStateMachine {
195                common: old_state.common,
196                state: IncomingSmStates::DecryptingPreimage(DecryptingPreimageState { txid }),
197            },
198            Err(error) => IncomingStateMachine {
199                common: old_state.common,
200                state: IncomingSmStates::FundingFailed { error },
201            },
202        }
203    }
204}
205
206#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
207pub struct DecryptingPreimageState {
208    txid: TransactionId,
209}
210
211impl DecryptingPreimageState {
212    fn transitions(
213        common: &IncomingSmCommon,
214        global_context: &DynGlobalClientContext,
215        context: &LightningClientContext,
216    ) -> Vec<StateTransition<IncomingStateMachine>> {
217        let success_context = global_context.clone();
218        let gateway_context = context.clone();
219
220        vec![StateTransition::new(
221            Self::await_preimage_decryption(success_context.clone(), common.contract_id),
222            move |dbtx, result, old_state| {
223                let gateway_context = gateway_context.clone();
224                let success_context = success_context.clone();
225                Box::pin(Self::transition_incoming_contract_funded(
226                    result,
227                    old_state,
228                    dbtx,
229                    success_context,
230                    gateway_context,
231                ))
232            },
233        )]
234    }
235
236    async fn await_preimage_decryption(
237        global_context: DynGlobalClientContext,
238        contract_id: ContractId,
239    ) -> Result<Preimage, IncomingSmError> {
240        loop {
241            debug!("Awaiting preimage decryption for contract {contract_id:?}");
242            match global_context
243                .module_api()
244                .wait_preimage_decrypted(contract_id)
245                .await
246            {
247                Ok((incoming_contract_account, preimage)) => {
248                    if let Some(preimage) = preimage {
249                        debug!("Preimage decrypted for contract {contract_id:?}");
250                        return Ok(preimage);
251                    }
252
253                    info!("Invalid preimage for contract {contract_id:?}");
254                    return Err(IncomingSmError::InvalidPreimage {
255                        contract: Box::new(incoming_contract_account),
256                    });
257                }
258                Err(error) => {
259                    warn!(
260                        "Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying..."
261                    );
262                }
263            }
264
265            sleep(Duration::from_secs(1)).await;
266        }
267    }
268
269    async fn transition_incoming_contract_funded(
270        result: Result<Preimage, IncomingSmError>,
271        old_state: IncomingStateMachine,
272        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
273        global_context: DynGlobalClientContext,
274        context: LightningClientContext,
275    ) -> IncomingStateMachine {
276        assert!(matches!(
277            old_state.state,
278            IncomingSmStates::DecryptingPreimage(_)
279        ));
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                IncomingStateMachine {
295                    common: old_state.common,
296                    state: IncomingSmStates::Preimage(preimage),
297                }
298            }
299            Err(IncomingSmError::InvalidPreimage { contract }) => {
300                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
301                    .await
302            }
303            Err(e) => IncomingStateMachine {
304                common: old_state.common,
305                state: IncomingSmStates::Failure(format!(
306                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
307                )),
308            },
309        }
310    }
311
312    async fn refund_incoming_contract(
313        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
314        global_context: DynGlobalClientContext,
315        context: LightningClientContext,
316        old_state: IncomingStateMachine,
317        contract: Box<IncomingContractAccount>,
318    ) -> IncomingStateMachine {
319        debug!("Refunding incoming contract {contract:?}");
320        let claim_input = contract.claim();
321        let client_input = ClientInput::<LightningInput> {
322            input: claim_input,
323            amounts: Amounts::new_bitcoin(contract.amount),
324            keys: vec![context.redeem_key],
325        };
326
327        let change_range = global_context
328            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
329            .await
330            .expect("Cannot claim input, additional funding needed");
331        debug!("Refunded incoming contract {contract:?} with {change_range:?}");
332
333        IncomingStateMachine {
334            common: old_state.common,
335            state: IncomingSmStates::RefundSubmitted {
336                out_points: change_range.into_iter().collect(),
337                error: IncomingSmError::InvalidPreimage { contract },
338            },
339        }
340    }
341}
342
343#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
344pub struct AwaitingPreimageDecryption {
345    txid: TransactionId,
346}
347
348#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
349pub struct PreimageState {
350    preimage: Preimage,
351}
352
353#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
354pub struct RefundSuccessState {
355    refund_txid: TransactionId,
356}