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