x402 payment-identifier 扩展实战:用支付 ID 实现 HTTP 402 支付的幂等性
2026/9/17 19:01:34 网站建设 项目流程

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 将幂等流程概括为四个步骤:

  1. 客户端调用generatePaymentId()生成一个唯一的支付 ID;
  2. 客户端在构造PaymentPayload之前,通过appendPaymentIdentifierToExtensions()把支付 ID 写入扩展字段;
  3. 服务端以支付 ID 为键缓存已处理的响应;
  4. 携带相同支付 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_LENGTH16支付 ID 最小长度
PAYMENT_ID_MAX_LENGTH128支付 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 中实现缓存逻辑。其核心模式为:

  1. PaymentRequired响应中通过declarePaymentIdentifierExtension(false)声明支持(可选);
  2. onAfterSettle钩子中调用extractPaymentIdentifier(paymentPayload)取出 ID,将响应写入内存缓存(示例 TTL 为 1 小时);
  3. 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 小时):适用于静态或低频变化的资源。

生产环境注意事项

  1. 分布式场景应使用 Redis 等共享缓存替代内存Map
  2. 缓存故障要优雅降级——缓存不可用时照常处理支付;
  3. 可引入载荷哈希:相同 ID 但不同载荷时返回 409 Conflict,进一步防止误用;
  4. 监控缓存命中率,用于调优 TTL 与识别滥用行为。

适用场景

  • 网络故障:请求失败后安全重试,避免重复扣款;
  • 客户端崩溃:持久化支付 ID,重启后恢复进行中的请求;
  • 负载均衡:同一请求命中共享缓存的多个服务端节点,仍能去重;
  • 测试/回放:开发期间以相同 ID 反复回放请求,不消耗资金。

最佳实践

  1. 在逻辑请求层级生成支付 ID,而不是每次重试都生成新 ID——否则重试将失去幂等意义;
  2. 持久化支付 ID:对长时间运行的操作保存 ID,使其在进程重启后依然有效;
  3. 使用描述性前缀(如order_sub_)区分支付类型,便于日志审计;
  4. 不要跨逻辑请求复用支付 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询