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::runtime::sleep;
20use fedimint_core::{Amount, OutPoint, TransactionId};
21use fedimint_ln_common::LightningInput;
22use fedimint_ln_common::contracts::incoming::IncomingContractAccount;
23use fedimint_ln_common::contracts::{ContractId, Preimage};
24use lightning_invoice::Bolt11Invoice;
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use tracing::{debug, error, info, warn};
28
29use crate::api::LnFederationApi;
30use crate::{LightningClientContext, PayType, set_payment_result};
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_module::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!(
259                        "Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying..."
260                    );
261                }
262            }
263
264            sleep(Duration::from_secs(1)).await;
265        }
266    }
267
268    async fn transition_incoming_contract_funded(
269        result: Result<Preimage, IncomingSmError>,
270        old_state: IncomingStateMachine,
271        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
272        global_context: DynGlobalClientContext,
273        context: LightningClientContext,
274    ) -> IncomingStateMachine {
275        assert!(matches!(
276            old_state.state,
277            IncomingSmStates::DecryptingPreimage(_)
278        ));
279
280        match result {
281            Ok(preimage) => {
282                let contract_id = old_state.common.contract_id;
283                let payment_hash = old_state.common.payment_hash;
284                set_payment_result(
285                    &mut dbtx.module_tx(),
286                    payment_hash,
287                    PayType::Internal(old_state.common.operation_id),
288                    contract_id,
289                    Amount::from_msats(0),
290                )
291                .await;
292
293                IncomingStateMachine {
294                    common: old_state.common,
295                    state: IncomingSmStates::Preimage(preimage),
296                }
297            }
298            Err(IncomingSmError::InvalidPreimage { contract }) => {
299                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
300                    .await
301            }
302            Err(e) => IncomingStateMachine {
303                common: old_state.common,
304                state: IncomingSmStates::Failure(format!(
305                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
306                )),
307            },
308        }
309    }
310
311    async fn refund_incoming_contract(
312        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
313        global_context: DynGlobalClientContext,
314        context: LightningClientContext,
315        old_state: IncomingStateMachine,
316        contract: Box<IncomingContractAccount>,
317    ) -> IncomingStateMachine {
318        debug!("Refunding incoming contract {contract:?}");
319        let claim_input = contract.claim();
320        let client_input = ClientInput::<LightningInput> {
321            input: claim_input,
322            amount: contract.amount,
323            keys: vec![context.redeem_key],
324        };
325
326        let change_range = global_context
327            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
328            .await
329            .expect("Cannot claim input, additional funding needed");
330        debug!("Refunded incoming contract {contract:?} with {change_range:?}");
331
332        IncomingStateMachine {
333            common: old_state.common,
334            state: IncomingSmStates::RefundSubmitted {
335                out_points: change_range.into_iter().collect(),
336                error: IncomingSmError::InvalidPreimage { contract },
337            },
338        }
339    }
340}
341
342#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
343pub struct AwaitingPreimageDecryption {
344    txid: TransactionId,
345}
346
347#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
348pub struct PreimageState {
349    preimage: Preimage,
350}
351
352#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
353pub struct RefundSuccessState {
354    refund_txid: TransactionId,
355}