1use std::sync::Arc;
20
21use axum_core::body::Body;
22use axum_core::extract::Request;
23use axum_core::response::{IntoResponse, Response};
24use http::header::ACCESS_CONTROL_EXPOSE_HEADERS;
25use http::{HeaderMap, HeaderValue, StatusCode};
26use r402_core::error::ErrorReason;
27use r402_core::facilitator::{DynFacilitator, Facilitator};
28use r402_core::resource_server::{CancelReason, ResourceServer, ResourceServerHooks};
29use r402_core::wire;
30use r402_core::wire::Base64Bytes;
31use serde_json::json;
32use tower::Service;
33#[cfg(feature = "telemetry")]
34use tracing::{Instrument, instrument};
35use url::Url;
36
37use super::hooks::DynPaygateHooks;
38use super::tracker::{BackgroundSettlementTracker, SettlementInFlightGuard};
39
40const PAYMENT_HEADER: &str = "Payment-Signature";
41
42pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
44
45pub fn ensure_expose_headers(headers: &mut HeaderMap) {
48 let x402 = HeaderValue::from_static(X402_EXPOSED_HEADERS);
49 match headers.get(ACCESS_CONTROL_EXPOSE_HEADERS) {
50 None => {
51 let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
52 }
53 Some(existing) => {
54 let Ok(existing_str) = existing.to_str() else {
55 let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
56 return;
57 };
58 if existing_str.contains("Payment-Required")
59 && existing_str.contains("Payment-Response")
60 {
61 return;
62 }
63 let merged = format!("{existing_str}, {X402_EXPOSED_HEADERS}");
64 if let Ok(value) = HeaderValue::from_str(&merged) {
65 let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, value);
66 }
67 }
68 }
69}
70
71#[must_use]
75pub const fn reason_to_status(reason: &ErrorReason) -> StatusCode {
76 match reason {
77 ErrorReason::Permit2AllowanceRequired => StatusCode::PRECONDITION_FAILED,
78 _ => StatusCode::PAYMENT_REQUIRED,
79 }
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum PaygateError {
85 #[error("Payment-Signature header is required")]
87 PaymentHeaderMissing,
88 #[error("Invalid or malformed payment header")]
90 InvalidPaymentHeader,
91 #[error("Unable to find matching payment requirements")]
93 NoPaymentMatching,
94 #[error("Verification failed: {0}")]
96 VerificationFailed(String),
97 #[error("settlement failed: {}", settlement_failure_summary(.0))]
103 Settlement(Box<wire::SettleResponse>),
104 #[error("settlement aborted: {0}")]
108 SettlementAborted(String),
109}
110
111#[allow(
112 clippy::missing_const_for_fn,
113 reason = "const fn would prevent matching on `Box` indirection"
114)]
115fn settlement_failure_summary(resp: &wire::SettleResponse) -> String {
116 match resp {
117 wire::SettleResponse::Failure {
118 reason,
119 message,
120 network,
121 ..
122 } => format!(
123 "{} ({}){}",
124 reason,
125 network,
126 message
127 .as_ref()
128 .map(|m| format!(": {m}"))
129 .unwrap_or_default(),
130 ),
131 wire::SettleResponse::Success { .. } => "success returned via error path".to_owned(),
132 _ => "unknown settlement variant".to_owned(),
135 }
136}
137
138type PaymentPayload = wire::PaymentPayload<wire::PaymentRequirements, serde_json::Value>;
139
140#[derive(Debug, Clone)]
145pub struct ResourceTemplate {
146 pub description: String,
148 pub mime_type: String,
150 pub url: Option<String>,
152}
153
154impl Default for ResourceTemplate {
155 fn default() -> Self {
156 Self {
157 description: String::new(),
158 mime_type: "application/json".to_owned(),
159 url: None,
160 }
161 }
162}
163
164impl ResourceTemplate {
165 #[allow(clippy::unwrap_used, reason = "fallback URL is a hardcoded constant")]
176 pub fn resolve(&self, base_url: Option<&Url>, req: &Request) -> wire::ResourceInfo {
177 let url = self.url.clone().unwrap_or_else(|| {
178 let mut url = base_url.cloned().unwrap_or_else(|| {
179 let host = req
180 .headers()
181 .get("host")
182 .and_then(|h| h.to_str().ok())
183 .unwrap_or("localhost");
184 let origin = format!("http://{host}");
185 let url =
186 Url::parse(&origin).unwrap_or_else(|_| Url::parse("http://localhost").unwrap());
187 #[cfg(feature = "telemetry")]
188 tracing::warn!(
189 "X402Middleware base_url is not configured; \
190 using {url} as origin for resource resolution"
191 );
192 url
193 });
194 url.set_path(req.uri().path());
195 url.set_query(req.uri().query());
196 url.to_string()
197 });
198 let mut info = wire::ResourceInfo::new(url);
199 if !self.description.is_empty() {
200 info = info.with_description(self.description.clone());
201 }
202 if !self.mime_type.is_empty() {
203 info = info.with_mime_type(self.mime_type.clone());
204 }
205 info
206 }
207}
208
209#[allow(
220 missing_debug_implementations,
221 reason = "ResourceServer contains dyn facilitator handles"
222)]
223pub struct PaygateBuilder {
224 server: ResourceServer,
225 accepts: Vec<wire::PriceTag>,
226 resource: Option<wire::ResourceInfo>,
227 hooks: Option<Arc<dyn DynPaygateHooks>>,
228 settlement_tracker: Option<BackgroundSettlementTracker>,
229}
230
231impl PaygateBuilder {
232 #[must_use]
234 pub fn accept(mut self, price_tag: wire::PriceTag) -> Self {
235 self.accepts.push(price_tag);
236 self
237 }
238
239 #[must_use]
241 pub fn accepts(mut self, price_tags: impl IntoIterator<Item = wire::PriceTag>) -> Self {
242 self.accepts.extend(price_tags);
243 self
244 }
245
246 #[must_use]
248 pub fn resource(mut self, resource: wire::ResourceInfo) -> Self {
249 self.resource = Some(resource);
250 self
251 }
252
253 #[must_use]
258 pub fn hooks<H>(mut self, hooks: H) -> Self
259 where
260 H: super::hooks::PaygateHooks + 'static,
261 {
262 self.hooks = Some(Arc::new(hooks));
263 self
264 }
265
266 #[must_use]
272 pub fn hooks_dyn(mut self, hooks: Arc<dyn DynPaygateHooks>) -> Self {
273 self.hooks = Some(hooks);
274 self
275 }
276
277 #[must_use]
279 pub fn with_resource_hook(mut self, hook: impl ResourceServerHooks + 'static) -> Self {
280 self.server.add_hook(hook);
281 self
282 }
283
284 #[must_use]
289 pub fn with_settlement_tracker(mut self, tracker: BackgroundSettlementTracker) -> Self {
290 self.settlement_tracker = Some(tracker);
291 self
292 }
293
294 #[must_use]
298 pub fn build(self) -> Paygate {
299 Paygate {
300 server: self.server,
301 accepts: self.accepts.into(),
302 resource: self
303 .resource
304 .unwrap_or_else(|| wire::ResourceInfo::new("").with_mime_type("application/json")),
305 hooks: self.hooks,
306 settlement_tracker: self.settlement_tracker,
307 }
308 }
309}
310
311#[allow(
322 missing_debug_implementations,
323 reason = "ResourceServer contains dyn facilitator handles"
324)]
325pub struct Paygate {
326 pub(crate) server: ResourceServer,
327 pub(crate) accepts: Arc<[wire::PriceTag]>,
328 pub(crate) resource: wire::ResourceInfo,
329 pub(crate) hooks: Option<Arc<dyn DynPaygateHooks>>,
330 pub(crate) settlement_tracker: Option<BackgroundSettlementTracker>,
339}
340
341impl Paygate {
342 pub fn builder(facilitator: impl Facilitator + 'static) -> PaygateBuilder {
344 PaygateBuilder {
345 server: ResourceServer::new(Arc::new(facilitator)),
346 accepts: Vec::new(),
347 resource: None,
348 hooks: None,
349 settlement_tracker: None,
350 }
351 }
352
353 #[must_use]
355 pub fn builder_from_dyn(facilitator: Arc<dyn DynFacilitator>) -> PaygateBuilder {
356 PaygateBuilder {
357 server: ResourceServer::from_dyn(facilitator),
358 accepts: Vec::new(),
359 resource: None,
360 hooks: None,
361 settlement_tracker: None,
362 }
363 }
364
365 #[must_use]
367 pub fn builder_from_server(server: ResourceServer) -> PaygateBuilder {
368 PaygateBuilder {
369 server,
370 accepts: Vec::new(),
371 resource: None,
372 hooks: None,
373 settlement_tracker: None,
374 }
375 }
376
377 #[must_use]
379 pub const fn resource_server(&self) -> &ResourceServer {
380 &self.server
381 }
382
383 #[must_use]
385 pub fn facilitator(&self) -> Arc<dyn DynFacilitator> {
386 self.server.facilitator()
387 }
388
389 #[must_use]
391 pub fn accepts(&self) -> &[wire::PriceTag] {
392 &self.accepts
393 }
394
395 #[must_use]
413 pub const fn settlement_tracker(&self) -> Option<&BackgroundSettlementTracker> {
414 self.settlement_tracker.as_ref()
415 }
416
417 #[must_use]
419 pub const fn resource(&self) -> &wire::ResourceInfo {
420 &self.resource
421 }
422
423 #[must_use]
429 pub fn hooks(&self) -> Option<&Arc<dyn DynPaygateHooks>> {
430 self.hooks.as_ref()
431 }
432
433 #[must_use]
443 #[allow(
444 clippy::expect_used,
445 reason = "infallible JSON/HTTP construction; panic indicates a bug"
446 )]
447 pub fn error_response(&self, err: PaygateError) -> Response {
448 match err {
449 PaygateError::PaymentHeaderMissing
450 | PaygateError::InvalidPaymentHeader
451 | PaygateError::NoPaymentMatching
452 | PaygateError::VerificationFailed(_) => {
453 let (status, payment_required) = {
454 let status = inferred_status(&err);
455 let payment_required = wire::PaymentRequired::new(self.resource.clone())
456 .with_error(err.to_string())
457 .with_accepts(
458 self.accepts
459 .iter()
460 .map(|pt| pt.requirements.clone())
461 .collect(),
462 );
463 (status, payment_required)
464 };
465 let body_bytes =
466 serde_json::to_vec(&payment_required).expect("serialization failed");
467 let header_value =
468 HeaderValue::from_bytes(Base64Bytes::encode(&body_bytes).as_ref())
469 .expect("invalid header value");
470
471 let mut response = Response::builder()
472 .status(status)
473 .header("Payment-Required", header_value)
474 .header("Content-Type", "application/json")
475 .body(Body::from(body_bytes))
476 .expect("failed to construct response");
477 ensure_expose_headers(response.headers_mut());
480 response
481 }
482 PaygateError::Settlement(failure) => {
483 #[cfg(feature = "telemetry")]
484 tracing::error!(failure = ?failure, "Settlement failed");
485 let body_bytes = serde_json::to_vec(&*failure).expect("serialization failed");
486 let header_value = failure
487 .encode_base64_any()
488 .and_then(|b64| HeaderValue::from_bytes(b64.as_ref()).ok());
489
490 let mut builder = Response::builder()
491 .status(StatusCode::PAYMENT_REQUIRED)
492 .header("Content-Type", "application/json");
493 if let Some(header_value) = header_value {
494 builder = builder.header("Payment-Response", header_value);
495 }
496 let mut response = builder
497 .body(Body::from(body_bytes))
498 .expect("failed to construct response");
499 ensure_expose_headers(response.headers_mut());
500 response
501 }
502 PaygateError::SettlementAborted(ref detail) => {
503 #[cfg(feature = "telemetry")]
504 tracing::error!(details = %detail, "Settlement aborted");
505 let body = json!({
506 "error": "settlement aborted",
507 "details": detail,
508 })
509 .to_string();
510
511 let mut response = Response::builder()
512 .status(StatusCode::PAYMENT_REQUIRED)
513 .header("Content-Type", "application/json")
514 .body(Body::from(body))
515 .expect("failed to construct response");
516 ensure_expose_headers(response.headers_mut());
517 response
518 }
519 }
520 }
521}
522
523impl Paygate {
524 pub async fn enrich_accepts(&mut self) {
526 let facilitator = self.facilitator();
527 let capabilities = Facilitator::supported(&facilitator)
528 .await
529 .unwrap_or_default();
530 let accepts: Vec<_> = self
531 .accepts
532 .iter()
533 .cloned()
534 .map(|mut pt| {
535 pt.enrich(&capabilities);
536 pt
537 })
538 .collect();
539 self.accepts = accepts.into();
540 }
541
542 #[cfg_attr(feature = "telemetry", instrument(name = "x402.verify_only", skip_all))]
553 pub async fn verify_only(&self, headers: &HeaderMap) -> Result<VerifiedPayment, PaygateError> {
554 let header_bytes = headers
555 .get(PAYMENT_HEADER)
556 .map(HeaderValue::as_bytes)
557 .ok_or(PaygateError::PaymentHeaderMissing)?;
558
559 let payload: PaymentPayload =
560 decode_payment_payload(header_bytes).ok_or(PaygateError::InvalidPaymentHeader)?;
561
562 let requirements = match_requirements(&payload, &self.accepts)?;
563 let outcome = self
564 .server
565 .verify_payment(&payload, &requirements)
566 .await
567 .map_err(|e| PaygateError::VerificationFailed(format!("{e}")))?;
568
569 if let wire::VerifyResponse::Invalid { reason, .. } = &outcome.response {
570 return Err(PaygateError::VerificationFailed(reason.to_string()));
571 }
572
573 let settle_request = build_settle_request(&payload, &requirements).map_err(|e| {
575 PaygateError::VerificationFailed(format!("settle request build failed: {e}"))
576 })?;
577
578 Ok(VerifiedPayment {
579 settle_request,
580 payload,
581 requirements,
582 server: self.server.clone(),
583 skip_handler: outcome.skip_handler,
584 })
585 }
586
587 #[cfg_attr(
599 feature = "telemetry",
600 instrument(name = "x402.handle_request", skip_all)
601 )]
602 pub async fn handle_request<
603 ReqBody,
604 ResBody,
605 S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
606 >(
607 &self,
608 inner: S,
609 req: http::Request<ReqBody>,
610 ) -> Result<Response, PaygateError>
611 where
612 S::Response: IntoResponse,
613 S::Error: IntoResponse,
614 S::Future: Send,
615 {
616 let verified = self.verify_only(req.headers()).await?;
617 let cancel = verified.cancellation_guard();
618
619 if let Some(directive) = verified.skip_handler.clone() {
621 let settlement = verified.settle_with_override(None).await?;
622 return skip_handler_response(&directive, &settlement);
623 }
624
625 let response = match call_inner(inner, req).await {
626 Ok(r) => r,
627 Err(err) => {
628 cancel
629 .cancel(
630 CancelReason::HandlerThrew,
631 Some("inner service error"),
632 None,
633 )
634 .await;
635 return Ok(err.into_response());
636 }
637 };
638
639 if response.status().is_client_error() || response.status().is_server_error() {
640 cancel
641 .cancel(
642 CancelReason::HandlerFailed,
643 Some("handler returned error status"),
644 Some(response.status().as_u16()),
645 )
646 .await;
647 return Ok(response.into_response());
648 }
649
650 let mut response = response.into_response();
651 let override_amount =
653 super::upto::resolve_response_settlement_amount(&mut response, verified.requirements())
654 .map_err(|e| PaygateError::SettlementAborted(e.to_string()))?;
655
656 let settlement = verified
657 .settle_with_override(override_amount.as_deref())
658 .await?;
659 let header_value = settlement_to_header(&settlement)?;
660
661 response
662 .headers_mut()
663 .insert("Payment-Response", header_value);
664 ensure_expose_headers(response.headers_mut());
666 Ok(response)
667 }
668}
669
670impl Paygate {
671 #[cfg_attr(
685 feature = "telemetry",
686 instrument(name = "x402.handle_request_concurrent", skip_all)
687 )]
688 pub async fn handle_request_concurrent<
689 ReqBody,
690 ResBody,
691 S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
692 >(
693 &self,
694 inner: S,
695 req: http::Request<ReqBody>,
696 ) -> Result<Response, PaygateError>
697 where
698 S::Response: IntoResponse,
699 S::Error: IntoResponse,
700 S::Future: Send + 'static,
701 ReqBody: Send + 'static,
702 {
703 let verified = self.verify_only(req.headers()).await?;
704 let cancel = verified.cancellation_guard();
705
706 if let Some(directive) = verified.skip_handler.clone() {
707 let settlement = verified.settle().await?;
708 return skip_handler_response(&directive, &settlement);
709 }
710
711 let settle_handle = tokio::spawn(async move { verified.settle().await });
715
716 let response = match call_inner(inner, req).await {
717 Ok(r) => r,
718 Err(err) => {
719 drop(settle_handle);
720 cancel
721 .cancel(
722 CancelReason::HandlerThrew,
723 Some("inner service error"),
724 None,
725 )
726 .await;
727 return Ok(err.into_response());
728 }
729 };
730
731 if response.status().is_client_error() || response.status().is_server_error() {
732 drop(settle_handle);
733 cancel
734 .cancel(
735 CancelReason::HandlerFailed,
736 Some("handler returned error status"),
737 Some(response.status().as_u16()),
738 )
739 .await;
740 return Ok(response.into_response());
741 }
742
743 let mut res = response.into_response();
744 let partial = super::upto::take_settlement_overrides_header(res.headers_mut());
746 let has_ext = res
747 .extensions_mut()
748 .remove::<super::upto::UptoActualAmount>()
749 .is_some();
750 if partial.is_some() || has_ext {
751 drop(settle_handle);
752 return Err(PaygateError::SettlementAborted(
753 "Settlement-Overrides / UptoActualAmount require SettlementMode::Sequential".into(),
754 ));
755 }
756
757 let settlement = settle_handle
758 .await
759 .map_err(|e| PaygateError::SettlementAborted(format!("settle task panicked: {e}")))??;
760 let header_value = settlement_to_header(&settlement)?;
761
762 res.headers_mut().insert("Payment-Response", header_value);
763 ensure_expose_headers(res.headers_mut());
764 Ok(res)
765 }
766
767 #[cfg_attr(
788 feature = "telemetry",
789 instrument(name = "x402.handle_request_background", skip_all)
790 )]
791 pub async fn handle_request_background<
792 ReqBody,
793 ResBody,
794 S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
795 >(
796 &self,
797 inner: S,
798 req: http::Request<ReqBody>,
799 ) -> Result<Response, PaygateError>
800 where
801 S::Response: IntoResponse,
802 S::Error: IntoResponse,
803 S::Future: Send + 'static,
804 ReqBody: Send + 'static,
805 {
806 let verified = self.verify_only(req.headers()).await?;
807 let cancel = verified.cancellation_guard();
808
809 if let Some(directive) = verified.skip_handler.clone() {
812 let settlement = verified.settle().await?;
813 return skip_handler_response(&directive, &settlement);
814 }
815
816 let settle_handle = tokio::spawn(async move { verified.settle().await });
831 let tracker_guard = self
835 .settlement_tracker
836 .as_ref()
837 .map(BackgroundSettlementTracker::start);
838 drop(tokio::spawn(supervise_background_settle(
842 settle_handle,
843 tracker_guard,
844 )));
845
846 let call_result = call_inner(inner, req).await;
849 match call_result {
850 Ok(r) => {
851 if r.status().is_client_error() || r.status().is_server_error() {
852 cancel
853 .cancel(
854 CancelReason::HandlerFailed,
855 Some("handler returned error status"),
856 Some(r.status().as_u16()),
857 )
858 .await;
859 }
860 let mut response = r.into_response();
861 drop(super::upto::take_settlement_overrides_header(
863 response.headers_mut(),
864 ));
865 drop(
866 response
867 .extensions_mut()
868 .remove::<super::upto::UptoActualAmount>(),
869 );
870 Ok(response)
871 }
872 Err(err) => {
873 cancel
874 .cancel(
875 CancelReason::HandlerThrew,
876 Some("inner service error"),
877 None,
878 )
879 .await;
880 Ok(err.into_response())
881 }
882 }
883 }
884}
885
886#[derive(Debug)]
892pub struct VerifiedPayment {
893 settle_request: wire::SettleRequest,
894 payload: PaymentPayload,
895 requirements: wire::PaymentRequirements,
896 server: ResourceServer,
897 pub skip_handler: Option<r402_core::SkipHandlerDirective>,
899}
900
901impl VerifiedPayment {
902 #[must_use]
904 pub fn cancellation_guard(&self) -> r402_core::CancellationGuard {
905 self.server
906 .cancellation_guard(self.payload.clone(), self.requirements.clone())
907 }
908
909 pub async fn settle(self) -> Result<wire::SettleResponse, PaygateError> {
915 self.settle_with_override(None).await
916 }
917
918 pub async fn settle_with_override(
931 self,
932 actual_amount: Option<&str>,
933 ) -> Result<wire::SettleResponse, PaygateError> {
934 use r402_core::SettlementOverrides;
935
936 let overrides = actual_amount.map(SettlementOverrides::amount);
937 let settlement = self
938 .server
939 .settle_payment(&self.payload, &self.requirements, overrides.as_ref())
940 .await
941 .map_err(|e| PaygateError::SettlementAborted(format!("{e}")))?;
942
943 if matches!(settlement, wire::SettleResponse::Failure { .. }) {
944 return Err(PaygateError::Settlement(Box::new(settlement)));
945 }
946
947 Ok(settlement)
948 }
949
950 #[must_use]
952 pub const fn requirements(&self) -> &wire::PaymentRequirements {
953 &self.requirements
954 }
955
956 #[must_use]
958 pub const fn settle_request(&self) -> &wire::SettleRequest {
959 &self.settle_request
960 }
961}
962
963async fn supervise_background_settle(
979 handle: tokio::task::JoinHandle<Result<wire::SettleResponse, PaygateError>>,
980 _tracker: Option<SettlementInFlightGuard>,
985) {
986 let outcome = handle.await;
987 log_background_settle_outcome(outcome);
988}
989
990fn log_background_settle_outcome(
995 outcome: Result<Result<wire::SettleResponse, PaygateError>, tokio::task::JoinError>,
996) {
997 match outcome {
998 Ok(Ok(_settlement)) => {
999 #[cfg(feature = "telemetry")]
1000 tracing::debug!("background settlement completed");
1001 record_background_settle_metric("ok");
1002 }
1003 Ok(Err(err)) => {
1004 log_background_settle_error(&err);
1005 record_background_settle_metric("error");
1006 }
1007 Err(join_err) => {
1008 let label = if join_err.is_panic() {
1009 "panic"
1010 } else {
1011 "cancelled"
1012 };
1013 log_background_settle_join_error(&join_err);
1014 record_background_settle_metric(label);
1015 }
1016 }
1017}
1018
1019#[cfg(feature = "metrics")]
1020fn record_background_settle_metric(result: &'static str) {
1021 ::metrics::counter!(
1022 r402_core::metrics::PAYGATE_BACKGROUND_SETTLE_TOTAL,
1023 "result" => result,
1024 )
1025 .increment(1);
1026}
1027#[cfg(not(feature = "metrics"))]
1028fn record_background_settle_metric(_result: &'static str) {}
1029
1030#[cfg(feature = "telemetry")]
1031fn log_background_settle_error(err: &PaygateError) {
1032 tracing::error!(error = %err, "background settlement returned error");
1033}
1034#[cfg(not(feature = "telemetry"))]
1035fn log_background_settle_error(_err: &PaygateError) {}
1036
1037#[cfg(feature = "telemetry")]
1038fn log_background_settle_join_error(join_err: &tokio::task::JoinError) {
1039 if join_err.is_panic() {
1040 tracing::error!(error = %join_err, "background settlement task panicked");
1041 } else {
1042 tracing::warn!(error = %join_err, "background settlement task cancelled");
1043 }
1044}
1045#[cfg(not(feature = "telemetry"))]
1046fn log_background_settle_join_error(_join_err: &tokio::task::JoinError) {}
1047
1048pub fn settlement_to_header(
1055 settlement: &wire::SettleResponse,
1056) -> Result<HeaderValue, PaygateError> {
1057 let encoded = settlement.encode_base64().ok_or_else(|| {
1058 PaygateError::SettlementAborted("cannot encode error settlement".to_owned())
1059 })?;
1060 HeaderValue::from_bytes(encoded.as_ref())
1061 .map_err(|e| PaygateError::SettlementAborted(e.to_string()))
1062}
1063
1064fn skip_handler_response(
1069 directive: &r402_core::SkipHandlerDirective,
1070 settlement: &wire::SettleResponse,
1071) -> Result<Response, PaygateError> {
1072 let content_type = directive
1073 .content_type
1074 .as_deref()
1075 .unwrap_or("application/json");
1076 let body_bytes = directive.body.as_ref().map_or_else(
1077 || b"null".to_vec(),
1078 |value| serde_json::to_vec(value).unwrap_or_else(|_| b"null".to_vec()),
1079 );
1080 let header_value = settlement_to_header(settlement)?;
1081 let mut response = Response::builder()
1082 .status(StatusCode::OK)
1083 .header(http::header::CONTENT_TYPE, content_type)
1084 .body(Body::from(body_bytes))
1085 .unwrap_or_else(|_| Response::new(Body::from(b"null".as_slice())));
1086 response
1087 .headers_mut()
1088 .insert("Payment-Response", header_value);
1089 ensure_expose_headers(response.headers_mut());
1090 Ok(response)
1091}
1092
1093async fn call_inner<
1095 ReqBody,
1096 ResBody,
1097 S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
1098>(
1099 mut inner: S,
1100 req: http::Request<ReqBody>,
1101) -> Result<http::Response<ResBody>, S::Error>
1102where
1103 S::Future: Send,
1104{
1105 #[cfg(feature = "telemetry")]
1106 {
1107 inner
1108 .call(req)
1109 .instrument(tracing::info_span!("inner"))
1110 .await
1111 }
1112 #[cfg(not(feature = "telemetry"))]
1113 {
1114 inner.call(req).await
1115 }
1116}
1117
1118fn decode_payment_payload<T: serde::de::DeserializeOwned>(header_bytes: &[u8]) -> Option<T> {
1120 let decoded = Base64Bytes::from(header_bytes).decode().ok()?;
1121 serde_json::from_slice(decoded.as_ref()).ok()
1122}
1123
1124fn inferred_status(err: &PaygateError) -> StatusCode {
1129 if let PaygateError::VerificationFailed(message) = err
1130 && message.contains("permit2_allowance_required")
1131 {
1132 return StatusCode::PRECONDITION_FAILED;
1133 }
1134 StatusCode::PAYMENT_REQUIRED
1135}
1136
1137fn match_requirements(
1138 payload: &PaymentPayload,
1139 accepts: &[wire::PriceTag],
1140) -> Result<wire::PaymentRequirements, PaygateError> {
1141 accepts
1142 .iter()
1143 .find(|pt| **pt == payload.accepted)
1144 .map(|pt| pt.requirements.clone())
1145 .ok_or(PaygateError::NoPaymentMatching)
1146}
1147
1148fn build_settle_request(
1149 payload: &PaymentPayload,
1150 requirements: &wire::PaymentRequirements,
1151) -> Result<wire::SettleRequest, String> {
1152 let verify: wire::TypedVerifyRequest<2, PaymentPayload, wire::PaymentRequirements> =
1153 wire::TypedVerifyRequest {
1154 x402_version: wire::V2,
1155 payment_payload: payload.clone(),
1156 payment_requirements: requirements.clone(),
1157 };
1158 let json = serde_json::to_value(&verify).map_err(|e| e.to_string())?;
1159 Ok(wire::SettleRequest::from(json))
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165
1166 #[test]
1167 fn adds_header_when_absent() {
1168 let mut headers = HeaderMap::new();
1169 ensure_expose_headers(&mut headers);
1170 assert_eq!(
1171 headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap(),
1172 X402_EXPOSED_HEADERS,
1173 );
1174 }
1175
1176 #[test]
1177 fn merges_existing_header() {
1178 let mut headers = HeaderMap::new();
1179 let _ = headers.insert(
1180 ACCESS_CONTROL_EXPOSE_HEADERS,
1181 HeaderValue::from_static("X-Foo"),
1182 );
1183 ensure_expose_headers(&mut headers);
1184 let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
1185 let value = value.to_str().unwrap();
1186 assert!(value.contains("X-Foo"));
1187 assert!(value.contains("Payment-Required"));
1188 assert!(value.contains("Payment-Response"));
1189 }
1190
1191 #[test]
1192 fn expose_headers_idempotent() {
1193 let mut headers = HeaderMap::new();
1194 ensure_expose_headers(&mut headers);
1195 ensure_expose_headers(&mut headers);
1196 let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
1197 assert_eq!(value, X402_EXPOSED_HEADERS);
1198 }
1199
1200 #[test]
1201 fn permit2_allowance_required_maps_to_412() {
1202 assert_eq!(
1203 reason_to_status(&ErrorReason::Permit2AllowanceRequired),
1204 StatusCode::PRECONDITION_FAILED,
1205 );
1206 }
1207
1208 #[test]
1209 fn other_reasons_map_to_402() {
1210 for reason in [
1211 ErrorReason::InvalidPayload,
1212 ErrorReason::InvalidPaymentRequirements,
1213 ErrorReason::InvalidExactEvmPayloadSignature,
1214 ErrorReason::InsufficientFunds,
1215 ErrorReason::DuplicateSettlement,
1216 ErrorReason::InvalidExactSolanaPayloadMemoMismatch,
1217 ErrorReason::InvalidTransactionState,
1218 ErrorReason::UnexpectedSettleError,
1219 ErrorReason::Custom("some_unknown_code".into()),
1220 ] {
1221 assert_eq!(
1222 reason_to_status(&reason),
1223 StatusCode::PAYMENT_REQUIRED,
1224 "reason {reason:?} should map to 402"
1225 );
1226 }
1227 }
1228}