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::util::FmtCompact as _;
12use fedimint_core::{Amount, OutPoint};
13use fedimint_lnv2_common::contracts::OutgoingContract;
14use fedimint_lnv2_common::{LightningInput, LightningInputV0, LightningInvoice, OutgoingWitness};
15use serde::{Deserialize, Serialize};
16
17use super::FinalReceiveState;
18use super::events::{OutgoingPaymentFailed, OutgoingPaymentSucceeded};
19use crate::{GatewayClientContextV2, GatewayClientModuleV2};
20
21#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
22pub struct SendStateMachine {
23    pub common: SendSMCommon,
24    pub state: SendSMState,
25}
26
27impl SendStateMachine {
28    pub fn update(&self, state: SendSMState) -> Self {
29        Self {
30            common: self.common.clone(),
31            state,
32        }
33    }
34}
35
36impl fmt::Display for SendStateMachine {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(
39            f,
40            "Send State Machine Operation ID: {:?} State: {}",
41            self.common.operation_id, self.state
42        )
43    }
44}
45
46#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
47pub struct SendSMCommon {
48    pub operation_id: OperationId,
49    pub outpoint: OutPoint,
50    pub contract: OutgoingContract,
51    pub max_delay: u64,
52    pub min_contract_amount: Amount,
53    pub invoice: LightningInvoice,
54    pub claim_keypair: Keypair,
55}
56
57#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
58pub enum SendSMState {
59    Sending,
60    Claiming(Claiming),
61    Cancelled(Cancelled),
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct PaymentResponse {
66    preimage: [u8; 32],
67    target_federation: Option<FederationId>,
68}
69
70impl fmt::Display for SendSMState {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            SendSMState::Sending => write!(f, "Sending"),
74            SendSMState::Claiming(_) => write!(f, "Claiming"),
75            SendSMState::Cancelled(_) => write!(f, "Cancelled"),
76        }
77    }
78}
79
80#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
81pub struct Claiming {
82    pub preimage: [u8; 32],
83    pub outpoints: Vec<OutPoint>,
84}
85
86#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable, Serialize, Deserialize)]
87pub enum Cancelled {
88    InvoiceExpired,
89    TimeoutTooClose,
90    Underfunded,
91    RegistrationError(String),
92    FinalizationError(String),
93    Rejected,
94    Refunded,
95    Failure,
96    LightningRpcError(String),
97    DuplicatePayment,
98}
99
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(context.clone(), self.common.clone()),
124                    move |dbtx, result, old_state| {
125                        Box::pin(Self::transition_send_payment(
126                            dbtx,
127                            old_state,
128                            gc.clone(),
129                            result,
130                            gateway_context.clone(),
131                        ))
132                    },
133                )]
134            }
135            SendSMState::Claiming(..) | SendSMState::Cancelled(..) => {
136                vec![]
137            }
138        }
139    }
140
141    fn operation_id(&self) -> OperationId {
142        self.common.operation_id
143    }
144}
145
146impl SendStateMachine {
147    async fn send_payment(
148        context: GatewayClientContextV2,
149        common: SendSMCommon,
150    ) -> Result<PaymentResponse, Cancelled> {
151        let SendSMCommon {
152            operation_id: _,
153            outpoint,
154            contract,
155            max_delay,
156            min_contract_amount,
157            invoice,
158            claim_keypair: _,
159        } = common;
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        // The persisted `max_delay` was measured against the contract's
176        // remaining blocks when the send was accepted. A dispatch that happens
177        // later, after a restart or while waiting for the lightning node, must
178        // not spend blocks that have since passed: an HTLC whose timelock
179        // outlives the contract lets the payee settle after the gateway can no
180        // longer claim. So the budget for a fresh dispatch is measured again,
181        // waiting for the federation to answer. Every rail below needs the
182        // lightning node, so wait for it first: a budget measured before an
183        // outage would go stale for as long as the outage lasts.
184        context.gateway.await_lightning_connected().await;
185        let fresh_max_delay = context
186            .module
187            .await_outgoing_contract_max_delay(outpoint)
188            .await;
189
190        // Invoice expiry and the fresh budget, by contrast with the checks
191        // above, change over time, and this state machine re-runs from scratch
192        // on every restart: either can run out while a payment dispatched
193        // before a crash is still in flight, and that payment settles or fails
194        // regardless. Cancelling on them alone would forfeit a contract the
195        // in-flight payment can still claim. Each rail below therefore resumes
196        // anything it already started unconditionally and consults this
197        // verdict only before dispatching fresh.
198        let fresh_dispatch_refusal = fresh_dispatch_refusal(invoice.is_expired(), fresh_max_delay);
199
200        // To make gateway operation easier, we check if the invoice was created using
201        // the LNv1 protocol and if the gateway supports the target federation.
202        // If it does, we can fund an LNv1 incoming contract to satisfy the LNv2
203        // outgoing payment.
204        if let Some(client) = context.gateway.is_lnv1_invoice(&invoice).await {
205            let final_state = context
206                .gateway
207                .relay_lnv1_swap(client.value(), &invoice, fresh_dispatch_refusal.is_none())
208                .await;
209            return match final_state {
210                Ok(Some(final_receive_state)) => match final_receive_state {
211                    FinalReceiveState::Rejected => Err(Cancelled::Rejected),
212                    FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
213                        preimage,
214                        target_federation: Some(client.value().federation_id()),
215                    }),
216                    FinalReceiveState::Refunded => Err(Cancelled::Refunded),
217                    FinalReceiveState::Failure => Err(Cancelled::Failure),
218                },
219                Ok(None) => Err(fresh_dispatch_refusal
220                    .expect("the relay only refuses a fresh dispatch when one was denied")),
221                Err(e) => Err(Cancelled::FinalizationError(e.fmt_compact().to_string())),
222            };
223        }
224
225        match context
226            .gateway
227            .is_direct_swap(&invoice)
228            .await
229            .map_err(|e| Cancelled::RegistrationError(e.fmt_compact().to_string()))?
230        {
231            Some((contract, client)) => {
232                match client
233                    .get_first_module::<GatewayClientModuleV2>()
234                    .expect("Must have client module")
235                    .relay_direct_swap(
236                        contract,
237                        invoice
238                            .amount_milli_satoshis()
239                            .expect("amountless invoices are not supported"),
240                        fresh_dispatch_refusal.is_none(),
241                    )
242                    .await
243                {
244                    Ok(Some(final_receive_state)) => match final_receive_state {
245                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
246                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
247                            preimage,
248                            target_federation: Some(client.federation_id()),
249                        }),
250                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
251                        FinalReceiveState::Failure => Err(Cancelled::Failure),
252                    },
253                    Ok(None) => Err(fresh_dispatch_refusal
254                        .expect("the relay only refuses a fresh dispatch when one was denied")),
255                    Err(e) => Err(Cancelled::FinalizationError(e.fmt_compact().to_string())),
256                }
257            }
258            None => {
259                // A payment the node already knows resolves through `pay`'s
260                // idempotent resume path, which ignores `max_delay`, so the
261                // verdict only refuses a dispatch that never happened.
262                if let Some(refusal) = fresh_dispatch_refusal
263                    && !context
264                        .gateway
265                        .outbound_payment_exists(*invoice.payment_hash())
266                        .await
267                {
268                    return Err(refusal);
269                }
270
271                let preimage = context
272                    .gateway
273                    .pay(invoice, fresh_max_delay, max_fee)
274                    .await
275                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
276                Ok(PaymentResponse {
277                    preimage,
278                    target_federation: None,
279                })
280            }
281        }
282    }
283
284    async fn transition_send_payment(
285        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
286        old_state: SendStateMachine,
287        global_context: DynGlobalClientContext,
288        result: Result<PaymentResponse, Cancelled>,
289        client_ctx: GatewayClientContextV2,
290    ) -> SendStateMachine {
291        match result {
292            Ok(payment_response) => {
293                client_ctx
294                    .module
295                    .client_ctx
296                    .log_event(
297                        &mut dbtx.module_tx(),
298                        OutgoingPaymentSucceeded {
299                            payment_image: old_state.common.contract.payment_image.clone(),
300                            target_federation: payment_response.target_federation,
301                        },
302                    )
303                    .await;
304                let client_input = ClientInput::<LightningInput> {
305                    input: LightningInput::V0(LightningInputV0::Outgoing(
306                        old_state.common.outpoint,
307                        OutgoingWitness::Claim(payment_response.preimage),
308                    )),
309                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
310                    keys: vec![old_state.common.claim_keypair],
311                };
312
313                let outpoints = global_context
314                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
315                    .await
316                    .expect("Cannot claim input, additional funding needed")
317                    .into_iter()
318                    .collect();
319
320                old_state.update(SendSMState::Claiming(Claiming {
321                    preimage: payment_response.preimage,
322                    outpoints,
323                }))
324            }
325            Err(e) => {
326                client_ctx
327                    .module
328                    .client_ctx
329                    .log_event(
330                        &mut dbtx.module_tx(),
331                        OutgoingPaymentFailed {
332                            payment_image: old_state.common.contract.payment_image.clone(),
333                            error: e.clone(),
334                        },
335                    )
336                    .await;
337                old_state.update(SendSMState::Cancelled(e))
338            }
339        }
340    }
341}
342
343/// Decides whether a send may dispatch a payment it has not started yet,
344/// returning the reason to cancel with if not. A payment already in flight
345/// resumes regardless.
346pub(crate) fn fresh_dispatch_refusal(
347    invoice_expired: bool,
348    fresh_max_delay: u64,
349) -> Option<Cancelled> {
350    if invoice_expired {
351        Some(Cancelled::InvoiceExpired)
352    } else if fresh_max_delay == 0 {
353        Some(Cancelled::TimeoutTooClose)
354    } else {
355        None
356    }
357}