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