Портал розробників
Тема

Rust SDK Reference#

Rust SDK Reference (for exact, exact + permit2, upto, aggr_deferred)#

Crate#

Directory / Lib aliasPublished name (crates.io)Description
x402-coreokxweb3-app-x402-coreCore: server, facilitator client, types, HTTP utilities, HMAC authentication
x402-axumokxweb3-app-x402-axumAxum middleware (Tower Layer/Service)
x402-evmokxweb3-app-x402-evmEVM mechanisms: exact (EIP-3009 / Permit2), upto, aggr_deferred

Cargo.toml deps use the Published name (okxweb3-app-*), while source use statements use the Lib alias (short name) — the crate is explicitly renamed via [lib] name = "...", so they line up at compile time.

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


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" | "upto"
    pub network: Network,            // CAIP-2 identifier
    pub asset: String,               // Token contract address
    pub amount: String,              // Price (or cap, for "upto") 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
}

Common fields in extra:

keyschemeMeaning
assetTransferMethodexact / upto"eip3009" (default) or "permit2"
facilitatorAddressuptoThe upto proxy enforces witness.facilitator == msg.sender; automatically injected from getSupported by enhance_payment_requirements
name / versionexact (EIP-3009 path)EIP-712 domain, used for client-side signing

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,
    #[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. The payload contents vary by scheme (EIP-3009 / Permit2 / upto Permit2).

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>,  // see the EVM Payload section below
    #[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,
    /// OKX extension: if true, wait for on-chain confirmation (exact scheme only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sync_settle: Option<bool>,
}

#[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,
    /// Actual amount settled in atomic units. Present for schemes like
    /// `upto` where settlement amount may differ from the cap.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub amount: Option<String>,
    /// OKX extension: "pending" | "success" | "timeout".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    #[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,
    /// upto: the facilitator address is exposed via `extra.facilitatorAddress`;
    /// the seller SDK injects it into the challenge's `extra` during
    /// `enhance_payment_requirements`.
    #[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>,
    /// CAIP family pattern → signer addresses.
    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>,
    /// "pending" | "success" | "failed"
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
}

Traits#

SchemeNetworkServer#

Server-side scheme implementation. exact / aggr_deferred / upto all implement this trait.

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 communicating with 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 { payment_required }

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

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

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)#

Construction and registration#

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

// register() uses builder pattern (consumes self, returns Self).
// Multiple schemes can coexist on the same network; the route config picks by scheme name.
let mut server = X402ResourceServer::new(facilitator)
    .register("eip155:196", ExactEvmScheme::new())          // exact (EIP-3009 / Permit2)
    .register("eip155:196", AggrDeferredEvmScheme::new())   // aggr_deferred
    .register("eip155:196", UptoEvmScheme::new());          // upto (cap + override)

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(
        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;

    /// `resource` is currently unused, but the API shape is kept for future
    /// extension (request-level `ResourceInfo` override).
    pub async fn build_payment_requirements(
        &self,
        scheme: &str,        // "exact" | "aggr_deferred" | "upto"
        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>;

    /// `settlement_overrides`: used by the upto scheme — the business handler
    /// decides the actual charge amount for this request (≤ cap), passed to the
    /// middleware via the `settlement-overrides` response header; the middleware
    /// parses it and calls this method.
    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 / SettlementOverrides#

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

/// Set by the business handler via the `settlement-overrides` response header
/// (the `set_settlement_overrides()` helper is recommended); the middleware
/// reads it and applies it at settle time. `amount` supports three formats:
///   - atomic-unit integer:   "1234000"
///   - percentage of the cap: "50%"
///   - USD string:            "$0.05" (same syntax as `price`)
/// **⚠️ If the handler does not write the response header → the full cap is charged (same behavior as exact)**
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettlementOverrides {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub amount: Option<String>,
}

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",
)?;

// Or a custom base URL (sandbox / staging):
let client = OkxHttpFacilitatorClient::with_url(
    "https://sandbox.okx.com",
    "your-api-key",
    "your-secret-key",
    "your-passphrase",
)?;

Both new / with_url return Result<Self, X402Error>. The client implements the FacilitatorClient trait, and all requests automatically carry HMAC-SHA256 authentication headers.

Endpoints called#

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": ""}, which the client unwraps automatically.


HMAC authentication#

rust
use x402_core::http::hmac::build_auth_headers;

// Add OKX authentication headers to a request in your own HTTP client.
let headers = build_auth_headers(
    api_key,
    secret_key,
    passphrase,
    "GET",                       // uppercase HTTP method
    "/api/v6/pay/x402/supported",
    "",                          // GET usually has an empty body
)?;
// Returns Result<HeaderMap, X402Error>
// Headers: OK-ACCESS-KEY, OK-ACCESS-SIGN, OK-ACCESS-TIMESTAMP, OK-ACCESS-PASSPHRASE

Signature rule: Base64(HMAC-SHA256(secret_key, timestamp + METHOD + request_path + body)). sign_request is a crate-internal implementation detail and is not public.


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";
pub const SETTLEMENT_OVERRIDES_HEADER: &str = "settlement-overrides";

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>,
    pub description: String,
    pub mime_type: String,
    /// `true` makes settle wait for on-chain confirmation before returning
    /// (exact only); `false` / `None` → asynchronous settlement
    /// (`status="pending"`), with the middleware polling per
    /// `DEFAULT_POLL_DEADLINE`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sync_settle: Option<bool>,
    /// The merchant pins `ResourceInfo.url` manually; when `None` the middleware
    /// composes it automatically from `X-Forwarded-Proto` + `Host` + path + query.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resource: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptConfig {
    pub scheme: String,    // "exact" | "aggr_deferred" | "upto"
    pub price: String,     // "$0.01" / "0.01" / JSON AssetAmount
    pub network: String,   // "eip155:196"
    pub pay_to: String,
    /// Defaults to 300s (5 min)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_timeout_seconds: Option<u64>,
    /// Scheme-specific metadata. Common usage:
    ///   - exact: `{"assetTransferMethod":"permit2"}` switches to the Permit2 flow
    ///   - upto: usually left empty — `UptoEvmScheme::enhance_payment_requirements`
    ///           injects `assetTransferMethod` + `facilitatorAddress` automatically
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extra: Option<HashMap<String, serde_json::Value>>,
}

Example (multiple schemes coexisting)#

rust
use std::collections::HashMap;
use serde_json::json;
use x402_axum::{AcceptConfig, RoutePaymentConfig};

let mut routes = HashMap::new();
routes.insert("GET /api/data".to_string(), RoutePaymentConfig {
    accepts: vec![
        // 1) default exact + EIP-3009 (USD₮0 on X Layer)
        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,
        },
        // 2) exact + Permit2 (USD-pegged stablecoins)
        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: Some(HashMap::from([(
                "assetTransferMethod".to_string(),
                json!("permit2"),
            )])),
        },
        // 3) aggr_deferred (TEE aggregation)
        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),
    resource: None,
});

Axum middleware (x402-axum)#

Basic usage#

rust
use axum::{Router, routing::get};
use x402_axum::{payment_middleware, 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, PaymentResolverFn, RoutesConfig,
};
use x402_core::server::X402ResourceServer;

// Basic middleware
pub fn payment_middleware(
    routes: RoutesConfig,
    server: X402ResourceServer,
) -> PaymentLayer;

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

// settle timeout callback (manual secondary confirmation of the on-chain transaction)
pub fn payment_middleware_with_timeout_hook(
    routes: RoutesConfig,
    server: X402ResourceServer,
    timeout_hook: OnSettlementTimeoutHook,
) -> PaymentLayer;

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

// Custom payment resolver (dynamically decide the route config)
pub fn payment_middleware_with_resolver(
    routes: RoutesConfig,
    server: X402ResourceServer,
    resolver: PaymentResolverFn,
) -> PaymentLayer;

OnSettlementTimeoutHook#

rust
use std::pin::Pin;
use std::future::Future;
use x402_axum::{OnSettlementTimeoutHook, SettlementTimeoutResult};

pub struct SettlementTimeoutResult {
    pub confirmed: bool,
}

/// Parameter order: (tx_hash, network), **not** (route, tx_hash).
pub type OnSettlementTimeoutHook = Box<
    dyn Fn(String, String)
            -> Pin<Box<dyn Future<Output = SettlementTimeoutResult> + Send>>
        + Send + Sync,
>;

let hook: OnSettlementTimeoutHook = Box::new(|tx_hash, network| {
    Box::pin(async move {
        // Do timeout observation here: on-chain secondary confirmation / logging / metrics reporting / etc.
        SettlementTimeoutResult { confirmed: false }
    })
});

Middleware flow#

  1. Get the route cfg for this request: prefer req.extensions::<PreMatchedRoute>() (already matched by an outer router); otherwise fall back to find_route_config(state.routes, method, path) — if neither matches → pass through to the inner handler
  2. No payment-signature request header → return 402 + the PAYMENT-REQUIRED request header
  3. Decode and verify the payment payload
  4. Match the payload against the route's accepts
  5. Verify via the facilitator (POST /verify)
  6. Call the inner handler and buffer the response
  7. If the business handler wrote a settlement-overrides response header (upto), the middleware parses it and calls settle with SettlementOverrides
  8. Settle via the facilitator (POST /settle)
  9. Asynchronous (status: "pending") → poll within poll_deadline
  10. Timeout → call OnSettlementTimeoutHook (if configured)
  11. Add the PAYMENT-RESPONSE request 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;

Additional types exposed by x402-axum itself:

rust
/// The outer router pins the already-matched route cfg into the request
/// extensions; when the middleware sees it, it uses it directly and skips its
/// own second path match.
pub struct PreMatchedRoute(pub RoutePaymentConfig);

EVM mechanisms (x402-evm)#

ExactEvmScheme#

rust
use x402_evm::ExactEvmScheme;

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

Responsible for:

  • Price parsing: "$0.01" / "0.01" / JSON AssetAmount
  • Converting the price into atomic units using the token's decimals
  • Selecting the default asset via [get_default_asset] by network
  • Injecting the EIP-712 domain (name / version) into extra for client-side EIP-3009 signing
  • Also supporting Permit2: when the buyer sets extra.assetTransferMethod = "permit2", they sign a Permit2 credential, producing an ExactPermit2Payload

AggrDeferredEvmScheme#

rust
use x402_evm::AggrDeferredEvmScheme;

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

All price / requirements logic is delegated to ExactEvmScheme; the seller configuration is identical to exact, with on-chain settlement aggregated by the facilitator's TEE.

UptoEvmScheme#

rust
use x402_evm::UptoEvmScheme;

let scheme = UptoEvmScheme::new();
scheme.scheme();  // "upto"

upto is a Permit2-only cap-and-override mode:

  • PaymentRequirements.amount is the cap, not the actual charge
  • enhance_payment_requirements forces extra.assetTransferMethod = "permit2"
  • Automatically injects extra.facilitatorAddress from getSupported into the challenge, so the buyer pins the facilitator address into witness.facilitator (the contract enforces msg.sender == witness.facilitator)
  • The business handler writes settlement-overrides: {"amount":"..."} in the response header to decide the actual charge; the middleware reads it and calls settle_payment(..., overrides), with the remaining balance automatically not charged

EVM Payload types#

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

// ---- EIP-3009 (default) ----
#[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,
}

// ---- Exact + Permit2 ----
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Permit2Witness {
    pub to: String,
    pub valid_after: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Permit2Permitted {
    pub token: String,
    pub amount: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Permit2Authorization {
    pub from: String,
    pub permitted: Permit2Permitted,
    pub spender: String,
    pub nonce: String,
    pub deadline: String,
    pub witness: Permit2Witness,
}

#[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;
}

// ---- Upto + Permit2 (cap mode) ----
/// Upto witness — adds `facilitator` so the upto proxy can enforce
/// `msg.sender == witness.facilitator` on chain.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UptoPermit2Witness {
    pub to: String,
    pub facilitator: String,
    pub valid_after: String,
}

/// `permitted.amount` is the cap; facilitator may settle ≤ this.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UptoPermit2Authorization {
    pub from: String,
    pub permitted: Permit2Permitted,
    pub spender: String,
    pub nonce: String,
    pub deadline: String,
    pub witness: UptoPermit2Witness,
}

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

/// Checks whether the `PaymentPayload.payload` JSON has the upto witness shape
/// (`permit2Authorization.witness.facilitator` is present).
pub fn is_upto_permit2_payload(payload: &serde_json::Value) -> bool;

Permit2 / Upto constants#

Buyer prerequisite (one-time): before using the exact + permit2 or upto path, the buyer wallet must approve(PERMIT2_ADDRESS, MAX). OKX Agentic Wallet users: when the Agent detects insufficient allowance it guides the approval (choose one of MAX / custom / revoke), and after the user confirms it goes on-chain automatically; plain EOA wallets must send the approve transaction themselves.

rust
// Permit2 is a CREATE2-vanity deployment; the address is identical on every EVM chain.
pub const PERMIT2_ADDRESS: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3";

pub const PERMIT2_EIP712_DOMAIN_NAME: &str = "Permit2";

// The Permit2 proxy contract deployed by x402 — receives the Permit2 signature and forwards it to the ERC-20.
pub const X402_EXACT_PERMIT2_PROXY_ADDRESS: &str =
    "0x402085c248EeA27D92E8b30b2C58ed07f9E20001";
pub const X402_UPTO_PERMIT2_PROXY_ADDRESS: &str =
    "0x4020e7393B728A3939659E5732F87fdd8e680002";

// Permit2 witness typehash literals — the field order is ABI-significant; any
// reordering will cause on-chain signature verification to fail.
pub const PERMIT2_EXACT_WITNESS_TYPE_STRING: &str = /* see source */;
pub const PERMIT2_UPTO_WITNESS_TYPE_STRING:  &str = /* see source */;

Asset configuration#

rust
#[derive(Debug, Clone)]
pub struct DefaultAssetInfo {
    pub address: &'static str,
    pub name: &'static str,       // EIP-712 domain name (USD₮0 uses U+20AE)
    pub version: &'static str,
    pub decimals: u8,
    pub asset_transfer_method: Option<&'static str>,  // forces "permit2"
    pub supports_eip2612: bool,                       // EIP-2612 permit() support
}

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

// Pre-registered:
//   XLAYER_MAINNET (eip155:196)  + XLAYER_MAINNET_USDT  (0x779ded..., USD₮0, 6 decimals)
//   XLAYER_TESTNET (eip155:1952) + XLAYER_TESTNET_USDT  (0x9e29b3..., USD₮0, 6 decimals)

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("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("not initialized: {0}")]
    NotInitialized(String),
    #[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 (x402_core::utils)#

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

/// Network wildcard 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 (x402_core::schemas)#

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#

Directory / Lib aliasPublished name (crates.io)Description
mpp-evmokxweb3-app-mppOKX MPP EVM Seller SDK: EvmChargeMethod / EvmSessionMethod / EvmChargeChallenger, SA-API client, local store, EIP-712 signing, (feature handlers) Axum drop-in handlers
mpp(upstream crates.io)Upstream MPP protocol layer: PaymentCredential / PaymentChallenge / ChargeMethod / SessionMethod traits, Axum extractors MppCharge<C> / WithReceipt<T>, challenge codec, HMAC, PaymentErrorDetails
payment-router-axumokxweb3-app-payment-router-axumDual-protocol (MPP + x402) routing Tower Layer; an adapter pattern that lets one axum app serve both protocols

mpp-evm re-exports the upstream crate via pub use ::mpp;, so the business side only needs to depend on okxweb3-app-mpp; to use upstream modules such as proxy, go through mpp_evm::mpp::proxy::....


Constants#

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

/// X Layer mainnet escrow contract address (fallback when `with_escrow` is not provided).
pub const DEFAULT_ESCROW_CONTRACT: &str = "0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b";

Core types (mpp-evm)#

SaApiResponse#

The unified SA-API 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 (base64url-encoded into request).

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>>,
    /// Endpoint URL this charge protects; SA aggregates revenue by URL.
    /// The SDK does not fill this automatically; set it manually when building the challenge if needed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resource_url: Option<String>,
}

/// 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,
    #[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,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub memo: Option<String>,
}

Eip3009Authorization / Eip3009Split#

The shape of the Charge payload.authorization (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,
    /// For splits, each split is signed with its own EIP-3009 (primary + 1 per split).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub splits: Option<Vec<Eip3009Split>>,
}

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#

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>,
    /// Current on-chain known deposit.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deposit: Option<String>,
    // The fields below are deprecated in the new protocol; kept as Option only for deserialization compatibility:
    #[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.
///
/// Note: `cumulative_amount` has been removed in the new protocol version (only
/// settle updates it); the field remains `Option` only for backwards-compat.
#[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,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cumulative_amount: Option<String>,
}

SettleRequestPayload / CloseRequestPayload#

The request body for the SDK actively calling /session/settle / /session/close (flat, without the 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.
/// Invariants:
///   accepted_cumulative monotonically non-decreasing
///   spent 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#

The pluggable SA-API client interface; the 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>;

    // Session
    // Note: there is no /session/voucher endpoint; vouchers are handled locally in the SDK
    //       (`EvmSessionMethod::submit_voucher`).
    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 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 carries HMAC-SHA256 authentication headers. HTTP timeout is 30 seconds.

Endpoints#

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": ""}, which the client unwraps automatically.


Charge — EvmChargeMethod#

Implements mpp::protocol::traits::ChargeMethod, passing the credential through to the 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 (the SA-API broadcasts transferWithAuthorization on-chain)
  • "hash"charge_verify_hash (the client has already broadcast; the SA-API verifies the tx hash)

Splits are passed through as payload.authorization.splits[]; the SA-API owns split validation.


Charge — EvmChargeChallenger#

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

Config#

rust
pub struct EvmChargeChallengerConfig {
    pub charge_method: EvmChargeMethod,
    pub currency: String,                       // ERC-20 contract, 40-hex
    pub recipient: String,                      // primary 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>>,
    /// One URL per charger; SA aggregates revenue by URL. `None` disables reporting.
    pub resource_url: Option<String>,
}

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(self, v: impl Into<String>) -> Self;
    pub fn recipient(self, v: impl Into<String>) -> Self;
    pub fn chain_id(self, v: u64) -> Self;
    pub fn fee_payer(self, v: bool) -> Self;
    pub fn splits(self, v: Vec<ChargeSplit>) -> Self;
    pub fn resource_url(self, v: impl Into<String>) -> Self;
    pub fn build(self) -> EvmChargeChallenger;
}

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


Session — EvmSessionMethod#

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

Construction and 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 / a custom remote signer).
    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 (defaults to [`UuidNonceProvider`]).
    pub fn with_nonce_provider(mut self, p: Arc<dyn NonceProvider>) -> Self;

    /// Custom EIP-712 domain `name` / `version` (defaults to the 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 (defaults to `U256::MAX`, never expires).
    pub fn with_deadline(mut self, d: U256) -> Self;

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

    /// challenge methodDetails (typed).
    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).
    /// When not called explicitly, escrow uses [`DEFAULT_ESCROW_CONTRACT`] automatically.
    pub fn with_escrow(self, escrow_contract: impl Into<String>) -> Self;

    /// Startup check that the local EIP-712 domain matches the contract's
    /// `domainSeparator()`; mismatch → 8000. Strongly recommended to call once at
    /// startup — otherwise every subsequent voucher / settle / close signature
    /// will be rejected by the on-chain 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 the 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 both `cumulative_amount` / `provided_voucher_sig` are `None`,
    /// takes the waiver branch (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"payee check → SA session/open → write local store
"voucher"submit_voucher (local signature verify + raise highest) → deduct_from_channel (deduction)
"topUp"SA session/topUp → add to local deposit
"close"take the payer-provided voucher → 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, the error propagates upward.
    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,
    /// `authorized_signer` is already resolved to payer at open time (address(0) → payer),
    /// so the storage layer always sees a non-zero address.
    pub authorized_signer: Address,
    pub deposit: u128,
    pub highest_voucher_amount: u128,
    pub highest_voucher_signature: Option<Bytes>,
    /// Throttling: the minimum voucher increment; `None` disables throttling.
    pub min_voucher_delta: Option<u128>,
    /// Amount deducted (invariant: spent ≤ highest_voucher_amount).
    #[serde(default)]
    pub spent: u128,
    /// Number of deduct calls.
    #[serde(default)]
    pub units: u64,
}

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;
}

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

  • Lost on restart: a process restart / crash loses all channel state. If your business cannot tolerate this loss (long-lived channels, multi-instance HA, hot reload), implement your own persistent store (SQLite / Redis / Postgres / DynamoDB / ...) and inject it via with_store(...).
  • Abandoned channel accumulation: when the payer never calls close, records linger — this is a general session-lifecycle problem, not specific to in-memory. Merchants should have a cleanup strategy, or clean up by business TTL.

NonceProvider trait#

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

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

The contract-level nonce-used set is keyed by (payee, channelId, nonce), and reuse reverts with NonceAlreadyUsed. The SDK is only responsible for allocating a nonce that is "very likely unused"; 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")]
    SignatureParse,
    #[error("ecrecover failed")]
    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>;

/// Compose 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 a Base64UrlJson<T>, and .decode() returns a generic error. This extension normalizes it to SaApiError, so it can be used with ? alongside other SDK calls.

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 (mpp_evm::axum, feature = "handlers")#

You need to enable the handlers feature in Cargo.toml:

toml
okxweb3-app-mpp = { version = "0.2", features = ["handlers"] }
rust
use mpp_evm::axum as mpp_axum;

#[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 automatically mapped to the correct HTTP status code via SaApiError::to_problem_details(...).

Note: the module name is mpp_evm::axum, which collides with the external axum crate; when referencing both in the same .rs file, it is recommended to alias the mpp one (e.g. use mpp_evm::axum as mpp_axum;).


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
8000API service internal error
70000Missing required field or format error
70001Chain not in the supported list
70002Payer is blacklisted
70003source missing, feePayer=true does not support hash mode, or txHash already used
70004Signature verification failed
70005Splits total ≥ primary amount
70006Split count > 10
70007Transaction not confirmed on-chain
70008On-chain contract channel state already closed
70009Challenge does not exist or has expired
70010channelId does not exist
70011Escrow contract grace period < 10 minutes; refuses to open the channel
70012cumulativeAmount exceeds the channel deposit balance
70013Voucher increment below minVoucherDelta
70014Channel is in CLOSING state and does not accept new Vouchers
70015Insufficient local account balance for deduction (available < amount)

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

Lets one axum app serve MPP + x402 at the same time, with the business handler being 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 whether the request headers belong to this protocol (does not read the body)

    /// Produce this protocol's 402 challenge line for the route. `route_cfg` is
    /// the type-erased `AdapterConfig`; each adapter uses
    /// `downcast_ref::<MyRouteConfig>()` to recover its concrete type.
    fn get_challenge<'a>(
        &'a self,
        parts: &'a http::request::Parts,
        route_cfg: &'a AdapterConfig,
    ) -> ChallengeFuture<'a>;

    fn make_service(&self, inner: InnerService) -> InnerService;

    /// Called after route matching, before forwarding. Injects the matched cfg
    /// into extensions for the inner middleware to use directly. Default no-op.
    fn enrich_request_extensions(
        &self,
        _extensions: &mut http::Extensions,
        _route_cfg: &UnifiedRouteConfig,
    ) {
    }

    /// Called once at startup so the adapter can register its routes into
    /// internal state. Default no-op.
    fn prepare(&self, _routes: &[(String, UnifiedRouteConfig)]) -> Result<(), String> {
        Ok(())
    }
}

Built-in adapters#

rust
use std::sync::Arc;
use payment_router_axum::adapters::{MppAdapter, MppRouteConfig, X402Adapter, X402RouteConfig};

// MppAdapter::new takes an `Arc<EvmMpp>` (built via EvmMpp::builder(...).with_charge(...).build()).
let mpp_adapter: Arc<dyn ProtocolAdapter> = Arc::new(MppAdapter::new(mpp));

// X402Adapter::new takes an owned `X402ResourceServer` and returns a builder; finish with `.build()`.
// The route config is not passed here, but goes into UnifiedRouteConfig.adapter_configs["x402"].
let x402_adapter: Arc<dyn ProtocolAdapter> = Arc::new(X402Adapter::new(x402_server).build());

Full adapter construction methods#

rust
impl MppAdapter {
    pub fn new(mpp: Arc<EvmMpp>) -> Self;
    /// Override the default priority of 10 (custom adapters should start from 100).
    pub fn with_priority(self, priority: u32) -> Self;
}

impl X402Adapter {
    /// One arg + builder finish.
    pub fn new(server: X402ResourceServer) -> X402AdapterBuilder;
}

impl X402AdapterBuilder {
    pub fn priority(self, priority: u32) -> Self;
    pub fn poll_deadline(self, d: Duration) -> Self;
    pub fn resolver(self, resolver: PaymentResolverFn) -> Self;

    // Hooks with the same names as x402-axum's payment_middleware_with_*, forwarded
    // verbatim to the internal PaymentLayer.
    pub fn on_protected_request(self, hook: OnProtectedRequestHook) -> Self;
    pub fn on_before_verify(self, hook: OnBeforeVerifyHook) -> Self;
    pub fn on_after_verify(self, hook: OnAfterVerifyHook) -> Self;
    pub fn on_verify_failure(self, hook: OnVerifyFailureHook) -> Self;
    pub fn on_before_settle(self, hook: OnBeforeSettleHook) -> Self;
    pub fn on_after_settle(self, hook: OnAfterSettleHook) -> Self;
    pub fn on_settle_failure(self, hook: OnSettleFailureHook) -> Self;
    pub fn on_settlement_timeout(self, hook: OnSettlementTimeoutHook) -> Self;

    pub fn build(self) -> X402Adapter;
}

MppAdapter internally uses the full mpp::server::axum::ChargeChallenger flow (HMAC check + EIP-3009 verification + SA-API settlement); X402Adapter internally goes through x402-axum's native PaymentMiddleware.

Per-adapter typed route config#

rust
/// MPP per-route config.
#[derive(Debug, Clone, Default)]
pub struct MppRouteConfig {
    /// `"charge"` or `"session"` (empty → `"charge"`)
    pub intent: String,
    pub amount: String,                         // base-units integer string
    pub currency: String,
    pub description: Option<String>,
    /// The merchant's own reference id (charge only)
    pub external_id: Option<String>,
    /// session only: billing unit ("request" / "byte" etc.)
    pub unit_type: Option<String>,
    /// session only: suggested initial deposit (base units, stringified)
    pub suggested_deposit: Option<String>,
}

/// x402 per-route config (compile-time mapped to `x402_axum::RoutePaymentConfig`).
#[derive(Debug, Clone, Default)]
pub struct X402RouteConfig {
    pub accepts: Vec<x402_axum::AcceptConfig>,
    pub description: String,
    pub mime_type: String,
    pub sync_settle: Option<bool>,
    pub resource: Option<String>,
}

Router configuration#

rust
use std::collections::HashMap;
use std::sync::Arc;
use payment_router_axum::{
    AdapterConfig, BuildError, PaymentRouterConfig, PaymentRouterLayer,
    ProtocolAdapter, UnifiedRouteConfig,
};

#[derive(Clone)]
pub struct AdapterConfig(Arc<dyn Any + Send + Sync>);

impl AdapterConfig {
    pub fn new<T: Any + Send + Sync + 'static>(value: T) -> Self;
    pub fn downcast_ref<T: Any>(&self) -> Option<&T>;
}

#[derive(Debug, Clone, Default)]
pub struct UnifiedRouteConfig {
    pub description: Option<String>,
    /// adapter.name() → that adapter's type-erased config on this route.
    /// An adapter not listed is not enabled on this route.
    pub adapter_configs: HashMap<String, AdapterConfig>,
}

impl UnifiedRouteConfig {
    pub fn builder() -> UnifiedRouteConfigBuilder;
}

impl UnifiedRouteConfigBuilder {
    pub fn description(self, desc: impl Into<String>) -> Self;
    /// Typed injection; T must match the type expected by the corresponding adapter.
    pub fn adapter<T: Any + Send + Sync + 'static>(
        self,
        name: impl Into<String>,
        config: T,
    ) -> Self;
    pub fn build(self) -> UnifiedRouteConfig;
}

pub struct PaymentRouterConfig {
    /// `Vec<(pattern, route_cfg)>`. A Vec rather than a HashMap — declaration
    /// order matters (spec §9 first-match-wins). The pattern is shaped like
    /// "GET /path" or "/path".
    pub routes: Vec<(String, UnifiedRouteConfig)>,
    /// The list of protocol adapters (MPP / x402 / custom).
    pub protocols: Vec<Arc<dyn ProtocolAdapter>>,
    pub on_error: Option<Arc<ErrorHandler>>,
}

pub type ErrorHandler =
    dyn Fn(&(dyn std::error::Error + Send + Sync), ErrorContext) + Send + Sync + 'static;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorPhase {
    Detect,        // adapter.detect()
    Challenge,     // adapter.get_challenge()
    Handle,        // adapter-wrapped service call
}

impl ErrorPhase {
    pub fn as_str(self) -> &'static str;  // "detect" / "challenge" / "handle"
}

#[derive(Debug, Clone)]
pub struct ErrorContext {
    pub phase: ErrorPhase,
    pub protocol: String,
    pub route: Option<String>,
}

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

impl PaymentRouterLayer {
    /// On failure returns `payment_router_axum::BuildError`: the unified routes
    /// reference an unregistered `adapter.name()`, or some adapter's `prepare()`
    /// hook errored (e.g. a typed-config downcast failure).
    pub fn new(cfg: PaymentRouterConfig) -> Result<Self, BuildError>;
}

End-to-end assembly example#

rust
let route = UnifiedRouteConfig::builder()
    .description("photo")
    .adapter("mpp", MppRouteConfig {
        intent: "charge".into(),
        amount: "100".into(),
        currency: "0x...".into(),
        description: Some("photo".into()),
        external_id: None,
        unit_type: None,
        suggested_deposit: None,
    })
    .adapter("x402", X402RouteConfig {
        accepts: vec![/* AcceptConfig {...} */],
        description: "photo".into(),
        mime_type: "image/png".into(),
        sync_settle: None,
        resource: None,
    })
    .build();

let layer = PaymentRouterLayer::new(PaymentRouterConfig {
    routes: vec![("GET /photo".into(), route)],
    protocols: vec![mpp_adapter, x402_adapter],
    on_error: None,
})?;
Table of contents