Skip to main content

iota_sdk_graphql_client/
faucet.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2025 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{collections::HashSet, time::Duration};
6
7use iota_types::{Address, ObjectId, TransactionDigest};
8use reqwest::{StatusCode, Url};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use tracing::{error, info};
12
13use crate::WaitForTransaction;
14
15pub const FAUCET_LOCAL_HOST: &str = "http://localhost:9123";
16
17const FAUCET_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
18const FAUCET_POLL_INTERVAL: Duration = Duration::from_secs(2);
19
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum FaucetError {
23    #[error("Cannot fetch request status due to a bad gateway.")]
24    BadGateway,
25    #[error("Invalid faucet URL: {0}")]
26    InvalidUrl(#[from] url::ParseError),
27    #[error("Faucet request was unsuccessful: {0}")]
28    Request(String),
29    #[error("Reqwest error: {0}")]
30    Reqwest(#[from] reqwest::Error),
31    #[error("Faucet request was unsuccessful with status code {0}")]
32    StatusCode(StatusCode),
33    #[error("Faucet request timed out")]
34    TimedOut,
35    #[error(
36        "Faucet URL scheme `{0}` needs TLS: enable the `tls-ring` or `tls-aws-lc` feature, or pass \
37         your own client to `new_with_reqwest_client`"
38    )]
39    TlsUnavailable(String),
40    #[error(
41        "Faucet service received too many requests from this IP address. Please try again later."
42    )]
43    TooManyRequests,
44    #[error("Faucet service is currently overloaded or unavailable. Please try again later.")]
45    Unavailable,
46}
47
48pub struct FaucetClient {
49    faucet_url: Url,
50    inner: reqwest::Client,
51}
52
53#[derive(serde::Deserialize)]
54struct FaucetResponse {
55    task: Option<String>,
56    error: Option<String>,
57}
58
59#[derive(Clone, Debug, Deserialize, Serialize)]
60#[serde(rename_all = "camelCase")]
61struct BatchStatusFaucetResponse {
62    pub status: Option<BatchSendStatus>,
63    pub error: Option<String>,
64}
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(rename_all = "UPPERCASE")]
68#[non_exhaustive]
69pub enum BatchSendStatusType {
70    InProgress,
71    Succeeded,
72    Discarded,
73}
74
75#[derive(Clone, Debug, Deserialize, Serialize)]
76pub struct BatchSendStatus {
77    pub status: BatchSendStatusType,
78    pub transferred_gas_objects: Option<FaucetReceipt>,
79}
80
81#[derive(Clone, Debug, Deserialize, Serialize)]
82pub struct FaucetReceipt {
83    pub sent: Vec<CoinInfo>,
84}
85
86#[derive(Clone, Debug, Deserialize, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct CoinInfo {
89    pub amount: u64,
90    pub id: ObjectId,
91    pub transfer_tx_digest: TransactionDigest,
92}
93
94impl FaucetClient {
95    /// Construct a new `FaucetClient` with the given faucet service URL. This
96    /// [`FaucetClient`] expects that the service provides two endpoints:
97    /// /v1/gas and /v1/status. As such, do not provide the request
98    /// endpoint, just the top level service endpoint.
99    ///
100    /// - /v1/gas is used to request gas
101    /// - /v1/status/task-uuid is used to check the status of the request
102    ///
103    /// The HTTP client is built for you, trusting the platform store plus the
104    /// bundled Mozilla roots. Use [`Self::new_with_reqwest_client`] to supply
105    /// your own.
106    ///
107    /// An `https` URL is rejected on a build without a crypto provider, since
108    /// no request to it could succeed. See the crate README.
109    pub fn new(faucet_url: &str) -> Result<Self, FaucetError> {
110        if let Some(scheme) = crate::tls::unsupported_scheme(faucet_url) {
111            return Err(FaucetError::TlsUnavailable(scheme));
112        }
113        Self::new_with_reqwest_client(
114            faucet_url,
115            crate::tls::default_http_client_builder().build()?,
116        )
117    }
118
119    /// Construct a new `FaucetClient` that issues its requests through the
120    /// supplied [`reqwest::Client`].
121    ///
122    /// This is the way to choose your own trust anchors or TLS backend.
123    ///
124    /// Note that on a build with `tls-ring` or `tls-aws-lc`, `reqwest` has no
125    /// crypto provider to fall back on, so building a `reqwest::Client` panics
126    /// unless one has been installed for the process. See the crate README.
127    pub fn new_with_reqwest_client(
128        faucet_url: &str,
129        client: reqwest::Client,
130    ) -> Result<Self, FaucetError> {
131        Ok(FaucetClient {
132            faucet_url: Url::parse(faucet_url)?,
133            inner: client,
134        })
135    }
136
137    /// Create a new Faucet client connected to a `localnet` faucet.
138    pub fn new_localnet() -> Self {
139        Self::new(FAUCET_LOCAL_HOST).expect("cannot build localnet faucet client")
140    }
141
142    /// Request gas from the faucet. Note that this will return the UUID of the
143    /// request and not wait until the token is received. Use
144    /// `request_and_wait` to wait for the token.
145    pub async fn request(&self, address: Address) -> Result<Option<String>, FaucetError> {
146        self.request_impl(address).await
147    }
148
149    /// Internal implementation of a faucet request. It returns the task Uuid as
150    /// a String.
151    async fn request_impl(&self, address: Address) -> Result<Option<String>, FaucetError> {
152        let address = address.to_string();
153        let json_body = json![{
154            "FixedAmountRequest": {
155                "recipient": &address
156            }
157        }];
158        let url = format!("{}v1/gas", self.faucet_url);
159        info!(
160            "Requesting gas from faucet for address {} : {}",
161            address, url
162        );
163        let resp = self
164            .inner
165            .post(url)
166            .header("content-type", "application/json")
167            .json(&json_body)
168            .send()
169            .await?;
170        match resp.status() {
171            StatusCode::ACCEPTED | StatusCode::CREATED => {
172                let faucet_resp: FaucetResponse = resp.json().await?;
173
174                if let Some(err) = faucet_resp.error {
175                    error!("Faucet request was unsuccessful: {err}");
176                    Err(FaucetError::Request(err))
177                } else {
178                    info!("Request successful: {:?}", faucet_resp.task);
179                    Ok(faucet_resp.task)
180                }
181            }
182            StatusCode::TOO_MANY_REQUESTS => {
183                error!("Faucet service received too many requests from this IP address.");
184                Err(FaucetError::TooManyRequests)
185            }
186            StatusCode::SERVICE_UNAVAILABLE => {
187                error!("Faucet service is currently overloaded or unavailable.");
188                Err(FaucetError::Unavailable)
189            }
190            status_code => {
191                error!("Faucet request was unsuccessful: {status_code}");
192                Err(FaucetError::StatusCode(status_code))
193            }
194        }
195    }
196
197    /// Request gas from the faucet and wait until the request is completed and
198    /// token is transferred. Returns `FaucetReceipt` if the request is
199    /// successful, which contains the list of tokens transferred, and the
200    /// transaction digest.
201    ///
202    /// Note that the faucet is heavily rate-limited, so calling repeatedly the
203    /// faucet would likely result in a 429 code or 502 code.
204    ///
205    /// If you intend to use the transferred tokens with the graphql client,
206    /// consider using `request_and_wait_for_finalized` instead to ensure
207    /// the tokens are available in the indexer.
208    pub async fn request_and_wait(
209        &self,
210        address: Address,
211    ) -> Result<Option<FaucetReceipt>, FaucetError> {
212        let request_id = self.request(address).await?;
213
214        if let Some(request_id) = request_id {
215            let status_response = crate::wait::timeout(FAUCET_REQUEST_TIMEOUT, async {
216                loop {
217                    info!("Polling faucet request status: {request_id}");
218                    let status_response = self.request_status(request_id.clone()).await?;
219
220                    if let Some(status_response) = status_response {
221                        match status_response.status {
222                            BatchSendStatusType::Succeeded => {
223                                info!("Faucet request {request_id} succeeded");
224                                break Ok::<_, FaucetError>(status_response);
225                            }
226                            BatchSendStatusType::Discarded => {
227                                break Ok(BatchSendStatus {
228                                    status: BatchSendStatusType::Discarded,
229                                    transferred_gas_objects: None,
230                                });
231                            }
232                            // Still pending — fall through to the poll interval and retry.
233                            BatchSendStatusType::InProgress => {}
234                        }
235                    }
236
237                    crate::wait::sleep(FAUCET_POLL_INTERVAL).await;
238                }
239            })
240            .await
241            .map_err(|_| {
242                error!(
243                    "Faucet request {request_id} timed out. Timeout set to {} seconds",
244                    FAUCET_REQUEST_TIMEOUT.as_secs()
245                );
246                FaucetError::TimedOut
247            })??;
248            Ok(status_response.transferred_gas_objects)
249        } else {
250            Ok(None)
251        }
252    }
253
254    /// Request gas from the faucet and wait until the request is completed and
255    /// token is transferred and finalized on the ledger. Returns
256    /// `FaucetReceipt` if the request is successful, which contains the
257    /// list of tokens transferred, and the transaction digest.
258    ///
259    /// This is a convenience method that combines `request_and_wait` and
260    /// waiting for the funding transactions to be finalized using the provided
261    /// GraphQL `GraphQLClient`.
262    pub async fn request_and_wait_for_finalized(
263        &self,
264        address: Address,
265        client: &crate::GraphQLClient,
266    ) -> Result<Option<FaucetReceipt>, crate::error::GraphQLError> {
267        let Some(receipt) = self.request_and_wait(address).await? else {
268            return Ok(None);
269        };
270
271        // Wait for the funding transactions to be finalized
272        let tx_digests = receipt
273            .sent
274            .iter()
275            .map(|coin| coin.transfer_tx_digest)
276            .collect::<HashSet<_>>();
277        for digest in tx_digests {
278            client
279                .wait_for_transaction(digest, WaitForTransaction::Finalized)
280                .await?;
281        }
282
283        Ok(Some(receipt))
284    }
285
286    /// Check the faucet request status.
287    ///
288    /// Possible statuses are defined in: [`BatchSendStatusType`]
289    pub async fn request_status(&self, id: String) -> Result<Option<BatchSendStatus>, FaucetError> {
290        let status_url = format!("{}v1/status/{}", self.faucet_url, id);
291        info!("Checking status of faucet request: {status_url}");
292        let response = self.inner.get(&status_url).send().await?;
293
294        if response.status() == StatusCode::TOO_MANY_REQUESTS {
295            return Err(FaucetError::TooManyRequests);
296        } else if response.status() == StatusCode::BAD_GATEWAY {
297            return Err(FaucetError::BadGateway);
298        }
299
300        let json = response.json::<BatchStatusFaucetResponse>().await?;
301
302        Ok(json.status)
303    }
304}