1use 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#[derive(Debug, Clone)]
18pub struct Balance {
19 pub coin_type: StructTag,
21 pub total_balance: u64,
23}
24
25impl Client {
26 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 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 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 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}