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