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#[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")]
128#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
129#[cfg_attr(feature = "uniffi", uniffi(flat_error))]
130pub enum IncomingSmError {
131    #[error("Violated fee policy. Offer amount {offer_amount} Payment amount: {payment_amount}")]
132    ViolatedFeePolicy {
133        offer_amount: Amount,
134        payment_amount: Amount,
135    },
136    #[error("Invalid offer. Offer hash: {offer_hash} Payment hash: {payment_hash}")]
137    InvalidOffer {
138        offer_hash: sha256::Hash,
139        payment_hash: sha256::Hash,
140    },
141    #[error("Timed out fetching the offer")]
142    TimeoutFetchingOffer { payment_hash: sha256::Hash },
143    #[error("Error fetching the contract {payment_hash}. Error: {error_message}")]
144    FetchContractError {
145        payment_hash: sha256::Hash,
146        error_message: String,
147    },
148    #[error("Invalid preimage. Contract: {contract:?}")]
149    InvalidPreimage {
150        contract: Box<IncomingContractAccount>,
151    },
152    #[error("There was a failure when funding the contract: {error_message}")]
153    FailedToFundContract { error_message: String },
154    #[error("Failed to parse the amount from the invoice: {invoice}")]
155    AmountError { invoice: Bolt11Invoice },
156    // New variants have to be appended: the `Encodable` derive assigns indices by
157    // declaration order and this type is persisted inside `RefundSubmitted` and
158    // `FundingFailed` states, so inserting one mid-enum makes existing client
159    // databases fail to decode.
160    #[error(
161        "A contract already exists for payment hash {payment_hash}, funding it again would credit the existing account"
162    )]
163    ContractAlreadyExists { payment_hash: sha256::Hash },
164}
165
166#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
167pub struct FundingOfferState {
168    pub txid: TransactionId,
169}
170
171impl FundingOfferState {
172    fn transitions(
173        &self,
174        global_context: &DynGlobalClientContext,
175    ) -> Vec<StateTransition<IncomingStateMachine>> {
176        let txid = self.txid;
177        vec![StateTransition::new(
178            Self::await_funding_success(global_context.clone(), txid),
179            |_dbtx, result, old_state| {
180                Box::pin(async { Self::transition_funding_success(result, old_state) })
181            },
182        )]
183    }
184
185    async fn await_funding_success(
186        global_context: DynGlobalClientContext,
187        txid: TransactionId,
188    ) -> Result<(), IncomingSmError> {
189        global_context
190            .await_tx_accepted(txid)
191            .await
192            .map_err(|error_message| IncomingSmError::FailedToFundContract { error_message })
193    }
194
195    fn transition_funding_success(
196        result: Result<(), IncomingSmError>,
197        old_state: IncomingStateMachine,
198    ) -> IncomingStateMachine {
199        let txid = match old_state.state {
200            IncomingSmStates::FundingOffer(refund) => refund.txid,
201            _ => panic!("Invalid state transition"),
202        };
203
204        match result {
205            Ok(()) => IncomingStateMachine {
206                common: old_state.common,
207                state: IncomingSmStates::DecryptingPreimage(DecryptingPreimageState { txid }),
208            },
209            Err(error) => IncomingStateMachine {
210                common: old_state.common,
211                state: IncomingSmStates::FundingFailed { error },
212            },
213        }
214    }
215}
216
217#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
218pub struct DecryptingPreimageState {
219    txid: TransactionId,
220}
221
222impl DecryptingPreimageState {
223    fn transitions(
224        common: &IncomingSmCommon,
225        global_context: &DynGlobalClientContext,
226        context: &LightningClientContext,
227    ) -> Vec<StateTransition<IncomingStateMachine>> {
228        let success_context = global_context.clone();
229        let gateway_context = context.clone();
230
231        vec![StateTransition::new(
232            Self::await_preimage_decryption(success_context.clone(), common.contract_id),
233            move |dbtx, result, old_state| {
234                let gateway_context = gateway_context.clone();
235                let success_context = success_context.clone();
236                Box::pin(Self::transition_incoming_contract_funded(
237                    result,
238                    old_state,
239                    dbtx,
240                    success_context,
241                    gateway_context,
242                ))
243            },
244        )]
245    }
246
247    async fn await_preimage_decryption(
248        global_context: DynGlobalClientContext,
249        contract_id: ContractId,
250    ) -> Result<Preimage, IncomingSmError> {
251        loop {
252            debug!("Awaiting preimage decryption for contract {contract_id:?}");
253            match global_context
254                .module_api()
255                .wait_preimage_decrypted(contract_id)
256                .await
257            {
258                Ok((incoming_contract_account, preimage)) => {
259                    if let Some(preimage) = preimage {
260                        debug!("Preimage decrypted for contract {contract_id:?}");
261                        return Ok(preimage);
262                    }
263
264                    info!("Invalid preimage for contract {contract_id:?}");
265                    return Err(IncomingSmError::InvalidPreimage {
266                        contract: Box::new(incoming_contract_account),
267                    });
268                }
269                Err(error) => {
270                    warn!(
271                        "Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying..."
272                    );
273                }
274            }
275
276            sleep(Duration::from_secs(1)).await;
277        }
278    }
279
280    async fn transition_incoming_contract_funded(
281        result: Result<Preimage, IncomingSmError>,
282        old_state: IncomingStateMachine,
283        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
284        global_context: DynGlobalClientContext,
285        context: LightningClientContext,
286    ) -> IncomingStateMachine {
287        assert_matches!(old_state.state, IncomingSmStates::DecryptingPreimage(_));
288
289        match result {
290            Ok(preimage) => {
291                let contract_id = old_state.common.contract_id;
292                let payment_hash = old_state.common.payment_hash;
293                set_payment_result(
294                    &mut dbtx.module_tx(),
295                    payment_hash,
296                    PayType::Internal(old_state.common.operation_id),
297                    contract_id,
298                    Amount::from_msats(0),
299                )
300                .await;
301
302                // client_ctx is None for the gateway since it does not emit the client events
303                if let Some(ref client_ctx) = context.client_ctx {
304                    client_ctx
305                        .log_event(
306                            &mut dbtx.module_tx(),
307                            crate::events::SendPaymentUpdateEvent {
308                                operation_id: old_state.common.operation_id,
309                                status: crate::events::SendPaymentStatus::Success(preimage.0),
310                            },
311                        )
312                        .await;
313                }
314
315                IncomingStateMachine {
316                    common: old_state.common,
317                    state: IncomingSmStates::Preimage(preimage),
318                }
319            }
320            Err(IncomingSmError::InvalidPreimage { contract }) => {
321                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
322                    .await
323            }
324            Err(e) => IncomingStateMachine {
325                common: old_state.common,
326                state: IncomingSmStates::Failure(format!(
327                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
328                )),
329            },
330        }
331    }
332
333    async fn refund_incoming_contract(
334        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
335        global_context: DynGlobalClientContext,
336        context: LightningClientContext,
337        old_state: IncomingStateMachine,
338        contract: Box<IncomingContractAccount>,
339    ) -> IncomingStateMachine {
340        debug!("Refunding incoming contract {contract:?}");
341        let claim_input = contract.claim();
342        let client_input = ClientInput::<LightningInput> {
343            input: claim_input,
344            amounts: Amounts::new_bitcoin(contract.amount),
345            keys: vec![context.redeem_key],
346        };
347
348        let change_range = global_context
349            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
350            .await
351            .expect("Cannot claim input, additional funding needed");
352        debug!("Refunded incoming contract {contract:?} with {change_range:?}");
353
354        // client_ctx is None for the gateway since it does not emit the client events
355        if let Some(ref client_ctx) = context.client_ctx {
356            client_ctx
357                .log_event(
358                    &mut dbtx.module_tx(),
359                    crate::events::SendPaymentUpdateEvent {
360                        operation_id: old_state.common.operation_id,
361                        status: crate::events::SendPaymentStatus::Refunded,
362                    },
363                )
364                .await;
365        }
366
367        IncomingStateMachine {
368            common: old_state.common,
369            state: IncomingSmStates::RefundSubmitted {
370                out_points: change_range.into_iter().collect(),
371                error: IncomingSmError::InvalidPreimage { contract },
372            },
373        }
374    }
375}
376
377#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
378pub struct AwaitingPreimageDecryption {
379    txid: TransactionId,
380}
381
382#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
383pub struct PreimageState {
384    preimage: Preimage,
385}
386
387#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
388pub struct RefundSuccessState {
389    refund_txid: TransactionId,
390}