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