1use std::collections::HashSet;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use async_stream::stream;
7use async_trait::async_trait;
8use bitcoin::hashes::{Hash, sha256};
9use bitcoin::key::Keypair;
10use bitcoin::secp256k1::{self, PublicKey, SecretKey};
11use fedimint_core::Amount;
12use fedimint_core::task::TaskGroup;
13use fedimint_core::util::BoxStream;
14use fedimint_gateway_common::{
15 CloseChannelsWithPeerRequest, CloseChannelsWithPeerResponse, ConnectPeerRequest,
16 GetInvoiceRequest, GetInvoiceResponse, ListTransactionsResponse, OpenChannelRequest,
17 SendOnchainRequest, SetChannelFeesRequest,
18};
19use fedimint_lightning::{
20 CreateInvoiceRequest, CreateInvoiceResponse, GetBalancesResponse, GetLnOnchainAddressResponse,
21 GetNodeInfoResponse, GetRouteHintsResponse, ILnRpcClient, InterceptPaymentRequest,
22 InterceptPaymentResponse, LightningRpcError, ListChannelsResponse, OpenChannelResponse,
23 PayInvoiceResponse, RouteHtlcStream, SendOnchainResponse,
24};
25use fedimint_ln_common::PrunedInvoice;
26use fedimint_ln_common::contracts::Preimage;
27use fedimint_ln_common::route_hints::RouteHint;
28use fedimint_logging::LOG_TEST;
29use lightning_invoice::{
30 Bolt11Invoice, Currency, DEFAULT_EXPIRY_TIME, InvoiceBuilder, PaymentSecret,
31};
32use rand::rngs::OsRng;
33use tokio::sync::mpsc;
34use tracing::info;
35
36pub const INVALID_INVOICE_PAYMENT_SECRET: [u8; 32] = [212; 32];
37
38pub const MOCK_INVOICE_PREIMAGE: [u8; 32] = [1; 32];
39
40#[derive(Debug)]
41pub struct FakeLightningTest {
42 pub gateway_node_pub_key: secp256k1::PublicKey,
43 gateway_node_sec_key: secp256k1::SecretKey,
44 amount_sent: AtomicU64,
45 outbound_payments: Mutex<HashSet<sha256::Hash>>,
50}
51
52impl FakeLightningTest {
53 pub fn new() -> Self {
54 info!(target: LOG_TEST, "Setting up fake lightning test fixture");
55 let ctx = bitcoin::secp256k1::Secp256k1::new();
56 let kp = Keypair::new(&ctx, &mut OsRng);
57 let amount_sent = AtomicU64::new(0);
58
59 FakeLightningTest {
60 gateway_node_sec_key: SecretKey::from_keypair(&kp),
61 gateway_node_pub_key: PublicKey::from_keypair(&kp),
62 amount_sent,
63 outbound_payments: Mutex::new(HashSet::new()),
64 }
65 }
66
67 fn record_outbound_payment(&self, payment_hash: sha256::Hash) {
68 self.outbound_payments
69 .lock()
70 .expect("Not poisoned")
71 .insert(payment_hash);
72 }
73}
74
75impl Default for FakeLightningTest {
76 fn default() -> Self {
77 Self::new()
78 }
79}
80
81impl FakeLightningTest {
82 pub fn invoice(
83 &self,
84 amount: Amount,
85 expiry_time: Option<u64>,
86 ) -> fedimint_gateway_server::Result<Bolt11Invoice> {
87 let ctx = bitcoin::secp256k1::Secp256k1::new();
88 let payment_hash = sha256::Hash::hash(&MOCK_INVOICE_PREIMAGE);
89
90 Ok(InvoiceBuilder::new(Currency::Regtest)
91 .description(String::new())
92 .payment_hash(payment_hash)
93 .current_timestamp()
94 .min_final_cltv_expiry_delta(0)
95 .payment_secret(PaymentSecret([0; 32]))
96 .amount_milli_satoshis(amount.msats)
97 .expiry_time(Duration::from_secs(
98 expiry_time.unwrap_or(DEFAULT_EXPIRY_TIME),
99 ))
100 .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &self.gateway_node_sec_key))
101 .unwrap())
102 }
103
104 pub fn unpayable_invoice(&self, amount: Amount, expiry_time: Option<u64>) -> Bolt11Invoice {
109 let ctx = secp256k1::Secp256k1::new();
110 let kp = Keypair::new(&ctx, &mut OsRng);
112 let payment_hash = sha256::Hash::hash(&MOCK_INVOICE_PREIMAGE);
113
114 InvoiceBuilder::new(Currency::Regtest)
117 .payee_pub_key(kp.public_key())
118 .description("INVALID INVOICE DESCRIPTION".to_string())
119 .payment_hash(payment_hash)
120 .current_timestamp()
121 .min_final_cltv_expiry_delta(0)
122 .payment_secret(PaymentSecret(INVALID_INVOICE_PAYMENT_SECRET))
123 .amount_milli_satoshis(amount.msats)
124 .expiry_time(Duration::from_secs(
125 expiry_time.unwrap_or(DEFAULT_EXPIRY_TIME),
126 ))
127 .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &SecretKey::from_keypair(&kp)))
128 .expect("Invoice creation failed")
129 }
130
131 pub fn listening_address(&self) -> String {
132 "FakeListeningAddress".to_string()
133 }
134}
135
136#[async_trait]
137impl ILnRpcClient for FakeLightningTest {
138 async fn info(&self) -> Result<GetNodeInfoResponse, LightningRpcError> {
139 Ok(GetNodeInfoResponse {
140 pub_key: self.gateway_node_pub_key,
141 alias: "FakeLightningNode".to_string(),
142 network: "regtest".to_string(),
143 block_height: 0,
144 synced_to_chain: false,
145 })
146 }
147
148 async fn routehints(
149 &self,
150 _num_route_hints: usize,
151 ) -> Result<GetRouteHintsResponse, LightningRpcError> {
152 Ok(GetRouteHintsResponse {
153 route_hints: vec![RouteHint(vec![])],
154 })
155 }
156
157 async fn pay(
158 &self,
159 invoice: Bolt11Invoice,
160 _max_delay: u64,
161 _max_fee: Amount,
162 ) -> Result<PayInvoiceResponse, LightningRpcError> {
163 self.amount_sent.fetch_add(
164 invoice
165 .amount_milli_satoshis()
166 .expect("Invoice missing amount"),
167 Ordering::Relaxed,
168 );
169
170 if *invoice.payment_secret() == PaymentSecret(INVALID_INVOICE_PAYMENT_SECRET) {
171 return Err(LightningRpcError::FailedPayment {
172 failure_reason: "Invoice was invalid".to_string(),
173 });
174 }
175
176 self.record_outbound_payment(*invoice.payment_hash());
177
178 Ok(PayInvoiceResponse {
179 preimage: Preimage(MOCK_INVOICE_PREIMAGE),
180 })
181 }
182
183 fn supports_private_payments(&self) -> bool {
184 true
185 }
186
187 async fn outbound_payment_exists(
188 &self,
189 payment_hash: sha256::Hash,
190 ) -> Result<bool, LightningRpcError> {
191 Ok(self
192 .outbound_payments
193 .lock()
194 .expect("Not poisoned")
195 .contains(&payment_hash))
196 }
197
198 async fn pay_private(
199 &self,
200 invoice: PrunedInvoice,
201 _max_delay: u64,
202 _max_fee: Amount,
203 ) -> Result<PayInvoiceResponse, LightningRpcError> {
204 self.amount_sent
205 .fetch_add(invoice.amount.msats, Ordering::Relaxed);
206
207 if invoice.payment_secret == INVALID_INVOICE_PAYMENT_SECRET {
208 return Err(LightningRpcError::FailedPayment {
209 failure_reason: "Invoice was invalid".to_string(),
210 });
211 }
212
213 self.record_outbound_payment(invoice.payment_hash);
214
215 Ok(PayInvoiceResponse {
216 preimage: Preimage(MOCK_INVOICE_PREIMAGE),
217 })
218 }
219
220 async fn route_htlcs<'a>(
221 self: Box<Self>,
222 task_group: &TaskGroup,
223 ) -> Result<(RouteHtlcStream<'a>, Arc<dyn ILnRpcClient>), LightningRpcError> {
224 let handle = task_group.make_handle();
225 let shutdown_receiver = handle.make_shutdown_rx();
226
227 let (_, mut receiver) = mpsc::channel::<InterceptPaymentRequest>(0);
231 let stream: BoxStream<'a, InterceptPaymentRequest> = Box::pin(stream! {
232 shutdown_receiver.await;
233 if let Some(htlc_result) = receiver.recv().await {
235 yield htlc_result;
236 }
237 });
238 Ok((stream, Arc::new(Self::new())))
239 }
240
241 async fn complete_htlc(
242 &self,
243 _htlc: InterceptPaymentResponse,
244 ) -> Result<(), LightningRpcError> {
245 Ok(())
246 }
247
248 async fn create_invoice(
249 &self,
250 create_invoice_request: CreateInvoiceRequest,
251 ) -> Result<CreateInvoiceResponse, LightningRpcError> {
252 let ctx = secp256k1::Secp256k1::new();
253
254 let invoice = match create_invoice_request.payment_hash {
255 Some(payment_hash) => InvoiceBuilder::new(Currency::Regtest)
256 .description(String::new())
257 .payment_hash(payment_hash)
258 .current_timestamp()
259 .min_final_cltv_expiry_delta(0)
260 .payment_secret(PaymentSecret([0; 32]))
261 .amount_milli_satoshis(create_invoice_request.amount_msat)
262 .expiry_time(Duration::from_secs(u64::from(
263 create_invoice_request.expiry_secs,
264 )))
265 .build_signed(|m| ctx.sign_ecdsa_recoverable(m, &self.gateway_node_sec_key))
266 .unwrap(),
267 None => {
268 return Err(LightningRpcError::FailedToGetInvoice {
269 failure_reason: "FakeLightningTest does not support creating invoices without a payment hash".to_string(),
270 });
271 }
272 };
273
274 Ok(CreateInvoiceResponse {
275 invoice: invoice.to_string(),
276 })
277 }
278
279 async fn get_ln_onchain_address(
280 &self,
281 ) -> Result<GetLnOnchainAddressResponse, LightningRpcError> {
282 Err(LightningRpcError::FailedToGetLnOnchainAddress {
283 failure_reason: "FakeLightningTest does not support getting a funding address"
284 .to_string(),
285 })
286 }
287
288 async fn send_onchain(
289 &self,
290 _payload: SendOnchainRequest,
291 ) -> Result<SendOnchainResponse, LightningRpcError> {
292 Err(LightningRpcError::FailedToWithdrawOnchain {
293 failure_reason: "FakeLightningTest does not support withdrawing funds on-chain"
294 .to_string(),
295 })
296 }
297
298 async fn open_channel(
299 &self,
300 _payload: OpenChannelRequest,
301 ) -> Result<OpenChannelResponse, LightningRpcError> {
302 Err(LightningRpcError::FailedToOpenChannel {
303 failure_reason: "FakeLightningTest does not support opening channels".to_string(),
304 })
305 }
306
307 async fn connect_peer(&self, _payload: ConnectPeerRequest) -> Result<(), LightningRpcError> {
308 Err(LightningRpcError::FailedToConnectToPeer {
309 failure_reason: "FakeLightningTest does not support connecting to peers".to_string(),
310 })
311 }
312
313 async fn close_channels_with_peer(
314 &self,
315 _payload: CloseChannelsWithPeerRequest,
316 ) -> Result<CloseChannelsWithPeerResponse, LightningRpcError> {
317 Err(LightningRpcError::FailedToCloseChannelsWithPeer {
318 failure_reason: "FakeLightningTest does not support closing channels by peer"
319 .to_string(),
320 })
321 }
322
323 async fn list_channels(&self) -> Result<ListChannelsResponse, LightningRpcError> {
324 Err(LightningRpcError::FailedToListChannels {
325 failure_reason: "FakeLightningTest does not support listing active channels"
326 .to_string(),
327 })
328 }
329
330 async fn set_channel_fees(
331 &self,
332 _payload: SetChannelFeesRequest,
333 ) -> Result<(), LightningRpcError> {
334 Err(LightningRpcError::FailedToSetChannelFees {
335 failure_reason: "FakeLightningTest does not support updating channel fees".to_string(),
336 })
337 }
338
339 async fn get_balances(&self) -> Result<GetBalancesResponse, LightningRpcError> {
340 Ok(GetBalancesResponse {
341 onchain_balance_sats: 0,
342 lightning_balance_msats: 0,
343 inbound_lightning_liquidity_msats: 0,
344 })
345 }
346
347 async fn get_invoice(
348 &self,
349 _get_invoice_request: GetInvoiceRequest,
350 ) -> Result<Option<GetInvoiceResponse>, LightningRpcError> {
351 Err(LightningRpcError::FailedToGetInvoice {
352 failure_reason: "FakeLightningTest does not support getting invoices".to_string(),
353 })
354 }
355
356 async fn list_transactions(
357 &self,
358 _start_secs: u64,
359 _end_secs: u64,
360 ) -> Result<ListTransactionsResponse, LightningRpcError> {
361 Err(LightningRpcError::FailedToListTransactions {
362 failure_reason: "FakeLightningTest does not support listing transactions".to_string(),
363 })
364 }
365
366 fn create_offer(
367 &self,
368 _amount_msat: Option<Amount>,
369 _description: Option<String>,
370 _expiry_secs: Option<u32>,
371 _quantity: Option<u64>,
372 ) -> Result<String, LightningRpcError> {
373 Err(LightningRpcError::Bolt12Error {
374 failure_reason: "FakeLightningTest does not support Bolt12".to_string(),
375 })
376 }
377
378 async fn pay_offer(
379 &self,
380 _offer: String,
381 _quantity: Option<u64>,
382 _amount: Option<Amount>,
383 _payer_note: Option<String>,
384 ) -> Result<Preimage, LightningRpcError> {
385 Err(LightningRpcError::Bolt12Error {
386 failure_reason: "FakeLightningTest does not support Bolt12".to_string(),
387 })
388 }
389
390 fn sync_wallet(&self) -> Result<(), LightningRpcError> {
391 Ok(())
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[tokio::test]
405 async fn outbound_record_tracks_dispatched_payments() {
406 let ln = FakeLightningTest::new();
407 let invoice = ln
408 .invoice(Amount::from_sats(1000), None)
409 .expect("can create invoice");
410 let payment_hash = *invoice.payment_hash();
411
412 assert!(
413 !ln.outbound_payment_exists(payment_hash)
414 .await
415 .expect("fake lookup cannot fail"),
416 "no payment has been dispatched yet"
417 );
418
419 ln.pay(invoice, 0, Amount::ZERO)
420 .await
421 .expect("fake payment succeeds");
422
423 assert!(
424 ln.outbound_payment_exists(payment_hash)
425 .await
426 .expect("fake lookup cannot fail"),
427 "the dispatched payment must be on record"
428 );
429 assert!(
430 !ln.outbound_payment_exists(sha256::Hash::hash(b"never dispatched"))
431 .await
432 .expect("fake lookup cannot fail"),
433 "an unrelated hash must not be reported as dispatched"
434 );
435 }
436}