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
15#[derive(Debug, Clone)]
37pub struct FaucetClient {
38 faucet_url: Url,
39 client: Client,
40 retry_config: Arc<RetryConfig>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
49#[serde(untagged)]
50pub(crate) enum FaucetResponse {
51 Direct(Vec<String>),
53 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 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 pub fn with_url(url: &str) -> AptosResult<Self> {
108 let faucet_url = Url::parse(url)?;
109 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 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 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 pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
174 self.fund(address, 100_000_000).await }
176
177 #[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 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 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 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 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}