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
12#[derive(Debug, Clone)]
34pub struct FaucetClient {
35 faucet_url: Url,
36 client: Client,
37 retry_config: Arc<RetryConfig>,
38}
39
40#[derive(Debug, Clone, Deserialize)]
46#[serde(untagged)]
47pub(crate) enum FaucetResponse {
48 Direct(Vec<String>),
50 Object { txn_hashes: Vec<String> },
52}
53
54impl FaucetResponse {
55 pub(super) fn into_hashes(self) -> Vec<String> {
56 match self {
57 FaucetResponse::Direct(hashes) => hashes,
58 FaucetResponse::Object { txn_hashes } => txn_hashes,
59 }
60 }
61}
62
63impl FaucetClient {
64 pub fn new(config: &AptosConfig) -> AptosResult<Self> {
71 let faucet_url = config
72 .faucet_url()
73 .cloned()
74 .ok_or_else(|| AptosError::Config("faucet URL not configured".into()))?;
75
76 let pool = config.pool_config();
77
78 let mut builder = Client::builder()
79 .timeout(config.timeout)
80 .pool_max_idle_per_host(pool.max_idle_per_host.unwrap_or(usize::MAX))
81 .pool_idle_timeout(pool.idle_timeout)
82 .tcp_nodelay(pool.tcp_nodelay);
83
84 if let Some(keepalive) = pool.tcp_keepalive {
85 builder = builder.tcp_keepalive(keepalive);
86 }
87
88 let client = builder.build().map_err(AptosError::Http)?;
89
90 let retry_config = Arc::new(config.retry_config().clone());
91
92 Ok(Self {
93 faucet_url,
94 client,
95 retry_config,
96 })
97 }
98
99 pub fn with_url(url: &str) -> AptosResult<Self> {
105 let faucet_url = Url::parse(url)?;
106 let client = Client::new();
107 Ok(Self {
108 faucet_url,
109 client,
110 retry_config: Arc::new(RetryConfig::default()),
111 })
112 }
113
114 pub async fn fund(&self, address: AccountAddress, amount: u64) -> AptosResult<Vec<String>> {
131 let url = self.build_url(&format!("mint?address={address}&amount={amount}"))?;
132 let client = self.client.clone();
133 let retry_config = self.retry_config.clone();
134
135 let executor = RetryExecutor::new((*retry_config).clone());
136 executor
137 .execute(|| {
138 let client = client.clone();
139 let url = url.clone();
140 async move {
141 let response = client.post(url).send().await?;
142
143 if response.status().is_success() {
144 let faucet_response: FaucetResponse = response.json().await?;
145 Ok(faucet_response.into_hashes())
146 } else {
147 let status = response.status();
148 let body = response.text().await.unwrap_or_default();
149 Err(AptosError::api(status.as_u16(), body))
150 }
151 }
152 })
153 .await
154 }
155
156 pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
162 self.fund(address, 100_000_000).await }
164
165 #[cfg(feature = "ed25519")]
173 pub async fn create_and_fund(
174 &self,
175 amount: u64,
176 ) -> AptosResult<(crate::account::Ed25519Account, Vec<String>)> {
177 let account = crate::account::Ed25519Account::generate();
178 let txn_hashes = self.fund(account.address(), amount).await?;
179 Ok((account, txn_hashes))
180 }
181
182 fn build_url(&self, path: &str) -> AptosResult<Url> {
183 let base = self.faucet_url.as_str().trim_end_matches('/');
184 Url::parse(&format!("{base}/{path}")).map_err(AptosError::Url)
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use wiremock::{
192 Mock, MockServer, ResponseTemplate,
193 matchers::{method, path_regex},
194 };
195
196 #[test]
197 fn test_faucet_client_creation() {
198 let client = FaucetClient::new(&AptosConfig::testnet());
199 assert!(client.is_ok());
200
201 let client = FaucetClient::new(&AptosConfig::mainnet());
203 assert!(client.is_err());
204 }
205
206 fn create_mock_faucet_client(server: &MockServer) -> FaucetClient {
207 let config = AptosConfig::custom(&server.uri())
208 .unwrap()
209 .with_faucet_url(&server.uri())
210 .unwrap()
211 .without_retry();
212 FaucetClient::new(&config).unwrap()
213 }
214
215 #[tokio::test]
216 async fn test_fund_success() {
217 let server = MockServer::start().await;
218
219 Mock::given(method("POST"))
220 .and(path_regex(r"^/mint$"))
221 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
222 "txn_hashes": ["0xabc123", "0xdef456"]
223 })))
224 .expect(1)
225 .mount(&server)
226 .await;
227
228 let client = create_mock_faucet_client(&server);
229 let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
230
231 assert_eq!(result.len(), 2);
232 assert_eq!(result[0], "0xabc123");
233 }
234
235 #[tokio::test]
236 async fn test_fund_success_direct_array() {
237 let server = MockServer::start().await;
239
240 Mock::given(method("POST"))
241 .and(path_regex(r"^/mint$"))
242 .respond_with(
243 ResponseTemplate::new(200)
244 .set_body_json(serde_json::json!(["0xhash123", "0xhash456"])),
245 )
246 .expect(1)
247 .mount(&server)
248 .await;
249
250 let client = create_mock_faucet_client(&server);
251 let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
252
253 assert_eq!(result.len(), 2);
254 assert_eq!(result[0], "0xhash123");
255 assert_eq!(result[1], "0xhash456");
256 }
257
258 #[tokio::test]
259 async fn test_fund_default() {
260 let server = MockServer::start().await;
261
262 Mock::given(method("POST"))
264 .and(path_regex(r"^/mint$"))
265 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
266 "txn_hashes": ["0xfund123"]
267 })))
268 .expect(1)
269 .mount(&server)
270 .await;
271
272 let client = create_mock_faucet_client(&server);
273 let result = client.fund_default(AccountAddress::ONE).await.unwrap();
274
275 assert_eq!(result.len(), 1);
276 }
277
278 #[tokio::test]
279 async fn test_fund_error() {
280 let server = MockServer::start().await;
281
282 Mock::given(method("POST"))
283 .and(path_regex(r"^/mint$"))
284 .respond_with(ResponseTemplate::new(500).set_body_string("Faucet error"))
285 .expect(1)
286 .mount(&server)
287 .await;
288
289 let config = AptosConfig::custom(&server.uri())
291 .unwrap()
292 .with_faucet_url(&server.uri())
293 .unwrap()
294 .without_retry();
295 let client = FaucetClient::new(&config).unwrap();
296 let result = client.fund(AccountAddress::ONE, 100_000_000).await;
297
298 assert!(result.is_err());
299 }
300
301 #[tokio::test]
302 async fn test_fund_rate_limited() {
303 let server = MockServer::start().await;
304
305 Mock::given(method("POST"))
306 .and(path_regex(r"^/mint$"))
307 .respond_with(ResponseTemplate::new(429).set_body_string("Too many requests"))
308 .expect(1)
309 .mount(&server)
310 .await;
311
312 let config = AptosConfig::custom(&server.uri())
313 .unwrap()
314 .with_faucet_url(&server.uri())
315 .unwrap()
316 .without_retry();
317 let client = FaucetClient::new(&config).unwrap();
318 let result = client.fund(AccountAddress::ONE, 100_000_000).await;
319
320 assert!(result.is_err());
321 }
322
323 #[cfg(feature = "ed25519")]
324 #[tokio::test]
325 async fn test_create_and_fund() {
326 let server = MockServer::start().await;
327
328 Mock::given(method("POST"))
329 .and(path_regex(r"^/mint$"))
330 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331 "txn_hashes": ["0xnewaccount"]
332 })))
333 .expect(1)
334 .mount(&server)
335 .await;
336
337 let client = create_mock_faucet_client(&server);
338 let (account, txn_hashes) = client.create_and_fund(100_000_000).await.unwrap();
339
340 assert!(!account.address().is_zero());
341 assert_eq!(txn_hashes.len(), 1);
342 }
343
344 #[test]
345 fn test_build_url() {
346 let config = AptosConfig::testnet();
347 let client = FaucetClient::new(&config).unwrap();
348 let url = client.build_url("mint?address=0x1&amount=1000").unwrap();
349 assert!(url.as_str().contains("mint"));
350 assert!(url.as_str().contains("address=0x1"));
351 }
352}