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")]
243#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
244pub enum GatewayPayError {
245 #[error(
246 "Lightning Gateway failed to pay invoice. ErrorCode: {error_code:?} ErrorMessage: {error_message}"
247 )]
248 GatewayInternalError {
249 error_code: Option<u16>,
250 error_message: String,
251 },
252 #[error("OutgoingContract was not created in the federation")]
253 OutgoingContractError,
254}
255
256impl LightningPayFunded {
257 fn transitions(
258 &self,
259 common: LightningPayCommon,
260 context: LightningClientContext,
261 global_context: DynGlobalClientContext,
262 ) -> Vec<StateTransition<LightningPayStateMachine>> {
263 let gateway = self.gateway.clone();
264 let payload = self.payload.clone();
265 let contract_id = self.payload.contract_id;
266 let timelock = self.timelock;
267 let payment_hash = *common.invoice.payment_hash();
268 let success_common = common.clone();
269 let success_context = context.clone();
270 let timeout_common = common.clone();
271 let timeout_global_context = global_context.clone();
272 let cancel_context = context.clone();
273 let timeout_context = context.clone();
274 vec![
275 StateTransition::new(
276 Self::gateway_pay_invoice(gateway, payload, context, self.funding_time),
277 move |dbtx, result, old_state| {
278 let success_context = success_context.clone();
279 Box::pin(Self::transition_outgoing_contract_execution(
280 result,
281 old_state,
282 contract_id,
283 dbtx,
284 payment_hash,
285 success_common.clone(),
286 success_context,
287 ))
288 },
289 ),
290 StateTransition::new(
291 await_contract_cancelled(contract_id, global_context.clone()),
292 move |dbtx, (), old_state| {
293 let cancel_context = cancel_context.clone();
294 Box::pin(try_refund_outgoing_contract(
295 old_state,
296 common.clone(),
297 dbtx,
298 global_context.clone(),
299 format!("Gateway cancelled contract: {contract_id}"),
300 cancel_context,
301 ))
302 },
303 ),
304 StateTransition::new(
305 await_contract_timeout(timeout_global_context.clone(), timelock),
306 move |dbtx, (), old_state| {
307 let timeout_context = timeout_context.clone();
308 Box::pin(try_refund_outgoing_contract(
309 old_state,
310 timeout_common.clone(),
311 dbtx,
312 timeout_global_context.clone(),
313 format!("Outgoing contract timed out, BlockHeight: {timelock}"),
314 timeout_context,
315 ))
316 },
317 ),
318 ]
319 }
320
321 async fn gateway_pay_invoice(
322 gateway: LightningGateway,
323 payload: PayInvoicePayload,
324 context: LightningClientContext,
325 start: SystemTime,
326 ) -> Result<String, GatewayPayError> {
327 const GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(10);
328 const TIMEOUT_DURATION: Duration = Duration::from_mins(3);
329
330 loop {
331 let elapsed = fedimint_core::time::now()
338 .duration_since(start)
339 .unwrap_or_default();
340 if elapsed > TIMEOUT_DURATION {
341 std::future::pending::<()>().await;
342 }
343
344 match context
345 .gateway_conn
346 .pay_invoice(gateway.clone(), payload.clone())
347 .await
348 {
349 Ok(preimage) => return Ok(preimage),
350 Err(err) => {
351 match err.clone() {
352 GatewayPayError::GatewayInternalError {
353 error_code,
354 error_message,
355 } => {
356 if let Some(error_code) = error_code
358 && error_code == StatusCode::NOT_FOUND.as_u16()
359 {
360 warn!(
361 %error_message,
362 ?payload,
363 ?gateway,
364 ?RETRY_DELAY,
365 "Could not contact gateway"
366 );
367 sleep(RETRY_DELAY).await;
368 continue;
369 }
370 }
371 GatewayPayError::OutgoingContractError => {
372 return Err(err);
373 }
374 }
375
376 warn!(
377 err = %err.fmt_compact(),
378 ?payload,
379 ?gateway,
380 ?GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL,
381 "Gateway Internal Error. Could not complete payment. Trying again..."
382 );
383 sleep(GATEWAY_INTERNAL_ERROR_RETRY_INTERVAL).await;
384 }
385 }
386 }
387 }
388
389 async fn transition_outgoing_contract_execution(
390 result: Result<String, GatewayPayError>,
391 old_state: LightningPayStateMachine,
392 contract_id: ContractId,
393 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
394 payment_hash: sha256::Hash,
395 common: LightningPayCommon,
396 context: LightningClientContext,
397 ) -> LightningPayStateMachine {
398 match result {
399 Ok(preimage) => {
400 set_payment_result(
401 &mut dbtx.module_tx(),
402 payment_hash,
403 PayType::Lightning(old_state.common.operation_id),
404 contract_id,
405 common.gateway_fee,
406 )
407 .await;
408
409 if let Some(ref client_ctx) = context.client_ctx
411 && let Some(preimage_bytes) = fedimint_core::hex::decode(&preimage)
412 .ok()
413 .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
414 {
415 client_ctx
416 .log_event(
417 &mut dbtx.module_tx(),
418 crate::events::SendPaymentUpdateEvent {
419 operation_id: old_state.common.operation_id,
420 status: crate::events::SendPaymentStatus::Success(preimage_bytes),
421 },
422 )
423 .await;
424 }
425
426 LightningPayStateMachine {
427 common: old_state.common,
428 state: LightningPayStates::Success(preimage),
429 }
430 }
431 Err(e) => LightningPayStateMachine {
432 common: old_state.common,
433 state: LightningPayStates::Failure(e.to_string()),
434 },
435 }
436 }
437}
438
439#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
440pub struct LightningPayRefundable {
443 contract_id: ContractId,
444 pub block_timelock: u32,
445 pub error: GatewayPayError,
446}
447
448impl LightningPayRefundable {
449 fn transitions(
450 &self,
451 common: LightningPayCommon,
452 context: LightningClientContext,
453 global_context: DynGlobalClientContext,
454 ) -> Vec<StateTransition<LightningPayStateMachine>> {
455 let contract_id = self.contract_id;
456 let timeout_global_context = global_context.clone();
457 let timeout_common = common.clone();
458 let timelock = self.block_timelock;
459 let cancel_context = context.clone();
460 let timeout_context = context;
461 vec![
462 StateTransition::new(
463 await_contract_cancelled(contract_id, global_context.clone()),
464 move |dbtx, (), old_state| {
465 let cancel_context = cancel_context.clone();
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 cancel_context,
473 ))
474 },
475 ),
476 StateTransition::new(
477 await_contract_timeout(timeout_global_context.clone(), timelock),
478 move |dbtx, (), old_state| {
479 let timeout_context = timeout_context.clone();
480 Box::pin(try_refund_outgoing_contract(
481 old_state,
482 timeout_common.clone(),
483 dbtx,
484 timeout_global_context.clone(),
485 format!(
486 "Refundable: Outgoing contract timed out. ContractId: {contract_id} BlockHeight: {timelock}"
487 ),
488 timeout_context,
489 ))
490 },
491 ),
492 ]
493 }
494}
495
496async fn await_contract_cancelled(contract_id: ContractId, global_context: DynGlobalClientContext) {
498 loop {
499 match global_context
502 .module_api()
503 .wait_outgoing_contract_cancelled(contract_id)
504 .await
505 {
506 Ok(_) => return,
507 Err(error) => {
508 info!(target: LOG_CLIENT_MODULE_LN, err = %error.fmt_compact(), "Error waiting for outgoing contract to be cancelled");
509 }
510 }
511
512 sleep(RETRY_DELAY).await;
513 }
514}
515
516async fn await_contract_timeout(global_context: DynGlobalClientContext, timelock: u32) {
519 global_context
520 .module_api()
521 .wait_block_height(u64::from(timelock))
522 .await;
523}
524
525async fn try_refund_outgoing_contract(
531 old_state: LightningPayStateMachine,
532 common: LightningPayCommon,
533 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
534 global_context: DynGlobalClientContext,
535 error_reason: String,
536 context: LightningClientContext,
537) -> LightningPayStateMachine {
538 let contract_data = common.contract;
539 let (refund_key, refund_input) = (
540 contract_data.recovery_key,
541 contract_data.contract_account.refund(),
542 );
543
544 let refund_client_input = ClientInput::<LightningInput> {
545 input: refund_input,
546 amounts: Amounts::new_bitcoin(contract_data.contract_account.amount),
547 keys: vec![refund_key],
548 };
549
550 let change_range = global_context
551 .claim_inputs(
552 dbtx,
553 ClientInputBundle::new_no_sm(vec![refund_client_input]),
556 )
557 .await
558 .expect("Cannot claim input, additional funding needed");
559
560 if let Some(ref client_ctx) = context.client_ctx {
562 client_ctx
563 .log_event(
564 &mut dbtx.module_tx(),
565 crate::events::SendPaymentUpdateEvent {
566 operation_id: old_state.common.operation_id,
567 status: crate::events::SendPaymentStatus::Refunded,
568 },
569 )
570 .await;
571 }
572
573 LightningPayStateMachine {
574 common: old_state.common,
575 state: LightningPayStates::Refund(LightningPayRefund {
576 txid: change_range.txid(),
577 out_points: change_range.into_iter().collect(),
578 error_reason,
579 }),
580 }
581}
582
583#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
584pub struct LightningPayRefund {
585 pub txid: TransactionId,
586 pub out_points: Vec<OutPoint>,
587 pub error_reason: String,
588}
589
590#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
591pub struct PayInvoicePayload {
592 pub federation_id: FederationId,
593 pub contract_id: ContractId,
594 pub payment_data: PaymentData,
596 pub preimage_auth: sha256::Hash,
597}
598
599impl PayInvoicePayload {
600 fn new(common: LightningPayCommon) -> Self {
601 Self {
602 contract_id: common.contract.contract_account.contract.contract_id(),
603 federation_id: common.federation_id,
604 preimage_auth: common.preimage_auth,
605 payment_data: PaymentData::Invoice(common.invoice),
606 }
607 }
608
609 fn new_pruned(common: LightningPayCommon) -> Self {
610 Self {
611 contract_id: common.contract.contract_account.contract.contract_id(),
612 federation_id: common.federation_id,
613 preimage_auth: common.preimage_auth,
614 payment_data: PaymentData::PrunedInvoice(
615 common.invoice.try_into().expect("Invoice has amount"),
616 ),
617 }
618 }
619}
620
621#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Decodable, Encodable)]
624#[serde(rename_all = "snake_case")]
625pub enum PaymentData {
626 Invoice(Bolt11Invoice),
627 PrunedInvoice(PrunedInvoice),
628}
629
630impl PaymentData {
631 pub fn amount(&self) -> Option<Amount> {
632 match self {
633 PaymentData::Invoice(invoice) => {
634 invoice.amount_milli_satoshis().map(Amount::from_msats)
635 }
636 PaymentData::PrunedInvoice(PrunedInvoice { amount, .. }) => Some(*amount),
637 }
638 }
639
640 pub fn destination(&self) -> secp256k1::PublicKey {
641 match self {
642 PaymentData::Invoice(invoice) => invoice
643 .payee_pub_key()
644 .copied()
645 .unwrap_or_else(|| invoice.recover_payee_pub_key()),
646 PaymentData::PrunedInvoice(PrunedInvoice { destination, .. }) => *destination,
647 }
648 }
649
650 pub fn payment_hash(&self) -> sha256::Hash {
651 match self {
652 PaymentData::Invoice(invoice) => *invoice.payment_hash(),
653 PaymentData::PrunedInvoice(PrunedInvoice { payment_hash, .. }) => *payment_hash,
654 }
655 }
656
657 pub fn route_hints(&self) -> Vec<RouteHint> {
658 match self {
659 PaymentData::Invoice(invoice) => {
660 invoice.route_hints().into_iter().map(Into::into).collect()
661 }
662 PaymentData::PrunedInvoice(PrunedInvoice { route_hints, .. }) => route_hints.clone(),
663 }
664 }
665
666 pub fn is_expired(&self) -> bool {
667 self.expiry_timestamp() < duration_since_epoch().as_secs()
668 }
669
670 pub fn expiry_timestamp(&self) -> u64 {
672 match self {
673 PaymentData::Invoice(invoice) => invoice.expires_at().map_or(u64::MAX, |t| t.as_secs()),
674 PaymentData::PrunedInvoice(PrunedInvoice {
675 expiry_timestamp, ..
676 }) => *expiry_timestamp,
677 }
678 }
679}