movement_sdk/api/
faucet.rs1use crate::config::MovementConfig;
4use crate::error::{MovementError, MovementResult};
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: &MovementConfig) -> MovementResult<Self> {
74 let faucet_url = config
75 .faucet_url()
76 .cloned()
77 .ok_or_else(|| MovementError::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(MovementError::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) -> MovementResult<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) -> MovementResult<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(MovementError::api(status.as_u16(), body))
162 }
163 }
164 })
165 .await
166 }
167
168 pub async fn fund_default(&self, address: AccountAddress) -> MovementResult<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 ) -> MovementResult<(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) -> MovementResult<Url> {
195 let base = self.faucet_url.as_str().trim_end_matches('/');
196 Url::parse(&format!("{base}/{path}")).map_err(MovementError::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 config = MovementConfig::testnet()
212 .with_faucet_url("https://faucet.example.com")
213 .unwrap();
214 assert!(FaucetClient::new(&config).is_ok());
215
216 assert!(FaucetClient::new(&MovementConfig::mainnet()).is_err());
218 }
219
220 fn create_mock_faucet_client(server: &MockServer) -> FaucetClient {
221 let config = MovementConfig::custom(&server.uri())
222 .unwrap()
223 .with_faucet_url(&server.uri())
224 .unwrap()
225 .without_retry();
226 FaucetClient::new(&config).unwrap()
227 }
228
229 #[tokio::test]
230 async fn test_fund_success() {
231 let server = MockServer::start().await;
232
233 Mock::given(method("POST"))
234 .and(path_regex(r"^/mint$"))
235 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
236 "txn_hashes": ["0xabc123", "0xdef456"]
237 })))
238 .expect(1)
239 .mount(&server)
240 .await;
241
242 let client = create_mock_faucet_client(&server);
243 let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
244
245 assert_eq!(result.len(), 2);
246 assert_eq!(result[0], "0xabc123");
247 }
248
249 #[tokio::test]
250 async fn test_fund_success_direct_array() {
251 let server = MockServer::start().await;
253
254 Mock::given(method("POST"))
255 .and(path_regex(r"^/mint$"))
256 .respond_with(
257 ResponseTemplate::new(200)
258 .set_body_json(serde_json::json!(["0xhash123", "0xhash456"])),
259 )
260 .expect(1)
261 .mount(&server)
262 .await;
263
264 let client = create_mock_faucet_client(&server);
265 let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
266
267 assert_eq!(result.len(), 2);
268 assert_eq!(result[0], "0xhash123");
269 assert_eq!(result[1], "0xhash456");
270 }
271
272 #[tokio::test]
273 async fn test_fund_default() {
274 let server = MockServer::start().await;
275
276 Mock::given(method("POST"))
278 .and(path_regex(r"^/mint$"))
279 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
280 "txn_hashes": ["0xfund123"]
281 })))
282 .expect(1)
283 .mount(&server)
284 .await;
285
286 let client = create_mock_faucet_client(&server);
287 let result = client.fund_default(AccountAddress::ONE).await.unwrap();
288
289 assert_eq!(result.len(), 1);
290 }
291
292 #[tokio::test]
293 async fn test_fund_error() {
294 let server = MockServer::start().await;
295
296 Mock::given(method("POST"))
297 .and(path_regex(r"^/mint$"))
298 .respond_with(ResponseTemplate::new(500).set_body_string("Faucet error"))
299 .expect(1)
300 .mount(&server)
301 .await;
302
303 let config = MovementConfig::custom(&server.uri())
305 .unwrap()
306 .with_faucet_url(&server.uri())
307 .unwrap()
308 .without_retry();
309 let client = FaucetClient::new(&config).unwrap();
310 let result = client.fund(AccountAddress::ONE, 100_000_000).await;
311
312 assert!(result.is_err());
313 }
314
315 #[tokio::test]
316 async fn test_fund_rate_limited() {
317 let server = MockServer::start().await;
318
319 Mock::given(method("POST"))
320 .and(path_regex(r"^/mint$"))
321 .respond_with(ResponseTemplate::new(429).set_body_string("Too many requests"))
322 .expect(1)
323 .mount(&server)
324 .await;
325
326 let config = MovementConfig::custom(&server.uri())
327 .unwrap()
328 .with_faucet_url(&server.uri())
329 .unwrap()
330 .without_retry();
331 let client = FaucetClient::new(&config).unwrap();
332 let result = client.fund(AccountAddress::ONE, 100_000_000).await;
333
334 assert!(result.is_err());
335 }
336
337 #[cfg(feature = "ed25519")]
338 #[tokio::test]
339 async fn test_create_and_fund() {
340 let server = MockServer::start().await;
341
342 Mock::given(method("POST"))
343 .and(path_regex(r"^/mint$"))
344 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
345 "txn_hashes": ["0xnewaccount"]
346 })))
347 .expect(1)
348 .mount(&server)
349 .await;
350
351 let client = create_mock_faucet_client(&server);
352 let (account, txn_hashes) = client.create_and_fund(100_000_000).await.unwrap();
353
354 assert!(!account.address().is_zero());
355 assert_eq!(txn_hashes.len(), 1);
356 }
357
358 #[test]
359 fn test_build_url() {
360 let config = MovementConfig::testnet()
361 .with_faucet_url("https://faucet.example.com")
362 .unwrap();
363 let client = FaucetClient::new(&config).unwrap();
364 let url = client.build_url("mint?address=0x1&amount=1000").unwrap();
365 assert!(url.as_str().contains("mint"));
366 assert!(url.as_str().contains("address=0x1"));
367 }
368}