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