use std::sync::Arc;
use axum_core::body::Body;
use axum_core::extract::Request;
use axum_core::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderValue, StatusCode};
use r402::facilitator::Facilitator;
use r402::proto;
use r402::proto::Base64Bytes;
use r402::proto::v2;
use serde_json::json;
use tower::Service;
#[cfg(feature = "telemetry")]
use tracing::{Instrument, instrument};
use url::Url;
const PAYMENT_HEADER: &str = "Payment-Signature";
#[derive(Debug, thiserror::Error)]
pub enum VerificationError {
#[error("Payment-Signature header is required")]
PaymentHeaderMissing,
#[error("Invalid or malformed payment header")]
InvalidPaymentHeader,
#[error("Unable to find matching payment requirements")]
NoPaymentMatching,
#[error("Verification failed: {0}")]
VerificationFailed(String),
}
#[derive(Debug, thiserror::Error)]
pub enum PaygateError {
#[error(transparent)]
Verification(#[from] VerificationError),
#[error("Settlement failed: {0}")]
Settlement(String),
}
type PaymentPayload = v2::PaymentPayload<v2::PaymentRequirements, serde_json::Value>;
#[derive(Debug, Clone)]
pub struct ResourceTemplate {
pub description: String,
pub mime_type: String,
pub url: Option<String>,
}
impl Default for ResourceTemplate {
fn default() -> Self {
Self {
description: String::new(),
mime_type: "application/json".to_owned(),
url: None,
}
}
}
impl ResourceTemplate {
#[allow(clippy::unwrap_used, reason = "fallback URL is a hardcoded constant")]
pub fn resolve(&self, base_url: Option<&Url>, req: &Request) -> v2::ResourceInfo {
let url = self.url.clone().unwrap_or_else(|| {
let mut url = base_url.cloned().unwrap_or_else(|| {
let host = req
.headers()
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let origin = format!("http://{host}");
let url =
Url::parse(&origin).unwrap_or_else(|_| Url::parse("http://localhost").unwrap());
#[cfg(feature = "telemetry")]
tracing::warn!(
"X402Middleware base_url is not configured; \
using {url} as origin for resource resolution"
);
url
});
url.set_path(req.uri().path());
url.set_query(req.uri().query());
url.to_string()
});
v2::ResourceInfo {
description: self.description.clone(),
mime_type: self.mime_type.clone(),
url,
}
}
}
#[allow(
missing_debug_implementations,
reason = "generic facilitator may not impl Debug"
)]
pub struct PaygateBuilder<TFacilitator> {
facilitator: TFacilitator,
accepts: Vec<v2::PriceTag>,
resource: Option<v2::ResourceInfo>,
}
impl<TFacilitator> PaygateBuilder<TFacilitator> {
#[must_use]
pub fn accept(mut self, price_tag: v2::PriceTag) -> Self {
self.accepts.push(price_tag);
self
}
#[must_use]
pub fn accepts(mut self, price_tags: impl IntoIterator<Item = v2::PriceTag>) -> Self {
self.accepts.extend(price_tags);
self
}
#[must_use]
pub fn resource(mut self, resource: v2::ResourceInfo) -> Self {
self.resource = Some(resource);
self
}
pub fn build(self) -> Paygate<TFacilitator> {
Paygate {
facilitator: self.facilitator,
accepts: self.accepts.into(),
resource: self.resource.unwrap_or_else(|| v2::ResourceInfo {
description: String::new(),
mime_type: "application/json".to_owned(),
url: String::new(),
}),
}
}
}
#[allow(
missing_debug_implementations,
reason = "generic facilitator may not impl Debug"
)]
pub struct Paygate<TFacilitator> {
pub(crate) facilitator: TFacilitator,
pub(crate) accepts: Arc<[v2::PriceTag]>,
pub(crate) resource: v2::ResourceInfo,
}
impl<TFacilitator> Paygate<TFacilitator> {
pub const fn builder(facilitator: TFacilitator) -> PaygateBuilder<TFacilitator> {
PaygateBuilder {
facilitator,
accepts: Vec::new(),
resource: None,
}
}
pub const fn facilitator(&self) -> &TFacilitator {
&self.facilitator
}
pub fn accepts(&self) -> &[v2::PriceTag] {
&self.accepts
}
pub const fn resource(&self) -> &v2::ResourceInfo {
&self.resource
}
#[must_use]
#[allow(
clippy::expect_used,
reason = "infallible JSON/HTTP construction; panic indicates a bug"
)]
pub fn error_response(&self, err: PaygateError) -> Response {
match err {
PaygateError::Verification(ve) => {
let payment_required = v2::PaymentRequired {
error: Some(ve.to_string()),
accepts: self
.accepts
.iter()
.map(|pt| pt.requirements.clone())
.collect(),
x402_version: v2::V2,
resource: self.resource.clone(),
extensions: None,
};
let body_bytes =
serde_json::to_vec(&payment_required).expect("serialization failed");
let header_value =
HeaderValue::from_bytes(Base64Bytes::encode(&body_bytes).as_ref())
.expect("invalid header value");
Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.header("Payment-Required", header_value)
.header("Content-Type", "application/json")
.body(Body::from(body_bytes))
.expect("failed to construct response")
}
PaygateError::Settlement(ref detail) => {
#[cfg(feature = "telemetry")]
tracing::error!(details = %detail, "Settlement failed");
let body = json!({ "error": "Settlement failed", "details": detail }).to_string();
Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.header("Content-Type", "application/json")
.body(Body::from(body))
.expect("failed to construct response")
}
}
}
}
impl<TFacilitator> Paygate<TFacilitator>
where
TFacilitator: Facilitator + Sync,
{
pub async fn enrich_accepts(&mut self) {
let capabilities = self.facilitator.supported().await.unwrap_or_default();
let accepts: Vec<_> = self
.accepts
.iter()
.cloned()
.map(|mut pt| {
pt.enrich(&capabilities);
pt
})
.collect();
self.accepts = accepts.into();
}
#[cfg_attr(feature = "telemetry", instrument(name = "x402.verify_only", skip_all))]
pub async fn verify_only(&self, headers: &HeaderMap) -> Result<VerifiedPayment, PaygateError> {
let header_bytes = headers
.get(PAYMENT_HEADER)
.map(HeaderValue::as_bytes)
.ok_or(VerificationError::PaymentHeaderMissing)?;
let payload: PaymentPayload =
decode_payment_payload(header_bytes).ok_or(VerificationError::InvalidPaymentHeader)?;
let verify_request = build_verify_request(payload, &self.accepts)?;
let verify_response = self
.facilitator
.verify(verify_request.clone())
.await
.map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
if let proto::VerifyResponse::Invalid { reason, .. } = verify_response {
return Err(VerificationError::VerificationFailed(reason).into());
}
Ok(VerifiedPayment {
settle_request: verify_request.into(),
})
}
#[cfg_attr(
feature = "telemetry",
instrument(name = "x402.handle_request", skip_all)
)]
pub async fn handle_request<
ReqBody,
ResBody,
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
>(
&self,
inner: S,
req: http::Request<ReqBody>,
) -> Result<Response, PaygateError>
where
S::Response: IntoResponse,
S::Error: IntoResponse,
S::Future: Send,
{
let verified = self.verify_only(req.headers()).await?;
let response = match call_inner(inner, req).await {
Ok(r) => r,
Err(err) => return Ok(err.into_response()),
};
if response.status().is_client_error() || response.status().is_server_error() {
return Ok(response.into_response());
}
let settlement = verified.settle(&self.facilitator).await?;
let header_value = settlement_to_header(&settlement)?;
let mut res = response;
res.headers_mut().insert("Payment-Response", header_value);
Ok(res.into_response())
}
}
impl<TFacilitator> Paygate<TFacilitator>
where
TFacilitator: Facilitator + Clone + Send + Sync + 'static,
{
#[cfg_attr(
feature = "telemetry",
instrument(name = "x402.handle_request_concurrent", skip_all)
)]
pub async fn handle_request_concurrent<
ReqBody,
ResBody,
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
>(
&self,
inner: S,
req: http::Request<ReqBody>,
) -> Result<Response, PaygateError>
where
S::Response: IntoResponse,
S::Error: IntoResponse,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
let verified = self.verify_only(req.headers()).await?;
let facilitator = self.facilitator.clone();
let settle_handle = tokio::spawn(async move { verified.settle(&facilitator).await });
let response = match call_inner(inner, req).await {
Ok(r) => r,
Err(err) => {
drop(settle_handle);
return Ok(err.into_response());
}
};
if response.status().is_client_error() || response.status().is_server_error() {
drop(settle_handle);
return Ok(response.into_response());
}
let settlement = settle_handle
.await
.map_err(|e| PaygateError::Settlement(format!("settle task panicked: {e}")))??;
let header_value = settlement_to_header(&settlement)?;
let mut res = response;
res.headers_mut().insert("Payment-Response", header_value);
Ok(res.into_response())
}
#[cfg_attr(
feature = "telemetry",
instrument(name = "x402.handle_request_background", skip_all)
)]
pub async fn handle_request_background<
ReqBody,
ResBody,
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
>(
&self,
inner: S,
req: http::Request<ReqBody>,
) -> Result<Response, PaygateError>
where
S::Response: IntoResponse,
S::Error: IntoResponse,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
let verified = self.verify_only(req.headers()).await?;
let facilitator = self.facilitator.clone();
tokio::spawn(async move {
if let Err(e) = verified.settle(&facilitator).await {
#[cfg(feature = "telemetry")]
tracing::error!(error = %e, "background settlement failed");
#[cfg(not(feature = "telemetry"))]
let _ = e;
}
});
match call_inner(inner, req).await {
Ok(r) => Ok(r.into_response()),
Err(err) => Ok(err.into_response()),
}
}
}
#[derive(Debug)]
pub struct VerifiedPayment {
settle_request: proto::SettleRequest,
}
impl VerifiedPayment {
pub async fn settle<F: Facilitator>(
self,
facilitator: &F,
) -> Result<proto::SettleResponse, PaygateError> {
let settlement = facilitator
.settle(self.settle_request)
.await
.map_err(|e| PaygateError::Settlement(format!("{e}")))?;
if let proto::SettleResponse::Error {
reason, message, ..
} = &settlement
{
let detail = message.as_deref().unwrap_or(reason.as_str());
return Err(PaygateError::Settlement(detail.to_owned()));
}
Ok(settlement)
}
#[must_use]
pub const fn settle_request(&self) -> &proto::SettleRequest {
&self.settle_request
}
}
pub fn settlement_to_header(
settlement: &proto::SettleResponse,
) -> Result<HeaderValue, PaygateError> {
let encoded = settlement
.encode_base64()
.ok_or_else(|| PaygateError::Settlement("cannot encode error settlement".to_owned()))?;
HeaderValue::from_bytes(encoded.as_ref()).map_err(|e| PaygateError::Settlement(e.to_string()))
}
async fn call_inner<
ReqBody,
ResBody,
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
>(
mut inner: S,
req: http::Request<ReqBody>,
) -> Result<http::Response<ResBody>, S::Error>
where
S::Future: Send,
{
#[cfg(feature = "telemetry")]
{
inner
.call(req)
.instrument(tracing::info_span!("inner"))
.await
}
#[cfg(not(feature = "telemetry"))]
{
inner.call(req).await
}
}
fn decode_payment_payload<T: serde::de::DeserializeOwned>(header_bytes: &[u8]) -> Option<T> {
let decoded = Base64Bytes::from(header_bytes).decode().ok()?;
serde_json::from_slice(decoded.as_ref()).ok()
}
fn build_verify_request(
payload: PaymentPayload,
accepts: &[v2::PriceTag],
) -> Result<proto::VerifyRequest, VerificationError> {
let selected = accepts
.iter()
.find(|pt| **pt == payload.accepted)
.ok_or(VerificationError::NoPaymentMatching)?;
let verify = v2::VerifyRequest {
x402_version: v2::V2,
payment_payload: payload,
payment_requirements: selected.requirements.clone(),
};
let json = serde_json::to_value(&verify)
.map_err(|e| VerificationError::VerificationFailed(format!("{e}")))?;
Ok(proto::VerifyRequest::from(json))
}