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