fedimint_client_wasm/
lib.rs

1#![cfg(target_family = "wasm")]
2mod db;
3
4use std::pin::pin;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use async_stream::try_stream;
9use db::MemAndIndexedDb;
10use fedimint_client::ClientHandleArc;
11use fedimint_client::secret::{PlainRootSecretStrategy, RootSecretStrategy};
12use fedimint_client_module::module::IClientModule;
13use fedimint_core::db::Database;
14use fedimint_core::invite_code::InviteCode;
15use fedimint_ln_client::{LightningClientInit, LightningClientModule};
16use fedimint_mint_client::MintClientInit;
17use futures::StreamExt;
18use futures::future::{AbortHandle, Abortable};
19use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription};
20use serde_json::json;
21use wasm_bindgen::prelude::wasm_bindgen;
22use wasm_bindgen::{JsError, JsValue};
23
24#[wasm_bindgen]
25pub struct WasmClient {
26    client: ClientHandleArc,
27}
28
29#[wasm_bindgen]
30pub struct RpcHandle {
31    abort_handle: AbortHandle,
32}
33
34#[wasm_bindgen]
35impl RpcHandle {
36    #[wasm_bindgen]
37    pub fn cancel(&self) {
38        self.abort_handle.abort();
39    }
40}
41
42#[wasm_bindgen]
43impl WasmClient {
44    #[wasm_bindgen]
45    /// Open fedimint client with already joined federation.
46    ///
47    /// After you have joined a federation, you can reopen the fedimint client
48    /// with same client_name. Opening client with same name at same time is
49    /// not supported. You can close the current client by calling
50    /// `client.free()`. NOTE: The client will remain active until all the
51    /// running rpc calls have finished.
52    // WasmClient::free is auto generated by wasm bindgen.
53    pub async fn open(client_name: String) -> Result<Option<WasmClient>, JsError> {
54        Self::open_inner(client_name)
55            .await
56            .map_err(|x| JsError::new(&x.to_string()))
57    }
58
59    #[wasm_bindgen]
60    /// Open a fedimint client by join a federation.
61    pub async fn join_federation(
62        client_name: String,
63        invite_code: String,
64    ) -> Result<WasmClient, JsError> {
65        Self::join_federation_inner(client_name, invite_code)
66            .await
67            .map_err(|x| JsError::new(&x.to_string()))
68    }
69
70    #[wasm_bindgen]
71    /// Parse an invite code and extract its components without joining the
72    /// federation
73    pub fn parse_invite_code(invite_code: &str) -> Result<String, JsError> {
74        let invite_code =
75            InviteCode::from_str(&invite_code).map_err(|e| JsError::new(&e.to_string()))?;
76        let federation_id = invite_code.federation_id().to_string();
77        let url = invite_code.url().to_string();
78        let result = json!({
79            "url": url,
80            "federation_id": federation_id,
81        });
82        Ok(serde_json::to_string(&result).map_err(|e| JsError::new(&e.to_string()))?)
83    }
84
85    async fn client_builder(db: Database) -> Result<fedimint_client::ClientBuilder, anyhow::Error> {
86        let mut builder = fedimint_client::Client::builder(db).await?;
87        builder.with_module(MintClientInit);
88        builder.with_module(LightningClientInit::default());
89        // FIXME: wallet module?
90        builder.with_primary_module(1);
91        Ok(builder)
92    }
93
94    #[wasm_bindgen]
95    /// Parse a bolt11 invoice and extract its components
96    /// without joining the federation
97    pub fn parse_bolt11_invoice(invoice_str: &str) -> Result<String, JsError> {
98        let invoice = lightning_invoice::Bolt11Invoice::from_str(invoice_str)
99            .map_err(|e| JsError::new(&format!("Failed to parse Lightning invoice: {}", e)))?;
100
101        let amount_msat = invoice.amount_milli_satoshis().unwrap_or(0);
102        let amount_sat = amount_msat as f64 / 1000.0;
103
104        let expiry_seconds = invoice.expiry_time().as_secs();
105
106        // memo
107        let description = match invoice.description() {
108            Bolt11InvoiceDescription::Direct(desc) => desc.to_string(),
109            Bolt11InvoiceDescription::Hash(_) => "Description hash only".to_string(),
110        };
111
112        let response = json!({
113            "amount": amount_sat,
114            "expiry": expiry_seconds,
115            "memo": description,
116        });
117        Ok(serde_json::to_string(&response).map_err(|e| JsError::new(&e.to_string()))?)
118    }
119
120    async fn open_inner(client_name: String) -> anyhow::Result<Option<WasmClient>> {
121        let db = Database::from(MemAndIndexedDb::new(&client_name).await?);
122        if !fedimint_client::Client::is_initialized(&db).await {
123            return Ok(None);
124        }
125        let client_secret = fedimint_client::Client::load_or_generate_client_secret(&db).await?;
126        let root_secret = PlainRootSecretStrategy::to_root_secret(&client_secret);
127        let builder = Self::client_builder(db).await?;
128        Ok(Some(Self {
129            client: Arc::new(builder.open(root_secret).await?),
130        }))
131    }
132
133    async fn join_federation_inner(
134        client_name: String,
135        invite_code: String,
136    ) -> anyhow::Result<WasmClient> {
137        let db = Database::from(MemAndIndexedDb::new(&client_name).await?);
138        let client_secret = fedimint_client::Client::load_or_generate_client_secret(&db).await?;
139        let root_secret = PlainRootSecretStrategy::to_root_secret(&client_secret);
140        let builder = Self::client_builder(db).await?;
141        let invite_code = InviteCode::from_str(&invite_code)?;
142        let config = fedimint_api_client::api::net::Connector::default()
143            .download_from_invite_code(&invite_code)
144            .await?;
145        let client = Arc::new(builder.join(root_secret, config, None).await?);
146        Ok(Self { client })
147    }
148
149    #[wasm_bindgen]
150    /// Call a fedimint client rpc the responses are returned using `cb`
151    /// callback. Each rpc call *can* return multiple responses by calling
152    /// `cb` multiple times. The returned RpcHandle can be used to cancel the
153    /// operation.
154    pub fn rpc(
155        &self,
156        module: &str,
157        method: &str,
158        payload: String,
159        cb: &js_sys::Function,
160    ) -> RpcHandle {
161        let (abort_handle, abort_registration) = AbortHandle::new_pair();
162        let rpc_handle = RpcHandle { abort_handle };
163
164        let client = self.client.clone();
165        let module = module.to_string();
166        let method = method.to_string();
167        let cb = cb.clone();
168
169        wasm_bindgen_futures::spawn_local(async move {
170            let future = async {
171                let mut stream = pin!(Self::rpc_inner(&client, &module, &method, payload));
172
173                while let Some(item) = stream.next().await {
174                    let this = JsValue::null();
175                    let _ = match item {
176                        Ok(item) => cb.call1(
177                            &this,
178                            &JsValue::from_str(
179                                &serde_json::to_string(&json!({"data": item})).unwrap(),
180                            ),
181                        ),
182                        Err(err) => cb.call1(
183                            &this,
184                            &JsValue::from_str(
185                                &serde_json::to_string(&json!({"error": err.to_string()})).unwrap(),
186                            ),
187                        ),
188                    };
189                }
190
191                // Send the end message
192                let _ = cb.call1(
193                    &JsValue::null(),
194                    &JsValue::from_str(&serde_json::to_string(&json!({"end": null})).unwrap()),
195                );
196            };
197
198            let abortable_future = Abortable::new(future, abort_registration);
199            let _ = abortable_future.await;
200        });
201
202        rpc_handle
203    }
204    fn rpc_inner<'a>(
205        client: &'a ClientHandleArc,
206        module: &'a str,
207        method: &'a str,
208        payload: String,
209    ) -> impl futures::Stream<Item = anyhow::Result<serde_json::Value>> + 'a {
210        try_stream! {
211            let payload: serde_json::Value = serde_json::from_str(&payload)?;
212            match module {
213                "" => {
214                    let mut stream = client.handle_global_rpc(method.to_owned(), payload);
215                    while let Some(item) = stream.next().await {
216                        yield item?;
217                    }
218                }
219                "ln" => {
220                    let ln = client
221                        .get_first_module::<LightningClientModule>()?
222                        .inner();
223                    let mut stream = ln.handle_rpc(method.to_owned(), payload).await;
224                    while let Some(item) = stream.next().await {
225                        yield item?;
226                    }
227                }
228                "mint" => {
229                    let mint = client
230                        .get_first_module::<fedimint_mint_client::MintClientModule>()?
231                        .inner();
232                    let mut stream = mint.handle_rpc(method.to_owned(), payload).await;
233                    while let Some(item) = stream.next().await {
234                        yield item?;
235                    }
236                }
237                _ => {
238                    Err(anyhow::format_err!("module not found: {module}"))?;
239                    unreachable!()
240                },
241            }
242        }
243    }
244}