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::time::Duration;
6
7use eyre::{bail, eyre};
8use iota_types::{Address, Digest, ObjectId};
9use reqwest::{StatusCode, Url};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use tracing::{error, info};
13
14pub const FAUCET_DEVNET_HOST: &str = "https://faucet.devnet.iota.cafe";
15pub const FAUCET_TESTNET_HOST: &str = "https://faucet.testnet.iota.cafe";
16pub const FAUCET_LOCAL_HOST: &str = "http://localhost:9123";
17
18const FAUCET_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
19const FAUCET_POLL_INTERVAL: Duration = Duration::from_secs(2);
20
21pub struct FaucetClient {
22    faucet_url: Url,
23    inner: reqwest::Client,
24}
25
26#[derive(serde::Deserialize)]
27struct FaucetResponse {
28    task: Option<String>,
29    error: Option<String>,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone)]
33#[serde(rename_all = "camelCase")]
34struct BatchStatusFaucetResponse {
35    pub status: Option<BatchSendStatus>,
36    pub error: Option<String>,
37}
38
39#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
40#[serde(rename_all = "UPPERCASE")]
41pub enum BatchSendStatusType {
42    InProgress,
43    Succeeded,
44    Discarded,
45}
46
47#[derive(Serialize, Deserialize, Debug, Clone)]
48pub struct BatchSendStatus {
49    pub status: BatchSendStatusType,
50    pub transferred_gas_objects: Option<FaucetReceipt>,
51}
52
53#[derive(Serialize, Deserialize, Debug, Clone)]
54pub struct FaucetReceipt {
55    pub sent: Vec<CoinInfo>,
56}
57
58#[derive(Serialize, Deserialize, Debug, Clone)]
59#[serde(rename_all = "camelCase")]
60pub struct CoinInfo {
61    pub amount: u64,
62    pub id: ObjectId,
63    pub transfer_tx_digest: Digest,
64}
65
66impl FaucetClient {
67    /// Construct a new `FaucetClient` with the given faucet service URL. This
68    /// [`FaucetClient`] expects that the service provides two endpoints:
69    /// /v1/gas and /v1/status. As such, do not provide the request
70    /// endpoint, just the top level service endpoint.
71    ///
72    /// - /v1/gas is used to request gas
73    /// - /v1/status/taks-uuid is used to check the status of the request
74    pub fn new(faucet_url: &str) -> Self {
75        let inner = reqwest::Client::new();
76        let faucet_url = Url::parse(faucet_url).expect("Invalid faucet URL");
77        FaucetClient { faucet_url, inner }
78    }
79
80    /// Create a new Faucet client connected to the `testnet` faucet.
81    pub fn new_testnet() -> Self {
82        Self::new(FAUCET_TESTNET_HOST)
83    }
84
85    /// Create a new Faucet client connected to the `devnet` faucet.
86    pub fn new_devnet() -> Self {
87        Self::new(FAUCET_DEVNET_HOST)
88    }
89
90    /// Create a new Faucet client connected to a `localnet` faucet.
91    pub fn new_localnet() -> Self {
92        Self::new(FAUCET_LOCAL_HOST)
93    }
94
95    /// Request gas from the faucet. Note that this will return the UUID of the
96    /// request and not wait until the token is received. Use
97    /// `request_and_wait` to wait for the token.
98    pub async fn request(&self, address: Address) -> eyre::Result<Option<String>> {
99        self.request_impl(address).await
100    }
101
102    /// Internal implementation of a faucet request. It returns the task Uuid as
103    /// a String.
104    async fn request_impl(&self, address: Address) -> eyre::Result<Option<String>> {
105        let address = address.to_string();
106        let json_body = json![{
107            "FixedAmountRequest": {
108                "recipient": &address
109            }
110        }];
111        let url = format!("{}v1/gas", self.faucet_url);
112        info!(
113            "Requesting gas from faucet for address {} : {}",
114            address, url
115        );
116        let resp = self
117            .inner
118            .post(url)
119            .header("content-type", "application/json")
120            .json(&json_body)
121            .send()
122            .await?;
123        match resp.status() {
124            StatusCode::ACCEPTED | StatusCode::CREATED => {
125                let faucet_resp: FaucetResponse = resp.json().await?;
126
127                if let Some(err) = faucet_resp.error {
128                    error!("Faucet request was unsuccessful: {err}");
129                    bail!("Faucet request was unsuccessful: {err}")
130                } else {
131                    info!("Request successful: {:?}", faucet_resp.task);
132                    Ok(faucet_resp.task)
133                }
134            }
135            StatusCode::TOO_MANY_REQUESTS => {
136                error!("Faucet service received too many requests from this IP address.");
137                bail!(
138                    "Faucet service received too many requests from this IP address. Please try again after 60 minutes."
139                );
140            }
141            StatusCode::SERVICE_UNAVAILABLE => {
142                error!("Faucet service is currently overloaded or unavailable.");
143                bail!(
144                    "Faucet service is currently overloaded or unavailable. Please try again later."
145                );
146            }
147            status_code => {
148                error!("Faucet request was unsuccessful: {status_code}");
149                bail!("Faucet request was unsuccessful: {status_code}");
150            }
151        }
152    }
153
154    /// Request gas from the faucet and wait until the request is completed and
155    /// token is transferred. Returns `FaucetReceipt` if the request is
156    /// successful, which contains the list of tokens transferred, and the
157    /// transaction digest.
158    ///
159    /// Note that the faucet is heavily rate-limited, so calling repeatedly the
160    /// faucet would likely result in a 429 code or 502 code.
161    pub async fn request_and_wait(&self, address: Address) -> eyre::Result<Option<FaucetReceipt>> {
162        let request_id = self.request(address).await?;
163        if let Some(request_id) = request_id {
164            let poll_response = tokio::time::timeout(FAUCET_REQUEST_TIMEOUT, async {
165                let mut interval = tokio::time::interval(FAUCET_POLL_INTERVAL);
166                loop {
167                    interval.tick().await;
168                    info!("Polling faucet request status: {request_id}");
169                    let req = self.request_status(request_id.clone()).await;
170
171                    if let Ok(Some(poll_response)) = req {
172                        match poll_response.status {
173                            BatchSendStatusType::Succeeded => {
174                                info!("Faucet request {request_id} succeeded");
175                                break Ok(poll_response);
176                            }
177                            BatchSendStatusType::Discarded => {
178                                break Ok(BatchSendStatus {
179                                    status: BatchSendStatusType::Discarded,
180                                    transferred_gas_objects: None,
181                                });
182                            }
183                            BatchSendStatusType::InProgress => {
184                                continue;
185                            }
186                        }
187                    } else if let Some(err) = req.err() {
188                        error!("Faucet request {request_id} failed. Error: {:?}", err);
189                        break Err(eyre!(
190                            "Faucet request {request_id} failed. Error: {:?}",
191                            err
192                        ));
193                    }
194                }
195            })
196            .await
197            .map_err(|_| {
198                error!(
199                    "Faucet request {request_id} timed out. Timeout set to {} seconds",
200                    FAUCET_REQUEST_TIMEOUT.as_secs()
201                );
202                eyre!("Faucet request timed out")
203            })??;
204            Ok(poll_response.transferred_gas_objects)
205        } else {
206            Ok(None)
207        }
208    }
209
210    /// Check the faucet request status.
211    ///
212    /// Possible statuses are defined in: [`BatchSendStatusType`]
213    pub async fn request_status(&self, id: String) -> eyre::Result<Option<BatchSendStatus>> {
214        let status_url = format!("{}v1/status/{}", self.faucet_url, id);
215        info!("Checking status of faucet request: {status_url}");
216        let response = self.inner.get(&status_url).send().await?;
217        if response.status() == StatusCode::TOO_MANY_REQUESTS {
218            bail!("Cannot fetch request status due to too many requests from this IP address.");
219        } else if response.status() == StatusCode::BAD_GATEWAY {
220            bail!("Cannot fetch request status due to a bad gateway.")
221        }
222        let json = response
223            .json::<BatchStatusFaucetResponse>()
224            .await
225            .map_err(|e| {
226                error!("Failed to parse faucet response: {:?}", e);
227                eyre!("Failed to parse faucet response: {:?}", e)
228            })?;
229        Ok(json.status)
230    }
231}