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