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 头的响应。

步骤 1. 客户端:GET /weather (无Authorization头)
        服务器:402 Payment Required
                WWW-Authenticate: Payment realm="...", challenges="[...usdt0-stable charge for $0.001...]"

步骤 2. 客户端使用他们的viem账户签署一个ERC-3009授权

步骤 3. 客户端:GET /weather + 包含序列化凭据的Authorization头
        服务器:verify()验证EIP-712签名
        服务器:settle()提交Stable上的transferWithAuthorization
                (~700ms 区块确认)
        服务器:200 OK { weather: "sunny" }
                Payment-Receipt: reference="0x8f3a...", status="success"

步骤 4. 在Stablescan上验证结算
        https://stablescan.xyz/tx/0x8f3a...

先决条件

  • Stable上资金充足的USDT0钱包。请参阅使用水龙头移动USDT0
  • Node 20+,并安装了mppxviemzod
  • Stable上的卖家账户(一个EOA)。对于默认的结算路径,卖家以USDT0支付gas;Gas豁免部分展示了零gas变体。
npm install mppx viem zod express

1. 定义共享Dapp模式

Method.from() 声明了意图以及请求(挑战)和凭据负载的 Dapp 模式。客户端和服务器都导入此定义。

// 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("无效的ERC-3009签名");
 
    // Recipt的reference字段将在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 token)支付 gas,因此无需管理单独的 gas token 余额。

// 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 豁免进行结算

使用 Stable 的Gas 豁免gasPrice = 0 提交内部交易。卖家仍然签署包装交易,但无需支付 gas 费。需要豁免服务器 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(`结算失败: ${result.error?.message}`);
  return { txHash: result.txHash };
}
{ txHash: "0x8f3a1b2c..." }

有关如何构建已签名的内部交易(gasPrice: 0,编码的 transferWithAuthorization 调用)然后再发布的信息,请参阅Gas 豁免协议

替代方案:移交给x402协调器

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

paymentPayload 的确切形状是 x402 中间件内部的,未在协议层面指定。最简单的路径是使用协调器自己的 SDK 来构建负载,或者坚持使用上述的直接提交路径。协调器不需要理解 MPP;它只看到 transferWithAuthorization 字段。

4. 客户端:签署凭据

Method.toClientcreateCredential() 接入 mppx。客户端读取挑战,使用代理的 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 中间件来发布挑战、解析传入的 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服务器正在监听 http://localhost:${PORT}`);
});
MPP服务器正在监听 http://localhost:4022

6. 端到端运行流程

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

确认挑战

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 token 余额。
  • 在客户端-服务器跳跃中使用了 MPP 的 WWW-Authenticate / Authorization / Payment-Receipt 线路格式。
  • 在同一 HTTP 请求生命周期内(~700 毫秒区块时间)通过 Stable 上的 transferWithAuthorization 进行结算。

接下来推荐

  • MPP概念:阅读MPP如何与x402相关以及其他意图是什么。
  • MPP会话:当按请求结算成本过高时,使用链下凭证流式传输小额支付。
  • 协调器:使用 Semantic Pay 或 Heurist 作为结算目标,而不是直接提交。