fedimint_lnv2_client/
send_sm.rs

1use anyhow::ensure;
2use bitcoin::hashes::sha256;
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::util::SafeUrl;
11use fedimint_core::util::backoff_util::api_networking_backoff;
12use fedimint_core::{OutPoint, TransactionId, crit, secp256k1, util};
13use fedimint_lnv2_common::contracts::OutgoingContract;
14use fedimint_lnv2_common::{LightningInput, LightningInputV0, OutgoingWitness};
15use fedimint_logging::LOG_CLIENT_MODULE_LNV2;
16use futures::future::pending;
17use secp256k1::Keypair;
18use secp256k1::schnorr::Signature;
19use tracing::instrument;
20
21use crate::api::LightningFederationApi;
22use crate::events::{SendPaymentStatus, SendPaymentUpdateEvent};
23use crate::{LightningClientContext, LightningInvoice};
24
25#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
26pub struct SendStateMachine {
27    pub common: SendSMCommon,
28    pub state: SendSMState,
29}
30
31impl SendStateMachine {
32    pub fn update(&self, state: SendSMState) -> Self {
33        Self {
34            common: self.common.clone(),
35            state,
36        }
37    }
38}
39
40#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
41pub struct SendSMCommon {
42    pub operation_id: OperationId,
43    pub outpoint: OutPoint,
44    pub contract: OutgoingContract,
45    pub gateway_api: Option<SafeUrl>,
46    pub invoice: Option<LightningInvoice>,
47    pub refund_keypair: Keypair,
48}
49
50#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
51pub enum SendSMState {
52    Funding,
53    Funded,
54    Rejected(String),
55    Success([u8; 32]),
56    Refunding(Vec<OutPoint>),
57}
58
59async fn send_update_event(
60    context: LightningClientContext,
61    dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
62    operation_id: OperationId,
63    status: SendPaymentStatus,
64) {
65    context
66        .client_ctx
67        .log_event(
68            &mut dbtx.module_tx(),
69            SendPaymentUpdateEvent {
70                operation_id,
71                status,
72            },
73        )
74        .await;
75}
76
77#[cfg_attr(doc, aquamarine::aquamarine)]
78/// State machine that requests the lightning gateway to pay an invoice on
79/// behalf of a federation client.
80///
81/// ```mermaid
82/// graph LR
83/// classDef virtual fill:#fff,stroke-dasharray: 5 5
84///
85///     Funding -- funding tx is rejected --> Rejected
86///     Funding -- funding tx is accepted --> Funded
87///     Funded -- post invoice returns preimage  --> Success
88///     Funded -- post invoice returns forfeit tx --> Refunding
89///     Funded -- await_preimage returns preimage --> Success
90///     Funded -- await_preimage expires --> Refunding
91/// ```
92impl State for SendStateMachine {
93    type ModuleContext = LightningClientContext;
94
95    fn transitions(
96        &self,
97        context: &Self::ModuleContext,
98        global_context: &DynGlobalClientContext,
99    ) -> Vec<StateTransition<Self>> {
100        let c_pay = context.clone();
101        let gc_pay = global_context.clone();
102        let c_preimage = context.clone();
103        let gc_preimage = global_context.clone();
104
105        match &self.state {
106            SendSMState::Funding => {
107                vec![StateTransition::new(
108                    Self::await_funding(global_context.clone(), self.common.outpoint.txid),
109                    move |_, error, old_state| {
110                        Box::pin(async move { Self::transition_funding(error, &old_state) })
111                    },
112                )]
113            }
114            SendSMState::Funded => {
115                vec![
116                    StateTransition::new(
117                        Self::gateway_send_payment(
118                            self.common.gateway_api.clone().unwrap(),
119                            context.federation_id,
120                            self.common.outpoint,
121                            self.common.contract.clone(),
122                            self.common.invoice.clone().unwrap(),
123                            self.common.refund_keypair,
124                            context.clone(),
125                        ),
126                        move |dbtx, response, old_state| {
127                            Box::pin(Self::transition_gateway_send_payment(
128                                c_pay.clone(),
129                                gc_pay.clone(),
130                                dbtx,
131                                response,
132                                old_state,
133                            ))
134                        },
135                    ),
136                    StateTransition::new(
137                        Self::await_preimage(
138                            self.common.outpoint,
139                            self.common.contract.clone(),
140                            gc_preimage.clone(),
141                        ),
142                        move |dbtx, preimage, old_state| {
143                            Box::pin(Self::transition_preimage(
144                                c_preimage.clone(),
145                                dbtx,
146                                gc_preimage.clone(),
147                                old_state,
148                                preimage,
149                            ))
150                        },
151                    ),
152                ]
153            }
154            SendSMState::Refunding(..) | SendSMState::Success(..) | SendSMState::Rejected(..) => {
155                vec![]
156            }
157        }
158    }
159
160    fn operation_id(&self) -> OperationId {
161        self.common.operation_id
162    }
163}
164
165impl SendStateMachine {
166    async fn await_funding(
167        global_context: DynGlobalClientContext,
168        txid: TransactionId,
169    ) -> Result<(), String> {
170        global_context.await_tx_accepted(txid).await
171    }
172
173    fn transition_funding(
174        result: Result<(), String>,
175        old_state: &SendStateMachine,
176    ) -> SendStateMachine {
177        match result {
178            Ok(()) => old_state.update(SendSMState::Funded),
179            Err(error) => old_state.update(SendSMState::Rejected(error)),
180        }
181    }
182
183    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(refund_keypair, context))]
184    async fn gateway_send_payment(
185        gateway_api: SafeUrl,
186        federation_id: FederationId,
187        outpoint: OutPoint,
188        contract: OutgoingContract,
189        invoice: LightningInvoice,
190        refund_keypair: Keypair,
191        context: LightningClientContext,
192    ) -> Result<[u8; 32], Signature> {
193        util::retry("gateway-send-payment", api_networking_backoff(), || async {
194            let payment_result = context
195                .gateway_conn
196                .send_payment(
197                    gateway_api.clone(),
198                    federation_id,
199                    outpoint,
200                    contract.clone(),
201                    invoice.clone(),
202                    refund_keypair.sign_schnorr(secp256k1::Message::from_digest(
203                        *invoice.consensus_hash::<sha256::Hash>().as_ref(),
204                    )),
205                )
206                .await?;
207
208            ensure!(
209                contract.verify_gateway_response(&payment_result),
210                "Invalid gateway response: {payment_result:?}"
211            );
212
213            Ok(payment_result)
214        })
215        .await
216        .expect("Number of retries has no limit")
217    }
218
219    async fn transition_gateway_send_payment(
220        context: LightningClientContext,
221        global_context: DynGlobalClientContext,
222        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
223        gateway_response: Result<[u8; 32], Signature>,
224        old_state: SendStateMachine,
225    ) -> SendStateMachine {
226        match gateway_response {
227            Ok(preimage) => {
228                send_update_event(
229                    context,
230                    dbtx,
231                    old_state.common.operation_id,
232                    SendPaymentStatus::Success(preimage),
233                )
234                .await;
235
236                old_state.update(SendSMState::Success(preimage))
237            }
238            Err(signature) => {
239                let client_input = ClientInput::<LightningInput> {
240                    input: LightningInput::V0(LightningInputV0::Outgoing(
241                        old_state.common.outpoint,
242                        OutgoingWitness::Cancel(signature),
243                    )),
244                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
245                    keys: vec![old_state.common.refund_keypair],
246                };
247
248                let change_range = global_context
249                    .claim_inputs(
250                        dbtx,
251                        // The input of the refund tx is managed by this state machine
252                        ClientInputBundle::new_no_sm(vec![client_input]),
253                    )
254                    .await
255                    .expect("Cannot claim input, additional funding needed");
256
257                send_update_event(
258                    context,
259                    dbtx,
260                    old_state.common.operation_id,
261                    SendPaymentStatus::Refunded,
262                )
263                .await;
264
265                old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
266            }
267        }
268    }
269
270    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
271    async fn await_preimage(
272        outpoint: OutPoint,
273        contract: OutgoingContract,
274        global_context: DynGlobalClientContext,
275    ) -> Option<[u8; 32]> {
276        let preimage = global_context
277            .module_api()
278            .await_preimage(outpoint, contract.expiration)
279            .await?;
280
281        if contract.verify_preimage(&preimage) {
282            return Some(preimage);
283        }
284
285        crit!(target: LOG_CLIENT_MODULE_LNV2, "Federation returned invalid preimage {:?}", preimage);
286
287        pending().await
288    }
289
290    async fn transition_preimage(
291        context: LightningClientContext,
292        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
293        global_context: DynGlobalClientContext,
294        old_state: SendStateMachine,
295        preimage: Option<[u8; 32]>,
296    ) -> SendStateMachine {
297        if let Some(preimage) = preimage {
298            send_update_event(
299                context,
300                dbtx,
301                old_state.common.operation_id,
302                SendPaymentStatus::Success(preimage),
303            )
304            .await;
305
306            return old_state.update(SendSMState::Success(preimage));
307        }
308
309        let client_input = ClientInput::<LightningInput> {
310            input: LightningInput::V0(LightningInputV0::Outgoing(
311                old_state.common.outpoint,
312                OutgoingWitness::Refund,
313            )),
314            amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
315            keys: vec![old_state.common.refund_keypair],
316        };
317
318        let change_range = global_context
319            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
320            .await
321            .expect("Cannot claim input, additional funding needed");
322
323        send_update_event(
324            context,
325            dbtx,
326            old_state.common.operation_id,
327            SendPaymentStatus::Refunded,
328        )
329        .await;
330
331        old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
332    }
333}