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) settle_before_execution: bool,
96    pub(crate) accepts: Arc<Vec<v2::PriceTag>>,
97    pub(crate) resource: v2::ResourceInfo,
98}
99
100/// Builder for constructing a [`Paygate`] with validated configuration.
101///
102/// # Example
103///
104/// ```ignore
105/// let gate = Paygate::builder(facilitator)
106///     .accept(price_tag)
107///     .resource(resource_info)
108///     .settle_before_execution(true)
109///     .build();
110/// ```
111#[allow(missing_debug_implementations)]
112pub struct PaygateBuilder<TFacilitator> {
113    facilitator: TFacilitator,
114    settle_before_execution: bool,
115    accepts: Vec<v2::PriceTag>,
116    resource: Option<v2::ResourceInfo>,
117}
118
119impl<TFacilitator> Paygate<TFacilitator> {
120    /// Returns a new builder seeded with the given facilitator.
121    pub const fn builder(facilitator: TFacilitator) -> PaygateBuilder<TFacilitator> {
122        PaygateBuilder {
123            facilitator,
124            settle_before_execution: false,
125            accepts: Vec::new(),
126            resource: None,
127        }
128    }
129
130    /// Returns a reference to the accepted price tags.
131    pub fn accepts(&self) -> &[v2::PriceTag] {
132        &self.accepts
133    }
134
135    /// Returns a reference to the resource information.
136    pub const fn resource(&self) -> &v2::ResourceInfo {
137        &self.resource
138    }
139}
140
141impl<TFacilitator> PaygateBuilder<TFacilitator> {
142    /// Adds a single accepted payment option.
143    #[must_use]
144    pub fn accept(mut self, price_tag: v2::PriceTag) -> Self {
145        self.accepts.push(price_tag);
146        self
147    }
148
149    /// Adds multiple accepted payment options.
150    #[must_use]
151    pub fn accepts(mut self, price_tags: impl IntoIterator<Item = v2::PriceTag>) -> Self {
152        self.accepts.extend(price_tags);
153        self
154    }
155
156    /// Sets the resource metadata returned in 402 responses.
157    #[must_use]
158    pub fn resource(mut self, resource: v2::ResourceInfo) -> Self {
159        self.resource = Some(resource);
160        self
161    }
162
163    /// Enables or disables settlement before request execution.
164    ///
165    /// Default is `false` (settle after execution).
166    #[must_use]
167    pub const fn settle_before_execution(mut self, enabled: bool) -> Self {
168        self.settle_before_execution = enabled;
169        self
170    }
171
172    /// Consumes the builder and produces a configured [`Paygate`].
173    ///
174    /// Uses empty resource info if none was provided.
175    pub fn build(self) -> Paygate<TFacilitator> {
176        Paygate {
177            facilitator: self.facilitator,
178            settle_before_execution: self.settle_before_execution,
179            accepts: Arc::new(self.accepts),
180            resource: self.resource.unwrap_or_else(|| v2::ResourceInfo {
181                description: String::new(),
182                mime_type: "application/json".to_owned(),
183                url: String::new(),
184            }),
185        }
186    }
187}
188
189/// The V2 payment header name.
190const PAYMENT_HEADER_NAME: &str = "Payment-Signature";
191
192/// The V2 payment payload type.
193type V2PaymentPayload = v2::PaymentPayload<v2::PaymentRequirements, serde_json::Value>;
194
195impl<TFacilitator> Paygate<TFacilitator> {
196    /// Calls the inner service with proper telemetry instrumentation.
197    async fn call_inner<
198        ReqBody,
199        ResBody,
200        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
201    >(
202        mut inner: S,
203        req: http::Request<ReqBody>,
204    ) -> Result<http::Response<ResBody>, S::Error>
205    where
206        S::Future: Send,
207    {
208        #[cfg(feature = "telemetry")]
209        {
210            inner
211                .call(req)
212                .instrument(tracing::info_span!("inner"))
213                .await
214        }
215        #[cfg(not(feature = "telemetry"))]
216        {
217            inner.call(req).await
218        }
219    }
220}
221
222impl<TFacilitator> Paygate<TFacilitator>
223where
224    TFacilitator: Facilitator + Sync,
225{
226    /// Handles an incoming request, processing payment if required.
227    ///
228    /// Returns 402 response if payment fails.
229    /// Otherwise, returns the response from the inner service.
230    ///
231    /// # Errors
232    ///
233    /// This method is infallible (`Infallible` error type).
234    #[cfg_attr(
235        feature = "telemetry",
236        instrument(name = "x402.handle_request", skip_all)
237    )]
238    pub async fn handle_request<
239        ReqBody,
240        ResBody,
241        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
242    >(
243        self,
244        inner: S,
245        req: http::Request<ReqBody>,
246    ) -> Result<Response, Infallible>
247    where
248        S::Response: IntoResponse,
249        S::Error: IntoResponse,
250        S::Future: Send,
251    {
252        match self.handle_request_fallible(inner, req).await {
253            Ok(response) => Ok(response),
254            Err(err) => Ok(error_into_response(err, &self.accepts, &self.resource)),
255        }
256    }
257
258    /// Enriches price tags with facilitator capabilities (e.g., fee payer address).
259    pub async fn enrich_accepts(&mut self) {
260        let capabilities = self.facilitator.supported().await.unwrap_or_default();
261
262        let accepts = (*self.accepts)
263            .clone()
264            .into_iter()
265            .map(|mut pt| {
266                pt.enrich(&capabilities);
267                pt
268            })
269            .collect::<Vec<_>>();
270        self.accepts = Arc::new(accepts);
271    }
272
273    /// Handles an incoming request, returning errors as `PaygateError`.
274    ///
275    /// This is the fallible version of `handle_request` that returns an actual error
276    /// instead of turning it into 402 Payment Required response.
277    ///
278    /// # Errors
279    ///
280    /// Returns [`PaygateError`] if payment processing fails.
281    pub async fn handle_request_fallible<
282        ReqBody,
283        ResBody,
284        S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
285    >(
286        &self,
287        inner: S,
288        req: http::Request<ReqBody>,
289    ) -> Result<Response, PaygateError>
290    where
291        S::Response: IntoResponse,
292        S::Error: IntoResponse,
293        S::Future: Send,
294    {
295        let header = extract_payment_header(req.headers(), PAYMENT_HEADER_NAME).ok_or(
296            VerificationError::PaymentHeaderRequired(PAYMENT_HEADER_NAME),
297        )?;
298        let payment_payload = extract_payment_payload::<V2PaymentPayload>(header)
299            .ok_or(VerificationError::InvalidPaymentHeader)?;
300
301        let verify_request = make_verify_request(payment_payload, &self.accepts)?;
302
303        if self.settle_before_execution {
304            #[cfg(feature = "telemetry")]
305            tracing::debug!("Settling payment before request execution");
306
307            let settlement = self
308                .facilitator
309                .settle(verify_request.into())
310                .await
311                .map_err(|e| PaygateError::Settlement(format!("{e}")))?;
312
313            if let proto::SettleResponse::Error {
314                reason, message, ..
315            } = &settlement
316            {
317                let detail = message.as_deref().unwrap_or(reason.as_str());
318                return Err(PaygateError::Settlement(detail.to_owned()));
319            }
320
321            let header_value = settlement_to_header(settlement)?;
322
323            let response = match Self::call_inner(inner, req).await {
324                Ok(response) => response,
325                Err(err) => return Ok(err.into_response()),
326            };
327
328            let mut res = response;
329            res.headers_mut().insert("Payment-Response", header_value);
330            Ok(res.into_response())
331        } else {
332            #[cfg(feature = "telemetry")]
333            tracing::debug!("Settling payment after request execution");
334
335            let verify_response = self
336                .facilitator
337                .verify(verify_request.clone())
338                .await
339                .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
340
341            validate_verify_response(verify_response)?;
342
343            let response = match Self::call_inner(inner, req).await {
344                Ok(response) => response,
345                Err(err) => return Ok(err.into_response()),
346            };
347
348            if response.status().is_client_error() || response.status().is_server_error() {
349                return Ok(response.into_response());
350            }
351
352            let settlement = self
353                .facilitator
354                .settle(verify_request.into())
355                .await
356                .map_err(|e| PaygateError::Settlement(format!("{e}")))?;
357
358            if let proto::SettleResponse::Error {
359                reason, message, ..
360            } = &settlement
361            {
362                let detail = message.as_deref().unwrap_or(reason.as_str());
363                return Err(PaygateError::Settlement(detail.to_owned()));
364            }
365
366            let header_value = settlement_to_header(settlement)?;
367
368            let mut res = response;
369            res.headers_mut().insert("Payment-Response", header_value);
370            Ok(res.into_response())
371        }
372    }
373}
374
375/// Extracts the payment header value from the header map.
376fn extract_payment_header<'a>(header_map: &'a HeaderMap, header_name: &'a str) -> Option<&'a [u8]> {
377    header_map.get(header_name).map(HeaderValue::as_bytes)
378}
379
380/// Extracts and deserializes the payment payload from base64-encoded header bytes.
381fn extract_payment_payload<T>(header_bytes: &[u8]) -> Option<T>
382where
383    T: serde::de::DeserializeOwned,
384{
385    let base64 = Base64Bytes::from(header_bytes).decode().ok()?;
386    let value = serde_json::from_slice(base64.as_ref()).ok()?;
387    Some(value)
388}
389
390/// Converts a [`proto::SettleResponse`] into an HTTP header value.
391///
392/// Returns an error response if conversion fails.
393#[allow(clippy::needless_pass_by_value)] // settlement is consumed by serialization
394fn settlement_to_header(settlement: proto::SettleResponse) -> Result<HeaderValue, PaygateError> {
395    let json =
396        serde_json::to_vec(&settlement).map_err(|err| PaygateError::Settlement(err.to_string()))?;
397    let payment_header = Base64Bytes::encode(json);
398    HeaderValue::from_bytes(payment_header.as_ref())
399        .map_err(|err| PaygateError::Settlement(err.to_string()))
400}
401
402/// Constructs a V2 verify request from the payment payload and accepted requirements.
403fn make_verify_request(
404    payment_payload: V2PaymentPayload,
405    accepts: &[v2::PriceTag],
406) -> Result<proto::VerifyRequest, VerificationError> {
407    let accepted = &payment_payload.accepted;
408
409    let selected = accepts
410        .iter()
411        .find(|price_tag| **price_tag == *accepted)
412        .ok_or(VerificationError::NoPaymentMatching)?;
413
414    let verify_request = v2::VerifyRequest {
415        x402_version: v2::V2,
416        payment_payload,
417        payment_requirements: selected.requirements.clone(),
418    };
419
420    let json = serde_json::to_value(&verify_request)
421        .map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
422
423    Ok(proto::VerifyRequest::from(json))
424}
425
426/// Validates a verify response, rejecting invalid or unknown variants.
427fn validate_verify_response(
428    verify_response: proto::VerifyResponse,
429) -> Result<(), VerificationError> {
430    match verify_response {
431        proto::VerifyResponse::Valid { .. } => Ok(()),
432        proto::VerifyResponse::Invalid { reason, .. } => {
433            Err(VerificationError::VerificationFailed(reason))
434        }
435        _ => Err(VerificationError::VerificationFailed(
436            "unknown verify response variant".into(),
437        )),
438    }
439}
440
441/// Converts a [`PaygateError`] into a V2 402 Payment Required HTTP response.
442fn error_into_response(
443    err: PaygateError,
444    accepts: &[v2::PriceTag],
445    resource: &v2::ResourceInfo,
446) -> Response {
447    match err {
448        PaygateError::Verification(err) => {
449            let payment_required_response = v2::PaymentRequired {
450                error: Some(err.to_string()),
451                accepts: accepts.iter().map(|pt| pt.requirements.clone()).collect(),
452                x402_version: v2::V2,
453                resource: resource.clone(),
454                extensions: None,
455            };
456            let payment_required_bytes =
457                serde_json::to_vec(&payment_required_response).expect("serialization failed");
458            let payment_required_header = Base64Bytes::encode(&payment_required_bytes);
459            let header_value = HeaderValue::from_bytes(payment_required_header.as_ref())
460                .expect("Failed to create header value");
461
462            Response::builder()
463                .status(StatusCode::PAYMENT_REQUIRED)
464                .header("Payment-Required", header_value)
465                .body(Body::empty())
466                .expect("Fail to construct response")
467        }
468        PaygateError::Settlement(ref err) => {
469            #[cfg(feature = "telemetry")]
470            tracing::error!(details = %err, "Settlement failed");
471            let body = Body::from(
472                json!({ "error": "Settlement failed" }).to_string(),
473            );
474            Response::builder()
475                .status(StatusCode::INTERNAL_SERVER_ERROR)
476                .header("Content-Type", "application/json")
477                .body(body)
478                .expect("Fail to construct response")
479        }
480    }
481}