use std::time::{SystemTime, UNIX_EPOCH};
use alloy::{
primitives::{B256, U256},
signers::Signer,
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::{Client as HttpClient, Url};
use rpc::{
PaymentGuaranteeRequest, PaymentGuaranteeRequestClaims, PaymentGuaranteeRequestClaimsV1,
PaymentGuaranteeRequestClaimsV2, PaymentGuaranteeValidationPolicyV2,
compute_validation_request_hash, compute_validation_subject_hash,
};
use serde::Serialize;
use crate::{Client, PaymentSignature, SigningScheme, error::X402Error};
pub mod model;
pub use model::*;
#[async_trait]
pub trait FlowSigner: Send + Sync {
async fn sign_payment(
&self,
claims: PaymentGuaranteeRequestClaims,
scheme: SigningScheme,
) -> Result<PaymentSignature, X402Error>;
}
#[async_trait]
impl<S> FlowSigner for Client<S>
where
S: Signer + Send + Sync,
{
async fn sign_payment(
&self,
claims: PaymentGuaranteeRequestClaims,
scheme: SigningScheme,
) -> Result<PaymentSignature, X402Error> {
match claims {
PaymentGuaranteeRequestClaims::V1(c) => self
.user
.sign_payment(c, scheme)
.await
.map_err(X402Error::Signing),
PaymentGuaranteeRequestClaims::V2(c) => self
.user
.sign_payment_v2(*c, scheme)
.await
.map_err(X402Error::Signing),
}
}
}
#[derive(Clone)]
pub struct X402Flow<S> {
http: HttpClient,
signer: S,
}
impl<S> X402Flow<S> {
pub fn new(signer: S) -> Result<Self, X402Error> {
Ok(Self {
http: HttpClient::new(),
signer,
})
}
}
impl<S> X402Flow<S>
where
S: FlowSigner,
{
pub async fn sign_payment(
&self,
payment_requirements: PaymentRequirements,
user_address: String,
) -> Result<X402SignedPayment, X402Error> {
if !payment_requirements.scheme.to_lowercase().contains("4mica") {
return Err(X402Error::InvalidScheme(payment_requirements.scheme));
}
let tab = self
.request_tab(1, payment_requirements.clone(), user_address.clone(), None)
.await?;
let claims = Self::build_claims_request_v1(&payment_requirements, &tab, &user_address)?;
let signature = self
.signer
.sign_payment(
PaymentGuaranteeRequestClaims::V1(claims.clone()),
SigningScheme::Eip712,
)
.await?;
let payload = PaymentGuaranteeRequest::new(
PaymentGuaranteeRequestClaims::V1(claims.clone()),
signature.signature.clone(),
signature.scheme.clone(),
);
let envelope = X402PaymentEnvelope {
x402_version: 1,
scheme: payment_requirements.scheme,
network: payment_requirements.network,
payload: payload.clone(),
};
let envelope =
serde_json::to_vec(&envelope).map_err(|e| X402Error::EncodeEnvelope(e.to_string()))?;
let header = BASE64_STANDARD.encode(envelope);
Ok(X402SignedPayment {
header,
payload,
signature,
})
}
pub async fn sign_payment_v2(
&self,
payment_required: X402PaymentRequiredV2,
accepted: PaymentRequirementsV2,
user_address: String,
) -> Result<X402SignedPayment, X402Error> {
if !accepted.scheme.to_lowercase().contains("4mica") {
return Err(X402Error::InvalidScheme(accepted.scheme));
}
if payment_required.x402_version != 2 {
return Err(X402Error::InvalidVersion("expected x402 version 2".into()));
}
let tab = self
.request_tab(
2,
accepted.clone(),
user_address.clone(),
Some(payment_required.resource.clone()),
)
.await?;
let claims = Self::build_claims_request_v2(&accepted, &tab, &user_address)?;
let signature = self
.signer
.sign_payment(
PaymentGuaranteeRequestClaims::V2(Box::new(claims.clone())),
SigningScheme::Eip712,
)
.await?;
let payload = PaymentGuaranteeRequest::new(
PaymentGuaranteeRequestClaims::V2(Box::new(claims.clone())),
signature.signature.clone(),
signature.scheme.clone(),
);
let envelope = X402PaymentEnvelopeV2 {
x402_version: 2,
accepted: accepted.clone(),
payload: payload.clone(),
resource: payment_required.resource,
};
let envelope =
serde_json::to_vec(&envelope).map_err(|e| X402Error::EncodeEnvelope(e.to_string()))?;
let header = BASE64_STANDARD.encode(envelope);
Ok(X402SignedPayment {
header,
payload,
signature,
})
}
pub async fn settle_payment(
&self,
payment: X402SignedPayment,
payment_requirements: PaymentRequirements,
facilitator_url: &str,
) -> Result<X402SettledPayment, X402Error> {
let base_url = Url::parse(facilitator_url)
.map_err(|e| X402Error::InvalidFacilitatorUrl(e.to_string()))?;
let url = base_url
.join("settle")
.map_err(|e| X402Error::InvalidFacilitatorUrl(e.to_string()))?;
let response = self
.http
.post(url)
.json(&FacilitatorSettleParams {
x402_version: 1,
payment_header: payment.header.clone(),
payment_requirements,
})
.send()
.await?;
let status = response.status();
let settlement: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(X402Error::SettlementFailed {
status,
body: settlement,
});
}
Ok(X402SettledPayment {
payment,
settlement,
})
}
async fn request_tab<PR: X402PaymentRequirements + Serialize>(
&self,
x402_version: u8,
payment_requirements: PR,
user_address: String,
resource: Option<X402ResourceInfo>,
) -> Result<TabResponse, X402Error> {
let extra: PaymentRequirementsExtra = match payment_requirements.extra() {
Some(extra) => serde_json::from_value(extra.clone())
.map_err(|e| X402Error::InvalidExtra(e.to_string()))?,
None => return Err(X402Error::InvalidExtra("extra is required".into())),
};
let Some(tab_url) = extra.tab_endpoint else {
return Err(X402Error::TabResolutionFailed("missing tabEndpoint".into()));
};
let payload = TabRequestParams {
x402_version,
user_address,
payment_requirements,
resource,
};
let response = self
.http
.post(tab_url)
.json(&payload)
.send()
.await?
.error_for_status()?;
Ok(response.json().await?)
}
fn build_claims_request_v1(
requirements: &impl X402PaymentRequirements,
tab: &TabResponse,
user_address: &str,
) -> Result<PaymentGuaranteeRequestClaimsV1, X402Error> {
let payment_context = Self::build_payment_context(requirements, tab, user_address)?;
Ok(PaymentGuaranteeRequestClaimsV1::new(
user_address.to_string(),
requirements.pay_to().to_string(),
payment_context.tab_id,
payment_context.req_id,
payment_context.amount,
payment_context.timestamp,
Some(requirements.asset().to_string()),
))
}
fn build_claims_request_v2(
requirements: &PaymentRequirementsV2,
tab: &TabResponse,
user_address: &str,
) -> Result<PaymentGuaranteeRequestClaimsV2, X402Error> {
let payment_context = Self::build_payment_context(requirements, tab, user_address)?;
let extra = parse_payment_requirements_extra(requirements)?;
let validation_subject_hash = compute_validation_subject_hash(
user_address,
requirements.pay_to(),
payment_context.tab_id,
payment_context.req_id,
payment_context.amount,
requirements.asset(),
payment_context.timestamp,
)
.map_err(|e| X402Error::InvalidExtra(e.to_string()))?;
let mut validation_policy = PaymentGuaranteeValidationPolicyV2 {
validation_registry_address: extra.validation_registry_address.ok_or_else(|| {
X402Error::InvalidExtra("missing validationRegistryAddress".into())
})?,
validation_request_hash: B256::ZERO,
validation_chain_id: extra
.validation_chain_id
.ok_or_else(|| X402Error::InvalidExtra("missing validationChainId".into()))?,
validator_address: extra
.validator_address
.ok_or_else(|| X402Error::InvalidExtra("missing validatorAddress".into()))?,
validator_agent_id: extra
.validator_agent_id
.as_deref()
.ok_or_else(|| X402Error::InvalidExtra("missing validatorAgentId".into()))
.and_then(|raw| parse_u256_field("validatorAgentId", raw))?,
min_validation_score: extra
.min_validation_score
.ok_or_else(|| X402Error::InvalidExtra("missing minValidationScore".into()))?,
validation_subject_hash: B256::from(validation_subject_hash),
job_hash: extra
.job_hash
.as_deref()
.ok_or_else(|| X402Error::InvalidExtra("missing jobHash".into()))
.and_then(|raw| parse_b256_field("jobHash", raw))?,
required_validation_tag: extra.required_validation_tag.unwrap_or_default(),
};
validation_policy.validation_request_hash = B256::from(
compute_validation_request_hash(&validation_policy).map_err(|e| {
X402Error::InvalidExtra(format!("invalid validation request policy: {e}"))
})?,
);
PaymentGuaranteeRequestClaimsV2::builder(
user_address.to_string(),
requirements.pay_to.to_string(),
payment_context.tab_id,
payment_context.req_id,
payment_context.amount,
payment_context.timestamp,
)
.asset_address(requirements.asset.to_string())
.validation_policy(validation_policy)
.build()
.map_err(|e| X402Error::InvalidExtra(e.to_string()))
}
fn build_payment_context(
requirements: &impl X402PaymentRequirements,
tab: &TabResponse,
user_address: &str,
) -> Result<PaymentContext, X402Error> {
let tab_id = parse_u256_field("tabId", &tab.tab_id)?;
let req_id = match tab.next_req_id.as_deref() {
Some(raw) => parse_u256_field("nextReqId", raw)?,
None => U256::ZERO,
};
let amount = parse_u256_field("amount", requirements.amount())?;
if !tab.user_address.eq_ignore_ascii_case(user_address) {
return Err(X402Error::UserMismatch {
found: tab.user_address.clone(),
expected: user_address.to_string(),
});
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
Ok(PaymentContext {
tab_id,
req_id,
amount,
timestamp,
})
}
}
#[derive(Debug, Clone, Copy)]
struct PaymentContext {
tab_id: U256,
req_id: U256,
amount: U256,
timestamp: u64,
}
fn parse_payment_requirements_extra(
requirements: &impl X402PaymentRequirements,
) -> Result<PaymentRequirementsExtra, X402Error> {
match requirements.extra() {
Some(extra) => serde_json::from_value(extra.clone())
.map_err(|e| X402Error::InvalidExtra(e.to_string())),
None => Err(X402Error::InvalidExtra("extra is required".into())),
}
}
fn parse_u256_field(field: &str, raw: &str) -> Result<U256, X402Error> {
let trimmed = raw.trim();
let value = if let Some(rest) = trimmed.strip_prefix("0x") {
U256::from_str_radix(rest, 16)
} else {
U256::from_str_radix(trimmed, 10)
};
value.map_err(|e| X402Error::InvalidNumber {
field: field.to_string(),
source: e.into(),
})
}
fn parse_b256_field(field: &str, raw: &str) -> Result<B256, X402Error> {
let trimmed = raw.trim();
<B256 as std::str::FromStr>::from_str(trimmed).map_err(|e| X402Error::InvalidNumber {
field: field.to_string(),
source: e.into(),
})
}