use crate::config::AptosConfig;
use crate::error::{AptosError, AptosResult};
use crate::retry::{RetryConfig, RetryExecutor};
use crate::types::AccountAddress;
use reqwest::Client;
use serde::Deserialize;
use std::sync::Arc;
use url::Url;
const MAX_FAUCET_RESPONSE_SIZE: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct FaucetClient {
faucet_url: Url,
client: Client,
retry_config: Arc<RetryConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub(crate) enum FaucetResponse {
Direct(Vec<String>),
Object { txn_hashes: Vec<String> },
}
impl FaucetResponse {
pub(super) fn into_hashes(self) -> Vec<String> {
match self {
FaucetResponse::Direct(hashes) => hashes,
FaucetResponse::Object { txn_hashes } => txn_hashes,
}
}
}
impl FaucetClient {
pub fn new(config: &AptosConfig) -> AptosResult<Self> {
let faucet_url = config
.faucet_url()
.cloned()
.ok_or_else(|| AptosError::Config("faucet URL not configured".into()))?;
let pool = config.pool_config();
let mut builder = Client::builder()
.timeout(config.timeout)
.pool_max_idle_per_host(pool.max_idle_per_host.unwrap_or(usize::MAX))
.pool_idle_timeout(pool.idle_timeout)
.tcp_nodelay(pool.tcp_nodelay);
if let Some(keepalive) = pool.tcp_keepalive {
builder = builder.tcp_keepalive(keepalive);
}
let client = builder.build().map_err(AptosError::Http)?;
let retry_config = Arc::new(config.retry_config().clone());
Ok(Self {
faucet_url,
client,
retry_config,
})
}
pub fn with_url(url: &str) -> AptosResult<Self> {
let faucet_url = Url::parse(url)?;
crate::config::validate_url_scheme(&faucet_url)?;
let client = Client::new();
Ok(Self {
faucet_url,
client,
retry_config: Arc::new(RetryConfig::default()),
})
}
pub async fn fund(&self, address: AccountAddress, amount: u64) -> AptosResult<Vec<String>> {
let url = self.build_url(&format!("mint?address={address}&amount={amount}"))?;
let client = self.client.clone();
let retry_config = self.retry_config.clone();
let executor = RetryExecutor::from_shared(retry_config);
executor
.execute(|| {
let client = client.clone();
let url = url.clone();
async move {
let response = client.post(url).send().await?;
if response.status().is_success() {
let bytes = crate::config::read_response_bounded(
response,
MAX_FAUCET_RESPONSE_SIZE,
)
.await?;
let faucet_response: FaucetResponse = serde_json::from_slice(&bytes)?;
Ok(faucet_response.into_hashes())
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
Err(AptosError::api(status.as_u16(), body))
}
}
})
.await
}
pub async fn fund_default(&self, address: AccountAddress) -> AptosResult<Vec<String>> {
self.fund(address, 100_000_000).await }
#[cfg(feature = "ed25519")]
pub async fn create_and_fund(
&self,
amount: u64,
) -> AptosResult<(crate::account::Ed25519Account, Vec<String>)> {
let account = crate::account::Ed25519Account::generate();
let txn_hashes = self.fund(account.address(), amount).await?;
Ok((account, txn_hashes))
}
fn build_url(&self, path: &str) -> AptosResult<Url> {
let base = self.faucet_url.as_str().trim_end_matches('/');
Url::parse(&format!("{base}/{path}")).map_err(AptosError::Url)
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path_regex},
};
#[test]
fn test_faucet_client_creation() {
let client = FaucetClient::new(&AptosConfig::testnet());
assert!(client.is_ok());
let client = FaucetClient::new(&AptosConfig::mainnet());
assert!(client.is_err());
}
fn create_mock_faucet_client(server: &MockServer) -> FaucetClient {
let config = AptosConfig::custom(&server.uri())
.unwrap()
.with_faucet_url(&server.uri())
.unwrap()
.without_retry();
FaucetClient::new(&config).unwrap()
}
#[tokio::test]
async fn test_fund_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"txn_hashes": ["0xabc123", "0xdef456"]
})))
.expect(1)
.mount(&server)
.await;
let client = create_mock_faucet_client(&server);
let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], "0xabc123");
}
#[tokio::test]
async fn test_fund_success_direct_array() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!(["0xhash123", "0xhash456"])),
)
.expect(1)
.mount(&server)
.await;
let client = create_mock_faucet_client(&server);
let result = client.fund(AccountAddress::ONE, 100_000_000).await.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], "0xhash123");
assert_eq!(result[1], "0xhash456");
}
#[tokio::test]
async fn test_fund_default() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"txn_hashes": ["0xfund123"]
})))
.expect(1)
.mount(&server)
.await;
let client = create_mock_faucet_client(&server);
let result = client.fund_default(AccountAddress::ONE).await.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn test_fund_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(ResponseTemplate::new(500).set_body_string("Faucet error"))
.expect(1)
.mount(&server)
.await;
let config = AptosConfig::custom(&server.uri())
.unwrap()
.with_faucet_url(&server.uri())
.unwrap()
.without_retry();
let client = FaucetClient::new(&config).unwrap();
let result = client.fund(AccountAddress::ONE, 100_000_000).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_fund_rate_limited() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(ResponseTemplate::new(429).set_body_string("Too many requests"))
.expect(1)
.mount(&server)
.await;
let config = AptosConfig::custom(&server.uri())
.unwrap()
.with_faucet_url(&server.uri())
.unwrap()
.without_retry();
let client = FaucetClient::new(&config).unwrap();
let result = client.fund(AccountAddress::ONE, 100_000_000).await;
assert!(result.is_err());
}
#[cfg(feature = "ed25519")]
#[tokio::test]
async fn test_create_and_fund() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"^/mint$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"txn_hashes": ["0xnewaccount"]
})))
.expect(1)
.mount(&server)
.await;
let client = create_mock_faucet_client(&server);
let (account, txn_hashes) = client.create_and_fund(100_000_000).await.unwrap();
assert!(!account.address().is_zero());
assert_eq!(txn_hashes.len(), 1);
}
#[test]
fn test_build_url() {
let config = AptosConfig::testnet();
let client = FaucetClient::new(&config).unwrap();
let url = client.build_url("mint?address=0x1&amount=1000").unwrap();
assert!(url.as_str().contains("mint"));
assert!(url.as_str().contains("address=0x1"));
}
}