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