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