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(|| {
59            "http://127.0.0.1:2345/"
60                .parse()
61                .expect("hardcoded default RPC URL is valid")
62        });
63
64        let mut base_url = match env::var("FULLNODE_API_INFO") {
65            Ok(it) => {
66                let crate::utils::UrlFromMultiAddr(url) = it.parse()?;
67                url
68            }
69            Err(env::VarError::NotPresent) => DEFAULT.clone(),
70            Err(e @ env::VarError::NotUnicode(_)) => bail!(e),
71        };
72        if token.is_some() && base_url.set_password(token).is_err() {
73            bail!("couldn't set override password")
74        }
75        // Set default token if not provided
76        if token.is_none() && base_url.password().is_none() {
77            // Honor the `FOREST_PATH` data directory override so the token saved
78            // by a daemon started with `FOREST_PATH` set is found here as well.
79            let default_token_path = crate::cli_shared::default_token_path();
80            if default_token_path.is_file() {
81                if let Ok(token) = std::fs::read_to_string(&default_token_path) {
82                    if base_url.set_password(Some(token.trim())).is_ok() {
83                        tracing::debug!("Loaded the default RPC token");
84                    } else {
85                        tracing::warn!("Failed to set the default RPC token");
86                    }
87                } else {
88                    tracing::warn!("Failed to load the default token file");
89                }
90            }
91        }
92        Ok(Self::from_url(base_url))
93    }
94    pub fn from_url(mut base_url: Url) -> Self {
95        let token = base_url.password().map(Into::into);
96        let _defer = base_url.set_password(None);
97        Self {
98            token,
99            base_url,
100            v0: Default::default(),
101            v1: Default::default(),
102            v2: Default::default(),
103        }
104    }
105    pub fn base_url(&self) -> &Url {
106        &self.base_url
107    }
108    pub async fn call<T: crate::lotus_json::HasLotusJson + std::fmt::Debug>(
109        &self,
110        req: Request<T>,
111    ) -> Result<T, ClientError> {
112        let api_path = req.api_path;
113        let Request {
114            method_name,
115            params,
116            timeout,
117            ..
118        } = req;
119        let method_name = method_name.as_ref();
120        let client = self.get_or_init_client(api_path).await?;
121        let span = tracing::debug_span!("request", method = %method_name, url = %client.url);
122        let work = async {
123            // jsonrpsee's clients have a global `timeout`, but not a per-request timeout, which
124            // RpcRequest expects.
125            // So shim in our own timeout
126            let result_or_timeout = tokio::time::timeout(
127                timeout,
128                match params {
129                    serde_json::Value::Null => Either::Left(Either::Left(
130                        client.request::<T::LotusJson, _>(method_name, ArrayParams::new()),
131                    )),
132                    serde_json::Value::Array(it) => {
133                        let mut params = ArrayParams::new();
134                        for param in it {
135                            params.insert(param)?
136                        }
137                        trace_params(params.clone());
138                        Either::Left(Either::Right(client.request(method_name, params)))
139                    }
140                    serde_json::Value::Object(it) => {
141                        let mut params = ObjectParams::new();
142                        for (name, param) in it {
143                            params.insert(&name, param)?
144                        }
145                        trace_params(params.clone());
146                        Either::Right(client.request(method_name, params))
147                    }
148                    prim @ (serde_json::Value::Bool(_)
149                    | serde_json::Value::Number(_)
150                    | serde_json::Value::String(_)) => {
151                        return Err(ClientError::Custom(format!(
152                            "invalid parameter type: `{prim}`"
153                        )));
154                    }
155                },
156            )
157            .await;
158            let result = match result_or_timeout {
159                Ok(Ok(it)) => Ok(T::from_lotus_json(it)),
160                Ok(Err(e)) => Err(e),
161                Err(_) => Err(ClientError::RequestTimeout),
162            };
163            debug!(?result);
164            result
165        };
166        work.instrument(span.or_current()).await
167    }
168    async fn get_or_init_client(&self, path: ApiPaths) -> Result<&UrlClient, ClientError> {
169        match path {
170            ApiPaths::V0 => &self.v0,
171            ApiPaths::V1 => &self.v1,
172            ApiPaths::V2 => &self.v2,
173        }
174        .get_or_try_init(|| async {
175            let url = self.base_url.join(path.path()).map_err(|it| {
176                ClientError::Custom(format!("creating url for endpoint failed: {it}"))
177            })?;
178            UrlClient::new(url, self.token.clone()).await
179        })
180        .await
181    }
182}
183
184fn trace_params(params: impl jsonrpsee::core::traits::ToRpcParams) {
185    if tracing::enabled!(Level::TRACE) {
186        match params.to_rpc_params() {
187            Ok(Some(it)) => tracing::trace!(params = %it),
188            Ok(None) => tracing::trace!("no params"),
189            Err(error) => tracing::trace!(%error, "couldn't decode params"),
190        }
191    }
192}
193
194/// Represents a single, perhaps persistent connection to a URL over which requests
195/// can be made using [`jsonrpsee`] primitives.
196pub struct UrlClient {
197    url: Url,
198    inner: UrlClientInner,
199}
200
201impl Debug for UrlClient {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.debug_struct("OneClient")
204            .field("url", &self.url)
205            .finish_non_exhaustive()
206    }
207}
208
209impl UrlClient {
210    pub async fn new(url: Url, token: impl Into<Option<String>>) -> Result<Self, ClientError> {
211        const ONE_DAY: Duration = Duration::from_secs(24 * 3600); // we handle timeouts ourselves.
212        let mut headers = HeaderMap::from_iter([(header::USER_AGENT, USER_AGENT.clone())]);
213        if let Some(token) = token.into() {
214            let value = HeaderValue::try_from(format!("Bearer {token}"))
215                .map_err(|e| ClientError::Custom(format!("Invalid authorization token: {e}")))?;
216            headers.insert(header::AUTHORIZATION, value);
217        }
218        let inner = match url.scheme() {
219            "ws" | "wss" => UrlClientInner::Ws(
220                jsonrpsee::ws_client::WsClientBuilder::new()
221                    .set_headers(headers)
222                    .max_request_size(MAX_REQUEST_BODY_SIZE)
223                    .max_response_size(*MAX_RESPONSE_BODY_SIZE)
224                    .request_timeout(ONE_DAY)
225                    .build(&url)
226                    .await?,
227            ),
228            "http" | "https" => UrlClientInner::Https(
229                jsonrpsee::http_client::HttpClientBuilder::new()
230                    .set_headers(headers)
231                    .max_request_size(MAX_REQUEST_BODY_SIZE)
232                    .max_response_size(*MAX_RESPONSE_BODY_SIZE)
233                    .request_timeout(ONE_DAY)
234                    .build(&url)?,
235            ),
236            it => {
237                return Err(ClientError::Custom(format!("Unsupported URL scheme: {it}")));
238            }
239        };
240        Ok(Self { url, inner })
241    }
242}
243
244#[allow(clippy::large_enum_variant)]
245enum UrlClientInner {
246    Ws(jsonrpsee::ws_client::WsClient),
247    Https(jsonrpsee::http_client::HttpClient),
248}
249
250impl jsonrpsee::core::client::ClientT for UrlClient {
251    fn notification<Params>(
252        &self,
253        method: &str,
254        params: Params,
255    ) -> impl Future<Output = Result<(), jsonrpsee::core::client::Error>> + Send
256    where
257        Params: ToRpcParams + Send,
258    {
259        match &self.inner {
260            UrlClientInner::Ws(it) => Either::Left(it.notification(method, params)),
261            UrlClientInner::Https(it) => Either::Right(it.notification(method, params)),
262        }
263    }
264
265    fn request<R, Params>(
266        &self,
267        method: &str,
268        params: Params,
269    ) -> impl Future<Output = Result<R, jsonrpsee::core::client::Error>> + Send
270    where
271        R: DeserializeOwned,
272        Params: ToRpcParams + Send,
273    {
274        match &self.inner {
275            UrlClientInner::Ws(it) => Either::Left(it.request(method, params)),
276            UrlClientInner::Https(it) => Either::Right(it.request(method, params)),
277        }
278    }
279
280    fn batch_request<'a, R>(
281        &self,
282        batch: jsonrpsee::core::params::BatchRequestBuilder<'a>,
283    ) -> impl Future<
284        Output = Result<
285            jsonrpsee::core::client::BatchResponse<'a, R>,
286            jsonrpsee::core::client::Error,
287        >,
288    > + Send
289    where
290        R: DeserializeOwned + fmt::Debug + 'a,
291    {
292        match &self.inner {
293            UrlClientInner::Ws(it) => Either::Left(it.batch_request(batch)),
294            UrlClientInner::Https(it) => Either::Right(it.batch_request(batch)),
295        }
296    }
297}
298
299impl jsonrpsee::core::client::SubscriptionClientT for UrlClient {
300    fn subscribe<'a, N, Params>(
301        &self,
302        subscribe_method: &'a str,
303        params: Params,
304        unsubscribe_method: &'a str,
305    ) -> impl Future<
306        Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
307    >
308    where
309        Params: ToRpcParams + Send,
310        N: DeserializeOwned,
311    {
312        match &self.inner {
313            UrlClientInner::Ws(it) => {
314                Either::Left(it.subscribe(subscribe_method, params, unsubscribe_method))
315            }
316            UrlClientInner::Https(it) => {
317                Either::Right(it.subscribe(subscribe_method, params, unsubscribe_method))
318            }
319        }
320    }
321
322    fn subscribe_to_method<N>(
323        &self,
324        method: &str,
325    ) -> impl Future<
326        Output = Result<jsonrpsee::core::client::Subscription<N>, jsonrpsee::core::client::Error>,
327    >
328    where
329        N: DeserializeOwned,
330    {
331        match &self.inner {
332            UrlClientInner::Ws(it) => Either::Left(it.subscribe_to_method(method)),
333            UrlClientInner::Https(it) => Either::Right(it.subscribe_to_method(method)),
334        }
335    }
336}
337
338/// Rewrites a JSON-RPC call error anywhere in the chain to its server-sent message,
339/// preserving any context layered on top.
340pub fn humanize_rpc_error(e: anyhow::Error) -> anyhow::Error {
341    match e.downcast_ref::<ClientError>() {
342        Some(ClientError::Call(obj)) => {
343            let contexts: Vec<String> = e
344                .chain()
345                .take_while(|cause| cause.downcast_ref::<ClientError>().is_none())
346                .map(ToString::to_string)
347                .collect();
348            let mut out = match obj.data() {
349                Some(data) => anyhow::anyhow!("{} (data: {data})", obj.message()),
350                None => anyhow::anyhow!("{}", obj.message()),
351            };
352            for context in contexts.into_iter().rev() {
353                out = out.context(context);
354            }
355            out
356        }
357        _ => e,
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::cli_shared::FOREST_DATA_DIR_ENV;
365    use jsonrpsee::types::ErrorObject;
366
367    fn call_error() -> ClientError {
368        ClientError::Call(ErrorObject::owned(1, "export already running", None::<()>))
369    }
370
371    #[test]
372    fn rpc_call_errors_render_as_their_message() {
373        assert_eq!(
374            format!("{:#}", humanize_rpc_error(call_error().into())),
375            "export already running"
376        );
377
378        let wrapped = anyhow::Error::from(call_error()).context("failed to check F3 sync status");
379        assert_eq!(
380            format!("{:#}", humanize_rpc_error(wrapped)),
381            "failed to check F3 sync status: export already running"
382        );
383
384        // Non-RPC errors pass through untouched, chain included.
385        let other = anyhow::anyhow!("inner").context("outer");
386        assert_eq!(format!("{:#}", humanize_rpc_error(other)), "outer: inner");
387    }
388
389    // The RPC client should pick up the admin token from the data directory
390    // pointed to by `FOREST_PATH`, mirroring where a daemon started with the
391    // same variable saves it.
392    #[test]
393    #[serial_test::serial]
394    fn default_token_is_loaded_from_forest_path_data_dir() {
395        let tmp_dir = tempfile::tempdir().unwrap();
396        std::fs::write(tmp_dir.path().join("token"), "secret-token").unwrap();
397
398        unsafe {
399            env::remove_var("FULLNODE_API_INFO");
400            env::set_var(FOREST_DATA_DIR_ENV, tmp_dir.path());
401        }
402        let client = Client::default_or_from_env(None).unwrap();
403        unsafe { env::remove_var(FOREST_DATA_DIR_ENV) };
404
405        assert_eq!(client.token.as_deref(), Some("secret-token"));
406    }
407}