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