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 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 pub fn with_url(url: &str) -> AptosResult<Self> {
116 let faucet_url = Url::parse(url)?;
117 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 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 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 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 pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
193 self.fund(address, 100_000_000).await }
195
196 #[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 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 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 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 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 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 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}