Skip to main content

movement_sdk/api/
faucet.rs

1//! Faucet client for funding accounts on testnets.
2
3use 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
12/// Maximum faucet response size: 1 MB (faucet responses are typically tiny).
13const MAX_FAUCET_RESPONSE_SIZE: usize = 1024 * 1024;
14
15/// Client for the Movement faucet service.
16///
17/// The faucet is only available on devnet and testnet. Requests are
18/// automatically retried with exponential backoff for transient failures.
19///
20/// # Example
21///
22/// ```rust,no_run
23/// use movement_sdk::api::FaucetClient;
24/// use movement_sdk::config::MovementConfig;
25/// use movement_sdk::types::AccountAddress;
26///
27/// #[tokio::main]
28/// async fn main() -> anyhow::Result<()> {
29///     let config = MovementConfig::testnet();
30///     let client = FaucetClient::new(&config)?;
31///     let address = AccountAddress::from_hex("0x123")?;
32///     client.fund(address, 100_000_000).await?;
33///     Ok(())
34/// }
35/// ```
36#[derive(Debug, Clone)]
37pub struct FaucetClient {
38    faucet_url: Url,
39    client: Client,
40    retry_config: Arc<RetryConfig>,
41}
42
43/// Response from the faucet.
44///
45/// The faucet API can return different formats depending on version:
46/// - Direct array: `["hash1", "hash2"]`
47/// - Object: `{"txn_hashes": ["hash1", "hash2"]}`
48#[derive(Debug, Clone, Deserialize)]
49#[serde(untagged)]
50pub(crate) enum FaucetResponse {
51    /// Direct array of transaction hashes (localnet format).
52    Direct(Vec<String>),
53    /// Object with `txn_hashes` field (some older/alternative formats).
54    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    /// Creates a new faucet client.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the faucet URL is not configured in the config, or if the HTTP client
72    /// fails to build (e.g., invalid TLS configuration).
73    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    /// Creates a faucet client with a custom URL.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if the URL cannot be parsed.
107    pub fn with_url(url: &str) -> MovementResult<Self> {
108        let faucet_url = Url::parse(url)?;
109        // SECURITY: Validate URL scheme to prevent SSRF via dangerous protocols
110        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    /// Funds an account with the specified amount of octas.
120    ///
121    /// # Arguments
122    ///
123    /// * `address` - The account address to fund
124    /// * `amount` - Amount in octas (1 APT = 10^8 octas)
125    ///
126    /// # Returns
127    ///
128    /// The transaction hashes of the funding transactions.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the URL cannot be built, the HTTP request fails, the API returns
133    /// an error status code (e.g., rate limiting 429, server error 500), or the response
134    /// cannot be parsed as JSON.
135    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                        // SECURITY: Stream body with size limit to prevent OOM
150                        // from malicious responses (including chunked encoding).
151                        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    /// Funds an account with a default amount (usually 1 APT).
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the funding request fails (see [`fund`](Self::fund) for details).
173    pub async fn fund_default(&self, address: AccountAddress) -> MovementResult<Vec<String>> {
174        self.fund(address, 100_000_000).await // 1 APT
175    }
176
177    /// Creates an account and funds it.
178    ///
179    /// This is useful for quickly creating test accounts.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if the funding request fails (see [`fund`](Self::fund) for details).
184    #[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        // testnet preset has no faucet URL by default; opt in via with_faucet_url.
211        let config = MovementConfig::testnet()
212            .with_faucet_url("https://faucet.example.com")
213            .unwrap();
214        assert!(FaucetClient::new(&config).is_ok());
215
216        // Mainnet has no faucet
217        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        // Test the direct array format used by localnet
252        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        // Note: path_regex only matches the path, not query parameters
277        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        // Create client without retry to test error handling
304        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}