1use 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
12const MAX_FAUCET_RESPONSE_SIZE: usize = 1024 * 1024;
14
15const MAX_FAUCET_ERROR_BODY_SIZE: usize = 8 * 1024;
22
23#[derive(Debug, Clone)]
45pub struct FaucetClient {
46 faucet_url: Url,
47 client: Client,
48 retry_config: Arc<RetryConfig>,
49}
50
51#[derive(Debug, Clone, Deserialize)]
57#[serde(untagged)]
58pub(crate) enum FaucetResponse {
59 Direct(Vec<String>),
61 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 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 pub fn with_url(url: &str) -> AptosResult<Self> {
119 let faucet_url = Url::parse(url)?;
120 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 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 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 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 pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
196 self.fund(address, 100_000_000).await }
198
199 #[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 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 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 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 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 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 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}