1use std::time::{Duration, SystemTime};
2
3use bitcoin::hashes::sha256;
4use fedimint_client::sm::{ClientSMDatabaseTransaction, State, StateTransition};
5use fedimint_client::transaction::{ClientInput, ClientInputBundle};
6use fedimint_client::DynGlobalClientContext;
7use fedimint_core::config::FederationId;
8use fedimint_core::core::OperationId;
9use fedimint_core::encoding::{Decodable, Encodable};
10use fedimint_core::task::sleep;
11use fedimint_core::time::duration_since_epoch;
12use fedimint_core::{secp256k1, Amount, OutPoint, TransactionId};
13use fedimint_ln_common::contracts::outgoing::OutgoingContractData;
14use fedimint_ln_common::contracts::{ContractId, FundedContract, IdentifiableContract};
15use fedimint_ln_common::route_hints::RouteHint;
16use fedimint_ln_common::{LightningGateway, LightningInput, PrunedInvoice};
17use futures::future::pending;
18use lightning_invoice::Bolt11Invoice;
19use reqwest::StatusCode;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use tracing::{error, warn};
23
24pub use self::lightningpay::LightningPayStates;
25use crate::api::LnFederationApi;
26use crate::{set_payment_result, LightningClientContext, PayType};
27
28const RETRY_DELAY: Duration = Duration::from_secs(1);
29
30#[allow(deprecated)]
36pub(super) mod lightningpay {
37 use fedimint_core::encoding::{Decodable, Encodable};
38 use fedimint_core::OutPoint;
39
40 use super::{
41 LightningPayCreatedOutgoingLnContract, LightningPayFunded, LightningPayRefund,
42 LightningPayRefundable,
43 };
44
45 #[cfg_attr(doc, aquamarine::aquamarine)]
46 #[allow(clippy::large_enum_variant)]
63 #[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
64 pub enum LightningPayStates {
65 CreatedOutgoingLnContract(LightningPayCreatedOutgoingLnContract),
66 FundingRejected,
67 Funded(LightningPayFunded),
68 Success(String),
69 #[deprecated(
70 since = "0.4.0",
71 note = "Pay State Machine skips over this state and will retry payments until cancellation or timeout"
72 )]
73 Refundable(LightningPayRefundable),
74 Refund(LightningPayRefund),
75 #[deprecated(
76 since = "0.4.0",
77 note = "Pay State Machine does not need to wait for the refund tx to be accepted"
78 )]
79 Refunded(Vec<OutPoint>),
80 Failure(String),
81 }
82}
83
84#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
85pub struct LightningPayCommon {
86 pub operation_id: OperationId,
87 pub federation_id: FederationId,
88 pub contract: OutgoingContractData,
89 pub gateway_fee: Amount,
90 pub preimage_auth: sha256::Hash,
91 pub invoice: lightning_invoice::Bolt11Invoice,
92}
93
94#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
95pub struct LightningPayStateMachine {
96 pub common: LightningPayCommon,
97 pub state: LightningPayStates,
98}
99
100impl State for LightningPayStateMachine {
101 type ModuleContext = LightningClientContext;
102
103 fn transitions(
104 &self,
105 context: &Self::ModuleContext,
106 global_context: &DynGlobalClientContext,
107 ) -> Vec<StateTransition<Self>> {
108 match &self.state {
109 LightningPayStates::CreatedOutgoingLnContract(created_outgoing_ln_contract) => {
110 created_outgoing_ln_contract.transitions(global_context)
111 }
112 LightningPayStates::Funded(funded) => {
113 funded.transitions(self.common.clone(), context.clone(), global_context.clone())
114 }
115 #[allow(deprecated)]
116 LightningPayStates::Refundable(refundable) => {
117 refundable.transitions(self.common.clone(), global_context.clone())
118 }
119 #[allow(deprecated)]
120 LightningPayStates::Success(_)
121 | LightningPayStates::FundingRejected
122 | LightningPayStates::Refund(_)
123 | LightningPayStates::Refunded(_)
124 | LightningPayStates::Failure(_) => {
125 vec![]
126 }
127 }
128 }
129
130 fn operation_id(&self) -> OperationId {
131 self.common.operation_id
132 }
133}
134
135#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
136pub struct LightningPayCreatedOutgoingLnContract {
137 pub funding_txid: TransactionId,
138 pub contract_id: ContractId,
139 pub gateway: LightningGateway,
140}
141
142impl LightningPayCreatedOutgoingLnContract {
143 fn transitions(
144 &self,
145 global_context: &DynGlobalClientContext,
146 ) -> Vec<StateTransition<LightningPayStateMachine>> {
147 let txid = self.funding_txid;
148 let contract_id = self.contract_id;
149 let success_context = global_context.clone();
150 let gateway = self.gateway.clone();
151 vec![StateTransition::new(
152 Self::await_outgoing_contract_funded(success_context, txid, contract_id),
153 move |_dbtx, result, old_state| {
154 let gateway = gateway.clone();
155 Box::pin(async move {
156 Self::transition_outgoing_contract_funded(&result, old_state, gateway)
157 })
158 },
159 )]
160 }
161
162 async fn await_outgoing_contract_funded(
163 global_context: DynGlobalClientContext,
164 txid: TransactionId,
165 contract_id: ContractId,
166 ) -> Result<u32, GatewayPayError> {
167 global_context
168 .await_tx_accepted(txid)
169 .await
170 .map_err(|_| GatewayPayError::OutgoingContractError)?;
171
172 match global_context
173 .module_api()
174 .await_contract(contract_id)
175 .await
176 .contract
177 {
178 FundedContract::Outgoing(contract) => Ok(contract.timelock),
179 FundedContract::Incoming(..) => {
180 error!("Federation returned wrong account type");
181
182 pending().await
183 }
184 }
185 }
186
187 fn transition_outgoing_contract_funded(
188 result: &Result<u32, GatewayPayError>,
189 old_state: LightningPayStateMachine,
190 gateway: LightningGateway,
191 ) -> LightningPayStateMachine {
192 assert!(matches!(
193 old_state.state,
194 LightningPayStates::CreatedOutgoingLnContract(_)
195 ));
196
197 match result {
198 Ok(timelock) => {
199 let common = old_state.common.clone();
201 let payload = if gateway.supports_private_payments {
202 PayInvoicePayload::new_pruned(common.clone())
203 } else {
204 PayInvoicePayload::new(common.clone())
205 };
206 LightningPayStateMachine {
207 common: old_state.common,
208 state: LightningPayStates::Funded(LightningPayFunded {
209 payload,
210 gateway,
211 timelock: *timelock,
212 funding_time: fedimint_core::time::now(),
213 }),
214 }
215 }
216 Err(_) => {
217 LightningPayStateMachine {
219 common: old_state.common,
220 state: LightningPayStates::FundingRejected,
221 }
222 }
223 }
224 }
225}
226
227#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
228pub struct LightningPayFunded {
229 pub payload: PayInvoicePayload,
230 pub gateway: LightningGateway,
231 pub timelock: u32,
232 pub funding_time: SystemTime,
233}
234
235#[derive(
236 Error, Debug, Hash, Serialize, Deserialize, Encodable, Decodable, Clone, Eq, PartialEq,
237)]
238#[serde(rename_all = "snake_case")]
239pub enum GatewayPayError {
240 #[error("Lightning Gateway failed to pay invoice. ErrorCode: {error_code:?} ErrorMessage: {error_message}")]
241 GatewayInternalError {
242 error_code: Option<u16>,
243 error_message: String,
244 },
245 #[error("OutgoingContract was not created in the federation")]
246 OutgoingContractError,
247}
248
249impl LightningPayFunded {
250 fn transitions(
251 &self,
252 common: LightningPayCommon,
253 context: LightningClientContext,
254 global_context: DynGlobalClientContext,
255 ) -> Vec<StateTransition<LightningPayStateMachine>> {
256 let gateway = self.gateway.clone();
257 let payload = self.payload.clone();
258 let contract_id = self.payload.contract_id;
259 let timelock = self.timelock;
260 let payment_hash = *common.invoice.payment_hash();
261 let success_common = common.clone();
262 let timeout_common = common.clone();
263 let timeout_global_context = global_context.clone();
264 vec![
265 StateTransition::new(
266 Self::gateway_pay_invoice(gateway, payload, context, self.funding_time),
267 move |dbtx, result, old_state| {
268 Box::pin(Self::transition_outgoing_contract_execution(
269 result,
270 old_state,
271 contract_id,
272 dbtx,
273 payment_hash,
274 success_common.clone(),
275 ))
276 },
277 ),
278 StateTransition::new(
279 await_contract_cancelled(contract_id, global_context.clone()),
280 move |dbtx, (), old_state| {
281 Box::pin(try_refund_outgoing_contract(
282 old_state,
283 common.clone(),
284 dbtx,
285 global_context.clone(),
286 format!("Gateway cancelled contract: {contract_id}"),
287 ))
288 },
289 ),
290 StateTransition::new(
291 await_contract_timeout(timeout_global_context.clone(), timelock),
292 move |dbtx, (), old_state| {
293 Box::pin(try_refund_outgoing_contract(
294 old_state,
295 timeout_common.clone(),
296 dbtx,
297 timeout_global_context.clone(),
298 format!("Outgoing contract timed out, BlockHeight: {timelock}"),
299 ))
300 },
301 ),
302 ]
303 }
304
305 async fn gateway_pay_invoice(
306 gateway: LightningGateway,
307 payload: PayInvoicePayload,
308 context: LightningClientContext,
309 start: SystemTime,
310 ) -> Result<String, GatewayPayError> {
311 const GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(10);
312 const TIMEOUT_DURATION: Duration = Duration::from_secs(180);
313
314 loop {
315 let elapsed = fedimint_core::time::now()
322 .duration_since(start)
323 .unwrap_or_default();
324 if elapsed > TIMEOUT_DURATION {
325 std::future::pending::<()>().await;
326 }
327
328 match context
329 .gateway_conn
330 .pay_invoice(gateway.clone(), payload.clone())
331 .await
332 {
333 Ok(preimage) => return Ok(preimage),
334 Err(error) => {
335 match error.clone() {
336 GatewayPayError::GatewayInternalError {
337 error_code,
338 error_message,
339 } => {
340 if let Some(error_code) = error_code {
342 if error_code == StatusCode::NOT_FOUND.as_u16() {
343 warn!(
344 ?error_message,
345 ?payload,
346 ?gateway,
347 ?RETRY_DELAY,
348 "Could not contact gateway"
349 );
350 sleep(RETRY_DELAY).await;
351 continue;
352 }
353 }
354 }
355 GatewayPayError::OutgoingContractError => {
356 return Err(error);
357 }
358 }
359
360 warn!(
361 ?error,
362 ?payload,
363 ?gateway,
364 ?GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL,
365 "Gateway Internal Error. Could not complete payment. Trying again..."
366 );
367 sleep(GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL).await;
368 }
369 }
370 }
371 }
372
373 async fn transition_outgoing_contract_execution(
374 result: Result<String, GatewayPayError>,
375 old_state: LightningPayStateMachine,
376 contract_id: ContractId,
377 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
378 payment_hash: sha256::Hash,
379 common: LightningPayCommon,
380 ) -> LightningPayStateMachine {
381 match result {
382 Ok(preimage) => {
383 set_payment_result(
384 &mut dbtx.module_tx(),
385 payment_hash,
386 PayType::Lightning(old_state.common.operation_id),
387 contract_id,
388 common.gateway_fee,
389 )
390 .await;
391 LightningPayStateMachine {
392 common: old_state.common,
393 state: LightningPayStates::Success(preimage),
394 }
395 }
396 Err(e) => LightningPayStateMachine {
397 common: old_state.common,
398 state: LightningPayStates::Failure(e.to_string()),
399 },
400 }
401 }
402}
403
404#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
405pub struct LightningPayRefundable {
408 contract_id: ContractId,
409 pub block_timelock: u32,
410 pub error: GatewayPayError,
411}
412
413impl LightningPayRefundable {
414 fn transitions(
415 &self,
416 common: LightningPayCommon,
417 global_context: DynGlobalClientContext,
418 ) -> Vec<StateTransition<LightningPayStateMachine>> {
419 let contract_id = self.contract_id;
420 let timeout_global_context = global_context.clone();
421 let timeout_common = common.clone();
422 let timelock = self.block_timelock;
423 vec![
424 StateTransition::new(
425 await_contract_cancelled(contract_id, global_context.clone()),
426 move |dbtx, (), old_state| {
427 Box::pin(try_refund_outgoing_contract(
428 old_state,
429 common.clone(),
430 dbtx,
431 global_context.clone(),
432 format!("Refundable: Gateway cancelled contract: {contract_id}"),
433 ))
434 },
435 ),
436 StateTransition::new(
437 await_contract_timeout(timeout_global_context.clone(), timelock),
438 move |dbtx, (), old_state| {
439 Box::pin(try_refund_outgoing_contract(
440 old_state,
441 timeout_common.clone(),
442 dbtx,
443 timeout_global_context.clone(),
444 format!("Refundable: Outgoing contract timed out. ContractId: {contract_id} BlockHeight: {timelock}"),
445 ))
446 },
447 ),
448 ]
449 }
450}
451
452async fn await_contract_cancelled(contract_id: ContractId, global_context: DynGlobalClientContext) {
454 loop {
455 match global_context
458 .module_api()
459 .wait_outgoing_contract_cancelled(contract_id)
460 .await
461 {
462 Ok(_) => return,
463 Err(error) => {
464 error!("Error waiting for outgoing contract to be cancelled: {error:?}");
465 }
466 }
467
468 sleep(RETRY_DELAY).await;
469 }
470}
471
472async fn await_contract_timeout(global_context: DynGlobalClientContext, timelock: u32) {
475 global_context
476 .module_api()
477 .wait_block_height(u64::from(timelock))
478 .await;
479}
480
481async fn try_refund_outgoing_contract(
487 old_state: LightningPayStateMachine,
488 common: LightningPayCommon,
489 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
490 global_context: DynGlobalClientContext,
491 error_reason: String,
492) -> LightningPayStateMachine {
493 let contract_data = common.contract;
494 let (refund_key, refund_input) = (
495 contract_data.recovery_key,
496 contract_data.contract_account.refund(),
497 );
498
499 let refund_client_input = ClientInput::<LightningInput> {
500 input: refund_input,
501 amount: contract_data.contract_account.amount,
502 keys: vec![refund_key],
503 };
504
505 let change_range = global_context
506 .claim_inputs(
507 dbtx,
508 ClientInputBundle::new_no_sm(vec![refund_client_input]),
511 )
512 .await
513 .expect("Cannot claim input, additional funding needed");
514
515 LightningPayStateMachine {
516 common: old_state.common,
517 state: LightningPayStates::Refund(LightningPayRefund {
518 txid: change_range.txid(),
519 out_points: change_range.into_iter().collect(),
520 error_reason,
521 }),
522 }
523}
524
525#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
526pub struct LightningPayRefund {
527 pub txid: TransactionId,
528 pub out_points: Vec<OutPoint>,
529 pub error_reason: String,
530}
531
532#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
533pub struct PayInvoicePayload {
534 pub federation_id: FederationId,
535 pub contract_id: ContractId,
536 pub payment_data: PaymentData,
538 pub preimage_auth: sha256::Hash,
539}
540
541impl PayInvoicePayload {
542 fn new(common: LightningPayCommon) -> Self {
543 Self {
544 contract_id: common.contract.contract_account.contract.contract_id(),
545 federation_id: common.federation_id,
546 preimage_auth: common.preimage_auth,
547 payment_data: PaymentData::Invoice(common.invoice),
548 }
549 }
550
551 fn new_pruned(common: LightningPayCommon) -> Self {
552 Self {
553 contract_id: common.contract.contract_account.contract.contract_id(),
554 federation_id: common.federation_id,
555 preimage_auth: common.preimage_auth,
556 payment_data: PaymentData::PrunedInvoice(
557 common.invoice.try_into().expect("Invoice has amount"),
558 ),
559 }
560 }
561}
562
563#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
566#[serde(rename_all = "snake_case")]
567pub enum PaymentData {
568 Invoice(Bolt11Invoice),
569 PrunedInvoice(PrunedInvoice),
570}
571
572impl PaymentData {
573 pub fn amount(&self) -> Option<Amount> {
574 match self {
575 PaymentData::Invoice(invoice) => {
576 invoice.amount_milli_satoshis().map(Amount::from_msats)
577 }
578 PaymentData::PrunedInvoice(PrunedInvoice { amount, .. }) => Some(*amount),
579 }
580 }
581
582 pub fn destination(&self) -> secp256k1::PublicKey {
583 match self {
584 PaymentData::Invoice(invoice) => invoice
585 .payee_pub_key()
586 .copied()
587 .unwrap_or_else(|| invoice.recover_payee_pub_key()),
588 PaymentData::PrunedInvoice(PrunedInvoice { destination, .. }) => *destination,
589 }
590 }
591
592 pub fn payment_hash(&self) -> sha256::Hash {
593 match self {
594 PaymentData::Invoice(invoice) => *invoice.payment_hash(),
595 PaymentData::PrunedInvoice(PrunedInvoice { payment_hash, .. }) => *payment_hash,
596 }
597 }
598
599 pub fn route_hints(&self) -> Vec<RouteHint> {
600 match self {
601 PaymentData::Invoice(invoice) => {
602 invoice.route_hints().into_iter().map(Into::into).collect()
603 }
604 PaymentData::PrunedInvoice(PrunedInvoice { route_hints, .. }) => route_hints.clone(),
605 }
606 }
607
608 pub fn is_expired(&self) -> bool {
609 self.expiry_timestamp() < duration_since_epoch().as_secs()
610 }
611
612 pub fn expiry_timestamp(&self) -> u64 {
614 match self {
615 PaymentData::Invoice(invoice) => invoice.expires_at().map_or(u64::MAX, |t| t.as_secs()),
616 PaymentData::PrunedInvoice(PrunedInvoice {
617 expiry_timestamp, ..
618 }) => *expiry_timestamp,
619 }
620 }
621}