use std::fmt::{Debug, Formatter};
use std::sync::Arc;
use std::time::Duration;
use futures_core::Stream;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::rpc_params;
use jsonrpsee::ws_client::{PingConfig, WsClient, WsClientBuilder};
use jsonrpsee_http_client::{HeaderMap, HeaderValue, HttpClient, HttpClientBuilder};
use serde_json::Value;
use sui_sdk_types::bcs::ToBcs;
use sui_sdk_types::{
Address, Digest, GasCostSummary, GasPayment, Input, Object, ObjectReference, Transaction,
TransactionExpiration, TransactionKind, UserSignature, Version,
};
use super::{CLIENT_SDK_TYPE_HEADER, CLIENT_SDK_VERSION_HEADER, CLIENT_TARGET_API_VERSION_HEADER};
use crate::api::{CoinReadApiClient, ReadApiClient as _, WriteApiClient as _};
use crate::error::JsonRpcClientError;
use crate::msgs::{
Coin, DryRunTransactionBlockResponse, SuiExecutionStatus, SuiObjectData, SuiObjectDataError,
SuiObjectDataOptions, SuiObjectResponse, SuiObjectResponseError, SuiObjectResponseQuery,
SuiTransactionBlockEffectsAPI as _, SuiTransactionBlockResponse,
SuiTransactionBlockResponseOptions,
};
use crate::serde::encode_base64_default;
pub const MAX_GAS_BUDGET: u64 = 50000000000;
pub const MULTI_GET_OBJECT_MAX_SIZE: usize = 50;
pub const SUI_COIN_TYPE: &str = "0x2::sui::SUI";
pub const SUI_LOCAL_NETWORK_URL: &str = "http://127.0.0.1:9000";
pub const SUI_LOCAL_NETWORK_WS: &str = "ws://127.0.0.1:9000";
pub const SUI_LOCAL_NETWORK_GAS_URL: &str = "http://127.0.0.1:5003/gas";
pub const SUI_DEVNET_URL: &str = "https://fullnode.devnet.sui.io:443";
pub const SUI_TESTNET_URL: &str = "https://fullnode.testnet.sui.io:443";
pub type SuiClientResult<T = ()> = Result<T, SuiClientError>;
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(thiserror::Error, Debug)]
pub enum SuiClientError {
#[error("jsonrpsee client error: {0}")]
JsonRpcClient(#[from] JsonRpcClientError),
#[error("Data error: {0}")]
DataError(String),
#[error(
"Client/Server api version mismatch, client api version : {client_version}, server api version : {server_version}"
)]
ServerVersionMismatch {
client_version: String,
server_version: String,
},
#[error(
"Insufficient funds for address [{address}]; found balance {found}, requested: {requested}"
)]
InsufficientFunds {
address: Address,
found: u64,
requested: u64,
},
#[error("In object response: {0}")]
SuiObjectResponse(#[from] SuiObjectResponseError),
#[error("In object data: {0}")]
SuiObjectData(#[from] SuiObjectDataError),
}
pub struct SuiClientBuilder {
request_timeout: Duration,
ws_url: Option<String>,
ws_ping_interval: Option<Duration>,
basic_auth: Option<(String, String)>,
}
impl Default for SuiClientBuilder {
fn default() -> Self {
Self {
request_timeout: Duration::from_secs(60),
ws_url: None,
ws_ping_interval: None,
basic_auth: None,
}
}
}
impl SuiClientBuilder {
pub const fn request_timeout(mut self, request_timeout: Duration) -> Self {
self.request_timeout = request_timeout;
self
}
#[deprecated = "\
JSON-RPC subscriptions have been deprecated since at least mainnet-v1.28.3. \
See <https://github.com/MystenLabs/sui/releases/tag/mainnet-v1.28.3>\
"]
pub fn ws_url(mut self, url: impl AsRef<str>) -> Self {
self.ws_url = Some(url.as_ref().to_string());
self
}
#[deprecated = "\
JSON-RPC subscriptions have been deprecated since at least mainnet-v1.28.3. \
See <https://github.com/MystenLabs/sui/releases/tag/mainnet-v1.28.3>\
"]
pub const fn ws_ping_interval(mut self, duration: Duration) -> Self {
self.ws_ping_interval = Some(duration);
self
}
pub fn basic_auth(mut self, username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
self.basic_auth = Some((username.as_ref().to_string(), password.as_ref().to_string()));
self
}
pub async fn build_localnet(self) -> SuiClientResult<SuiClient> {
self.build(SUI_LOCAL_NETWORK_URL).await
}
pub async fn build_devnet(self) -> SuiClientResult<SuiClient> {
self.build(SUI_DEVNET_URL).await
}
pub async fn build_testnet(self) -> SuiClientResult<SuiClient> {
self.build(SUI_TESTNET_URL).await
}
#[allow(clippy::future_not_send)]
pub async fn build(self, http: impl AsRef<str>) -> SuiClientResult<SuiClient> {
let client_version = env!("CARGO_PKG_VERSION");
let mut headers = HeaderMap::new();
headers.insert(
CLIENT_TARGET_API_VERSION_HEADER,
HeaderValue::from_static(client_version),
);
headers.insert(
CLIENT_SDK_VERSION_HEADER,
HeaderValue::from_static(client_version),
);
headers.insert(CLIENT_SDK_TYPE_HEADER, HeaderValue::from_static("rust"));
if let Some((username, password)) = self.basic_auth {
let auth = encode_base64_default(format!("{}:{}", username, password));
headers.insert(
http::header::AUTHORIZATION,
HeaderValue::from_str(&format!("Basic {}", auth))
.expect("Failed creating HeaderValue for basic auth"),
);
}
let ws = if let Some(url) = self.ws_url {
let mut builder = WsClientBuilder::default()
.max_request_size(2 << 30)
.set_headers(headers.clone())
.request_timeout(self.request_timeout);
if let Some(duration) = self.ws_ping_interval {
builder = builder.enable_ws_ping(PingConfig::default().ping_interval(duration))
}
Some(builder.build(url).await?)
} else {
None
};
let http = HttpClientBuilder::default()
.max_request_size(2 << 30)
.set_headers(headers.clone())
.request_timeout(self.request_timeout)
.build(http)?;
let info = Self::get_server_info(&http, &ws).await?;
Ok(SuiClient {
http: Arc::new(http),
ws: Arc::new(ws),
info: Arc::new(info),
})
}
async fn get_server_info(
http: &HttpClient,
ws: &Option<WsClient>,
) -> Result<ServerInfo, SuiClientError> {
let rpc_spec: Value = http.request("rpc.discover", rpc_params![]).await?;
let version = rpc_spec
.pointer("/info/version")
.and_then(|v| v.as_str())
.ok_or_else(|| {
SuiClientError::DataError(
"Fail parsing server version from rpc.discover endpoint.".into(),
)
})?;
let rpc_methods = Self::parse_methods(&rpc_spec)?;
let subscriptions = if let Some(ws) = ws {
let rpc_spec: Value = ws.request("rpc.discover", rpc_params![]).await?;
Self::parse_methods(&rpc_spec)?
} else {
Vec::new()
};
Ok(ServerInfo {
rpc_methods,
subscriptions,
version: version.to_string(),
})
}
fn parse_methods(server_spec: &Value) -> Result<Vec<String>, SuiClientError> {
let methods = server_spec
.pointer("/methods")
.and_then(|methods| methods.as_array())
.ok_or_else(|| {
SuiClientError::DataError(
"Fail parsing server information from rpc.discover endpoint.".into(),
)
})?;
Ok(methods
.iter()
.flat_map(|method| method["name"].as_str())
.map(|s| s.into())
.collect())
}
}
#[derive(Clone)]
pub struct SuiClient {
http: Arc<HttpClient>,
ws: Arc<Option<WsClient>>,
info: Arc<ServerInfo>,
}
impl Debug for SuiClient {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"RPC client. Http: {:?}, Websocket: {:?}",
self.http, self.ws
)
}
}
struct ServerInfo {
rpc_methods: Vec<String>,
subscriptions: Vec<String>,
version: String,
}
impl SuiClient {
pub fn builder() -> SuiClientBuilder {
Default::default()
}
pub fn available_rpc_methods(&self) -> &Vec<String> {
&self.info.rpc_methods
}
pub fn available_subscriptions(&self) -> &Vec<String> {
&self.info.subscriptions
}
pub fn api_version(&self) -> &str {
&self.info.version
}
pub fn check_api_version(&self) -> SuiClientResult<()> {
let server_version = self.api_version();
let client_version = env!("CARGO_PKG_VERSION");
if server_version != client_version {
return Err(SuiClientError::ServerVersionMismatch {
client_version: client_version.to_string(),
server_version: server_version.to_string(),
});
};
Ok(())
}
pub fn http(&self) -> &HttpClient {
&self.http
}
pub fn ws(&self) -> Option<&WsClient> {
(*self.ws).as_ref()
}
pub async fn get_shared_oarg(&self, id: Address, mutable: bool) -> SuiClientResult<Input> {
let data = self
.http()
.get_object(id, Some(SuiObjectDataOptions::new().with_owner()))
.await?
.into_object()?;
Ok(data.shared_object_arg(mutable)?)
}
pub async fn get_imm_or_owned_oarg(&self, id: Address) -> SuiClientResult<Input> {
let data = self
.http()
.get_object(id, Some(SuiObjectDataOptions::new().with_owner()))
.await?
.into_object()?;
Ok(data.imm_or_owned_object_arg()?)
}
pub async fn object_args<Iter>(
&self,
ids: Iter,
) -> Result<impl Iterator<Item = Result<Input, BoxError>> + use<Iter>, BoxError>
where
Iter: IntoIterator<Item = Address> + Send,
Iter::IntoIter: Send,
{
let options = SuiObjectDataOptions::new().with_owner();
Ok(self
.multi_get_objects(ids, options)
.await?
.into_iter()
.map(|r| Ok(r.into_object()?.object_arg(false)?)))
}
pub async fn full_object(&self, id: Address) -> Result<Object, BoxError> {
let options = SuiObjectDataOptions {
show_bcs: true,
show_owner: true,
show_storage_rebate: true,
show_previous_transaction: true,
..Default::default()
};
Ok(self
.http()
.get_object(id, Some(options))
.await?
.into_object()?
.into_full_object()?)
}
pub async fn full_objects<Iter>(
&self,
ids: Iter,
) -> Result<impl Iterator<Item = Result<Object, BoxError>>, BoxError>
where
Iter: IntoIterator<Item = Address> + Send,
Iter::IntoIter: Send,
{
let options = SuiObjectDataOptions {
show_bcs: true,
show_owner: true,
show_storage_rebate: true,
show_previous_transaction: true,
..Default::default()
};
Ok(self
.multi_get_objects(ids, options)
.await?
.into_iter()
.map(|r| Ok(r.into_object()?.into_full_object()?)))
}
pub async fn multi_get_objects<I>(
&self,
object_ids: I,
options: SuiObjectDataOptions,
) -> SuiClientResult<Vec<SuiObjectResponse>>
where
I: IntoIterator<Item = Address> + Send,
I::IntoIter: Send,
{
let mut result = Vec::new();
for chunk in iter_chunks(object_ids, MULTI_GET_OBJECT_MAX_SIZE) {
if chunk.len() == 1 {
let elem = self
.http()
.get_object(chunk[0], Some(options.clone()))
.await?;
result.push(elem);
} else {
let it = self
.http()
.multi_get_objects(chunk, Some(options.clone()))
.await?;
result.extend(it);
}
}
Ok(result)
}
pub async fn submit_transaction(
&self,
tx_data: &Transaction,
signatures: &[UserSignature],
options: Option<SuiTransactionBlockResponseOptions>,
) -> Result<SuiTransactionBlockResponse, JsonRpcClientError> {
let tx_bytes = tx_data
.to_bcs_base64()
.expect("Transaction is BCS-compatible");
self.http()
.execute_transaction_block(
tx_bytes,
signatures.iter().map(UserSignature::to_base64).collect(),
options,
None,
)
.await
}
pub async fn dry_run_transaction(
&self,
tx_kind: &TransactionKind,
sender: Address,
gas_price: u64,
) -> Result<DryRunTransactionBlockResponse, JsonRpcClientError> {
let tx_data = Transaction {
kind: tx_kind.clone(),
sender,
gas_payment: GasPayment {
objects: vec![],
owner: sender,
price: gas_price,
budget: MAX_GAS_BUDGET,
},
expiration: TransactionExpiration::None,
};
let tx_bytes = tx_data
.to_bcs_base64()
.expect("Transaction serialization shouldn't fail");
self.http().dry_run_transaction_block(tx_bytes).await
}
pub async fn gas_budget(
&self,
tx_kind: &TransactionKind,
sender: Address,
price: u64,
) -> Result<u64, DryRunError> {
let options = GasBudgetOptions::new(price);
self.gas_budget_with_options(tx_kind, sender, options).await
}
pub async fn gas_budget_with_options(
&self,
tx_kind: &TransactionKind,
sender: Address,
options: GasBudgetOptions,
) -> Result<u64, DryRunError> {
let tx_data = Transaction {
kind: tx_kind.clone(),
sender,
gas_payment: GasPayment {
objects: vec![],
owner: sender,
price: options.price,
budget: options.dry_run_budget,
},
expiration: TransactionExpiration::None,
};
let tx_bytes = tx_data
.to_bcs_base64()
.expect("Transaction serialization shouldn't fail");
let response = self.http().dry_run_transaction_block(tx_bytes).await?;
if let SuiExecutionStatus::Failure { error } = response.effects.status() {
return Err(DryRunError::Execution(error.clone(), response));
}
let budget = {
let safe_overhead = options.safe_overhead_multiplier * options.price;
estimate_gas_budget_from_gas_cost(response.effects.gas_cost_summary(), safe_overhead)
};
Ok(budget)
}
pub async fn get_gas_data(
&self,
tx_kind: &TransactionKind,
sponsor: Address,
budget: u64,
price: u64,
) -> Result<GasPayment, GetGasDataError> {
let exclude = if let TransactionKind::ProgrammableTransaction(ptb) = tx_kind {
use sui_sdk_types::Input::*;
ptb.inputs
.iter()
.filter_map(|i| match i {
Pure { .. } => None,
Shared(shared) => Some(shared.object_id()),
ImmutableOrOwned(oref) | Receiving(oref) => Some(*oref.object_id()),
_ => panic!("unknown Input type"),
})
.collect()
} else {
vec![]
};
if budget < price {
return Err(GetGasDataError::BudgetTooSmall { budget, price });
}
let objects = self
.get_gas_payment(sponsor, budget, &exclude)
.await
.map_err(GetGasDataError::from_not_enough_gas)?;
Ok(GasPayment {
objects: objects
.into_iter()
.map(|(object_id, version, digest)| {
ObjectReference::new(object_id, version, digest)
})
.collect(),
owner: sponsor,
price,
budget,
})
}
pub async fn get_gas_payment(
&self,
sponsor: Address,
budget: u64,
exclude: &[Address],
) -> Result<Vec<(Address, Version, Digest)>, NotEnoughGasError> {
Ok(self
.coins_for_amount(sponsor, Some("0x2::sui::SUI".to_owned()), budget, exclude)
.await
.map_err(|inner| NotEnoughGasError {
sponsor,
budget,
inner,
})?
.into_iter()
.map(|c| c.object_ref())
.collect())
}
#[deprecated(since = "0.14.5", note = "use SuiClient::coins_for_amount")]
pub async fn select_coins(
&self,
address: Address,
coin_type: Option<String>,
amount: u64,
exclude: Vec<Address>,
) -> SuiClientResult<Vec<Coin>> {
self.coins_for_amount(address, coin_type, amount, &exclude)
.await
}
pub async fn coins_for_amount(
&self,
address: Address,
coin_type: Option<String>,
amount: u64,
exclude: &[Address],
) -> SuiClientResult<Vec<Coin>> {
use futures_util::{TryStreamExt as _, future};
let mut coins = vec![];
let mut total = 0;
let mut stream = std::pin::pin!(
self.coins_for_address(address, coin_type, None)
.try_filter(|c| future::ready(!exclude.contains(&c.coin_object_id)))
);
while let Some(coin) = stream.try_next().await? {
total += coin.balance;
coins.push(coin);
if total >= amount {
return Ok(coins);
}
}
Err(SuiClientError::InsufficientFunds {
address,
found: total,
requested: amount,
})
}
pub fn coins_for_address(
&self,
address: Address,
coin_type: Option<String>,
page_size: Option<u32>,
) -> impl Stream<Item = SuiClientResult<Coin>> + Send + '_ {
async_stream::try_stream! {
let mut has_next_page = true;
let mut cursor = None;
while has_next_page {
let page = self
.http()
.get_coins(address, coin_type.clone(), cursor, page_size.map(|u| u as usize))
.await?;
for coin in page.data
{
yield coin;
}
has_next_page = page.has_next_page;
cursor = page.next_cursor;
}
}
}
pub fn owned_objects(
&self,
owner: Address,
query: Option<SuiObjectResponseQuery>,
page_size: Option<u32>,
) -> impl Stream<Item = SuiClientResult<SuiObjectData>> + Send + '_ {
use crate::api::IndexerApiClient as _;
async_stream::try_stream! {
let mut has_next_page = true;
let mut cursor = None;
while has_next_page {
let page = self
.http()
.get_owned_objects(owner, query.clone(), cursor, page_size.map(|u| u as usize)).await?;
for data in page.data {
yield data.into_object()?;
}
has_next_page = page.has_next_page;
cursor = page.next_cursor;
}
}
}
pub async fn latest_object_ref(
&self,
object_id: Address,
) -> SuiClientResult<(Address, Version, Digest)> {
Ok(self
.http()
.get_object(object_id, Some(SuiObjectDataOptions::default()))
.await?
.into_object()?
.object_ref())
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct GasBudgetOptions {
pub price: u64,
pub dry_run_budget: u64,
pub safe_overhead_multiplier: u64,
}
impl GasBudgetOptions {
#[expect(
clippy::missing_const_for_fn,
reason = "We might evolve the defaults to use non-const expressions"
)]
pub fn new(price: u64) -> Self {
Self {
price,
dry_run_budget: MAX_GAS_BUDGET,
safe_overhead_multiplier: GAS_SAFE_OVERHEAD_MULTIPLIER,
}
}
}
#[derive(thiserror::Error, Debug)]
#[expect(
clippy::large_enum_variant,
reason = "Boxing now would break backwards compatibility"
)]
pub enum DryRunError {
#[error("Error in dry run: {0}")]
Execution(String, DryRunTransactionBlockResponse),
#[error("In JSON-RPC client: {0}")]
Client(#[from] JsonRpcClientError),
}
#[derive(thiserror::Error, Debug)]
pub enum GetGasDataError {
#[error("In JSON-RPC client: {0}")]
Client(#[from] JsonRpcClientError),
#[error(
"Gas budget {budget} is less than the gas price {price}. \
The gas budget must be at least the gas price of {price}."
)]
BudgetTooSmall { budget: u64, price: u64 },
#[error(
"Cannot find gas coins for address [{sponsor}] \
with amount sufficient for the required gas amount [{budget}]. \
Caused by {inner}"
)]
NotEnoughGas {
sponsor: Address,
budget: u64,
inner: SuiClientError,
},
}
impl GetGasDataError {
fn from_not_enough_gas(e: NotEnoughGasError) -> Self {
let NotEnoughGasError {
sponsor,
budget,
inner,
} = e;
Self::NotEnoughGas {
sponsor,
budget,
inner,
}
}
}
#[derive(thiserror::Error, Debug)]
#[error(
"Cannot find gas coins for address [{sponsor}] \
with amount sufficient for the required gas amount [{budget}]. \
Caused by {inner}"
)]
pub struct NotEnoughGasError {
sponsor: Address,
budget: u64,
inner: SuiClientError,
}
const GAS_SAFE_OVERHEAD_MULTIPLIER: u64 = 1000;
fn estimate_gas_budget_from_gas_cost(gas_cost_summary: &GasCostSummary, safe_overhead: u64) -> u64 {
let computation_cost_with_overhead = gas_cost_summary.computation_cost + safe_overhead;
let gas_usage_with_overhead = gas_cost_summary.net_gas_usage() + safe_overhead as i64;
computation_cost_with_overhead.max(gas_usage_with_overhead.max(0) as u64)
}
fn iter_chunks<I>(iter: I, chunk_size: usize) -> impl Iterator<Item = Vec<I::Item>> + Send
where
I: IntoIterator,
I::IntoIter: Send,
{
let mut iter = iter.into_iter();
std::iter::from_fn(move || {
let elem = iter.next()?;
let mut v = Vec::with_capacity(chunk_size);
v.push(elem);
v.extend(iter.by_ref().take(chunk_size - 1));
Some(v)
})
}