subxt_lightclient/
lib.rs

1// Copyright 2019-2025 Parity Technologies (UK) Ltd.
2// This file is dual-licensed as Apache-2.0 or GPL-3.0.
3// see LICENSE for license details.
4
5//! A wrapper around [`smoldot_light`] which provides an light client capable of connecting
6//! to Substrate based chains.
7
8#![deny(missing_docs)]
9#![cfg_attr(docsrs, feature(doc_cfg))]
10
11#[cfg(any(
12    all(feature = "web", feature = "native"),
13    not(any(feature = "web", feature = "native"))
14))]
15compile_error!("subxt-lightclient: exactly one of the 'web' and 'native' features should be used.");
16
17mod platform;
18mod shared_client;
19// mod receiver;
20mod background;
21mod chain_config;
22mod rpc;
23
24use background::{BackgroundTask, BackgroundTaskHandle};
25use futures::Stream;
26use platform::DefaultPlatform;
27use serde_json::value::RawValue;
28use shared_client::SharedClient;
29use std::future::Future;
30use tokio::sync::mpsc;
31
32pub use chain_config::{ChainConfig, ChainConfigError};
33
34/// Things that can go wrong when constructing the [`LightClient`].
35#[derive(Debug, thiserror::Error)]
36pub enum LightClientError {
37    /// Error encountered while adding the chain to the light-client.
38    #[error("Failed to add the chain to the light client: {0}.")]
39    AddChainError(String),
40}
41
42/// Things that can go wrong calling methods of [`LightClientRpc`].
43#[derive(Debug, thiserror::Error)]
44pub enum LightClientRpcError {
45    /// Error response from the JSON-RPC server.
46    #[error(transparent)]
47    JsonRpcError(JsonRpcError),
48    /// Smoldot could not handle the RPC call.
49    #[error("Smoldot could not handle the RPC call: {0}.")]
50    SmoldotError(String),
51    /// Background task dropped.
52    #[error("The background task was dropped.")]
53    BackgroundTaskDropped,
54}
55
56/// An error response from the JSON-RPC server (ie smoldot) in response to
57/// a method call or as a subscription notification.
58#[derive(Debug, thiserror::Error)]
59#[error("RPC Error: {0}.")]
60pub struct JsonRpcError(Box<RawValue>);
61
62impl JsonRpcError {
63    /// Attempt to deserialize this error into some type.
64    pub fn try_deserialize<'a, T: serde::de::Deserialize<'a>>(
65        &'a self,
66    ) -> Result<T, serde_json::Error> {
67        serde_json::from_str(self.0.get())
68    }
69}
70
71/// This represents a single light client connection to the network. Instantiate
72/// it with [`LightClient::relay_chain()`] to communicate with a relay chain, and
73/// then call [`LightClient::parachain()`] to establish connections to parachains.
74#[derive(Clone)]
75pub struct LightClient {
76    client: SharedClient<DefaultPlatform>,
77    relay_chain_id: smoldot_light::ChainId,
78}
79
80impl LightClient {
81    /// Given a chain spec, establish a connection to a relay chain. Any subsequent calls to
82    /// [`LightClient::parachain()`] will set this as the relay chain.
83    ///
84    /// # Panics
85    ///
86    /// The panic behaviour depends on the feature flag being used:
87    ///
88    /// ## Native
89    ///
90    /// Panics when called outside of a `tokio` runtime context.
91    ///
92    /// ## Web
93    ///
94    /// If smoldot panics, then the promise created will be leaked. For more details, see
95    /// https://docs.rs/wasm-bindgen-futures/latest/wasm_bindgen_futures/fn.future_to_promise.html.
96    pub fn relay_chain<'a>(
97        chain_config: impl Into<ChainConfig<'a>>,
98    ) -> Result<(Self, LightClientRpc), LightClientError> {
99        let mut client = smoldot_light::Client::new(platform::build_platform());
100        let chain_config = chain_config.into();
101        let chain_spec = chain_config.as_chain_spec();
102
103        let config = smoldot_light::AddChainConfig {
104            specification: chain_spec,
105            json_rpc: smoldot_light::AddChainConfigJsonRpc::Enabled {
106                max_pending_requests: u32::MAX.try_into().unwrap(),
107                max_subscriptions: u32::MAX,
108            },
109            database_content: "",
110            potential_relay_chains: std::iter::empty(),
111            user_data: (),
112        };
113
114        let added_chain = client
115            .add_chain(config)
116            .map_err(|err| LightClientError::AddChainError(err.to_string()))?;
117
118        let relay_chain_id = added_chain.chain_id;
119        let rpc_responses = added_chain
120            .json_rpc_responses
121            .expect("Light client RPC configured; qed");
122        let shared_client: SharedClient<_> = client.into();
123
124        let light_client_rpc =
125            LightClientRpc::new_raw(shared_client.clone(), relay_chain_id, rpc_responses);
126        let light_client = Self {
127            client: shared_client,
128            relay_chain_id,
129        };
130
131        Ok((light_client, light_client_rpc))
132    }
133
134    /// Given a chain spec, establish a connection to a parachain.
135    ///
136    /// # Panics
137    ///
138    /// The panic behaviour depends on the feature flag being used:
139    ///
140    /// ## Native
141    ///
142    /// Panics when called outside of a `tokio` runtime context.
143    ///
144    /// ## Web
145    ///
146    /// If smoldot panics, then the promise created will be leaked. For more details, see
147    /// https://docs.rs/wasm-bindgen-futures/latest/wasm_bindgen_futures/fn.future_to_promise.html.
148    pub fn parachain<'a>(
149        &self,
150        chain_config: impl Into<ChainConfig<'a>>,
151    ) -> Result<LightClientRpc, LightClientError> {
152        let chain_config = chain_config.into();
153        let chain_spec = chain_config.as_chain_spec();
154
155        let config = smoldot_light::AddChainConfig {
156            specification: chain_spec,
157            json_rpc: smoldot_light::AddChainConfigJsonRpc::Enabled {
158                max_pending_requests: u32::MAX.try_into().unwrap(),
159                max_subscriptions: u32::MAX,
160            },
161            database_content: "",
162            potential_relay_chains: std::iter::once(self.relay_chain_id),
163            user_data: (),
164        };
165
166        let added_chain = self
167            .client
168            .add_chain(config)
169            .map_err(|err| LightClientError::AddChainError(err.to_string()))?;
170
171        let chain_id = added_chain.chain_id;
172        let rpc_responses = added_chain
173            .json_rpc_responses
174            .expect("Light client RPC configured; qed");
175
176        Ok(LightClientRpc::new_raw(
177            self.client.clone(),
178            chain_id,
179            rpc_responses,
180        ))
181    }
182}
183
184/// This represents a single RPC connection to a specific chain, and is constructed by calling
185/// one of the methods on [`LightClient`]. Using this, you can make RPC requests to the chain.
186#[derive(Clone, Debug)]
187pub struct LightClientRpc {
188    handle: BackgroundTaskHandle,
189}
190
191impl LightClientRpc {
192    // Dev note: this would provide a "low level" interface if one is needed.
193    // Do we actually need to provide this, or can we entirely hide Smoldot?
194    pub(crate) fn new_raw<TPlat, TChain>(
195        client: impl Into<SharedClient<TPlat, TChain>>,
196        chain_id: smoldot_light::ChainId,
197        rpc_responses: smoldot_light::JsonRpcResponses<TPlat>,
198    ) -> Self
199    where
200        TPlat: smoldot_light::platform::PlatformRef + Send + 'static,
201        TChain: Send + 'static,
202    {
203        let (background_task, background_handle) =
204            BackgroundTask::new(client.into(), chain_id, rpc_responses);
205
206        // For now we spawn the background task internally, but later we can expose
207        // methods to give this back to the user so that they can exert backpressure.
208        spawn(async move { background_task.run().await });
209
210        LightClientRpc {
211            handle: background_handle,
212        }
213    }
214
215    /// Make an RPC request to a chain, getting back a result.
216    pub async fn request(
217        &self,
218        method: String,
219        params: Option<Box<RawValue>>,
220    ) -> Result<Box<RawValue>, LightClientRpcError> {
221        self.handle.request(method, params).await
222    }
223
224    /// Subscribe to some RPC method, getting back a stream of notifications.
225    pub async fn subscribe(
226        &self,
227        method: String,
228        params: Option<Box<RawValue>>,
229        unsub: String,
230    ) -> Result<LightClientRpcSubscription, LightClientRpcError> {
231        let (id, notifications) = self.handle.subscribe(method, params, unsub).await?;
232        Ok(LightClientRpcSubscription { id, notifications })
233    }
234}
235
236/// A stream of notifications handed back when [`LightClientRpc::subscribe`] is called.
237pub struct LightClientRpcSubscription {
238    notifications: mpsc::UnboundedReceiver<Result<Box<RawValue>, JsonRpcError>>,
239    id: String,
240}
241
242impl LightClientRpcSubscription {
243    /// Return the subscription ID
244    pub fn id(&self) -> &str {
245        &self.id
246    }
247}
248
249impl Stream for LightClientRpcSubscription {
250    type Item = Result<Box<RawValue>, JsonRpcError>;
251    fn poll_next(
252        mut self: std::pin::Pin<&mut Self>,
253        cx: &mut std::task::Context<'_>,
254    ) -> std::task::Poll<Option<Self::Item>> {
255        self.notifications.poll_recv(cx)
256    }
257}
258
259/// A quick helper to spawn a task that works for WASM.
260fn spawn<F: Future + Send + 'static>(future: F) {
261    #[cfg(feature = "native")]
262    tokio::spawn(async move {
263        future.await;
264    });
265    #[cfg(feature = "web")]
266    wasm_bindgen_futures::spawn_local(async move {
267        future.await;
268    });
269}