sui_jsonrpc/
client.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fmt::{Debug, Formatter};
5use std::sync::Arc;
6use std::time::Duration;
7
8use af_sui_types::{
9    Address as SuiAddress,
10    GasCostSummary,
11    GasData,
12    ObjectArg,
13    ObjectId,
14    ObjectRef,
15    TransactionData,
16    TransactionDataV1,
17    TransactionExpiration,
18    TransactionKind,
19    encode_base64_default,
20};
21use jsonrpsee::core::client::ClientT;
22use jsonrpsee::http_client::{HeaderMap, HeaderValue, HttpClient, HttpClientBuilder};
23use jsonrpsee::rpc_params;
24use jsonrpsee::ws_client::{PingConfig, WsClient, WsClientBuilder};
25use serde_json::Value;
26
27use super::{CLIENT_SDK_TYPE_HEADER, CLIENT_SDK_VERSION_HEADER, CLIENT_TARGET_API_VERSION_HEADER};
28use crate::api::{CoinReadApiClient, ReadApiClient as _, WriteApiClient as _};
29use crate::error::JsonRpcClientError;
30use crate::msgs::{
31    Coin,
32    DryRunTransactionBlockResponse,
33    SuiExecutionStatus,
34    SuiObjectDataError,
35    SuiObjectDataOptions,
36    SuiObjectResponse,
37    SuiObjectResponseError,
38    SuiTransactionBlockEffectsAPI as _,
39};
40
41/// Maximum possible budget.
42pub const MAX_GAS_BUDGET: u64 = 50000000000;
43/// Maximum number of objects that can be fetched via
44/// [multi_get_objects][crate::api::ReadApiClient::multi_get_objects].
45pub const MULTI_GET_OBJECT_MAX_SIZE: usize = 50;
46pub const SUI_COIN_TYPE: &str = "0x2::sui::SUI";
47pub const SUI_LOCAL_NETWORK_URL: &str = "http://127.0.0.1:9000";
48pub const SUI_LOCAL_NETWORK_WS: &str = "ws://127.0.0.1:9000";
49pub const SUI_LOCAL_NETWORK_GAS_URL: &str = "http://127.0.0.1:5003/gas";
50pub const SUI_DEVNET_URL: &str = "https://fullnode.devnet.sui.io:443";
51pub const SUI_TESTNET_URL: &str = "https://fullnode.testnet.sui.io:443";
52
53pub type SuiClientResult<T = ()> = Result<T, SuiClientError>;
54
55#[derive(thiserror::Error, Debug)]
56pub enum SuiClientError {
57    #[error("jsonrpsee client error: {0}")]
58    JsonRpcClient(#[from] JsonRpcClientError),
59    #[error("Data error: {0}")]
60    DataError(String),
61    #[error(
62        "Client/Server api version mismatch, client api version : {client_version}, server api version : {server_version}"
63    )]
64    ServerVersionMismatch {
65        client_version: String,
66        server_version: String,
67    },
68    #[error(
69        "Insufficient funds for address [{address}]; found balance {found}, requested: {requested}"
70    )]
71    InsufficientFunds {
72        address: SuiAddress,
73        found: u64,
74        requested: u64,
75    },
76    #[error("In object response: {0}")]
77    SuiObjectResponse(#[from] SuiObjectResponseError),
78    #[error("In object data: {0}")]
79    SuiObjectData(#[from] SuiObjectDataError),
80}
81
82/// A Sui client builder for connecting to the Sui network
83///
84/// By default the `maximum concurrent requests` is set to 256 and
85/// the `request timeout` is set to 60 seconds. These can be adjusted using the
86/// `max_concurrent_requests` function, and the `request_timeout` function.
87/// If you use the WebSocket, consider setting the `ws_ping_interval` field to a
88/// value of your choice to prevent the inactive WS subscription being
89/// disconnected due to proxy timeout.
90///
91/// # Examples
92///
93/// ```rust,no_run
94/// use sui_jsonrpc::client::SuiClientBuilder;
95/// #[tokio::main]
96/// async fn main() -> Result<(), color_eyre::eyre::Error> {
97///     let sui = SuiClientBuilder::default()
98///         .build("http://127.0.0.1:9000")
99///         .await?;
100///
101///     println!("Sui local network version: {:?}", sui.api_version());
102///     Ok(())
103/// }
104/// ```
105pub struct SuiClientBuilder {
106    request_timeout: Duration,
107    ws_url: Option<String>,
108    ws_ping_interval: Option<Duration>,
109    basic_auth: Option<(String, String)>,
110}
111
112impl Default for SuiClientBuilder {
113    fn default() -> Self {
114        Self {
115            request_timeout: Duration::from_secs(60),
116            ws_url: None,
117            ws_ping_interval: None,
118            basic_auth: None,
119        }
120    }
121}
122
123impl SuiClientBuilder {
124    /// Set the request timeout to the specified duration
125    pub const fn request_timeout(mut self, request_timeout: Duration) -> Self {
126        self.request_timeout = request_timeout;
127        self
128    }
129
130    /// Set the WebSocket URL for the Sui network
131    #[deprecated = "\
132        JSON-RPC subscriptions have been deprecated since at least mainnet-v1.28.3. \
133        See <https://github.com/MystenLabs/sui/releases/tag/mainnet-v1.28.3>\
134    "]
135    pub fn ws_url(mut self, url: impl AsRef<str>) -> Self {
136        self.ws_url = Some(url.as_ref().to_string());
137        self
138    }
139
140    /// Set the WebSocket ping interval
141    #[deprecated = "\
142        JSON-RPC subscriptions have been deprecated since at least mainnet-v1.28.3. \
143        See <https://github.com/MystenLabs/sui/releases/tag/mainnet-v1.28.3>\
144    "]
145    pub const fn ws_ping_interval(mut self, duration: Duration) -> Self {
146        self.ws_ping_interval = Some(duration);
147        self
148    }
149
150    /// Set the basic auth credentials for the HTTP client
151    pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
152        self.basic_auth = Some((username.as_ref().to_string(), password.as_ref().to_string()));
153        self
154    }
155
156    /// Returns a [SuiClient] object that is ready to interact with the local
157    /// development network (by default it expects the Sui network to be
158    /// up and running at `127.0.0.1:9000`).
159    pub async fn build_localnet(self) -> SuiClientResult<SuiClient> {
160        self.build(SUI_LOCAL_NETWORK_URL).await
161    }
162
163    /// Returns a [SuiClient] object that is ready to interact with the Sui devnet.
164    pub async fn build_devnet(self) -> SuiClientResult<SuiClient> {
165        self.build(SUI_DEVNET_URL).await
166    }
167
168    /// Returns a [SuiClient] object that is ready to interact with the Sui testnet.
169    pub async fn build_testnet(self) -> SuiClientResult<SuiClient> {
170        self.build(SUI_TESTNET_URL).await
171    }
172
173    /// Returns a [SuiClient] object connected to the Sui network running at the URI provided.
174    #[allow(clippy::future_not_send)]
175    pub async fn build(self, http: impl AsRef<str>) -> SuiClientResult<SuiClient> {
176        let client_version = env!("CARGO_PKG_VERSION");
177        let mut headers = HeaderMap::new();
178        headers.insert(
179            CLIENT_TARGET_API_VERSION_HEADER,
180            // in rust, the client version is the same as the target api version
181            HeaderValue::from_static(client_version),
182        );
183        headers.insert(
184            CLIENT_SDK_VERSION_HEADER,
185            HeaderValue::from_static(client_version),
186        );
187        headers.insert(CLIENT_SDK_TYPE_HEADER, HeaderValue::from_static("rust"));
188
189        if let Some((username, password)) = self.basic_auth {
190            let auth = encode_base64_default(format!("{}:{}", username, password));
191            headers.insert(
192                http::header::AUTHORIZATION,
193                HeaderValue::from_str(&format!("Basic {}", auth))
194                    .expect("Failed creating HeaderValue for basic auth"),
195            );
196        }
197
198        let ws = if let Some(url) = self.ws_url {
199            let mut builder = WsClientBuilder::default()
200                .max_request_size(2 << 30)
201                .set_headers(headers.clone())
202                .request_timeout(self.request_timeout);
203
204            if let Some(duration) = self.ws_ping_interval {
205                builder = builder.enable_ws_ping(PingConfig::default().ping_interval(duration))
206            }
207
208            Some(builder.build(url).await?)
209        } else {
210            None
211        };
212
213        let http = HttpClientBuilder::default()
214            .max_request_size(2 << 30)
215            .set_headers(headers.clone())
216            .request_timeout(self.request_timeout)
217            .build(http)?;
218
219        let info = Self::get_server_info(&http, &ws).await?;
220
221        Ok(SuiClient {
222            http: Arc::new(http),
223            ws: Arc::new(ws),
224            info: Arc::new(info),
225        })
226    }
227
228    /// Return the server information as a `ServerInfo` structure.
229    ///
230    /// Fails with an error if it cannot call the RPC discover.
231    async fn get_server_info(
232        http: &HttpClient,
233        ws: &Option<WsClient>,
234    ) -> Result<ServerInfo, SuiClientError> {
235        let rpc_spec: Value = http.request("rpc.discover", rpc_params![]).await?;
236        let version = rpc_spec
237            .pointer("/info/version")
238            .and_then(|v| v.as_str())
239            .ok_or_else(|| {
240                SuiClientError::DataError(
241                    "Fail parsing server version from rpc.discover endpoint.".into(),
242                )
243            })?;
244        let rpc_methods = Self::parse_methods(&rpc_spec)?;
245
246        let subscriptions = if let Some(ws) = ws {
247            let rpc_spec: Value = ws.request("rpc.discover", rpc_params![]).await?;
248            Self::parse_methods(&rpc_spec)?
249        } else {
250            Vec::new()
251        };
252        Ok(ServerInfo {
253            rpc_methods,
254            subscriptions,
255            version: version.to_string(),
256        })
257    }
258
259    fn parse_methods(server_spec: &Value) -> Result<Vec<String>, SuiClientError> {
260        let methods = server_spec
261            .pointer("/methods")
262            .and_then(|methods| methods.as_array())
263            .ok_or_else(|| {
264                SuiClientError::DataError(
265                    "Fail parsing server information from rpc.discover endpoint.".into(),
266                )
267            })?;
268
269        Ok(methods
270            .iter()
271            .flat_map(|method| method["name"].as_str())
272            .map(|s| s.into())
273            .collect())
274    }
275}
276
277/// SuiClient is the basic type that provides all the necessary abstractions for interacting with the Sui network.
278///
279/// # Usage
280///
281/// Use [SuiClientBuilder] to build a [SuiClient].
282#[derive(Clone)]
283pub struct SuiClient {
284    http: Arc<HttpClient>,
285    ws: Arc<Option<WsClient>>,
286    info: Arc<ServerInfo>,
287}
288
289impl Debug for SuiClient {
290    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
291        write!(
292            f,
293            "RPC client. Http: {:?}, Websocket: {:?}",
294            self.http, self.ws
295        )
296    }
297}
298
299/// ServerInfo contains all the useful information regarding the API version, the available RPC calls, and subscriptions.
300struct ServerInfo {
301    rpc_methods: Vec<String>,
302    subscriptions: Vec<String>,
303    version: String,
304}
305
306impl SuiClient {
307    pub fn builder() -> SuiClientBuilder {
308        Default::default()
309    }
310
311    /// Returns a list of RPC methods supported by the node the client is connected to.
312    pub fn available_rpc_methods(&self) -> &Vec<String> {
313        &self.info.rpc_methods
314    }
315
316    /// Returns a list of streaming/subscription APIs supported by the node the client is connected to.
317    pub fn available_subscriptions(&self) -> &Vec<String> {
318        &self.info.subscriptions
319    }
320
321    /// Returns the API version information as a string.
322    ///
323    /// The format of this string is `<major>.<minor>.<patch>`, e.g., `1.6.0`,
324    /// and it is retrieved from the OpenRPC specification via the discover service method.
325    pub fn api_version(&self) -> &str {
326        &self.info.version
327    }
328
329    /// Verifies if the API version matches the server version and returns an error if they do not match.
330    pub fn check_api_version(&self) -> SuiClientResult<()> {
331        let server_version = self.api_version();
332        let client_version = env!("CARGO_PKG_VERSION");
333        if server_version != client_version {
334            return Err(SuiClientError::ServerVersionMismatch {
335                client_version: client_version.to_string(),
336                server_version: server_version.to_string(),
337            });
338        };
339        Ok(())
340    }
341
342    /// Returns a reference to the underlying http client.
343    pub fn http(&self) -> &HttpClient {
344        &self.http
345    }
346
347    /// Returns a reference to the underlying WebSocket client, if any.
348    pub fn ws(&self) -> Option<&WsClient> {
349        (*self.ws).as_ref()
350    }
351
352    pub async fn get_shared_oarg(&self, id: ObjectId, mutable: bool) -> SuiClientResult<ObjectArg> {
353        let data = self
354            .http()
355            .get_object(id, Some(SuiObjectDataOptions::new().with_owner()))
356            .await?
357            .into_object()?;
358        Ok(data.shared_object_arg(mutable)?)
359    }
360
361    pub async fn get_imm_or_owned_oarg(&self, id: ObjectId) -> SuiClientResult<ObjectArg> {
362        let data = self
363            .http()
364            .get_object(id, Some(SuiObjectDataOptions::new().with_owner()))
365            .await?
366            .into_object()?;
367        Ok(data.imm_or_owned_object_arg()?)
368    }
369
370    /// Return the object data for a list of objects.
371    ///
372    /// This method works for any number of object ids.
373    pub async fn multi_get_objects<I>(
374        &self,
375        object_ids: I,
376        options: SuiObjectDataOptions,
377    ) -> SuiClientResult<Vec<SuiObjectResponse>>
378    where
379        I: IntoIterator<Item = ObjectId> + Send,
380        I::IntoIter: Send,
381    {
382        let mut result = Vec::new();
383        for chunk in iter_chunks(object_ids, MULTI_GET_OBJECT_MAX_SIZE) {
384            if chunk.len() == 1 {
385                let elem = self
386                    .http()
387                    .get_object(chunk[0], Some(options.clone()))
388                    .await?;
389                result.push(elem);
390            } else {
391                let it = self
392                    .http()
393                    .multi_get_objects(chunk, Some(options.clone()))
394                    .await?;
395                result.extend(it);
396            }
397        }
398        Ok(result)
399    }
400
401    /// Return a list of coins for the given address, or an error upon failure.
402    ///
403    /// Note that the function selects coins to meet or exceed the requested `amount`.
404    /// If that it is not possible, it will fail with an insufficient fund error.
405    ///
406    /// The coins can be filtered by `coin_type` (e.g., 0x168da5bf1f48dafc111b0a488fa454aca95e0b5e::usdc::USDC)
407    /// or use `None` to use the default `Coin<SUI>`.
408    ///
409    /// # Examples
410    ///
411    /// ```rust,no_run
412    /// use sui_jsonrpc::client::SuiClientBuilder;
413    /// use af_sui_types::Address as SuiAddress;
414    ///
415    /// #[tokio::main]
416    /// async fn main() -> color_eyre::Result<()> {
417    ///     let sui = SuiClientBuilder::default().build_localnet().await?;
418    ///     let address = "0x0000....0000".parse()?;
419    ///     let coins = sui
420    ///         .select_coins(address, None, 5, vec![])
421    ///         .await?;
422    ///     Ok(())
423    /// }
424    /// ```
425    pub async fn select_coins(
426        &self,
427        address: SuiAddress,
428        coin_type: Option<String>,
429        amount: u64,
430        exclude: Vec<ObjectId>,
431    ) -> SuiClientResult<Vec<Coin>> {
432        let mut coins = vec![];
433        let mut total = 0;
434        let mut has_next_page = true;
435        let mut cursor = None;
436
437        while has_next_page {
438            let page = self
439                .http()
440                .get_coins(address, coin_type.clone(), cursor, None)
441                .await?;
442
443            for coin in page
444                .data
445                .into_iter()
446                .filter(|c| !exclude.contains(&c.coin_object_id))
447            {
448                total += coin.balance;
449                coins.push(coin);
450                if total >= amount {
451                    return Ok(coins);
452                }
453            }
454
455            has_next_page = page.has_next_page;
456            cursor = page.next_cursor;
457        }
458
459        Err(SuiClientError::InsufficientFunds {
460            address,
461            found: total,
462            requested: amount,
463        })
464    }
465
466    /// Estimate a budget for the transaction by dry-running it.
467    ///
468    /// Uses default [`GasBudgetOptions`] to compute the cost estimate.
469    pub async fn gas_budget(
470        &self,
471        tx_kind: &TransactionKind,
472        sender: SuiAddress,
473        price: u64,
474    ) -> Result<u64, DryRunError> {
475        let options = GasBudgetOptions::new(price);
476        self.gas_budget_with_options(tx_kind, sender, options).await
477    }
478
479    /// Estimate a budget for the transaction by dry-running it.
480    pub async fn gas_budget_with_options(
481        &self,
482        tx_kind: &TransactionKind,
483        sender: SuiAddress,
484        options: GasBudgetOptions,
485    ) -> Result<u64, DryRunError> {
486        let sentinel = TransactionData::V1(TransactionDataV1 {
487            kind: tx_kind.clone(),
488            sender,
489            gas_data: GasData {
490                payment: vec![],
491                owner: sender,
492                price: options.price,
493                budget: options.dry_run_budget,
494            },
495            expiration: TransactionExpiration::None,
496        });
497        let response = self
498            .http()
499            .dry_run_transaction_block(encode_base64_default(
500                bcs::to_bytes(&sentinel).expect("TransactionData serialization shouldn't fail"),
501            ))
502            .await?;
503        if let SuiExecutionStatus::Failure { error } = response.effects.status() {
504            return Err(DryRunError::Execution(error.clone(), response));
505        }
506
507        let budget = {
508            let safe_overhead = options.safe_overhead_multiplier * options.price;
509            estimate_gas_budget_from_gas_cost(response.effects.gas_cost_summary(), safe_overhead)
510        };
511        Ok(budget)
512    }
513
514    /// Build the gas data for a transaction by querying the node for gas objects.
515    pub async fn get_gas_data(
516        &self,
517        tx_kind: &TransactionKind,
518        sponsor: SuiAddress,
519        budget: u64,
520        price: u64,
521    ) -> Result<GasData, GetGasDataError> {
522        let exclude = if let TransactionKind::ProgrammableTransaction(ptb) = tx_kind {
523            use sui_sdk_types::Input::*;
524
525            ptb.inputs
526                .iter()
527                .filter_map(|i| match i {
528                    Pure { .. } => None,
529                    Shared { object_id, .. } => Some(*object_id),
530                    ImmutableOrOwned(oref) | Receiving(oref) => Some(*oref.object_id()),
531                })
532                .collect()
533        } else {
534            vec![]
535        };
536
537        if budget < price {
538            return Err(GetGasDataError::BudgetTooSmall { budget, price });
539        }
540
541        let coins = self
542            .select_coins(sponsor, Some("0x2::sui::SUI".to_owned()), budget, exclude)
543            .await
544            .map_err(|inner| GetGasDataError::NotEnoughGas {
545                sponsor,
546                budget,
547                inner,
548            })?
549            .into_iter()
550            .map(|c| c.object_ref())
551            .collect();
552
553        Ok(GasData {
554            payment: coins,
555            owner: sponsor,
556            price,
557            budget,
558        })
559    }
560
561    /// Get the latest object reference for an ID from the node.
562    pub async fn latest_object_ref(&self, object_id: ObjectId) -> SuiClientResult<ObjectRef> {
563        Ok(self
564            .http()
565            .get_object(object_id, Some(SuiObjectDataOptions::default()))
566            .await?
567            .into_object()?
568            .object_ref())
569    }
570}
571
572/// Parameters for computing the gas budget for a transaction using a dry-run.
573#[derive(Clone, Debug)]
574#[non_exhaustive]
575pub struct GasBudgetOptions {
576    /// The gas price. Must be set via [`Self::new`].
577    pub price: u64,
578
579    /// The budget for the dry-run.
580    pub dry_run_budget: u64,
581
582    /// Multiplier on the gas price. The result is a balance to add to both the computation and net
583    /// gas costs to account for possible fluctuations when the transaction is actually submitted.
584    pub safe_overhead_multiplier: u64,
585}
586
587impl GasBudgetOptions {
588    #[expect(
589        clippy::missing_const_for_fn,
590        reason = "We might evolve the defaults to use non-const expressions"
591    )]
592    pub fn new(price: u64) -> Self {
593        Self {
594            price,
595            dry_run_budget: MAX_GAS_BUDGET,
596            safe_overhead_multiplier: GAS_SAFE_OVERHEAD_MULTIPLIER,
597        }
598    }
599}
600
601#[derive(thiserror::Error, Debug)]
602#[expect(
603    clippy::large_enum_variant,
604    reason = "Boxing now would break backwards compatibility"
605)]
606pub enum DryRunError {
607    #[error("Error in dry run: {0}")]
608    Execution(String, DryRunTransactionBlockResponse),
609    #[error("In JSON-RPC client: {0}")]
610    Client(#[from] JsonRpcClientError),
611}
612
613#[derive(thiserror::Error, Debug)]
614pub enum GetGasDataError {
615    #[error("In JSON-RPC client: {0}")]
616    Client(#[from] JsonRpcClientError),
617    #[error(
618        "Gas budget {budget} is less than the gas price {price}. \
619           The gas budget must be at least the gas price of {price}."
620    )]
621    BudgetTooSmall { budget: u64, price: u64 },
622    #[error(
623        "Cannot find gas coins for address [{sponsor}] \
624        with amount sufficient for the required gas amount [{budget}]. \
625        Caused by {inner}"
626    )]
627    NotEnoughGas {
628        sponsor: SuiAddress,
629        budget: u64,
630        inner: SuiClientError,
631    },
632}
633
634/// Multiplier on the gas price for computing gas budgets from dry-runs.
635///
636/// Same value as used in the Sui CLI.
637const GAS_SAFE_OVERHEAD_MULTIPLIER: u64 = 1000;
638
639/// Use primarily on the gas cost of dry-runs.
640///
641/// Same as used in the Sui CLI.
642///
643/// # Arguments
644/// - `gas_cost_summary`: gas cost breakdown
645/// - `safe_overhead`: balance to add to both the computation and net gas costs to account for
646///   possible fluctuations when the transaction is actually submitted.
647fn estimate_gas_budget_from_gas_cost(gas_cost_summary: &GasCostSummary, safe_overhead: u64) -> u64 {
648    let computation_cost_with_overhead = gas_cost_summary.computation_cost + safe_overhead;
649
650    let gas_usage_with_overhead = gas_cost_summary.net_gas_usage() + safe_overhead as i64;
651    computation_cost_with_overhead.max(gas_usage_with_overhead.max(0) as u64)
652}
653
654fn iter_chunks<I>(iter: I, chunk_size: usize) -> impl Iterator<Item = Vec<I::Item>> + Send
655where
656    I: IntoIterator,
657    I::IntoIter: Send,
658{
659    let mut iter = iter.into_iter();
660    std::iter::from_fn(move || {
661        let elem = iter.next()?;
662        let mut v = Vec::with_capacity(chunk_size);
663        v.push(elem);
664        v.extend(iter.by_ref().take(chunk_size - 1));
665        Some(v)
666    })
667}