Skip to main content

aptos_sdk/api/
faucet.rs

1//! Faucet client for funding accounts on testnets.
2
3use crate::config::AptosConfig;
4use crate::error::{AptosError, AptosResult};
5use crate::retry::{RetryConfig, RetryExecutor};
6use crate::types::AccountAddress;
7use reqwest::Client;
8use serde::Deserialize;
9use std::sync::Arc;
10use url::Url;
11
12/// Client for the Aptos faucet service.
13///
14/// The faucet is only available on devnet and testnet. Requests are
15/// automatically retried with exponential backoff for transient failures.
16///
17/// # Example
18///
19/// ```rust,no_run
20/// use aptos_sdk::api::FaucetClient;
21/// use aptos_sdk::config::AptosConfig;
22/// use aptos_sdk::types::AccountAddress;
23///
24/// #[tokio::main]
25/// async fn main() -> anyhow::Result<()> {
26///     let config = AptosConfig::testnet();
27///     let client = FaucetClient::new(&config)?;
28///     let address = AccountAddress::from_hex("0x123")?;
29///     client.fund(address, 100_000_000).await?;
30///     Ok(())
31/// }
32/// ```
33#[derive(Debug, Clone)]
34pub struct FaucetClient {
35    faucet_url: Url,
36    client: Client,
37    retry_config: Arc<RetryConfig>,
38}
39
40/// Response from the faucet.
41///
42/// The faucet API can return different formats depending on version:
43/// - Direct array: `["hash1", "hash2"]`
44/// - Object: `{"txn_hashes": ["hash1", "hash2"]}`
45#[derive(Debug, Clone, Deserialize)]
46#[serde(untagged)]
47pub(crate) enum FaucetResponse {
48    /// Direct array of transaction hashes (localnet format).
49    Direct(Vec<String>),
50    /// Object with `txn_hashes` field (some older/alternative formats).
51    Object { txn_hashes: Vec<String> },
52}
53
54impl FaucetResponse {
55    pub(super) fn into_hashes(self) -> Vec<String> {
56        match self {
57            FaucetResponse::Direct(hashes) => hashes,
58            FaucetResponse::Object { txn_hashes } => txn_hashes,
59        }
60    }
61}
62
63impl FaucetClient {
64    /// Creates a new faucet client.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the faucet URL is not configured in the config, or if the HTTP client
69    /// fails to build (e.g., invalid TLS configuration).
70    pub fn new(config: &AptosConfig) -> AptosResult<Self> {
71        let faucet_url = config
72            .faucet_url()
73            .cloned()
74            .ok_or_else(|| AptosError::Config("faucet URL not configured".into()))?;
75
76        let pool = config.pool_config();
77
78        let mut builder = Client::builder()
79            .timeout(config.timeout)
80            .pool_max_idle_per_host(pool.max_idle_per_host.unwrap_or(usize::MAX))
81            .pool_idle_timeout(pool.idle_timeout)
82            .tcp_nodelay(pool.tcp_nodelay);
83
84        if let Some(keepalive) = pool.tcp_keepalive {
85            builder = builder.tcp_keepalive(keepalive);
86        }
87
88        let client = builder.build().map_err(AptosError::Http)?;
89
90        let retry_config = Arc::new(config.retry_config().clone());
91
92        Ok(Self {
93            faucet_url,
94            client,
95            retry_config,
96        })
97    }
98
99    /// Creates a faucet client with a custom URL.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the URL cannot be parsed.
104    pub fn with_url(url: &str) -> AptosResult<Self> {
105        let faucet_url = Url::parse(url)?;
106        let client = Client::new();
107        Ok(Self {
108            faucet_url,
109            client,
110            retry_config: Arc::new(RetryConfig::default()),
111        })
112    }
113
114    /// Funds an account with the specified amount of octas.
115    ///
116    /// # Arguments
117    ///
118    /// * `address` - The account address to fund
119    /// * `amount` - Amount in octas (1 APT = 10^8 octas)
120    ///
121    /// # Returns
122    ///
123    /// The transaction hashes of the funding transactions.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the URL cannot be built, the HTTP request fails, the API returns
128    /// an error status code (e.g., rate limiting 429, server error 500), or the response
129    /// cannot be parsed as JSON.
130    pub async fn fund(&self, address: AccountAddress, amount: u64) -> AptosResult<Vec<String>> {
131        let url = self.build_url(&format!("mint?address={address}&amount={amount}"))?;
132        let client = self.client.clone();
133        let retry_config = self.retry_config.clone();
134
135        let executor = RetryExecutor::new((*retry_config).clone());
136        executor
137            .execute(|| {
138                let client = client.clone();
139                let url = url.clone();
140                async move {
141                    let response = client.post(url).send().await?;
142
143                    if response.status().is_success() {
144                        let faucet_response: FaucetResponse = response.json().await?;
145                        Ok(faucet_response.into_hashes())
146                    } else {
147                        let status = response.status();
148                        let body = response.text().await.unwrap_or_default();
149                        Err(AptosError::api(status.as_u16(), body))
150                    }
151                }
152            })
153            .await
154    }
155
156    /// Funds an account with a default amount (usually 1 APT).
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the funding request fails (see [`fund`](Self::fund) for details).
161    pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
162        self.fund(address, 100_000_000).await // 1 APT
163    }
164
165    /// Creates an account and funds it.
166    ///
167    /// This is useful for quickly creating test accounts.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the funding request fails (see [`fund`](Self::fund) for details).
172    #[cfg(feature = "ed25519")]
173    pub async fn create_and_fund(
174        &self,
175        amount: u64,
176    ) -> AptosResult<(crate::account::Ed25519Account, Vec<String>)> {
177        let account = crate::account::Ed25519Account::generate();
178        let txn_hashes = self.fund(account.address(), amount).await?;
179        Ok((account, txn_hashes))
180    }
181
182    fn build_url(&self, path: &str) -> AptosResult<Url> {
183        let base = self.faucet_url.as_str().trim_end_matches('/');
184        Url::parse(&format!("{base}/{path}")).map_err(AptosError::Url)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use wiremock::{
192        Mock, MockServer, ResponseTemplate,
193        matchers::{method, path_regex},
194    };
195
196    #[test]
197    fn test_faucet_client_creation() {
198        let client = FaucetClient::new(&AptosConfig::testnet());
199        assert!(client.is_ok());
200
201        // Mainnet has no faucet
202        let client = FaucetClient::new(&AptosConfig::mainnet());
203        assert!(client.is_err());
204    }
205
206    fn create_mock_faucet_client(server: &MockServer) -> FaucetClient {
207        let config = AptosConfig::custom(&server.uri())
208            .unwrap()
209            .with_faucet_url(&server.uri())
210            .unwrap()
211            .without_retry();
212        FaucetClient::new(&config).unwrap()
213    }
214
215    #[tokio::test]
216    async fn test_fund_success() {
217        let server = MockServer::start().await;
218
219        Mock::given(method("POST"))
220            .and(path_regex(r"^/mint$"))
221            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
222                "txn_hashes": ["0xabc123", "0xdef456"]
223            })))
224            .expect(1)
225            .mount(&server)
226            .await;
227
228        let client = create_mock_faucet_client(&server);
229        let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
230
231        assert_eq!(result.len(), 2);
232        assert_eq!(result[0], "0xabc123");
233    }
234
235    #[tokio::test]
236    async fn test_fund_success_direct_array() {
237        // Test the direct array format used by localnet
238        let server = MockServer::start().await;
239
240        Mock::given(method("POST"))
241            .and(path_regex(r"^/mint$"))
242            .respond_with(
243                ResponseTemplate::new(200)
244                    .set_body_json(serde_json::json!(["0xhash123", "0xhash456"])),
245            )
246            .expect(1)
247            .mount(&server)
248            .await;
249
250        let client = create_mock_faucet_client(&server);
251        let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
252
253        assert_eq!(result.len(), 2);
254        assert_eq!(result[0], "0xhash123");
255        assert_eq!(result[1], "0xhash456");
256    }
257
258    #[tokio::test]
259    async fn test_fund_default() {
260        let server = MockServer::start().await;
261
262        // Note: path_regex only matches the path, not query parameters
263        Mock::given(method("POST"))
264            .and(path_regex(r"^/mint$"))
265            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
266                "txn_hashes": ["0xfund123"]
267            })))
268            .expect(1)
269            .mount(&server)
270            .await;
271
272        let client = create_mock_faucet_client(&server);
273        let result = client.fund_default(AccountAddress::ONE).await.unwrap();
274
275        assert_eq!(result.len(), 1);
276    }
277
278    #[tokio::test]
279    async fn test_fund_error() {
280        let server = MockServer::start().await;
281
282        Mock::given(method("POST"))
283            .and(path_regex(r"^/mint$"))
284            .respond_with(ResponseTemplate::new(500).set_body_string("Faucet error"))
285            .expect(1)
286            .mount(&server)
287            .await;
288
289        // Create client without retry to test error handling
290        let config = AptosConfig::custom(&server.uri())
291            .unwrap()
292            .with_faucet_url(&server.uri())
293            .unwrap()
294            .without_retry();
295        let client = FaucetClient::new(&config).unwrap();
296        let result = client.fund(AccountAddress::ONE, 100_000_000).await;
297
298        assert!(result.is_err());
299    }
300
301    #[tokio::test]
302    async fn test_fund_rate_limited() {
303        let server = MockServer::start().await;
304
305        Mock::given(method("POST"))
306            .and(path_regex(r"^/mint$"))
307            .respond_with(ResponseTemplate::new(429).set_body_string("Too many requests"))
308            .expect(1)
309            .mount(&server)
310            .await;
311
312        let config = AptosConfig::custom(&server.uri())
313            .unwrap()
314            .with_faucet_url(&server.uri())
315            .unwrap()
316            .without_retry();
317        let client = FaucetClient::new(&config).unwrap();
318        let result = client.fund(AccountAddress::ONE, 100_000_000).await;
319
320        assert!(result.is_err());
321    }
322
323    #[cfg(feature = "ed25519")]
324    #[tokio::test]
325    async fn test_create_and_fund() {
326        let server = MockServer::start().await;
327
328        Mock::given(method("POST"))
329            .and(path_regex(r"^/mint$"))
330            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                "txn_hashes": ["0xnewaccount"]
332            })))
333            .expect(1)
334            .mount(&server)
335            .await;
336
337        let client = create_mock_faucet_client(&server);
338        let (account, txn_hashes) = client.create_and_fund(100_000_000).await.unwrap();
339
340        assert!(!account.address().is_zero());
341        assert_eq!(txn_hashes.len(), 1);
342    }
343
344    #[test]
345    fn test_build_url() {
346        let config = AptosConfig::testnet();
347        let client = FaucetClient::new(&config).unwrap();
348        let url = client.build_url("mint?address=0x1&amount=1000").unwrap();
349        assert!(url.as_str().contains("mint"));
350        assert!(url.as_str().contains("address=0x1"));
351    }
352}