Rust SDK Reference#

Rust SDK Reference (for exact, aggr_deferred)#

Crate#

CrateDescription
x402-coreCore: server, facilitator client, types, HTTP utilities, HMAC auth
x402-axumAxum middleware (Tower Layer/Service)
x402-evmEVM mechanisms: exact, aggr_deferred

The Rust SDK currently provides server-side (seller) and facilitator-client functionality. Buyer-side payment signing is on the roadmap.


Core types#

Network / Money / Price#

rust
pub type Network = String;
// CAIP-2 format, e.g., "eip155:196"

pub type Money = String;
// User-friendly amount, e.g., "$0.01", "0.01"

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Price {
    Money(Money),
    Asset(AssetAmount),
}

AssetAmount#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetAmount {
    pub asset: String,      // Token contract address
    pub amount: String,     // Amount in token's smallest unit
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extra: Option<HashMap<String, serde_json::Value>>,
}

ResourceInfo#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceInfo {
    pub url: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mime_type: Option<String>,
}

PaymentRequirements#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentRequirements {
    pub scheme: String,              // "exact" | "aggr_deferred"
    pub network: Network,            // CAIP-2 identifier
    pub asset: String,               // Token contract address
    pub amount: String,              // Price in token's smallest unit
    pub pay_to: String,              // Recipient wallet address
    pub max_timeout_seconds: u64,    // Authorization validity window
    #[serde(default)]
    pub extra: HashMap<String, serde_json::Value>,  // Scheme-specific data
}

PaymentRequired#

The 402 response body.

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentRequired {
    #[serde(rename = "x402Version")]
    pub x402_version: u32,           // Protocol version (2)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    pub resource: ResourceInfo,
    pub accepts: Vec<PaymentRequirements>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extensions: Option<HashMap<String, serde_json::Value>>,
}

PaymentPayload#

The client's signed payment.

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentPayload {
    #[serde(rename = "x402Version")]
    pub x402_version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resource: Option<ResourceInfo>,
    pub accepted: PaymentRequirements,
    pub payload: HashMap<String, serde_json::Value>,  // Scheme-specific signed data
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extensions: Option<HashMap<String, serde_json::Value>>,
}

Facilitator types#

VerifyRequest / VerifyResponse#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyRequest {
    #[serde(rename = "x402Version")]
    pub x402_version: u32,
    pub payment_payload: PaymentPayload,
    pub payment_requirements: PaymentRequirements,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyResponse {
    pub is_valid: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub invalid_reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub invalid_message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payer: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extensions: Option<HashMap<String, serde_json::Value>>,
}

SettleRequest / SettleResponse#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettleRequest {
    #[serde(rename = "x402Version")]
    pub x402_version: u32,
    pub payment_payload: PaymentPayload,
    pub payment_requirements: PaymentRequirements,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sync_settle: Option<bool>,   // OKX extension: wait for on-chain confirmation
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettleResponse {
    pub success: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payer: Option<String>,
    pub transaction: String,          // Tx hash (empty for aggr_deferred)
    pub network: Network,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub amount: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,       // OKX: "pending" | "success" | "timeout"
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extensions: Option<HashMap<String, serde_json::Value>>,
}

SupportedKind / SupportedResponse#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SupportedKind {
    #[serde(rename = "x402Version")]
    pub x402_version: u32,
    pub scheme: String,
    pub network: Network,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extra: Option<HashMap<String, serde_json::Value>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportedResponse {
    pub kinds: Vec<SupportedKind>,
    pub extensions: Vec<String>,
    pub signers: HashMap<String, Vec<String>>,
}

SettleStatusResponse#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettleStatusResponse {
    pub success: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_reason: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error_message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payer: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub transaction: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network: Option<Network>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,       // "pending" | "success" | "failed"
}

Traits#

SchemeNetworkServer#

The server-side scheme implementation.

rust
#[async_trait]
pub trait SchemeNetworkServer: Send + Sync {
    fn scheme(&self) -> &str;

    async fn parse_price(
        &self,
        price: &Price,
        network: &Network,
    ) -> Result<AssetAmount, X402Error>;

    async fn enhance_payment_requirements(
        &self,
        payment_requirements: PaymentRequirements,
        supported_kind: &SupportedKind,
        facilitator_extensions: &[String],
    ) -> Result<PaymentRequirements, X402Error>;
}

FacilitatorClient#

The network boundary for talking to a remote facilitator.

rust
#[async_trait]
pub trait FacilitatorClient: Send + Sync {
    async fn get_supported(&self) -> Result<SupportedResponse, X402Error>;
    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, X402Error>;
    async fn settle(&self, request: &SettleRequest) -> Result<SettleResponse, X402Error>;
    async fn get_settle_status(&self, tx_hash: &str) -> Result<SettleStatusResponse, X402Error>;
}

ResourceServerExtension#

rust
#[async_trait]
pub trait ResourceServerExtension: Send + Sync {
    fn key(&self) -> &str;

    async fn enrich_payment_required(
        &self,
        payment_required: PaymentRequired,
        context: &PaymentRequiredContext,
    ) -> PaymentRequired;

    async fn enrich_verify_extensions(
        &self,
        extensions: HashMap<String, serde_json::Value>,
        payment_payload: &PaymentPayload,
        payment_requirements: &PaymentRequirements,
    ) -> HashMap<String, serde_json::Value>;

    async fn enrich_settle_extensions(
        &self,
        extensions: HashMap<String, serde_json::Value>,
        payment_payload: &PaymentPayload,
        payment_requirements: &PaymentRequirements,
    ) -> HashMap<String, serde_json::Value>;
}

pub struct PaymentRequiredContext {
    pub url: String,
    pub method: String,
}

pub struct SettleResultContext {
    pub url: String,
    pub method: String,
    pub payment_payload: PaymentPayload,
    pub payment_requirements: PaymentRequirements,
    pub settle_response: SettleResponse,
}

FacilitatorExtension#

rust
#[async_trait]
pub trait FacilitatorExtension: Send + Sync {
    fn key(&self) -> &str;
    fn supported_networks(&self) -> Vec<Network>;
}

Server API (X402ResourceServer)#

Constructor & registration#

rust
use x402_core::server::X402ResourceServer;
use x402_evm::{ExactEvmScheme, AggrDeferredEvmScheme};

// register() uses builder pattern (consumes self, returns Self)
let mut server = X402ResourceServer::new(facilitator)
  .register("eip155:196", ExactEvmScheme::new())
  .register("eip155:196", AggrDeferredEvmScheme::new());

Methods#

rust
use std::collections::HashMap;
use std::time::Duration;                                                                                                                                                                              

use x402_core::error::X402Error;                                                                                                                                                                      
use x402_core::facilitator::FacilitatorClient;
use x402_core::http::{PollResult, SettlementOverrides};
use x402_core::server::X402ResourceServer;                                                                                                                                                            
use x402_core::types::{
  PaymentPayload, PaymentRequirements, ResourceInfo, SchemeNetworkServer,
  SettleResponse, SupportedResponse, VerifyResponse,
};                                                                                                                                                                                                    
              
impl X402ResourceServer {
    pub fn new(facilitator: impl FacilitatorClient + 'static) -> Self;

    pub fn register(
      mut self,
      network: &str,
      scheme: impl SchemeNetworkServer + 'static,
    ) -> Self;

    pub async fn initialize(&mut self) -> Result<(), X402Error>;

    pub fn supported(&self) -> Option<&SupportedResponse>;
    pub fn facilitator(&self) -> &dyn FacilitatorClient;

    pub async fn build_payment_requirements(
      &self,
      scheme: &str,        // "exact"
      network: &str,       // "eip155:196"
      price: &str,         // "$0.01"
      pay_to: &str,        // "0xSeller"
      max_timeout_seconds: u64,
      resource: &ResourceInfo,
      config_extra: Option<&HashMap<String, serde_json::Value>>,
    ) -> Result<PaymentRequirements, X402Error>;                                                                                                                                                      

    pub async fn verify_payment(
      &self,
      payment_payload: &PaymentPayload,
      payment_requirements: &PaymentRequirements,
    ) -> Result<VerifyResponse, X402Error>;

    pub async fn settle_payment(
      &self,
      payment_payload: &PaymentPayload,
      payment_requirements: &PaymentRequirements,
      sync_settle: Option<bool>,
      settlement_overrides: Option<&SettlementOverrides>,
    ) -> Result<SettleResponse, X402Error>;                                                                                                                                                           

    pub async fn poll_settle_status(
      &self,
      tx_hash: &str,
      poll_interval: Duration,  // DEFAULT_POLL_INTERVAL = 1s
      poll_deadline: Duration,  // DEFAULT_POLL_DEADLINE = 5s
    ) -> PollResult;
}                          

PollResult#

rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollResult {
    Success,   // Transaction confirmed on-chain
    Failed,    // Transaction failed on-chain
    Timeout,   // Polling deadline exceeded
}

OKX Facilitator client (OkxHttpFacilitatorClient)#

rust
use x402_core::http::OkxHttpFacilitatorClient;                                                                                                                                                        
                                                                                                                                                                                                        
// Default URL (https://web3.okx.com)                                                                                                                                                                 
let client = OkxHttpFacilitatorClient::new(
    "your-api-key",
    "your-secret-key",
    "your-passphrase",
)?;  

Implements the FacilitatorClient trait. Every request includes HMAC-SHA256 authentication.

Endpoints invoked#

MethodOKX path
get_supported()GET /api/v6/pay/x402/supported
verify(request)POST /api/v6/pay/x402/verify
settle(request)POST /api/v6/pay/x402/settle
get_settle_status(tx_hash)GET /api/v6/pay/x402/settle/status?txHash=...

OKX responses are wrapped in {"code": 0, "data": {...}, "msg": ""} — the client unwraps automatically.


HMAC authentication#

rust
use x402_core::http::hmac::{sign_request, build_auth_headers};

// Sign a request
let signature = sign_request(secret_key, timestamp, method, request_path, body);
// Message = timestamp + METHOD + requestPath + body
// Signature = Base64(HMAC-SHA256(secretKey, message))

// Build all auth headers
let headers = build_auth_headers(api_key, secret_key, passphrase, method, request_path, body);
// Returns: OK-ACCESS-KEY, OK-ACCESS-SIGN, OK-ACCESS-TIMESTAMP, OK-ACCESS-PASSPHRASE

HTTP utilities#

Header encode / decode#

rust
use x402_core::http::{
    encode_payment_signature_header,
    decode_payment_signature_header,
    encode_payment_required_header,
    decode_payment_required_header,
    encode_payment_response_header,
    decode_payment_response_header,
};

let encoded = encode_payment_required_header(&payment_required)?;  // → base64 string
let decoded = decode_payment_required_header(&header_value)?;       // → PaymentRequired

Constants#

rust
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1);
pub const DEFAULT_POLL_DEADLINE: Duration = Duration::from_secs(5);
pub const PAYMENT_SIGNATURE_HEADER: &str = "PAYMENT-SIGNATURE";
pub const PAYMENT_REQUIRED_HEADER: &str = "PAYMENT-REQUIRED";
pub const PAYMENT_RESPONSE_HEADER: &str = "PAYMENT-RESPONSE";

Route configuration#

rust
use x402_core::http::{RoutesConfig, RoutePaymentConfig, AcceptConfig};
use std::collections::HashMap;

pub type RoutesConfig = HashMap<String, RoutePaymentConfig>;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutePaymentConfig {
    pub accepts: Vec<AcceptConfig>,   // Accepted payment options
    pub description: String,          // Resource description
    pub mime_type: String,            // Response MIME type
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sync_settle: Option<bool>,    // Enable sync settlement
}

#[derive(Debug, Clone, Serialize, Deserialize)]                                                                                                                                                       
#[serde(rename_all = "camelCase")]
pub struct AcceptConfig {
    pub scheme: String,    // "exact" | "aggr_deferred"
    pub price: String,     // "$0.01" or token amount
    pub network: String,   // "eip155:196"
    pub pay_to: String,    // Recipient wallet
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_timeout_seconds: Option<u64>,  // Default: 300s (5 min)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extra: Option<HashMap<String, serde_json::Value>>,  // Scheme-specific metadata
}        

Example#

rust
use std::collections::HashMap;
use x402_axum::{AcceptConfig, RoutePaymentConfig};
                                                                                                                                                                                                    
let mut routes = HashMap::new();
routes.insert("GET /api/data".to_string(), RoutePaymentConfig {
    accepts: vec![
      AcceptConfig {
          scheme: "exact".to_string(),
          price: "$0.01".to_string(),
          network: "eip155:196".to_string(),
          pay_to: "0xSeller".to_string(),
          max_timeout_seconds: None,
          extra: None,
      },
      AcceptConfig {
          scheme: "aggr_deferred".to_string(),
          price: "$0.001".to_string(),
          network: "eip155:196".to_string(),
          pay_to: "0xSeller".to_string(),
          max_timeout_seconds: None,
          extra: None,
      },
    ],
    description: "Premium data".to_string(),
    mime_type: "application/json".to_string(),
    sync_settle: Some(true),
});                 

Axum middleware (x402-axum)#

Basic usage#

rust
use std::time::Duration;
use axum::{Router, routing::get};
use x402_axum::{
  payment_middleware, payment_middleware_with_poll_deadline,
  payment_middleware_with_timeout_hook, payment_middleware_with_timeout_hook_and_deadline,
  OnSettlementTimeoutHook, RoutesConfig,                                                                                                                                                            
};
use x402_core::server::X402ResourceServer;                                                                                                                                                            
                                                                                                                                                                                                    
let app = Router::new()
  .route("/api/data", get(handler))
  .layer(payment_middleware(routes, server));

Constructors#

rust
use std::time::Duration;
use x402_axum::{
    OnSettlementTimeoutHook, PaymentLayer, RoutesConfig,
};                                                                                                                                                                                                    
use x402_core::server::X402ResourceServer;
                                                                                                                                                                                                    
// Basic middleware
pub fn payment_middleware(
    routes: RoutesConfig,
    server: X402ResourceServer,
) -> PaymentLayer;

// With custom poll deadline                                                                                                                                                                          
pub fn payment_middleware_with_poll_deadline(
    routes: RoutesConfig,
    server: X402ResourceServer,
    poll_deadline: Duration,
) -> PaymentLayer;

// With settlement timeout recovery hook
pub fn payment_middleware_with_timeout_hook(
    routes: RoutesConfig,
    server: X402ResourceServer,
    timeout_hook: OnSettlementTimeoutHook,                                                                                                                                                            
) -> PaymentLayer;

// With both
pub fn payment_middleware_with_timeout_hook_and_deadline(
    routes: RoutesConfig,
    server: X402ResourceServer,
    timeout_hook: OnSettlementTimeoutHook,
    poll_deadline: Duration,
) -> PaymentLayer;

OnSettlementTimeoutHook#

rust
use std::pin::Pin;
use std::future::Future;                                                                                                                                                                              
use x402_axum::{OnSettlementTimeoutHook, SettlementTimeoutResult};
                                                                                                                                                                                                
pub struct SettlementTimeoutResult {
    pub confirmed: bool,
}

pub type OnSettlementTimeoutHook = Box<
    dyn Fn(String, String) -> Pin<Box<dyn Future<Output = SettlementTimeoutResult> + Send>>
      + Send + Sync,                                                                                                                                                                                
>;              

// Usage: arguments are (tx_hash, network), not (route, tx_hash)                                                                                                                                      
let hook: OnSettlementTimeoutHook = Box::new(|tx_hash, network| {
Box::pin(async move {
    tracing::warn!("Timeout for tx={} network={}", tx_hash, network);
      SettlementTimeoutResult { confirmed: false }
    })
});       

Middleware flow#

  1. Check whether the route requires payment (find_route_config)

  2. If there is no payment-signature header → return 402 with the PAYMENT-REQUIRED header

  3. Decode and validate the payment payload

  4. Match the payload against the route's accepted requirements

  5. Verify via the facilitator (POST /verify)

  6. Call the inner handler and buffer the response

  7. Settle via the facilitator (POST /settle)

  8. If async (status: "pending") → poll within the deadline

  9. On timeout → invoke the timeout callback (if configured)

  10. Add the PAYMENT-RESPONSE header to the response

Re-exports#

rust
pub use x402_core::http::{
    AcceptConfig,
    BeforeHookResult,
    OnAfterSettleHook,
    OnAfterVerifyHook,
    OnBeforeSettleHook,
    OnBeforeVerifyHook,
    OnProtectedRequestHook,
    OnSettleFailureHook,
    OnSettlementTimeoutHook,
    OnVerifyFailureHook,
    PaymentResolverFn,
    PollResult,
    ProtectedRequestResult,
    RequestContext,
    ResolvedAccept,
    RoutePaymentConfig,
    RoutesConfig,
    SettleContext,
    SettleRecoveryResult,
    SettleResultContext,
    SettlementOverrides,
    SettlementTimeoutResult,
    VerifyContext,
    VerifyRecoveryResult,
    VerifyResultContext,
    DEFAULT_POLL_DEADLINE,
    DEFAULT_POLL_INTERVAL,
    SETTLEMENT_OVERRIDES_HEADER,
  };
  pub use x402_core::server::X402ResourceServer;

EVM mechanisms (x402-evm)#

ExactEvmScheme#

rust
use x402_evm::ExactEvmScheme;

let scheme = ExactEvmScheme::new();
scheme.scheme();  // "exact"

Handles:

  • Price parsing: "$0.01", "0.01", AssetAmount

  • Token-amount conversion with the correct decimals

  • Network → default-asset lookup (USDC on Base, USDT on X Layer)

  • Injecting EIP-712 domain info into extra for EIP-3009 tokens

DeferredEvmScheme#

rust
use x402_evm::AggrDeferredEvmScheme;
                                                                                                                                                                                                        
let scheme = AggrDeferredEvmScheme::new();
scheme.scheme();  // "aggr_deferred"

All price / requirements logic is delegated to ExactEvmScheme — from the seller's perspective, they look identical.

EVM payload types#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetTransferMethod {
    #[serde(rename = "eip3009")]
    Eip3009,
    Permit2,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EIP3009Authorization {
    pub from: String,
    pub to: String,
    pub value: String,
    pub valid_after: String,
    pub valid_before: String,
    pub nonce: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExactEIP3009Payload {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
    pub authorization: EIP3009Authorization,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExactPermit2Payload {
    pub signature: String,
    pub permit2_authorization: Permit2Authorization,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ExactEvmPayloadV2 {
    EIP3009(ExactEIP3009Payload),
    Permit2(ExactPermit2Payload),
}

impl ExactEvmPayloadV2 {
    pub fn is_permit2(&self) -> bool;
    pub fn is_eip3009(&self) -> bool;
}

Asset configuration#

rust
#[derive(Debug, Clone)]
pub struct DefaultAssetInfo {
    pub address: &'static str,       // Token contract address
    pub name: &'static str,          // EIP-712 domain name
    pub version: &'static str,       // EIP-712 domain version
    pub decimals: u8,                // Token decimal places
    pub asset_transfer_method: Option<&'static str>,  // "permit2" override
    pub supports_eip2612: bool,      // EIP-2612 permit() support
}

#[derive(Debug, Clone)]
pub struct ChainConfig {
    pub network: &'static str,       // CAIP-2 identifier
    pub chain_id: u64,
}

// Pre-registered assets:
// XLAYER_MAINNET_USDT:   eip155:196,   0x779ded..., 6 decimals, name: "USD₮0"

pub fn get_default_asset(network: &str) -> Option<DefaultAssetInfo>;

Error types#

rust
#[derive(Debug, thiserror::Error)]
pub enum X402Error {
    #[error(transparent)]
    Verify(#[from] VerifyError),
    #[error(transparent)]
    Settle(#[from] SettleError),
    #[error(transparent)]
    FacilitatorResponse(#[from] FacilitatorResponseError),
    #[error("configuration error: {0}")]
    Config(String),
    #[error("route configuration error: {0}")]
    RouteConfig(String),
    #[error("unsupported scheme: {0}")]
    UnsupportedScheme(String),
    #[error("unsupported network: {0}")]
    UnsupportedNetwork(String),
    #[error("price parse error: {0}")]
    PriceParse(String),
    #[error("not initialized: {0}")]
    NotInitialized(String),
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("serialization error: {0}")]
    Serialization(#[from] serde_json::Error),
    #[error("base64 decode error: {0}")]
    Base64Decode(#[from] base64::DecodeError),
    #[error("{0}")]
    Other(String),                                                                                                                                                                                    
}

#[derive(Debug, Clone, thiserror::Error)]
pub struct VerifyError {
    pub status_code: u16,
    pub invalid_reason: Option<String>,
    pub invalid_message: Option<String>,
    pub payer: Option<String>,
}

#[derive(Debug, Clone, thiserror::Error)]
pub struct SettleError {
    pub status_code: u16,
    pub error_reason: Option<String>,
    pub error_message: Option<String>,
    pub payer: Option<String>,
    pub transaction: String,
    pub network: Network,
}

#[derive(Debug, Clone, thiserror::Error)]
pub struct FacilitatorResponseError(pub String);

Utility functions#

rust
pub fn safe_base64_encode(data: &str) -> String;
pub fn safe_base64_decode(data: &str) -> Result<String, X402Error>;

// Network pattern matching: "eip155:*" matches "eip155:196"
pub fn network_matches_pattern(network: &str, pattern: &str) -> bool;

pub fn find_schemes_by_network<'a, T>(
    map: &'a HashMap<String, HashMap<String, T>>,
    network: &str,
) -> Option<&'a HashMap<String, T>>;

pub fn find_by_network_and_scheme<'a, T>(
    map: &'a HashMap<String, HashMap<String, T>>,
    scheme: &str,
    network: &str,
) -> Option<&'a T>;

pub fn deep_equal(obj1: &serde_json::Value, obj2: &serde_json::Value) -> bool;

Schema validation#

rust
pub fn validate_payment_requirements(req: &PaymentRequirements) -> Result<(), X402Error>;
pub fn validate_payment_payload(payload: &PaymentPayload) -> Result<(), X402Error>;
pub fn validate_payment_required(required: &PaymentRequired) -> Result<(), X402Error>;

Validates that all required fields are non-empty.


Rust SDK Reference (for charge, session)#

Crate#

CrateDescription
mpp-evmOKX MPP EVM Seller SDK: EvmChargeMethod / EvmSessionMethod / EvmChargeChallenger, SA-API client, local store, EIP-712 signing, Axum handlers
mppUpstream MPP protocol layer (tempoxyz/mpp-rs): PaymentCredential / PaymentChallenge / ChargeMethod / SessionMethod traits, Axum extractor MppCharge<C> / WithReceipt<T>, challenge codec, HMAC, PaymentErrorDetails
payment-router-axumDual-protocol (MPP + x402) routing Tower Layer — adapter pattern lets one axum app handle both protocols

The Rust MPP SDK currently provides seller (server-side) capability. Buyer-side (initiating paid requests in axum) is on the roadmap.


Core types (mpp-evm)#

Default chain ID#

rust
/// Default X Layer chain ID.
pub const DEFAULT_CHAIN_ID: u64 = 196;

SaApiResponse#

The SA-API unified response wrapper; the client unwraps data automatically.

rust
#[derive(Debug, Clone, Deserialize)]
pub struct SaApiResponse<T> {
    pub code: i64,
    pub data: Option<T>,
    #[serde(default)]
    pub msg: String,
}

ChargeMethodDetails / ChargeSplit#

The methodDetails of a Charge challenge (placed inside the base64url-encoded request field).

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChargeMethodDetails {
    pub chain_id: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fee_payer: Option<bool>,                // server pays gas (transaction mode)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub permit2_address: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub memo: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub splits: Option<Vec<ChargeSplit>>,
}

/// Constraints: sum(splits[].amount) < request.amount; primary recipient
/// must retain a non-zero remainder.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChargeSplit {
    pub amount: String,                         // base-units integer string
    pub recipient: String,                      // 40-hex EIP-55 address
    #[serde(skip_serializing_if = "Option::is_none")]
    pub memo: Option<String>,
}

SessionMethodDetails / SessionSplit#

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionMethodDetails {
    pub chain_id: u64,
    pub escrow_contract: String,                // 40-hex escrow address
    #[serde(skip_serializing_if = "Option::is_none")]
    pub channel_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_voucher_delta: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fee_payer: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub splits: Option<Vec<SessionSplit>>,
}

/// Constraints: bps in [1, 9999]; sum(splits[].bps) < 10000.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSplit {
    pub recipient: String,
    pub bps: u32,                               // basis points
    #[serde(skip_serializing_if = "Option::is_none")]
    pub memo: Option<String>,
}

Eip3009Authorization / Eip3009Split#

The shape of payload.authorization for Charge (filled in after the client signs EIP-3009).

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Eip3009Authorization {
    #[serde(rename = "type")]
    pub auth_type: String,                      // always "eip-3009"
    pub from: String,
    pub to: String,
    pub value: String,
    pub valid_after: String,
    pub valid_before: String,
    pub nonce: String,
    pub signature: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub splits: Option<Vec<Eip3009Split>>,      // independent signature per split
}

impl Eip3009Authorization {
    pub const TYPE: &'static str = "eip-3009";
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Eip3009Split {
    pub from: String,
    pub to: String,
    pub value: String,
    pub valid_after: String,
    pub valid_before: String,
    pub nonce: String,
    pub signature: String,
}

ChargeReceipt / SessionReceipt / ChannelStatus#

SA-API response bodies.

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChargeReceipt {
    pub method: String,                         // "evm"
    pub reference: String,                      // on-chain tx hash
    pub status: String,
    pub timestamp: String,
    pub chain_id: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub confirmations: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub challenge_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub external_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionReceipt {
    pub method: String,
    pub intent: String,
    pub status: String,
    pub timestamp: String,
    pub chain_id: u64,
    pub channel_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reference: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deposit: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub challenge_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub accepted_cumulative: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub spent: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub confirmations: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub units: Option<u64>,
}

/// Response from GET /session/status.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelStatus {
    pub channel_id: String,
    pub payer: String,
    pub payee: String,
    pub token: String,
    pub deposit: String,
    pub settled_on_chain: String,
    pub session_status: String,
    pub remaining_balance: String
}

SettleRequestPayload / CloseRequestPayload#

Request body the SDK sends to /session/settle / /session/close (flat shape, no challenge wrapper).

rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettleRequestPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action: Option<String>,                 // "settle"
    pub channel_id: String,
    pub cumulative_amount: String,              // uint128 decimal string
    pub voucher_signature: String,              // 65-byte r‖s‖v hex (payer)
    pub payee_signature: String,                // 65-byte r‖s‖v hex (payee)
    pub nonce: String,                          // uint256 decimal string
    pub deadline: String,                       // uint256 decimal string
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseRequestPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action: Option<String>,                 // "close"
    pub channel_id: String,
    pub cumulative_amount: String,
    /// Normal branch: 65-byte r‖s‖v hex.
    /// Waiver branch (cumulative ≤ settledOnChain or no local voucher): "".
    pub voucher_signature: String,
    pub payee_signature: String,
    pub nonce: String,
    pub deadline: String,
}

ServerAccountingState#

rust
/// Server-side per-session accounting maintained by the SDK.
/// Invariants:
///   accepted_cumulative is monotonically non-decreasing
///   spent is monotonically non-decreasing
///   available = accepted_cumulative - spent
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerAccountingState {
    pub accepted_cumulative: u128,
    pub spent: u128,
    pub settled_on_chain: u128,
}

SaApiClient trait#

A pluggable SA-API client interface; default implementation is [OkxSaApiClient].

rust
#[async_trait]
pub trait SaApiClient: Send + Sync {
    // Charge
    async fn charge_settle(
        &self,
        credential: &serde_json::Value,
    ) -> Result<ChargeReceipt, SaApiError>;
    async fn charge_verify_hash(
        &self,
        credential: &serde_json::Value,
    ) -> Result<ChargeReceipt, SaApiError>;

    async fn session_open(
        &self,
        credential: &serde_json::Value,
    ) -> Result<SessionReceipt, SaApiError>;
    async fn session_top_up(
        &self,
        credential: &serde_json::Value,
    ) -> Result<SessionReceipt, SaApiError>;
    async fn session_settle(
        &self,
        payload: &SettleRequestPayload,
    ) -> Result<SessionReceipt, SaApiError>;
    async fn session_close(
        &self,
        payload: &CloseRequestPayload,
    ) -> Result<SessionReceipt, SaApiError>;
    async fn session_status(
        &self,
        channel_id: &str,
    ) -> Result<ChannelStatus, SaApiError>;
}

OKX SA-API client (OkxSaApiClient)#

rust
#[derive(Debug, Clone)]
pub struct OkxSaApiClient { /* private */ }

impl OkxSaApiClient {
    /// Default production base URL (https://web3.okx.com).
    pub fn new(api_key: String, secret_key: String, passphrase: String) -> Self;

    /// Custom base URL (sandbox / staging).
    pub fn with_base_url(
        base_url: String,
        api_key: String,
        secret_key: String,
        passphrase: String,
    ) -> Self;
}

Implements the SaApiClient trait; every request automatically attaches HMAC-SHA256 auth headers. HTTP timeout is 30 seconds.

Endpoints invoked#

Trait methodOKX path
charge_settle()POST /api/v6/pay/mpp/charge/settle
charge_verify_hash()POST /api/v6/pay/mpp/charge/verifyHash
session_open()POST /api/v6/pay/mpp/session/open
session_top_up()POST /api/v6/pay/mpp/session/topUp
session_settle()POST /api/v6/pay/mpp/session/settle
session_close()POST /api/v6/pay/mpp/session/close
session_status(channel_id)GET /api/v6/pay/mpp/session/status?channelId=...

OKX responses are wrapped in {"code": 0, "data": {...}, "msg": ""}; the client unwraps automatically.

HMAC auth header constants#

rust
pub const HEADER_API_KEY: &str    = "OK-ACCESS-KEY";
pub const HEADER_SIGN: &str       = "OK-ACCESS-SIGN";
pub const HEADER_TIMESTAMP: &str  = "OK-ACCESS-TIMESTAMP";
pub const HEADER_PASSPHRASE: &str = "OK-ACCESS-PASSPHRASE";

Signing algorithm: Base64(HMAC-SHA256(secret_key, timestamp + METHOD + request_path + body)), identical to OKX REST API v5.


Charge — EvmChargeMethod#

Implements mpp::protocol::traits::ChargeMethod; forwards the credential to SA-API.

rust
#[derive(Clone)]
pub struct EvmChargeMethod { /* private */ }

impl EvmChargeMethod {
    pub fn new(sa_client: Arc<dyn SaApiClient>) -> Self;
}

payload.type routing:

  • "transaction"charge_settle (SA-API broadcasts transferWithAuthorization on-chain)
  • "hash"charge_verify_hash (client already broadcast; SA-API verifies the tx hash)

splits are forwarded as payload.authorization.splits[]; SA-API owns split validation.


Charge — EvmChargeChallenger#

Implements upstream mpp::server::axum::ChargeChallenger and can be attached to an MppCharge<C> extractor.

Configuration#

rust
pub struct EvmChargeChallengerConfig {
    pub charge_method: EvmChargeMethod,
    pub currency: String,                       // ERC-20 contract, 40-hex
    pub recipient: String,                      // payee address
    pub chain_id: u64,                          // 196 = X Layer
    pub fee_payer: Option<bool>,                // Some(true) = transaction mode
    pub realm: String,                          // for WWW-Authenticate header
    pub secret_key: String,                     // HMAC for challenge signing
    pub splits: Option<Vec<ChargeSplit>>,
}

Construction#

rust
#[derive(Clone)]
pub struct EvmChargeChallenger { /* private */ }

impl EvmChargeChallenger {
    pub fn new(cfg: EvmChargeChallengerConfig) -> Self;

    pub fn builder(
        charge_method: EvmChargeMethod,
        realm: impl Into<String>,
        secret_key: impl Into<String>,
    ) -> EvmChargeChallengerBuilder;
}

pub struct EvmChargeChallengerBuilder { /* private */ }

impl EvmChargeChallengerBuilder {
    pub fn currency(mut self, v: impl Into<String>) -> Self;
    pub fn recipient(mut self, v: impl Into<String>) -> Self;
    pub fn chain_id(mut self, v: u64) -> Self;
    pub fn fee_payer(mut self, v: bool) -> Self;
    pub fn splits(mut self, v: Vec<ChargeSplit>) -> Self;
    pub fn build(self) -> EvmChargeChallenger;
}

EvmChargeChallenger implements mpp::server::axum::ChargeChallenger, exposing challenge(amount, options) and verify_payment(authorization_header) — together with the upstream MppCharge<C> extractor / WithReceipt<T> response wrapper, they form the minimal charge handler.


Session — EvmSessionMethod#

Implements mpp::protocol::traits::SessionMethod. Maintains local channel state, supports voucher local verification + cumulative deduction, and merchant-initiated settle/close.

Construction & configuration#

rust
#[derive(Clone)]
pub struct EvmSessionMethod { /* private */ }

impl EvmSessionMethod {
    /// Default in-memory store.
    pub fn new(sa_client: Arc<dyn SaApiClient>) -> Self;

    /// Inject a custom [`SessionStore`].
    pub fn with_store(
        sa_client: Arc<dyn SaApiClient>,
        store: Arc<dyn SessionStore>,
    ) -> Self;

    /// Inject the payee signer. Accepts any
    /// `alloy::signers::Signer + Send + Sync + 'static`
    /// (PrivateKeySigner / AwsSigner / LedgerSigner / custom remote-signer wrapper).
    pub fn with_signer<S: Signer + Send + Sync + 'static>(mut self, signer: S) -> Self;

    /// Startup fast-fail check: `signer.address() == expected`.
    pub fn verify_payee(self, expected: Address) -> Result<Self, SaApiError>;

    /// Custom nonce allocator (default: [`UuidNonceProvider`]).
    pub fn with_nonce_provider(mut self, p: Arc<dyn NonceProvider>) -> Self;

    /// Custom EIP-712 domain `name` / `version` (default: OKX canonical values).
    pub fn with_domain_meta(
        mut self,
        name: impl Into<Cow<'static, str>>,
        version: impl Into<Cow<'static, str>>,
    ) -> Self;

    /// Custom signature deadline (default: `U256::MAX`, never expires).
    pub fn with_deadline(mut self, d: U256) -> Self;

    /// Set challenge `methodDetails` from a raw JSON value.
    pub fn with_method_details(mut self, details: serde_json::Value) -> Self;

    /// Set challenge `methodDetails` from a typed struct.
    pub fn with_typed_method_details(
        mut self,
        details: SessionMethodDetails,
    ) -> Result<Self, serde_json::Error>;

    /// Minimal builder: only sets escrow; `chain_id` defaults to X Layer (196).
    pub fn with_escrow(self, escrow_contract: impl Into<String>) -> Self;

    /// Startup check that the local EIP-712 domain matches the contract's
    /// on-chain `domainSeparator()`; mismatch → 8000. Run once at startup —
    /// otherwise every voucher / settle / close signature will be rejected
    /// by the contract.
    pub fn assert_domain_matches(&self, on_chain: B256) -> Result<(), SaApiError>;
}

Business methods#

rust
impl EvmSessionMethod {
    /// Local store handle.
    pub fn store(&self) -> Arc<dyn SessionStore>;

    /// On-chain channel status (passthrough to SA-API).
    pub async fn status(&self, channel_id: &str) -> Result<ChannelStatus, SaApiError>;

    /// Local voucher: guard + EIP-712 verify + bump `highest_voucher`.
    /// Byte-level idempotent (same cum + same sig): verify and highest
    /// update are skipped, but deduct still runs.
    pub async fn submit_voucher(
        &self,
        channel_id: &str,
        cumulative_amount: u128,
        signature: Bytes,
    ) -> Result<(), SaApiError>;

    /// Atomic deduct: `available = highest_voucher_amount - spent`;
    /// insufficient → 70015.
    pub async fn deduct_from_channel(
        &self,
        channel_id: &str,
        amount: u128,
    ) -> Result<ChannelRecord, SaApiError>;

    /// Take local highest voucher → sign SettleAuth → call `/session/settle`.
    pub async fn settle_with_authorization(
        &self,
        channel_id: &str,
    ) -> Result<SessionReceipt, SaApiError>;

    /// Sign CloseAuth → call `/session/close` → remove from store on success.
    /// If `cumulative_amount` / `provided_voucher_sig` are `None`, fall back
    /// to local highest; if both are `None` and the store has no voucher,
    /// take the waiver path (empty string).
    pub async fn close_with_authorization(
        &self,
        channel_id: &str,
        cumulative_amount: Option<u128>,
        provided_voucher_sig: Option<Bytes>,
    ) -> Result<SessionReceipt, SaApiError>;
}

Session action routing (inside SessionMethod::verify_session)#

payload.actionBehavior
"open"Verify payee → call SA session/open → write local store
"voucher"submit_voucher (local verify + bump highest) → deduct_from_channel (deduct)
"topUp"Call SA session/topUp → add to local deposit
"close"Take payer-provided voucher → run local close flow

Session — SessionStore trait#

rust
/// Closure-based atomic update. `Err` aborts the whole update; the store
/// keeps the old value (transaction semantics).
pub type ChannelUpdater =
    Box<dyn FnOnce(&mut ChannelRecord) -> Result<(), SaApiError> + Send>;

#[async_trait]
pub trait SessionStore: Send + Sync {
    async fn get(&self, channel_id: &str) -> Option<ChannelRecord>;
    async fn put(&self, record: ChannelRecord);
    async fn remove(&self, channel_id: &str);

    /// Atomic read-modify-write. Channel absent → 70010 channel_not_found;
    /// updater returns Err → no write happens, the error propagates.
    async fn update(
        &self,
        channel_id: &str,
        updater: ChannelUpdater,
    ) -> Result<ChannelRecord, SaApiError>;
}

ChannelRecord#

rust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelRecord {
    pub channel_id: String,
    pub chain_id: u64,
    pub escrow_contract: Address,
    pub payer: Address,
    pub payee: Address,
    pub authorized_signer: Address,             // address(0) is resolved to payer at open time
    pub deposit: u128,
    pub highest_voucher_amount: u128,
    pub highest_voucher_signature: Option<Bytes>,
    pub min_voucher_delta: Option<u128>,        // throttle: minimum voucher increment
    #[serde(default)]
    pub spent: u128,                            // already deducted (invariant: spent ≤ highest)
    #[serde(default)]
    pub units: u64,                             // number of deduct calls
}

impl ChannelRecord {
    pub fn voucher_signer(&self) -> Address;    // returns authorized_signer
}

Default implementation — InMemorySessionStore#

rust
#[derive(Debug, Default, Clone)]
pub struct InMemorySessionStore { /* private */ }

impl InMemorySessionStore {
    pub fn new() -> Self;
}

In-process HashMap, suitable for most single-process deployments (operations are short, lock contention is low). Two caveats:

  • Lost on restart: process restart / crash loses all channel state. If the business can't accept this loss (long-lived channels, multi-instance HA, hot reload), implement a persistent store (SQLite / Redis / Postgres / DynamoDB / ...) and inject it via with_store(...).
  • Abandoned channel accumulation: when the payer never calls close, records stick around — this is a session lifecycle issue across all stores, not specific to in-memory (SQL / Redis stores fill up the same way). Merchants should have a cleanup strategy or apply business-level TTL.

NonceProvider trait#

rust
#[async_trait]
pub trait NonceProvider: Send + Sync {
    async fn allocate(
        &self,
        payee: Address,
        channel_id: B256,
    ) -> Result<U256, SaApiError>;
}

/// Default impl: UUID v4 → U256 (128-bit random, stateless, safe across
/// multi-instance / restart).
#[derive(Debug, Default, Clone)]
pub struct UuidNonceProvider;

The on-chain used-nonce set is keyed by (payee, channelId, nonce); reuse reverts with NonceAlreadyUsed. The SDK only allocates "unlikely-to-have-been-used" nonces; it does not track the used set.


EIP-712 signing (mpp_evm::eip712)#

Domain#

rust
pub const VOUCHER_DOMAIN_NAME: &str    = "EVM Payment Channel";
pub const VOUCHER_DOMAIN_VERSION: &str = "1";

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DomainMeta {
    pub name: Cow<'static, str>,
    pub version: Cow<'static, str>,
}

impl DomainMeta {
    pub fn new(
        name: impl Into<Cow<'static, str>>,
        version: impl Into<Cow<'static, str>>,
    ) -> Self;
}

impl Default for DomainMeta { /* uses VOUCHER_DOMAIN_* constants */ }

pub fn build_domain(
    meta: &DomainMeta,
    chain_id: u64,
    escrow_contract: Address,
) -> alloy_sol_types::Eip712Domain;

Voucher verification#

rust
sol! {
    /// EIP-712 typed struct; 1:1 with the contract's `Voucher`.
    struct Voucher {
        bytes32 channelId;
        uint128 cumulativeAmount;
    }
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum VerifyError {
    #[error("signature must be 65 bytes, got {0}")]
    BadLength(usize),
    #[error("non-canonical signature: s exceeds secp256k1 half-order (high-s)")]
    HighS,
    #[error("signature parse failed (cannot construct Signature from bytes)")]
    SignatureParse,
    #[error("ecrecover failed (cannot recover signer from prehash)")]
    Recover,
    #[error("signer mismatch: recovered {recovered}, expected {expected}")]
    AddressMismatch { recovered: Address, expected: Address },
}

/// 1) signature.len() == 65  2) low-s precheck  3) EIP-712 digest
/// 4) ecrecover + strict address comparison
pub fn verify_voucher(
    meta: &DomainMeta,
    escrow_contract: Address,
    chain_id: u64,
    channel_id: B256,
    cumulative_amount: u128,
    signature: &[u8],
    expected_signer: Address,
) -> Result<(), VerifyError>;

SettleAuthorization / CloseAuthorization signing#

rust
sol! {
    struct SettleAuthorization {
        bytes32 channelId;
        uint128 cumulativeAmount;
        uint256 nonce;
        uint256 deadline;
    }
    struct CloseAuthorization {
        bytes32 channelId;
        uint128 cumulativeAmount;
        uint256 nonce;
        uint256 deadline;
    }
}

#[derive(Debug, Clone)]
pub struct SignedAuthorization {
    pub channel_id: B256,
    pub cumulative_amount: u128,
    pub nonce: U256,
    pub deadline: U256,
    pub signature: Bytes,                       // 65-byte (r, s, v)
}

pub async fn sign_settle_authorization(
    meta: &DomainMeta,
    signer: &(impl Signer + ?Sized),
    escrow_contract: Address,
    chain_id: u64,
    channel_id: B256,
    cumulative_amount: u128,
    nonce: U256,
    deadline: U256,
) -> Result<SignedAuthorization, SaApiError>;

pub async fn sign_close_authorization(
    meta: &DomainMeta,
    signer: &(impl Signer + ?Sized),
    escrow_contract: Address,
    chain_id: u64,
    channel_id: B256,
    cumulative_amount: u128,
    nonce: U256,
    deadline: U256,
) -> Result<SignedAuthorization, SaApiError>;

Challenge builders (mpp_evm::charge::challenge)#

rust
pub const METHOD_NAME: &str          = "evm";
pub const INTENT_CHARGE: &str        = "charge";
pub const INTENT_SESSION: &str       = "session";
pub const DEFAULT_EXPIRES_MINUTES: i64 = 5;

/// Build a method="evm" charge challenge with HMAC-protected `id`.
pub fn build_charge_challenge(
    secret_key: &str,
    realm: &str,
    request: &mpp::protocol::intents::ChargeRequest,
    expires: Option<&str>,
    description: Option<&str>,
) -> Result<mpp::protocol::core::PaymentChallenge, String>;

pub fn build_session_challenge(
    secret_key: &str,
    realm: &str,
    request: &mpp::protocol::intents::SessionRequest,
    expires: Option<&str>,
    description: Option<&str>,
) -> Result<mpp::protocol::core::PaymentChallenge, String>;

/// Assemble a request body from a base-units amount + typed method details.
pub fn charge_request_with(
    amount_base_units: impl Into<String>,
    currency: impl Into<String>,
    recipient: impl Into<String>,
    details: ChargeMethodDetails,
) -> Result<mpp::protocol::intents::ChargeRequest, String>;

pub fn session_request_with(
    amount_per_unit_base: impl Into<String>,
    currency: impl Into<String>,
    recipient: impl Into<String>,
    details: SessionMethodDetails,
) -> Result<mpp::protocol::intents::SessionRequest, String>;

CredentialExt — decoding challenge.request#

PaymentCredential.challenge.request is Base64UrlJson<T>; .decode() returns a generic error. This extension normalizes it to SaApiError, so it can be ?-chained with the rest of the SDK.

rust
pub trait CredentialExt {
    fn decode_request<R: DeserializeOwned>(&self) -> Result<R, SaApiError>;
}

impl CredentialExt for mpp::protocol::core::PaymentCredential { /* ... */ }

// Usage:
use mpp_evm::CredentialExt;
use mpp::protocol::intents::SessionRequest;

let request: SessionRequest = credential.decode_request()?;

Axum drop-in handlers (feature = "handlers")#

rust
use mpp_evm::handlers;

#[derive(Debug, Clone, Deserialize)]
pub struct SettleBody {
    #[serde(rename = "channelId")]
    pub channel_id: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct StatusQuery {
    #[serde(rename = "channelId")]
    pub channel_id: String,
}

/// POST /session/settle — body { "channelId": "0x..." }.
pub async fn session_settle(
    State(method): State<Arc<EvmSessionMethod>>,
    Json(body): Json<SettleBody>,
) -> Response;

/// GET /session/status?channelId=0x...
pub async fn session_status(
    State(method): State<Arc<EvmSessionMethod>>,
    Query(q): Query<StatusQuery>,
) -> Response;

Errors are auto-mapped to the correct HTTP status codes via SaApiError::to_problem_details(...).


Error types#

rust
#[derive(Debug, Clone, thiserror::Error)]
#[error("SA API error {code}: {msg}")]
pub struct SaApiError {
    pub code: u32,
    pub msg: String,
}

impl SaApiError {
    pub fn new(code: u32, msg: impl Into<String>) -> Self;

    /// Map to mpp-rs `PaymentErrorDetails` (RFC 9457 ProblemDetails).
    pub fn to_problem_details(
        &self,
        challenge_id: Option<&str>,
    ) -> mpp::PaymentErrorDetails;
}

Error code mapping#

codeMeaning
8000Internal API service error
70000Missing required field or invalid format
70001Chain not in supported list
70002Payer is blocklisted
70003source missing, feePayer=true incompatible with hash mode, or txHash already used
70004Signature verification failed
70005Split total ≥ primary amount
70006Split count > 10
70007Transaction not confirmed on-chain
70008On-chain contract channel is already closed
70009Challenge does not exist or has expired
70010channelId not found
70011Escrow contract grace period configuration not met; channel open rejected
70012cumulativeAmount exceeds the channel's deposit balance
70013Voucher increment below minVoucherDelta
70014Channel in CLOSING state, won't accept new Vouchers

Dual-protocol routing (payment-router-axum)#

Lets one axum app accept both MPP + x402; business handlers stay protocol-agnostic.

Adapter trait#

rust
pub trait ProtocolAdapter: Send + Sync + 'static {
    fn name(&self) -> &str;                                 // "mpp" | "x402" | custom
    fn priority(&self) -> u32;                              // 10 = MPP, 20 = x402, 100+ = custom
    fn detect(&self, parts: &http::request::Parts) -> bool; // checks request headers for this protocol
    fn get_challenge<'a>(
        &'a self,
        parts: &'a http::request::Parts,
        route_cfg: &'a serde_json::Value,
    ) -> ChallengeFuture<'a>;                                // build the 402 challenge line
    fn make_service(&self, inner: InnerService) -> InnerService;  // wrap inner with this protocol's native middleware
}

Built-in adapters#

rust
use payment_router_axum::adapters::{MppAdapter, X402Adapter};

let mpp_adapter  = Arc::new(MppAdapter::new(mpp_charge_challenger));
// X402Adapter::new takes owned (routes, server), not Arc-wrapped.
let x402_adapter = Arc::new(X402Adapter::new(x402_routes, x402_server));

MppAdapter internally runs the full mpp::server::axum::ChargeChallenger flow (HMAC verify + EIP-3009 verify + SA-API settle). X402Adapter internally uses the native PaymentMiddleware from x402-axum.

Router configuration#

rust
pub struct UnifiedRouteConfig {
    /// Optional human-readable description.
    pub description: Option<String>,
    /// adapter.name() → JSON config for that adapter on this route.
    /// Adapters not listed here are not enabled on this route.
    pub adapter_configs: HashMap<String, serde_json::Value>,
}

pub struct PaymentRouterConfig {
    /// `Vec<(pattern, route_cfg)>`. Vec instead of HashMap — declaration order is significant
    /// (spec §9 first-match-wins). Pattern is e.g. "GET /path" or "/path"
    /// (any method).
    pub routes: Vec<(String, UnifiedRouteConfig)>,
    /// List of protocol adapters (MPP / x402 / custom).
    pub protocols: Vec<Arc<dyn ProtocolAdapter>>,
    /// Optional error observer.
    pub on_error: Option<Arc<ErrorHandler>>,
}

/// `ErrorHandler` type alias:
/// `dyn Fn(&(dyn Error + Send + Sync), ErrorContext) + Send + Sync + 'static`
pub type ErrorHandler = /* see payment-router-axum::types */;

pub struct PaymentRouterLayer { /* tower::Layer */ }

impl PaymentRouterLayer {
    pub fn new(cfg: PaymentRouterConfig) -> Result<Self, BuildError>;
}