lumina_node_wasm/
client.rs

1//! A browser compatible wrappers for the [`lumina-node`].
2
3use std::time::Duration;
4
5use blockstore::EitherBlockstore;
6use celestia_types::nmt::Namespace;
7use celestia_types::{Blob, ExtendedHeader};
8use js_sys::Array;
9use libp2p::Multiaddr;
10use libp2p::identity::Keypair;
11use lumina_node::blockstore::{InMemoryBlockstore, IndexedDbBlockstore};
12use lumina_node::network;
13use lumina_node::node::{DEFAULT_PRUNING_WINDOW_IN_MEMORY, NodeBuilder};
14use lumina_node::store::{EitherStore, InMemoryStore, IndexedDbStore, SamplingMetadata};
15use serde::{Deserialize, Serialize};
16use tracing::{debug, error};
17use wasm_bindgen::prelude::*;
18use web_sys::BroadcastChannel;
19
20use crate::commands::{CheckableResponseExt, NodeCommand, SingleHeaderQuery};
21use crate::error::{Context, Result};
22use crate::ports::WorkerClient;
23use crate::utils::{
24    Network, is_safari, js_value_from_display, request_storage_persistence, timeout,
25};
26use crate::worker::{WasmBlockstore, WasmStore};
27pub use crate::wrapper::libp2p::{ConnectionCountersSnapshot, NetworkInfoSnapshot};
28use crate::wrapper::node::{PeerTrackerInfoSnapshot, SyncingInfoSnapshot};
29
30/// Config for the lumina wasm node.
31#[wasm_bindgen(inspectable, js_name = NodeConfig)]
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WasmNodeConfig {
34    /// A network to connect to.
35    pub network: Network,
36
37    /// A list of bootstrap peers to connect to.
38    #[wasm_bindgen(getter_with_clone)]
39    pub bootnodes: Vec<String>,
40
41    /// Optionally start with a provided private key used as libp2p identity. Expects 32 bytes
42    /// containing ed25519 secret key.
43    #[wasm_bindgen(getter_with_clone)]
44    pub identity_key: Option<Vec<u8>>,
45
46    /// Whether to store data in persistent memory or not.
47    ///
48    /// **Default value:** true
49    #[wasm_bindgen(js_name = usePersistentMemory)]
50    pub use_persistent_memory: bool,
51
52    /// Pruning window defines maximum age of a block for it to be retained in store.
53    ///
54    /// If pruning window is smaller than sampling window, then blocks will be pruned
55    /// right after they are sampled. This is useful when you want to keep low
56    /// memory footprint but still validate the blockchain.
57    ///
58    /// If this is not set, then default value will apply:
59    ///
60    /// * If `use_persistent_memory == true`, default value is 7 days plus 1 hour.
61    /// * If `use_persistent_memory == false`, default value is 0 seconds.
62    #[wasm_bindgen(js_name = customPruningWindowSecs)]
63    pub custom_pruning_window_secs: Option<u32>,
64}
65
66/// `NodeClient` is responsible for steering [`NodeWorker`] by sending it commands and receiving
67/// responses over the provided port.
68///
69/// [`NodeWorker`]: crate::worker::NodeWorker
70#[wasm_bindgen]
71pub struct NodeClient {
72    worker: WorkerClient,
73}
74
75#[wasm_bindgen]
76impl NodeClient {
77    /// Create a new connection to a Lumina node running in [`NodeWorker`]. Provided `port` is
78    /// expected to have `MessagePort`-like interface for sending and receiving messages.
79    #[wasm_bindgen(constructor)]
80    #[allow(deprecated)] // TODO: https://github.com/eigerco/lumina/issues/754
81    pub async fn new(port: JsValue) -> Result<NodeClient> {
82        // Safari doesn't have the `navigator.storage()` api
83        if !is_safari()?
84            && let Err(e) = request_storage_persistence().await
85        {
86            error!("Error requesting storage persistence: {e}");
87        }
88
89        let worker = WorkerClient::new(port)?;
90
91        // keep pinging worker until it responds.
92        loop {
93            if timeout(100, worker.exec(NodeCommand::InternalPing))
94                .await
95                .is_ok()
96            {
97                break;
98            }
99        }
100
101        debug!("Connected to worker");
102
103        Ok(Self { worker })
104    }
105
106    /// Establish a new connection to the existing worker over provided port
107    #[wasm_bindgen(js_name = addConnectionToWorker)]
108    pub async fn add_connection_to_worker(&self, port: JsValue) -> Result<()> {
109        self.worker.add_connection_to_worker(port).await
110    }
111
112    /// Check whether Lumina is currently running
113    #[wasm_bindgen(js_name = isRunning)]
114    pub async fn is_running(&self) -> Result<bool> {
115        let command = NodeCommand::IsRunning;
116        let response = self.worker.exec(command).await?;
117        let running = response.into_is_running().check_variant()?;
118
119        Ok(running)
120    }
121
122    /// Start the node with the provided config, if it's not running
123    pub async fn start(&self, config: &WasmNodeConfig) -> Result<()> {
124        let command = NodeCommand::StartNode(config.clone());
125        let response = self.worker.exec(command).await?;
126        response.into_node_started().check_variant()??;
127
128        Ok(())
129    }
130
131    /// Stop the node.
132    pub async fn stop(&self) -> Result<()> {
133        let command = NodeCommand::StopNode;
134        let response = self.worker.exec(command).await?;
135        response.into_node_stopped().check_variant()?;
136
137        Ok(())
138    }
139
140    /// Get node's local peer ID.
141    #[wasm_bindgen(js_name = localPeerId)]
142    pub async fn local_peer_id(&self) -> Result<String> {
143        let command = NodeCommand::GetLocalPeerId;
144        let response = self.worker.exec(command).await?;
145        let peer_id = response.into_local_peer_id().check_variant()?;
146
147        Ok(peer_id)
148    }
149
150    /// Get current [`PeerTracker`] info.
151    #[wasm_bindgen(js_name = peerTrackerInfo)]
152    pub async fn peer_tracker_info(&self) -> Result<PeerTrackerInfoSnapshot> {
153        let command = NodeCommand::GetPeerTrackerInfo;
154        let response = self.worker.exec(command).await?;
155        let peer_info = response.into_peer_tracker_info().check_variant()?;
156
157        Ok(peer_info.into())
158    }
159
160    /// Wait until the node is connected to at least 1 peer.
161    #[wasm_bindgen(js_name = waitConnected)]
162    pub async fn wait_connected(&self) -> Result<()> {
163        let command = NodeCommand::WaitConnected { trusted: false };
164        let response = self.worker.exec(command).await?;
165        let _ = response.into_connected().check_variant()?;
166
167        Ok(())
168    }
169
170    /// Wait until the node is connected to at least 1 trusted peer.
171    #[wasm_bindgen(js_name = waitConnectedTrusted)]
172    pub async fn wait_connected_trusted(&self) -> Result<()> {
173        let command = NodeCommand::WaitConnected { trusted: true };
174        let response = self.worker.exec(command).await?;
175        response.into_connected().check_variant()?
176    }
177
178    /// Get current network info.
179    #[wasm_bindgen(js_name = networkInfo)]
180    pub async fn network_info(&self) -> Result<NetworkInfoSnapshot> {
181        let command = NodeCommand::GetNetworkInfo;
182        let response = self.worker.exec(command).await?;
183
184        response.into_network_info().check_variant()?
185    }
186
187    /// Get all the multiaddresses on which the node listens.
188    pub async fn listeners(&self) -> Result<Array> {
189        let command = NodeCommand::GetListeners;
190        let response = self.worker.exec(command).await?;
191        let listeners = response.into_listeners().check_variant()?;
192        let result = listeners?.iter().map(js_value_from_display).collect();
193
194        Ok(result)
195    }
196
197    /// Get all the peers that node is connected to.
198    #[wasm_bindgen(js_name = connectedPeers)]
199    pub async fn connected_peers(&self) -> Result<Array> {
200        let command = NodeCommand::GetConnectedPeers;
201        let response = self.worker.exec(command).await?;
202        let peers = response.into_connected_peers().check_variant()?;
203        let result = peers?.iter().map(js_value_from_display).collect();
204
205        Ok(result)
206    }
207
208    /// Trust or untrust the peer with a given ID.
209    #[wasm_bindgen(js_name = setPeerTrust)]
210    pub async fn set_peer_trust(&self, peer_id: &str, is_trusted: bool) -> Result<()> {
211        let command = NodeCommand::SetPeerTrust {
212            peer_id: peer_id.parse()?,
213            is_trusted,
214        };
215        let response = self.worker.exec(command).await?;
216        response.into_set_peer_trust().check_variant()?
217    }
218
219    /// Request the head header from the network.
220    #[wasm_bindgen(js_name = requestHeadHeader)]
221    pub async fn request_head_header(&self) -> Result<ExtendedHeader> {
222        let command = NodeCommand::RequestHeader(SingleHeaderQuery::Head);
223        let response = self.worker.exec(command).await?;
224        response.into_header().check_variant()?
225    }
226
227    /// Request a header for the block with a given hash from the network.
228    #[wasm_bindgen(js_name = requestHeaderByHash)]
229    pub async fn request_header_by_hash(&self, hash: &str) -> Result<ExtendedHeader> {
230        let command = NodeCommand::RequestHeader(SingleHeaderQuery::ByHash(hash.parse()?));
231        let response = self.worker.exec(command).await?;
232        response.into_header().check_variant()?
233    }
234
235    /// Request a header for the block with a given height from the network.
236    #[wasm_bindgen(js_name = requestHeaderByHeight)]
237    pub async fn request_header_by_height(&self, height: u64) -> Result<ExtendedHeader> {
238        let command = NodeCommand::RequestHeader(SingleHeaderQuery::ByHeight(height));
239        let response = self.worker.exec(command).await?;
240        response.into_header().check_variant()?
241    }
242
243    /// Request headers in range (from, from + amount] from the network.
244    ///
245    /// The headers will be verified with the `from` header.
246    #[wasm_bindgen(js_name = requestVerifiedHeaders)]
247    pub async fn request_verified_headers(
248        &self,
249        from: &ExtendedHeader,
250        amount: u64,
251    ) -> Result<Vec<ExtendedHeader>> {
252        let command = NodeCommand::GetVerifiedHeaders {
253            from: from.clone(),
254            amount,
255        };
256        let response = self.worker.exec(command).await?;
257        response.into_headers().check_variant()?
258    }
259
260    /// Request all blobs with provided namespace in the block corresponding to this header
261    /// using bitswap protocol.
262    #[wasm_bindgen(js_name = requestAllBlobs)]
263    pub async fn request_all_blobs(
264        &self,
265        namespace: &Namespace,
266        block_height: u64,
267        timeout_secs: Option<f64>,
268    ) -> Result<Vec<Blob>> {
269        let command = NodeCommand::RequestAllBlobs {
270            namespace: *namespace,
271            block_height,
272            timeout_secs,
273        };
274        let response = self.worker.exec(command).await?;
275        response.into_blobs().check_variant()?
276    }
277
278    /// Get current header syncing info.
279    #[wasm_bindgen(js_name = syncerInfo)]
280    pub async fn syncer_info(&self) -> Result<SyncingInfoSnapshot> {
281        let command = NodeCommand::GetSyncerInfo;
282        let response = self.worker.exec(command).await?;
283        let syncer_info = response.into_syncer_info().check_variant()?;
284
285        Ok(syncer_info?.into())
286    }
287
288    /// Get the latest header announced in the network.
289    #[wasm_bindgen(js_name = getNetworkHeadHeader)]
290    pub async fn get_network_head_header(&self) -> Result<Option<ExtendedHeader>> {
291        let command = NodeCommand::LastSeenNetworkHead;
292        let response = self.worker.exec(command).await?;
293        response.into_last_seen_network_head().check_variant()?
294    }
295
296    /// Get the latest locally synced header.
297    #[wasm_bindgen(js_name = getLocalHeadHeader)]
298    pub async fn get_local_head_header(&self) -> Result<ExtendedHeader> {
299        let command = NodeCommand::GetHeader(SingleHeaderQuery::Head);
300        let response = self.worker.exec(command).await?;
301        response.into_header().check_variant()?
302    }
303
304    /// Get a synced header for the block with a given hash.
305    #[wasm_bindgen(js_name = getHeaderByHash)]
306    pub async fn get_header_by_hash(&self, hash: &str) -> Result<ExtendedHeader> {
307        let command = NodeCommand::GetHeader(SingleHeaderQuery::ByHash(hash.parse()?));
308        let response = self.worker.exec(command).await?;
309        response.into_header().check_variant()?
310    }
311
312    /// Get a synced header for the block with a given height.
313    #[wasm_bindgen(js_name = getHeaderByHeight)]
314    pub async fn get_header_by_height(&self, height: u64) -> Result<ExtendedHeader> {
315        let command = NodeCommand::GetHeader(SingleHeaderQuery::ByHeight(height));
316        let response = self.worker.exec(command).await?;
317        response.into_header().check_variant()?
318    }
319
320    /// Get synced headers from the given heights range.
321    ///
322    /// If start of the range is undefined (None), the first returned header will be of height 1.
323    /// If end of the range is undefined (None), the last returned header will be the last header in the
324    /// store.
325    ///
326    /// # Errors
327    ///
328    /// If range contains a height of a header that is not found in the store.
329    #[wasm_bindgen(js_name = getHeaders)]
330    pub async fn get_headers(
331        &self,
332        start_height: Option<u64>,
333        end_height: Option<u64>,
334    ) -> Result<Vec<ExtendedHeader>> {
335        let command = NodeCommand::GetHeadersRange {
336            start_height,
337            end_height,
338        };
339        let response = self.worker.exec(command).await?;
340        response.into_headers().check_variant()?
341    }
342
343    /// Get data sampling metadata of an already sampled height.
344    #[wasm_bindgen(js_name = getSamplingMetadata)]
345    pub async fn get_sampling_metadata(&self, height: u64) -> Result<Option<SamplingMetadata>> {
346        let command = NodeCommand::GetSamplingMetadata { height };
347        let response = self.worker.exec(command).await?;
348        response.into_sampling_metadata().check_variant()?
349    }
350
351    /// Returns a [`BroadcastChannel`] for events generated by [`Node`].
352    #[wasm_bindgen(js_name = eventsChannel)]
353    pub async fn events_channel(&self) -> Result<BroadcastChannel> {
354        let command = NodeCommand::GetEventsChannelName;
355        let response = self.worker.exec(command).await?;
356        let name = response.into_events_channel_name().check_variant()?;
357
358        Ok(BroadcastChannel::new(&name).unwrap())
359    }
360}
361
362#[wasm_bindgen(js_class = NodeConfig)]
363impl WasmNodeConfig {
364    /// Get the configuration with default bootnodes for provided network
365    pub fn default(network: Network) -> WasmNodeConfig {
366        let bootnodes = network::Network::from(network)
367            .canonical_bootnodes()
368            .map(|addr| addr.to_string())
369            .collect::<Vec<_>>();
370
371        WasmNodeConfig {
372            network,
373            bootnodes,
374            identity_key: None,
375            use_persistent_memory: true,
376            custom_pruning_window_secs: None,
377        }
378    }
379
380    pub(crate) async fn into_node_builder(self) -> Result<NodeBuilder<WasmBlockstore, WasmStore>> {
381        let network = network::Network::from(self.network);
382        let network_id = network.id();
383
384        let mut builder = if self.use_persistent_memory {
385            let store_name = format!("lumina-{network_id}");
386            let blockstore_name = format!("lumina-{network_id}-blockstore");
387
388            let store = IndexedDbStore::new(&store_name)
389                .await
390                .context("Failed to open the store")?;
391
392            let blockstore = IndexedDbBlockstore::new(&blockstore_name)
393                .await
394                .context("Failed to open the blockstore")?;
395
396            NodeBuilder::new()
397                .store(EitherStore::Right(store))
398                .blockstore(EitherBlockstore::Right(blockstore))
399        } else {
400            NodeBuilder::new()
401                .store(EitherStore::Left(InMemoryStore::new()))
402                .blockstore(EitherBlockstore::Left(InMemoryBlockstore::new()))
403                // In-memory stores are memory hungry, so we prune blocks as soon as possible.
404                .pruning_window(DEFAULT_PRUNING_WINDOW_IN_MEMORY)
405        };
406
407        if let Some(key_bytes) = self.identity_key {
408            let keypair = Keypair::ed25519_from_bytes(key_bytes).context("could not decode key")?;
409            builder = builder.keypair(keypair);
410        }
411
412        let bootnodes = self
413            .bootnodes
414            .into_iter()
415            .map(|addr| {
416                addr.parse()
417                    .with_context(|| format!("invalid multiaddr: {addr}"))
418            })
419            .collect::<Result<Vec<Multiaddr>, _>>()?;
420
421        builder = builder
422            .network(network)
423            .sync_batch_size(128)
424            .bootnodes(bootnodes);
425
426        if let Some(secs) = self.custom_pruning_window_secs {
427            let dur = Duration::from_secs(secs.into());
428            builder = builder.pruning_window(dur);
429        }
430
431        Ok(builder)
432    }
433}