Skip to main content

sui_graphql/client/
chain.rs

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