Skip to main content

chainrpc_core/
sui.rs

1//! Sui RPC support — method safety, CU costs, and transport.
2//!
3//! Sui uses JSON-RPC directly. Sui organizes data around checkpoints
4//! (finalized) and objects rather than traditional blocks.
5
6use 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
16// ---------------------------------------------------------------------------
17// Method classification
18// ---------------------------------------------------------------------------
19
20pub 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// ---------------------------------------------------------------------------
60// CU cost table
61// ---------------------------------------------------------------------------
62
63#[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
109// ---------------------------------------------------------------------------
110// Known endpoints
111// ---------------------------------------------------------------------------
112
113pub 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
132// ---------------------------------------------------------------------------
133// SuiTransport
134// ---------------------------------------------------------------------------
135
136pub 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
164// ---------------------------------------------------------------------------
165// SuiChainClient
166// ---------------------------------------------------------------------------
167
168/// Sui implementation of [`ChainClient`].
169///
170/// Maps `height` to Sui checkpoint sequence numbers.
171pub 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        // Sui returns checkpoint number as a string
197        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}