Skip to main content

sui_graphql/client/
chain.rs

1//! Chain information convenience methods.
2
3use sui_graphql_macros::Response;
4
5use super::Client;
6use crate::error::Error;
7use crate::scalars::BigInt;
8use crate::scalars::DateTime;
9use crate::scalars::Digest;
10
11/// Information about an epoch.
12///
13/// This struct is consistent with the TypeScript SDK's `EpochInfo` and
14/// the gRPC `Epoch` type from sui-rpc.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct Epoch {
18    /// The epoch's id as a sequence number starting at 0.
19    pub epoch: u64,
20    /// The first checkpoint in this epoch.
21    pub first_checkpoint: Option<u64>,
22    /// The last checkpoint in this epoch (None if epoch is ongoing).
23    pub last_checkpoint: Option<u64>,
24    /// Timestamp when this epoch started.
25    pub epoch_start_timestamp: Option<DateTime>,
26    /// Timestamp when this epoch ended (None if ongoing).
27    pub epoch_end_timestamp: Option<DateTime>,
28    /// The total number of transactions in this epoch.
29    pub epoch_total_transactions: Option<u64>,
30    /// Reference gas price in MIST for this epoch.
31    pub reference_gas_price: Option<u64>,
32    /// The protocol version for this epoch.
33    pub protocol_version: Option<u64>,
34}
35
36impl Client {
37    /// Get the chain identifier (e.g., "35834a8a" for mainnet).
38    ///
39    /// # Example
40    ///
41    /// ```no_run
42    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
43    /// use sui_graphql::Client;
44    ///
45    /// let client = Client::new("https://graphql.mainnet.sui.io/graphql")?;
46    /// let chain_id = client.chain_identifier().await?;
47    /// println!("Connected to chain: {}", chain_id);
48    /// # Ok(())
49    /// # }
50    /// ```
51    pub async fn chain_identifier(&self) -> Result<Digest, Error> {
52        #[derive(Response)]
53        struct Response {
54            #[field(path = "chainIdentifier?")]
55            chain_identifier: Option<Digest>,
56        }
57
58        const QUERY: &str = "query { chainIdentifier }";
59
60        let response = self.query::<Response>(QUERY, serde_json::json!({})).await?;
61
62        response
63            .into_data()
64            .and_then(|d| d.chain_identifier)
65            .ok_or(Error::MissingData("chain identifier"))
66    }
67
68    /// Get the current protocol version.
69    ///
70    /// # Example
71    ///
72    /// ```no_run
73    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
74    /// use sui_graphql::Client;
75    ///
76    /// let client = Client::new("https://graphql.mainnet.sui.io/graphql")?;
77    /// let version = client.protocol_version().await?;
78    /// println!("Protocol version: {}", version);
79    /// # Ok(())
80    /// # }
81    /// ```
82    pub async fn protocol_version(&self) -> Result<u64, Error> {
83        #[derive(Response)]
84        struct Response {
85            #[field(path = "protocolConfigs?.protocolVersion?")]
86            protocol_version: Option<u64>,
87        }
88
89        const QUERY: &str = "query { protocolConfigs { protocolVersion } }";
90
91        let response = self.query::<Response>(QUERY, serde_json::json!({})).await?;
92
93        response
94            .into_data()
95            .and_then(|d| d.protocol_version)
96            .ok_or(Error::MissingData("protocol version"))
97    }
98
99    /// Get epoch information by ID, or the current epoch if no ID is provided.
100    ///
101    /// Returns `None` if the epoch does not exist or was pruned.
102    ///
103    /// # Example
104    ///
105    /// ```no_run
106    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
107    /// use sui_graphql::Client;
108    ///
109    /// let client = Client::new("https://graphql.mainnet.sui.io/graphql")?;
110    ///
111    /// // Get current epoch
112    /// let epoch = client.epoch(None).await?;
113    ///
114    /// // Get specific epoch
115    /// let epoch = client.epoch(Some(100)).await?;
116    /// # Ok(())
117    /// # }
118    /// ```
119    pub async fn epoch(&self, epoch_id: Option<u64>) -> Result<Option<Epoch>, Error> {
120        #[derive(Response)]
121        struct Response {
122            #[field(path = "epoch?.epochId?")]
123            epoch_id: Option<u64>,
124            #[field(path = "epoch?.protocolConfigs?.protocolVersion?")]
125            protocol_version: Option<u64>,
126            #[field(path = "epoch?.referenceGasPrice?")]
127            reference_gas_price: Option<BigInt>,
128            #[field(path = "epoch?.startTimestamp?")]
129            start_timestamp: Option<DateTime>,
130            #[field(path = "epoch?.endTimestamp?")]
131            end_timestamp: Option<DateTime>,
132            #[field(path = "epoch?.totalTransactions?")]
133            total_transactions: Option<u64>,
134            // Use alias syntax matching GraphQL: "alias:field" where alias comes first
135            // e.g., "firstCheckpoint:checkpoints" validates against "checkpoints" schema
136            // but extracts from "firstCheckpoint" in JSON (the aliased name in the query)
137            #[field(path = "epoch?.firstCheckpoint:checkpoints?.nodes?[].sequenceNumber")]
138            first_checkpoint_seq: Option<Vec<u64>>,
139            // TODO use nodes[0] once we have support for it
140            #[field(path = "epoch?.lastCheckpoint:checkpoints?.nodes?[].sequenceNumber")]
141            last_checkpoint_seq: Option<Vec<u64>>,
142        }
143
144        const QUERY: &str = r#"
145            query($epochId: UInt53) {
146                epoch(epochId: $epochId) {
147                    epochId
148                    protocolConfigs {
149                        protocolVersion
150                    }
151                    referenceGasPrice
152                    startTimestamp
153                    endTimestamp
154                    totalTransactions
155                    firstCheckpoint: checkpoints(first: 1) {
156                        nodes {
157                            sequenceNumber
158                        }
159                    }
160                    lastCheckpoint: checkpoints(last: 1) {
161                        nodes {
162                            sequenceNumber
163                        }
164                    }
165                }
166            }
167        "#;
168
169        let variables = serde_json::json!({
170            "epochId": epoch_id,
171        });
172
173        let response = self.query::<Response>(QUERY, variables).await?;
174
175        let Some(data) = response.into_data() else {
176            return Ok(None);
177        };
178
179        let Some(epoch) = data.epoch_id else {
180            return Ok(None);
181        };
182
183        let reference_gas_price = data.reference_gas_price.map(|b| b.0);
184
185        // Extract first/last checkpoint from the nested queries
186        let first_checkpoint = data.first_checkpoint_seq.and_then(|v| v.first().copied());
187        let last_checkpoint = data.last_checkpoint_seq.and_then(|v| v.first().copied());
188
189        Ok(Some(Epoch {
190            epoch,
191            first_checkpoint,
192            last_checkpoint,
193            epoch_start_timestamp: data.start_timestamp,
194            epoch_end_timestamp: data.end_timestamp,
195            epoch_total_transactions: data.total_transactions,
196            reference_gas_price,
197            protocol_version: data.protocol_version,
198        }))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use wiremock::Mock;
206    use wiremock::MockServer;
207    use wiremock::ResponseTemplate;
208    use wiremock::matchers::method;
209    use wiremock::matchers::path;
210
211    #[tokio::test]
212    async fn test_chain_identifier() {
213        let mock_server = MockServer::start().await;
214
215        // Use a valid Base58 encoded 32-byte digest
216        let expected_digest = Digest::ZERO;
217
218        Mock::given(method("POST"))
219            .and(path("/"))
220            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
221                "data": {
222                    "chainIdentifier": expected_digest.to_string()
223                }
224            })))
225            .mount(&mock_server)
226            .await;
227
228        let client = Client::new(&mock_server.uri()).unwrap();
229        let result = client.chain_identifier().await;
230
231        assert!(result.is_ok());
232        assert_eq!(result.unwrap(), expected_digest);
233    }
234
235    #[tokio::test]
236    async fn test_protocol_version() {
237        let mock_server = MockServer::start().await;
238
239        Mock::given(method("POST"))
240            .and(path("/"))
241            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
242                "data": {
243                    "protocolConfigs": {
244                        "protocolVersion": 70
245                    }
246                }
247            })))
248            .mount(&mock_server)
249            .await;
250
251        let client = Client::new(&mock_server.uri()).unwrap();
252        let result = client.protocol_version().await;
253
254        assert!(result.is_ok());
255        assert_eq!(result.unwrap(), 70);
256    }
257
258    #[tokio::test]
259    async fn test_protocol_version_missing() {
260        let mock_server = MockServer::start().await;
261
262        Mock::given(method("POST"))
263            .and(path("/"))
264            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
265                "data": {
266                    "protocolConfigs": null
267                }
268            })))
269            .mount(&mock_server)
270            .await;
271
272        let client = Client::new(&mock_server.uri()).unwrap();
273        let result = client.protocol_version().await;
274
275        assert!(result.is_err());
276        assert!(matches!(result, Err(Error::MissingData(_))));
277    }
278
279    #[tokio::test]
280    async fn test_epoch() {
281        let mock_server = MockServer::start().await;
282
283        Mock::given(method("POST"))
284            .and(path("/"))
285            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
286                "data": {
287                    "epoch": {
288                        "epochId": 500,
289                        "protocolConfigs": {
290                            "protocolVersion": 70
291                        },
292                        "referenceGasPrice": "1000",
293                        "startTimestamp": "2024-01-15T00:00:00Z",
294                        "endTimestamp": null,
295                                                "totalTransactions": 987654,
296                        "firstCheckpoint": {
297                            "nodes": [{ "sequenceNumber": 10000 }]
298                        },
299                        "lastCheckpoint": {
300                            "nodes": [{ "sequenceNumber": 22344 }]
301                        }
302                    }
303                }
304            })))
305            .mount(&mock_server)
306            .await;
307
308        let client = Client::new(&mock_server.uri()).unwrap();
309        let result = client.epoch(None).await;
310
311        assert!(result.is_ok());
312        let epoch = result.unwrap();
313        assert!(epoch.is_some());
314
315        let epoch = epoch.unwrap();
316        assert_eq!(epoch.epoch, 500);
317        assert_eq!(epoch.protocol_version, Some(70));
318        assert_eq!(epoch.reference_gas_price, Some(1000));
319        assert_eq!(epoch.epoch_total_transactions, Some(987654));
320        assert_eq!(epoch.first_checkpoint, Some(10000));
321        assert_eq!(epoch.last_checkpoint, Some(22344));
322    }
323
324    #[tokio::test]
325    async fn test_epoch_by_id() {
326        let mock_server = MockServer::start().await;
327
328        Mock::given(method("POST"))
329            .and(path("/"))
330            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                "data": {
332                    "epoch": {
333                        "epochId": 100,
334                        "protocolConfigs": {
335                            "protocolVersion": 50
336                        },
337                        "referenceGasPrice": "750",
338                        "startTimestamp": "2023-06-01T00:00:00Z",
339                        "endTimestamp": "2023-06-02T00:00:00Z",
340                                                "totalTransactions": 100000,
341                        "firstCheckpoint": {
342                            "nodes": [{ "sequenceNumber": 1000 }]
343                        },
344                        "lastCheckpoint": {
345                            "nodes": [{ "sequenceNumber": 5999 }]
346                        }
347                    }
348                }
349            })))
350            .mount(&mock_server)
351            .await;
352
353        let client = Client::new(&mock_server.uri()).unwrap();
354        let result = client.epoch(Some(100)).await;
355
356        assert!(result.is_ok());
357        let epoch = result.unwrap();
358        assert!(epoch.is_some());
359
360        let epoch = epoch.unwrap();
361        assert_eq!(epoch.epoch, 100);
362        assert_eq!(epoch.protocol_version, Some(50));
363        assert_eq!(epoch.reference_gas_price, Some(750));
364        assert_eq!(epoch.epoch_total_transactions, Some(100000));
365        assert_eq!(epoch.first_checkpoint, Some(1000));
366        assert_eq!(epoch.last_checkpoint, Some(5999));
367    }
368
369    // Note: test_epoch_not_found is omitted because the current macro doesn't support
370    // nullable parent paths with array fields. When epoch is null, the checkpoint
371    // extraction fails. This limitation will be addressed in a future update.
372
373    #[tokio::test]
374    async fn test_epoch_with_timestamps() {
375        let mock_server = MockServer::start().await;
376
377        Mock::given(method("POST"))
378            .and(path("/"))
379            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
380                "data": {
381                    "epoch": {
382                        "epochId": 100,
383                        "protocolConfigs": {
384                            "protocolVersion": 50
385                        },
386                        "referenceGasPrice": "1000",
387                        "startTimestamp": "2024-01-15T00:00:00Z",
388                        "endTimestamp": "2024-01-16T00:00:00.123Z",
389                                                "totalTransactions": 100000,
390                        "firstCheckpoint": {
391                            "nodes": [{ "sequenceNumber": 1000 }]
392                        },
393                        "lastCheckpoint": {
394                            "nodes": [{ "sequenceNumber": 5999 }]
395                        }
396                    }
397                }
398            })))
399            .mount(&mock_server)
400            .await;
401
402        let client = Client::new(&mock_server.uri()).unwrap();
403        let result = client.epoch(Some(100)).await;
404
405        assert!(result.is_ok());
406        let epoch = result.unwrap().unwrap();
407
408        // Verify timestamps are parsed as DateTime
409        assert_eq!(
410            epoch.epoch_start_timestamp,
411            Some("2024-01-15T00:00:00Z".parse::<DateTime>().unwrap())
412        );
413        assert_eq!(
414            epoch.epoch_end_timestamp,
415            Some("2024-01-16T00:00:00.123Z".parse::<DateTime>().unwrap())
416        );
417    }
418}