Rust SDK Reference#
Rust SDK Reference (for exact, exact + permit2, upto, aggr_deferred)#
Crate#
| Directory / Lib alias | Published name (crates.io) | Description |
|---|---|---|
x402-core | okxweb3-app-x402-core | Core: server, facilitator client, types, HTTP utilities, HMAC authentication |
x402-axum | okxweb3-app-x402-axum | Axum middleware (Tower Layer/Service) |
x402-evm | okxweb3-app-x402-evm | EVM mechanisms: exact (EIP-3009 / Permit2), upto, aggr_deferred |
Cargo.toml deps use the Published name (
okxweb3-app-*), while sourceusestatements 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#
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#
#[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#
#[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#
#[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:
| key | scheme | Meaning |
|---|---|---|
assetTransferMethod | exact / upto | "eip3009" (default) or "permit2" |
facilitatorAddress | upto | The upto proxy enforces witness.facilitator == msg.sender; automatically injected from getSupported by enhance_payment_requirements |
name / version | exact (EIP-3009 path) | EIP-712 domain, used for client-side signing |
PaymentRequired#
The 402 response body.
#[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).
#[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#
#[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#
#[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#
#[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#
#[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.
#[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.
#[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#
#[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#
#[async_trait]
pub trait FacilitatorExtension: Send + Sync {
fn key(&self) -> &str;
fn supported_networks(&self) -> Vec<Network>;
}
Server API (X402ResourceServer)#
Construction and registration#
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#
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#
#[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)#
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#
| Method | OKX 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#
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#
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#
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#
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)#
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#
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#
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#
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#
- Get the route cfg for this request: prefer
req.extensions::<PreMatchedRoute>()(already matched by an outer router); otherwise fall back tofind_route_config(state.routes, method, path)— if neither matches → pass through to the inner handler - No
payment-signaturerequest header → return 402 + thePAYMENT-REQUIREDrequest header - Decode and verify the payment payload
- Match the payload against the route's
accepts - Verify via the facilitator (
POST /verify) - Call the inner handler and buffer the response
- If the business handler wrote a
settlement-overridesresponse header (upto), the middleware parses it and calls settle withSettlementOverrides - Settle via the facilitator (
POST /settle) - Asynchronous (
status: "pending") → poll withinpoll_deadline - Timeout → call
OnSettlementTimeoutHook(if configured) - Add the
PAYMENT-RESPONSErequest header to the response
Re-exports#
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:
/// 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#
use x402_evm::ExactEvmScheme;
let scheme = ExactEvmScheme::new();
scheme.scheme(); // "exact"
Responsible for:
- Price parsing:
"$0.01"/"0.01"/ JSONAssetAmount - 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) intoextrafor client-side EIP-3009 signing - Also supporting Permit2: when the buyer sets
extra.assetTransferMethod = "permit2", they sign a Permit2 credential, producing anExactPermit2Payload
AggrDeferredEvmScheme#
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#
use x402_evm::UptoEvmScheme;
let scheme = UptoEvmScheme::new();
scheme.scheme(); // "upto"
upto is a Permit2-only cap-and-override mode:
PaymentRequirements.amountis the cap, not the actual chargeenhance_payment_requirementsforcesextra.assetTransferMethod = "permit2"- Automatically injects
extra.facilitatorAddressfromgetSupportedinto the challenge, so the buyer pins the facilitator address intowitness.facilitator(the contract enforcesmsg.sender == witness.facilitator) - The business handler writes
settlement-overrides: {"amount":"..."}in the response header to decide the actual charge; the middleware reads it and callssettle_payment(..., overrides), with the remaining balance automatically not charged
EVM Payload types#
#[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 + permit2oruptopath, the buyer wallet mustapprove(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.
// 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#
#[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#
#[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)#
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)#
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 period / subscriptions)#
Crate#
Subscriptions (the period scheme) reuse the same three crates as the other x402 schemes; this section is based on SDK 0.3.x (subscription support since 0.3.0).
| Directory / Lib alias | Published name (crates.io) | Subscription content |
|---|---|---|
x402-core | okxweb3-app-x402-core | subscription module: terms / signature / request-response types, codec, SubscriptionFacilitatorClient / SubscriptionStore traits, period math, constants |
x402-evm | okxweb3-app-x402-evm | subscription module: PermitSubscriptionScheme (the period scheme), SubscriptionPlan, AccessProof EIP-712 verification |
x402-axum | okxweb3-app-x402-axum | SubscriptionSupport, subscription middleware branches (verify → settle), the on_before_access hook, InMemorySubscriptionStore |
[dependencies]
okxweb3-app-x402-axum = "0.3"
okxweb3-app-x402-core = "0.3"
okxweb3-app-x402-evm = "0.3"
Subscriptions are a seller + facilitator-client capability; buyer signing (subscription terms / Permit2 / cancel auth) is done by the Onchain OS client and is not part of this SDK.
Core types (x402_core::subscription)#
SubscriptionTerms#
Buyer-signed subscription terms (EIP-712, 17 signed fields; planId is an unsigned business identifier carried on the wire only).
#[serde(rename_all = "camelCase")]
pub struct SubscriptionTerms {
pub payer: String, // buyer (owner)
pub merchant: String, // recipient merchant (= route offer payTo)
pub facilitator: String, // facilitator EOA
pub token: String, // payment token (= offer asset)
pub amount_per_period: String, // per-period amount, base units (uint160)
pub period_sec: u64, // fixed period secs; 0 in calendar-month mode
pub max_periods: u32, // max periods (allowance cap)
pub start_at: u64, // 0 = chain uses block.timestamp
pub initial_charge_periods: u32, // first-charge params (promotions)
pub initial_charge_amount: String,
pub terms_deadline: u64,
pub permit_hash: String, // binds the paired PermitSingle signature
pub salt: String,
#[serde(default)]
pub plan_id: String, // business planId (unsigned, carried only)
pub plan_tier: u8, // tier (drives up/downgrade direction)
pub change_from_sub_id: String, // all-zero = create; non-zero = change (source subId)
pub change_effective_at: u8, // 0 none / 1 upgrade (immediate) / 2 downgrade (period end)
#[serde(default)]
pub period_mode: u8, // 0 fixed interval / 1 calendar month
}
PermitDetails / PermitSingle#
Permit2 AllowanceTransfer authorization (spender = subscription contract).
#[serde(rename_all = "camelCase")]
pub struct PermitDetails {
pub token: String,
pub amount: String, // covers reserved + this full commitment
pub expiration: u64, // must cover the whole subscription window
pub nonce: u64, // = on-chain Permit2.allowance(owner,token,spender).nonce
}
#[serde(rename_all = "camelCase")]
pub struct PermitSingle {
pub details: PermitDetails,
pub spender: String, // subscription contract address
pub sig_deadline: String,
}
CancelAuth / PendingChangeCancelAuth#
#[serde(rename_all = "camelCase")]
pub struct CancelAuth {
pub action: u8, // 0 = cancel_subscription
pub sub_id: String,
pub initiator: u8, // 0 = payer / 1 = merchant
pub nonce: String,
pub deadline: u64,
pub signature: String,
}
// Revert a not-yet-effective downgrade (payer-signed); 4-field digest {subId,newSubId,nonce,deadline}
#[serde(rename_all = "camelCase")]
pub struct PendingChangeCancelAuth {
pub sub_id: String,
pub new_sub_id: String, // = the current PENDING newSubId, bound into the signature
pub nonce: String,
pub deadline: u64,
pub signature: String,
}
AccessProof / SubscriptionPayloadInner#
AccessProof: a buyer-wallet-signed access credential (APP-Access header, ecrecover-verified by the middleware). SubscriptionPayloadInner: the double-signed payload unpacked from PAYMENT-SIGNATURE (terms + PermitSingle + the two signatures); the middleware unpacks it into a facilitator request.
Facilitator request / response types (x402_core::subscription)#
// Create
pub struct CreateSubscriptionRequest { pub chain_index: u64, pub terms: SubscriptionTerms,
pub permit: PermitSingle, pub terms_sig: String, pub permit_sig: String, pub sync_settle: bool }
pub struct CreateSubscriptionResponse { pub sub_id: String, pub tx_hash: Option<String>, pub state: u8 }
// Change (up/downgrade)
pub struct ChangeSubscriptionRequest { pub chain_index: u64, pub old_sub_id: String,
pub new_terms: SubscriptionTerms, pub permit: PermitSingle, pub terms_sig: String,
pub permit_sig: String, pub sync_settle: bool }
pub struct ChangeResponse { pub new_sub_id: String, pub tx_hash: Option<String>, pub state: u8 }
// Charge
pub struct ChargeResponse { pub sub_id: String, pub period: u32, pub tx_hash: Option<String>,
pub state: u8, pub plan_change_triggered: bool, pub new_sub_id: Option<String> }
// Cancel / cancel-pending-change / finalize (shared response)
pub struct CancelSubscriptionRequest { pub sub_id: String, pub cancel_auth: CancelAuth, pub sync_settle: bool }
pub struct CancelPendingChangeRequest { pub sub_id: String, pub cancel_auth: PendingChangeCancelAuth, pub sync_settle: bool }
pub struct TxResultResponse { pub sub_id: String, pub tx_hash: Option<String>, pub state: Option<u8> }
// Queries: subscription detail / pending downgrade / charge ledger
pub struct SubscriptionStatus { /* subId, state, payer, planId, planTier, amountPerPeriod,
periodSec, periodMode, startAt, billingAnchorAt, maxPeriods, lastChargedPeriod,
currentPeriod, elapsedPeriods, nextChargeableAt, changedToSubId, isActive,
serviceEnded, pendingPlanChange: Option<PendingPlanChange>, … all serde(default) */ }
pub struct PendingPlanChange { pub sub_id: String, pub new_sub_id: String,
pub effective_from_period: u32, pub state: u8 }
pub struct SubscriptionCharge { pub sub_id: String, pub period: u32,
pub charge_type: u8, // 1 initial / 2 periodic / 3 downgrade_first_period / 4 finalize_expired_marker
pub amount: String, pub state: u8, pub tx_hash: Option<String>,
pub plan_change_triggered: bool, pub new_sub_id: Option<String> }
pub struct ChargesResponse { /* charges: Vec<SubscriptionCharge>, pagination fields */ }
Constants (x402_core::subscription)#
pub mod subscription_state { // subscription state
pub const PENDING: u8 = 0; pub const ACTIVE: u8 = 1; pub const COMPLETED: u8 = 2;
pub const CANCELED: u8 = 3; pub const CHANGED: u8 = 4; pub const FAILED: u8 = 99;
}
pub mod change_effective_at { // when a change takes effect
pub const NONE: u8 = 0; pub const IMMEDIATE: u8 = 1; /* upgrade */ pub const PERIOD_END: u8 = 2; /* downgrade */
}
pub mod cancel_initiator { pub const PAYER: u8 = 0; pub const MERCHANT: u8 = 1; }
pub const PERIOD_MODE_FIXED: u8 = 0; // fixed interval
pub const PERIOD_MODE_CALENDAR_MONTH: u8 = 1; // calendar month
x402_evm::subscription: SUBSCRIPTION_SCHEME = "period", DEFAULT_ACCESS_PROOF_WINDOW_SECS = 300 (AccessProof verification window, seconds).
Traits#
SubscriptionFacilitatorClient (x402_core::subscription)#
Implemented by OkxHttpFacilitatorClient (the same client as the plain FacilitatorClient).
#[async_trait]
pub trait SubscriptionFacilitatorClient: Send + Sync {
async fn create_subscription(&self, req: &CreateSubscriptionRequest) -> Result<CreateSubscriptionResponse, X402Error>;
async fn charge(&self, sub_id: &str, sync_settle: bool) -> Result<ChargeResponse, X402Error>;
async fn change_subscription(&self, req: &ChangeSubscriptionRequest) -> Result<ChangeResponse, X402Error>;
async fn cancel_subscription(&self, req: &CancelSubscriptionRequest) -> Result<TxResultResponse, X402Error>;
async fn cancel_pending_change(&self, req: &CancelPendingChangeRequest) -> Result<TxResultResponse, X402Error>;
async fn finalize_expired(&self, sub_id: &str) -> Result<TxResultResponse, X402Error>;
async fn get_subscription(&self, sub_id: &str) -> Result<SubscriptionStatus, X402Error>;
async fn get_charges(&self, sub_id: &str, limit: u32, offset: u32) -> Result<ChargesResponse, X402Error>;
async fn get_pending_change(&self, sub_id: &str) -> Result<Option<PendingPlanChange>, X402Error>;
}
SubscriptionStore + SubscriptionRecord (x402_core::subscription)#
Optional seller-side cache: speeds up APP-Access gating and backs due_subscriptions; the facilitator / chain remain authoritative. Default impl InMemorySubscriptionStore (in-process, non-durable); implement your own over Redis/SQL.
#[async_trait]
pub trait SubscriptionStore: Send + Sync {
async fn get(&self, sub_id: &str) -> Option<SubscriptionRecord>;
async fn put(&self, record: SubscriptionRecord);
async fn remove(&self, sub_id: &str);
async fn list(&self) -> Vec<SubscriptionRecord>; // due filtering is done by SubscriptionSupport::due_subscriptions
}
#[serde(rename_all = "camelCase")]
pub struct SubscriptionRecord {
pub sub_id: String, pub state: u8, pub payer: String,
pub plan_id: String, pub plan_tier: u8,
pub next_chargeable_at: Option<u64>, // drives due_subscriptions
pub changed_to_sub_id: Option<String>,
pub start_at: u64, pub period_sec: u64, pub period_mode: u8, pub billing_anchor_at: u64,
pub max_periods: u32, pub last_charged_period: Option<u32>, pub updated_at: u64,
}
EVM mechanism (x402-evm::subscription)#
PermitSubscriptionScheme#
use x402_evm::subscription::PermitSubscriptionScheme;
let scheme = PermitSubscriptionScheme::new()
.with_subscription_contract("0x…") // optional: pin the A2APaySubscription contract (else from /supported)
.with_facilitator("0x…") // optional: pin the facilitator EOA (else from /supported `facilitatorAddress`, falling back to `facilitator`)
.with_permit2_contract("0x…"); // optional: pin the Permit2 domain contract
scheme.scheme(); // "period"
Handles:
- Builds the 402
PaymentRequirements(schemeperiod): injects the facilitator EOA intoextra.facilitator, the subscription and Permit2 contracts intoextra.contracts, and the EIP-712 domain intoextra.domain, for the buyer's double signing; SettlementMode::Pre(settle-before-serve): subscribe/change settle before the resource is served;- On a plan change, for requests carrying
APP-Accessit injectsextra.changeFrom(direction + source subId) into each offer, and stripsinitialChargefrom downgrade offers.
SubscriptionPlan#
pub struct SubscriptionPlan {
pub id: String, pub tier: u8, pub network: String, pub pay_to: String,
pub price: String, // display price (resolves the asset)
pub amount_per_period: String, // per-period amount, base units
pub period_sec: u64, pub period_mode: u8, pub max_periods: u32, pub start_at: u64,
pub initial_charge_periods: u32, pub initial_charge_amount: String,
pub max_timeout_seconds: Option<u64>,
pub name: Option<String>, pub features: Option<Vec<String>>,
}
impl SubscriptionPlan { pub fn to_accept_config(&self) -> AcceptConfig; } // turn into one route accepts entry
verify_access_proof(...): ecrecover-verifies the APP-Access AccessProof (called internally by the middleware when gating access; sellers rarely call it directly).
Subscription capability (x402-axum::SubscriptionSupport)#
impl SubscriptionSupport {
pub fn new(facilitator: Arc<dyn SubscriptionFacilitatorClient>, access_window_secs: u64) -> Self;
pub fn with_store(self, store: Arc<dyn SubscriptionStore>) -> Self; // enable cache + a default 30s access cache
pub fn with_access_cache_ttl(self, secs: u64) -> Self; // override access-cache freshness; 0 = always query
pub fn on_before_access(self, hook: OnBeforeAccessHook) -> Self; // merchant access-veto hook
// For seller-driven recurring charging:
pub async fn due_subscriptions(&self, now_unix: u64) -> Vec<SubscriptionRecord>; // active & due
pub async fn charge_and_record(&self, sub_id: &str, sync_settle: bool) -> Result<ChargeRecordOutcome, String>;
}
pub struct ChargeRecordOutcome { pub state: u8, pub period: u32, pub tx_hash: Option<String>,
pub plan_change_triggered: bool, pub new_sub_id: Option<String> }
pub struct SubmitOutcome { pub sub_id: String, pub tx_hash: Option<String>, pub state: u8 } // create/change result
Access-hook types:
pub type OnBeforeAccessHook =
Arc<dyn Fn(AccessContext) -> Pin<Box<dyn Future<Output = BeforeAccessResult> + Send>> + Send + Sync>;
pub struct AccessContext { pub sub_id: String, pub payer: String }
pub struct BeforeAccessResult { pub abort: bool, pub reason: Option<String> } // abort=true → bare 402
The verify → settle orchestration for create / change / cancel / access is internal to the middleware (
pub(crate), not public API); sellers just wire it in viaPaymentMiddlewareBuilder::subscription(support).
Route config extension (RoutePaymentConfig.operation)#
Subscriptions add an operation field on RoutePaymentConfig declaring whether the route is a plain resource endpoint or a subscription-operation endpoint:
#[serde(rename_all = "kebab-case")]
pub enum SubscriptionOperation { Change, Cancel, CancelPendingChange }
pub struct RoutePaymentConfig {
pub accepts: Vec<AcceptConfig>,
pub description: String,
pub mime_type: String,
pub sync_settle: Option<bool>,
pub resource: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<SubscriptionOperation>, // None = resource endpoint; Some(...) = change/cancel
}
operation | Endpoint semantics | Middleware behavior |
|---|---|---|
None | resource endpoint / first-time subscribe | APP-Access gating; or create from a double-signed payload |
Change | change plan | APP-Access → 402 with changeFrom; double-sig → up/downgrade |
Cancel | cancel a subscription | relays the buyer-signed CancelAuth |
CancelPendingChange | revert a not-yet-effective downgrade | relays the buyer-signed PendingChangeCancelAuth |
Axum middleware integration (subscriptions)#
use x402_axum::{PaymentMiddlewareBuilder, SubscriptionSupport};
let app = Router::new()
.route("/weather", get(handler))
.route("/subscription/change", get(change_handler))
.route("/subscription/cancel", post(ok))
.layer(
PaymentMiddlewareBuilder::new(routes, server)
.subscription(subscription) // attach subscription support
.build(),
);
The subscription middleware dispatches by operation, and each operation runs an explicit verify → settle pair:
- resource endpoint +
APP-Access→ verify the AccessProof →on_before_accesshook → plan/period check → serve; - resource endpoint +
PAYMENT-SIGNATURE(double-sig) → bind terms to the offer → create the subscription (settle-before-serve); Change→ 402 withchangeFrom/ up/downgrade settlement;Cancel/CancelPendingChange→ relay the buyer-signed authorization.
Rust SDK Reference (for charge, session)#
Crate#
| Directory / Lib alias | Published name (crates.io) | Description |
|---|---|---|
mpp-evm | okxweb3-app-mpp | OKX 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-axum | okxweb3-app-payment-router-axum | Dual-protocol (MPP + x402) routing Tower Layer; an adapter pattern that lets one axum app serve both protocols |
mpp-evmre-exports the upstream crate viapub use ::mpp;, so the business side only needs to depend onokxweb3-app-mpp; to use upstream modules such asproxy, go throughmpp_evm::mpp::proxy::....
Constants#
/// 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.
#[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).
#[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#
#[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).
#[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#
#[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).
#[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#
/// 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].
#[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)#
#[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 method | OKX 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.
#[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 broadcaststransferWithAuthorizationon-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#
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#
#[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#
#[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#
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.action | Behavior |
|---|---|
"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#
/// 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#
#[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#
#[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#
#[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#
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#
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#
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)#
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.
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:
okxweb3-app-mpp = { version = "0.2", features = ["handlers"] }
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#
#[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#
| code | Meaning |
|---|---|
| 8000 | API service internal error |
| 70000 | Missing required field or format error |
| 70001 | Chain not in the supported list |
| 70002 | Payer is blacklisted |
| 70003 | source missing, feePayer=true does not support hash mode, or txHash already used |
| 70004 | Signature verification failed |
| 70005 | Splits total ≥ primary amount |
| 70006 | Split count > 10 |
| 70007 | Transaction not confirmed on-chain |
| 70008 | On-chain contract channel state already closed |
| 70009 | Challenge does not exist or has expired |
| 70010 | channelId does not exist |
| 70011 | Escrow contract grace period < 10 minutes; refuses to open the channel |
| 70012 | cumulativeAmount exceeds the channel deposit balance |
| 70013 | Voucher increment below minVoucherDelta |
| 70014 | Channel is in CLOSING state and does not accept new Vouchers |
| 70015 | Insufficient 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#
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#
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#
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#
/// 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#
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#
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,
})?;
- Rust SDK Reference (for exact, exact + permit2, upto, aggr_deferred)CrateCore typesNetwork / Money / PriceAssetAmountResourceInfoPaymentRequirementsPaymentRequiredPaymentPayloadFacilitator typesVerifyRequest / VerifyResponseSettleRequest / SettleResponseSupportedKind / SupportedResponseSettleStatusResponseTraitsSchemeNetworkServerFacilitatorClientResourceServerExtensionFacilitatorExtensionServer API (X402ResourceServer)Construction and registrationMethodsPollResult / SettlementOverridesOKX Facilitator client (OkxHttpFacilitatorClient)Endpoints calledHMAC authenticationHTTP utilitiesHeader encode/decodeConstantsRoute configurationExample (multiple schemes coexisting)Axum middleware (x402-axum)Basic usageConstructorsOnSettlementTimeoutHookMiddleware flowRe-exportsEVM mechanisms (x402-evm)ExactEvmSchemeAggrDeferredEvmSchemeUptoEvmSchemeEVM Payload typesPermit2 / Upto constantsAsset configurationError typesUtility functions (x402_core::utils)Schema validation (x402_core::schemas)Rust SDK Reference (for period / subscriptions)CrateCore types (x402_core::subscription)SubscriptionTermsPermitDetails / PermitSingleCancelAuth / PendingChangeCancelAuthAccessProof / SubscriptionPayloadInnerFacilitator request / response types (x402_core::subscription)Constants (x402_core::subscription)TraitsSubscriptionFacilitatorClient (x402_core::subscription)SubscriptionStore + SubscriptionRecord (x402_core::subscription)EVM mechanism (x402-evm::subscription)PermitSubscriptionSchemeSubscriptionPlanSubscription capability (x402-axum::SubscriptionSupport)Route config extension (RoutePaymentConfig.operation)Axum middleware integration (subscriptions)Rust SDK Reference (for charge, session)CrateConstantsCore types (mpp-evm)SaApiResponseChargeMethodDetails / ChargeSplitSessionMethodDetails / SessionSplitEip3009Authorization / Eip3009SplitChargeReceipt / SessionReceipt / ChannelStatusSettleRequestPayload / CloseRequestPayloadServerAccountingStateSaApiClient traitOKX SA-API client (OkxSaApiClient)EndpointsCharge — EvmChargeMethodCharge — EvmChargeChallengerConfigConstructionSession — EvmSessionMethodConstruction and configurationBusiness methodsSession action routing (inside SessionMethod::verify_session)Session — SessionStore traitChannelRecordDefault implementation — InMemorySessionStoreNonceProvider traitEIP-712 signing (mpp_evm::eip712)DomainVoucher verificationSettleAuthorization / CloseAuthorization signingChallenge builders (mpp_evm::charge::challenge)CredentialExt — decoding challenge.requestAxum drop-in handlers (mpp_evm::axum, feature = "handlers")Error typesError code mappingDual-protocol routing (payment-router-axum)Adapter traitBuilt-in adaptersFull adapter construction methodsPer-adapter typed route configRouter configurationEnd-to-end assembly example
