Skip to main content

r402_http/server/
paygate.rs

1//! Core payment gate logic for enforcing x402 payments (V2-only).
2//!
3//! The [`Paygate`] struct handles the full payment lifecycle:
4//! extracting headers, verifying with the facilitator, settling on-chain,
5//! and returning 402 responses when payment is required.
6
7use std::convert::Infallible;
8use std::sync::Arc;
9
10use axum_core::body::Body;
11use axum_core::extract::Request;
12use axum_core::response::{IntoResponse, Response};
13use http::{HeaderMap, HeaderValue, StatusCode};
14use r402::facilitator::Facilitator;
15use r402::proto;
16use r402::proto::Base64Bytes;
17use r402::proto::v2;
18use serde_json::json;
19use tower::Service;
20#[cfg(feature = "telemetry")]
21use tracing::{Instrument, instrument};
22use url::Url;
23
24use super::{PaygateError, VerificationError};
25
26/// Builder for resource information that can be used with both V1 and V2 protocols.
27#[derive(Debug, Clone)]
28pub struct ResourceInfoBuilder {
29    /// Description of the protected resource
30    pub description: String,
31    /// MIME type of the protected resource
32    pub mime_type: String,
33    /// Optional explicit URL of the protected resource
34    pub url: Option<String>,
35}
36
37impl Default for ResourceInfoBuilder {
38    fn default() -> Self {
39        Self {
40            description: String::new(),
41            mime_type: "application/json".to_string(),
42            url: None,
43        }
44    }
45}
46
47impl ResourceInfoBuilder {
48    /// Determines the resource URL (static or dynamic).
49    ///
50    /// If `url` is set, returns it directly. Otherwise, constructs a URL by combining
51    /// the base URL with the request URI's path and query.
52    ///
53    /// # Panics
54    ///
55    /// Panics if internal URL construction fails (should not happen in practice).
56    #[allow(clippy::unwrap_used)]
57    pub fn as_resource_info(&self, base_url: Option<&Url>, req: &Request) -> v2::ResourceInfo {
58        let url = self.url.clone().unwrap_or_else(|| {
59            let mut url = base_url.cloned().unwrap_or_else(|| {
60                let host = req.headers().get("host").and_then(|h| h.to_str().ok()).unwrap_or("localhost");
61                let origin = format!("http://{host}");
62                let url = Url::parse(&origin).unwrap_or_else(|_| Url::parse("http://localhost").unwrap());
63                #[cfg(feature = "telemetry")]
64                tracing::warn!(
65                    "X402Middleware base_url is not configured; using {url} as origin for resource resolution"
66                );
67                url
68            });
69            let request_uri = req.uri();
70            url.set_path(request_uri.path());
71            url.set_query(request_uri.query());
72            url.to_string()
73        });
74        v2::ResourceInfo {
75            description: self.description.clone(),
76            mime_type: self.mime_type.clone(),
77            url,
78        }
79    }
80}
81
82/// V2-only payment gate for enforcing x402 payments.
83///
84/// Handles the full payment lifecycle: header extraction, verification,
85/// settlement, and 402 response generation using the V2 wire format.
86///
87/// Construct via [`PaygateBuilder`] (obtained from [`Paygate::builder`]).
88///
89/// To add lifecycle hooks (before/after verify and settle), wrap your
90/// facilitator with [`HookedFacilitator`](r402::hooks::HookedFacilitator)
91/// before passing it to the payment gate.
92#[allow(missing_debug_implementations)]
93pub struct Paygate<TFacilitator> {
94    pub(crate) facilitator: TFacilitator,
95    pub(crate) accepts: Arc<Vec<v2::PriceTag>>,
96    pub(crate) resource: v2::ResourceInfo,
97}
98
99/// Builder for constructing a [`Paygate`] with validated configuration.
100///
101/// # Example
102///
103/// ```ignore
104/// let gate = Paygate::builder(facilitator)
105///     .accept(price_tag)
106///     .resource(resource_info)
107///     .build();
108/// ```
109#[allow(missing_debug_implementations)]
110pub struct PaygateBuilder<TFacilitator> {
111    facilitator: TFacilitator,
112    accepts: Vec<v2::PriceTag>,
113    resource: Option<v2::ResourceInfo>,
114}
115
116impl<TFacilitator> Paygate<TFacilitator> {
117    /// Returns a new builder seeded with the given facilitator.
118    pub const fn builder(facilitator: TFacilitator) -> PaygateBuilder<TFacilitator> {
119        PaygateBuilder {
120            facilitator,
121            accepts: Vec::new(),
122            resource: None,
123        }
124    }
125
126    /// Returns a reference to the accepted price tags.
127    pub fn accepts(&self) -> &[v2::PriceTag] {
128        &self.accepts
129    }
130
131    /// Returns a reference to the resource information.
132    pub const fn resource(&self) -> &v2::ResourceInfo {
133        &self.resource
134    }
135}
136
137impl<TFacilitator> PaygateBuilder<TFacilitator> {
138    /// Adds a single accepted payment option.
139    #[must_use]
140    pub fn accept(mut self, price_tag: v2::PriceTag) -> Self {
141        self.accepts.push(price_tag);
142        self
143    }
144
145    /// Adds multiple accepted payment options.
146    #[must_use]
147    pub fn accepts(mut self, price_tags: impl IntoIterator<Item = v2::PriceTag>) -> Self {
148        self.accepts.extend(price_tags);
149        self
150    }
151
152    /// Sets the resource metadata returned in 402 responses.
153    #[must_use]
154    pub fn resource(mut self, resource: v2::ResourceInfo) -> Self {
155        self.resource = Some(resource);
156        self
157    }
158
159    /// Consumes the builder and produces a configured [`Paygate`].
160    ///
161    /// Uses empty resource info if none was provided.
162    pub fn build(self) -> Paygate<TFacilitator> {
163        Paygate {
164            facilitator: self.facilitator,
165            accepts: Arc::new(self.accepts),
166            resource: self.resource.unwrap_or_else(|| v2::ResourceInfo {
167                description: String::new(),
168                mime_type: "application/json".to_owned(),
169                url: String::new(),
170            }),
171        }
172    }
173}
174
175/// The V2 payment header name.
176const PAYMENT_HEADER_NAME: &str = "Payment-Signature";
177
178/// The V2 payment payload type.
179type V2PaymentPayload = v2::PaymentPayload<v2::PaymentRequirements, serde_json::Value>;
180
181impl<TFacilitator> Paygate<TFacilitator> {
182    /// Calls the inner service with proper telemetry instrumentation.
183    async fn call_inner<
184        ReqBody,
185        ResBody,
186        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
187    >(
188        mut inner: S,
189        req: http::Request<ReqBody>,
190    ) -> Result<http::Response<ResBody>, S::Error>
191    where
192        S::Future: Send,
193    {
194        #[cfg(feature = "telemetry")]
195        {
196            inner
197                .call(req)
198                .instrument(tracing::info_span!("inner"))
199                .await
200        }
201        #[cfg(not(feature = "telemetry"))]
202        {
203            inner.call(req).await
204        }
205    }
206}
207
208impl<TFacilitator> Paygate<TFacilitator>
209where
210    TFacilitator: Facilitator + Sync,
211{
212    /// Handles an incoming request, processing payment if required.
213    ///
214    /// Returns 402 response if payment fails.
215    /// Otherwise, returns the response from the inner service.
216    ///
217    /// # Errors
218    ///
219    /// This method is infallible (`Infallible` error type).
220    #[cfg_attr(
221        feature = "telemetry",
222        instrument(name = "x402.handle_request", skip_all)
223    )]
224    pub async fn handle_request<
225        ReqBody,
226        ResBody,
227        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
228    >(
229        self,
230        inner: S,
231        req: http::Request<ReqBody>,
232    ) -> Result<Response, Infallible>
233    where
234        S::Response: IntoResponse,
235        S::Error: IntoResponse,
236        S::Future: Send,
237    {
238        match self.handle_request_fallible(inner, req).await {
239            Ok(response) => Ok(response),
240            Err(err) => Ok(error_into_response(err, &self.accepts, &self.resource)),
241        }
242    }
243
244    /// Enriches price tags with facilitator capabilities (e.g., fee payer address).
245    pub async fn enrich_accepts(&mut self) {
246        let capabilities = self.facilitator.supported().await.unwrap_or_default();
247
248        let accepts = (*self.accepts)
249            .clone()
250            .into_iter()
251            .map(|mut pt| {
252                pt.enrich(&capabilities);
253                pt
254            })
255            .collect::<Vec<_>>();
256        self.accepts = Arc::new(accepts);
257    }
258
259    /// Handles an incoming request, returning errors as `PaygateError`.
260    ///
261    /// This is the fallible version of `handle_request` that returns an actual error
262    /// instead of turning it into 402 Payment Required response.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`PaygateError`] if payment processing fails.
267    pub async fn handle_request_fallible<
268        ReqBody,
269        ResBody,
270        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
271    >(
272        &self,
273        inner: S,
274        req: http::Request<ReqBody>,
275    ) -> Result<Response, PaygateError>
276    where
277        S::Response: IntoResponse,
278        S::Error: IntoResponse,
279        S::Future: Send,
280    {
281        let header = extract_payment_header(req.headers(), PAYMENT_HEADER_NAME).ok_or(
282            VerificationError::PaymentHeaderRequired(PAYMENT_HEADER_NAME),
283        )?;
284        let payment_payload = extract_payment_payload::<V2PaymentPayload>(header)
285            .ok_or(VerificationError::InvalidPaymentHeader)?;
286
287        let verify_request = make_verify_request(payment_payload, &self.accepts)?;
288
289        // Step 1: Verify the payment before executing the request.
290        let verify_response = self
291            .facilitator
292            .verify(verify_request.clone())
293            .await
294            .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
295
296        validate_verify_response(verify_response)?;
297
298        // Step 2: Execute the inner handler.
299        let response = match Self::call_inner(inner, req).await {
300            Ok(response) => response,
301            Err(err) => return Ok(err.into_response()),
302        };
303
304        // Step 3: Skip settlement if the handler returned an error response.
305        if response.status().is_client_error() || response.status().is_server_error() {
306            return Ok(response.into_response());
307        }
308
309        // Step 4: Settle the payment on-chain.
310        let settlement = self
311            .facilitator
312            .settle(verify_request.into())
313            .await
314            .map_err(|e| PaygateError::Settlement(format!("{e}")))?;
315
316        if let proto::SettleResponse::Error {
317            reason, message, ..
318        } = &settlement
319        {
320            let detail = message.as_deref().unwrap_or(reason.as_str());
321            return Err(PaygateError::Settlement(detail.to_owned()));
322        }
323
324        let header_value = settlement_to_header(settlement)?;
325
326        let mut res = response;
327        res.headers_mut().insert("Payment-Response", header_value);
328        Ok(res.into_response())
329    }
330}
331
332/// Extracts the payment header value from the header map.
333fn extract_payment_header<'a>(header_map: &'a HeaderMap, header_name: &'a str) -> Option<&'a [u8]> {
334    header_map.get(header_name).map(HeaderValue::as_bytes)
335}
336
337/// Extracts and deserializes the payment payload from base64-encoded header bytes.
338fn extract_payment_payload<T>(header_bytes: &[u8]) -> Option<T>
339where
340    T: serde::de::DeserializeOwned,
341{
342    let base64 = Base64Bytes::from(header_bytes).decode().ok()?;
343    let value = serde_json::from_slice(base64.as_ref()).ok()?;
344    Some(value)
345}
346
347/// Converts a [`proto::SettleResponse`] into an HTTP header value.
348///
349/// Returns an error response if conversion fails.
350#[allow(clippy::needless_pass_by_value)] // settlement is consumed by serialization
351fn settlement_to_header(settlement: proto::SettleResponse) -> Result<HeaderValue, PaygateError> {
352    let json =
353        serde_json::to_vec(&settlement).map_err(|err| PaygateError::Settlement(err.to_string()))?;
354    let payment_header = Base64Bytes::encode(json);
355    HeaderValue::from_bytes(payment_header.as_ref())
356        .map_err(|err| PaygateError::Settlement(err.to_string()))
357}
358
359/// Constructs a V2 verify request from the payment payload and accepted requirements.
360fn make_verify_request(
361    payment_payload: V2PaymentPayload,
362    accepts: &[v2::PriceTag],
363) -> Result<proto::VerifyRequest, VerificationError> {
364    let accepted = &payment_payload.accepted;
365
366    let selected = accepts
367        .iter()
368        .find(|price_tag| **price_tag == *accepted)
369        .ok_or(VerificationError::NoPaymentMatching)?;
370
371    let verify_request = v2::VerifyRequest {
372        x402_version: v2::V2,
373        payment_payload,
374        payment_requirements: selected.requirements.clone(),
375    };
376
377    let json = serde_json::to_value(&verify_request)
378        .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
379
380    Ok(proto::VerifyRequest::from(json))
381}
382
383/// Validates a verify response, rejecting invalid or unknown variants.
384fn validate_verify_response(
385    verify_response: proto::VerifyResponse,
386) -> Result<(), VerificationError> {
387    match verify_response {
388        proto::VerifyResponse::Valid { .. } => Ok(()),
389        proto::VerifyResponse::Invalid { reason, .. } => {
390            Err(VerificationError::VerificationFailed(reason))
391        }
392        _ => Err(VerificationError::VerificationFailed(
393            "unknown verify response variant".into(),
394        )),
395    }
396}
397
398/// Converts a [`PaygateError`] into a V2 402 Payment Required HTTP response.
399fn error_into_response(
400    err: PaygateError,
401    accepts: &[v2::PriceTag],
402    resource: &v2::ResourceInfo,
403) -> Response {
404    match err {
405        PaygateError::Verification(err) => {
406            let payment_required_response = v2::PaymentRequired {
407                error: Some(err.to_string()),
408                accepts: accepts.iter().map(|pt| pt.requirements.clone()).collect(),
409                x402_version: v2::V2,
410                resource: resource.clone(),
411                extensions: None,
412            };
413            let payment_required_bytes =
414                serde_json::to_vec(&payment_required_response).expect("serialization failed");
415            let payment_required_header = Base64Bytes::encode(&payment_required_bytes);
416            let header_value = HeaderValue::from_bytes(payment_required_header.as_ref())
417                .expect("Failed to create header value");
418
419            Response::builder()
420                .status(StatusCode::PAYMENT_REQUIRED)
421                .header("Payment-Required", header_value)
422                .header("Content-Type", "application/json")
423                .body(Body::from(payment_required_bytes))
424                .expect("Fail to construct response")
425        }
426        PaygateError::Settlement(ref err) => {
427            #[cfg(feature = "telemetry")]
428            tracing::error!(details = %err, "Settlement failed");
429            let body = Body::from(
430                json!({ "error": "Settlement failed", "details": err.clone() }).to_string(),
431            );
432            Response::builder()
433                .status(StatusCode::PAYMENT_REQUIRED)
434                .header("Content-Type", "application/json")
435                .body(body)
436                .expect("Fail to construct response")
437        }
438    }
439}