use base64::Engine;
use rust_x402::{
client::X402Client,
types::{PaymentRequirements, PaymentRequirementsResponse, SettleResponse},
wallet::WalletFactory,
Result,
};
use std::time::{Duration, Instant};
use tokio::time::sleep;
const BACKEND_URL: &str = "http://localhost:4021";
const FACILITATOR_URL: &str = "http://localhost:4020";
const TEST_PRIVATE_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const TEST_PAYER_ADDRESS: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
async fn wait_for_service(url: &str, max_wait: Duration) -> Result<()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.tcp_keepalive(Duration::from_secs(30))
.build()
.map_err(|e| {
rust_x402::X402Error::network_error(format!("Failed to create client: {}", e))
})?;
let start = Instant::now();
let mut last_error = None;
let mut last_status = None;
let mut attempt = 0;
while start.elapsed() < max_wait {
attempt += 1;
match client.get(url).send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
return Ok(());
}
last_status = Some(status.as_u16());
if status == 503 && attempt < 10 {
sleep(Duration::from_millis(2000)).await;
continue;
}
}
Err(e) => {
last_error = Some(e.to_string());
if attempt < 10 {
sleep(Duration::from_millis(2000)).await;
continue;
}
}
}
sleep(Duration::from_millis(1000)).await;
}
Err(rust_x402::X402Error::network_error(format!(
"Service at {} did not become ready within {:?} (attempted {} times). Last status: {:?}, Last error: {:?}",
url, max_wait, attempt, last_status, last_error
)))
}
fn create_test_payment_payload(
requirements: &PaymentRequirements,
) -> Result<rust_x402::types::PaymentPayload> {
let wallet =
WalletFactory::from_private_key(TEST_PRIVATE_KEY, &requirements.network).map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!("Failed to create wallet: {}", e))
})?;
wallet
.create_signed_payment_payload(requirements, TEST_PAYER_ADDRESS)
.map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Failed to create payment payload: {}",
e
))
})
}
async fn assert_payment_required(
response: reqwest::Response,
endpoint: &str,
) -> Result<PaymentRequirementsResponse> {
let status = response.status();
if status != 402 {
return Err(rust_x402::X402Error::invalid_payment_requirements(format!(
"Expected 402 Payment Required for endpoint {}, but got status {}",
endpoint, status
)));
}
let payment_req: PaymentRequirementsResponse = response.json().await.map_err(|e| {
rust_x402::X402Error::invalid_payment_requirements(format!(
"Failed to parse payment requirements JSON for endpoint {}: {}",
endpoint, e
))
})?;
if payment_req.x402_version != 1 {
return Err(rust_x402::X402Error::invalid_payment_requirements(format!(
"Invalid x402 version: expected 1, got {}",
payment_req.x402_version
)));
}
if payment_req.accepts.is_empty() {
return Err(rust_x402::X402Error::invalid_payment_requirements(
"Payment requirements must include at least one accept option".to_string(),
));
}
let req = &payment_req.accepts[0];
if req.scheme != "exact" {
return Err(rust_x402::X402Error::invalid_payment_requirements(format!(
"Invalid payment scheme: expected 'exact', got '{}'",
req.scheme
)));
}
if req.network != "base-sepolia" {
return Err(rust_x402::X402Error::invalid_payment_requirements(format!(
"Invalid network: expected 'base-sepolia', got '{}'",
req.network
)));
}
if req.pay_to.is_empty() {
return Err(rust_x402::X402Error::invalid_payment_requirements(
"Payment requirements must include pay_to address".to_string(),
));
}
if req.max_amount_required.is_empty() {
return Err(rust_x402::X402Error::invalid_payment_requirements(
"Payment requirements must include max_amount_required".to_string(),
));
}
Ok(payment_req)
}
async fn assert_payment_success(
response: reqwest::Response,
endpoint: &str,
) -> Result<serde_json::Value> {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(rust_x402::X402Error::invalid_payment_payload(format!(
"Expected successful response (2xx) for endpoint {} after payment, but got status {} with body: {}",
endpoint, status, body
)));
}
let settlement_header = response
.headers()
.get("X-PAYMENT-RESPONSE")
.ok_or_else(|| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Missing X-PAYMENT-RESPONSE header in response for endpoint {}",
endpoint
))
})?
.clone();
let settlement_b64 = settlement_header.to_str().map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Invalid X-PAYMENT-RESPONSE header encoding for endpoint {}: {}",
endpoint, e
))
})?;
let settlement_bytes = base64::engine::general_purpose::STANDARD
.decode(settlement_b64)
.map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Failed to decode X-PAYMENT-RESPONSE header for endpoint {}: {}",
endpoint, e
))
})?;
let settlement: SettleResponse = serde_json::from_slice(&settlement_bytes).map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Failed to parse settlement response JSON for endpoint {}: {}",
endpoint, e
))
})?;
if !settlement.success {
return Err(rust_x402::X402Error::invalid_payment_payload(format!(
"Payment settlement failed for endpoint {}: {}",
endpoint,
settlement
.error_reason
.as_deref()
.unwrap_or("Unknown error")
)));
}
if settlement.transaction.is_empty() {
return Err(rust_x402::X402Error::invalid_payment_payload(format!(
"Settlement response missing transaction hash for endpoint {}",
endpoint
)));
}
if !settlement.transaction.starts_with("0x") || settlement.transaction.len() != 66 {
return Err(rust_x402::X402Error::invalid_payment_payload(format!(
"Invalid transaction hash format for endpoint {}: expected 0x-prefixed 64-char hex string, got: {}",
endpoint, settlement.transaction
)));
}
let data: serde_json::Value = response.json().await.map_err(|e| {
rust_x402::X402Error::invalid_payment_payload(format!(
"Failed to parse response JSON for endpoint {}: {}",
endpoint, e
))
})?;
Ok(data)
}
#[tokio::test]
#[ignore] async fn test_services_health() {
sleep(Duration::from_secs(2)).await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Client creation should succeed");
let mut response = None;
for attempt in 0..10 {
match client.get(&format!("{}/health", BACKEND_URL)).send().await {
Ok(resp) if resp.status().is_success() => {
response = Some(resp);
break;
}
Ok(resp) => {
response = Some(resp);
if attempt < 9 {
sleep(Duration::from_millis(500)).await;
}
}
Err(e) => {
if attempt < 9 {
eprintln!("Attempt {} failed: {}, retrying...", attempt + 1, e);
sleep(Duration::from_millis(500)).await;
} else {
panic!("Failed to connect to backend after 10 attempts: {}", e);
}
}
}
}
let response = response.expect("Health check request should succeed");
if response.status() != 200 {
panic!(
"Backend health endpoint should return 200, but got {} with body: {:?}",
response.status(),
response.text().await.ok()
);
}
let health: serde_json::Value = response
.json()
.await
.expect("Health response should be JSON");
if health["status"] != "healthy" {
panic!(
"Backend health status should be 'healthy', but got: {:?}",
health["status"]
);
}
let mut facilitator_response = None;
for attempt in 0..10 {
match client
.get(&format!("{}/health", FACILITATOR_URL))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
facilitator_response = Some(resp);
break;
}
Ok(resp) => {
facilitator_response = Some(resp);
if attempt < 9 {
sleep(Duration::from_millis(500)).await;
}
}
Err(e) => {
if attempt < 9 {
eprintln!("Attempt {} failed: {}, retrying...", attempt + 1, e);
sleep(Duration::from_millis(500)).await;
} else {
panic!("Failed to connect to facilitator after 10 attempts: {}", e);
}
}
}
}
let response = facilitator_response.expect("Facilitator health check request should succeed");
if response.status() != 200 {
panic!(
"Facilitator health endpoint should return 200, but got {} with body: {:?}",
response.status(),
response.text().await.ok()
);
}
let health: serde_json::Value = response
.json()
.await
.expect("Health response should be JSON");
if health["status"] != "healthy" {
panic!(
"Facilitator health status should be 'healthy', but got: {:?}",
health["status"]
);
}
}
#[tokio::test]
#[ignore] async fn test_payment_required_response() {
wait_for_service(&format!("{}/health", BACKEND_URL), Duration::from_secs(60))
.await
.expect("Backend should be healthy");
let client = X402Client::new().expect("Client creation should succeed");
let response = client
.get(&format!("{}/test", BACKEND_URL))
.send()
.await
.expect("Request should succeed");
let payment_req_response = assert_payment_required(response, "/test")
.await
.expect("Should receive valid payment requirements");
let req = &payment_req_response.accepts[0];
assert_eq!(
req.scheme, "exact",
"Payment scheme should be 'exact', got '{}'",
req.scheme
);
assert_eq!(
req.network, "base-sepolia",
"Network should be 'base-sepolia', got '{}'",
req.network
);
}
#[tokio::test]
#[ignore] async fn test_end_to_end_payment_flow() {
wait_for_service(&format!("{}/health", BACKEND_URL), Duration::from_secs(60))
.await
.expect("Backend should be healthy");
wait_for_service(
&format!("{}/health", FACILITATOR_URL),
Duration::from_secs(60),
)
.await
.expect("Facilitator should be healthy");
let client = X402Client::new().expect("Client creation should succeed");
let endpoint = "/test";
let response = client
.get(&format!("{}{}", BACKEND_URL, endpoint))
.send()
.await
.expect("Initial request should succeed");
if response.status() != 402 {
panic!(
"First request to {} should return 402 Payment Required, but got status {}",
endpoint,
response.status()
);
}
let payment_req_response = assert_payment_required(response, endpoint)
.await
.expect("Should receive valid payment requirements");
let payment_req = &payment_req_response.accepts[0];
let payment_payload =
create_test_payment_payload(payment_req).expect("Payment payload creation should succeed");
let final_response = client
.get(&format!("{}{}", BACKEND_URL, endpoint))
.payment(&payment_payload)
.expect("Payment header creation should succeed")
.send()
.await
.expect("Payment request should succeed");
let data = assert_payment_success(final_response, endpoint)
.await
.expect("Payment should be successful");
if data["message"] != "Payment successful! This is a protected endpoint." {
panic!(
"Response message should match expected value. Got: {:?}",
data["message"]
);
}
assert!(
data["timestamp"].is_string(),
"Response should include timestamp field"
);
assert!(
data["data"].is_object(),
"Response should include data object"
);
}
#[tokio::test]
#[ignore] async fn test_health_endpoint_no_payment() {
wait_for_service(&format!("{}/health", BACKEND_URL), Duration::from_secs(60))
.await
.expect("Backend should be healthy");
let client = X402Client::new().expect("Client creation should succeed");
let response = client
.get(&format!("{}/health", BACKEND_URL))
.send()
.await
.expect("Health check request should succeed");
if response.status() != 200 {
panic!(
"Health endpoint should not require payment (expected 200), but got status {}",
response.status()
);
}
let health: serde_json::Value = response
.json()
.await
.expect("Health response should be JSON");
if health["status"] != "healthy" {
panic!(
"Health status should be 'healthy', but got: {:?}",
health["status"]
);
}
}
#[tokio::test]
#[ignore] async fn test_multiple_endpoints() {
wait_for_service(&format!("{}/health", BACKEND_URL), Duration::from_secs(60))
.await
.expect("Backend should be healthy");
let client = X402Client::new().expect("Client creation should succeed");
let endpoints = vec!["/joke", "/api/data", "/test"];
for endpoint in endpoints {
let response = client
.get(&format!("{}{}", BACKEND_URL, endpoint))
.send()
.await
.unwrap_or_else(|_| panic!("Request to {} should succeed", endpoint));
if response.status() != 402 {
panic!(
"Endpoint {} should return 402 Payment Required, but got status {}",
endpoint,
response.status()
);
}
let payment_req_response = assert_payment_required(response, endpoint)
.await
.unwrap_or_else(|_| {
panic!("Should receive valid payment requirements for {}", endpoint)
});
let payment_req = &payment_req_response.accepts[0];
let payment_payload = create_test_payment_payload(payment_req)
.unwrap_or_else(|_| panic!("Payment payload creation should succeed for {}", endpoint));
let final_response = client
.get(&format!("{}{}", BACKEND_URL, endpoint))
.payment(&payment_payload)
.unwrap_or_else(|_| panic!("Payment header creation should succeed for {}", endpoint))
.send()
.await
.unwrap_or_else(|_| panic!("Payment request should succeed for {}", endpoint));
assert_payment_success(final_response, endpoint)
.await
.unwrap_or_else(|_| panic!("Payment should be successful for {}", endpoint));
}
}
#[tokio::test]
#[ignore] async fn test_facilitator_supported_endpoint() {
wait_for_service(
&format!("{}/health", FACILITATOR_URL),
Duration::from_secs(60),
)
.await
.expect("Facilitator should be healthy");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Client creation should succeed");
let response = client
.get(&format!("{}/supported", FACILITATOR_URL))
.send()
.await
.expect("Supported endpoint request should succeed");
if response.status() != 200 {
panic!(
"Facilitator supported endpoint should return 200, but got status {}",
response.status()
);
}
let supported: serde_json::Value = response
.json()
.await
.expect("Supported response should be JSON");
if !supported["kinds"].is_array() {
panic!(
"Supported response should include 'kinds' array, but got: {:?}",
supported["kinds"]
);
}
let kinds = supported["kinds"].as_array().unwrap();
if kinds.is_empty() {
panic!("Facilitator should support at least one payment kind");
}
let has_base_sepolia = kinds
.iter()
.any(|k| k["network"].as_str() == Some("base-sepolia"));
if !has_base_sepolia {
panic!("Facilitator should support base-sepolia network");
}
}