use std::fmt::{self, Debug, Formatter};
use std::str::FromStr;
use std::sync::Arc;
use compact_str::CompactString;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use super::{Extensions, SupportedResponse, V2, Version2};
use crate::chain::ChainId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct ResourceInfo {
pub url: CompactString,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<CompactString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<CompactString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_name: Option<CompactString>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<CompactString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<CompactString>,
}
impl ResourceInfo {
#[must_use]
pub fn new(url: impl Into<CompactString>) -> Self {
Self {
url: url.into(),
description: None,
mime_type: None,
service_name: None,
tags: Vec::new(),
icon_url: None,
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<CompactString>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_mime_type(mut self, mime_type: impl Into<CompactString>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
#[must_use]
pub fn with_service_name(mut self, service_name: impl Into<CompactString>) -> Self {
self.service_name = Some(service_name.into());
self
}
#[must_use]
pub fn with_tags(mut self, tags: Vec<CompactString>) -> Self {
self.tags = tags;
self
}
#[must_use]
pub fn with_tag(mut self, tag: impl Into<CompactString>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn with_icon_url(mut self, icon_url: impl Into<CompactString>) -> Self {
self.icon_url = Some(icon_url.into());
self
}
}
#[cfg(test)]
mod resource_info_tests {
use super::*;
#[test]
fn minimal_resource_omits_optional_fields() {
let info = ResourceInfo::new("https://example.com/paid");
let v = serde_json::to_value(&info).unwrap();
assert_eq!(v["url"], "https://example.com/paid");
assert!(v.get("description").is_none());
assert!(v.get("mimeType").is_none());
}
#[test]
fn full_resource_roundtrips() {
let info = ResourceInfo::new("https://example.com/r")
.with_description("doc")
.with_mime_type("application/json")
.with_service_name("Example Weather")
.with_tag("weather")
.with_tag("forecast")
.with_icon_url("https://example.com/icon.png");
let encoded = serde_json::to_value(&info).unwrap();
assert_eq!(encoded["mimeType"], "application/json");
assert_eq!(encoded["serviceName"], "Example Weather");
assert_eq!(encoded["tags"], serde_json::json!(["weather", "forecast"]));
assert_eq!(encoded["iconUrl"], "https://example.com/icon.png");
let decoded: ResourceInfo = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, info);
}
#[test]
fn discovery_metadata_omitted_by_default() {
let info = ResourceInfo::new("https://example.com/r");
let v = serde_json::to_value(&info).unwrap();
assert!(v.get("serviceName").is_none());
assert!(v.get("tags").is_none());
assert!(v.get("iconUrl").is_none());
}
#[test]
fn deserializes_spec_compliant_optional_fields() {
let json = serde_json::json!({ "url": "https://x.test" });
let decoded: ResourceInfo = serde_json::from_value(json).unwrap();
assert_eq!(decoded.url, "https://x.test");
assert!(decoded.description.is_none());
assert!(decoded.mime_type.is_none());
}
#[test]
fn rejects_unknown_field() {
let json = serde_json::json!({ "url": "https://x.test", "unknown": 1 });
assert!(serde_json::from_value::<ResourceInfo>(json).is_err());
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct PaymentRequirements<
TScheme = CompactString,
TAmount = CompactString,
TAddress = CompactString,
TExtra = serde_json::Value,
> {
pub scheme: TScheme,
pub network: ChainId,
pub amount: TAmount,
pub pay_to: TAddress,
pub max_timeout_seconds: u64,
pub asset: TAddress,
#[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
pub extra: Option<TExtra>,
}
#[must_use]
pub fn find_matching_requirements<'a>(
available: &'a [PaymentRequirements],
accepted: &PaymentRequirements,
) -> Option<&'a PaymentRequirements> {
available
.iter()
.find(|req| req.matches_payload_accepted(accepted))
}
impl<TScheme, TAmount, TAddress, TExtra> PaymentRequirements<TScheme, TAmount, TAddress, TExtra> {
#[must_use]
pub const fn new(
scheme: TScheme,
network: ChainId,
amount: TAmount,
pay_to: TAddress,
asset: TAddress,
max_timeout_seconds: u64,
) -> Self {
Self {
scheme,
network,
amount,
pay_to,
asset,
max_timeout_seconds,
extra: None,
}
}
#[must_use]
pub fn with_extra(mut self, extra: TExtra) -> Self {
self.extra = Some(extra);
self
}
#[must_use]
pub fn with_optional_extra(mut self, extra: Option<TExtra>) -> Self {
self.extra = extra;
self
}
}
impl PaymentRequirements {
#[must_use]
pub fn matches_payload_accepted(&self, accepted: &Self) -> bool {
self.scheme == accepted.scheme
&& self.network == accepted.network
&& self.amount == accepted.amount
&& self.asset == accepted.asset
&& self.pay_to == accepted.pay_to
}
#[must_use]
pub fn as_concrete<TScheme, TAmount, TAddress, TExtra>(
&self,
) -> Option<PaymentRequirements<TScheme, TAmount, TAddress, TExtra>>
where
TScheme: FromStr,
TAmount: FromStr,
TAddress: FromStr,
TExtra: DeserializeOwned,
{
let scheme = self.scheme.parse::<TScheme>().ok()?;
let amount = self.amount.parse::<TAmount>().ok()?;
let pay_to = self.pay_to.parse::<TAddress>().ok()?;
let asset = self.asset.parse::<TAddress>().ok()?;
let extra = self
.extra
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
Some(PaymentRequirements {
scheme,
network: self.network.clone(),
amount,
pay_to,
max_timeout_seconds: self.max_timeout_seconds,
asset,
extra,
})
}
}
#[cfg(test)]
mod payment_requirements_tests {
use super::*;
#[test]
fn rejects_unknown_top_level_field() {
let json = serde_json::json!({
"scheme": "exact",
"network": "eip155:8453",
"amount": "1",
"payTo": "0x0",
"maxTimeoutSeconds": 60,
"asset": "0x0",
"unknownField": 1
});
assert!(serde_json::from_value::<PaymentRequirements>(json).is_err());
}
#[test]
fn find_matching_requirements_go_semantics() {
let a = PaymentRequirements::new(
"exact".into(),
"eip155:1".parse().unwrap(),
"1000000".into(),
"0xrecipient1".into(),
"USDC".into(),
60,
);
let b = PaymentRequirements::new(
"exact".into(),
"eip155:8453".parse().unwrap(),
"2000000".into(),
"0xrecipient2".into(),
"USDC".into(),
30,
);
let available = [a.clone(), b.clone()];
let mut accepted = b;
accepted.max_timeout_seconds = 999;
let matched = find_matching_requirements(&available, &accepted).unwrap();
assert_eq!(matched.network.to_string(), "eip155:8453");
assert_eq!(matched.max_timeout_seconds, 30);
let mut miss = a;
miss.scheme = "nonexistent".into();
assert!(find_matching_requirements(&available, &miss).is_none());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct PaymentRequired {
pub x402_version: Version2,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<CompactString>,
pub resource: ResourceInfo,
#[serde(default)]
pub accepts: Vec<PaymentRequirements>,
#[serde(default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl PaymentRequired {
#[must_use]
pub fn new(resource: ResourceInfo) -> Self {
Self {
x402_version: V2,
error: None,
resource,
accepts: Vec::new(),
extensions: Extensions::new(),
}
}
#[must_use]
pub fn with_accepts(mut self, accepts: Vec<PaymentRequirements>) -> Self {
self.accepts = accepts;
self
}
#[must_use]
pub fn add_accept(mut self, accept: PaymentRequirements) -> Self {
self.accepts.push(accept);
self
}
#[must_use]
pub fn with_error(mut self, error: impl Into<CompactString>) -> Self {
self.error = Some(error.into());
self
}
#[must_use]
pub fn with_extensions(mut self, extensions: Extensions) -> Self {
self.extensions = extensions;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct PaymentPayload<TAccepted, TPayload> {
pub accepted: TAccepted,
pub payload: TPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<ResourceInfo>,
pub x402_version: Version2,
#[serde(default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl<TAccepted, TPayload> PaymentPayload<TAccepted, TPayload> {
#[must_use]
pub fn new(accepted: TAccepted, payload: TPayload) -> Self {
Self {
accepted,
payload,
resource: None,
x402_version: V2,
extensions: Extensions::new(),
}
}
#[must_use]
pub fn with_resource(mut self, resource: ResourceInfo) -> Self {
self.resource = Some(resource);
self
}
#[must_use]
pub fn with_optional_resource(mut self, resource: Option<ResourceInfo>) -> Self {
self.resource = resource;
self
}
#[must_use]
pub fn with_extensions(mut self, extensions: Extensions) -> Self {
self.extensions = extensions;
self
}
}
pub type Enricher = Arc<dyn Fn(&mut PriceTag, &SupportedResponse) + Send + Sync>;
#[derive(Clone)]
pub struct PriceTag {
pub requirements: PaymentRequirements,
#[doc(hidden)]
pub enricher: Option<Enricher>,
}
impl PriceTag {
#[must_use]
pub const fn new(requirements: PaymentRequirements) -> Self {
Self {
requirements,
enricher: None,
}
}
#[must_use]
pub fn with_enricher(mut self, enricher: Enricher) -> Self {
self.enricher = Some(enricher);
self
}
pub fn enrich(&mut self, capabilities: &SupportedResponse) {
if let Some(enricher) = self.enricher.clone() {
enricher(self, capabilities);
}
}
#[must_use]
pub const fn with_timeout(mut self, seconds: u64) -> Self {
self.requirements.max_timeout_seconds = seconds;
self
}
}
impl Debug for PriceTag {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("PriceTag")
.field("requirements", &self.requirements)
.field("enricher", &self.enricher.as_ref().map(|_| "<fn>"))
.finish()
}
}
impl PartialEq<PaymentRequirements> for PriceTag {
fn eq(&self, other: &PaymentRequirements) -> bool {
let this = &self.requirements;
this.scheme == other.scheme
&& this.network == other.network
&& this.amount == other.amount
&& this.asset == other.asset
&& this.pay_to == other.pay_to
}
}