Skip to main content

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    DuplicatePayment,
97}
98
99#[cfg_attr(doc, aquamarine::aquamarine)]
100/// State machine that handles the relay of an incoming Lightning payment.
101///
102/// ```mermaid
103/// graph LR
104/// classDef virtual fill:#fff,stroke-dasharray: 5 5
105///
106///     Sending -- payment is successful --> Claiming
107///     Sending -- payment fails --> Cancelled
108/// ```
109impl State for SendStateMachine {
110    type ModuleContext = GatewayClientContextV2;
111
112    fn transitions(
113        &self,
114        context: &Self::ModuleContext,
115        global_context: &DynGlobalClientContext,
116    ) -> Vec<StateTransition<Self>> {
117        let gc = global_context.clone();
118        let gateway_context = context.clone();
119
120        match &self.state {
121            SendSMState::Sending => {
122                vec![StateTransition::new(
123                    Self::send_payment(
124                        context.clone(),
125                        self.common.max_delay,
126                        self.common.min_contract_amount,
127                        self.common.invoice.clone(),
128                        self.common.contract.clone(),
129                    ),
130                    move |dbtx, result, old_state| {
131                        Box::pin(Self::transition_send_payment(
132                            dbtx,
133                            old_state,
134                            gc.clone(),
135                            result,
136                            gateway_context.clone(),
137                        ))
138                    },
139                )]
140            }
141            SendSMState::Claiming(..) | SendSMState::Cancelled(..) => {
142                vec![]
143            }
144        }
145    }
146
147    fn operation_id(&self) -> OperationId {
148        self.common.operation_id
149    }
150}
151
152impl SendStateMachine {
153    async fn send_payment(
154        context: GatewayClientContextV2,
155        max_delay: u64,
156        min_contract_amount: Amount,
157        invoice: LightningInvoice,
158        contract: OutgoingContract,
159    ) -> Result<PaymentResponse, Cancelled> {
160        let LightningInvoice::Bolt11(invoice) = invoice;
161
162        // The following two checks may fail in edge cases since they have inherent
163        // timing assumptions. Therefore, they may only be checked after we have created
164        // the state machine such that we can cancel the contract.
165        if invoice.is_expired() {
166            return Err(Cancelled::InvoiceExpired);
167        }
168
169        if max_delay == 0 {
170            return Err(Cancelled::TimeoutTooClose);
171        }
172
173        let Some(max_fee) = contract.amount.checked_sub(min_contract_amount) else {
174            return Err(Cancelled::Underfunded);
175        };
176
177        // To make gateway operation easier, we check if the invoice was created using
178        // the LNv1 protocol and if the gateway supports the target federation.
179        // If it does, we can fund an LNv1 incoming contract to satisfy the LNv2
180        // outgoing payment.
181        if let Some(client) = context.gateway.is_lnv1_invoice(&invoice).await {
182            let final_state = context
183                .gateway
184                .relay_lnv1_swap(client.value(), &invoice)
185                .await;
186            return match final_state {
187                Ok(final_receive_state) => match final_receive_state {
188                    FinalReceiveState::Rejected => Err(Cancelled::Rejected),
189                    FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
190                        preimage,
191                        target_federation: Some(client.value().federation_id()),
192                    }),
193                    FinalReceiveState::Refunded => Err(Cancelled::Refunded),
194                    FinalReceiveState::Failure => Err(Cancelled::Failure),
195                },
196                Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
197            };
198        }
199
200        match context
201            .gateway
202            .is_direct_swap(&invoice)
203            .await
204            .map_err(|e| Cancelled::RegistrationError(e.to_string()))?
205        {
206            Some((contract, client)) => {
207                match client
208                    .get_first_module::<GatewayClientModuleV2>()
209                    .expect("Must have client module")
210                    .relay_direct_swap(
211                        contract,
212                        invoice
213                            .amount_milli_satoshis()
214                            .expect("amountless invoices are not supported"),
215                    )
216                    .await
217                {
218                    Ok(final_receive_state) => match final_receive_state {
219                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
220                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
221                            preimage,
222                            target_federation: Some(client.federation_id()),
223                        }),
224                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
225                        FinalReceiveState::Failure => Err(Cancelled::Failure),
226                    },
227                    Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
228                }
229            }
230            None => {
231                let preimage = context
232                    .gateway
233                    .pay(invoice, max_delay, max_fee)
234                    .await
235                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
236                Ok(PaymentResponse {
237                    preimage,
238                    target_federation: None,
239                })
240            }
241        }
242    }
243
244    async fn transition_send_payment(
245        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
246        old_state: SendStateMachine,
247        global_context: DynGlobalClientContext,
248        result: Result<PaymentResponse, Cancelled>,
249        client_ctx: GatewayClientContextV2,
250    ) -> SendStateMachine {
251        match result {
252            Ok(payment_response) => {
253                // A single Lightning payment yields a single preimage, so the gateway
254                // must claim at most one outgoing contract per payment image. If another
255                // contract for the same invoice has already been claimed, e.g. two
256                // clients racing to pay it, forfeit this one so the sender is refunded
257                // rather than the gateway being reimbursed twice for a single payment.
258                // The claim is recorded in the gateway's global database so it spans all
259                // of the gateway's federations, not just the one funding this contract.
260                if !client_ctx
261                    .gateway
262                    .claim_payment_image(
263                        &old_state.common.contract.payment_image,
264                        old_state.common.operation_id,
265                    )
266                    .await
267                {
268                    client_ctx
269                        .module
270                        .client_ctx
271                        .log_event(
272                            &mut dbtx.module_tx(),
273                            OutgoingPaymentFailed {
274                                payment_image: old_state.common.contract.payment_image.clone(),
275                                error: Cancelled::DuplicatePayment,
276                            },
277                        )
278                        .await;
279
280                    return old_state.update(SendSMState::Cancelled(Cancelled::DuplicatePayment));
281                }
282
283                client_ctx
284                    .module
285                    .client_ctx
286                    .log_event(
287                        &mut dbtx.module_tx(),
288                        OutgoingPaymentSucceeded {
289                            payment_image: old_state.common.contract.payment_image.clone(),
290                            target_federation: payment_response.target_federation,
291                        },
292                    )
293                    .await;
294                let client_input = ClientInput::<LightningInput> {
295                    input: LightningInput::V0(LightningInputV0::Outgoing(
296                        old_state.common.outpoint,
297                        OutgoingWitness::Claim(payment_response.preimage),
298                    )),
299                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
300                    keys: vec![old_state.common.claim_keypair],
301                };
302
303                let outpoints = global_context
304                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
305                    .await
306                    .expect("Cannot claim input, additional funding needed")
307                    .into_iter()
308                    .collect();
309
310                old_state.update(SendSMState::Claiming(Claiming {
311                    preimage: payment_response.preimage,
312                    outpoints,
313                }))
314            }
315            Err(e) => {
316                client_ctx
317                    .module
318                    .client_ctx
319                    .log_event(
320                        &mut dbtx.module_tx(),
321                        OutgoingPaymentFailed {
322                            payment_image: old_state.common.contract.payment_image.clone(),
323                            error: e.clone(),
324                        },
325                    )
326                    .await;
327                old_state.update(SendSMState::Cancelled(e))
328            }
329        }
330    }
331}