Skip to main content

fedimint_wasm_tests/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::large_futures)]
3#![allow(dead_code)]
4#![allow(clippy::literal_string_with_formatting_args)]
5
6use std::sync::Arc;
7
8use anyhow::Result;
9use fedimint_client::secret::{PlainRootSecretStrategy, RootSecretStrategy};
10use fedimint_client::{Client, RootSecret};
11use fedimint_connectors::ConnectorRegistry;
12use fedimint_core::db::Database;
13use fedimint_core::db::mem_impl::MemDatabase;
14use fedimint_core::invite_code::InviteCode;
15use fedimint_ln_client::{LightningClientInit, LightningClientModule};
16use fedimint_mint_client::MintClientInit;
17use fedimint_wallet_client::WalletClientInit;
18use rand::thread_rng;
19
20async fn load_or_generate_mnemonic(db: &Database) -> anyhow::Result<[u8; 64]> {
21    Ok(
22        if let Ok(s) = Client::load_decodable_client_secret(db).await {
23            s
24        } else {
25            let secret = PlainRootSecretStrategy::random(&mut thread_rng());
26            Client::store_encodable_client_secret(db, secret).await?;
27            secret
28        },
29    )
30}
31
32async fn make_client_builder() -> Result<(fedimint_client::ClientBuilder, Database)> {
33    let mem_database = MemDatabase::default();
34    let mut builder = fedimint_client::Client::builder().await?;
35    builder.with_module(LightningClientInit::default());
36    builder.with_module(MintClientInit);
37    builder.with_module(WalletClientInit::default());
38
39    Ok((builder, mem_database.into()))
40}
41
42async fn client(invite_code: &InviteCode) -> Result<fedimint_client::ClientHandleArc> {
43    let (mut builder, db) = make_client_builder().await?;
44    let client_secret = load_or_generate_mnemonic(&db).await?;
45    let connectors = ConnectorRegistry::build_from_testing_defaults()
46        .bind()
47        .await?;
48    builder.stopped();
49    let client = builder
50        .preview(connectors, invite_code)
51        .await?
52        .join(
53            db,
54            RootSecret::StandardDoubleDerive(PlainRootSecretStrategy::to_root_secret(
55                &client_secret,
56            )),
57        )
58        .await
59        .map(Arc::new)?;
60    if let Ok(ln_client) = client.get_first_module::<LightningClientModule>() {
61        let _ = ln_client.update_gateway_cache().await;
62    }
63    Ok(client)
64}
65
66mod faucet {
67    use anyhow::Context;
68
69    pub async fn invite_code() -> anyhow::Result<String> {
70        let resp = gloo_net::http::Request::get(&url("/connect-string")?)
71            .send()
72            .await?;
73        if resp.ok() {
74            Ok(resp.text().await?)
75        } else {
76            anyhow::bail!(resp.text().await?);
77        }
78    }
79
80    pub async fn pay_invoice(invoice: &str) -> anyhow::Result<()> {
81        let resp = gloo_net::http::Request::post(&url("/pay")?)
82            .body(invoice)?
83            .send()
84            .await?;
85        if resp.ok() {
86            Ok(())
87        } else {
88            anyhow::bail!(resp.text().await?);
89        }
90    }
91
92    pub async fn gateway_api() -> anyhow::Result<String> {
93        let resp = gloo_net::http::Request::get(&url("/gateway-api")?)
94            .send()
95            .await?;
96        if resp.ok() {
97            Ok(resp.text().await?)
98        } else {
99            anyhow::bail!(resp.text().await?);
100        }
101    }
102
103    pub async fn generate_invoice(amt: u64) -> anyhow::Result<String> {
104        let resp = gloo_net::http::Request::post(&url("/invoice")?)
105            .body(amt)?
106            .send()
107            .await?;
108        if resp.ok() {
109            Ok(resp.text().await?)
110        } else {
111            anyhow::bail!(resp.text().await?);
112        }
113    }
114
115    /// Devimint allocates the faucet port when it starts and exports it as
116    /// `FM_PORT_FAUCET` to the command run via `wasm-test-setup --exec`, which
117    /// is how it reaches this build. Browser tests have no other way to learn
118    /// it at runtime.
119    ///
120    /// Only the wasm build captures it. This crate is also compiled natively
121    /// as part of `--workspace` builds, frequently from inside a devimint
122    /// environment, and must not go stale every time devimint hands out a
123    /// different port.
124    #[cfg(target_arch = "wasm32")]
125    const PORT: Option<&str> = option_env!("FM_PORT_FAUCET");
126    #[cfg(not(target_arch = "wasm32"))]
127    const PORT: Option<&str> = None;
128
129    fn url(path: &str) -> anyhow::Result<String> {
130        let port = PORT.context(
131            "FM_PORT_FAUCET was not set when fedimint-wasm-tests was built; \
132             run the tests via `devimint wasm-test-setup --exec`",
133        )?;
134        Ok(format!("http://localhost:{port}{path}"))
135    }
136}
137
138wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
139mod tests {
140    use std::time::Duration;
141
142    use anyhow::{anyhow, bail};
143    use fedimint_core::Amount;
144    use fedimint_derive_secret::DerivableSecret;
145    use fedimint_ln_client::{
146        LightningClientModule, LnPayState, LnReceiveState, OutgoingLightningPayment, PayType,
147    };
148    use fedimint_ln_common::LightningGateway;
149    use fedimint_ln_common::lightning_invoice::{Bolt11InvoiceDescription, Description};
150    use fedimint_mint_client::{
151        MintClientModule, ReissueExternalNotesState, SelectNotesWithAtleastAmount, SpendOOBState,
152    };
153    use futures::StreamExt;
154    use wasm_bindgen_test::wasm_bindgen_test;
155
156    use super::{Result, client, faucet};
157
158    #[wasm_bindgen_test]
159    async fn build_client() -> Result<()> {
160        let _client = client(&faucet::invite_code().await?.parse()?).await?;
161        Ok(())
162    }
163
164    async fn get_gateway(
165        client: &fedimint_client::ClientHandleArc,
166    ) -> anyhow::Result<LightningGateway> {
167        let lightning_module = client.get_first_module::<LightningClientModule>()?;
168        let gws = lightning_module.list_gateways().await;
169        let gw_api = faucet::gateway_api().await?;
170        let lnd_gw = gws
171            .into_iter()
172            .find(|x| x.info.api.to_string() == gw_api)
173            .expect("no gateway with api");
174
175        Ok(lnd_gw.info)
176    }
177
178    #[wasm_bindgen_test]
179    async fn receive() -> Result<()> {
180        let client = client(&faucet::invite_code().await?.parse()?).await?;
181        client.start_executor();
182        let ln_gateway = get_gateway(&client).await?;
183        futures::future::try_join_all(
184            (0..10)
185                .map(|_| receive_once(client.clone(), Amount::from_sats(21), ln_gateway.clone())),
186        )
187        .await?;
188        Ok(())
189    }
190
191    async fn receive_once(
192        client: fedimint_client::ClientHandleArc,
193        amount: Amount,
194        gateway: LightningGateway,
195    ) -> Result<()> {
196        let lightning_module = client.get_first_module::<LightningClientModule>()?;
197        let desc = Description::new("test".to_string())?;
198        let (opid, invoice, _) = lightning_module
199            .create_bolt11_invoice(
200                amount,
201                Bolt11InvoiceDescription::Direct(desc),
202                None,
203                (),
204                Some(gateway),
205            )
206            .await?;
207        faucet::pay_invoice(&invoice.to_string()).await?;
208
209        let mut updates = lightning_module
210            .subscribe_ln_receive(opid)
211            .await?
212            .into_stream();
213        while let Some(update) = updates.next().await {
214            match update {
215                LnReceiveState::Claimed => return Ok(()),
216                LnReceiveState::Canceled { reason } => {
217                    return Err(reason.into());
218                }
219                _ => {}
220            }
221        }
222        Err(anyhow!("Lightning receive failed"))
223    }
224
225    // Tests that ChaCha20 crypto functions used for backup and recovery are
226    // available in WASM at runtime. Related issue: https://github.com/fedimint/fedimint/issues/2843
227    #[wasm_bindgen_test]
228    #[allow(clippy::unused_async)]
229    async fn derive_chacha_key() {
230        let root_secret = DerivableSecret::new_root(&[0x42; 32], &[0x2a; 32]);
231        let key = root_secret.to_chacha20_poly1305_key();
232
233        // Prevent optimization
234        // FIXME: replace with `std::hint::black_box` once stabilized
235        assert!(format!("key: {key:?}").len() > 8);
236    }
237
238    async fn pay_once(
239        client: fedimint_client::ClientHandleArc,
240        ln_gateway: LightningGateway,
241    ) -> Result<(), anyhow::Error> {
242        let lightning_module = client.get_first_module::<LightningClientModule>()?;
243        let bolt11 = faucet::generate_invoice(11).await?;
244        let OutgoingLightningPayment {
245            payment_type,
246            contract_id: _,
247            fee: _,
248        } = lightning_module
249            .pay_bolt11_invoice(Some(ln_gateway), bolt11.parse()?, ())
250            .await?;
251        let PayType::Lightning(operation_id) = payment_type else {
252            unreachable!("paying invoice over lightning");
253        };
254        let lightning_module = client.get_first_module::<LightningClientModule>()?;
255        let mut updates = lightning_module
256            .subscribe_ln_pay(operation_id)
257            .await?
258            .into_stream();
259        loop {
260            match updates.next().await {
261                Some(LnPayState::Success { preimage: _ }) => {
262                    break;
263                }
264                Some(LnPayState::Refunded { gateway_error }) => {
265                    return Err(anyhow!("refunded {gateway_error}"));
266                }
267                None => return Err(anyhow!("Lightning send failed")),
268                _ => {}
269            }
270        }
271        Ok(())
272    }
273
274    #[wasm_bindgen_test]
275    async fn receive_and_pay() -> Result<()> {
276        let client = client(&faucet::invite_code().await?.parse()?).await?;
277        client.start_executor();
278        let ln_gateway = get_gateway(&client).await?;
279
280        futures::future::try_join_all(
281            (0..10)
282                .map(|_| receive_once(client.clone(), Amount::from_sats(21), ln_gateway.clone())),
283        )
284        .await?;
285        futures::future::try_join_all(
286            (0..10).map(|_| pay_once(client.clone(), ln_gateway.clone())),
287        )
288        .await?;
289
290        Ok(())
291    }
292
293    async fn send_and_recv_ecash_once(
294        client: fedimint_client::ClientHandleArc,
295    ) -> Result<(), anyhow::Error> {
296        let mint = client.get_first_module::<MintClientModule>()?;
297        let (_, notes) = mint
298            .spend_notes_with_selector(
299                &SelectNotesWithAtleastAmount,
300                Amount::from_sats(11),
301                Some(Duration::from_secs(10000)),
302                false,
303                (),
304            )
305            .await?;
306        let operation_id = mint.reissue_external_notes(notes, ()).await?;
307        let mut updates = mint
308            .subscribe_reissue_external_notes(operation_id)
309            .await?
310            .into_stream();
311        loop {
312            match updates.next().await {
313                Some(ReissueExternalNotesState::Done) => {
314                    break;
315                }
316                Some(ReissueExternalNotesState::Failed(error)) => {
317                    return Err(anyhow!("reissue failed {error}"));
318                }
319                Some(_) => {}
320                None => return Err(anyhow!("reissue failed")),
321            }
322        }
323        Ok(())
324    }
325
326    async fn send_ecash_exact(
327        client: fedimint_client::ClientHandleArc,
328        amount: Amount,
329    ) -> Result<(), anyhow::Error> {
330        let mint = client.get_first_module::<MintClientModule>()?;
331        'retry: loop {
332            let (operation_id, notes) = mint
333                .spend_notes_with_selector(
334                    &SelectNotesWithAtleastAmount,
335                    amount,
336                    Some(Duration::from_secs(10000)),
337                    false,
338                    (),
339                )
340                .await?;
341            if notes.total_amount() == amount {
342                return Ok(());
343            }
344            mint.try_cancel_spend_notes(operation_id).await;
345            let mut updates = mint
346                .subscribe_spend_notes(operation_id)
347                .await?
348                .into_stream();
349            while let Some(update) = updates.next().await {
350                if update == SpendOOBState::UserCanceledSuccess {
351                    continue 'retry;
352                }
353            }
354            bail!("failed to cancel notes");
355        }
356    }
357
358    #[wasm_bindgen_test]
359    async fn test_ecash() -> Result<()> {
360        let client = client(&faucet::invite_code().await?.parse()?).await?;
361        client.start_executor();
362        let ln_gateway = get_gateway(&client).await?;
363
364        futures::future::try_join_all(
365            (0..10)
366                .map(|_| receive_once(client.clone(), Amount::from_sats(100), ln_gateway.clone())), // Increased from 25 to 100 sats to account for mint fees
367        )
368        .await?;
369        futures::future::try_join_all((0..10).map(|_| send_and_recv_ecash_once(client.clone())))
370            .await?;
371        Ok(())
372    }
373
374    #[wasm_bindgen_test]
375    async fn test_ecash_exact() -> Result<()> {
376        let client = client(&faucet::invite_code().await?.parse()?).await?;
377        client.start_executor();
378        let ln_gateway = get_gateway(&client).await?;
379
380        receive_once(client.clone(), Amount::from_sats(100), ln_gateway).await?;
381        futures::future::try_join_all(
382            (0..3).map(|_| send_ecash_exact(client.clone(), Amount::from_sats(1))),
383        )
384        .await?;
385        Ok(())
386    }
387}