Skip to main content

fedimint_lnv2_client/
send_sm.rs

1use bitcoin::hashes::sha256;
2use fedimint_api_client::api::ServerError;
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/// State machine that requests the lightning gateway to pay an invoice on
78/// behalf of a federation client.
79///
80/// ```mermaid
81/// graph LR
82/// classDef virtual fill:#fff,stroke-dasharray: 5 5
83///
84///     Funding -- funding tx is rejected --> Rejected
85///     Funding -- funding tx is accepted --> Funded
86///     Funded -- post invoice returns preimage  --> Success
87///     Funded -- post invoice returns forfeit tx --> Refunding
88///     Funded -- await_preimage returns preimage --> Success
89///     Funded -- await_preimage expires --> Refunding
90/// ```
91impl State for SendStateMachine {
92    type ModuleContext = LightningClientContext;
93
94    fn transitions(
95        &self,
96        context: &Self::ModuleContext,
97        global_context: &DynGlobalClientContext,
98    ) -> Vec<StateTransition<Self>> {
99        let c_pay = context.clone();
100        let gc_pay = global_context.clone();
101        let c_preimage = context.clone();
102        let gc_preimage = global_context.clone();
103
104        match &self.state {
105            SendSMState::Funding => {
106                vec![StateTransition::new(
107                    Self::await_funding(global_context.clone(), self.common.outpoint.txid),
108                    move |_, error, old_state| {
109                        Box::pin(async move { Self::transition_funding(error, &old_state) })
110                    },
111                )]
112            }
113            SendSMState::Funded => {
114                vec![
115                    StateTransition::new(
116                        Self::gateway_send_payment(
117                            self.common.gateway_api.clone().unwrap(),
118                            context.federation_id,
119                            self.common.outpoint,
120                            self.common.contract.clone(),
121                            self.common.invoice.clone().unwrap(),
122                            self.common.refund_keypair,
123                            context.clone(),
124                        ),
125                        move |dbtx, response, old_state| {
126                            Box::pin(Self::transition_gateway_send_payment(
127                                c_pay.clone(),
128                                gc_pay.clone(),
129                                dbtx,
130                                response,
131                                old_state,
132                            ))
133                        },
134                    ),
135                    StateTransition::new(
136                        Self::await_preimage(
137                            self.common.outpoint,
138                            self.common.contract.clone(),
139                            gc_preimage.clone(),
140                        ),
141                        move |dbtx, preimage, old_state| {
142                            Box::pin(Self::transition_preimage(
143                                c_preimage.clone(),
144                                dbtx,
145                                gc_preimage.clone(),
146                                old_state,
147                                preimage,
148                            ))
149                        },
150                    ),
151                ]
152            }
153            SendSMState::Refunding(..) | SendSMState::Success(..) | SendSMState::Rejected(..) => {
154                vec![]
155            }
156        }
157    }
158
159    fn operation_id(&self) -> OperationId {
160        self.common.operation_id
161    }
162}
163
164impl SendStateMachine {
165    async fn await_funding(
166        global_context: DynGlobalClientContext,
167        txid: TransactionId,
168    ) -> Result<(), String> {
169        global_context.await_tx_accepted(txid).await
170    }
171
172    fn transition_funding(
173        result: Result<(), String>,
174        old_state: &SendStateMachine,
175    ) -> SendStateMachine {
176        match result {
177            Ok(()) => old_state.update(SendSMState::Funded),
178            Err(error) => old_state.update(SendSMState::Rejected(error)),
179        }
180    }
181
182    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(refund_keypair, context))]
183    async fn gateway_send_payment(
184        gateway_api: SafeUrl,
185        federation_id: FederationId,
186        outpoint: OutPoint,
187        contract: OutgoingContract,
188        invoice: LightningInvoice,
189        refund_keypair: Keypair,
190        context: LightningClientContext,
191    ) -> Result<[u8; 32], Signature> {
192        util::retry("gateway-send-payment", api_networking_backoff(), || async {
193            let payment_result = context
194                .gateway_conn
195                .send_payment(
196                    gateway_api.clone(),
197                    federation_id,
198                    outpoint,
199                    contract.clone(),
200                    invoice.clone(),
201                    refund_keypair.sign_schnorr(secp256k1::Message::from_digest(
202                        *invoice.consensus_hash::<sha256::Hash>().as_ref(),
203                    )),
204                )
205                .await?;
206
207            if !contract.verify_gateway_response(&payment_result) {
208                return Err(SendPaymentAttemptError::InvalidResponse(payment_result));
209            }
210
211            Ok(payment_result)
212        })
213        .await
214        .expect("Number of retries has no limit")
215    }
216
217    async fn transition_gateway_send_payment(
218        context: LightningClientContext,
219        global_context: DynGlobalClientContext,
220        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
221        gateway_response: Result<[u8; 32], Signature>,
222        old_state: SendStateMachine,
223    ) -> SendStateMachine {
224        match gateway_response {
225            Ok(preimage) => {
226                send_update_event(
227                    context,
228                    dbtx,
229                    old_state.common.operation_id,
230                    SendPaymentStatus::Success(preimage),
231                )
232                .await;
233
234                old_state.update(SendSMState::Success(preimage))
235            }
236            Err(signature) => {
237                let client_input = ClientInput::<LightningInput> {
238                    input: LightningInput::V0(LightningInputV0::Outgoing(
239                        old_state.common.outpoint,
240                        OutgoingWitness::Cancel(signature),
241                    )),
242                    amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
243                    keys: vec![old_state.common.refund_keypair],
244                };
245
246                let change_range = global_context
247                    .claim_inputs(
248                        dbtx,
249                        // The input of the refund tx is managed by this state machine
250                        ClientInputBundle::new_no_sm(vec![client_input]),
251                    )
252                    .await
253                    .expect("Cannot claim input, additional funding needed");
254
255                send_update_event(
256                    context,
257                    dbtx,
258                    old_state.common.operation_id,
259                    SendPaymentStatus::Refunded,
260                )
261                .await;
262
263                old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
264            }
265        }
266    }
267
268    #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
269    async fn await_preimage(
270        outpoint: OutPoint,
271        contract: OutgoingContract,
272        global_context: DynGlobalClientContext,
273    ) -> Option<[u8; 32]> {
274        let preimage = global_context
275            .module_api()
276            .await_preimage(outpoint, contract.expiration)
277            .await?;
278
279        if contract.verify_preimage(&preimage) {
280            return Some(preimage);
281        }
282
283        crit!(target: LOG_CLIENT_MODULE_LNV2, "Federation returned invalid preimage {:?}", preimage);
284
285        pending().await
286    }
287
288    async fn transition_preimage(
289        context: LightningClientContext,
290        dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
291        global_context: DynGlobalClientContext,
292        old_state: SendStateMachine,
293        preimage: Option<[u8; 32]>,
294    ) -> SendStateMachine {
295        if let Some(preimage) = preimage {
296            send_update_event(
297                context,
298                dbtx,
299                old_state.common.operation_id,
300                SendPaymentStatus::Success(preimage),
301            )
302            .await;
303
304            return old_state.update(SendSMState::Success(preimage));
305        }
306
307        let client_input = ClientInput::<LightningInput> {
308            input: LightningInput::V0(LightningInputV0::Outgoing(
309                old_state.common.outpoint,
310                OutgoingWitness::Refund,
311            )),
312            amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
313            keys: vec![old_state.common.refund_keypair],
314        };
315
316        let change_range = global_context
317            .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
318            .await
319            .expect("Cannot claim input, additional funding needed");
320
321        send_update_event(
322            context,
323            dbtx,
324            old_state.common.operation_id,
325            SendPaymentStatus::Refunded,
326        )
327        .await;
328
329        old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
330    }
331}
332
333/// Why one attempt to have the gateway pay the invoice failed, so that it is
334/// made again.
335#[derive(Debug, thiserror::Error)]
336enum SendPaymentAttemptError {
337    /// The gateway could not be reached, or answered with an error.
338    #[error(transparent)]
339    Gateway(#[from] ServerError),
340
341    /// The gateway's answer neither proves the payment nor cancels the
342    /// contract.
343    #[error("Invalid gateway response: {0:?}")]
344    InvalidResponse(Result<[u8; 32], Signature>),
345}