开发者平台
主题

Node SDK 参考#

Node SDK 参考(适用于 exactaggr_deferredperiod#

#

描述
@okxweb3/x402-core核心:客户端、服务端、facilitator、类型、订阅支持
@okxweb3/x402-evmEVM 机制:exact、aggr_deferred、period(订阅)
@okxweb3/x402-expressExpress 中间件(卖方)
@okxweb3/x402-nextNext.js 中间件(卖方)
@okxweb3/x402-honoHono 中间件(卖方)
@okxweb3/x402-fastifyFastify 中间件(卖方)
@okxweb3/x402-fetchFetch 包装器(买方)
@okxweb3/x402-axiosAxios 包装器(买方)
@okxweb3/x402-mcpMCP 集成
@okxweb3/x402-paywall浏览器付费墙 UI
@okxweb3/x402-extensions协议扩展

核心类型#

Network#

typescript
type Network = `${string}:${string}`;
// CAIP-2 format, e.g., "eip155:196", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"

Money / Price / AssetAmount#

typescript
type Money = string | number;
// User-friendly amount, e.g., "$0.01", "0.01", 0.01

type AssetAmount = {
  asset: string;            // Token contract address
  amount: string;           // Amount in token's smallest unit (e.g., "10000" for 0.01 USDC)
  extra?: Record<string, unknown>;  // Scheme-specific data (e.g., EIP-712 domain)
};

type Price = Money | AssetAmount;
// Either a user-friendly amount or a specific token amount

ResourceInfo#

typescript
interface ResourceInfo {
  url: string;              // Resource URL path
  description?: string;     // Human-readable description
  mimeType?: string;        // Response content type (e.g., "application/json")
}

PaymentRequirements#

描述卖方接受的支付方式。

typescript
type PaymentRequirements = {
  scheme: string;           // Payment scheme: "exact" | "aggr_deferred" | "period"
  network: Network;         // CAIP-2 network identifier
  asset: string;            // Token contract address
  amount: string;           // Price in token's smallest unit
  payTo: string;            // Recipient wallet address
  maxTimeoutSeconds: number; // Payment authorization validity window
  extra: Record<string, unknown>; // Scheme-specific data
};

各 scheme 的 extra 字段:

SchemeExtra 字段Type描述
exact (EIP-3009)extra.eip712.namestringEIP-712 域名称(例如 "USD Coin")
exact (EIP-3009)extra.eip712.versionstringEIP-712 域版本(例如 "2")
period(订阅)extra.plan / extra.amountPerPeriod / extra.periodSec / extra.contracts / extra.facilitator / extra.domain见"订阅支付"章节

PaymentRequired#

发送给客户端的 HTTP 402 响应体。

typescript
type PaymentRequired = {
  x402Version: number;       // Protocol version (currently 2)
  error?: string;            // Optional error message
  resource: ResourceInfo;    // Protected resource metadata
  accepts: PaymentRequirements[];  // List of accepted payment options
  extensions?: Record<string, unknown>;  // Extension data (e.g., Bazaar)
};

PaymentPayload#

客户端在重试请求中提交的签名支付。

typescript
type PaymentPayload = {
  x402Version: number;       // Must match server's version
  resource?: ResourceInfo;   // Optional resource reference
  accepted: PaymentRequirements;  // The chosen payment option from `accepts`
  payload: Record<string, unknown>;  // Scheme-specific signed data (see below)
  extensions?: Record<string, unknown>;  // Extension data
};

各 scheme 的 payload 字段:

exact (EIP-3009) 的 payload:

typescript
{
  signature: `0x${string}`;     // EIP-712 signature
  authorization: {
    from: `0x${string}`;       // Buyer wallet address
    to: `0x${string}`;         // Seller wallet address
    value: string;              // Amount in smallest unit
    validAfter: string;         // Unix timestamp (start validity)
    validBefore: string;        // Unix timestamp (end validity)
    nonce: `0x${string}`;      // 32-byte unique nonce
  };
}

aggr_deferred 的 payload:

typescript
{
  signature: `0x${string}`;     // Session key signature
  authorization: { /* same as EIP-3009 */ };
  // acceptedExtraOverrides includes sessionCert
}

period(订阅) 的 payload:

typescript
{
  permitSingle: {                 // Permit2 PermitSingle 授权
    details: { token, amount, expiration, nonce };
    spender: string;              // subscription 合约地址
    sigDeadline: string;
  };
  permitSingleSignature: `0x${string}`;
  terms: {                        // SubscriptionTerms EIP-712 消息(见订阅章节)
    payer, merchant, facilitator, token, amountPerPeriod, periodSec, periodMode,
    maxPeriods, startAt, initialChargePeriods, initialChargeAmount,
    planTier, changeFromSubId, changeEffectiveAt, permitHash, salt, termsDeadline,
  };
  termsSignature: `0x${string}`;
}

VerifyResponse#

typescript
type VerifyResponse = {
  isValid: boolean;          // Whether signature is valid
  invalidReason?: string;    // Machine-readable reason code
  invalidMessage?: string;   // Human-readable error message
  payer?: string;            // Recovered payer address
  extensions?: Record<string, unknown>;
};

SettleResponse#

typescript
type SettleResponse = {
  success: boolean;          // Whether settlement succeeded
  status?: "pending" | "success" | "timeout";  // OKX extension
  errorReason?: string;      // Machine-readable error code
  errorMessage?: string;     // Human-readable error message
  payer?: string;            // Payer address
  transaction: string;       // On-chain transaction hash (empty for aggr_deferred)
  network: Network;          // Settlement network
  amount?: string;           // Actual settled amount (may differ for "upto")
  extensions?: Record<string, unknown>;
};

SupportedKind / SupportedResponse#

typescript
type SupportedKind = {
  x402Version: number;
  scheme: string;
  network: Network;
  extra?: Record<string, unknown>;
};

type SupportedResponse = {
  kinds: SupportedKind[];
  extensions: string[];      // Supported extension keys
  signers: Record<string, string[]>;  // CAIP family → signer addresses
};

period scheme 的 SupportedKind.extra 额外携带三个字段:facilitatorAddress / subscriptionContract / permit2Contract(详见"订阅支付"章节)。


服务端 API (x402ResourceServer)#

构造函数#

typescript
import { x402ResourceServer } from "@okxweb3/x402-core/server";

const server = new x402ResourceServer(facilitatorClients?);
// facilitatorClients: FacilitatorClient | FacilitatorClient[]

register(network, server)#

注册服务端 scheme。可链式调用。

typescript
server
  .register("eip155:84532", new ExactEvmScheme())
  .register("eip155:196", new AggrDeferredEvmScheme())
  .register("eip155:196", new PermitSubscriptionScheme({ /* ... */ }));  // 订阅

registerExtension(extension)#

typescript
interface ResourceServerExtension {
  key: string;
  enrichDeclaration?: (declaration: unknown, transportContext: unknown) => unknown;
  enrichPaymentRequiredResponse?: (
    declaration: unknown,
    context: PaymentRequiredContext,
  ) => Promise<unknown>;
  enrichSettlementResponse?: (
    declaration: unknown,
    context: SettleResultContext,
  ) => Promise<unknown>;
}

initialize()#

从 facilitator 获取支持的 kinds。在启动时调用一次。

typescript
await server.initialize();

buildPaymentRequirements(config) → PaymentRequirements[]#

typescript
interface ResourceConfig {
  scheme: string;               // "exact" | "aggr_deferred" | "upto" | "period"
  payTo: string;                // Recipient wallet address
  price: Price;                 // "$0.01" or AssetAmount
  network: Network;             // "eip155:196"
  maxTimeoutSeconds?: number;   // Default: 300
  extra?: Record<string, unknown>;
}

const reqs = await server.buildPaymentRequirements({
  scheme: "exact",
  payTo: "0xSeller",
  price: "$0.01",
  network: "eip155:196",
});

buildPaymentRequirementsFromOptions(options, context) → PaymentRequirements[]#

动态定价和 payTo。函数接收 context 参数。

typescript
const reqs = await server.buildPaymentRequirementsFromOptions(
  [
    {
      scheme: "exact",
      network: "eip155:196",
      payTo: (ctx) => ctx.sellerId === "A" ? "0xWalletA" : "0xWalletB",
      price: (ctx) => ctx.premium ? "$0.10" : "$0.01",
    },
  ],
  requestContext
);

verifyPayment(payload, requirements) → VerifyResponse#

typescript
const result = await server.verifyPayment(paymentPayload, requirements);
// result.isValid: boolean

settlePayment(payload, requirements, ...) → SettleResponse#

typescript
const result = await server.settlePayment(
  paymentPayload,
  requirements,
  declaredExtensions?,     // Extension data from 402 response
  transportContext?,       // HTTP transport context
  settlementOverrides?,    // { amount: "$0.05" } for upto scheme
);

服务端生命周期钩子#

钩子上下文可中止/恢复
onBeforeVerify{ paymentPayload, requirements }{ abort: true, reason, message? }
onAfterVerify{ paymentPayload, requirements, result }
onVerifyFailure{ paymentPayload, requirements, error }{ recovered: true, result }
onBeforeSettle{ paymentPayload, requirements }{ abort: true, reason, message? }
onAfterSettle{ paymentPayload, requirements, result, transportContext? }
onSettleFailure{ paymentPayload, requirements, error }{ recovered: true, result }
typescript
server.onBeforeVerify(async (ctx) => {
  // Log or gate verification
});

server.onAfterSettle(async (ctx) => {
  console.log(`Settled: ${ctx.result.transaction} on ${ctx.result.network}`);
});

server.onSettleFailure(async (ctx) => {
  if (ctx.error.message.includes("timeout")) {
    return { recovered: true, result: { success: true, transaction: "", network: "eip155:196" } };
  }
});

HTTP 资源服务器 (x402HTTPResourceServer)#

更高层的封装,处理路由匹配、付费墙和 HTTP 特定逻辑。

构造函数#

typescript
import { x402HTTPResourceServer } from "@okxweb3/x402-core/http";

const httpServer = new x402HTTPResourceServer(resourceServer, routes);

RoutesConfig#

typescript
type RoutesConfig = Record<string, RouteConfig> | RouteConfig;

interface RouteConfig {
  accepts: PaymentOption | PaymentOption[];  // Accepted payment methods
  resource?: string;           // Override resource name
  description?: string;        // Human-readable description
  mimeType?: string;           // Response MIME type
  customPaywallHtml?: string;  // Custom HTML for browser 402 page
  unpaidResponseBody?: (ctx: HTTPRequestContext) => HTTPResponseBody | Promise<HTTPResponseBody>;
  settlementFailedResponseBody?: (ctx, result) => HTTPResponseBody | Promise<HTTPResponseBody>;
  extensions?: Record<string, unknown>;

  // ── 订阅(period)专属 ─────────────────────────────
  /**
   * 订阅特殊操作路由:
   *   "change"                 — 切换套餐(升 / 降级;同一 URL 分两阶段)
   *   "cancel"                 — 取消订阅
   *   "cancel-pending-change"  — 撤销排队中的降级
   * 未设置 → 普通访问路由。
   */
  operation?: "change" | "cancel" | "cancel-pending-change";

  /**
   * 路由级 onBeforeAccess,语义与 httpServer.onBeforeAccess() 相同,
   * 仅作用于本 route;在所有 seller 全局 hook 之后执行。
   */
  onBeforeAccess?: OnBeforeAccessHook;
}

interface PaymentOption {
  scheme: string;              // "exact" | "aggr_deferred" | "upto" | "period"
  payTo: string | DynamicPayTo;  // Static or dynamic recipient
  price: Price | DynamicPrice;   // Static or dynamic price
  network: Network;
  maxTimeoutSeconds?: number;
  extra?: Record<string, unknown>;
}

// Dynamic functions receive HTTPRequestContext
type DynamicPayTo = (context: HTTPRequestContext) => string | Promise<string>;
type DynamicPrice = (context: HTTPRequestContext) => Price | Promise<Price>;

onSettlementTimeout(hook)#

typescript
type OnSettlementTimeoutHook = (txHash: string, network: string) => Promise<{ confirmed: boolean }>;

httpServer.onSettlementTimeout(async (txHash, network) => {
  // Custom recovery logic
  return { confirmed: false };
});

onProtectedRequest(hook)#

typescript
type ProtectedRequestHook = (
  context: HTTPRequestContext,
  routeConfig: RouteConfig,
) => Promise<void | { grantAccess: true } | { abort: true; reason: string }>;

httpServer.onProtectedRequest(async (ctx, config) => {
  // Grant free access for certain users
  if (ctx.adapter.getHeader("x-api-key") === "internal") {
    return { grantAccess: true };
  }
});

onBeforeAccess(hook) — 订阅专属#

seller 级 chain-of-responsibility,verifyAccess 通过后、handler 之前触发。仅对订阅(period scheme)的 access-verified 请求触发。可多次注册,按注册顺序执行;第一个返回 { ok: false } 直接拒绝 → 402。路由级 RouteConfig.onBeforeAccess 在所有全局 hook 之后执行。详见"订阅支付"章节。

typescript
httpServer.onBeforeAccess(async (ctx) => {
  if (banList.has(ctx.subscription.subId)) return { ok: false, error: "banned" };
  return { ok: true };
});

HTTPAdapter.getHeaders?()#

HTTPAdapter 上新增可选方法 getHeaders?(): Record<string, string>(小写 key);官方四个 adapter(express / fastify / hono / next)均已实现。订阅 onBeforeAccessctx.request.headers 读全部请求头依赖此方法。


中间件参考#

Express (@okxweb3/x402-express)#

typescript
import {
  paymentMiddleware,
  paymentMiddlewareFromConfig,
  paymentMiddlewareFromHTTPServer,
  setSettlementOverrides,
} from "@okxweb3/x402-express";

// From pre-configured server (recommended)
app.use(paymentMiddleware(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?));

// From config (creates server internally)
app.use(paymentMiddlewareFromConfig(routes, facilitatorClients?, schemes?, paywallConfig?, paywall?, syncFacilitatorOnStart?));

// From HTTP server (most control) — 订阅场景需要挂 onBeforeAccess 时用这个
app.use(paymentMiddlewareFromHTTPServer(httpServer, paywallConfig?, paywall?, syncFacilitatorOnStart?));

// Settlement override in handler (for "upto" scheme)
app.post("/api/generate", (req, res) => {
  setSettlementOverrides(res, { amount: "$0.05" });
  res.json({ result: "..." });
});
ParameterTypeDefault描述
routesRoutesConfig必填路由到支付配置的映射
serverx402ResourceServer必填预配置的资源服务器
paywallConfigPaywallConfigundefined浏览器付费墙设置
paywallPaywallProviderundefined自定义付费墙渲染器
syncFacilitatorOnStartbooleantrue在首次请求时获取支持的 kinds

Next.js (@okxweb3/x402-next)#

typescript
import {
  paymentProxy,
  paymentProxyFromConfig,
  paymentProxyFromHTTPServer,
  withX402,
  withX402FromHTTPServer,
} from "@okxweb3/x402-next";

// As global middleware (middleware.ts)
const proxy = paymentProxy(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);
export async function middleware(request: NextRequest) { return proxy(request); }
export const config = { matcher: ["/api/:path*"] };

// Per-route wrapper (app/api/data/route.ts)
export const GET = withX402(handler, routeConfig, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);
export const GET = withX402FromHTTPServer(handler, httpServer, paywallConfig?, paywall?, syncFacilitatorOnStart?);

Hono (@okxweb3/x402-hono)#

typescript
import { paymentMiddleware, paymentMiddlewareFromConfig, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-hono";

app.use("/*", paymentMiddleware(routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?));

Fastify (@okxweb3/x402-fastify)#

typescript
import { paymentMiddleware, paymentMiddlewareFromConfig, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-fastify";

// NOTE: Fastify registers hooks directly, returns void
paymentMiddleware(app, routes, server, paywallConfig?, paywall?, syncFacilitatorOnStart?);

四个中间件对订阅场景均支持 payment-presettleaccess-verified 两种订阅分派(详见"订阅支付"章节)。


EVM 机制类型#

ExactEvmScheme(服务端)#

typescript
import { ExactEvmScheme } from "@okxweb3/x402-evm";

const scheme = new ExactEvmScheme();  // No constructor args for server-side
scheme.scheme;  // "exact"
// Automatically handles price parsing, EIP-712 domain injection

AggrDeferredEvmScheme(服务端)#

typescript
import { AggrDeferredEvmScheme } from "@okxweb3/x402-evm/deferred/server";

const scheme = new AggrDeferredEvmScheme();
scheme.scheme;  // "aggr_deferred"
// Delegates to ExactEvmScheme for price parsing

PermitSubscriptionScheme(订阅,服务端)#

typescript
import { PermitSubscriptionScheme } from "@okxweb3/x402-evm/subscription";

const scheme = new PermitSubscriptionScheme({
  facilitator,               // SubscriptionFacilitatorClient
  network: "eip155:196",
  store,                     // SubscriptionStore(与 SubscriptionClient 共享同一实例)
  accessProofWindowSec: 300, // AccessProof 时间窗,默认 ±300s
});
scheme.scheme;  // "period"

详见"订阅支付"章节的 SubscriptionCapability 接口。


客户端 API(买方)#

买方包会自动处理 402 Payment Required 响应:解析支付要求 → 通过配置的 EVM scheme 签名支付 payload → 携带 PAYMENT 头重发请求。

按你已用的 HTTP 客户端选择对应包:

包装对象适用场景
@okxweb3/x402-axiosAxiosInstance已有 Axios 代码库;需要使用拦截器 / 实例配置
@okxweb3/x402-fetchglobalThis.fetch基于 fetch 的运行时(浏览器、Edge、Node 18+)

两者的 API 形态一致:wrapXxxWithPayment(client_or_fetch, x402Client)wrapXxxWithPaymentFromConfig(client_or_fetch, config)

Axios — @okxweb3/x402-axios#

bash
npm install @okxweb3/x402-axios @okxweb3/x402-evm @okxweb3/x402-core axios
typescript
import axios from "axios";
import { wrapAxiosWithPaymentFromConfig } from "@okxweb3/x402-axios";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { xLayer } from "viem/chains";

// 用买家私钥构造 viem signer
const signer = toClientEvmSigner(
  createWalletClient({
    account: privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`),
    chain: xLayer,
    transport: http(),
  }),
);

const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
  schemes: [
    {
      network: "eip155:196", // X Layer;用 "eip155:*" 可匹配任意 EVM 链
      client: new ExactEvmScheme(signer),
    },
  ],
});

// 402 → 签名 → 重发,对调用方完全透明
const response = await api.get("https://api.example.com/paid-endpoint");

Fetch — @okxweb3/x402-fetch#

bash
npm install @okxweb3/x402-fetch @okxweb3/x402-evm @okxweb3/x402-core
typescript
import { wrapFetchWithPaymentFromConfig } from "@okxweb3/x402-fetch";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { xLayer } from "viem/chains";

const signer = toClientEvmSigner(
  createWalletClient({
    account: privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`),
    chain: xLayer,
    transport: http(),
  }),
);

const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [
    {
      network: "eip155:196",
      client: new ExactEvmScheme(signer),
    },
  ],
});

const response = await fetchWithPayment("https://api.example.com/paid-endpoint");

使用 x402Client 的 builder 模式#

需要注册多个 scheme / 网络,或在多个 transport 间共享同一客户端时,使用显式 builder。

typescript
import axios from "axios";
import { wrapAxiosWithPayment, x402Client } from "@okxweb3/x402-axios";
import { ExactEvmScheme, toClientEvmSigner } from "@okxweb3/x402-evm";

const client = new x402Client()
  .register("eip155:196", new ExactEvmScheme(signer));

const api = wrapAxiosWithPayment(axios.create(), client);

x402Client 也由 @okxweb3/x402-fetch 重新导出,同一实例可同时供两种 transport 使用。

读取支付回执#

请求重发成功后,服务端会在响应头返回 PAYMENT-RESPONSE,包含链上回执(txHash、实际结算金额等)。用 decodePaymentResponseHeader 解码:

typescript
import { decodePaymentResponseHeader } from "@okxweb3/x402-axios"; // 或 "@okxweb3/x402-fetch"

// Axios
const paymentResponse = response.headers["payment-response"];
// Fetch
// const paymentResponse = response.headers.get("PAYMENT-RESPONSE");

if (paymentResponse) {
  const receipt = decodePaymentResponseHeader(paymentResponse);
  console.log("支付回执:", receipt);
}

x402ClientConfig#

字段类型描述
schemesSchemeRegistration[]必填。每一项把 network(如 "eip155:196""eip155:*")与 scheme 客户端(如 new ExactEvmScheme(signer))配对。
policiesPaymentPolicy[]可选。见下方 Policies,按顺序对 accepts 进行过滤 / 转换。
paymentRequirementsSelectorSelectPaymentRequirements可选。从过滤后的列表中挑选最终一项。默认 (version, accepts) => accepts[0]

选择流水线(Selection Pipeline)#

收到 402 后,客户端按三步决定签哪一项:

  1. 按已注册的 scheme 过滤 —— 只保留 network + scheme 都已通过 register() 注册的 accepts
  2. 按顺序应用 policies —— 每个 PaymentPolicy 进一步过滤 / 转换列表。
  3. selector 选择 —— 从过滤后的列表中选出最终要签名的那一项。

第 1 或第 2 步留空数组时客户端直接抛错——不会发起签名。

Policies — PaymentPolicy#

typescript
type PaymentPolicy = (
  x402Version: number,
  paymentRequirements: PaymentRequirements[],
) => PaymentRequirements[];

policy 是个纯函数:拿当前 accepts,返回过滤后的子集(或转换后的副本)。常用场景:金额上限、网络白名单、scheme 偏好等。

typescript
import {
  wrapAxiosWithPaymentFromConfig,
  type PaymentPolicy,
} from "@okxweb3/x402-axios";

// 拒绝单笔超过 1 USDT(1_000_000 原子单位,6 位小数)的支付
const maxAmountPolicy: PaymentPolicy = (_version, reqs) =>
  reqs.filter(r => BigInt(r.amount) <= 1_000_000n);

// 仅允许 X Layer 主网
const xLayerOnlyPolicy: PaymentPolicy = (_version, reqs) =>
  reqs.filter(r => r.network === "eip155:196");

// 同时提供两种 scheme 时优先选 "exact"
const preferExactPolicy: PaymentPolicy = (_version, reqs) => {
  const exact = reqs.filter(r => r.scheme === "exact");
  return exact.length > 0 ? exact : reqs;
};

const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
  schemes: [{ network: "eip155:196", client: new ExactEvmScheme(signer) }],
  policies: [maxAmountPolicy, xLayerOnlyPolicy, preferExactPolicy],
});

policies 按数组顺序运行,把"收紧"型 policy(金额上限、白名单)放前面,"偏好"型 policy 放后面。

自定义 selector — SelectPaymentRequirements#

typescript
type SelectPaymentRequirements = (
  x402Version: number,
  paymentRequirements: PaymentRequirements[],
) => PaymentRequirements;

selector 在 policies 之后运行。当过滤后仍剩多项需要明确的挑选逻辑(如选最便宜)时使用:

typescript
const cheapestFirst: SelectPaymentRequirements = (_version, reqs) =>
  [...reqs].sort((a, b) => Number(BigInt(a.amount) - BigInt(b.amount)))[0];

const api = wrapAxiosWithPaymentFromConfig(axios.create(), {
  schemes: [{ network: "eip155:*", client: new ExactEvmScheme(signer) }],
  paymentRequirementsSelector: cheapestFirst,
});

生命周期钩子(Lifecycle Hooks)#

x402Client 提供三个生命周期钩子,用于埋点、最后一道闸门、错误恢复。用 builder 形式注册:

typescript
import { wrapAxiosWithPayment, x402Client } from "@okxweb3/x402-axios";
import { ExactEvmScheme } from "@okxweb3/x402-evm";

const client = new x402Client()
  .register("eip155:196", new ExactEvmScheme(signer))
  // 1. 签名前 —— 可整体中止支付
  .onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements }) => {
    const tooExpensive = BigInt(selectedRequirements.amount) > 5_000_000n;
    if (tooExpensive) {
      return { abort: true, reason: "金额超出买家策略上限" };
    }
  })
  // 2. 签名成功后 —— 仅观察(日志、埋点)
  .onAfterPaymentCreation(async ({ paymentPayload }) => {
    console.log("已签名 payload nonce:", paymentPayload.payload?.authorization?.nonce);
  })
  // 3. 签名失败时 —— 可返回手动构造的 payload 进行恢复
  .onPaymentCreationFailure(async ({ error }) => {
    console.error("payment 创建失败:", error.message);
    // return { recovered: true, payload: fallbackPayload };
  });

const api = wrapAxiosWithPayment(axios.create(), client);
钩子触发时机返回语义
onBeforePaymentCreation选择完成、scheme 签名前返回 void 继续 · 返回 { abort: true, reason } 取消并 reject
onAfterPaymentCreationscheme 返回签名后的 payload 后void(仅观察,不可改)
onPaymentCreationFailurescheme 签名时抛错返回 void 继续抛错 · 返回 { recovered: true, payload } 用替代 payload 恢复

同一阶段内的钩子按注册顺序执行。

客户端扩展 — registerExtension#

PaymentRequired 响应里带 extensions 字段、需要 scheme 相关的 payload 增强(如 Gas 代付的 permit 签名)时使用。enrichPaymentPayload 只会在 paymentRequired.extensions 中包含同名 key 时被触发。

typescript
client.registerExtension({
  key: "eip2612GasSponsoring",
  async enrichPaymentPayload(payload, paymentRequired) {
    // 签 EIP-2612 permit,挂在 payload.extensions 上
    return { ...payload, extensions: { ...payload.extensions, /* ... */ } };
  },
});

订阅支付(period scheme)#

period 是 x402 v2 SDK 支持的第三种 scheme(前两种是 exact / aggr_deferred)。买家一次双签(Permit2 PermitSingle + SubscriptionTerms)授权 N 期扣款;商家侧 middleware 自动处理五条链路:创建订阅 / 访问放行 / 切换套餐 / 取消 / 定时扣款。因该 scheme 引入了额外的 Store / Client / hook 与 wire 字段,单列一节展开说明。

顶层入口#

typescript
import { OKXFacilitatorClient } from "@okxweb3/x402-core";
import { x402HTTPResourceServer, x402ResourceServer } from "@okxweb3/x402-core/server";
import {
  InMemoryStore,
  SubscriptionClient,
  type Subscription, type SubscriptionState, type PendingPlanChange,
  type PlanCatalogEntry, type PlanInitialCharge,
  type AccessProof, type CancelAuth, type PendingChangeCancelAuth,
  type ChargeResult,
  type SettleSubscribeResult, type SettleChangeResult,
  type SettleCancelResult, type SettleCancelPendingChangeResult,
  type SubscriptionCapability, type SubscriptionStore,
  type OnBeforeAccessHook, type OnBeforeAccessContext, type OnBeforeAccessResult,
  type AccessRouteRequirements,
} from "@okxweb3/x402-core/subscription";
import { PermitSubscriptionScheme } from "@okxweb3/x402-evm/subscription";
import { paymentMiddleware, paymentMiddlewareFromHTTPServer } from "@okxweb3/x402-express";
PermitSubscriptionScheme
typescript
new PermitSubscriptionScheme({
  facilitator: SubscriptionFacilitatorClient,   // OKXFacilitatorClient / HTTPFacilitatorClient
  network: Network,                             // "eip155:196"
  store: SubscriptionStore,                     // 与 SubscriptionClient 共享
  accessProofWindowSec?: number,                // 默认 300(±300s)
});

实现 SubscriptionCapability 全部方法。用 x402ResourceServer.register(network, scheme) 挂载。

SubscriptionClient

seller 侧主动操作的高层封装(定时扣款 / 商家取消 / 从链上同步)。

typescript
class SubscriptionClient {
  constructor(config: { scheme: SubscriptionCapability; store: SubscriptionStore });

  charge(subId: string): Promise<ChargeResult>;
  cancelBySeller(subId: string, auth: CancelAuth, reason?: string): Promise<void>;
  syncFromChain(subId: string): Promise<Subscription | null>;
  getSubscription(subId: string): Promise<Subscription | null>;
}
InMemoryStore / SubscriptionStore
typescript
interface SubscriptionStore {
  get(subId: string): Promise<Subscription | null>;
  put(sub: Subscription): Promise<void>;
  delete(subId: string): Promise<void>;
  list(): Promise<Subscription[]>;
}

InMemoryStore 仅用于 demo / 单机;生产环境自实现持久化 store(Redis / Postgres / KV 等)。

OKXFacilitatorClient(订阅方法)

除通用 verifyPayment / settlePayment / getSupported,还实现了 SubscriptionFacilitatorClient

typescript
interface SubscriptionFacilitatorClient {
  subscribe(payload, requirements): Promise<...>;
  changeSubscription(payload, requirements): Promise<...>;
  cancelSubscription(subId, auth): Promise<...>;
  cancelPendingChange(subId, auth): Promise<...>;
  chargeSubscription(subId): Promise<...>;
  getSubscription(subId): Promise<...>;
  finalizeExpired(subId): Promise<...>;
  getCharges(subId): Promise<...>;
  getPendingChange(subId): Promise<...>;
}

核心类型#

Subscription

facilitator GET /subscriptions/detail 的本地投影;Store 存的就是这个。

typescript
interface Subscription {
  subId: string;
  payer: string;
  merchant: string;
  token: string;
  amountPerPeriod: string;
  periodMode: number;              // 0 fixed_seconds / 1 calendar_month
  periodSec: number;
  billingAnchorAt?: number;        // 自然月锚点(Unix s),fixed 模式为 undefined 或 0
  maxPeriods: number;
  startAt: number;
  state: SubscriptionState;
  lastChargedPeriod: number;
  totalPulled: string;             // 已扣总额
  planId: string;
  planTier: number;
  changedToSubId?: string;         // state === "changed" 时指向新 sub
  pendingPlanChange?: PendingPlanChange;

  // 读时刻派生(随墙钟漂移;判断过期请用 elapsedPeriods)
  isActive?: boolean;
  serviceEnded?: boolean;
  currentPeriod?: number;
  elapsedPeriods?: number;
  nextChargeableAt?: number;       // 下一次可扣时间点(Unix s),全部扣完为 null
}
SubscriptionState
typescript
type SubscriptionState =
  | "pending"     // 0 — 已上链,等待激活
  | "active"      // 1 — 活跃
  | "completed"   // 2 — maxPeriods 全部扣完
  | "canceled"    // 3 — 已取消
  | "changed"     // 4 — 已被切换(changedToSubId 指向新 sub)
  | "failed";     // 99 — 上链失败 / 异常
PendingPlanChange

排队中的降级(升级立即生效不产生 pending)。

typescript
interface PendingPlanChange {
  subId: string;                  // 当前订阅
  newSubId: string;               // 降级后新 sub id
  effectiveFromPeriod: number;    // 从第几期开始切换
  state: number;                  // 0 pending / 1 activated / 2 canceled / 3 expired
}
PlanCatalogEntry / PlanInitialCharge

Seller 侧套餐定义,作为 RouteConfig.accepts 的输入原料。

typescript
interface PlanCatalogEntry {
  id: string;                     // 业务 plan id(访问校验按此比对)
  tier: number;                   // 数字越大等级越高
  amountPerPeriod: string;        // 每期扣款金额(token base units)
  periodMode?: 0 | 1;             // 0 fixed_seconds / 1 calendar_month
  periodSec: number;              // 周期长度(秒)
  maxPeriods: number;             // 最多扣款期数
  asset?: string;                 // ERC-20;不填 → SDK 用 network 默认稳定币
  payTo: string;
  initialCharge?: PlanInitialCharge;
  name?: string;
}

interface PlanInitialCharge {
  periodCount: number;            // 首扣覆盖多少期
  totalAmount: string;            // 首扣总额(≤ periodCount × amountPerPeriod)
}
AccessProof

买家在访问 / 切换 / 取消订阅路由时携带的鉴权,EIP-191 personal_sign,±accessProofWindowSec 窗口。

typescript
interface AccessProof {
  kind: "subscription-id";
  subId: string;
  payer: string;
  timestamp: number;
  signature: string;
}

请求头:APP-Access: base64url(JSON.stringify(accessProof))

CancelAuth

EIP-712 取消授权。买家 / 商家均可作为 initiator。

typescript
interface CancelAuth {
  action: 0;                      // 固定 0 = cancel_subscription
  subId: string;
  initiator: 0 | 1;               // 0=payer / 1=merchant
  nonce: string;
  deadline: number;
  signature: string;
}

TypeHash:CancelAuth(uint8 action, bytes32 subId, uint8 initiator, bytes32 nonce, uint64 deadline)

PendingChangeCancelAuth

EIP-712 撤销排队中的降级授权。仅 payer 可签

typescript
interface PendingChangeCancelAuth {
  subId: string;
  newSubId: string;               // 必须等于当前 pendingPlanChange.newSubId
  nonce: string;
  deadline: number;
  signature: string;
}

TypeHash:PendingChangeCancelAuth(bytes32 subId, bytes32 newSubId, bytes32 nonce, uint64 deadline)

Verify / Settle 结果#

typescript
type VerifyResultOk    = { ok: true };
type VerifyResultFail  = { ok: false; error: string };

interface VerifyAccessOk    { ok: true; subscription: Subscription; }
interface VerifyOwnershipOk { ok: true; subId: string; payer: string; subscription: Subscription; }
interface VerifyChangeOk    { ok: true; oldSubId: string; direction: "upgrade" | "downgrade"; }

type SettleResultFail = {
  success: false;
  error: string;
  subId?: string;                 // pending=true 时 seller 可后续 syncFromChain(subId)
  pending?: boolean;
};

interface SettleSubscribeOk           { success: true; subId: string; subscription: Subscription; headers: Record<string, string>; }
interface SettleChangeOk              { success: true; oldSubId: string; newSubId: string; operationType: "upgrade" | "downgrade"; scheduledFromPeriod?: number; headers: Record<string, string>; }
interface SettleCancelOk              { success: true; subId: string; headers: Record<string, string>; }
interface SettleCancelPendingChangeOk { success: true; subId: string; headers: Record<string, string>; }

interface ChargeResult {
  success: true;
  period: number;                 // 本次扣款推进到的期数
  amount: string;                 // 本次扣款金额(base units)
  txHash?: string;
  planChangeTriggered?: boolean;  // 排队中的降级此次激活
  newSubId?: string;              // planChangeTriggered=true 时的新 sub id
}

SubscriptionCapability#

typescript
interface SubscriptionCapability {
  readonly settlementMode: "pre";       // 中间件采用"先 settle 再 handler"

  verifySubscribe(payload, requirements): Promise<VerifyResult>;
  settleSubscribe(payload, requirements): Promise<SettleSubscribeResult>;

  enrichAcceptsForChange(accepts: PaymentRequirements[], currentSubId: string): Promise<PaymentRequirements[] | null>;
  verifyChange(payload, requirements): Promise<VerifyChangeResult>;
  settleChange(payload, requirements): Promise<SettleChangeResult>;

  verifyCancel(auth: CancelAuth, subId: string): Promise<VerifyResult>;
  settleCancel(auth: CancelAuth, subId: string): Promise<SettleCancelResult>;

  verifyCancelPendingChange(auth: PendingChangeCancelAuth, subId: string): Promise<VerifyResult>;
  settleCancelPendingChange(auth: PendingChangeCancelAuth, subId: string): Promise<SettleCancelPendingChangeResult>;

  verifyAccess(proof: AccessProof, route: AccessRouteRequirements): Promise<VerifyAccessResult>;
  verifyOwnership(proof: AccessProof): Promise<VerifyOwnershipResult>;

  charge(subId: string): Promise<ChargeResult>;
  getSubscription(subId: string): Promise<Subscription | null>;
}

OnBeforeAccessContext / Result / Hook#

typescript
interface OnBeforeAccessContext {
  subscription: Subscription;                     // 命中订阅的完整快照
  request: {
    path: string;                                 // 请求 pathname(真实值)
    method: string;                               // HTTP method(真实值)
    headers: Record<string, string>;              // 全部请求头(小写 key);adapter 未实现 getHeaders 则为 {}
  };
  route: AccessRouteRequirements;                 // 本 route 的 plan 声明
}

type OnBeforeAccessResult =
  | { ok: true }
  | {
      ok: false;
      error?: string;                             // 直接进 402 响应体
      retryAfter?: number;                        // 进 Retry-After 响应头
      upgradeOffers?: PaymentRequirements[];      // 建议买家改用的 accept 列表
    };

type OnBeforeAccessHook = (ctx: OnBeforeAccessContext) => Promise<OnBeforeAccessResult>;

AccessRouteRequirements#

typescript
interface AccessRouteRequirements {
  acceptedPlanIds?: string[];             // route accepts 里的 plan id 白名单
  accepts?: PaymentRequirements[];        // 完整 accepts;extra.plan = { id, tier, name }
                                          // 同时带 extra.amountPerPeriod / extra.periodSec /
                                          // extra.periodMode / extra.maxPeriods
}

undefinedacceptedPlanIds 表示"无 plan 限制",任意活跃订阅通过,慎用。

/supported 返回的 extra 字段#

facilitator GET /supported 里每个 kinds[]extraperiod scheme 会缓存以下三个字段:

字段类型含义
facilitatorAddressstringfacilitator EOA;签入 Permit2 witness.facilitator,也是链上唯一被信任的 settle 触发者
subscriptionContractstringA2APaySubscription 合约地址(EIP-712 verifyingContract)
permit2ContractstringUniswap Permit2 合约地址

任一缺失 → SDK 抛 period supportedKind.extra is missing required fields ...

PaymentRequirements.extra(订阅 wire format)#

seller 下发 402 时,accepts[].extra 的完整结构(SubscriptionRequirementsExtra):

typescript
{
  contracts: { subscription: string; permit2: string };
  facilitator: string;                     // 从 /supported.extra.facilitatorAddress 拷贝
  amountPerPeriod: string;
  periodSec: number;
  periodMode?: number;                     // 0 fixed_seconds / 1 calendar_month
  maxPeriods: number;
  startAt?: number;
  initialCharge?: PlanInitialCharge;
  plan: { id: string; tier: number; name?: string };
  changeFrom?: ChangeFromExtra;            // 只在 operation="change" 的 402 里出现
  domain: TypedDataDomain;                 // EIP-712 域(name / version / chainId / verifyingContract)
}

changeFrom(切换套餐第一阶段 402 中):

typescript
{
  fromSubId: string;
  fromPlanId: string;
  fromPlanTier: number;
  direction: "upgrade" | "downgrade";
  effectiveAt: "immediate" | "period_end";
}

SubscriptionTerms EIP-712 结构#

买家签的第二把签名(第一把是 Permit2 PermitSingle),绑定套餐条款:

SubscriptionTerms(
  address payer,
  address merchant,
  address facilitator,
  address token,
  uint256 amountPerPeriod,
  uint256 periodSec,
  uint8   periodMode,
  uint256 maxPeriods,
  uint256 startAt,
  uint256 initialChargePeriods,
  uint256 initialChargeAmount,
  uint256 planTier,
  bytes32 changeFromSubId,
  uint8   changeEffectiveAt,        // 0=none / 1=immediate / 2=period_end
  bytes32 permitHash,
  bytes32 salt,
  uint64  termsDeadline
)

Domain:(name="A2APaySubscription", version="1", chainId, verifyingContract=subscriptionContract)

中间件订阅分支#

四个官方 middleware(express / fastify / hono / next)均支持订阅两条新的 dispatch 分支:

Result type触发场景中间件行为
payment-presettlesubscribe / change / cancel / cancel-pending-change 路由先 settle(调 facilitator 完成上链),成功后把 settleResult.data.subscription / subId 挂到 req.x402,再进 handler;失败直接 402
access-verified携带 APP-Access 头访问订阅资源路由无 facilitator / 无链上交互,本地 verifyAccess,通过后挂 req.x402.subscription,进 handler

seller handler 从 req.x402 读:

typescript
app.post("/subscription/cancel", (req, res) => {
  const x402 = (req as any).x402;
  res.json({ subId: x402.settleResult?.data?.subId });
});

响应头:settle 成功时中间件回写 PAYMENT-RESPONSE(JSON base64url),内容 { subId, txHash, state, ... }


Node SDK 参考(适用于 chargesession#

安装与导入#

bash
npm install @okxweb3/mpp viem

@okxweb3/mpp 自带上游 mppx 全部命名空间的透传,应用代码通常只需 import 这一个包。viem 用于 session 的 EIP-712 签名(SessionSigner),charge 不需要。

typescript
// 顶层:mppx runtime + 命名空间
import { Mppx, Errors } from '@okxweb3/mpp'

// EVM 共用:SA API 客户端、EIP-712 工具
import { SaApiClient, verifyVoucher, buildSettleAuth } from '@okxweb3/mpp/evm'

// EVM 服务端工厂
import { charge, session } from '@okxweb3/mpp/evm/server'

Charge - 单次支付#

注册#

typescript
const saClient = new SaApiClient({
  apiKey: process.env.OKX_API_KEY!,
  secretKey: process.env.OKX_SECRET_KEY!,
  passphrase: process.env.OKX_PASSPHRASE!,
})

const mppx = Mppx.create({
  methods: [charge({ saClient })],
  realm: 'demo.merchant.com',
  secretKey: process.env.MPPX_SECRET_KEY!,
})

调用#

typescript
async function premium(request: Request): Promise<Response> {
  const result = await mppx.charge({
    amount: '100',
    currency: '0xA8CE8aee21bC2A48a5EF670afCc9274C7bbbC035',
    recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
    methodDetails: { feePayer: true },     // chainId 默认 196
  })(request)

  if (result.status === 402) return result.challenge
  return result.withReceipt(Response.json({ data: 'premium content' }))
}

调用选项#

typescript
type ChargeOptions = {
  amount: string                  // 扣费金额,base units 整数字符串
  currency: string                // ERC-20 合约 EVM 地址
  recipient: string               // 收款方 EVM 地址
  description?: string            // 描述,写入 challenge
  externalId?: string             // 商户订单号,回带在 receipt 上
  methodDetails: {
    chainId?: number              // 默认 196 (X Layer)
    feePayer?: boolean            // true = 服务端代付 gas(仅 transaction 模式)
    permit2Address?: string       // Uniswap Permit2 合约地址
    splits?: ChargeSplit[]        // 分账,最多 10 项
    resourceUrl?: string          // Endpoint URL,用于后台按 URL 维度统计(见下方"resourceUrl 用法")
  }
}

type ChargeSplit = {
  amount: string                  // 该路收款 base units
  recipient: string               // 该路收款方 EVM 地址
  memo?: string
}

分账#

methodDetails.splits 即可:

typescript
methodDetails: {
  feePayer: true,
  splits: [
    { amount: '30', recipient: '0x...', memo: 'partner-a' },
    { amount: '20', recipient: '0x...', memo: 'partner-b' },
  ],
}

约束:分账总额必须严格小于 amount(主收款方至少留 1 base unit),最多 10 项;客户端要为每路 split 单独签 EIP-3009(已自动放在 payload.authorization.splits[])。校验由 SA API 强制(违反时 70005 / 70006)。

resourceUrl(按 endpoint 维度上报)#

商家想按 endpoint 维度统计 charge 交易量 / 收入时,在 methodDetails 里传 resourceUrl。SDK 会 base64url 编入 challenge.request,透传到 SA API /charge/settle/charge/verifyHash 供后台入库。

typescript
async function handler(request: Request): Promise<Response> {
  const result = await mppx.charge({
    amount: '100',
    currency: '0xA8CE8aee21bC2A48a5EF670afCc9274C7bbbC035',
    recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
    methodDetails: {
      feePayer: true,
      resourceUrl: 'https://api.myshop.com/v1/reports',   // 按此 URL 打点
    },
  })(request)

  if (result.status === 402) return result.challenge
  return result.withReceipt(Response.json({ data: '...' }))
}

处理链路

Seller SDK (mppx.charge)
   └── methodDetails.resourceUrl
        ↓ base64url 编入 ChargeRequest → PaymentChallenge.request
Buyer 回显 (ChallengeEcho)
        ↓ POST /charge/settle | /charge/verifyHash
SA API 后端(按 resourceUrl 归集统计)

限制

  • Session 模式不支持 —— 单会话内 voucher 高频提交可能跨多 URL,统计会串。
  • SA API 后端从 challenge.request 里解析 resource_url 时兼容缺省(空即不落库)。

Session - 按量支付#

按量计费场景:开通 escrow 通道 → 高频提交 voucher → 卖家随时主动 settle / close。

注册#

typescript
import { privateKeyToAccount } from 'viem/accounts'

const sellerSigner = privateKeyToAccount(process.env.MERCHANT_PK as `0x${string}`)

const mppx = Mppx.create({
  methods: [session({ saClient, signer: sellerSigner })],
  realm: 'demo.merchant.com',
  secretKey: process.env.MPPX_SECRET_KEY!,
})

工厂参数#

typescript
type SessionParameters = {
  saClient: SaApiClient                  // 必填
  signer: SessionSigner                  // 必填,settle / close 时签 EIP-712 授权
  chainId?: number                       // 默认 196
  escrowContract?: Hex                   // 默认 0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b
  domainName?: string                    // 默认 "EVM Payment Channel"
  domainVersion?: string                 // 默认 "1"
  store?: SessionStore                   // 默认进程内存 store
  minVoucherDelta?: string               // 默认 "0",base units
}

/** 卖家签名能力。viem 的 LocalAccount / WalletClient.account 都满足。 */
type SessionSigner = {
  signTypedData: <const td extends TypedDataDefinition>(p: td) => Promise<Hex>
}

escrowContract / domainName / domainVersion 必须与链上 escrow 合约的 EIP712Domain 完全一致,否则 voucher / settle / close 验签全部失败。

调用#

typescript
async function meter(request: Request): Promise<Response> {
  const result = await mppx.session({
    amount: '100',
    currency: '0x...',
    recipient: '0x...',
    unitType: 'request',
    suggestedDeposit: '10000',
    methodDetails: {},                    // chainId / escrowContract 自动用工厂默认
  })(request)

  if (result.status === 402) return result.challenge
  // open / topUp / close 这三个管理动作 SDK 强制返 204;
  // 只有 voucher 动作真正交付资源,下面这行 Response 会被透传。
  return result.withReceipt(Response.json({ data: 'metered content' }))
}

每次请求按 payload.action 走不同路径:

action行为
open校验 payee → 调 SA session/open 上链 → 写本地 store
voucher本地 EIP-712 验签 → 提升最高 voucher → 原子扣费(不调 SA API,纯本地)
topUp调 SA session/topUp → 累加本地 deposit
close卖家签 CloseAuth → 调 SA session/close → 删本地 store

调用选项#

typescript
type SessionOptions = {
  amount: string                  // 单价,base units
  currency: string                // ERC-20 EVM 地址
  recipient: string               // 收款方 EVM 地址
  description?: string
  externalId?: string
  unitType?: string               // "request" | "byte" | "llm_token" | ...
  suggestedDeposit?: string       // 建议初次开通存入金额,base units
  methodDetails: {
    chainId?: number              // 工厂默认兜底
    escrowContract?: string       // 工厂默认兜底
    channelId?: string
    minVoucherDelta?: string      // 节流:voucher 最小递增量
    feePayer?: boolean
    splits?: SessionSplit[]       // 按 bps 分润
  }
}

type SessionSplit = {
  recipient: string
  bps: number                     // basis points(1‒9999),sum(bps) < 10000
  memo?: string
}

Session 不支持 resourceUrl 字段:单会话可跨多 URL 提交 voucher,统计会串。按 endpoint 维度上报请使用 charge 模式。

扩展方法:主动 settle / status#

session({...}) 返回的对象在 mppx Method 上额外挂了两个调用:

typescript
/** 用本地最高 voucher 提链上结算(不关闭通道)。
 *  自动签 SettleAuthorization 并提交。 */
mppx.session.settle(channelId: string): Promise<SessionReceipt>

/** 查询链上通道状态。 */
mppx.session.status(channelId: string): Promise<ChannelStatus>

interface SessionReceipt {
  method: string                  // "evm"
  intent: string                  // "session"
  status: string                  // "success"
  timestamp: string               // RFC 3339
  channelId: string
  chainId: number
  reference?: string              // tx hash(transaction 模式)
  deposit: string                 // 链上 escrow 当前存款总额
}

interface ChannelStatus {
  channelId: string
  payer: string
  payee: string
  token: string
  deposit: string
  settledOnChain: string          // 已链上结算金额(settle 后才更新)
  sessionStatus: 'OPEN' | 'CLOSING' | 'CLOSED'
  remainingBalance: string        // = deposit - cumulativeAmount
}

自定义 SessionStore#

默认 memoryStore() 单进程够用,但进程重启即丢所有 channel state。长期 channel / 多实例 / 热重载部署需自实现持久化 store(Redis / Postgres / KV / DynamoDB / etcd 都行)。

typescript
interface SessionStore {
  get(channelId: string):
    Promise<ChannelState | null> | ChannelState | null
  set(channelId: string, state: ChannelState):
    Promise<void> | void
  delete(channelId: string):
    Promise<void> | void

  /** read-modify-write 整体原子。
   *  state 不存在时不调 mutator,直接返 null。
   *  实现侧需保证 mutator 调用期间无并发写入。 */
  update(channelId: string, mutator: ChannelMutator):
    Promise<ChannelState | null> | ChannelState | null
}

/** 同步纯函数,对 state 就地修改;抛错回滚不写入。
 *  不要在 mutator 内做异步 IO(实现可能多次调用它)。 */
type ChannelMutator = (state: ChannelState) => void

update() 是正确性的关键:进程内可用 per-id mutex;Redis 用 Lua;Postgres 用 SELECT ... FOR UPDATE 事务;DynamoDB / etcd 用 CAS 重试。

ChannelState#

typescript
interface ChannelState {
  channelId: Hex                  // 主键 = 链上 channelId
  chainId: number
  escrowContract: Hex
  domainName: string
  domainVersion: string
  signer: Hex                     // 期望的 voucher 签名者
  deposit: bigint                 // 链上 escrow 当前存款
  spent: bigint                   // 本地累计已扣
  units: number                   // 计费次数
  highestVoucherAmount: bigint    // 已接受的最高 voucher 金额
  highestVoucher:                 // 字节值(幂等 + idle close 用)
    | { cumulativeAmount: string; signature: Hex }
    | null
  challengeEcho: ChallengeEcho
  createdAt: string               // ISO 8601
}

SessionStore / ChannelMutator 在 v0.1.0 未通过 subpath 透出。结构类型即可匹配,按上面接口签名实现一份传给 session({ store: ... }) 即可。


EIP-712 工具#

构建和校验 session voucher / settle / close 授权用。链上 escrow 合约的 EIP712Domain 默认值:

typescript
DEFAULT_DOMAIN_NAME    = 'EVM Payment Channel'
DEFAULT_DOMAIN_VERSION = '1'

verifyVoucher#

校验签名是否出自 expectedSigner(基于 viem verifyTypedData / ecrecover)。

typescript
function verifyVoucher(params: {
  chainId: number
  escrowContract: Hex
  channelId: Hex
  cumulativeAmount: string | bigint
  signature: Hex
  expectedSigner: Hex
  domainName?: string             // 默认 "EVM Payment Channel"
  domainVersion?: string          // 默认 "1"
}): Promise<boolean>

buildSettleAuth / buildCloseAuth#

不签名,只构造 viem TypedDataDefinition,喂给 signer.signTypedData(...) 拿 65 字节签名。两者参数一致:

typescript
function buildSettleAuth(p: AuthMessageParams): TypedDataDefinition
function buildCloseAuth(p: AuthMessageParams): TypedDataDefinition

interface AuthMessageParams {
  chainId: number
  escrowContract: Hex
  channelId: Hex
  cumulativeAmount: string | bigint
  nonce: string | bigint
  deadline: string | bigint
  domainName?: string
  domainVersion?: string
}

randomU256 / unixDeadline#

typescript
/** 256-bit 密码学安全随机数,十进制字符串。 */
function randomU256(): string

/** unix 秒,十进制字符串;默认当前时间 + 1 小时。 */
function unixDeadline(secondsFromNow?: number): string

合约层 nonce 已用集 key = (payee, channelId, nonce)。重复使用会 NonceAlreadyUsed revert;SDK 不维护已用集,只负责生成「大概率没用过」的随机值。


SaApiClient#

OKX SA API HTTP 客户端,charge / session 工厂的底层依赖。用户只需把它实例化后传给工厂,不需要直接调它的方法。

typescript
new SaApiClient({
  apiKey: string
  secretKey: string
  passphrase: string
  baseUrl?: string                // 默认 "https://web3.okx.com"
  onError?: (info: SaApiErrorInfo) => void
})

interface SaApiErrorInfo {
  method: 'GET' | 'POST'
  path: string
  requestBody?: string
  httpStatus: number
  code?: number                   // SA 业务错误码;解析失败为 undefined
  msg?: string
  responseBody?: string
}

onError 在 HTTP 非 2xx、JSON 解析失败、或业务码非 0 时触发,try/catch 隔离不影响主流程;用于业务侧打 log / 上报。SDK 内部解包后会按错误码抛出对应 PaymentError 子类(见下一节)。


错误处理#

SDK 抛 mppx 顶层 Errors namespace 下的 PaymentError 子类;mppx 自动将其转为 RFC 9457 ProblemDetails 响应。

typescript
import { Errors } from '@okxweb3/mpp'

SA API 错误码 → PaymentError 子类#

code含义抛出的 PaymentError
8000API 服务内部错误VerificationFailedError
70000缺字段或格式错误VerificationFailedError
70001链不在支持列表VerificationFailedError
70002付款方在黑名单VerificationFailedError
70003source 缺失 / feePayer 与 hash 模式冲突 / txHash 已用VerificationFailedError
70004签名验证失败InvalidSignatureError
70005分账总额 ≥ 主金额InvalidPayloadError
70006分账数量 > 10InvalidPayloadError
70007交易未上链VerificationFailedError
70008链上 channel 已关闭ChannelClosedError
70009challenge 不存在 / 已过期InvalidChallengeError
70010channelId 不存在ChannelNotFoundError
70011escrow grace period 配置不达标InvalidPayloadError
70012cumulativeAmount > depositAmountExceedsDepositError
70013voucher 递增量 < minVoucherDeltaDeltaTooSmallError
70014channel 处于 CLOSING 状态ChannelClosedError

错误码常量:

typescript
import { SA_ERROR_CODES, type SaErrorCode } from '@okxweb3/mpp/evm'

SA_ERROR_CODES[70004]   // "invalid_signature"

session voucher 余额不足#

voucher action 在本地扣费时,若 highestVoucherAmount - spent < amountErrors.InsufficientBalanceError,mppx 转 402;channel 不存在抛 Errors.ChannelNotFoundError

目录