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