use std::fmt::Display;
use bon::Builder;
use x402_core::{
core::Resource,
facilitator::{Facilitator, SupportedResponse},
transport::{Accepts, PaymentPayload},
types::{Base64EncodedHeader, Extension, Record},
};
use crate::{
HttpRequest, HttpResponse,
errors::ErrorResponse,
processor::{PaymentState, RequestProcessor},
};
#[derive(Builder, Debug, Clone)]
pub struct PayWall<F: Facilitator> {
pub facilitator: F,
pub resource: Resource,
#[builder(into)]
pub accepts: Accepts,
#[builder(default)]
pub extensions: Record<Extension>,
}
impl<F: Facilitator> PayWall<F> {
pub fn process_request<'pw, Req: HttpRequest>(
&'pw self,
request: Req,
) -> Result<RequestProcessor<'pw, F, Req>, ErrorResponse> {
let payment_signature = request
.get_header("PAYMENT-SIGNATURE")
.ok_or_else(|| self.payment_required())
.and_then(|h| {
str::from_utf8(h).map_err(|err| {
self.invalid_payment(format!(
"Failed to decode PAYMENT-SIGNATURE header: {err}"
))
})
})
.map(|s| Base64EncodedHeader(s.to_string()))?;
let payload = PaymentPayload::try_from(payment_signature.clone()).map_err(|err| {
self.invalid_payment(format!("Failed to parse PAYMENT-SIGNATURE header: {err}"))
})?;
let initial_state = PaymentState {
verified: None,
settled: None,
required_extensions: self.extensions.to_owned(),
payload_extensions: payload.extensions.clone(),
};
let selected = self
.accepts
.clone()
.into_iter()
.find(|a| a == &payload.accepted)
.ok_or_else(|| self.invalid_payment("PaymentRequirements in payload not accepted"))?;
Ok(RequestProcessor {
paywall: self,
selected,
request,
payload,
payment_state: initial_state,
})
}
pub async fn handle_payment<Fun, Fut, Req, Res>(
self,
request: Req,
handler: Fun,
) -> Result<Res, ErrorResponse>
where
Fun: FnOnce(Req) -> Fut,
Fut: Future<Output = Res>,
Req: HttpRequest,
Res: HttpResponse,
{
let response = self
.update_accepts()
.await?
.process_request(request)?
.verify()
.await?
.run_handler(handler)
.await?
.settle_on_success()
.await?
.response();
Ok(response)
}
pub async fn update_accepts(mut self) -> Result<Self, ErrorResponse> {
let supported = self.facilitator.supported().await.map_err(|err| {
self.server_error(format!("Failed to get supported payment kinds: {err}"))
})?;
let filtered = filter_supported_accepts(&supported, self.accepts.to_owned());
self.accepts = filtered;
Ok(self)
}
pub fn payment_required(&self) -> ErrorResponse {
ErrorResponse::payment_required(
self.resource.to_owned().into(),
self.accepts.to_owned(),
self.extensions.to_owned(),
)
}
pub fn invalid_payment(&self, reason: impl Display) -> ErrorResponse {
ErrorResponse::invalid_payment(
reason,
self.resource.to_owned().into(),
self.accepts.to_owned(),
self.extensions.to_owned(),
)
}
pub fn payment_failed(&self, reason: impl Display) -> ErrorResponse {
ErrorResponse::payment_failed(
reason,
self.resource.to_owned().into(),
self.accepts.to_owned(),
self.extensions.to_owned(),
)
}
pub fn server_error(&self, reason: impl Display) -> ErrorResponse {
ErrorResponse::server_error(
reason,
self.resource.to_owned().into(),
self.accepts.to_owned(),
self.extensions.to_owned(),
)
}
}
pub fn filter_supported_accepts(supported: &SupportedResponse, accepts: Accepts) -> Accepts {
accepts
.into_iter()
.filter_map(|mut pr| {
supported
.kinds
.iter()
.find(|kind| {
kind.x402_version.as_v2().is_some()
&& kind.scheme == pr.scheme
&& kind.network == pr.network
})
.map(|s| {
if s.extra.is_some() {
pr.extra = s.extra.clone();
}
pr
})
})
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use x402_core::{
facilitator::SupportedResponse,
transport::{Accepts, PaymentRequirements},
types::AmountValue,
};
use crate::paywall::filter_supported_accepts;
#[test]
fn test_filter_supported_accepts() {
let supported: SupportedResponse = serde_json::from_value(json!({
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:84532"
},
{
"x402Version": 2,
"scheme": "exact",
"network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
"extra": {
"feePayer": "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
}
},
{
"x402Version": 1,
"scheme": "exact",
"network": "base-sepolia"
},
{
"x402Version": 1,
"scheme": "exact",
"network": "solana-devnet",
"extra": {
"feePayer": "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
}
}
],
"extensions": [],
"signers": {
"eip155:*": [
"0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf"
],
"solana:*": [
"CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
]
}
}))
.unwrap();
let accepts = Accepts::from(vec![
PaymentRequirements {
scheme: "exact".to_string(),
network: "eip155:84532".to_string(),
amount: AmountValue(1000),
asset: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
pay_to: "0x3CB9B3bBfde8501f411bB69Ad3DC07908ED0dE20".to_string(),
max_timeout_seconds: 60,
extra: Some(json!({
"name": "USD Coin",
"version": "2"
})),
},
PaymentRequirements {
scheme: "exact".to_string(),
network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1".to_string(),
amount: AmountValue(2000000),
asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
pay_to: "Ge3jkza5KRfXvaq3GELNLh6V1pjjdEKNpEdGXJgjjKUR".to_string(),
max_timeout_seconds: 60,
extra: None,
},
PaymentRequirements {
scheme: "exact".to_string(),
network: "solana:UnknownNetwork".to_string(),
amount: AmountValue(2000000),
asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
pay_to: "Ge3jkza5KRfXvaq3GELNLh6V1pjjdEKNpEdGXJgjjKUR".to_string(),
max_timeout_seconds: 60,
extra: None,
},
]);
let updated = filter_supported_accepts(&supported, accepts);
assert_eq!(
updated.as_ref().len(),
2,
"Only 2 payment requirements should be supported"
);
assert_eq!(
updated.as_ref()[0].extra,
Some(json!({
"name": "USD Coin",
"version": "2"
})),
"EVM payment requirement should retain extra"
);
assert_eq!(
updated.as_ref()[1].extra,
Some(json!({
"feePayer": "CKPKJWNdJEqa81x7CkZ14BVPiY6y16Sxs7owznqtWYp5"
})),
"Solana payment requirement should have updated extra from supported kinds"
);
}
}