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        // To make gateway operation easier, we check if the invoice was created using
176        // the LNv1 protocol and if the gateway supports the target federation.
177        // If it does, we can fund an LNv1 incoming contract to satisfy the LNv2
178        // outgoing payment.
179        if let Some(client) = context.gateway.is_lnv1_invoice(&invoice).await {
180            let final_state = context
181                .gateway
182                .relay_lnv1_swap(client.value(), &invoice)
183                .await;
184            return match final_state {
185                Ok(final_receive_state) => match final_receive_state {
186                    FinalReceiveState::Rejected => Err(Cancelled::Rejected),
187                    FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
188                        preimage,
189                        target_federation: Some(client.value().federation_id()),
190                    }),
191                    FinalReceiveState::Refunded => Err(Cancelled::Refunded),
192                    FinalReceiveState::Failure => Err(Cancelled::Failure),
193                },
194                Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
195            };
196        }
197
198        match context
199            .gateway
200            .is_direct_swap(&invoice)
201            .await
202            .map_err(|e| Cancelled::RegistrationError(e.to_string()))?
203        {
204            Some((contract, client)) => {
205                match client
206                    .get_first_module::<GatewayClientModuleV2>()
207                    .expect("Must have client module")
208                    .relay_direct_swap(
209                        contract,
210                        invoice
211                            .amount_milli_satoshis()
212                            .expect("amountless invoices are not supported"),
213                    )
214                    .await
215                {
216                    Ok(final_receive_state) => match final_receive_state {
217                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
218                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
219                            preimage,
220                            target_federation: Some(client.federation_id()),
221                        }),
222                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
223                        FinalReceiveState::Failure => Err(Cancelled::Failure),
224                    },
225                    Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
226                }
227            }
228            None => {
229                let preimage = context
230                    .gateway
231                    .pay(invoice, max_delay, max_fee)
232                    .await
233                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
234                Ok(PaymentResponse {
235                    preimage,
236                    target_federation: None,
237                })
238            }
239        }
240    }
241
242    async fn transition_send_payment(
243        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
244        old_state: SendStateMachine,
245        global_context: DynGlobalClientContext,
246        result: Result<PaymentResponse, Cancelled>,
247        client_ctx: GatewayClientContextV2,
248    ) -> SendStateMachine {
249        match result {
250            Ok(payment_response) => {
251                client_ctx
252                    .module
253                    .client_ctx
254                    .log_event(
255                        &mut dbtx.module_tx(),
256                        OutgoingPaymentSucceeded {
257                            payment_image: old_state.common.contract.payment_image.clone(),
258                            target_federation: payment_response.target_federation,
259                        },
260                    )
261                    .await;
262                let client_input = ClientInput::<LightningInput> {
263                    input: LightningInput::V0(LightningInputV0::Outgoing(
264                        old_state.common.outpoint,
265                        OutgoingWitness::Claim(payment_response.preimage),
266                    )),
267                    amount: old_state.common.contract.amount,
268                    keys: vec![old_state.common.claim_keypair],
269                };
270
271                let outpoints = global_context
272                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
273                    .await
274                    .expect("Cannot claim input, additional funding needed")
275                    .into_iter()
276                    .collect();
277
278                old_state.update(SendSMState::Claiming(Claiming {
279                    preimage: payment_response.preimage,
280                    outpoints,
281                }))
282            }
283            Err(e) => {
284                client_ctx
285                    .module
286                    .client_ctx
287                    .log_event(
288                        &mut dbtx.module_tx(),
289                        OutgoingPaymentFailed {
290                            payment_image: old_state.common.contract.payment_image.clone(),
291                            error: e.clone(),
292                        },
293                    )
294                    .await;
295                old_state.update(SendSMState::Cancelled(e))
296            }
297        }
298    }
299}