fedimint_gwv2_client/
send_sm.rs

1use std::fmt;
2
3use fedimint_client_module::DynGlobalClientContext;
4use fedimint_client_module::sm::{ClientSMDatabaseTransaction, State, StateTransition};
5use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
6use fedimint_core::config::FederationId;
7use fedimint_core::core::OperationId;
8use fedimint_core::encoding::{Decodable, Encodable};
9use fedimint_core::secp256k1::Keypair;
10use fedimint_core::{Amount, OutPoint};
11use fedimint_lnv2_common::contracts::OutgoingContract;
12use fedimint_lnv2_common::{LightningInput, LightningInputV0, LightningInvoice, OutgoingWitness};
13use serde::{Deserialize, Serialize};
14
15use super::FinalReceiveState;
16use super::events::{OutgoingPaymentFailed, OutgoingPaymentSucceeded};
17use crate::{GatewayClientContextV2, GatewayClientModuleV2};
18
19#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
20pub struct SendStateMachine {
21    pub common: SendSMCommon,
22    pub state: SendSMState,
23}
24
25impl SendStateMachine {
26    pub fn update(&self, state: SendSMState) -> Self {
27        Self {
28            common: self.common.clone(),
29            state,
30        }
31    }
32}
33
34impl fmt::Display for SendStateMachine {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(
37            f,
38            "Send State Machine Operation ID: {:?} State: {}",
39            self.common.operation_id, self.state
40        )
41    }
42}
43
44#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
45pub struct SendSMCommon {
46    pub operation_id: OperationId,
47    pub outpoint: OutPoint,
48    pub contract: OutgoingContract,
49    pub max_delay: u64,
50    pub min_contract_amount: Amount,
51    pub invoice: LightningInvoice,
52    pub claim_keypair: Keypair,
53}
54
55#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
56pub enum SendSMState {
57    Sending,
58    Claiming(Claiming),
59    Cancelled(Cancelled),
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct PaymentResponse {
64    preimage: [u8; 32],
65    target_federation: Option<FederationId>,
66}
67
68impl fmt::Display for SendSMState {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            SendSMState::Sending => write!(f, "Sending"),
72            SendSMState::Claiming(_) => write!(f, "Claiming"),
73            SendSMState::Cancelled(_) => write!(f, "Cancelled"),
74        }
75    }
76}
77
78#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
79pub struct Claiming {
80    pub preimage: [u8; 32],
81    pub outpoints: Vec<OutPoint>,
82}
83
84#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable, Serialize, Deserialize)]
85pub enum Cancelled {
86    InvoiceExpired,
87    TimeoutTooClose,
88    Underfunded,
89    RegistrationError(String),
90    FinalizationError(String),
91    Rejected,
92    Refunded,
93    Failure,
94    LightningRpcError(String),
95}
96
97#[cfg_attr(doc, aquamarine::aquamarine)]
98/// State machine that handles the relay of an incoming Lightning payment.
99///
100/// ```mermaid
101/// graph LR
102/// classDef virtual fill:#fff,stroke-dasharray: 5 5
103///
104///     Sending -- payment is successful --> Claiming
105///     Sending -- payment fails --> Cancelled
106/// ```
107impl State for SendStateMachine {
108    type ModuleContext = GatewayClientContextV2;
109
110    fn transitions(
111        &self,
112        context: &Self::ModuleContext,
113        global_context: &DynGlobalClientContext,
114    ) -> Vec<StateTransition<Self>> {
115        let gc = global_context.clone();
116        let gateway_context = context.clone();
117
118        match &self.state {
119            SendSMState::Sending => {
120                vec![StateTransition::new(
121                    Self::send_payment(
122                        context.clone(),
123                        self.common.max_delay,
124                        self.common.min_contract_amount,
125                        self.common.invoice.clone(),
126                        self.common.contract.clone(),
127                    ),
128                    move |dbtx, result, old_state| {
129                        Box::pin(Self::transition_send_payment(
130                            dbtx,
131                            old_state,
132                            gc.clone(),
133                            result,
134                            gateway_context.clone(),
135                        ))
136                    },
137                )]
138            }
139            SendSMState::Claiming(..) | SendSMState::Cancelled(..) => {
140                vec![]
141            }
142        }
143    }
144
145    fn operation_id(&self) -> OperationId {
146        self.common.operation_id
147    }
148}
149
150impl SendStateMachine {
151    async fn send_payment(
152        context: GatewayClientContextV2,
153        max_delay: u64,
154        min_contract_amount: Amount,
155        invoice: LightningInvoice,
156        contract: OutgoingContract,
157    ) -> Result<PaymentResponse, Cancelled> {
158        let LightningInvoice::Bolt11(invoice) = invoice;
159
160        // The following two checks may fail in edge cases since they have inherent
161        // timing assumptions. Therefore, they may only be checked after we have created
162        // the state machine such that we can cancel the contract.
163        if invoice.is_expired() {
164            return Err(Cancelled::InvoiceExpired);
165        }
166
167        if max_delay == 0 {
168            return Err(Cancelled::TimeoutTooClose);
169        }
170
171        let Some(max_fee) = contract.amount.checked_sub(min_contract_amount) else {
172            return Err(Cancelled::Underfunded);
173        };
174
175        match context
176            .gateway
177            .is_direct_swap(&invoice)
178            .await
179            .map_err(|e| Cancelled::RegistrationError(e.to_string()))?
180        {
181            Some((contract, client)) => {
182                match client
183                    .get_first_module::<GatewayClientModuleV2>()
184                    .expect("Must have client module")
185                    .relay_direct_swap(
186                        contract,
187                        invoice
188                            .amount_milli_satoshis()
189                            .expect("amountless invoices are not supported"),
190                    )
191                    .await
192                {
193                    Ok(final_receive_state) => match final_receive_state {
194                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
195                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
196                            preimage,
197                            target_federation: Some(client.federation_id()),
198                        }),
199                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
200                        FinalReceiveState::Failure => Err(Cancelled::Failure),
201                    },
202                    Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
203                }
204            }
205            None => {
206                let preimage = context
207                    .gateway
208                    .pay(invoice, max_delay, max_fee)
209                    .await
210                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
211                Ok(PaymentResponse {
212                    preimage,
213                    target_federation: None,
214                })
215            }
216        }
217    }
218
219    async fn transition_send_payment(
220        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
221        old_state: SendStateMachine,
222        global_context: DynGlobalClientContext,
223        result: Result<PaymentResponse, Cancelled>,
224        client_ctx: GatewayClientContextV2,
225    ) -> SendStateMachine {
226        match result {
227            Ok(payment_response) => {
228                client_ctx
229                    .module
230                    .client_ctx
231                    .log_event(
232                        &mut dbtx.module_tx(),
233                        OutgoingPaymentSucceeded {
234                            payment_image: old_state.common.contract.payment_image.clone(),
235                            target_federation: payment_response.target_federation,
236                        },
237                    )
238                    .await;
239                let client_input = ClientInput::<LightningInput> {
240                    input: LightningInput::V0(LightningInputV0::Outgoing(
241                        old_state.common.outpoint,
242                        OutgoingWitness::Claim(payment_response.preimage),
243                    )),
244                    amount: old_state.common.contract.amount,
245                    keys: vec![old_state.common.claim_keypair],
246                };
247
248                let outpoints = global_context
249                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
250                    .await
251                    .expect("Cannot claim input, additional funding needed")
252                    .into_iter()
253                    .collect();
254
255                old_state.update(SendSMState::Claiming(Claiming {
256                    preimage: payment_response.preimage,
257                    outpoints,
258                }))
259            }
260            Err(e) => {
261                client_ctx
262                    .module
263                    .client_ctx
264                    .log_event(
265                        &mut dbtx.module_tx(),
266                        OutgoingPaymentFailed {
267                            payment_image: old_state.common.contract.payment_image.clone(),
268                            error: e.clone(),
269                        },
270                    )
271                    .await;
272                old_state.update(SendSMState::Cancelled(e))
273            }
274        }
275    }
276}