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#[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 if !contract.verify_gateway_response(&payment_result) {
209 return Err(SendPaymentAttemptError::InvalidResponse(payment_result));
210 }
211
212 Ok(payment_result)
213 })
214 .await
215 .expect("Number of retries has no limit")
216 }
217
218 async fn transition_gateway_send_payment(
219 context: LightningClientContext,
220 global_context: DynGlobalClientContext,
221 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
222 gateway_response: Result<[u8; 32], Signature>,
223 old_state: SendStateMachine,
224 ) -> SendStateMachine {
225 match gateway_response {
226 Ok(preimage) => {
227 send_update_event(
228 context,
229 dbtx,
230 old_state.common.operation_id,
231 SendPaymentStatus::Success(preimage),
232 )
233 .await;
234
235 old_state.update(SendSMState::Success(preimage))
236 }
237 Err(signature) => {
238 let client_input = ClientInput::<LightningInput> {
239 input: LightningInput::V0(LightningInputV0::Outgoing(
240 old_state.common.outpoint,
241 OutgoingWitness::Cancel(signature),
242 )),
243 amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
244 keys: vec![old_state.common.refund_keypair],
245 };
246
247 let change_range = global_context
248 .claim_inputs(
249 dbtx,
250 ClientInputBundle::new_no_sm(vec![client_input]),
252 )
253 .await
254 .expect("Cannot claim input, additional funding needed");
255
256 send_update_event(
257 context,
258 dbtx,
259 old_state.common.operation_id,
260 SendPaymentStatus::Refunded,
261 )
262 .await;
263
264 old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
265 }
266 }
267 }
268
269 #[instrument(target = LOG_CLIENT_MODULE_LNV2, skip(global_context))]
270 async fn await_preimage(
271 outpoint: OutPoint,
272 contract: OutgoingContract,
273 global_context: DynGlobalClientContext,
274 ) -> Option<[u8; 32]> {
275 let preimage = global_context
276 .module_api()
277 .await_preimage(outpoint, contract.expiration)
278 .await?;
279
280 if contract.verify_preimage(&preimage) {
281 return Some(preimage);
282 }
283
284 crit!(target: LOG_CLIENT_MODULE_LNV2, "Federation returned invalid preimage {:?}", preimage);
285
286 pending().await
287 }
288
289 async fn transition_preimage(
290 context: LightningClientContext,
291 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
292 global_context: DynGlobalClientContext,
293 old_state: SendStateMachine,
294 preimage: Option<[u8; 32]>,
295 ) -> SendStateMachine {
296 if let Some(preimage) = preimage {
297 send_update_event(
298 context,
299 dbtx,
300 old_state.common.operation_id,
301 SendPaymentStatus::Success(preimage),
302 )
303 .await;
304
305 return old_state.update(SendSMState::Success(preimage));
306 }
307
308 let client_input = ClientInput::<LightningInput> {
309 input: LightningInput::V0(LightningInputV0::Outgoing(
310 old_state.common.outpoint,
311 OutgoingWitness::Refund,
312 )),
313 amounts: Amounts::new_bitcoin(old_state.common.contract.amount),
314 keys: vec![old_state.common.refund_keypair],
315 };
316
317 let change_range = global_context
318 .claim_inputs(dbtx, ClientInputBundle::new_no_sm(vec![client_input]))
319 .await
320 .expect("Cannot claim input, additional funding needed");
321
322 send_update_event(
323 context,
324 dbtx,
325 old_state.common.operation_id,
326 SendPaymentStatus::Refunded,
327 )
328 .await;
329
330 old_state.update(SendSMState::Refunding(change_range.into_iter().collect()))
331 }
332}
333
334#[derive(Debug, thiserror::Error)]
337enum SendPaymentAttemptError {
338 #[error(transparent)]
340 Gateway(#[from] ServerError),
341
342 #[error("Invalid gateway response: {0:?}")]
345 InvalidResponse(Result<[u8; 32], Signature>),
346}