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