Skip to main content

iota_sdk_graphql_client/api/
coins.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2026 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5//! Coin API implementation.
6
7use cynic::QueryBuilder;
8use futures::Stream;
9use iota_types::{Address, Identifier, StructTag, framework::Coin};
10
11use crate::{
12    GraphQLClient, ListObjectsQuery,
13    api::define_query,
14    error::GraphQLResult,
15    pagination::{Page, PaginationFilter},
16    query_types::{CoinMetadata, CoinMetadataArgs, CoinMetadataQueryFragment, ObjectFilter},
17    streams::stream_paginated_query,
18};
19
20define_query! {
21    /// Query for [`GraphQLClient::coins`]. Await it to send the request.
22    #[derive(Clone)]
23    pub struct ListCoinsQuery {
24        client: GraphQLClient,
25        owner: Address,
26        coin_type: Option<StructTag>,
27        pagination: PaginationFilter,
28    }
29    output: GraphQLResult<Page<Coin>>;
30}
31
32impl ListCoinsQuery {
33    /// Only return coins of this type. Defaults to every type.
34    pub fn coin_type(mut self, coin_type: StructTag) -> Self {
35        self.coin_type = Some(coin_type);
36        self
37    }
38
39    /// Set the page to fetch.
40    pub fn pagination(mut self, pagination: PaginationFilter) -> Self {
41        self.pagination = pagination;
42        self
43    }
44
45    /// Stream every item, page by page, starting at the pagination's cursor
46    /// and in its direction, with its limit as the page size.
47    pub fn stream(self) -> impl Stream<Item = GraphQLResult<Coin>> + Unpin {
48        let pagination = self.pagination.clone();
49        stream_paginated_query(move |page| self.clone().pagination(page).send(), pagination)
50    }
51
52    fn objects_query(self) -> ListObjectsQuery {
53        let type_tag = self.coin_type.map(StructTag::new_coin).unwrap_or_else(|| {
54            StructTag::new(
55                Address::FRAMEWORK,
56                Identifier::from_static("coin"),
57                Identifier::from_static("Coin"),
58                Default::default(),
59            )
60        });
61        ListObjectsQuery::new(self.client)
62            .filter(ObjectFilter {
63                type_tag: Some(type_tag.to_string()),
64                owner: Some(self.owner),
65                object_ids: None,
66            })
67            .pagination(self.pagination)
68    }
69
70    async fn send(self) -> GraphQLResult<Page<Coin>> {
71        let response = self.objects_query().await?;
72
73        Ok(Page::new(
74            response.page_info,
75            response
76                .data
77                .iter()
78                .flat_map(Coin::try_from_object)
79                .collect::<Vec<_>>(),
80        ))
81    }
82}
83
84define_query! {
85    /// Query for [`GraphQLClient::gas_coins`]. Await it to send the request.
86    #[derive(Clone)]
87    pub struct ListGasCoinsQuery {
88        coins: ListCoinsQuery,
89    }
90    output: GraphQLResult<Page<Coin>>;
91}
92
93impl ListGasCoinsQuery {
94    /// Set the page to fetch.
95    pub fn pagination(mut self, pagination: PaginationFilter) -> Self {
96        self.coins = self.coins.pagination(pagination);
97        self
98    }
99
100    /// Stream every item, page by page, starting at the pagination's cursor
101    /// and in its direction, with its limit as the page size.
102    pub fn stream(self) -> impl Stream<Item = GraphQLResult<Coin>> + Unpin {
103        let pagination = self.coins.pagination.clone();
104        stream_paginated_query(move |page| self.clone().pagination(page).send(), pagination)
105    }
106
107    async fn send(self) -> GraphQLResult<Page<Coin>> {
108        self.coins.send().await
109    }
110}
111
112define_query! {
113    /// Query for [`GraphQLClient::coin_metadata`]. Await it to send the
114    /// request.
115    pub struct GetCoinMetadataQuery {
116        client: GraphQLClient,
117        coin_type: StructTag,
118    }
119    output: GraphQLResult<Option<CoinMetadata>>;
120}
121
122impl GetCoinMetadataQuery {
123    async fn send(self) -> GraphQLResult<Option<CoinMetadata>> {
124        let coin_type = self.coin_type.to_string();
125        let operation = CoinMetadataQueryFragment::build(CoinMetadataArgs {
126            coin_type: &coin_type,
127        });
128        let response = self.client.run_query(&operation).await?;
129
130        Ok(response.coin_metadata)
131    }
132}
133
134define_query! {
135    /// Query for [`GraphQLClient::total_supply`]. Await it to send the request.
136    pub struct GetTotalSupplyQuery {
137        client: GraphQLClient,
138        coin_type: StructTag,
139    }
140    output: GraphQLResult<Option<u64>>;
141}
142
143impl GetTotalSupplyQuery {
144    async fn send(self) -> GraphQLResult<Option<u64>> {
145        let coin_metadata = self.client.coin_metadata(self.coin_type).await?;
146
147        coin_metadata
148            .and_then(|c| c.supply)
149            .map(|c| c.try_into())
150            .transpose()
151    }
152}
153
154impl GraphQLClient {
155    /// Get the list of coins for the specified address. For IOTA coins, set
156    /// the coin type to `0x2::iota::IOTA`.
157    pub fn coins(&self, owner: Address) -> ListCoinsQuery {
158        ListCoinsQuery {
159            client: self.clone(),
160            owner,
161            coin_type: None,
162            pagination: PaginationFilter::default(),
163        }
164    }
165
166    /// Get the list of gas coins for the specified address.
167    pub fn gas_coins(&self, owner: Address) -> ListGasCoinsQuery {
168        ListGasCoinsQuery {
169            coins: self.coins(owner).coin_type(StructTag::new_gas()),
170        }
171    }
172
173    /// Get the coin metadata for the coin type.
174    pub fn coin_metadata(&self, coin_type: StructTag) -> GetCoinMetadataQuery {
175        GetCoinMetadataQuery {
176            client: self.clone(),
177            coin_type,
178        }
179    }
180
181    /// Get total supply for the coin type.
182    pub fn total_supply(&self, coin_type: StructTag) -> GetTotalSupplyQuery {
183        GetTotalSupplyQuery {
184            client: self.clone(),
185            coin_type,
186        }
187    }
188}
189
190#[cfg(all(test, not(target_arch = "wasm32")))]
191mod tests {
192    use futures::StreamExt;
193    use iota_types::{Address, Ed25519PublicKey, StructTag};
194    use tokio::time;
195
196    use crate::{
197        client::LOCAL_HOST,
198        faucet::FaucetClient,
199        test_utils::{
200            NUM_COINS_FROM_FAUCET, assert_backward_page, assert_forward_page, backward_page,
201            forward_page, sent_variables, test_client,
202        },
203    };
204
205    #[tokio::test]
206    async fn coin_metadata_and_total_supply_send_the_coin_type() {
207        let vars = sent_variables("CoinMetadataQueryFragment", |client| async move {
208            let _ = client.coin_metadata(StructTag::new_gas()).await;
209        })
210        .await;
211        assert_eq!(vars["coinType"], "0x2::iota::IOTA");
212
213        let vars = sent_variables("CoinMetadataQueryFragment", |client| async move {
214            let _ = client.total_supply(StructTag::new_gas()).await;
215        })
216        .await;
217        assert_eq!(vars["coinType"], "0x2::iota::IOTA");
218    }
219
220    #[tokio::test]
221    async fn coins_sends_the_owner_coin_type_and_pagination() {
222        let vars = sent_variables("ObjectsQueryFragment", |client| async move {
223            let _ = client
224                .coins(Address::STD)
225                .coin_type(StructTag::new_gas())
226                .pagination(backward_page())
227                .await;
228        })
229        .await;
230        assert_eq!(vars["filter"]["owner"], Address::STD.to_string());
231        assert_eq!(
232            vars["filter"]["type"],
233            StructTag::new_coin(StructTag::new_gas()).to_string()
234        );
235        assert_backward_page(&vars);
236
237        let vars = sent_variables("ObjectsQueryFragment", |client| async move {
238            let _ = client
239                .coins(Address::STD)
240                .coin_type(StructTag::new_gas())
241                .pagination(forward_page())
242                .await;
243        })
244        .await;
245        assert_forward_page(&vars);
246    }
247
248    #[tokio::test]
249    async fn gas_coins_sends_the_gas_coin_type() {
250        let vars = sent_variables("ObjectsQueryFragment", |client| async move {
251            let _ = client
252                .gas_coins(Address::STD)
253                .pagination(backward_page())
254                .await;
255        })
256        .await;
257        assert_eq!(
258            vars["filter"]["type"],
259            StructTag::new_coin(StructTag::new_gas()).to_string()
260        );
261    }
262
263    #[tokio::test]
264    async fn coins_default_to_every_coin_type() {
265        let vars = sent_variables("ObjectsQueryFragment", |client| async move {
266            let _ = client.coins(Address::STD).pagination(backward_page()).await;
267        })
268        .await;
269        assert_eq!(vars["filter"]["type"], "0x2::coin::Coin");
270    }
271
272    #[tokio::test]
273    async fn test_coins_query() {
274        let client = test_client();
275        client
276            .coins(Address::STD)
277            .await
278            .map_err(|e| {
279                format!(
280                    "Coins query failed for {} network: Error {e}",
281                    client.rpc_server()
282                )
283            })
284            .unwrap();
285    }
286
287    #[tokio::test]
288    async fn test_coins_stream() {
289        let client = test_client();
290        let faucet = match client.rpc_server().as_str() {
291            LOCAL_HOST => FaucetClient::new_localnet(),
292            // The testnet and devnet faucets are web-only and expose no
293            // programmatic gas endpoint, so this test can only fund an
294            // address on localnet.
295            _ => return,
296        };
297        let key = Ed25519PublicKey::random();
298        let address = key.derive_address();
299        faucet
300            .request_and_wait_for_finalized(address, &client)
301            .await
302            .unwrap();
303
304        const MAX_RETRIES: u32 = 10;
305        const RETRY_DELAY: time::Duration = time::Duration::from_secs(1);
306
307        // Retry until the expected number of coins is streamed. Indexer lag can
308        // return a successful but incomplete page, so retry on a low count as
309        // well as on a stream error, re-counting from scratch each attempt.
310        let mut num_coins = 0;
311        for attempt in 0..MAX_RETRIES {
312            num_coins = 0;
313            let mut stream = client.coins(address).stream();
314            let mut errored = false;
315            while let Some(result) = stream.next().await {
316                match result {
317                    Ok(_) => num_coins += 1,
318                    Err(_) => {
319                        errored = true;
320                        break;
321                    }
322                }
323            }
324
325            if !errored && num_coins >= NUM_COINS_FROM_FAUCET {
326                break;
327            }
328            if attempt < MAX_RETRIES - 1 {
329                time::sleep(RETRY_DELAY).await;
330            }
331        }
332
333        assert!(
334            num_coins >= NUM_COINS_FROM_FAUCET,
335            "expected at least {NUM_COINS_FROM_FAUCET} coins for {address}, got {num_coins}"
336        );
337    }
338
339    #[tokio::test]
340    async fn test_coin_metadata_query() {
341        let client = test_client();
342        client
343            .coin_metadata(StructTag::new_gas())
344            .await
345            .map_err(|e| {
346                format!(
347                    "Coin metadata query failed for {} network: Error: {e}",
348                    client.rpc_server()
349                )
350            })
351            .unwrap()
352            .unwrap();
353    }
354
355    #[tokio::test]
356    async fn test_total_supply() {
357        let client = test_client();
358        client
359            .total_supply(StructTag::new_gas())
360            .await
361            .map_err(|e| {
362                format!(
363                    "Total supply query failed for {} network. Error: {e}",
364                    client.rpc_server()
365                )
366            })
367            .unwrap()
368            .unwrap();
369    }
370}