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
77impl 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 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#[derive(Debug, thiserror::Error)]
336enum SendPaymentAttemptError {
337 #[error(transparent)]
339 Gateway(#[from] ServerError),
340
341 #[error("Invalid gateway response: {0:?}")]
344 InvalidResponse(Result<[u8; 32], Signature>),
345}