1use 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
41pub const MAX_GAS_BUDGET: u64 = 50000000000;
43pub 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
82pub 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 pub const fn request_timeout(mut self, request_timeout: Duration) -> Self {
126 self.request_timeout = request_timeout;
127 self
128 }
129
130 #[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 #[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 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 pub async fn build_localnet(self) -> SuiClientResult<SuiClient> {
160 self.build(SUI_LOCAL_NETWORK_URL).await
161 }
162
163 pub async fn build_devnet(self) -> SuiClientResult<SuiClient> {
165 self.build(SUI_DEVNET_URL).await
166 }
167
168 pub async fn build_testnet(self) -> SuiClientResult<SuiClient> {
170 self.build(SUI_TESTNET_URL).await
171 }
172
173 #[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 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 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#[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
299struct 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 pub fn available_rpc_methods(&self) -> &Vec<String> {
313 &self.info.rpc_methods
314 }
315
316 pub fn available_subscriptions(&self) -> &Vec<String> {
318 &self.info.subscriptions
319 }
320
321 pub fn api_version(&self) -> &str {
326 &self.info.version
327 }
328
329 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 pub fn http(&self) -> &HttpClient {
344 &self.http
345 }
346
347 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 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 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 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 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 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 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#[derive(Clone, Debug)]
574#[non_exhaustive]
575pub struct GasBudgetOptions {
576 pub price: u64,
578
579 pub dry_run_budget: u64,
581
582 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
634const GAS_SAFE_OVERHEAD_MULTIPLIER: u64 = 1000;
638
639fn 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}