x402 payment-identifier 扩展实战:用支付 ID 实现 HTTP 402 支付的幂等性
【免费下载链接】x402A payments protocol for the internet. Built on HTTP.项目地址: https://gitcode.com/GitHub_Trending/x4/x402
本文是 x402 开源支付协议仓库中examples/typescript/clients/payment-identifier示例的完整实战指南,讲解如何借助payment-identifier扩展为基于 HTTP 402 的链上支付流程引入幂等性(idempotency):客户端为每个逻辑请求生成唯一支付 ID,服务端按 ID 缓存响应,从而让网络故障重试、客户端崩溃恢复等场景不再产生重复扣款。读完本文,你将掌握generatePaymentId()、appendPaymentIdentifierToExtensions()等扩展 API 的用法、底层校验约束与格式规范,并能在本地完整跑通"首次请求扣款、同 ID 重试直接命中缓存"的端到端验证。
为什么支付需要幂等性
x402 的支付链路涉及 EVM 链上签名、交易广播与 HTTP 请求,属于典型的"跨系统不确定状态"场景:一次支付请求在网络上超时后,客户端无法确定服务端是否已经完成了扣款与放行。如果简单重试,可能造成同一笔逻辑交易被重复结算;如果不重试,又可能因为网络抖动白白丢失有效请求。
payment-identifier扩展正是为解决该问题而生:它允许客户端在PaymentPayload中携带一个全局唯一的支付 ID,服务端以此为键缓存已处理请求的响应。当同一个 ID 再次出现时,服务端直接返回缓存结果、跳过支付处理,从而把"至少一次"的不可靠重试,收敛为"恰好一次"的幂等语义。
从源码结构看,该扩展在 TypeScript 包中位于 typescript/packages/extensions/src/payment-identifier,由类型定义、工具函数、客户端辅助函数、服务端声明函数与校验/提取逻辑五个模块组成,并配套了 payment-identifier.test.ts 共 669 行的完整测试。
核心工作流程
关联文档 examples/typescript/clients/payment-identifier/README.md 将幂等流程概括为四个步骤:
- 客户端调用
generatePaymentId()生成一个唯一的支付 ID; - 客户端在构造
PaymentPayload之前,通过appendPaymentIdentifierToExtensions()把支付 ID 写入扩展字段; - 服务端以支付 ID 为键缓存已处理的响应;
- 携带相同支付 ID 的重试请求直接命中缓存返回,不再重新处理支付。
对应的 TypeScript 骨架代码如下(出自 index.ts 的完整版见下一节):
import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; import { appendPaymentIdentifierToExtensions, generatePaymentId } from "@x402/extensions/payment-identifier"; const client = new x402Client(); // ... register schemes ... // Generate a unique payment ID for this logical request const paymentId = generatePaymentId(); // Hook into payment flow to add the payment ID before payload creation client.onBeforePaymentCreation(async ({ paymentRequired }) => { if (!paymentRequired.extensions) { paymentRequired.extensions = {}; } appendPaymentIdentifierToExtensions(paymentRequired.extensions, paymentId); }); const fetchWithPayment = wrapFetchWithPayment(fetch, client); // First request - payment is processed const response1 = await fetchWithPayment(url); // Retry with same payment ID - cached response returned (no payment) const response2 = await fetchWithPayment(url);需要注意第 2 步发生在onBeforePaymentCreation钩子中,即在协议握手返回PaymentRequired(402 响应)之后、客户端最终构造PaymentPayload之前,这样支付 ID 才能随支付载荷一并提交给服务端。
客户端示例的完整实现
仓库中的可运行示例位于 examples/typescript/clients/payment-identifier/index.ts,相比 README 中的骨架,它补充了方案注册、环境变量读取、耗时统计与结算验证等完整逻辑。
初始化与方案注册
import { config } from "dotenv"; import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; import { privateKeyToAccount } from "viem/accounts"; config(); const privateKey = process.env.PRIVATE_KEY as `0x${string}`; if (!privateKey) { console.error("❌ PRIVATE_KEY environment variable is required"); process.exit(1); } const baseURL = process.env.RESOURCE_SERVER_URL || "http://localhost:4022"; const endpointPath = process.env.ENDPOINT_PATH || "/weather"; const url = `${baseURL}${endpointPath}`;这里注册了两个 EVM 支付方案:ExactEvmScheme(精确金额)与UptoEvmScheme(上限金额),均以privateKeyToAccount构造的签名者完成签名,网络匹配模式为eip155:*。三个可配置环境变量的默认值分别为http://localhost:4022、/weather,以及必填的PRIVATE_KEY。
支付 ID 的生成与注入
const paymentId = generatePaymentId(); console.log(`\n🔑 Generated Payment ID: ${paymentId}`); client.onBeforePaymentCreation(async ({ paymentRequired }) => { if (paymentRequired.extensions) { appendPaymentIdentifierToExtensions(paymentRequired.extensions, paymentId); } });注意示例中paymentRequired.extensions在服务端声明了该扩展时才会存在;appendPaymentIdentifierToExtensions内部同样会做防御性检查(详见下文"客户端注入的源码实现"),只在服务端确实声明了payment-identifier时才写入 ID。
首次请求与重试请求的对比验证
const fetchWithPayment = wrapFetchWithPayment(fetch, client); const startTime1 = Date.now(); const response1 = await fetchWithPayment(url, { method: "GET" }); const duration1 = Date.now() - startTime1; const body1 = await response1.json(); console.log(`Response (${duration1}ms):`, JSON.stringify(body1, null, 2)); const paymentResponse1 = new x402HTTPClient(client).getPaymentSettleResponse(name => response1.headers.get(name), ); if (paymentResponse1) { console.log(`\n💰 Payment settled on ${paymentResponse1.network}`); }x402HTTPClient(client).getPaymentSettleResponse()从响应头中解析结算凭证(settle response),据此确认首次请求确实发生了链上结算。第二次请求复用同一个paymentId发起,示例预期服务端直接返回缓存(cached: true),此时getPaymentSettleResponse返回空值,控制台打印✅ No payment processed - response served from cache!。最后程序还会计算两次请求的耗时差并输出缓存加速百分比:
if (duration2 < duration1) { console.log( ` ⚡ Cached response was ${Math.round((1 - duration2 / duration1) * 100)}% faster!`, ); }扩展 API 与格式约束(源码级解析)
常量与格式规范
在 types.ts 中定义了扩展的格式契约:
| 常量 | 值 | 说明 |
|---|---|---|
PAYMENT_IDENTIFIER | "payment-identifier" | 扩展在extensions对象中的键名 |
PAYMENT_ID_MIN_LENGTH | 16 | 支付 ID 最小长度 |
PAYMENT_ID_MAX_LENGTH | 128 | 支付 ID 最大长度 |
PAYMENT_ID_PATTERN | /^[a-zA-Z0-9_-]+$/ | 仅允许字母、数字、连字符与下划线 |
扩展结构为{ info: { required, id? }, schema }:required表示服务端是否强制要求客户端提供 ID(true时缺失将得到 400 Bad Request);id为客户端提供的幂等键;schema是对应 JSON Schema(基于 JSON Schema 2020-12 草案),用于对info结构做程序化校验。
生成函数:generatePaymentId
utils.ts 中的实现非常简洁:
export function generatePaymentId(prefix: string = "pay_"): string { const uuid = crypto.randomUUID().replace(/-/g, ""); return `${prefix}${uuid}`; }- 默认前缀
pay_,可传入自定义前缀(如txn_),传空字符串则无前缀; - 基于
crypto.randomUUID()生成 UUID v4 并去掉连字符,得到 32 位十六进制字符; - 默认生成的
pay_+ 32 位十六进制 = 36 字符,落在 16~128 的合法区间内。
同文件提供的isValidPaymentId()会先检查类型与长度边界(16 ≤ length ≤ 128),再用PAYMENT_ID_PATTERN校验字符集。测试 payment-identifier.test.ts 覆盖了默认/自定义/无前缀三种生成方式、100 次生成的唯一性,以及过短(15 字符)、过长(129 字符)、非法字符(!@#$%^&*()、空格、点号)等拒绝路径。
客户端注入:appendPaymentIdentifierToExtensions
client.ts 中该函数的关键行为是仅在服务端声明了扩展时才写入 ID:
export function appendPaymentIdentifierToExtensions( extensions: Record<string, unknown>, id?: string, ): Record<string, unknown> { const extension = extensions[PAYMENT_IDENTIFIER]; // Only append if the server declared this extension with valid structure if (!isPaymentIdentifierExtension(extension)) { return extensions; } const paymentId = id ?? generatePaymentId(); if (!isValidPaymentId(paymentId)) { throw new Error( `Invalid payment ID: "${paymentId}". ` + `ID must be 16-128 characters and contain only alphanumeric characters, hyphens, and underscores.`, ); } extension.info.id = paymentId; return extensions; }- 未传
id时自动调用generatePaymentId(); - 传入自定义 ID 时会先经
isValidPaymentId校验,非法则抛出明确错误; - 若服务端未声明该扩展(
isPaymentIdentifierExtension返回 false),函数原样返回,不会污染扩展对象。
服务端声明:declarePaymentIdentifierExtension
服务端侧通过 resourceServer.ts 中的declarePaymentIdentifierExtension(required = false)在PaymentRequired.extensions中声明能力:
export function declarePaymentIdentifierExtension( required: boolean = false, ): PaymentIdentifierExtension { return { info: { required }, schema: paymentIdentifierSchema, }; }同时导出一个paymentIdentifierResourceServerExtension常量(key: PAYMENT_IDENTIFIER),供资源服务器通过registerExtension接入统一扩展注册体系。
服务端校验与提取
validation.ts 提供了完整的服务端侧工具集:
extractPaymentIdentifier(paymentPayload, validate = true):从PaymentPayload.extensions中提取 ID,缺失或非法返回null;extractAndValidatePaymentIdentifier():一次性返回{ id, validation },便于在结算处理器中同时拿到 ID 与校验结果;validatePaymentIdentifier(extension):结合 JSON Schema(Ajv)与格式约束做双重校验;validatePaymentIdentifierRequirement(paymentPayload, serverRequired):当服务端要求必填而客户端未提供时返回 400 级别错误信息;isPaymentIdentifierRequired()/hasPaymentIdentifier():用于快速判断必填性、扩展是否存在。
端到端运行指南
前置条件
- Node.js v20+(推荐通过 nvm 安装);
- pnpm v10(推荐通过 pnpm 官方安装脚本安装);
- 一个运行中的 payment-identifier 服务端,参见 payment-identifier server example;
- 用于支付的有效 EVM 私钥(示例环境为 Base Sepolia 网络上的 USDC)。
安装与构建
在 TypeScript 示例根目录(examples/typescript)统一安装并构建所有包:
cd ../../ pnpm install && pnpm build cd clients/payment-identifier配置环境变量
复制本地环境变量模板并填入私钥:
cp .env-local .env必填环境变量:
PRIVATE_KEY—— 用于 EVM 支付的以太坊私钥。
可选环境变量(由 index.ts 读取):RESOURCE_SERVER_URL(默认http://localhost:4022)与ENDPOINT_PATH(默认/weather)。
启动服务端并运行客户端
先在另一个终端启动 payment-identifier 服务端:
cd ../../servers/payment-identifier pnpm dev再运行客户端:
pnpm dev预期输出
🔑 Generated Payment ID: pay_7d5d747be160e280504c099d984bcfe0 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📤 First Request (with payment ID: pay_7d5d747be160e280...) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Making request to: http://localhost:4022/weather Response (1523ms): { "report": { "weather": "sunny", "temperature": 70, "cached": false } } 💰 Payment settled on eip155:84532 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📤 Second Request (SAME payment ID: pay_7d5d747be160e280...) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Making request to: http://localhost:4022/weather 💡 Expected: Server returns cached response without payment processing Response (45ms): { "report": { "weather": "sunny", "temperature": 70, "cached": true } } ✅ No payment processed - response served from cache! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📊 Summary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Payment ID: pay_7d5d747be160e280504c099d984bcfe0 First request: 1523ms (payment processed) Second request: 45ms (cached) ⚡ Cached response was 97% faster!注意输出中的耗时与加速百分比是运行时动态计算的,随网络与链上确认速度变化,并非固定指标;关键在于第二次请求的响应体出现"cached": true且没有新的结算凭证。
服务端侧幂等实现与行为矩阵
为了让客户端示例跑通,服务端必须在 examples/typescript/servers/payment-identifier 中实现缓存逻辑。其核心模式为:
- 在
PaymentRequired响应中通过declarePaymentIdentifierExtension(false)声明支持(可选); - 在
onAfterSettle钩子中调用extractPaymentIdentifier(paymentPayload)取出 ID,将响应写入内存缓存(示例 TTL 为 1 小时); - 在
onProtectedRequest钩子中解码paymentHeader、提取 ID 并查询缓存,命中则直接返回{ grantAccess: true }跳过支付。
服务端的幂等行为矩阵(出自服务端示例 README):
| 场景 | 服务端响应 |
|---|---|
| 新的支付 ID | 正常处理支付并缓存响应 |
| 相同支付 ID(TTL 内) | 返回缓存响应,跳过支付 |
| 相同支付 ID(TTL 已过期) | 正常处理支付并更新缓存 |
| 无支付 ID | 正常处理支付(不缓存) |
必填与可选配置
// Payment ID is optional (clients can omit it) declarePaymentIdentifierExtension(false) // Payment ID is required (clients must provide it) declarePaymentIdentifierExtension(true)缓存 TTL 调优
- 短 TTL(5~15 分钟):适用于对时效性敏感的资源;
- 长 TTL(1~24 小时):适用于静态或低频变化的资源。
生产环境注意事项
- 分布式场景应使用 Redis 等共享缓存替代内存
Map; - 缓存故障要优雅降级——缓存不可用时照常处理支付;
- 可引入载荷哈希:相同 ID 但不同载荷时返回 409 Conflict,进一步防止误用;
- 监控缓存命中率,用于调优 TTL 与识别滥用行为。
适用场景
- 网络故障:请求失败后安全重试,避免重复扣款;
- 客户端崩溃:持久化支付 ID,重启后恢复进行中的请求;
- 负载均衡:同一请求命中共享缓存的多个服务端节点,仍能去重;
- 测试/回放:开发期间以相同 ID 反复回放请求,不消耗资金。
最佳实践
- 在逻辑请求层级生成支付 ID,而不是每次重试都生成新 ID——否则重试将失去幂等意义;
- 持久化支付 ID:对长时间运行的操作保存 ID,使其在进程重启后依然有效;
- 使用描述性前缀(如
order_、sub_)区分支付类型,便于日志审计; - 不要跨逻辑请求复用支付 ID:不同业务请求必须使用不同 ID,避免误命中缓存。
小结
通过payment-identifier扩展,x402 将 HTTP 402 支付流程中的重试不确定性收敛为可验证的幂等语义:客户端一侧只需在onBeforePaymentCreation钩子中注入generatePaymentId()生成的 ID,服务端一侧以extractPaymentIdentifier+ 缓存实现去重。扩展包内置了严格的格式校验(16~128 字符、字母数字与-/_)、JSON Schema 校验和必填控制,并有完整的单元测试与端到端示例支撑,可直接作为生产支付网关幂等层的实现参考。如需继续深入,可阅读 扩展实现源码、扩展测试 以及 服务端示例。
【免费下载链接】x402A payments protocol for the internet. Built on HTTP.项目地址: https://gitcode.com/GitHub_Trending/x4/x402
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考