use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rs_merkle::{MerkleProof, algorithms::Sha256 as MerkleSha256};
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use stateset_crypto::pqc::{
HybridSignatureBundle as PqcHybridSignatureBundle, HybridSigningKeypair,
HybridSigningPublicKey, StrictSigningKeypair, StrictSigningPublicKey, hybrid_sign_event_hash,
hybrid_verify_event_signature, strict_sign_event_hash, strict_verify_event_signature,
};
use strum::{Display, EnumString};
use thiserror::Error;
use uuid::Uuid;
pub const X402_VERSION: &str = "1.0";
pub const X402_DOMAIN_SEPARATOR: &str = "X402_PAYMENT_V1";
pub const X402_MAX_VALIDITY_SECONDS: u64 = 86400;
pub const X402_DEFAULT_VALIDITY_SECONDS: u64 = 3600;
pub const X402_DEFAULT_SIGNATURE_SCHEME: X402SignatureScheme = X402SignatureScheme::Ed25519MlDsa65;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402Network {
#[default]
#[strum(serialize = "set_chain", serialize = "set", serialize = "ssc")]
SetChain,
#[strum(serialize = "set_chain_testnet", serialize = "set_testnet")]
SetChainTestnet,
Base,
Arc,
#[strum(serialize = "arc_testnet", serialize = "arc-testnet")]
ArcTestnet,
BaseSepolia,
#[strum(serialize = "ethereum", serialize = "eth", serialize = "mainnet")]
Ethereum,
#[strum(serialize = "ethereum_sepolia", serialize = "sepolia")]
EthereumSepolia,
#[strum(serialize = "arbitrum", serialize = "arb")]
Arbitrum,
#[strum(serialize = "optimism", serialize = "op")]
Optimism,
}
impl X402Network {
#[must_use]
pub const fn chain_id(&self) -> u64 {
match self {
Self::SetChain => 84532001, Self::SetChainTestnet => 84532002, Self::Arc => 5042001, Self::ArcTestnet => 5042002, Self::Base => 8453,
Self::BaseSepolia => 84532,
Self::Ethereum => 1,
Self::EthereumSepolia => 11155111,
Self::Arbitrum => 42161,
Self::Optimism => 10,
}
}
#[must_use]
pub const fn is_testnet(&self) -> bool {
matches!(
self,
Self::SetChainTestnet | Self::ArcTestnet | Self::BaseSepolia | Self::EthereumSepolia
)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402Asset {
#[default]
#[strum(serialize = "USDC")]
Usdc,
#[strum(serialize = "USDT", serialize = "TETHER")]
Usdt,
#[serde(rename = "ssusd", alias = "ss_usd")]
#[strum(serialize = "ssUSD", serialize = "SSUSD", serialize = "SS_USD")]
SsUsd,
#[serde(rename = "wssusd", alias = "wss_usd")]
#[strum(serialize = "wssUSD", serialize = "WSSUSD", serialize = "WSS_USD")]
WssUsd,
#[strum(serialize = "DAI")]
Dai,
#[strum(serialize = "ETH", serialize = "ETHER")]
Eth,
}
impl X402Asset {
#[must_use]
pub const fn decimals(&self) -> u8 {
match self {
Self::Usdc | Self::Usdt | Self::SsUsd | Self::WssUsd => 6,
Self::Dai | Self::Eth => 18,
}
}
#[must_use]
pub const fn contract_address(&self, network: X402Network) -> Option<&'static str> {
match (self, network) {
(Self::Usdc, X402Network::SetChain) => {
Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
}
(Self::SsUsd, X402Network::SetChain) => {
Some("0x0000000000000000000000000000000000001001")
}
(Self::Usdc, X402Network::Arc | X402Network::ArcTestnet) => {
Some("0x3600000000000000000000000000000000000000")
}
(Self::Usdc, X402Network::Base) => Some("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"),
(Self::Usdc, X402Network::Ethereum) => {
Some("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
}
(Self::Usdt, X402Network::Ethereum) => {
Some("0xdAC17F958D2ee523a2206206994597C13D831ec7")
}
(Self::Dai, X402Network::Ethereum) => Some("0x6B175474E89094C44Da98b954Ee4606eB48"),
(Self::Eth, _) => None,
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402IntentStatus {
#[default]
Created,
Signed,
Sequenced,
Batched,
Settled,
Expired,
Failed,
Cancelled,
}
impl std::str::FromStr for X402IntentStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"created" => Ok(Self::Created),
"signed" => Ok(Self::Signed),
"sequenced" => Ok(Self::Sequenced),
"batched" => Ok(Self::Batched),
"settled" => Ok(Self::Settled),
"expired" => Ok(Self::Expired),
"failed" => Ok(Self::Failed),
"cancelled" | "canceled" => Ok(Self::Cancelled),
_ => Err(format!("Unknown x402 intent status: {s}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402CreditDirection {
#[strum(serialize = "credit", serialize = "cr")]
Credit,
#[strum(serialize = "debit", serialize = "dr")]
Debit,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402SignatureScheme {
#[default]
Ed25519,
MlDsa65,
Ed25519MlDsa65,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct X402SignatureBundle {
#[serde(with = "hex")]
pub ml_dsa_65_signature: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct X402PublicKeyBundle {
#[serde(with = "hex")]
pub ml_dsa_65_public_key: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402PaymentIntent {
pub id: Uuid,
pub version: String,
pub status: X402IntentStatus,
pub payer_address: String,
pub payee_address: String,
pub amount: u64,
pub amount_decimal: Decimal,
pub asset: X402Asset,
pub network: X402Network,
pub chain_id: u64,
pub token_address: Option<String>,
pub created_at_unix: u64,
pub valid_until: u64,
pub nonce: u64,
pub idempotency_key: Option<String>,
pub resource_uri: Option<String>,
pub resource_method: Option<String>,
pub description: Option<String>,
pub cart_id: Option<Uuid>,
pub order_id: Option<Uuid>,
pub invoice_id: Option<Uuid>,
pub merchant_id: Option<String>,
pub signing_hash: Option<String>,
pub payer_signature_scheme: Option<X402SignatureScheme>,
pub payer_signature: Option<String>,
pub payer_public_key: Option<String>,
pub payer_signature_bundle: Option<X402SignatureBundle>,
pub payer_public_key_bundle: Option<X402PublicKeyBundle>,
pub sequence_number: Option<u64>,
pub sequenced_at: Option<DateTime<Utc>>,
pub batch_id: Option<Uuid>,
pub batch_merkle_root: Option<String>,
pub inclusion_proof: Option<Vec<String>>,
pub tx_hash: Option<String>,
pub block_number: Option<u64>,
pub gas_used: Option<u64>,
pub settled_at: Option<DateTime<Utc>>,
pub metadata: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl X402PaymentIntent {
pub fn new(
payer_address: impl Into<String>,
payee_address: impl Into<String>,
amount: u64,
asset: X402Asset,
network: X402Network,
) -> Self {
let now = Utc::now();
let now_unix = now.timestamp() as u64;
let decimals = asset.decimals();
let divisor = 10u64.pow(u32::from(decimals));
let amount_decimal = Decimal::from(amount) / Decimal::from(divisor);
Self {
id: Uuid::new_v4(),
version: X402_VERSION.to_string(),
status: X402IntentStatus::Created,
payer_address: payer_address.into(),
payee_address: payee_address.into(),
amount,
amount_decimal,
asset,
network,
chain_id: network.chain_id(),
token_address: asset.contract_address(network).map(String::from),
created_at_unix: now_unix,
valid_until: now_unix + X402_DEFAULT_VALIDITY_SECONDS,
nonce: 0,
idempotency_key: None,
resource_uri: None,
resource_method: None,
description: None,
cart_id: None,
order_id: None,
invoice_id: None,
merchant_id: None,
signing_hash: None,
payer_signature_scheme: Some(X402_DEFAULT_SIGNATURE_SCHEME),
payer_signature: None,
payer_public_key: None,
payer_signature_bundle: None,
payer_public_key_bundle: None,
sequence_number: None,
sequenced_at: None,
batch_id: None,
batch_merkle_root: None,
inclusion_proof: None,
tx_hash: None,
block_number: None,
gas_used: None,
settled_at: None,
metadata: None,
created_at: now,
updated_at: now,
}
}
#[must_use]
pub fn with_validity(mut self, seconds: u64) -> Self {
self.valid_until = self.created_at_unix + seconds.min(X402_MAX_VALIDITY_SECONDS);
self
}
#[must_use]
pub const fn with_nonce(mut self, nonce: u64) -> Self {
self.nonce = nonce;
self
}
pub fn with_resource(mut self, uri: impl Into<String>, method: impl Into<String>) -> Self {
self.resource_uri = Some(uri.into());
self.resource_method = Some(method.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub const fn with_order(mut self, order_id: Uuid) -> Self {
self.order_id = Some(order_id);
self
}
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
#[must_use]
pub fn is_expired(&self) -> bool {
let now = Utc::now().timestamp() as u64;
now > self.valid_until
}
#[must_use]
pub fn is_signed(&self) -> bool {
let has_signing_hash =
self.signing_hash.as_ref().is_some_and(|signing_hash| !signing_hash.is_empty());
if !has_signing_hash {
return false;
}
match self.signature_scheme() {
X402SignatureScheme::Ed25519 => {
self.payer_signature.as_ref().is_some_and(|signature| !signature.is_empty())
&& self
.payer_public_key
.as_ref()
.is_some_and(|public_key| !public_key.is_empty())
}
X402SignatureScheme::MlDsa65 => {
self.payer_signature_bundle.is_some() && self.payer_public_key_bundle.is_some()
}
X402SignatureScheme::Ed25519MlDsa65 => {
self.payer_signature.as_ref().is_some_and(|signature| !signature.is_empty())
&& self
.payer_public_key
.as_ref()
.is_some_and(|public_key| !public_key.is_empty())
&& self.payer_signature_bundle.is_some()
&& self.payer_public_key_bundle.is_some()
}
}
}
#[must_use]
pub fn is_settled(&self) -> bool {
self.status == X402IntentStatus::Settled && self.tx_hash.is_some()
}
pub fn try_canonical_signing_data(&self) -> Result<String, X402CryptoError> {
let payload = serde_json::json!({
"version": self.version,
"payer": self.payer_address,
"payee": self.payee_address,
"amount": self.amount.to_string(),
"asset": self.asset.to_string(),
"chainId": self.chain_id,
"tokenAddress": self.token_address,
"nonce": self.nonce,
"validUntil": self.valid_until,
"resourceUri": self.resource_uri,
"resourceMethod": self.resource_method,
});
serde_jcs::to_string(&payload).map_err(|e| X402CryptoError::Serialization(e.to_string()))
}
pub fn canonical_signing_data(&self) -> Result<String, X402CryptoError> {
self.try_canonical_signing_data()
}
#[must_use]
pub fn sequencer_signing_hash(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(X402_DOMAIN_SEPARATOR.as_bytes());
hasher.update(self.payer_address.as_bytes());
hasher.update(self.payee_address.as_bytes());
hasher.update(self.amount.to_be_bytes());
hasher.update(format!("{:?}", self.asset).to_lowercase().as_bytes());
hasher.update(self.network.to_string().as_bytes());
hasher.update(self.chain_id.to_be_bytes());
hasher.update(self.valid_until.to_be_bytes());
hasher.update(self.nonce.to_be_bytes());
match &self.resource_uri {
Some(uri) => {
hasher.update([1u8]);
hasher.update((uri.len() as u64).to_be_bytes());
hasher.update(uri.as_bytes());
}
None => hasher.update([0u8]),
}
match &self.resource_method {
Some(method) => {
hasher.update([1u8]);
hasher.update((method.len() as u64).to_be_bytes());
hasher.update(method.as_bytes());
}
None => hasher.update([0u8]),
}
let result = hasher.finalize();
let mut hash = [0u8; 32];
hash.copy_from_slice(&result);
hash
}
#[must_use]
pub fn signature_scheme(&self) -> X402SignatureScheme {
self.payer_signature_scheme.unwrap_or(X402SignatureScheme::Ed25519)
}
#[must_use]
pub fn allows_signing_scheme(&self, requested: X402SignatureScheme) -> bool {
match self.payer_signature_scheme {
Some(configured) => configured == requested,
None => true,
}
}
pub fn sign_with_ed25519(&mut self, private_key: &[u8; 32]) -> Result<(), X402CryptoError> {
let signing_hash = self.sequencer_signing_hash();
let signing_key = SigningKey::from_bytes(private_key);
let signature = signing_key.sign(&signing_hash);
let public_key = signing_key.verifying_key();
self.signing_hash = Some(hex0x(signing_hash));
self.payer_signature_scheme = Some(X402SignatureScheme::Ed25519);
self.payer_signature = Some(hex0x(signature.to_bytes()));
self.payer_public_key = Some(hex0x(public_key.to_bytes()));
self.payer_signature_bundle = None;
self.payer_public_key_bundle = None;
self.status = X402IntentStatus::Signed;
Ok(())
}
pub fn sign_with_hybrid(
&mut self,
keypair: &HybridSigningKeypair,
) -> Result<(), X402CryptoError> {
let signing_hash = self.sequencer_signing_hash();
let signature = hybrid_sign_event_hash(&signing_hash, &keypair.private)
.map_err(|e| X402CryptoError::InvalidKey(e.to_string()))?;
self.signing_hash = Some(hex0x(signing_hash));
self.payer_signature_scheme = Some(X402SignatureScheme::Ed25519MlDsa65);
self.payer_signature = Some(hex0x(signature.ed25519_signature));
self.payer_public_key = Some(hex0x(keypair.public.ed25519_public_key));
self.payer_signature_bundle =
Some(X402SignatureBundle { ml_dsa_65_signature: signature.ml_dsa_65_signature });
self.payer_public_key_bundle = Some(X402PublicKeyBundle {
ml_dsa_65_public_key: keypair.public.ml_dsa_65_public_key.clone(),
});
self.status = X402IntentStatus::Signed;
Ok(())
}
pub fn sign_with_strict(
&mut self,
keypair: &StrictSigningKeypair,
) -> Result<(), X402CryptoError> {
let signing_hash = self.sequencer_signing_hash();
let signature = strict_sign_event_hash(&signing_hash, &keypair.private)
.map_err(|e| X402CryptoError::InvalidKey(e.to_string()))?;
self.signing_hash = Some(hex0x(signing_hash));
self.payer_signature_scheme = Some(X402SignatureScheme::MlDsa65);
self.payer_signature = None;
self.payer_public_key = None;
self.payer_signature_bundle = Some(X402SignatureBundle { ml_dsa_65_signature: signature });
self.payer_public_key_bundle = Some(X402PublicKeyBundle {
ml_dsa_65_public_key: keypair.public.ml_dsa_65_public_key.clone(),
});
self.status = X402IntentStatus::Signed;
Ok(())
}
pub fn verify_signature(&self) -> Result<bool, X402CryptoError> {
let signing_hash = self.sequencer_signing_hash();
let stored_hash =
self.signing_hash.as_deref().ok_or(X402CryptoError::MissingField("signing_hash"))?;
if decode_hex_array::<32>(stored_hash)? != signing_hash {
return Ok(false);
}
match self.signature_scheme() {
X402SignatureScheme::Ed25519 => {
let signature_hex = self
.payer_signature
.as_deref()
.ok_or(X402CryptoError::MissingField("payer_signature"))?;
let public_key_hex = self
.payer_public_key
.as_deref()
.ok_or(X402CryptoError::MissingField("payer_public_key"))?;
let signature = Signature::from_bytes(&decode_hex_array::<64>(signature_hex)?);
let public_key = VerifyingKey::from_bytes(&decode_hex_array::<32>(public_key_hex)?)
.map_err(|e| X402CryptoError::InvalidKey(e.to_string()))?;
Ok(public_key.verify(&signing_hash, &signature).is_ok())
}
X402SignatureScheme::MlDsa65 => {
let signature_bundle = self
.payer_signature_bundle
.as_ref()
.ok_or(X402CryptoError::MissingField("payer_signature_bundle"))?;
let public_key_bundle = self
.payer_public_key_bundle
.as_ref()
.ok_or(X402CryptoError::MissingField("payer_public_key_bundle"))?;
let public_key = StrictSigningPublicKey {
ml_dsa_65_public_key: public_key_bundle.ml_dsa_65_public_key.clone(),
};
Ok(strict_verify_event_signature(
&signing_hash,
&signature_bundle.ml_dsa_65_signature,
&public_key,
))
}
X402SignatureScheme::Ed25519MlDsa65 => {
let signature_hex = self
.payer_signature
.as_deref()
.ok_or(X402CryptoError::MissingField("payer_signature"))?;
let public_key_hex = self
.payer_public_key
.as_deref()
.ok_or(X402CryptoError::MissingField("payer_public_key"))?;
let signature_bundle = self
.payer_signature_bundle
.as_ref()
.ok_or(X402CryptoError::MissingField("payer_signature_bundle"))?;
let public_key_bundle = self
.payer_public_key_bundle
.as_ref()
.ok_or(X402CryptoError::MissingField("payer_public_key_bundle"))?;
let signature = PqcHybridSignatureBundle {
ed25519_signature: decode_hex_array::<64>(signature_hex)?,
ml_dsa_65_signature: signature_bundle.ml_dsa_65_signature.clone(),
};
let public_key = HybridSigningPublicKey {
ed25519_public_key: decode_hex_array::<32>(public_key_hex)?,
ml_dsa_65_public_key: public_key_bundle.ml_dsa_65_public_key.clone(),
};
Ok(hybrid_verify_event_signature(&signing_hash, &signature, &public_key))
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402PaymentRequired {
pub version: String,
pub payee_address: String,
pub amount: u64,
pub amount_display: String,
pub asset: X402Asset,
pub networks: Vec<X402Network>,
pub resource_uri: String,
pub resource_method: String,
pub description: Option<String>,
pub validity_seconds: u64,
pub merchant_id: Option<String>,
pub merchant_name: Option<String>,
pub terms: Option<String>,
pub generated_at: DateTime<Utc>,
}
impl X402PaymentRequired {
pub fn new(
payee_address: impl Into<String>,
amount: u64,
asset: X402Asset,
resource_uri: impl Into<String>,
resource_method: impl Into<String>,
) -> Self {
let decimals = asset.decimals();
let divisor = 10u64.pow(u32::from(decimals));
let decimal_amount = Decimal::from(amount) / Decimal::from(divisor);
let amount_display = format!("{decimal_amount:.6} {asset}");
Self {
version: X402_VERSION.to_string(),
payee_address: payee_address.into(),
amount,
amount_display,
asset,
networks: vec![X402Network::SetChain, X402Network::Base],
resource_uri: resource_uri.into(),
resource_method: resource_method.into(),
description: None,
validity_seconds: X402_DEFAULT_VALIDITY_SECONDS,
merchant_id: None,
merchant_name: None,
terms: None,
generated_at: Utc::now(),
}
}
#[must_use]
pub fn with_networks(mut self, networks: Vec<X402Network>) -> Self {
self.networks = networks;
self
}
pub fn with_merchant(mut self, id: impl Into<String>, name: impl Into<String>) -> Self {
self.merchant_id = Some(id.into());
self.merchant_name = Some(name.into());
self
}
pub fn try_to_header_value(&self) -> std::result::Result<String, serde_json::Error> {
use base64::{Engine, engine::general_purpose::STANDARD};
let json = serde_json::to_string(self)?;
Ok(STANDARD.encode(json.as_bytes()))
}
pub fn to_header_value(&self) -> std::result::Result<String, serde_json::Error> {
self.try_to_header_value()
}
pub fn from_header_value(value: &str) -> Result<Self, String> {
use base64::{Engine, engine::general_purpose::STANDARD};
let bytes = STANDARD.decode(value).map_err(|e| format!("Invalid base64: {e}"))?;
let json = String::from_utf8(bytes).map_err(|e| format!("Invalid UTF-8: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Invalid JSON: {e}"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402PaymentReceipt {
#[serde(alias = "id")]
pub receipt_id: Uuid,
pub intent_id: Uuid,
pub sequence_number: u64,
pub batch_id: Uuid,
pub merkle_root: String,
pub inclusion_proof: Vec<String>,
pub leaf_index: u32,
pub total_leaves: u32,
pub tx_hash: Option<String>,
pub block_number: Option<u64>,
pub payer_address: String,
pub payee_address: String,
pub amount: u64,
pub asset: X402Asset,
pub network: X402Network,
pub chain_id: u64,
pub nonce: u64,
pub valid_until: u64,
pub signing_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payer_signature_scheme: Option<X402SignatureScheme>,
pub payer_signature: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payer_signature_bundle: Option<X402SignatureBundle>,
pub created_at: DateTime<Utc>,
}
impl X402PaymentReceipt {
#[must_use]
pub fn verify_inclusion(&self) -> bool {
if self.merkle_root.is_empty() {
return false;
}
if self.total_leaves == 0 || self.leaf_index >= self.total_leaves {
return false;
}
let root = match decode_hex_array::<32>(&self.merkle_root) {
Ok(bytes) => bytes,
Err(_) => return false,
};
let leaf = match payment_leaf_hash(self) {
Ok(hash) => hash,
Err(_) => return false,
};
let mut proof_hashes = Vec::with_capacity(self.inclusion_proof.len());
for proof_hash in &self.inclusion_proof {
match decode_hex_array::<32>(proof_hash) {
Ok(hash) => proof_hashes.push(hash),
Err(_) => return false,
}
}
let proof = MerkleProof::<MerkleSha256>::new(proof_hashes);
proof.verify(root, &[self.leaf_index as usize], &[leaf], self.total_leaves as usize)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402CreditAccount {
pub id: Uuid,
pub payer_address: String,
pub asset: X402Asset,
pub network: X402Network,
pub balance: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateX402CreditAccount {
pub payer_address: String,
pub asset: X402Asset,
pub network: X402Network,
pub initial_balance: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402CreditAdjustment {
pub payer_address: String,
pub asset: X402Asset,
pub network: X402Network,
pub direction: X402CreditDirection,
pub amount: u64,
pub reason: Option<String>,
pub reference_id: Option<String>,
pub metadata: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402CreditTransaction {
pub id: Uuid,
pub account_id: Uuid,
pub payer_address: String,
pub asset: X402Asset,
pub network: X402Network,
pub direction: X402CreditDirection,
pub amount: u64,
pub balance_after: u64,
pub reason: Option<String>,
pub reference_id: Option<String>,
pub metadata: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct X402CreditTransactionFilter {
pub payer_address: Option<String>,
pub asset: Option<X402Asset>,
pub network: Option<X402Network>,
pub direction: Option<X402CreditDirection>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum X402BatchStatus {
#[default]
Pending,
Committed,
Settling,
Settled,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct X402PaymentBatch {
pub id: Uuid,
pub status: X402BatchStatus,
pub network: X402Network,
pub payment_count: u32,
pub total_amounts: Vec<(X402Asset, u64)>,
pub merkle_root: Option<String>,
pub prev_state_root: Option<String>,
pub new_state_root: Option<String>,
pub sequence_start: u64,
pub sequence_end: u64,
pub tx_hash: Option<String>,
pub block_number: Option<u64>,
pub gas_used: Option<u64>,
pub created_at: DateTime<Utc>,
pub committed_at: Option<DateTime<Utc>>,
pub settled_at: Option<DateTime<Utc>>,
}
impl X402PaymentBatch {
#[must_use]
pub fn new(network: X402Network) -> Self {
Self {
id: Uuid::new_v4(),
status: X402BatchStatus::Pending,
network,
payment_count: 0,
total_amounts: Vec::new(),
merkle_root: None,
prev_state_root: None,
new_state_root: None,
sequence_start: 0,
sequence_end: 0,
tx_hash: None,
block_number: None,
gas_used: None,
created_at: Utc::now(),
committed_at: None,
settled_at: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateX402PaymentIntent {
pub payer_address: String,
pub payee_address: String,
pub amount: u64,
pub asset: X402Asset,
pub network: X402Network,
pub nonce: Option<u64>,
pub validity_seconds: Option<u64>,
pub resource_uri: Option<String>,
pub resource_method: Option<String>,
pub description: Option<String>,
pub cart_id: Option<Uuid>,
pub order_id: Option<Uuid>,
pub invoice_id: Option<Uuid>,
pub merchant_id: Option<String>,
pub idempotency_key: Option<String>,
pub metadata: Option<String>,
pub signature_scheme: Option<X402SignatureScheme>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignX402PaymentIntent {
pub intent_id: Uuid,
pub signature_scheme: Option<X402SignatureScheme>,
pub signature: String,
pub public_key: String,
pub signature_bundle: Option<X402SignatureBundle>,
pub public_key_bundle: Option<X402PublicKeyBundle>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct X402PaymentIntentFilter {
pub payer_address: Option<String>,
pub payee_address: Option<String>,
pub status: Option<X402IntentStatus>,
pub network: Option<X402Network>,
pub asset: Option<X402Asset>,
pub order_id: Option<Uuid>,
pub batch_id: Option<Uuid>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[must_use]
pub fn generate_x402_intent_id() -> Uuid {
Uuid::new_v4()
}
#[must_use]
pub fn to_smallest_unit(amount: Decimal, asset: X402Asset) -> u64 {
if amount <= Decimal::ZERO {
return 0;
}
let decimals = asset.decimals();
let multiplier = Decimal::from(10u64.pow(u32::from(decimals)));
let scaled = amount * multiplier;
if scaled.fract() != Decimal::ZERO {
return scaled.ceil().to_u64().unwrap_or(u64::MAX);
}
scaled.to_u64().unwrap_or(0)
}
#[must_use]
pub fn from_smallest_unit(amount: u64, asset: X402Asset) -> Decimal {
let decimals = asset.decimals();
let divisor = Decimal::from(10u64.pow(u32::from(decimals)));
Decimal::from(amount) / divisor
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum X402CryptoError {
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("serialization error: {0}")]
Serialization(String),
#[error("invalid hex: {0}")]
InvalidHex(String),
#[error("invalid length: expected {expected}, got {got}")]
InvalidLength { expected: usize, got: usize },
#[error("invalid key: {0}")]
InvalidKey(String),
}
fn hex0x(bytes: impl AsRef<[u8]>) -> String {
format!("0x{}", hex::encode(bytes))
}
fn decode_hex_array<const N: usize>(value: &str) -> Result<[u8; N], X402CryptoError> {
let trimmed = value.strip_prefix("0x").unwrap_or(value);
let bytes = hex::decode(trimmed).map_err(|e| X402CryptoError::InvalidHex(e.to_string()))?;
if bytes.len() != N {
return Err(X402CryptoError::InvalidLength { expected: N, got: bytes.len() });
}
let mut arr = [0u8; N];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn decode_hex_bytes(value: &str) -> Result<Vec<u8>, X402CryptoError> {
let trimmed = value.strip_prefix("0x").unwrap_or(value);
hex::decode(trimmed).map_err(|e| X402CryptoError::InvalidHex(e.to_string()))
}
fn normalize_optional_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn update_optional_leaf_bytes(
hasher: &mut Sha256,
bytes: Option<&[u8]>,
) -> Result<(), X402CryptoError> {
match bytes {
Some(bytes) => {
let len = u64::try_from(bytes.len())
.map_err(|_| X402CryptoError::Serialization("byte slice too large".to_string()))?;
hasher.update([1u8]);
hasher.update(len.to_be_bytes());
hasher.update(bytes);
}
None => hasher.update([0u8]),
}
Ok(())
}
fn payment_leaf_hash(receipt: &X402PaymentReceipt) -> Result<[u8; 32], X402CryptoError> {
let mut hasher = Sha256::new();
hasher.update(receipt.intent_id.as_bytes());
hasher.update(receipt.sequence_number.to_be_bytes());
hasher.update(receipt.payer_address.as_bytes());
hasher.update(receipt.payee_address.as_bytes());
hasher.update(receipt.amount.to_be_bytes());
hasher.update(receipt.asset.to_string().to_lowercase().as_bytes());
hasher.update(receipt.network.to_string().as_bytes());
hasher.update(receipt.chain_id.to_be_bytes());
hasher.update(receipt.nonce.to_be_bytes());
hasher.update(receipt.valid_until.to_be_bytes());
let signing_hash = decode_hex_array::<32>(&receipt.signing_hash)?;
let legacy_signature =
normalize_optional_string(&receipt.payer_signature).map(|sig| decode_hex_bytes(&sig));
let legacy_signature = legacy_signature.transpose()?;
hasher.update(signing_hash);
hasher.update(
receipt
.payer_signature_scheme
.unwrap_or(X402SignatureScheme::Ed25519)
.to_string()
.as_bytes(),
);
update_optional_leaf_bytes(&mut hasher, legacy_signature.as_deref())?;
update_optional_leaf_bytes(
&mut hasher,
receipt.payer_signature_bundle.as_ref().map(|bundle| bundle.ml_dsa_65_signature.as_slice()),
)?;
let result = hasher.finalize();
let mut hash = [0u8; 32];
hash.copy_from_slice(&result);
Ok(hash)
}
#[cfg(test)]
mod tests {
use super::*;
use stateset_crypto::pqc::{generate_hybrid_signing_keypair, generate_strict_signing_keypair};
#[test]
fn test_x402_payment_intent_creation() {
let intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000, X402Asset::Usdc,
X402Network::SetChain,
);
assert_eq!(intent.amount, 1_000_000);
assert_eq!(intent.amount_decimal, Decimal::from(1));
assert_eq!(intent.asset, X402Asset::Usdc);
assert_eq!(intent.network, X402Network::SetChain);
assert_eq!(intent.chain_id, 84532001);
assert_eq!(intent.payer_signature_scheme, Some(X402_DEFAULT_SIGNATURE_SCHEME));
assert_eq!(intent.signature_scheme(), X402_DEFAULT_SIGNATURE_SCHEME);
assert!(!intent.is_expired());
assert!(!intent.is_signed());
}
#[test]
fn test_x402_legacy_rows_fall_back_to_ed25519() {
let mut intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000,
X402Asset::Usdc,
X402Network::SetChain,
);
intent.payer_signature_scheme = None;
assert_eq!(intent.signature_scheme(), X402SignatureScheme::Ed25519);
}
#[test]
fn test_x402_new_intents_require_configured_signature_scheme() {
let intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000,
X402Asset::Usdc,
X402Network::SetChain,
);
assert!(intent.allows_signing_scheme(X402_DEFAULT_SIGNATURE_SCHEME));
assert!(!intent.allows_signing_scheme(X402SignatureScheme::Ed25519));
}
#[test]
fn test_x402_network_chain_ids() {
assert_eq!(X402Network::SetChain.chain_id(), 84532001);
assert_eq!(X402Network::Base.chain_id(), 8453);
assert_eq!(X402Network::Ethereum.chain_id(), 1);
}
#[test]
fn test_x402_asset_decimals() {
assert_eq!(X402Asset::Usdc.decimals(), 6);
assert_eq!(X402Asset::Dai.decimals(), 18);
assert_eq!(X402Asset::Eth.decimals(), 18);
}
#[test]
fn test_amount_conversion() {
let decimal = Decimal::from(100);
let smallest = to_smallest_unit(decimal, X402Asset::Usdc);
assert_eq!(smallest, 100_000_000);
let back = from_smallest_unit(smallest, X402Asset::Usdc);
assert_eq!(back, decimal);
}
#[test]
fn test_amount_conversion_rounds_up_sub_precision() {
let decimal = Decimal::new(1, 7); let smallest = to_smallest_unit(decimal, X402Asset::Usdc);
assert_eq!(smallest, 1);
}
#[test]
fn test_amount_conversion_non_positive_maps_to_zero() {
assert_eq!(to_smallest_unit(Decimal::ZERO, X402Asset::Usdc), 0);
assert_eq!(to_smallest_unit(Decimal::new(-1, 0), X402Asset::Usdc), 0);
}
#[test]
fn test_signature_fails_when_resource_binding_changes() {
let mut intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000,
X402Asset::Usdc,
X402Network::SetChain,
)
.with_resource("/a", "GET")
.with_nonce(42);
intent.sign_with_ed25519(&[7u8; 32]).unwrap();
assert!(intent.verify_signature().unwrap());
let mut replayed = intent.clone();
replayed.resource_uri = Some("/premium".to_string());
assert!(!replayed.verify_signature().unwrap());
let mut method_changed = intent;
method_changed.resource_method = Some("POST".to_string());
assert!(!method_changed.verify_signature().unwrap());
}
#[test]
#[cfg_attr(miri, ignore = "hybrid PQC signature verification is too slow under Miri")]
fn test_hybrid_signature_verifies() {
let mut intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000,
X402Asset::Usdc,
X402Network::SetChain,
)
.with_resource("/hybrid", "POST")
.with_nonce(7);
let keypair = generate_hybrid_signing_keypair().unwrap();
intent.sign_with_hybrid(&keypair).unwrap();
assert_eq!(intent.payer_signature_scheme, Some(X402SignatureScheme::Ed25519MlDsa65));
assert!(intent.payer_signature_bundle.is_some());
assert!(intent.payer_public_key_bundle.is_some());
assert!(intent.verify_signature().unwrap());
}
#[test]
#[cfg_attr(miri, ignore = "strict PQC signature verification is too slow under Miri")]
fn test_strict_signature_verifies() {
let mut intent = X402PaymentIntent::new(
"0x1234567890abcdef1234567890abcdef12345678",
"0xabcdef1234567890abcdef1234567890abcdef12",
1_000_000,
X402Asset::Usdc,
X402Network::SetChain,
)
.with_resource("/strict", "POST")
.with_nonce(9);
let keypair = generate_strict_signing_keypair().unwrap();
intent.sign_with_strict(&keypair).unwrap();
assert_eq!(intent.payer_signature_scheme, Some(X402SignatureScheme::MlDsa65));
assert!(intent.payer_signature.is_none());
assert!(intent.payer_public_key.is_none());
assert!(intent.verify_signature().unwrap());
}
#[test]
fn test_x402_payment_required_header() {
let req =
X402PaymentRequired::new("0xpayee", 1_000_000, X402Asset::Usdc, "/api/resource", "GET");
let header = req.to_header_value().unwrap();
let decoded = X402PaymentRequired::from_header_value(&header).unwrap();
assert_eq!(decoded.payee_address, "0xpayee");
assert_eq!(decoded.amount, 1_000_000);
}
#[test]
fn test_x402_merkle_inclusion_verification() {
let mut receipt = X402PaymentReceipt {
receipt_id: Uuid::new_v4(),
intent_id: Uuid::new_v4(),
sequence_number: 42,
batch_id: Uuid::new_v4(),
merkle_root: String::new(),
inclusion_proof: vec![],
leaf_index: 0,
total_leaves: 0,
tx_hash: None,
block_number: None,
payer_address: "0xpayer".to_string(),
payee_address: "0xpayee".to_string(),
amount: 1_000_000,
asset: X402Asset::Usdc,
network: X402Network::SetChain,
chain_id: X402Network::SetChain.chain_id(),
nonce: 7,
valid_until: 1_705_320_000,
signing_hash: format!("0x{}", "11".repeat(32)),
payer_signature_scheme: Some(X402SignatureScheme::Ed25519),
payer_signature: format!("0x{}", "22".repeat(64)),
payer_signature_bundle: None,
created_at: Utc::now(),
};
let mut other = receipt.clone();
other.intent_id = Uuid::new_v4();
other.sequence_number = 43;
other.nonce = 8;
other.signing_hash = format!("0x{}", "33".repeat(32));
other.payer_signature = format!("0x{}", "44".repeat(64));
let leaf = payment_leaf_hash(&receipt).unwrap();
let other_leaf = payment_leaf_hash(&other).unwrap();
let leaves = vec![leaf, other_leaf];
let tree = rs_merkle::MerkleTree::<MerkleSha256>::from_leaves(&leaves);
let root = tree.root().expect("merkle root");
let proof = tree.proof(&[0]);
receipt.inclusion_proof =
proof.proof_hashes().iter().map(|h| format!("0x{}", hex::encode(h))).collect();
receipt.merkle_root = format!("0x{}", hex::encode(root));
receipt.total_leaves = leaves.len() as u32;
receipt.leaf_index = 0;
assert!(receipt.verify_inclusion());
}
}