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)]
78impl 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 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}