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