1use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, OnceLock};
8
9use async_trait::async_trait;
10use crate::chain_client::{ChainBlock, ChainClient};
11use crate::error::TransportError;
12use crate::method_safety::MethodSafety;
13use crate::request::{JsonRpcRequest, JsonRpcResponse};
14use crate::transport::{HealthStatus, RpcTransport};
15
16pub fn classify_sui_method(method: &str) -> MethodSafety {
21 if sui_unsafe_methods().contains(method) {
22 MethodSafety::Unsafe
23 } else if sui_idempotent_methods().contains(method) {
24 MethodSafety::Idempotent
25 } else {
26 MethodSafety::Safe
27 }
28}
29
30pub fn is_sui_safe_to_retry(method: &str) -> bool {
31 classify_sui_method(method) == MethodSafety::Safe
32}
33
34pub fn is_sui_safe_to_dedup(method: &str) -> bool {
35 classify_sui_method(method) == MethodSafety::Safe
36}
37
38pub fn is_sui_cacheable(method: &str) -> bool {
39 classify_sui_method(method) == MethodSafety::Safe
40}
41
42fn sui_unsafe_methods() -> &'static HashSet<&'static str> {
43 static UNSAFE: OnceLock<HashSet<&'static str>> = OnceLock::new();
44 UNSAFE.get_or_init(HashSet::new)
45}
46
47fn sui_idempotent_methods() -> &'static HashSet<&'static str> {
48 static IDEMPOTENT: OnceLock<HashSet<&'static str>> = OnceLock::new();
49 IDEMPOTENT.get_or_init(|| {
50 [
51 "sui_executeTransactionBlock",
52 "sui_dryRunTransactionBlock",
53 ]
54 .into_iter()
55 .collect()
56 })
57}
58
59#[derive(Debug, Clone)]
64pub struct SuiCuCostTable {
65 costs: HashMap<String, u32>,
66 default_cost: u32,
67}
68
69impl SuiCuCostTable {
70 pub fn defaults() -> Self {
71 let mut table = Self::new(15);
72 let entries: &[(&str, u32)] = &[
73 ("sui_getLatestCheckpointSequenceNumber", 5),
74 ("sui_getCheckpoint", 20),
75 ("sui_getObject", 10),
76 ("sui_multiGetObjects", 30),
77 ("sui_getTransactionBlock", 15),
78 ("sui_multiGetTransactionBlocks", 30),
79 ("sui_getEvents", 20),
80 ("sui_getTotalTransactionBlocks", 5),
81 ("sui_executeTransactionBlock", 10),
82 ("sui_dryRunTransactionBlock", 30),
83 ("suix_getOwnedObjects", 20),
84 ("suix_getCoins", 15),
85 ("suix_getAllBalances", 10),
86 ("suix_getReferenceGasPrice", 5),
87 ];
88 for &(method, cost) in entries {
89 table.costs.insert(method.to_string(), cost);
90 }
91 table
92 }
93
94 pub fn new(default_cost: u32) -> Self {
95 Self { costs: HashMap::new(), default_cost }
96 }
97
98 pub fn cost_for(&self, method: &str) -> u32 {
99 self.costs.get(method).copied().unwrap_or(self.default_cost)
100 }
101}
102
103impl Default for SuiCuCostTable {
104 fn default() -> Self {
105 Self::defaults()
106 }
107}
108
109pub fn sui_mainnet_endpoints() -> &'static [&'static str] {
114 &[
115 "https://fullnode.mainnet.sui.io:443",
116 "https://sui-mainnet.nodereal.io",
117 ]
118}
119
120pub fn sui_testnet_endpoints() -> &'static [&'static str] {
121 &[
122 "https://fullnode.testnet.sui.io:443",
123 ]
124}
125
126pub fn sui_devnet_endpoints() -> &'static [&'static str] {
127 &[
128 "https://fullnode.devnet.sui.io:443",
129 ]
130}
131
132pub struct SuiTransport {
137 inner: Arc<dyn RpcTransport>,
138}
139
140impl SuiTransport {
141 pub fn new(inner: Arc<dyn RpcTransport>) -> Self {
142 Self { inner }
143 }
144
145 pub fn inner(&self) -> &Arc<dyn RpcTransport> {
146 &self.inner
147 }
148}
149
150#[async_trait]
151impl RpcTransport for SuiTransport {
152 async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
153 self.inner.send(req).await
154 }
155
156 async fn send_batch(&self, reqs: Vec<JsonRpcRequest>) -> Result<Vec<JsonRpcResponse>, TransportError> {
157 self.inner.send_batch(reqs).await
158 }
159
160 fn health(&self) -> HealthStatus { self.inner.health() }
161 fn url(&self) -> &str { self.inner.url() }
162}
163
164pub struct SuiChainClient {
172 transport: Arc<dyn RpcTransport>,
173 chain_id: String,
174}
175
176impl SuiChainClient {
177 pub fn new(transport: Arc<dyn RpcTransport>, chain_id: impl Into<String>) -> Self {
178 Self {
179 transport,
180 chain_id: chain_id.into(),
181 }
182 }
183}
184
185#[async_trait]
186impl ChainClient for SuiChainClient {
187 async fn get_head_height(&self) -> Result<u64, TransportError> {
188 let req = JsonRpcRequest::new(
189 1,
190 "sui_getLatestCheckpointSequenceNumber",
191 vec![],
192 );
193 let resp = self.transport.send(req).await?;
194 let result = resp.into_result().map_err(TransportError::Rpc)?;
195
196 let cp_str = result.as_str().unwrap_or("0");
198 cp_str.parse::<u64>().map_err(|e| {
199 TransportError::Other(format!("invalid sui checkpoint number: {e}"))
200 })
201 }
202
203 async fn get_block_by_height(
204 &self,
205 height: u64,
206 ) -> Result<Option<ChainBlock>, TransportError> {
207 let req = JsonRpcRequest::new(
208 1,
209 "sui_getCheckpoint",
210 vec![serde_json::Value::String(height.to_string())],
211 );
212 let resp = self.transport.send(req).await?;
213 let result = resp.into_result().map_err(TransportError::Rpc)?;
214
215 if result.is_null() {
216 return Ok(None);
217 }
218
219 let hash = result["digest"]
220 .as_str()
221 .unwrap_or_default()
222 .to_string();
223 let parent_hash = result["previousDigest"]
224 .as_str()
225 .unwrap_or_default()
226 .to_string();
227 let timestamp = result["timestampMs"]
228 .as_str()
229 .and_then(|s| s.parse::<u64>().ok())
230 .map(|ms| (ms / 1000) as i64)
231 .unwrap_or(0);
232 let tx_count = result["transactions"]
233 .as_array()
234 .map(|a| a.len() as u32)
235 .unwrap_or(0);
236
237 Ok(Some(ChainBlock {
238 height,
239 hash,
240 parent_hash,
241 timestamp,
242 tx_count,
243 }))
244 }
245
246 fn chain_id(&self) -> &str {
247 &self.chain_id
248 }
249
250 fn chain_family(&self) -> &str {
251 "sui"
252 }
253
254 async fn health_check(&self) -> Result<bool, TransportError> {
255 self.get_head_height().await.map(|_| true)
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::request::RpcId;
263 use serde_json::Value;
264 use std::sync::Mutex;
265
266 struct MockTransport {
267 url: String,
268 responses: Mutex<Vec<JsonRpcResponse>>,
269 }
270
271 impl MockTransport {
272 fn new(responses: Vec<JsonRpcResponse>) -> Self {
273 Self { url: "mock://sui".into(), responses: Mutex::new(responses) }
274 }
275 }
276
277 #[async_trait]
278 impl RpcTransport for MockTransport {
279 async fn send(&self, _req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
280 let mut r = self.responses.lock().unwrap();
281 if r.is_empty() { Err(TransportError::Other("no mock".into())) }
282 else { Ok(r.remove(0)) }
283 }
284 fn url(&self) -> &str { &self.url }
285 }
286
287 fn ok(result: Value) -> JsonRpcResponse {
288 JsonRpcResponse { jsonrpc: "2.0".into(), id: RpcId::Number(1), result: Some(result), error: None }
289 }
290
291 #[test]
292 fn classify() {
293 assert_eq!(classify_sui_method("sui_getCheckpoint"), MethodSafety::Safe);
294 assert_eq!(classify_sui_method("sui_getObject"), MethodSafety::Safe);
295 assert_eq!(classify_sui_method("sui_executeTransactionBlock"), MethodSafety::Idempotent);
296 }
297
298 #[test]
299 fn cu_costs() {
300 let t = SuiCuCostTable::defaults();
301 assert_eq!(t.cost_for("sui_getLatestCheckpointSequenceNumber"), 5);
302 assert_eq!(t.cost_for("sui_getCheckpoint"), 20);
303 }
304
305 #[test]
306 fn endpoints() {
307 assert!(!sui_mainnet_endpoints().is_empty());
308 assert!(!sui_testnet_endpoints().is_empty());
309 }
310
311 #[tokio::test]
312 async fn sui_get_head_height() {
313 let t = Arc::new(MockTransport::new(vec![ok(serde_json::Value::String("50000000".into()))]));
314 let c = SuiChainClient::new(t, "mainnet");
315 assert_eq!(c.get_head_height().await.unwrap(), 50000000);
316 }
317
318 #[tokio::test]
319 async fn sui_get_checkpoint() {
320 let t = Arc::new(MockTransport::new(vec![ok(serde_json::json!({
321 "digest": "checkpoint_digest_abc",
322 "previousDigest": "checkpoint_digest_prev",
323 "timestampMs": "1700000000000",
324 "transactions": ["tx1", "tx2", "tx3"]
325 }))]));
326 let c = SuiChainClient::new(t, "mainnet");
327 let b = c.get_block_by_height(100).await.unwrap().unwrap();
328 assert_eq!(b.hash, "checkpoint_digest_abc");
329 assert_eq!(b.parent_hash, "checkpoint_digest_prev");
330 assert_eq!(b.timestamp, 1700000000);
331 assert_eq!(b.tx_count, 3);
332 }
333
334 #[tokio::test]
335 async fn sui_metadata() {
336 let t = Arc::new(MockTransport::new(vec![]));
337 let c = SuiChainClient::new(t, "testnet");
338 assert_eq!(c.chain_family(), "sui");
339 assert_eq!(c.chain_id(), "testnet");
340 }
341}