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, LightningOutputOutcome};
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, 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        context: &LightningClientContext,
164    ) -> Vec<StateTransition<IncomingStateMachine>> {
165        let txid = self.txid;
166        vec![StateTransition::new(
167            Self::await_funding_success(
168                global_context.clone(),
169                OutPoint { txid, out_idx: 0 },
170                context.clone(),
171            ),
172            |_dbtx, result, old_state| {
173                Box::pin(async { Self::transition_funding_success(result, old_state) })
174            },
175        )]
176    }
177
178    async fn await_funding_success(
179        global_context: DynGlobalClientContext,
180        out_point: OutPoint,
181        context: LightningClientContext,
182    ) -> Result<(), IncomingSmError> {
183        debug!("Awaiting funding success for outpoint: {out_point:?}");
184        for retry in 0.. {
185            let sleep = (retry * 15).min(90);
186            match global_context
187                .api()
188                .await_output_outcome::<LightningOutputOutcome>(
189                    out_point,
190                    Duration::from_secs(90),
191                    &context.ln_decoder,
192                )
193                .await
194            {
195                Ok(_) => {
196                    debug!("Funding success for outpoint: {out_point:?}");
197                    return Ok(());
198                }
199                Err(e) if e.is_rejected() => {
200                    warn!("Funding failed for outpoint: {out_point:?}: {e:?}");
201                    return Err(IncomingSmError::FailedToFundContract {
202                        error_message: e.to_string(),
203                    });
204                }
205                Err(e) => {
206                    e.report_if_important();
207                    debug!(error = %e, "Awaiting output outcome failed, retrying in {sleep}s",);
208                }
209            }
210            // give some time for other things to run
211            fedimint_core::runtime::sleep(Duration::from_secs(sleep)).await;
212        }
213
214        unreachable!("there is too many u64s to ever get here")
215    }
216
217    fn transition_funding_success(
218        result: Result<(), IncomingSmError>,
219        old_state: IncomingStateMachine,
220    ) -> IncomingStateMachine {
221        let txid = match old_state.state {
222            IncomingSmStates::FundingOffer(refund) => refund.txid,
223            _ => panic!("Invalid state transition"),
224        };
225
226        match result {
227            Ok(()) => IncomingStateMachine {
228                common: old_state.common,
229                state: IncomingSmStates::DecryptingPreimage(DecryptingPreimageState { txid }),
230            },
231            Err(error) => IncomingStateMachine {
232                common: old_state.common,
233                state: IncomingSmStates::FundingFailed { error },
234            },
235        }
236    }
237}
238
239#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
240pub struct DecryptingPreimageState {
241    txid: TransactionId,
242}
243
244impl DecryptingPreimageState {
245    fn transitions(
246        common: &IncomingSmCommon,
247        global_context: &DynGlobalClientContext,
248        context: &LightningClientContext,
249    ) -> Vec<StateTransition<IncomingStateMachine>> {
250        let success_context = global_context.clone();
251        let gateway_context = context.clone();
252
253        vec![StateTransition::new(
254            Self::await_preimage_decryption(success_context.clone(), common.contract_id),
255            move |dbtx, result, old_state| {
256                let gateway_context = gateway_context.clone();
257                let success_context = success_context.clone();
258                Box::pin(Self::transition_incoming_contract_funded(
259                    result,
260                    old_state,
261                    dbtx,
262                    success_context,
263                    gateway_context,
264                ))
265            },
266        )]
267    }
268
269    async fn await_preimage_decryption(
270        global_context: DynGlobalClientContext,
271        contract_id: ContractId,
272    ) -> Result<Preimage, IncomingSmError> {
273        loop {
274            debug!("Awaiting preimage decryption for contract {contract_id:?}");
275            match global_context
276                .module_api()
277                .wait_preimage_decrypted(contract_id)
278                .await
279            {
280                Ok((incoming_contract_account, preimage)) => {
281                    if let Some(preimage) = preimage {
282                        debug!("Preimage decrypted for contract {contract_id:?}");
283                        return Ok(preimage);
284                    }
285
286                    info!("Invalid preimage for contract {contract_id:?}");
287                    return Err(IncomingSmError::InvalidPreimage {
288                        contract: Box::new(incoming_contract_account),
289                    });
290                }
291                Err(error) => {
292                    warn!("Incoming contract {contract_id:?} error waiting for preimage decryption: {error:?}, will keep retrying...");
293                }
294            }
295
296            sleep(Duration::from_secs(1)).await;
297        }
298    }
299
300    async fn transition_incoming_contract_funded(
301        result: Result<Preimage, IncomingSmError>,
302        old_state: IncomingStateMachine,
303        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
304        global_context: DynGlobalClientContext,
305        context: LightningClientContext,
306    ) -> IncomingStateMachine {
307        assert!(matches!(
308            old_state.state,
309            IncomingSmStates::DecryptingPreimage(_)
310        ));
311
312        match result {
313            Ok(preimage) => {
314                let contract_id = old_state.common.contract_id;
315                let payment_hash = old_state.common.payment_hash;
316                set_payment_result(
317                    &mut dbtx.module_tx(),
318                    payment_hash,
319                    PayType::Internal(old_state.common.operation_id),
320                    contract_id,
321                    Amount::from_msats(0),
322                )
323                .await;
324
325                IncomingStateMachine {
326                    common: old_state.common,
327                    state: IncomingSmStates::Preimage(preimage),
328                }
329            }
330            Err(IncomingSmError::InvalidPreimage { contract }) => {
331                Self::refund_incoming_contract(dbtx, global_context, context, old_state, contract)
332                    .await
333            }
334            Err(e) => IncomingStateMachine {
335                common: old_state.common,
336                state: IncomingSmStates::Failure(format!(
337                    "Unexpected internal error occurred while decrypting the preimage: {e:?}"
338                )),
339            },
340        }
341    }
342
343    async fn refund_incoming_contract(
344        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
345        global_context: DynGlobalClientContext,
346        context: LightningClientContext,
347        old_state: IncomingStateMachine,
348        contract: Box<IncomingContractAccount>,
349    ) -> IncomingStateMachine {
350        debug!("Refunding incoming contract {contract:?}");
351        let claim_input = contract.claim();
352        let client_input = ClientInput::<LightningInput> {
353            input: claim_input,
354            amount: contract.amount,
355            keys: vec![context.redeem_key],
356        };
357
358        let out_points = global_context
359            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
360            .await
361            .expect("Cannot claim input, additional funding needed")
362            .1;
363        debug!("Refunded incoming contract {contract:?} with {out_points:?}");
364
365        IncomingStateMachine {
366            common: old_state.common,
367            state: IncomingSmStates::RefundSubmitted {
368                out_points,
369                error: IncomingSmError::InvalidPreimage { contract },
370            },
371        }
372    }
373}
374
375#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
376pub struct AwaitingPreimageDecryption {
377    txid: TransactionId,
378}
379
380#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
381pub struct PreimageState {
382    preimage: Preimage,
383}
384
385#[derive(Debug, Clone, Eq, PartialEq, Decodable, Encodable)]
386pub struct RefundSuccessState {
387    refund_txid: TransactionId,
388}