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