1use std::time::Duration;
2
3use fedimint_api_client::api::DynModuleApi;
4use fedimint_client_module::DynGlobalClientContext;
5use fedimint_client_module::module::OutPointRange;
6use fedimint_client_module::sm::{ClientSMDatabaseTransaction, DynState, State, StateTransition};
7use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
8use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, OperationId};
9use fedimint_core::encoding::{Decodable, Encodable};
10use fedimint_core::module::Amounts;
11use fedimint_core::secp256k1::Keypair;
12use fedimint_core::task::sleep;
13use fedimint_core::util::FmtCompact as _;
14use fedimint_core::{OutPoint, TransactionId};
15use fedimint_ln_common::LightningInput;
16use fedimint_ln_common::contracts::incoming::IncomingContractAccount;
17use fedimint_ln_common::contracts::{DecryptedPreimage, FundedContract};
18use fedimint_ln_common::federation_endpoint_constants::ACCOUNT_ENDPOINT;
19use fedimint_logging::LOG_CLIENT_MODULE_LN;
20use lightning_invoice::Bolt11Invoice;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23use tracing::{debug, info};
24
25use crate::api::LnFederationApi;
26use crate::{LightningClientContext, ReceivingKey};
27
28const RETRY_DELAY: Duration = Duration::from_secs(1);
29
30#[cfg_attr(doc, aquamarine::aquamarine)]
31#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
45pub enum LightningReceiveStates {
46 SubmittedOffer(LightningReceiveSubmittedOffer),
47 Canceled(LightningReceiveError),
48 ConfirmedInvoice(LightningReceiveConfirmedInvoice),
49 Funded(LightningReceiveFunded),
50 Success(Vec<OutPoint>),
51}
52
53#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
54pub struct LightningReceiveStateMachine {
55 pub operation_id: OperationId,
56 pub state: LightningReceiveStates,
57}
58
59impl State for LightningReceiveStateMachine {
60 type ModuleContext = LightningClientContext;
61
62 fn transitions(
63 &self,
64 context: &Self::ModuleContext,
65 global_context: &DynGlobalClientContext,
66 ) -> Vec<StateTransition<Self>> {
67 match &self.state {
68 LightningReceiveStates::SubmittedOffer(submitted_offer) => {
69 submitted_offer.transitions(global_context)
70 }
71 LightningReceiveStates::ConfirmedInvoice(confirmed_invoice) => {
72 confirmed_invoice.transitions(context, global_context)
73 }
74 LightningReceiveStates::Funded(funded) => funded.transitions(global_context),
75 LightningReceiveStates::Success(_) | LightningReceiveStates::Canceled(_) => {
76 vec![]
77 }
78 }
79 }
80
81 fn operation_id(&self) -> fedimint_core::core::OperationId {
82 self.operation_id
83 }
84}
85
86impl IntoDynInstance for LightningReceiveStateMachine {
87 type DynType = DynState;
88
89 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
90 DynState::from_typed(instance_id, self)
91 }
92}
93
94#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
96pub struct LightningReceiveSubmittedOfferV0 {
97 pub offer_txid: TransactionId,
98 pub invoice: Bolt11Invoice,
99 pub payment_keypair: Keypair,
100}
101
102#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
103pub struct LightningReceiveSubmittedOffer {
104 pub offer_txid: TransactionId,
105 pub invoice: Bolt11Invoice,
106 pub receiving_key: ReceivingKey,
107}
108
109#[derive(
110 Error, Clone, Debug, Serialize, Deserialize, Encodable, Decodable, Eq, PartialEq, Hash,
111)]
112#[serde(rename_all = "snake_case")]
113pub enum LightningReceiveError {
114 #[error("Offer transaction was rejected")]
115 Rejected,
116 #[error("Incoming Lightning invoice was not paid within the timeout")]
117 Timeout,
118 #[error("Claim transaction was rejected")]
119 ClaimRejected,
120 #[error("The decrypted preimage was invalid")]
121 InvalidPreimage,
122}
123
124impl LightningReceiveSubmittedOffer {
125 fn transitions(
126 &self,
127 global_context: &DynGlobalClientContext,
128 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
129 let global_context = global_context.clone();
130 let txid = self.offer_txid;
131 let invoice = self.invoice.clone();
132 let receiving_key = self.receiving_key;
133 vec![StateTransition::new(
134 Self::await_invoice_confirmation(global_context, txid),
135 move |_dbtx, result, old_state| {
136 let invoice = invoice.clone();
137 Box::pin(async move {
138 Self::transition_confirmed_invoice(&result, &old_state, invoice, receiving_key)
139 })
140 },
141 )]
142 }
143
144 async fn await_invoice_confirmation(
145 global_context: DynGlobalClientContext,
146 txid: TransactionId,
147 ) -> Result<(), String> {
148 global_context.await_tx_accepted(txid).await
151 }
152
153 fn transition_confirmed_invoice(
154 result: &Result<(), String>,
155 old_state: &LightningReceiveStateMachine,
156 invoice: Bolt11Invoice,
157 receiving_key: ReceivingKey,
158 ) -> LightningReceiveStateMachine {
159 match result {
160 Ok(()) => LightningReceiveStateMachine {
161 operation_id: old_state.operation_id,
162 state: LightningReceiveStates::ConfirmedInvoice(LightningReceiveConfirmedInvoice {
163 invoice,
164 receiving_key,
165 }),
166 },
167 Err(_) => LightningReceiveStateMachine {
168 operation_id: old_state.operation_id,
169 state: LightningReceiveStates::Canceled(LightningReceiveError::Rejected),
170 },
171 }
172 }
173}
174
175#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
176pub struct LightningReceiveConfirmedInvoice {
177 pub(crate) invoice: Bolt11Invoice,
178 pub(crate) receiving_key: ReceivingKey,
179}
180
181impl LightningReceiveConfirmedInvoice {
182 fn transitions(
183 &self,
184 context: &LightningClientContext,
185 global_context: &DynGlobalClientContext,
186 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
187 let invoice = self.invoice.clone();
188 let receiving_key = self.receiving_key;
189 let global_context = global_context.clone();
190 let context = context.clone();
191 vec![StateTransition::new(
192 Self::await_incoming_contract_account(invoice, global_context.clone()),
193 move |dbtx, contract, old_state| {
194 let context = context.clone();
195 Box::pin(Self::transition_funded(
196 old_state,
197 receiving_key,
198 contract,
199 dbtx,
200 global_context.clone(),
201 context,
202 ))
203 },
204 )]
205 }
206
207 async fn await_incoming_contract_account(
208 invoice: Bolt11Invoice,
209 global_context: DynGlobalClientContext,
210 ) -> Result<IncomingContractAccount, LightningReceiveError> {
211 let contract_id = (*invoice.payment_hash()).into();
212 loop {
213 let now_epoch = fedimint_core::time::duration_since_epoch();
215 match get_incoming_contract(global_context.module_api(), contract_id).await {
216 Ok(Some(incoming_contract_account)) => {
217 match incoming_contract_account.contract.decrypted_preimage {
218 DecryptedPreimage::Pending => {
219 info!("Waiting for preimage decryption for contract {contract_id}");
222 }
223 DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
224 DecryptedPreimage::Invalid => {
225 return Err(LightningReceiveError::InvalidPreimage);
226 }
227 }
228 }
229 Ok(None) => {
230 const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_mins(1);
233 if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
234 return Err(LightningReceiveError::Timeout);
235 }
236 debug!("Still waiting preimage decryption for contract {contract_id}");
237 }
238 Err(error) => {
239 error.report_if_unusual("Awaiting incoming contract");
240 debug!(
241 target: LOG_CLIENT_MODULE_LN,
242 err = %error.fmt_compact(),
243 "External LN payment retryable error waiting for preimage decryption"
244 );
245 }
246 }
247 sleep(RETRY_DELAY).await;
248 }
249 }
250
251 async fn transition_funded(
252 old_state: LightningReceiveStateMachine,
253 receiving_key: ReceivingKey,
254 result: Result<IncomingContractAccount, LightningReceiveError>,
255 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
256 global_context: DynGlobalClientContext,
257 context: LightningClientContext,
258 ) -> LightningReceiveStateMachine {
259 match result {
260 Ok(contract) => {
261 if let Some(ref client_ctx) = context.client_ctx {
263 client_ctx
264 .log_event(
265 &mut dbtx.module_tx(),
266 crate::events::ReceivePaymentEvent {
267 operation_id: old_state.operation_id,
268 amount: contract.amount,
269 },
270 )
271 .await;
272 }
273
274 match receiving_key {
275 ReceivingKey::Personal(keypair) => {
276 let change_range =
277 Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
278 .await;
279 LightningReceiveStateMachine {
280 operation_id: old_state.operation_id,
281 state: LightningReceiveStates::Funded(LightningReceiveFunded {
282 txid: change_range.txid(),
283 out_points: change_range.into_iter().collect(),
284 }),
285 }
286 }
287 ReceivingKey::External(_) => {
288 LightningReceiveStateMachine {
290 operation_id: old_state.operation_id,
291 state: LightningReceiveStates::Success(vec![]),
292 }
293 }
294 }
295 }
296 Err(e) => LightningReceiveStateMachine {
297 operation_id: old_state.operation_id,
298 state: LightningReceiveStates::Canceled(e),
299 },
300 }
301 }
302
303 async fn claim_incoming_contract(
304 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
305 contract: IncomingContractAccount,
306 keypair: Keypair,
307 global_context: DynGlobalClientContext,
308 ) -> OutPointRange {
309 let input = contract.claim();
310 let client_input = ClientInput::<LightningInput> {
311 input,
312 amounts: Amounts::new_bitcoin(contract.amount),
313 keys: vec![keypair],
314 };
315
316 global_context
317 .claim_inputs(
318 dbtx,
319 ClientInputBundle::new_no_sm(vec![client_input]),
322 )
323 .await
324 .expect("Cannot claim input, additional funding needed")
325 }
326}
327
328fn has_invoice_expired(
329 invoice: &Bolt11Invoice,
330 now_epoch: Duration,
331 clock_skew_tolerance: Duration,
332) -> bool {
333 assert!(now_epoch >= clock_skew_tolerance);
334 invoice.would_expire(now_epoch.checked_sub(clock_skew_tolerance).unwrap())
336}
337
338pub async fn get_incoming_contract(
339 module_api: DynModuleApi,
340 contract_id: fedimint_ln_common::contracts::ContractId,
341) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
342 match module_api.fetch_contract(contract_id).await {
343 Ok(Some(contract)) => {
344 if let FundedContract::Incoming(incoming) = contract.contract {
345 Ok(Some(IncomingContractAccount {
346 amount: contract.amount,
347 contract: incoming.contract,
348 }))
349 } else {
350 Err(fedimint_api_client::api::FederationError::general(
351 ACCOUNT_ENDPOINT,
352 contract_id,
353 anyhow::anyhow!("Contract {contract_id} is not an incoming contract"),
354 ))
355 }
356 }
357 Ok(None) => Ok(None),
358 Err(e) => Err(e),
359 }
360}
361
362#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
363pub struct LightningReceiveFunded {
364 txid: TransactionId,
365 out_points: Vec<OutPoint>,
366}
367
368impl LightningReceiveFunded {
369 fn transitions(
370 &self,
371 global_context: &DynGlobalClientContext,
372 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
373 let out_points = self.out_points.clone();
374 vec![StateTransition::new(
375 Self::await_claim_success(global_context.clone(), self.txid),
376 move |_dbtx, result, old_state| {
377 let out_points = out_points.clone();
378 Box::pin(
379 async move { Self::transition_claim_success(&result, &old_state, out_points) },
380 )
381 },
382 )]
383 }
384
385 async fn await_claim_success(
386 global_context: DynGlobalClientContext,
387 txid: TransactionId,
388 ) -> Result<(), String> {
389 global_context.await_tx_accepted(txid).await
392 }
393
394 fn transition_claim_success(
395 result: &Result<(), String>,
396 old_state: &LightningReceiveStateMachine,
397 out_points: Vec<OutPoint>,
398 ) -> LightningReceiveStateMachine {
399 match result {
400 Ok(()) => {
401 LightningReceiveStateMachine {
403 operation_id: old_state.operation_id,
404 state: LightningReceiveStates::Success(out_points),
405 }
406 }
407 Err(_) => {
408 LightningReceiveStateMachine {
410 operation_id: old_state.operation_id,
411 state: LightningReceiveStates::Canceled(LightningReceiveError::ClaimRejected),
412 }
413 }
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use bitcoin::hashes::{Hash, sha256};
421 use fedimint_core::secp256k1::{Secp256k1, SecretKey};
422 use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
423
424 use super::*;
425
426 #[test]
427 fn test_invoice_expiration() -> anyhow::Result<()> {
428 let now = fedimint_core::time::duration_since_epoch();
429 let one_second = Duration::from_secs(1);
430 for expiration in [one_second, Duration::from_hours(1)] {
431 for tolerance in [one_second, Duration::from_mins(1)] {
432 let invoice = invoice(now, expiration)?;
433 assert!(!has_invoice_expired(
434 &invoice,
435 now.checked_sub(one_second).unwrap(),
436 tolerance
437 ));
438 assert!(!has_invoice_expired(&invoice, now, tolerance));
439 assert!(!has_invoice_expired(&invoice, now + expiration, tolerance));
440 assert!(!has_invoice_expired(
441 &invoice,
442 (now + expiration + tolerance)
443 .checked_sub(one_second)
444 .unwrap(),
445 tolerance
446 ));
447 assert!(has_invoice_expired(
448 &invoice,
449 now + expiration + tolerance,
450 tolerance
451 ));
452 assert!(has_invoice_expired(
453 &invoice,
454 now + expiration + tolerance + one_second,
455 tolerance
456 ));
457 }
458 }
459 Ok(())
460 }
461
462 fn invoice(now_epoch: Duration, expiry_time: Duration) -> anyhow::Result<Bolt11Invoice> {
463 let ctx = Secp256k1::new();
464 let secret_key = SecretKey::new(&mut rand::thread_rng());
465 Ok(InvoiceBuilder::new(Currency::Regtest)
466 .description(String::new())
467 .payment_hash(sha256::Hash::hash(&[0; 32]))
468 .duration_since_epoch(now_epoch)
469 .min_final_cltv_expiry_delta(0)
470 .payment_secret(PaymentSecret([0; 32]))
471 .amount_milli_satoshis(1000)
472 .expiry_time(expiry_time)
473 .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &secret_key))?)
474 }
475}