Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

在 Stable 上构建 MPP 端点

本指南将逐步介绍如何为 Stable 上的 USDT0 编写自定义 MPP 支付方法,并提供一个受 MPP 保护的端点。买方签署一个 ERC-3009 transferWithAuthorization,服务器通过 mppxverify() 钩子对其进行验证,结算则在你控制的单独步骤中完成。

你将构建的内容

一个 HTTP 端点,它返回 402 Payment Required 以及一个 MPP WWW-Authenticate 挑战,接受 Authorization 标头中已签名的凭据,对其进行验证,在 USDT0 上结算 transferWithAuthorization,并返回带有 Payment-Receipt 标头的响应。

step 1. Client: GET /weather (no Authorization header)
        Server: 402 Payment Required
                WWW-Authenticate: Payment realm="...", challenges="[...usdt0-stable charge for $0.001...]"

step 2. Client signs an ERC-3009 authorization with their viem account

step 3. Client: GET /weather + Authorization header containing the serialized credential
        Server: verify() validates the EIP-712 signature
        Server: settle() submits transferWithAuthorization on Stable
                (~700ms block confirmation)
        Server: 200 OK { weather: "sunny" }
                Payment-Receipt: reference="0x8f3a...", status="success"

step 4. Verify settlement on Stablescan
        https://stablescan.xyz/tx/0x8f3a...

前提条件

  • 在 Stable 上有一个已注资的 USDT0 钱包。请参阅 使用水龙头转移 USDT0
  • 安装了 mppxviemzod 的 Node 20+。
  • 在 Stable 上有一个卖方账户(一个 EOA)。在默认结算路径下,卖方以 USDT0 支付 gas;Gas Waiver 一节展示了零 gas 的变体。
npm install mppx viem zod express

1. 定义共享 schema

Method.from() 声明意图以及请求(Challenge)和凭据载荷的 schema。客户端和服务器都导入此定义。

// src/method.ts
import { Method } from "mppx";
import { z } from "zod";
import { parseUnits } from "viem";
 
export const USDT0_STABLE = "0x779Ded0c9e1022225f8E0630b35a9b54bE713736";
export const CHAIN_ID = 988;
 
// Request: the Challenge payload the server sends to the client.
const zRequest = z.pipe(
  z.object({
    chainId: z.literal(CHAIN_ID),
    asset: z.literal(USDT0_STABLE),
    amount: z.string(),             // human-readable, e.g. "0.001"
    decimals: z.literal(6),
    payTo: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
    validAfter: z.number().int().nonnegative(),
    validBefore: z.number().int().positive(),
    nonce: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
  }),
  z.transform(({ amount, decimals, ...rest }) => ({
    ...rest,
    amount: parseUnits(amount, decimals).toString(),  // atomic units
  })),
);
 
// Credential payload: what the client returns after signing.
const zPayload = z.object({
  from: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
  signature: z.string().regex(/^0x[a-fA-F0-9]{130}$/), // 65-byte hex
});
 
export const usdt0Stable = Method.from({
  intent: "charge",
  name: "usdt0-stable",
  schema: { request: zRequest, credential: { payload: zPayload } },
});
 
// EIP-712 domain + type, used by both client and server.
export const EIP712_DOMAIN = {
  name: "USDT0",
  version: "1",
  chainId: CHAIN_ID,
  verifyingContract: USDT0_STABLE,
} as const;
 
export const TRANSFER_WITH_AUTHORIZATION_TYPES = {
  TransferWithAuthorization: [
    { name: "from", type: "address" },
    { name: "to", type: "address" },
    { name: "value", type: "uint256" },
    { name: "validAfter", type: "uint256" },
    { name: "validBefore", type: "uint256" },
    { name: "nonce", type: "bytes32" },
  ],
} as const;
usdt0Stable.name === "usdt0-stable"
usdt0Stable.intent === "charge"

2. 服务器:验证凭据

Method.toSerververify() 接入 mppx。该函数接收反序列化后的凭据(挑战 + 载荷),并且必须在证明无效时抛出异常,或者返回一个 Receipt

// src/server-method.ts
import { Method, Receipt } from "mppx";
import { verifyTypedData } from "viem";
import {
  usdt0Stable,
  EIP712_DOMAIN,
  TRANSFER_WITH_AUTHORIZATION_TYPES,
} from "./method";
 
export const usdt0StableServer = Method.toServer(usdt0Stable, {
  async verify({ credential }) {
    const { request } = credential.challenge;
    const { from, signature } = credential.payload;
 
    const valid = await verifyTypedData({
      address: from as `0x${string}`,
      domain: EIP712_DOMAIN,
      types: TRANSFER_WITH_AUTHORIZATION_TYPES,
      primaryType: "TransferWithAuthorization",
      message: {
        from: from as `0x${string}`,
        to: request.payTo as `0x${string}`,
        value: BigInt(request.amount),
        validAfter: BigInt(request.validAfter),
        validBefore: BigInt(request.validBefore),
        nonce: request.nonce as `0x${string}`,
      },
      signature: signature as `0x${string}`,
    });
 
    if (!valid) throw new Error("Invalid ERC-3009 signature");
 
    // The Receipt's reference is filled in with the tx hash after settle().
    return Receipt.from({
      method: usdt0Stable.name,
      reference: "pending",
      status: "success",
      timestamp: new Date().toISOString(),
    });
  },
});
{
  method: "usdt0-stable",
  reference: "pending",
  status: "success",
  timestamp: "2026-06-01T12:34:56.000Z"
}

3. 结算:提交 transferWithAuthorization

结算被有意地与 verify() 分离。在 verify() 返回后,你通过适合你运维模型的任意路径将授权提交到链上。以下是三个选项,按推荐顺序排列。

默认:服务器直接提交

卖方的 EOA 将带有已签名授权的 transferWithAuthorization 提交到 USDT0。卖方以 USDT0(Stable 的原生 gas 代币)支付 gas,因此无需管理单独的 gas 代币余额。

// src/settle.ts
import { createWalletClient, http, parseSignature } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { stable } from "viem/chains";
import { USDT0_STABLE } from "./method";
 
const USDT0_ABI = [
  {
    name: "transferWithAuthorization",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "from", type: "address" },
      { name: "to", type: "address" },
      { name: "value", type: "uint256" },
      { name: "validAfter", type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce", type: "bytes32" },
      { name: "v", type: "uint8" },
      { name: "r", type: "bytes32" },
      { name: "s", type: "bytes32" },
    ],
    outputs: [],
  },
] as const;
 
const seller = privateKeyToAccount(process.env.SELLER_KEY as `0x${string}`);
const wallet = createWalletClient({
  account: seller,
  chain: stable,
  transport: http("https://rpc.stable.xyz"),
});
 
export async function settleDirect(credential: {
  challenge: { request: any };
  payload: { from: string; signature: string };
}): Promise<{ txHash: `0x${string}` }> {
  const { request } = credential.challenge;
  const { v, r, s } = parseSignature(credential.payload.signature as `0x${string}`);
 
  const txHash = await wallet.writeContract({
    address: USDT0_STABLE,
    abi: USDT0_ABI,
    functionName: "transferWithAuthorization",
    args: [
      credential.payload.from as `0x${string}`,
      request.payTo as `0x${string}`,
      BigInt(request.amount),
      BigInt(request.validAfter),
      BigInt(request.validBefore),
      request.nonce as `0x${string}`,
      Number(v),
      r as `0x${string}`,
      s as `0x${string}`,
    ],
  });
 
  return { txHash };
}
{ txHash: "0x8f3a1b2c..." }

替代方案:通过 Gas Waiver 结算

使用 Stable 的 Gas WaivergasPrice = 0 提交内层交易。卖方仍然签署包装交易,但不支付 gas。需要一个 Waiver Server API 密钥。

// src/settle-waiver.ts
import { encodeFunctionData } from "viem";
import { USDT0_STABLE } from "./method";
import { USDT0_ABI } from "./settle";
 
const WAIVER_SERVER = "https://waiver.stable.xyz"; // mainnet endpoint
 
export async function settleViaWaiver(
  credential: { challenge: { request: any }; payload: { from: string; signature: string } },
  signedInnerTxHex: `0x${string}`,
): Promise<{ txHash: `0x${string}` }> {
  const res = await fetch(`${WAIVER_SERVER}/v1/submit`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.WAIVER_API_KEY}`,
    },
    body: JSON.stringify({ transactions: [signedInnerTxHex] }),
  });
 
  const lines = (await res.text()).trim().split("\n");
  const result = JSON.parse(lines[0]);
  if (!result.success) throw new Error(`Settle failed: ${result.error?.message}`);
  return { txHash: result.txHash };
}
{ txHash: "0x8f3a1b2c..." }

关于如何在发布前构建已签名的内层交易(gasPrice: 0,已编码的 transferWithAuthorization 调用),请参阅 Gas waiver 协议

替代方案:交给 x402 facilitator

如果你已经运行了 x402 facilitator 集成(Semantic PayHeurist),你可以将其复用为结算目标。向 /settle POST 一个 paymentPayload;facilitator 会提交链上调用。

paymentPayload 的确切结构是 x402 中间件内部的,未在线路层面指定。最简单的路径是使用 facilitator 自己的 SDK 来构建载荷,或者坚持使用上面的直接提交路径。facilitator 不需要支持 MPP;它只看到 transferWithAuthorization 字段。

4. 客户端:签署凭据

Method.toClientcreateCredential() 接入 mppx。客户端读取 Challenge,使用代理的 viem 账户签署 EIP-712 授权,并序列化凭据。

// src/client-method.ts
import { Credential, Method } from "mppx";
import { hexToSignature, parseSignature } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import {
  usdt0Stable,
  EIP712_DOMAIN,
  TRANSFER_WITH_AUTHORIZATION_TYPES,
} from "./method";
 
export function createUsdt0StableClient(privateKey: `0x${string}`) {
  const account = privateKeyToAccount(privateKey);
 
  return Method.toClient(usdt0Stable, {
    async createCredential({ challenge }) {
      const { request } = challenge;
 
      const signature = await account.signTypedData({
        domain: EIP712_DOMAIN,
        types: TRANSFER_WITH_AUTHORIZATION_TYPES,
        primaryType: "TransferWithAuthorization",
        message: {
          from: account.address,
          to: request.payTo as `0x${string}`,
          value: BigInt(request.amount),
          validAfter: BigInt(request.validAfter),
          validBefore: BigInt(request.validBefore),
          nonce: request.nonce as `0x${string}`,
        },
      });
 
      return Credential.serialize({
        challenge,
        payload: { from: account.address, signature },
      });
    },
  });
}
"eyJjaGFsbGVuZ2UiOnsi..." // base64-serialized credential, ~600 bytes

5. 将服务器组装起来

使用 mppx 的 Express 中间件来发出 Challenge、解析传入的 Authorization 标头、运行 verify()、调用你的结算函数,并发出 Payment-Receipt 标头。

// src/server.ts
import express from "express";
import { Mppx } from "mppx/express";
import { randomBytes } from "node:crypto";
import { usdt0StableServer } from "./server-method";
import { settleDirect } from "./settle";
 
const PAY_TO = process.env.PAY_TO_ADDRESS as `0x${string}`;
const PORT = Number(process.env.PORT ?? 4022);
 
const mppx = Mppx.create({
  secretKey: process.env.MPP_SECRET_KEY!,
  methods: [usdt0StableServer],
  onVerified: async ({ credential, receipt }) => {
    const { txHash } = await settleDirect(credential);
    return { ...receipt, reference: txHash };
  },
});
 
const app = express();
 
app.get(
  "/weather",
  mppx.charge({
    amount: "0.001",
    method: "usdt0-stable",
    request: {
      chainId: 988,
      asset: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
      decimals: 6,
      payTo: PAY_TO,
      validAfter: 0,
      validBefore: Math.floor(Date.now() / 1000) + 300,
      nonce: `0x${randomBytes(32).toString("hex")}`,
    },
  })((_req, res) => {
    res.json({ weather: "sunny", temperature: 70 });
  }),
);
 
app.listen(PORT, () => {
  console.log(`MPP server listening on http://localhost:${PORT}`);
});
MPP server listening on http://localhost:4022

6. 端到端运行整个流程

启动服务器,确认 Challenge,运行客户端,并确认结算。

确认 Challenge

curl -i http://localhost:4022/weather
HTTP/1.1 402 Payment Required
WWW-Authenticate: Payment realm="...", challenges="[{\"method\":\"usdt0-stable\",\"request\":{...}}]"
Content-Type: application/json

{"error":"Payment required"}

发送一个付费请求

// src/client.ts
import { Mppx } from "mppx/client";
import { createUsdt0StableClient } from "./client-method";
 
const client = Mppx.create({
  methods: [createUsdt0StableClient(process.env.BUYER_KEY as `0x${string}`)],
});
 
const res = await fetch("http://localhost:4022/weather", {
  // mppx wraps fetch with the 402 retry loop:
  ...client.fetchOptions(),
});
 
console.log(res.status, await res.json());
console.log("Payment-Receipt:", res.headers.get("Payment-Receipt"));
npx tsx src/client.ts
200 { weather: "sunny", temperature: 70 }
Payment-Receipt: reference="0x8f3a1b2c...", status="success", timestamp="2026-06-01T12:34:56.000Z"

在 Stablescan 上验证

打开 https://stablescan.xyz/tx/0x8f3a1b2c...,确认 transferWithAuthorization 已结算到你的 PAY_TO 地址。

你刚刚完成的内容

  • 以 USDT0 付款,以美元计价,买方一侧无需管理 gas 代币余额。
  • 在客户端到服务器的跳转上使用了 MPP 的 WWW-Authenticate / Authorization / Payment-Receipt 线路格式。
  • 在同一个 HTTP 请求生命周期内(约 700 毫秒的出块时间)使用 transferWithAuthorization 在 Stable 上完成了结算。

推荐的后续内容

  • MPP 概念 — 了解 MPP 如何与 x402 关联,以及其他意图是什么样的。
  • MPP 会话 — 当按请求结算成本过高时,使用链下凭证流式传输微支付。
  • Facilitators — 使用 Semantic Pay 或 Heurist 作为结算目标,而不是直接提交。