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