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