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