Skip to main content

forest/rpc/
client.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! # Design Goals
5//! - use [`jsonrpsee`] clients and primitives.
6//! - Support [`rpc::Request`](crate::rpc::Request).
7//! - Support different
8//!   - endpoint paths (`v0`, `v1`).
9//!   - communication protocols (`ws`, `http`).
10//! - Support per-request timeouts.
11
12use std::env;
13use std::fmt::{self, Debug};
14use std::sync::LazyLock;
15use std::time::Duration;
16
17use anyhow::bail;
18use futures::future::Either;
19use http::{HeaderMap, HeaderValue, header};
20use jsonrpsee::core::ClientError;
21use jsonrpsee::core::client::ClientT as _;
22use jsonrpsee::core::params::{ArrayParams, ObjectParams};
23use jsonrpsee::core::traits::ToRpcParams;
24use serde::de::DeserializeOwned;
25use tracing::{Instrument, Level, debug};
26use url::Url;
27
28use super::{ApiPaths, MAX_REQUEST_BODY_SIZE, MAX_RESPONSE_BODY_SIZE, Request};
29
30/// `User-Agent` advertised by the RPC client, e.g. `forest/0.33.7+git.e69baf3e4`.
31///
32/// Built once and cloned per client. Some public RPC providers (e.g. Filfox,
33/// fronted by Cloudflare) reject requests without a `User-Agent` with a `403`.
34static USER_AGENT: LazyLock<HeaderValue> = LazyLock::new(|| {
35    HeaderValue::from_str(&format!(
36        "forest/{}",
37        crate::utils::version::FOREST_VERSION_STRING.as_str()
38    ))
39    .expect("Forest version string is a valid header value")
40});
41
42/// A JSON-RPC client that can dispatch either a [`crate::rpc::Request`] to a single URL.
43pub struct Client {
44    /// SHOULD end in a slash, due to our use of [`Url::join`].
45    base_url: Url,
46    token: Option<String>,
47    // just having these versions inline is easier than using a map
48    v0: tokio::sync::OnceCell<UrlClient>,
49    v1: tokio::sync::OnceCell<UrlClient>,
50    v2: tokio::sync::OnceCell<UrlClient>,
51}
52
53impl Client {
54    /// Use either the URL in the environment or a default.
55    ///
56    /// If `token` is provided, use that over the token in either of the above.
57    pub fn default_or_from_env(token: Option<&str>) -> anyhow::Result<Self> {
58        static DEFAULT: LazyLock<Url> = LazyLock::new(|| "http://127.0.0.1:2345/".parse().unwrap());
59
60        let mut base_url = match env::var("FULLNODE_API_INFO") {
61            Ok(it) => {
62                let crate::utils::UrlFromMultiAddr(url) = it.parse()?;
63                url
64            }
65            Err(env::VarError::NotPresent) => DEFAULT.clone(),
66            Err(e @ env::VarError::NotUnicode(_)) => bail!(e),
67        };
68        if token.is_some() && base_url.set_password(token).is_err() {
69            bail!("couldn't set override password")
70        }
71        // Set default token if not provided
72        if token.is_none() && base_url.password().is_none() {
73            // Honor the `FOREST_PATH` data directory override so the token saved
74            // by a daemon started with `FOREST_PATH` set is found here as well.
75            let default_token_path = crate::cli_shared::default_token_path();
76            if default_token_path.is_file() {
77                if let Ok(token) = std::fs::read_to_string(&default_token_path) {
78                    if base_url.set_password(Some(token.trim())).is_ok() {
79                        tracing::debug!("Loaded the default RPC token");
80                    } else {
81                        tracing::warn!("Failed to set the default RPC token");
82                    }
83                } else {
84                    tracing::warn!("Failed to load the default token file");
85                }
86            }
87        }
88        Ok(Self::from_url(base_url))
89    }
90    pub fn from_url(mut base_url: Url) -> Self {
91        let token = base_url.password().map(Into::into);
92        let _defer = base_url.set_password(None);
93        Self {
94            token,
95            base_url,
96            v0: Default::default(),
97            v1: Default::default(),
98            v2: Default::default(),
99        }
100    }
101    pub fn base_url(&self) -> &Url {
102        &self.base_url
103    }
104    pub async fn call<T: crate::lotus_json::HasLotusJson + std::fmt::Debug>(
105        &self,
106        req: Request<T>,
107    ) -> Result<T, ClientError> {
108        let api_path = req.api_path;
109        let Request {
110            method_name,
111            params,
112            timeout,
113            ..
114        } = req;
115        let method_name = method_name.as_ref();
116        let client = self.get_or_init_client(api_path).await?;
117        let span = tracing::debug_span!("request", method = %method_name, url = %client.url);
118        let work = async {
119            // jsonrpsee's clients have a global `timeout`, but not a per-request timeout, which
120            // RpcRequest expects.
121            // So shim in our own timeout
122            let result_or_timeout = tokio::time::timeout(
123                timeout,
124                match params {
125                    serde_json::Value::Null => Either::Left(Either::Left(
126                        client.request::<T::LotusJson, _>(method_name, ArrayParams::new()),
127                    )),
128                    serde_json::Value::Array(it) => {
129                        let mut params = ArrayParams::new();
130                        for param in it {
131                            params.insert(param)?
132                        }
133                        trace_params(params.clone());
134                        Either::Left(Either::Right(client.request(method_name, params)))
135                    }
136                    serde_json::Value::Object(it) => {
137                        let mut params = ObjectParams::new();
138                        for (name, param) in it {
139                            params.insert(&name, param)?
140                        }
141                        trace_params(params.clone());
142                        Either::Right(client.request(method_name, params))
143                    }
144                    prim @ (serde_json::Value::Bool(_)
145                    | serde_json::Value::Number(_)
146                    | serde_json::Value::String(_)) => {
147                        return Err(ClientError::Custom(format!(
148                            "invalid parameter type: `{prim}`"
149                        )));
150                    }
151                },
152            )
153            .await;
154            let result = match result_or_timeout {
155                Ok(Ok(it)) => Ok(T::from_lotus_json(it)),
156                Ok(Err(e)) => Err(e),
157                Err(_) => Err(ClientError::RequestTimeout),
158            };
159            debug!(?result);
160            result
161        };
162        work.instrument(span.or_current()).await
163    }
164    async fn get_or_init_client(&self, path: ApiPaths) -> Result<&UrlClient, ClientError> {
165        match path {
166            ApiPaths::V0 => &self.v0,
167            ApiPaths::V1 => &self.v1,
168            ApiPaths::V2 => &self.v2,
169        }
170        .get_or_try_init(|| async {
171            let url = self.base_url.join(path.path()).map_err(|it| {
172                ClientError::Custom(format!("creating url for endpoint failed: {it}"))
173            })?;
174            UrlClient::new(url, self.token.clone()).await
175        })
176        .await
177    }
178}
179
180fn trace_params(params: impl jsonrpsee::core::traits::ToRpcParams) {
181    if tracing::enabled!(Level::TRACE) {
182        match params.to_rpc_params() {
183            Ok(Some(it)) => tracing::trace!(params = %it),
184            Ok(None) => tracing::trace!("no params"),
185            Err(error) => tracing::trace!(%error, "couldn't decode params"),
186        }
187    }
188}
189
190/// Represents a single, perhaps persistent connection to a URL over which requests
191/// can be made using [`jsonrpsee`] primitives.
192pub struct UrlClient {
193    url: Url,
194    inner: UrlClientInner,
195}
196
197impl Debug for UrlClient {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.debug_struct("OneClient")
200            .field("url", &self.url)
201            .finish_non_exhaustive()
202    }
203}
204
205impl UrlClient {
206    pub async fn new(url: Url, token: impl Into<Option<String>>) -> Result<Self, ClientError> {
207        const ONE_DAY: Duration = Duration::from_secs(24 * 3600); // we handle timeouts ourselves.
208        let mut headers = HeaderMap::from_iter([(header::USER_AGENT, USER_AGENT.clone())]);
209        if let Some(token) = token.into() {
210            let value = HeaderValue::try_from(format!("Bearer {token}"))
211                .map_err(|e| ClientError::Custom(format!("Invalid authorization token: {e}")))?;
212            headers.insert(header::AUTHORIZATION, value);
213        }
214        let inner = match url.scheme() {
215            "ws" | "wss" => UrlClientInner::Ws(
216                jsonrpsee::ws_client::WsClientBuilder::new()
217                    .set_headers(headers)
218                    .max_request_size(MAX_REQUEST_BODY_SIZE)
219                    .max_response_size(*MAX_RESPONSE_BODY_SIZE)
220                    .request_timeout(ONE_DAY)
221                    .build(&url)
222                    .await?,
223            ),
224            "http" | "https" => UrlClientInner::Https(
225                jsonrpsee::http_client::HttpClientBuilder::new()
226                    .set_headers(headers)
227                    .max_request_size(MAX_REQUEST_BODY_SIZE)
228                    .max_response_size(*MAX_RESPONSE_BODY_SIZE)
229                    .request_timeout(ONE_DAY)
230                    .build(&url)?,
231            ),
232            it => {
233                return Err(ClientError::Custom(format!("Unsupported URL scheme: {it}")));
234            }
235        };
236        Ok(Self { url, inner })
237    }
238}
239
240#[allow(clippy::large_enum_variant)]
241enum UrlClientInner {
242    Ws(jsonrpsee::ws_client::WsClient),
243    Https(jsonrpsee::http_client::HttpClient),
244}
245
246impl jsonrpsee::core::client::ClientT for UrlClient {
247    fn notification<Params>(
248        &self,
249        method: &str,
250        params: Params,
251    ) -> impl Future<Output = Result<(), jsonrpsee::core::client::Error>> + Send
252    where
253        Params: ToRpcParams + Send,
254    {
255        match &self.inner {
256            UrlClientInner::Ws(it) => Either::Left(it.notification(method, params)),
257            UrlClientInner::Https(it) => Either::Right(it.notification(method, params)),
258        }
259    }
260
261    fn request<R, Params>(
262        &self,
263        method: &str,
264        params: Params,
265    ) -> impl Future<Output = Result<R, jsonrpsee::core::client::Error>> + Send
266    where
267        R: DeserializeOwned,
268        Params: ToRpcParams + Send,
269    {
270        match &self.inner {
271            UrlClientInner::Ws(it) => Either::Left(it.request(method, params)),
272            UrlClientInner::Https(it) => Either::Right(it.request(method, params)),
273        }
274    }
275
276    fn batch_request<'a, R>(
277        &self,
278        batch: jsonrpsee::core::params::BatchRequestBuilder<'a>,
279    ) -> impl Future<
280        Output = Result<
281            jsonrpsee::core::client::BatchResponse<'a, R>,
282            jsonrpsee::core::client::Error,
283        >,
284    > + Send
285    where
286        R: DeserializeOwned + fmt::Debug + 'a,
287    {
288        match &self.inner {
289            UrlClientInner::Ws(it) => Either::Left(it.batch_request(batch)),
290            UrlClientInner::Https(it) => Either::Right(it.batch_request(batch)),
291        }
292    }
293}
294
295impl jsonrpsee::core::client::SubscriptionClientT for UrlClient {
296    fn subscribe<'a, N, Params>(
297        &self,
298        subscribe_method: &'a str,
299        params: Params,
300        unsubscribe_method: &'a str,
301    ) -> impl Future<
302        Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
303    >
304    where
305        Params: ToRpcParams + Send,
306        N: DeserializeOwned,
307    {
308        match &self.inner {
309            UrlClientInner::Ws(it) => {
310                Either::Left(it.subscribe(subscribe_method, params, unsubscribe_method))
311            }
312            UrlClientInner::Https(it) => {
313                Either::Right(it.subscribe(subscribe_method, params, unsubscribe_method))
314            }
315        }
316    }
317
318    fn subscribe_to_method<N>(
319        &self,
320        method: &str,
321    ) -> impl Future<
322        Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
323    >
324    where
325        N: DeserializeOwned,
326    {
327        match &self.inner {
328            UrlClientInner::Ws(it) => Either::Left(it.subscribe_to_method(method)),
329            UrlClientInner::Https(it) => Either::Right(it.subscribe_to_method(method)),
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::cli_shared::FOREST_DATA_DIR_ENV;
338
339    // The RPC client should pick up the admin token from the data directory
340    // pointed to by `FOREST_PATH`, mirroring where a daemon started with the
341    // same variable saves it.
342    #[test]
343    #[serial_test::serial]
344    fn default_token_is_loaded_from_forest_path_data_dir() {
345        let tmp_dir = tempfile::tempdir().unwrap();
346        std::fs::write(tmp_dir.path().join("token"), "secret-token").unwrap();
347
348        unsafe {
349            env::remove_var("FULLNODE_API_INFO");
350            env::set_var(FOREST_DATA_DIR_ENV, tmp_dir.path());
351        }
352        let client = Client::default_or_from_env(None).unwrap();
353        unsafe { env::remove_var(FOREST_DATA_DIR_ENV) };
354
355        assert_eq!(client.token.as_deref(), Some("secret-token"));
356    }
357}