Skip to main content

fedimint_testing/
ln.rs

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    /// Payment hashes this node has dispatched, mirroring a real node's
46    /// outbound payment store so `outbound_payment_exists` can distinguish a
47    /// payment sent before a state machine restart from one that never left
48    /// the gateway.
49    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    /// Creates an invoice that is not payable
105    ///
106    /// * Mocks use hard-coded invoice description to fail the payment
107    /// * Real fixtures won't be able to route to randomly generated node pubkey
108    pub fn unpayable_invoice(&self, amount: Amount, expiry_time: Option<u64>) -> Bolt11Invoice {
109        let ctx = secp256k1::Secp256k1::new();
110        // Generate fake node keypair
111        let kp = Keypair::new(&ctx, &mut OsRng);
112        let payment_hash = sha256::Hash::hash(&MOCK_INVOICE_PREIMAGE);
113
114        // `FakeLightningTest` will fail to pay any invoice with
115        // `INVALID_INVOICE_DESCRIPTION` in the description of the invoice.
116        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        // `FakeLightningTest` will never intercept any HTLCs because there is no
228        // lightning connection, so instead we just create a stream that blocks
229        // until the task group is shutdown.
230        let (_, mut receiver) = mpsc::channel::<InterceptPaymentRequest>(0);
231        let stream: BoxStream<'a, InterceptPaymentRequest> = Box::pin(stream! {
232            shutdown_receiver.await;
233            // This block, and `receiver`, exist solely to satisfy the type checker.
234            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    /// The fake's outbound record backs `outbound_payment_exists`, which the
400    /// gateway state machines use to distinguish a payment dispatched before
401    /// a restart from one that never left the gateway. A record that answered
402    /// wrongly would make the resume-past-expiry tests pass or fail for the
403    /// wrong reason.
404    #[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}