Skip to main content

sui_rpc/client/
mod.rs

1use std::sync::Arc;
2use std::time::Duration;
3use tap::Pipe;
4use tonic::body::Body;
5use tonic::codec::CompressionEncoding;
6use tonic::transport::channel::ClientTlsConfig;
7use tower::Layer;
8use tower::Service;
9use tower::ServiceBuilder;
10use tower::util::BoxLayer;
11use tower::util::BoxService;
12
13mod response_ext;
14pub use response_ext::ResponseExt;
15
16mod interceptors;
17pub use interceptors::HeadersInterceptor;
18
19mod watchdog;
20pub use watchdog::BodyIdleTimeout;
21use watchdog::DEFAULT_BODY_IDLE_TIMEOUT;
22use watchdog::WatchdogLayer;
23
24mod staking_rewards;
25pub use staking_rewards::DelegatedStake;
26
27mod coin_selection;
28mod lists;
29
30mod transaction_execution;
31pub use transaction_execution::ExecuteAndWaitError;
32
33use crate::proto::sui::rpc::v2::ledger_service_client::LedgerServiceClient;
34use crate::proto::sui::rpc::v2::move_package_service_client::MovePackageServiceClient;
35use crate::proto::sui::rpc::v2::signature_verification_service_client::SignatureVerificationServiceClient;
36use crate::proto::sui::rpc::v2::state_service_client::StateServiceClient;
37use crate::proto::sui::rpc::v2::subscription_service_client::SubscriptionServiceClient;
38use crate::proto::sui::rpc::v2::transaction_execution_service_client::TransactionExecutionServiceClient;
39#[cfg(feature = "unstable")]
40use crate::proto::sui::rpc::v2alpha::proof_service_client::ProofServiceClient;
41
42type Result<T, E = tonic::Status> = std::result::Result<T, E>;
43type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
44type BoxedChannel = BoxService<http::Request<Body>, http::Response<Body>, tonic::Status>;
45
46type RequestLayer = BoxLayer<
47    BoxService<http::Request<Body>, http::Response<Body>, BoxError>,
48    http::Request<Body>,
49    http::Response<Body>,
50    BoxError,
51>;
52
53const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
54const DEFAULT_TCP_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
55const DEFAULT_TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
56const DEFAULT_TCP_KEEPALIVE_RETRIES: u32 = 3;
57const DEFAULT_HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5);
58const DEFAULT_HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
59
60// All RPCs made through a `Client` (and all of its clones) are multiplexed
61// over a single HTTP/2 connection, so the connection-level receive window is
62// shared by every in-flight response. A streaming response that the
63// application holds without polling pins up to a full stream window of that
64// shared budget; once the connection window is exhausted, every RPC on the
65// channel hangs indefinitely while TCP and HTTP/2 keepalives stay healthy.
66// hyper's client defaults (2 MiB stream / 5 MiB connection) let ~3 stalled
67// streams starve the connection. Keep the stream window at hyper's default
68// but raise the connection window so ~32 concurrently stalled streams are
69// needed instead.
70const DEFAULT_HTTP2_STREAM_WINDOW_SIZE: u32 = 2 * 1024 * 1024;
71const DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE: u32 = 64 * 1024 * 1024;
72
73/// A gRPC client for the Sui fullnode RPC interface.
74///
75/// All RPCs made through a client and its clones are multiplexed over a
76/// single HTTP/2 connection.
77///
78/// # Timeouts and deadlines
79///
80/// No default bounds the total duration of a call. Two opt-in bounds are
81/// available:
82///
83/// - A per-call deadline set with [`tonic::Request::set_timeout`]. This
84///   attaches the standard `grpc-timeout` header, so a server that supports
85///   it enforces the deadline too, and the client enforces it locally end to
86///   end: tonic bounds the wait for response headers, and the client's
87///   watchdog (see [`with_body_idle_timeout`](Client::with_body_idle_timeout))
88///   bounds the response body against the same deadline.
89/// - A client-wide response-headers timeout set with
90///   [`with_response_headers_timeout`](Client::with_response_headers_timeout).
91///   This is enforced locally only and its timer stops once response headers
92///   arrive, so it bounds every unary call (whose headers are not sent until
93///   the handler completes) without cutting off long-lived streams.
94///
95/// Independent of any deadline, the watchdog resets RPCs whose response body
96/// makes no progress for 30 seconds (configurable), so a call on a stalled
97/// connection fails with `DeadlineExceeded` instead of hanging forever.
98#[derive(Clone)]
99pub struct Client {
100    channel: tonic::transport::Channel,
101
102    // Everything other than the channel is only consulted when building a
103    // per-service client or reconfiguring, so it lives behind an `Arc` to
104    // keep `Client` itself small; it is cloned by value into futures
105    // throughout the SDK. The `Endpoint` alone is over 500 bytes.
106    config: Arc<ClientConfig>,
107}
108
109#[derive(Clone)]
110struct ClientConfig {
111    uri: http::Uri,
112    endpoint: tonic::transport::Endpoint,
113    headers: HeadersInterceptor,
114    max_decoding_message_size: Option<usize>,
115    body_idle_timeout: Option<Duration>,
116
117    /// Layer to apply to all RPC requests
118    request_layer: Option<RequestLayer>,
119}
120
121impl Client {
122    /// URL for the public-good, Sui Foundation provided fullnodes for mainnet.
123    pub const MAINNET_FULLNODE: &str = "https://fullnode.mainnet.sui.io";
124
125    /// URL for the public-good, Sui Foundation provided fullnodes for testnet.
126    pub const TESTNET_FULLNODE: &str = "https://fullnode.testnet.sui.io";
127
128    /// URL for the public-good, Sui Foundation provided fullnodes for devnet.
129    pub const DEVNET_FULLNODE: &str = "https://fullnode.devnet.sui.io";
130
131    /// URL for the public-good, Sui Foundation provided archive for mainnet.
132    pub const MAINNET_ARCHIVE: &str = "https://archive.mainnet.sui.io";
133
134    /// URL for the public-good, Sui Foundation provided archive for testnet.
135    pub const TESTNET_ARCHIVE: &str = "https://archive.testnet.sui.io";
136
137    /// Build a client from a fully custom [`tonic::transport::Endpoint`].
138    ///
139    /// This bypasses every transport default that [`Client::new`] applies,
140    /// including the HTTP/2 flow-control windows that protect the shared
141    /// connection from starvation by stalled streaming responses. Prefer
142    /// [`Client::new`] plus the `with_*` configuration methods unless an
143    /// endpoint setting is needed that the client does not expose. The
144    /// idle-body watchdog (see [`Client::with_body_idle_timeout`]) is part of
145    /// the client rather than the endpoint and stays enabled.
146    ///
147    /// In particular, do not rely on
148    /// [`http2_adaptive_window`](tonic::transport::Endpoint::http2_adaptive_window)
149    /// as a substitute for large static windows: with adaptive windowing,
150    /// hyper starts the connection window at the 64 KiB HTTP/2 spec default
151    /// until bandwidth-delay probing ramps up, so a single stalled stream can
152    /// starve the whole connection.
153    pub fn from_endpoint(endpoint: &tonic::transport::Endpoint) -> Self {
154        let uri = endpoint.uri().clone();
155        let channel = endpoint.connect_lazy();
156        Self {
157            channel,
158            config: Arc::new(ClientConfig {
159                uri,
160                endpoint: endpoint.clone(),
161                headers: Default::default(),
162                max_decoding_message_size: None,
163                body_idle_timeout: Some(DEFAULT_BODY_IDLE_TIMEOUT),
164                request_layer: None,
165            }),
166        }
167    }
168
169    #[allow(clippy::result_large_err)]
170    pub fn new<T>(uri: T) -> Result<Self>
171    where
172        T: TryInto<http::Uri>,
173        T::Error: Into<BoxError>,
174    {
175        let uri = uri
176            .try_into()
177            .map_err(Into::into)
178            .map_err(status_from_error)?;
179        let mut endpoint = tonic::transport::Endpoint::from(uri.clone());
180        if uri.scheme() == Some(&http::uri::Scheme::HTTPS) {
181            endpoint = endpoint
182                .tls_config(ClientTlsConfig::new().with_enabled_roots())
183                .map_err(Into::into)
184                .map_err(status_from_error)?;
185        }
186
187        let endpoint = endpoint
188            .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
189            .tcp_keepalive(Some(DEFAULT_TCP_KEEPALIVE_IDLE))
190            .tcp_keepalive_interval(Some(DEFAULT_TCP_KEEPALIVE_INTERVAL))
191            .tcp_keepalive_retries(Some(DEFAULT_TCP_KEEPALIVE_RETRIES))
192            .http2_keep_alive_interval(DEFAULT_HTTP2_KEEP_ALIVE_INTERVAL)
193            .keep_alive_timeout(DEFAULT_HTTP2_KEEP_ALIVE_TIMEOUT)
194            .initial_stream_window_size(DEFAULT_HTTP2_STREAM_WINDOW_SIZE)
195            .initial_connection_window_size(DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE);
196        let channel = endpoint.connect_lazy();
197
198        Ok(Self {
199            channel,
200            config: Arc::new(ClientConfig {
201                uri,
202                endpoint,
203                headers: Default::default(),
204                max_decoding_message_size: None,
205                body_idle_timeout: Some(DEFAULT_BODY_IDLE_TIMEOUT),
206                request_layer: None,
207            }),
208        })
209    }
210
211    /// Set the idle timeout for the client's response-body watchdog.
212    /// Defaults to 30 seconds.
213    ///
214    /// The watchdog bounds the time between response-body progress events: if
215    /// a whole idle period passes without a frame of the response being
216    /// delivered to the caller -- because the connection is starved or dead,
217    /// or because the caller has parked a streaming response without polling
218    /// it -- the watchdog resets the stream, releasing the HTTP/2
219    /// flow-control window it had pinned, and the call observes a
220    /// [`DeadlineExceeded`](tonic::Code::DeadlineExceeded) status on its next
221    /// poll. This is what turns "an RPC on a starved connection hangs
222    /// forever" into a bounded failure, and what keeps an abandoned stream
223    /// from starving the shared connection in the first place.
224    ///
225    /// Streams that are legitimately quiet for longer than the timeout (the
226    /// fullnode's checkpoint subscription is not: it emits watermarks every
227    /// few seconds) should raise or disable the watchdog for that call with a
228    /// [`BodyIdleTimeout`] request extension.
229    pub fn with_body_idle_timeout(mut self, timeout: Duration) -> Self {
230        Arc::make_mut(&mut self.config).body_idle_timeout = Some(timeout);
231        self
232    }
233
234    /// Disable the client's response-body watchdog (see
235    /// [`with_body_idle_timeout`](Self::with_body_idle_timeout)).
236    ///
237    /// Without it, an RPC whose response can no longer make progress hangs
238    /// indefinitely; only disable the watchdog when every call is bounded by
239    /// the caller. It can be re-enabled for individual requests with a
240    /// [`BodyIdleTimeout`] request extension.
241    pub fn without_body_idle_timeout(mut self) -> Self {
242        Arc::make_mut(&mut self.config).body_idle_timeout = None;
243        self
244    }
245
246    /// Set a timeout for the response-headers phase of every RPC made
247    /// through this client. Disabled by default.
248    ///
249    /// The timer covers a request from dispatch on the connection until
250    /// response headers arrive and is dropped once they do, so a client-wide
251    /// value does not cut off long-lived streaming responses. Because a
252    /// server does not send response headers for a unary call until the
253    /// handler completes, this effectively bounds the total duration of
254    /// unary calls; the body that follows is bounded by the idle-body
255    /// watchdog (see [`with_body_idle_timeout`](Self::with_body_idle_timeout))
256    /// and, when set, the per-call deadline. Connection establishment is
257    /// bounded separately by the connect timeout.
258    ///
259    /// This timeout is enforced locally only; it is not communicated to the
260    /// server. When a per-call deadline ([`tonic::Request::set_timeout`]) is
261    /// also set, the shorter of the two bounds the headers phase locally, so
262    /// a per-call deadline can tighten this bound but never extend it --
263    /// size the timeout for the slowest expected RPC. Expiry surfaces as
264    /// [`DeadlineExceeded`](tonic::Code::DeadlineExceeded).
265    ///
266    /// This rebuilds the underlying channel, so it must be called before the
267    /// client is used or cloned; earlier clones keep the previous
268    /// configuration.
269    pub fn with_response_headers_timeout(mut self, timeout: Duration) -> Self {
270        let config = Arc::make_mut(&mut self.config);
271        config.endpoint = config.endpoint.clone().timeout(timeout);
272        self.channel = config.endpoint.connect_lazy();
273        self
274    }
275
276    /// Set the HTTP/2 per-stream receive window, in bytes.
277    ///
278    /// This bounds how much unread response data a single RPC can buffer
279    /// before the server must stop sending on that stream. It also bounds how
280    /// much of the shared connection window (see
281    /// [`with_initial_connection_window_size`](Self::with_initial_connection_window_size))
282    /// one stalled stream can pin. Defaults to 2 MiB.
283    ///
284    /// This rebuilds the underlying channel, so it must be called before the
285    /// client is used or cloned; earlier clones keep the previous
286    /// configuration.
287    pub fn with_initial_stream_window_size(mut self, size: u32) -> Self {
288        let config = Arc::make_mut(&mut self.config);
289        config.endpoint = config.endpoint.clone().initial_stream_window_size(size);
290        self.channel = config.endpoint.connect_lazy();
291        self
292    }
293
294    /// Set the HTTP/2 connection-level receive window, in bytes.
295    ///
296    /// This window is shared by every RPC multiplexed over the client's
297    /// single HTTP/2 connection, including all clones of the client. Response
298    /// data that the application has not yet read counts against it, so it
299    /// determines how many concurrently stalled streaming responses it takes
300    /// to starve the connection and hang every other RPC on it. Defaults to
301    /// 64 MiB (~32 stalled streams at the default 2 MiB stream window).
302    ///
303    /// This rebuilds the underlying channel, so it must be called before the
304    /// client is used or cloned; earlier clones keep the previous
305    /// configuration.
306    pub fn with_initial_connection_window_size(mut self, size: u32) -> Self {
307        let config = Arc::make_mut(&mut self.config);
308        config.endpoint = config.endpoint.clone().initial_connection_window_size(size);
309        self.channel = config.endpoint.connect_lazy();
310        self
311    }
312
313    pub fn with_headers(mut self, headers: HeadersInterceptor) -> Self {
314        Arc::make_mut(&mut self.config).headers = headers;
315        self
316    }
317
318    /// Provide an optional [`Layer`] that will be used to wrap all RPC
319    /// requests.
320    ///
321    /// This could be helpful in providing global metrics and logging
322    /// for all outbound requests.
323    ///
324    /// The layer's service may return any response body that implements
325    /// [`http_body::Body<Data = bytes::Bytes>`] and any error type that
326    /// implements `Into<Box<dyn Error + Send + Sync>>`. Both are mapped
327    /// to the internal types automatically.
328    ///
329    /// # Example
330    ///
331    /// Add a layer that logs each request URI:
332    ///
333    /// ```
334    /// # let _rt = tokio::runtime::Builder::new_current_thread()
335    /// #     .build()
336    /// #     .unwrap();
337    /// # let _guard = _rt.enter();
338    /// use sui_rpc::Client;
339    /// use tower::ServiceBuilder;
340    ///
341    /// let client = Client::new(Client::MAINNET_FULLNODE)
342    ///     .unwrap()
343    ///     .request_layer(ServiceBuilder::new().map_request(|req: http::Request<_>| {
344    ///         println!("request to {}", req.uri());
345    ///         req
346    ///     }));
347    /// ```
348    pub fn request_layer<L, ResBody, E>(mut self, layer: L) -> Self
349    where
350        L: Layer<BoxService<http::Request<Body>, http::Response<Body>, BoxError>>
351            + Send
352            + Sync
353            + 'static,
354        L::Service: Service<http::Request<Body>, Response = http::Response<ResBody>, Error = E>
355            + Send
356            + 'static,
357        <L::Service as Service<http::Request<Body>>>::Future: Send + 'static,
358        ResBody: http_body::Body<Data = bytes::Bytes> + Send + 'static,
359        ResBody::Error: Into<BoxError>,
360        E: Into<BoxError> + Send + 'static,
361    {
362        let layer = BoxLayer::new(
363            ServiceBuilder::new()
364                .map_response(|resp: http::Response<ResBody>| resp.map(Body::new))
365                .map_err(Into::<BoxError>::into)
366                .layer(layer),
367        );
368        Arc::make_mut(&mut self.config).request_layer = Some(layer);
369        self
370    }
371
372    pub fn with_max_decoding_message_size(mut self, limit: usize) -> Self {
373        Arc::make_mut(&mut self.config).max_decoding_message_size = Some(limit);
374        self
375    }
376
377    pub fn uri(&self) -> &http::Uri {
378        &self.config.uri
379    }
380
381    fn channel(&self) -> BoxedChannel {
382        let headers = self.config.headers.clone();
383
384        // Build the base service with headers applied at the HTTP level and the
385        // transport error mapped to BoxError for compatibility with user layers.
386        let base = BoxService::new(
387            ServiceBuilder::new()
388                .map_err(|e: tonic::transport::Error| -> BoxError { Box::new(e) })
389                .map_request(move |mut req: http::Request<Body>| {
390                    if !headers.headers().is_empty() {
391                        req.headers_mut()
392                            .extend(headers.headers().clone().into_headers());
393                    }
394                    req
395                })
396                .service(self.channel.clone()),
397        );
398
399        // Guard every response body with the idle-body watchdog, beneath any
400        // user layers so their view of the response goes through the
401        // watchdog's bridge.
402        let base = BoxService::new(WatchdogLayer::new(self.config.body_idle_timeout).layer(base));
403
404        // Apply the user's outbound request layer if present.
405        let layered = if let Some(layer) = &self.config.request_layer {
406            layer.layer(base)
407        } else {
408            base
409        };
410
411        // Map the final error to tonic::Status (a concrete type) so that
412        // downstream users of the tonic-generated clients don't run into
413        // lifetime-inference issues with async_trait and Box<dyn Error>.
414        BoxService::new(
415            ServiceBuilder::new()
416                .map_err(status_from_error)
417                .service(layered),
418        )
419    }
420
421    pub fn ledger_client(&mut self) -> LedgerServiceClient<BoxedChannel> {
422        LedgerServiceClient::new(self.channel())
423            .accept_compressed(CompressionEncoding::Zstd)
424            .pipe(|client| {
425                if let Some(limit) = self.config.max_decoding_message_size {
426                    client.max_decoding_message_size(limit)
427                } else {
428                    client
429                }
430            })
431    }
432
433    pub fn state_client(&mut self) -> StateServiceClient<BoxedChannel> {
434        StateServiceClient::new(self.channel())
435            .accept_compressed(CompressionEncoding::Zstd)
436            .pipe(|client| {
437                if let Some(limit) = self.config.max_decoding_message_size {
438                    client.max_decoding_message_size(limit)
439                } else {
440                    client
441                }
442            })
443    }
444
445    pub fn execution_client(&mut self) -> TransactionExecutionServiceClient<BoxedChannel> {
446        TransactionExecutionServiceClient::new(self.channel())
447            .accept_compressed(CompressionEncoding::Zstd)
448            .pipe(|client| {
449                if let Some(limit) = self.config.max_decoding_message_size {
450                    client.max_decoding_message_size(limit)
451                } else {
452                    client
453                }
454            })
455    }
456
457    pub fn package_client(&mut self) -> MovePackageServiceClient<BoxedChannel> {
458        MovePackageServiceClient::new(self.channel())
459            .accept_compressed(CompressionEncoding::Zstd)
460            .pipe(|client| {
461                if let Some(limit) = self.config.max_decoding_message_size {
462                    client.max_decoding_message_size(limit)
463                } else {
464                    client
465                }
466            })
467    }
468
469    pub fn signature_verification_client(
470        &mut self,
471    ) -> SignatureVerificationServiceClient<BoxedChannel> {
472        SignatureVerificationServiceClient::new(self.channel())
473            .accept_compressed(CompressionEncoding::Zstd)
474            .pipe(|client| {
475                if let Some(limit) = self.config.max_decoding_message_size {
476                    client.max_decoding_message_size(limit)
477                } else {
478                    client
479                }
480            })
481    }
482
483    pub fn subscription_client(&mut self) -> SubscriptionServiceClient<BoxedChannel> {
484        SubscriptionServiceClient::new(self.channel())
485            .accept_compressed(CompressionEncoding::Zstd)
486            .pipe(|client| {
487                if let Some(limit) = self.config.max_decoding_message_size {
488                    client.max_decoding_message_size(limit)
489                } else {
490                    client
491                }
492            })
493    }
494
495    /// Returns a client for the unstable alpha `ProofService`, which serves
496    /// Object Checkpoint State (OCS) inclusion proofs.
497    #[cfg(feature = "unstable")]
498    #[cfg_attr(doc_cfg, doc(cfg(feature = "unstable")))]
499    pub fn proof_client(&mut self) -> ProofServiceClient<BoxedChannel> {
500        ProofServiceClient::new(self.channel())
501            .accept_compressed(CompressionEncoding::Zstd)
502            .pipe(|client| {
503                if let Some(limit) = self.config.max_decoding_message_size {
504                    client.max_decoding_message_size(limit)
505                } else {
506                    client
507                }
508            })
509    }
510}
511
512/// Map a transport error to a [`tonic::Status`].
513///
514/// tonic surfaces an expired headers-phase timeout (the client's
515/// response-headers timeout, or a `grpc-timeout` deadline expiring before
516/// response headers arrive) as `Cancelled`. The gRPC code for an expired
517/// deadline is `DeadlineExceeded`, and the watchdog already uses it for
518/// body-phase expiry, so normalize before delegating to tonic's own mapping.
519fn status_from_error(error: BoxError) -> tonic::Status {
520    let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error.as_ref());
521    while let Some(err) = source {
522        // An embedded `Status` takes precedence, as in tonic's own mapping.
523        if err.is::<tonic::Status>() {
524            break;
525        }
526        if err.is::<tonic::TimeoutExpired>() {
527            return tonic::Status::deadline_exceeded(
528                "timeout expired before response headers were received",
529            );
530        }
531        source = err.source();
532    }
533    tonic::Status::from_error(error)
534}