Skip to main content

sui_graphql/client/
coins.rs

1//! Coin-related convenience methods.
2
3use futures::Stream;
4use sui_graphql_macros::Response;
5use sui_graphql_macros::graphql_query;
6use sui_sdk_types::Address;
7use sui_sdk_types::StructTag;
8
9use super::Client;
10use crate::error::Error;
11use crate::pagination::Page;
12use crate::pagination::PageInfo;
13use crate::pagination::paginate;
14use crate::scalars::BigInt;
15
16/// Balance information for a coin type.
17#[derive(Debug, Clone)]
18pub struct Balance {
19    /// The coin type (e.g., `0x2::sui::SUI`).
20    pub coin_type: StructTag,
21    /// The total balance in base units.
22    pub total_balance: u64,
23}
24
25impl Client {
26    /// Get the balance for a specific coin type owned by an address.
27    ///
28    /// # Example
29    ///
30    /// ```no_run
31    /// use sui_graphql::Client;
32    /// use sui_sdk_types::Address;
33    /// use sui_sdk_types::StructTag;
34    ///
35    /// #[tokio::main]
36    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
37    ///     let client = Client::new(Client::MAINNET)?;
38    ///     let owner: Address = "0x123...".parse()?;
39    ///
40    ///     // Get SUI balance using the helper
41    ///     let sui_balance = client.get_balance(owner, &StructTag::sui()).await?;
42    ///     if let Some(bal) = sui_balance {
43    ///         println!("SUI balance: {}", bal.total_balance);
44    ///     }
45    ///
46    ///     // Get balance for other coin types by parsing the type string
47    ///     let usdc_type: StructTag = "0xdba...::usdc::USDC".parse()?;
48    ///     let usdc_balance = client.get_balance(owner, &usdc_type).await?;
49    ///     if let Some(bal) = usdc_balance {
50    ///         println!("USDC balance: {}", bal.total_balance);
51    ///     }
52    ///
53    ///     Ok(())
54    /// }
55    /// ```
56    pub async fn get_balance(
57        &self,
58        owner: Address,
59        coin_type: &StructTag,
60    ) -> Result<Option<Balance>, Error> {
61        #[derive(Response)]
62        struct Response {
63            #[field(path = "address?.balance?.coinType?.repr?")]
64            coin_type: Option<StructTag>,
65            #[field(path = "address?.balance?.totalBalance?")]
66            total_balance: Option<BigInt>,
67        }
68
69        const QUERY: &str = graphql_query!(
70            "query($owner: SuiAddress!, $coinType: String!) {
71                address(address: $owner) {
72                    balance(coinType: $coinType) {
73                        coinType {
74                            repr
75                        }
76                        totalBalance
77                    }
78                }
79            }"
80        );
81
82        let variables = serde_json::json!({
83            "owner": owner,
84            "coinType": coin_type.to_string(),
85        });
86
87        let response = self.query::<Response>(QUERY, variables).await?;
88
89        let Some(data) = response.into_data() else {
90            return Ok(None);
91        };
92
93        match (data.coin_type, data.total_balance) {
94            (Some(coin_type), Some(total_balance)) => Ok(Some(Balance {
95                coin_type,
96                total_balance: total_balance.0,
97            })),
98            _ => Ok(None),
99        }
100    }
101
102    /// Stream all coin balances owned by an address.
103    ///
104    /// # Example
105    ///
106    /// ```no_run
107    /// use futures::StreamExt;
108    /// use std::pin::pin;
109    /// use sui_graphql::Client;
110    /// use sui_sdk_types::Address;
111    ///
112    /// #[tokio::main]
113    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
114    ///     let client = Client::new(Client::MAINNET)?;
115    ///     let owner: Address = "0x123...".parse()?;
116    ///
117    ///     let mut stream = pin!(client.list_balances(owner));
118    ///     while let Some(result) = stream.next().await {
119    ///         let balance = result?;
120    ///         println!("{}: {}", balance.coin_type, balance.total_balance);
121    ///     }
122    ///     Ok(())
123    /// }
124    /// ```
125    pub fn list_balances(&self, owner: Address) -> impl Stream<Item = Result<Balance, Error>> + '_ {
126        let client = self.clone();
127        paginate(move |cursor| {
128            let client = client.clone();
129            async move { client.fetch_balances_page(owner, cursor.as_deref()).await }
130        })
131    }
132
133    /// Fetch a single page of balances.
134    async fn fetch_balances_page(
135        &self,
136        owner: Address,
137        cursor: Option<&str>,
138    ) -> Result<Page<Balance>, Error> {
139        #[derive(Response)]
140        struct Response {
141            #[field(path = "address?.balances?.pageInfo?")]
142            page_info: Option<PageInfo>,
143            #[field(path = "address?.balances?.nodes?[].coinType?.repr?")]
144            coin_types: Option<Vec<Option<StructTag>>>,
145            #[field(path = "address?.balances?.nodes?[].totalBalance?")]
146            total_balances: Option<Vec<Option<BigInt>>>,
147        }
148
149        const QUERY: &str = graphql_query!(
150            "query($owner: SuiAddress!, $after: String) {
151                address(address: $owner) {
152                    balances(after: $after) {
153                        pageInfo {
154                            hasNextPage
155                            endCursor
156                        }
157                        nodes {
158                            coinType {
159                                repr
160                            }
161                            totalBalance
162                        }
163                    }
164                }
165            }"
166        );
167
168        let variables = serde_json::json!({
169            "owner": owner,
170            "after": cursor,
171        });
172
173        let response = self.query::<Response>(QUERY, variables).await?;
174
175        let data = response.into_data();
176        let page_info = data
177            .as_ref()
178            .and_then(|d| d.page_info.clone())
179            .unwrap_or_default();
180
181        let (coin_types, total_balances) = data
182            .map(|d| {
183                (
184                    d.coin_types.unwrap_or_default(),
185                    d.total_balances.unwrap_or_default(),
186                )
187            })
188            .unwrap_or_default();
189
190        // Zip coin_types and total_balances together
191        let balances: Vec<Balance> = coin_types
192            .into_iter()
193            .zip(total_balances)
194            .filter_map(|(ct, tb)| match (ct, tb) {
195                (Some(coin_type), Some(total_balance)) => Some((coin_type, total_balance)),
196                _ => None,
197            })
198            .map(|(coin_type, total_balance)| Balance {
199                coin_type,
200                total_balance: total_balance.0,
201            })
202            .collect();
203
204        Ok(Page {
205            items: balances,
206            has_next_page: page_info.has_next_page,
207            end_cursor: page_info.end_cursor,
208            ..Default::default()
209        })
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use futures::StreamExt;
217    use std::sync::Arc;
218    use std::sync::atomic::AtomicUsize;
219    use std::sync::atomic::Ordering;
220    use wiremock::Mock;
221    use wiremock::MockServer;
222    use wiremock::ResponseTemplate;
223    use wiremock::matchers::method;
224    use wiremock::matchers::path;
225
226    #[tokio::test]
227    async fn test_get_balance_found() {
228        let mock_server = MockServer::start().await;
229
230        Mock::given(method("POST"))
231            .and(path("/"))
232            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
233                "data": {
234                    "address": {
235                        "balance": {
236                            "coinType": {
237                                "repr": "0x2::sui::SUI"
238                            },
239                            "totalBalance": "1000000000"
240                        }
241                    }
242                }
243            })))
244            .mount(&mock_server)
245            .await;
246
247        let client = Client::new(&mock_server.uri()).unwrap();
248        let owner: Address = "0x1".parse().unwrap();
249
250        let result = client.get_balance(owner, &StructTag::sui()).await;
251        assert!(result.is_ok());
252
253        let balance = result.unwrap();
254        assert!(balance.is_some());
255
256        let balance = balance.unwrap();
257        assert_eq!(balance.coin_type, StructTag::sui());
258        assert_eq!(balance.total_balance, 1000000000);
259    }
260
261    #[tokio::test]
262    async fn test_get_balance_not_found() {
263        let mock_server = MockServer::start().await;
264
265        Mock::given(method("POST"))
266            .and(path("/"))
267            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
268                "data": {
269                    "address": {
270                        "balance": null
271                    }
272                }
273            })))
274            .mount(&mock_server)
275            .await;
276
277        let client = Client::new(&mock_server.uri()).unwrap();
278        let owner: Address = "0x1".parse().unwrap();
279
280        let result = client.get_balance(owner, &StructTag::sui()).await;
281        assert!(result.is_ok());
282        assert!(result.unwrap().is_none());
283    }
284
285    #[tokio::test]
286    async fn test_get_balance_invalid_number() {
287        let mock_server = MockServer::start().await;
288
289        Mock::given(method("POST"))
290            .and(path("/"))
291            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
292                "data": {
293                    "address": {
294                        "balance": {
295                            "coinType": {
296                                "repr": "0x2::sui::SUI"
297                            },
298                            "totalBalance": "not_a_number"
299                        }
300                    }
301                }
302            })))
303            .mount(&mock_server)
304            .await;
305
306        let client = Client::new(&mock_server.uri()).unwrap();
307        let owner: Address = "0x1".parse().unwrap();
308
309        let result = client.get_balance(owner, &StructTag::sui()).await;
310        assert!(matches!(result, Err(Error::Request(e)) if e.is_decode()));
311    }
312
313    #[tokio::test]
314    async fn test_list_balances_empty() {
315        let mock_server = MockServer::start().await;
316
317        Mock::given(method("POST"))
318            .and(path("/"))
319            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
320                "data": {
321                    "address": {
322                        "balances": {
323                            "pageInfo": {
324                                "hasNextPage": false,
325                                "endCursor": null
326                            },
327                            "nodes": []
328                        }
329                    }
330                }
331            })))
332            .mount(&mock_server)
333            .await;
334
335        let client = Client::new(&mock_server.uri()).unwrap();
336        let owner: Address = "0x1".parse().unwrap();
337
338        let stream = client.list_balances(owner);
339        let balances: Vec<_> = stream.collect().await;
340
341        assert!(balances.is_empty());
342    }
343
344    #[tokio::test]
345    async fn test_list_balances_multiple() {
346        let mock_server = MockServer::start().await;
347
348        Mock::given(method("POST"))
349            .and(path("/"))
350            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
351                "data": {
352                    "address": {
353                        "balances": {
354                            "pageInfo": {
355                                "hasNextPage": false,
356                                "endCursor": null
357                            },
358                            "nodes": [
359                                {
360                                    "coinType": { "repr": "0x2::sui::SUI" },
361                                    "totalBalance": "1000000000"
362                                },
363                                {
364                                    "coinType": { "repr": "0xabc::token::USDC" },
365                                    "totalBalance": "500000"
366                                }
367                            ]
368                        }
369                    }
370                }
371            })))
372            .mount(&mock_server)
373            .await;
374
375        let client = Client::new(&mock_server.uri()).unwrap();
376        let owner: Address = "0x1".parse().unwrap();
377
378        let stream = client.list_balances(owner);
379        let balances: Vec<_> = stream.collect().await;
380
381        assert_eq!(balances.len(), 2);
382        assert!(balances[0].is_ok());
383        assert!(balances[1].is_ok());
384
385        let bal1 = balances[0].as_ref().unwrap();
386        assert_eq!(bal1.coin_type, StructTag::sui());
387        assert_eq!(bal1.total_balance, 1000000000);
388
389        let bal2 = balances[1].as_ref().unwrap();
390        let usdc: StructTag = "0xabc::token::USDC".parse().unwrap();
391        assert_eq!(bal2.coin_type, usdc);
392        assert_eq!(bal2.total_balance, 500000);
393    }
394
395    #[tokio::test]
396    async fn test_list_balances_with_pagination() {
397        let mock_server = MockServer::start().await;
398        let call_count = Arc::new(AtomicUsize::new(0));
399        let call_count_clone = call_count.clone();
400
401        Mock::given(method("POST"))
402            .and(path("/"))
403            .respond_with(move |_req: &wiremock::Request| {
404                let count = call_count_clone.fetch_add(1, Ordering::SeqCst);
405                match count {
406                    0 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
407                        "data": {
408                            "address": {
409                                "balances": {
410                                    "pageInfo": {
411                                        "hasNextPage": true,
412                                        "endCursor": "cursor1"
413                                    },
414                                    "nodes": [
415                                        {
416                                            "coinType": { "repr": "0x2::sui::SUI" },
417                                            "totalBalance": "1000000000"
418                                        }
419                                    ]
420                                }
421                            }
422                        }
423                    })),
424                    1 => ResponseTemplate::new(200).set_body_json(serde_json::json!({
425                        "data": {
426                            "address": {
427                                "balances": {
428                                    "pageInfo": {
429                                        "hasNextPage": false,
430                                        "endCursor": null
431                                    },
432                                    "nodes": [
433                                        {
434                                            "coinType": { "repr": "0xabc::token::USDC" },
435                                            "totalBalance": "500000"
436                                        }
437                                    ]
438                                }
439                            }
440                        }
441                    })),
442                    _ => ResponseTemplate::new(200).set_body_json(serde_json::json!({
443                        "data": {
444                            "address": {
445                                "balances": {
446                                    "pageInfo": { "hasNextPage": false, "endCursor": null },
447                                    "nodes": []
448                                }
449                            }
450                        }
451                    })),
452                }
453            })
454            .mount(&mock_server)
455            .await;
456
457        let client = Client::new(&mock_server.uri()).unwrap();
458        let owner: Address = "0x1".parse().unwrap();
459
460        let stream = client.list_balances(owner);
461        let balances: Vec<_> = stream.collect().await;
462
463        assert_eq!(balances.len(), 2);
464        assert_eq!(call_count.load(Ordering::SeqCst), 2);
465
466        let bal1 = balances[0].as_ref().unwrap();
467        assert_eq!(bal1.coin_type, StructTag::sui());
468        assert_eq!(bal1.total_balance, 1000000000);
469
470        let bal2 = balances[1].as_ref().unwrap();
471        let usdc: StructTag = "0xabc::token::USDC".parse().unwrap();
472        assert_eq!(bal2.coin_type, usdc);
473        assert_eq!(bal2.total_balance, 500000);
474    }
475}