Skip to main content

x402/client/
http_client.rs

1use crate::constants::SCHEME_NAME;
2use crate::error::X402Error;
3use crate::payment::{PaymentPayload, PaymentRequiredBody};
4use crate::response::SettleResponse;
5use crate::scheme::SchemeClient;
6use base64::Engine;
7
8/// HTTP client that automatically handles 402 payment responses.
9///
10/// Wraps `reqwest::Client`. On a 402 response, it parses the payment
11/// requirements, signs an EIP-712 authorization via the provided
12/// [`SchemeClient`], and retries the request with a `PAYMENT-SIGNATURE` header.
13pub struct X402Client<S: SchemeClient> {
14    http: reqwest::Client,
15    scheme: S,
16}
17
18impl<S: SchemeClient> X402Client<S> {
19    pub fn new(scheme: S) -> Self {
20        Self {
21            http: reqwest::Client::builder()
22                .timeout(std::time::Duration::from_secs(30))
23                .redirect(reqwest::redirect::Policy::none())
24                .build()
25                .expect("failed to build HTTP client"),
26            scheme,
27        }
28    }
29
30    /// Create a client with a custom reqwest::Client.
31    pub fn with_http_client(scheme: S, http: reqwest::Client) -> Self {
32        Self { http, scheme }
33    }
34
35    /// Make a request, automatically handling 402 payment responses.
36    /// Returns the final response and optional settlement info.
37    pub async fn fetch(
38        &self,
39        url: &str,
40        method: reqwest::Method,
41    ) -> Result<(reqwest::Response, Option<SettleResponse>), X402Error> {
42        self.fetch_with_body(url, method, None).await
43    }
44
45    /// Make a request with an optional body, automatically handling 402 payment responses.
46    pub async fn fetch_with_body(
47        &self,
48        url: &str,
49        method: reqwest::Method,
50        body: Option<Vec<u8>>,
51    ) -> Result<(reqwest::Response, Option<SettleResponse>), X402Error> {
52        // First request
53        let mut req = self.http.request(method.clone(), url);
54        if let Some(ref b) = body {
55            req = req.body(b.clone());
56        }
57
58        let resp = req
59            .send()
60            .await
61            .map_err(|e| X402Error::HttpError(format!("request failed: {e}")))?;
62
63        if resp.status().as_u16() != 402 {
64            return Ok((resp, None));
65        }
66
67        // Parse 402 body
68        let body_402: PaymentRequiredBody = resp
69            .json()
70            .await
71            .map_err(|e| X402Error::HttpError(format!("failed to parse 402 body: {e}")))?;
72
73        // Find a matching scheme
74        let requirements = body_402
75            .accepts
76            .iter()
77            .find(|r| r.scheme == SCHEME_NAME)
78            .ok_or_else(|| {
79                X402Error::UnsupportedScheme(format!(
80                    "no supported scheme found in {:?}",
81                    body_402
82                        .accepts
83                        .iter()
84                        .map(|r| &r.scheme)
85                        .collect::<Vec<_>>()
86                ))
87            })?;
88
89        // Create signed payment payload
90        let payload = self
91            .scheme
92            .create_payment_payload(body_402.x402_version, requirements)
93            .await?;
94
95        // Encode and retry
96        let encoded = encode_payment(&payload)?;
97
98        let mut req = self.http.request(method, url);
99        req = req.header("PAYMENT-SIGNATURE", &encoded);
100        if let Some(b) = body {
101            req = req.body(b);
102        }
103
104        let resp = req
105            .send()
106            .await
107            .map_err(|e| X402Error::HttpError(format!("paid request failed: {e}")))?;
108
109        // Extract settlement info from headers.
110        // Format: "base64payload" or "base64payload.hmac_hex" (HMAC-signed).
111        let settle = resp
112            .headers()
113            .get("payment-response")
114            .and_then(|v| v.to_str().ok())
115            .and_then(|s| {
116                // Strip HMAC suffix if present (format: "base64.hmac_hex")
117                let payload_part = s.split('.').next().unwrap_or(s);
118                base64::engine::general_purpose::STANDARD
119                    .decode(payload_part)
120                    .ok()
121                    .and_then(|bytes| serde_json::from_slice::<SettleResponse>(&bytes).ok())
122            });
123
124        Ok((resp, settle))
125    }
126}
127
128/// Base64-encode a payment payload for the PAYMENT-SIGNATURE header.
129pub fn encode_payment(payload: &PaymentPayload) -> Result<String, X402Error> {
130    let json = serde_json::to_vec(payload)?;
131    Ok(base64::engine::general_purpose::STANDARD.encode(&json))
132}
133
134/// Decode a payment payload from the PAYMENT-SIGNATURE header.
135pub fn decode_payment(encoded: &str) -> Result<PaymentPayload, X402Error> {
136    let bytes = base64::engine::general_purpose::STANDARD
137        .decode(encoded)
138        .map_err(|e| X402Error::InvalidPayment(format!("invalid base64: {e}")))?;
139    serde_json::from_slice(&bytes)
140        .map_err(|e| X402Error::InvalidPayment(format!("invalid JSON: {e}")))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::payment::TempoPaymentData;
147    use alloy::primitives::{Address, FixedBytes};
148
149    fn sample_payload() -> PaymentPayload {
150        PaymentPayload {
151            x402_version: 1,
152            payload: TempoPaymentData {
153                from: Address::ZERO,
154                to: Address::ZERO,
155                value: "1000".to_string(),
156                token: Address::ZERO,
157                valid_after: 0,
158                valid_before: u64::MAX,
159                nonce: FixedBytes::ZERO,
160                signature: "0xdead".to_string(),
161            },
162        }
163    }
164
165    #[test]
166    fn test_encode_payment_roundtrip() {
167        let payload = sample_payload();
168        let encoded = encode_payment(&payload).unwrap();
169        let decoded = decode_payment(&encoded).unwrap();
170
171        assert_eq!(decoded.x402_version, payload.x402_version);
172        assert_eq!(decoded.payload.from, payload.payload.from);
173        assert_eq!(decoded.payload.value, payload.payload.value);
174        assert_eq!(decoded.payload.signature, payload.payload.signature);
175    }
176
177    #[test]
178    fn test_encode_produces_valid_base64() {
179        let payload = sample_payload();
180        let encoded = encode_payment(&payload).unwrap();
181
182        // Should decode without error
183        let result = base64::engine::general_purpose::STANDARD.decode(&encoded);
184        assert!(result.is_ok());
185
186        // Should be valid JSON
187        let json: Result<serde_json::Value, _> = serde_json::from_slice(&result.unwrap());
188        assert!(json.is_ok());
189    }
190}