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        // `max_delay` is computed once when the state machine is created and
163        // persisted, so this check cannot drift across a restart. It guards
164        // every rail, not just Lightning: after paying out, a swap races the
165        // outgoing contract's expiration to claim it, the same race an HTLC
166        // runs against the contract timelock.
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        // Invoice expiry, by contrast, reads the wall clock, and this state
176        // machine re-runs from scratch on every restart: the invoice can
177        // expire while a payment dispatched before a crash is still in
178        // flight, and that payment settles or fails regardless of invoice
179        // expiry. Cancelling on expiry alone would forfeit a contract the
180        // in-flight payment can still claim. Each rail below therefore
181        // resumes anything it already started unconditionally and consults
182        // this verdict only before dispatching fresh.
183        let allow_fresh_dispatch = !invoice.is_expired();
184
185        // To make gateway operation easier, we check if the invoice was created using
186        // the LNv1 protocol and if the gateway supports the target federation.
187        // If it does, we can fund an LNv1 incoming contract to satisfy the LNv2
188        // outgoing payment.
189        if let Some(client) = context.gateway.is_lnv1_invoice(&invoice).await {
190            let final_state = context
191                .gateway
192                .relay_lnv1_swap(client.value(), &invoice, allow_fresh_dispatch)
193                .await;
194            return match final_state {
195                Ok(Some(final_receive_state)) => match final_receive_state {
196                    FinalReceiveState::Rejected => Err(Cancelled::Rejected),
197                    FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
198                        preimage,
199                        target_federation: Some(client.value().federation_id()),
200                    }),
201                    FinalReceiveState::Refunded => Err(Cancelled::Refunded),
202                    FinalReceiveState::Failure => Err(Cancelled::Failure),
203                },
204                Ok(None) => Err(Cancelled::InvoiceExpired),
205                Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
206            };
207        }
208
209        match context
210            .gateway
211            .is_direct_swap(&invoice)
212            .await
213            .map_err(|e| Cancelled::RegistrationError(e.to_string()))?
214        {
215            Some((contract, client)) => {
216                match client
217                    .get_first_module::<GatewayClientModuleV2>()
218                    .expect("Must have client module")
219                    .relay_direct_swap(
220                        contract,
221                        invoice
222                            .amount_milli_satoshis()
223                            .expect("amountless invoices are not supported"),
224                        allow_fresh_dispatch,
225                    )
226                    .await
227                {
228                    Ok(Some(final_receive_state)) => match final_receive_state {
229                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
230                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
231                            preimage,
232                            target_federation: Some(client.federation_id()),
233                        }),
234                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
235                        FinalReceiveState::Failure => Err(Cancelled::Failure),
236                    },
237                    Ok(None) => Err(Cancelled::InvoiceExpired),
238                    Err(e) => Err(Cancelled::FinalizationError(e.to_string())),
239                }
240            }
241            None => {
242                // A payment the node already knows resolves through `pay`'s
243                // idempotent resume path, so expiry only refuses a dispatch
244                // that never happened.
245                if !allow_fresh_dispatch
246                    && !context
247                        .gateway
248                        .outbound_payment_exists(*invoice.payment_hash())
249                        .await
250                {
251                    return Err(Cancelled::InvoiceExpired);
252                }
253
254                let preimage = context
255                    .gateway
256                    .pay(invoice, max_delay, max_fee)
257                    .await
258                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
259                Ok(PaymentResponse {
260                    preimage,
261                    target_federation: None,
262                })
263            }
264        }
265    }
266
267    async fn transition_send_payment(
268        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
269        old_state: SendStateMachine,
270        global_context: DynGlobalClientContext,
271        result: Result<PaymentResponse, Cancelled>,
272        client_ctx: GatewayClientContextV2,
273    ) -> SendStateMachine {
274        match result {
275            Ok(payment_response) => {
276                // A single Lightning payment yields a single preimage, so the gateway
277                // must claim at most one outgoing contract per payment image. If another
278                // contract for the same invoice has already been claimed, e.g. two
279                // clients racing to pay it, forfeit this one so the sender is refunded
280                // rather than the gateway being reimbursed twice for a single payment.
281                // The claim is recorded in the gateway's global database so it spans all
282                // of the gateway's federations, not just the one funding this contract.
283                if !client_ctx
284                    .gateway
285                    .claim_payment_image(
286                        &old_state.common.contract.payment_image,
287                        old_state.common.operation_id,
288                    )
289                    .await
290                {
291                    client_ctx
292                        .module
293                        .client_ctx
294                        .log_event(
295                            &mut dbtx.module_tx(),
296                            OutgoingPaymentFailed {
297                                payment_image: old_state.common.contract.payment_image.clone(),
298                                error: Cancelled::DuplicatePayment,
299                            },
300                        )
301                        .await;
302
303                    return old_state.update(SendSMState::Cancelled(Cancelled::DuplicatePayment));
304                }
305
306                client_ctx
307                    .module
308                    .client_ctx
309                    .log_event(
310                        &mut dbtx.module_tx(),
311                        OutgoingPaymentSucceeded {
312                            payment_image: old_state.common.contract.payment_image.clone(),
313                            target_federation: payment_response.target_federation,
314                        },
315                    )
316                    .await;
317                let client_input = ClientInput::<LightningInput> {
318                    input: LightningInput::V0(LightningInputV0::Outgoing(
319                        old_state.common.outpoint,
320                        OutgoingWitness::Claim(payment_response.preimage),
321                    )),
322                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
323                    keys: vec![old_state.common.claim_keypair],
324                };
325
326                let outpoints = global_context
327                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
328                    .await
329                    .expect("Cannot claim input, additional funding needed")
330                    .into_iter()
331                    .collect();
332
333                old_state.update(SendSMState::Claiming(Claiming {
334                    preimage: payment_response.preimage,
335                    outpoints,
336                }))
337            }
338            Err(e) => {
339                client_ctx
340                    .module
341                    .client_ctx
342                    .log_event(
343                        &mut dbtx.module_tx(),
344                        OutgoingPaymentFailed {
345                            payment_image: old_state.common.contract.payment_image.clone(),
346                            error: e.clone(),
347                        },
348                    )
349                    .await;
350                old_state.update(SendSMState::Cancelled(e))
351            }
352        }
353    }
354}