use crate::constants::SCHEME_NAME;
use crate::error::X402Error;
use crate::payment::{PaymentPayload, PaymentRequiredBody};
use crate::response::SettleResponse;
use crate::scheme::SchemeClient;
use base64::Engine;
pub struct X402Client<S: SchemeClient> {
http: reqwest::Client,
scheme: S,
}
impl<S: SchemeClient> X402Client<S> {
pub fn new(scheme: S) -> Self {
Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build HTTP client"),
scheme,
}
}
pub fn with_http_client(scheme: S, http: reqwest::Client) -> Self {
Self { http, scheme }
}
pub async fn fetch(
&self,
url: &str,
method: reqwest::Method,
) -> Result<(reqwest::Response, Option<SettleResponse>), X402Error> {
self.fetch_with_body(url, method, None).await
}
pub async fn fetch_with_body(
&self,
url: &str,
method: reqwest::Method,
body: Option<Vec<u8>>,
) -> Result<(reqwest::Response, Option<SettleResponse>), X402Error> {
let mut req = self.http.request(method.clone(), url);
if let Some(ref b) = body {
req = req.body(b.clone());
}
let resp = req
.send()
.await
.map_err(|e| X402Error::HttpError(format!("request failed: {e}")))?;
if resp.status().as_u16() != 402 {
return Ok((resp, None));
}
let body_402: PaymentRequiredBody = resp
.json()
.await
.map_err(|e| X402Error::HttpError(format!("failed to parse 402 body: {e}")))?;
let requirements = body_402
.accepts
.iter()
.find(|r| r.scheme == SCHEME_NAME)
.ok_or_else(|| {
X402Error::UnsupportedScheme(format!(
"no supported scheme found in {:?}",
body_402
.accepts
.iter()
.map(|r| &r.scheme)
.collect::<Vec<_>>()
))
})?;
let payload = self
.scheme
.create_payment_payload(body_402.x402_version, requirements)
.await?;
let encoded = encode_payment(&payload)?;
let mut req = self.http.request(method, url);
req = req.header("PAYMENT-SIGNATURE", &encoded);
if let Some(b) = body {
req = req.body(b);
}
let resp = req
.send()
.await
.map_err(|e| X402Error::HttpError(format!("paid request failed: {e}")))?;
let settle = resp
.headers()
.get("payment-response")
.and_then(|v| v.to_str().ok())
.and_then(|s| {
let payload_part = s.split('.').next().unwrap_or(s);
base64::engine::general_purpose::STANDARD
.decode(payload_part)
.ok()
.and_then(|bytes| serde_json::from_slice::<SettleResponse>(&bytes).ok())
});
Ok((resp, settle))
}
}
pub fn encode_payment(payload: &PaymentPayload) -> Result<String, X402Error> {
let json = serde_json::to_vec(payload)?;
Ok(base64::engine::general_purpose::STANDARD.encode(&json))
}
pub fn decode_payment(encoded: &str) -> Result<PaymentPayload, X402Error> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| X402Error::InvalidPayment(format!("invalid base64: {e}")))?;
serde_json::from_slice(&bytes)
.map_err(|e| X402Error::InvalidPayment(format!("invalid JSON: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payment::TempoPaymentData;
use alloy::primitives::{Address, FixedBytes};
fn sample_payload() -> PaymentPayload {
PaymentPayload {
x402_version: 1,
payload: TempoPaymentData {
from: Address::ZERO,
to: Address::ZERO,
value: "1000".to_string(),
token: Address::ZERO,
valid_after: 0,
valid_before: u64::MAX,
nonce: FixedBytes::ZERO,
signature: "0xdead".to_string(),
},
}
}
#[test]
fn test_encode_payment_roundtrip() {
let payload = sample_payload();
let encoded = encode_payment(&payload).unwrap();
let decoded = decode_payment(&encoded).unwrap();
assert_eq!(decoded.x402_version, payload.x402_version);
assert_eq!(decoded.payload.from, payload.payload.from);
assert_eq!(decoded.payload.value, payload.payload.value);
assert_eq!(decoded.payload.signature, payload.payload.signature);
}
#[test]
fn test_encode_produces_valid_base64() {
let payload = sample_payload();
let encoded = encode_payment(&payload).unwrap();
let result = base64::engine::general_purpose::STANDARD.decode(&encoded);
assert!(result.is_ok());
let json: Result<serde_json::Value, _> = serde_json::from_slice(&result.unwrap());
assert!(json.is_ok());
}
}