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")]
113#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
114pub enum LightningReceiveError {
115 #[error("Offer transaction was rejected")]
116 Rejected,
117 #[error("Incoming Lightning invoice was not paid within the timeout")]
118 Timeout,
119 #[error("Claim transaction was rejected")]
120 ClaimRejected,
121 #[error("The decrypted preimage was invalid")]
122 InvalidPreimage,
123}
124
125impl LightningReceiveSubmittedOffer {
126 fn transitions(
127 &self,
128 global_context: &DynGlobalClientContext,
129 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
130 let global_context = global_context.clone();
131 let txid = self.offer_txid;
132 let invoice = self.invoice.clone();
133 let receiving_key = self.receiving_key;
134 vec![StateTransition::new(
135 Self::await_invoice_confirmation(global_context, txid),
136 move |_dbtx, result, old_state| {
137 let invoice = invoice.clone();
138 Box::pin(async move {
139 Self::transition_confirmed_invoice(&result, &old_state, invoice, receiving_key)
140 })
141 },
142 )]
143 }
144
145 async fn await_invoice_confirmation(
146 global_context: DynGlobalClientContext,
147 txid: TransactionId,
148 ) -> Result<(), String> {
149 global_context.await_tx_accepted(txid).await
152 }
153
154 fn transition_confirmed_invoice(
155 result: &Result<(), String>,
156 old_state: &LightningReceiveStateMachine,
157 invoice: Bolt11Invoice,
158 receiving_key: ReceivingKey,
159 ) -> LightningReceiveStateMachine {
160 match result {
161 Ok(()) => LightningReceiveStateMachine {
162 operation_id: old_state.operation_id,
163 state: LightningReceiveStates::ConfirmedInvoice(LightningReceiveConfirmedInvoice {
164 invoice,
165 receiving_key,
166 }),
167 },
168 Err(_) => LightningReceiveStateMachine {
169 operation_id: old_state.operation_id,
170 state: LightningReceiveStates::Canceled(LightningReceiveError::Rejected),
171 },
172 }
173 }
174}
175
176#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
177pub struct LightningReceiveConfirmedInvoice {
178 pub(crate) invoice: Bolt11Invoice,
179 pub(crate) receiving_key: ReceivingKey,
180}
181
182impl LightningReceiveConfirmedInvoice {
183 fn transitions(
184 &self,
185 context: &LightningClientContext,
186 global_context: &DynGlobalClientContext,
187 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
188 let invoice = self.invoice.clone();
189 let receiving_key = self.receiving_key;
190 let global_context = global_context.clone();
191 let context = context.clone();
192 vec![StateTransition::new(
193 Self::await_incoming_contract_account(invoice, global_context.clone()),
194 move |dbtx, contract, old_state| {
195 let context = context.clone();
196 Box::pin(Self::transition_funded(
197 old_state,
198 receiving_key,
199 contract,
200 dbtx,
201 global_context.clone(),
202 context,
203 ))
204 },
205 )]
206 }
207
208 async fn await_incoming_contract_account(
209 invoice: Bolt11Invoice,
210 global_context: DynGlobalClientContext,
211 ) -> Result<IncomingContractAccount, LightningReceiveError> {
212 let contract_id = (*invoice.payment_hash()).into();
213 loop {
214 let now_epoch = fedimint_core::time::duration_since_epoch();
216 match get_incoming_contract(global_context.module_api(), contract_id).await {
217 Ok(Some(incoming_contract_account)) => {
218 match incoming_contract_account.contract.decrypted_preimage {
219 DecryptedPreimage::Pending => {
220 info!("Waiting for preimage decryption for contract {contract_id}");
223 }
224 DecryptedPreimage::Some(_) => return Ok(incoming_contract_account),
225 DecryptedPreimage::Invalid => {
226 return Err(LightningReceiveError::InvalidPreimage);
227 }
228 }
229 }
230 Ok(None) => {
231 const CLOCK_SKEW_TOLERANCE: Duration = Duration::from_mins(1);
234 if has_invoice_expired(&invoice, now_epoch, CLOCK_SKEW_TOLERANCE) {
235 return Err(LightningReceiveError::Timeout);
236 }
237 debug!("Still waiting preimage decryption for contract {contract_id}");
238 }
239 Err(error) => {
240 error.report_if_unusual("Awaiting incoming contract");
241 debug!(
242 target: LOG_CLIENT_MODULE_LN,
243 err = %error.fmt_compact(),
244 "External LN payment retryable error waiting for preimage decryption"
245 );
246 }
247 }
248 sleep(RETRY_DELAY).await;
249 }
250 }
251
252 async fn transition_funded(
253 old_state: LightningReceiveStateMachine,
254 receiving_key: ReceivingKey,
255 result: Result<IncomingContractAccount, LightningReceiveError>,
256 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
257 global_context: DynGlobalClientContext,
258 context: LightningClientContext,
259 ) -> LightningReceiveStateMachine {
260 match result {
261 Ok(contract) => {
262 if let Some(ref client_ctx) = context.client_ctx {
264 client_ctx
265 .log_event(
266 &mut dbtx.module_tx(),
267 crate::events::ReceivePaymentEvent {
268 operation_id: old_state.operation_id,
269 amount: contract.amount,
270 },
271 )
272 .await;
273 }
274
275 match receiving_key {
276 ReceivingKey::Personal(keypair) => {
277 let change_range =
278 Self::claim_incoming_contract(dbtx, contract, keypair, global_context)
279 .await;
280 LightningReceiveStateMachine {
281 operation_id: old_state.operation_id,
282 state: LightningReceiveStates::Funded(LightningReceiveFunded {
283 txid: change_range.txid(),
284 out_points: change_range.into_iter().collect(),
285 }),
286 }
287 }
288 ReceivingKey::External(_) => {
289 LightningReceiveStateMachine {
291 operation_id: old_state.operation_id,
292 state: LightningReceiveStates::Success(vec![]),
293 }
294 }
295 }
296 }
297 Err(e) => LightningReceiveStateMachine {
298 operation_id: old_state.operation_id,
299 state: LightningReceiveStates::Canceled(e),
300 },
301 }
302 }
303
304 async fn claim_incoming_contract(
305 dbtx: &mut ClientSMDatabaseTransaction<'_, '_>,
306 contract: IncomingContractAccount,
307 keypair: Keypair,
308 global_context: DynGlobalClientContext,
309 ) -> OutPointRange {
310 let input = contract.claim();
311 let client_input = ClientInput::<LightningInput> {
312 input,
313 amounts: Amounts::new_bitcoin(contract.amount),
314 keys: vec![keypair],
315 };
316
317 global_context
318 .claim_inputs(
319 dbtx,
320 ClientInputBundle::new_no_sm(vec![client_input]),
323 )
324 .await
325 .expect("Cannot claim input, additional funding needed")
326 }
327}
328
329fn has_invoice_expired(
330 invoice: &Bolt11Invoice,
331 now_epoch: Duration,
332 clock_skew_tolerance: Duration,
333) -> bool {
334 assert!(now_epoch >= clock_skew_tolerance);
335 invoice.would_expire(now_epoch.checked_sub(clock_skew_tolerance).unwrap())
337}
338
339pub async fn get_incoming_contract(
340 module_api: DynModuleApi,
341 contract_id: fedimint_ln_common::contracts::ContractId,
342) -> Result<Option<IncomingContractAccount>, fedimint_api_client::api::FederationError> {
343 match module_api.fetch_contract(contract_id).await {
344 Ok(Some(contract)) => {
345 if let FundedContract::Incoming(incoming) = contract.contract {
346 Ok(Some(IncomingContractAccount {
347 amount: contract.amount,
348 contract: incoming.contract,
349 }))
350 } else {
351 Err(fedimint_api_client::api::FederationError::general(
352 ACCOUNT_ENDPOINT,
353 contract_id,
354 anyhow::anyhow!("Contract {contract_id} is not an incoming contract"),
355 ))
356 }
357 }
358 Ok(None) => Ok(None),
359 Err(e) => Err(e),
360 }
361}
362
363#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
364pub struct LightningReceiveFunded {
365 txid: TransactionId,
366 out_points: Vec<OutPoint>,
367}
368
369impl LightningReceiveFunded {
370 fn transitions(
371 &self,
372 global_context: &DynGlobalClientContext,
373 ) -> Vec<StateTransition<LightningReceiveStateMachine>> {
374 let out_points = self.out_points.clone();
375 vec![StateTransition::new(
376 Self::await_claim_success(global_context.clone(), self.txid),
377 move |_dbtx, result, old_state| {
378 let out_points = out_points.clone();
379 Box::pin(
380 async move { Self::transition_claim_success(&result, &old_state, out_points) },
381 )
382 },
383 )]
384 }
385
386 async fn await_claim_success(
387 global_context: DynGlobalClientContext,
388 txid: TransactionId,
389 ) -> Result<(), String> {
390 global_context.await_tx_accepted(txid).await
393 }
394
395 fn transition_claim_success(
396 result: &Result<(), String>,
397 old_state: &LightningReceiveStateMachine,
398 out_points: Vec<OutPoint>,
399 ) -> LightningReceiveStateMachine {
400 match result {
401 Ok(()) => {
402 LightningReceiveStateMachine {
404 operation_id: old_state.operation_id,
405 state: LightningReceiveStates::Success(out_points),
406 }
407 }
408 Err(_) => {
409 LightningReceiveStateMachine {
411 operation_id: old_state.operation_id,
412 state: LightningReceiveStates::Canceled(LightningReceiveError::ClaimRejected),
413 }
414 }
415 }
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use bitcoin::hashes::{Hash, sha256};
422 use fedimint_core::secp256k1::{Secp256k1, SecretKey};
423 use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
424
425 use super::*;
426
427 #[test]
428 fn test_invoice_expiration() -> anyhow::Result<()> {
429 let now = fedimint_core::time::duration_since_epoch();
430 let one_second = Duration::from_secs(1);
431 for expiration in [one_second, Duration::from_hours(1)] {
432 for tolerance in [one_second, Duration::from_mins(1)] {
433 let invoice = invoice(now, expiration)?;
434 assert!(!has_invoice_expired(
435 &invoice,
436 now.checked_sub(one_second).unwrap(),
437 tolerance
438 ));
439 assert!(!has_invoice_expired(&invoice, now, tolerance));
440 assert!(!has_invoice_expired(&invoice, now + expiration, tolerance));
441 assert!(!has_invoice_expired(
442 &invoice,
443 (now + expiration + tolerance)
444 .checked_sub(one_second)
445 .unwrap(),
446 tolerance
447 ));
448 assert!(has_invoice_expired(
449 &invoice,
450 now + expiration + tolerance,
451 tolerance
452 ));
453 assert!(has_invoice_expired(
454 &invoice,
455 now + expiration + tolerance + one_second,
456 tolerance
457 ));
458 }
459 }
460 Ok(())
461 }
462
463 fn invoice(now_epoch: Duration, expiry_time: Duration) -> anyhow::Result<Bolt11Invoice> {
464 let ctx = Secp256k1::new();
465 let secret_key = SecretKey::new(&mut rand::thread_rng());
466 Ok(InvoiceBuilder::new(Currency::Regtest)
467 .description(String::new())
468 .payment_hash(sha256::Hash::hash(&[0; 32]))
469 .duration_since_epoch(now_epoch)
470 .min_final_cltv_expiry_delta(0)
471 .payment_secret(PaymentSecret([0; 32]))
472 .amount_milli_satoshis(1000)
473 .expiry_time(expiry_time)
474 .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &secret_key))?)
475 }
476}