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#[cfg_attr(doc, aquamarine::aquamarine)]
101/// State machine that handles the relay of an incoming Lightning payment.
102///
103/// ```mermaid
104/// graph LR
105/// classDef virtual fill:#fff,stroke-dasharray: 5 5
106///
107///     Sending -- payment is successful --> Claiming
108///     Sending -- payment fails --> Cancelled
109/// ```
110impl State for SendStateMachine {
111    type ModuleContext = GatewayClientContextV2;
112
113    fn transitions(
114        &self,
115        context: &Self::ModuleContext,
116        global_context: &DynGlobalClientContext,
117    ) -> Vec<StateTransition<Self>> {
118        let gc = global_context.clone();
119        let gateway_context = context.clone();
120
121        match &self.state {
122            SendSMState::Sending => {
123                vec![StateTransition::new(
124                    Self::send_payment(context.clone(), self.common.clone()),
125                    move |dbtx, result, old_state| {
126                        Box::pin(Self::transition_send_payment(
127                            dbtx,
128                            old_state,
129                            gc.clone(),
130                            result,
131                            gateway_context.clone(),
132                        ))
133                    },
134                )]
135            }
136            SendSMState::Claiming(..) | SendSMState::Cancelled(..) => {
137                vec![]
138            }
139        }
140    }
141
142    fn operation_id(&self) -> OperationId {
143        self.common.operation_id
144    }
145}
146
147impl SendStateMachine {
148    async fn send_payment(
149        context: GatewayClientContextV2,
150        common: SendSMCommon,
151    ) -> Result<PaymentResponse, Cancelled> {
152        let SendSMCommon {
153            operation_id: _,
154            outpoint,
155            contract,
156            max_delay,
157            min_contract_amount,
158            invoice,
159            claim_keypair: _,
160        } = common;
161        let LightningInvoice::Bolt11(invoice) = invoice;
162
163        // `max_delay` is computed once when the state machine is created and
164        // persisted, so this check cannot drift across a restart. It guards
165        // every rail, not just Lightning: after paying out, a swap races the
166        // outgoing contract's expiration to claim it, the same race an HTLC
167        // runs against the contract timelock.
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        // The persisted `max_delay` was measured against the contract's
177        // remaining blocks when the send was accepted. A dispatch that happens
178        // later, after a restart or while waiting for the lightning node, must
179        // not spend blocks that have since passed: an HTLC whose timelock
180        // outlives the contract lets the payee settle after the gateway can no
181        // longer claim. So the budget for a fresh dispatch is measured again,
182        // waiting for the federation to answer. Every rail below needs the
183        // lightning node, so wait for it first: a budget measured before an
184        // outage would go stale for as long as the outage lasts.
185        context.gateway.await_lightning_connected().await;
186        let fresh_max_delay = context
187            .module
188            .await_outgoing_contract_max_delay(outpoint)
189            .await;
190
191        // Invoice expiry and the fresh budget, by contrast with the checks
192        // above, change over time, and this state machine re-runs from scratch
193        // on every restart: either can run out while a payment dispatched
194        // before a crash is still in flight, and that payment settles or fails
195        // regardless. Cancelling on them alone would forfeit a contract the
196        // in-flight payment can still claim. Each rail below therefore resumes
197        // anything it already started unconditionally and consults this
198        // verdict only before dispatching fresh.
199        let fresh_dispatch_refusal = fresh_dispatch_refusal(invoice.is_expired(), fresh_max_delay);
200
201        // To make gateway operation easier, we check if the invoice was created using
202        // the LNv1 protocol and if the gateway supports the target federation.
203        // If it does, we can fund an LNv1 incoming contract to satisfy the LNv2
204        // outgoing payment.
205        if let Some(client) = context.gateway.is_lnv1_invoice(&invoice).await {
206            let final_state = context
207                .gateway
208                .relay_lnv1_swap(client.value(), &invoice, fresh_dispatch_refusal.is_none())
209                .await;
210            return match final_state {
211                Ok(Some(final_receive_state)) => match final_receive_state {
212                    FinalReceiveState::Rejected => Err(Cancelled::Rejected),
213                    FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
214                        preimage,
215                        target_federation: Some(client.value().federation_id()),
216                    }),
217                    FinalReceiveState::Refunded => Err(Cancelled::Refunded),
218                    FinalReceiveState::Failure => Err(Cancelled::Failure),
219                },
220                Ok(None) => Err(fresh_dispatch_refusal
221                    .expect("the relay only refuses a fresh dispatch when one was denied")),
222                Err(e) => Err(Cancelled::FinalizationError(e.fmt_compact().to_string())),
223            };
224        }
225
226        match context
227            .gateway
228            .is_direct_swap(&invoice)
229            .await
230            .map_err(|e| Cancelled::RegistrationError(e.fmt_compact().to_string()))?
231        {
232            Some((contract, client)) => {
233                match client
234                    .get_first_module::<GatewayClientModuleV2>()
235                    .expect("Must have client module")
236                    .relay_direct_swap(
237                        contract,
238                        invoice
239                            .amount_milli_satoshis()
240                            .expect("amountless invoices are not supported"),
241                        fresh_dispatch_refusal.is_none(),
242                    )
243                    .await
244                {
245                    Ok(Some(final_receive_state)) => match final_receive_state {
246                        FinalReceiveState::Rejected => Err(Cancelled::Rejected),
247                        FinalReceiveState::Success(preimage) => Ok(PaymentResponse {
248                            preimage,
249                            target_federation: Some(client.federation_id()),
250                        }),
251                        FinalReceiveState::Refunded => Err(Cancelled::Refunded),
252                        FinalReceiveState::Failure => Err(Cancelled::Failure),
253                    },
254                    Ok(None) => Err(fresh_dispatch_refusal
255                        .expect("the relay only refuses a fresh dispatch when one was denied")),
256                    Err(e) => Err(Cancelled::FinalizationError(e.fmt_compact().to_string())),
257                }
258            }
259            None => {
260                // A payment the node already knows resolves through `pay`'s
261                // idempotent resume path, which ignores `max_delay`, so the
262                // verdict only refuses a dispatch that never happened.
263                if let Some(refusal) = fresh_dispatch_refusal
264                    && !context
265                        .gateway
266                        .outbound_payment_exists(*invoice.payment_hash())
267                        .await
268                {
269                    return Err(refusal);
270                }
271
272                let preimage = context
273                    .gateway
274                    .pay(invoice, fresh_max_delay, max_fee)
275                    .await
276                    .map_err(|e| Cancelled::LightningRpcError(e.to_string()))?;
277                Ok(PaymentResponse {
278                    preimage,
279                    target_federation: None,
280                })
281            }
282        }
283    }
284
285    async fn transition_send_payment(
286        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
287        old_state: SendStateMachine,
288        global_context: DynGlobalClientContext,
289        result: Result<PaymentResponse, Cancelled>,
290        client_ctx: GatewayClientContextV2,
291    ) -> SendStateMachine {
292        match result {
293            Ok(payment_response) => {
294                client_ctx
295                    .module
296                    .client_ctx
297                    .log_event(
298                        &mut dbtx.module_tx(),
299                        OutgoingPaymentSucceeded {
300                            payment_image: old_state.common.contract.payment_image.clone(),
301                            target_federation: payment_response.target_federation,
302                        },
303                    )
304                    .await;
305                let client_input = ClientInput::<LightningInput> {
306                    input: LightningInput::V0(LightningInputV0::Outgoing(
307                        old_state.common.outpoint,
308                        OutgoingWitness::Claim(payment_response.preimage),
309                    )),
310                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
311                    keys: vec![old_state.common.claim_keypair],
312                };
313
314                let outpoints = global_context
315                    .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
316                    .await
317                    .expect("Cannot claim input, additional funding needed")
318                    .into_iter()
319                    .collect();
320
321                old_state.update(SendSMState::Claiming(Claiming {
322                    preimage: payment_response.preimage,
323                    outpoints,
324                }))
325            }
326            Err(e) => {
327                client_ctx
328                    .module
329                    .client_ctx
330                    .log_event(
331                        &mut dbtx.module_tx(),
332                        OutgoingPaymentFailed {
333                            payment_image: old_state.common.contract.payment_image.clone(),
334                            error: e.clone(),
335                        },
336                    )
337                    .await;
338                old_state.update(SendSMState::Cancelled(e))
339            }
340        }
341    }
342}
343
344/// Decides whether a send may dispatch a payment it has not started yet,
345/// returning the reason to cancel with if not. A payment already in flight
346/// resumes regardless.
347pub(crate) fn fresh_dispatch_refusal(
348    invoice_expired: bool,
349    fresh_max_delay: u64,
350) -> Option<Cancelled> {
351    if invoice_expired {
352        Some(Cancelled::InvoiceExpired)
353    } else if fresh_max_delay == 0 {
354        Some(Cancelled::TimeoutTooClose)
355    } else {
356        None
357    }
358}