x402 Offer/Receipt 扩展详解:为 HTTP 支付协议添加服务端签名的 Offer 与 Receipt
【免费下载链接】x402A payments protocol for the internet. Built on HTTP.项目地址: https://gitcode.com/GitHub_Trending/x4/x402
本文基于 x402 仓库中@x402/extensions包的 Offer/Receipt 扩展文档与配套源码,讲解如何在 x402 支付流程中引入服务端签名的 Offer(报价承诺)与 Receipt(交付凭证):包括两种签名格式(JWS 与 EIP-712)的报文结构、服务端扩展注册与路由声明、客户端提取与匹配的完整代码路径,以及生产环境下的密钥绑定与管理策略。读完本文,你可以为 x402 资源服务器启用签名报价/小票能力,并在客户端正确提取、匹配和校验这些签名凭证。
1. 定位与整体流程
Offer/Receipt 扩展(当前版本 v1.0)为 x402 支付协议添加服务端签名能力:资源服务器对其在accepts[]中列出的每一项支付要求生成一个签名的 Offer,证明这些支付条件确实出自该服务器;支付并交付服务后,服务器再签发一个签名的 Receipt,证明服务已被交付。扩展文档见 typescript/packages/extensions/src/offer-receipt/README.md,规范定义见 specs/extensions/extension-offer-and-receipt.md。
其工作流如下(继承自 README):
┌─────────┐ ┌─────────────────┐ ┌─────────────┐ │ Client │ │ Resource Server │ │ Facilitator │ └────┬────┘ └────────┬────────┘ └──────┬──────┘ │ GET /resource │ │ │ ──────────────────────────────────►│ │ │ 402 + PaymentRequirements │ │ │ + SignedOffer(s) │ │ │ ◄──────────────────────────────────│ │ │ GET /resource + Payment Header │ │ │ ──────────────────────────────────►│ │ │ │ Verify + Settle │ │ │ ────────────────────────────────────►│ │ │ Settlement Response │ │ │ ◄────────────────────────────────────│ │ 200 + Resource + SignedReceipt │ │ │ ◄──────────────────────────────────│ │放置位置遵循 x402 v2 扩展结构:Offer 位于 402 响应extensions["offer-receipt"].info.offers[]数组中,每个 offer 对应accepts[]的一个条目;Receipt 位于支付成功响应extensions["offer-receipt"].info.receipt。规范明确要求:服务器 SHOULD 保持offers[]与accepts[]的顺序一致,但客户端 MUST 通过比较 payload 字段(network、asset、payTo、amount等)而非数组下标来匹配二者。
为什么需要 Offer 和 Receipt
两者解决的是不同的信任问题:
- Offer(签名报价):
- 当服务器没有返回签名 Receipt 时,Offer 可作为"曾发生交互"的降级证据;
- 证明报价确实来自资源服务器;
- 防止客户端伪造报价并冒充服务器行为。
- Receipt(签名小票):是可携带的"已付费服务"密码学证明,支撑四类下游场景:
- 已验证用户评价(类似电商的"Verified Purchase"徽章);
- 审计留痕:服务交付的密码学证据;
- 争议解决:证明付款后服务确实被交付;
- Agent 记忆:AI Agent 可出示与某服务的历史交互证明。
2. 签名工件结构与两种签名格式
扩展定义了两种签名格式,在 types.ts 中声明为SignatureFormat = "jws" | "eip712",选择依据见 README:
- JWS:适合服务端用受管密钥签名(HSM、KMS 等);
- EIP-712:适合基于钱包的签名(MetaMask、WalletConnect 等)。
2.1 共同对象形态
两类工件共享同一顶层结构,仅 payload 字段不同:
| 字段 | 类型 | 是否必需 | 说明 |
|---|---|---|---|
format | string | 是 | "eip712"或"jws" |
payload | object | 仅 EIP-712 | 规范化 payload 字段(JWS 时必须省略) |
signature | string | 是 | 格式相关的签名编码 |
acceptIndex | integer | 否 | accepts[]下标(仅 Offer 有,非签名部分) |
格式差异在源码中体现得很直白(见 types.ts):
// JWS:payload 被省略(已编码在 JWS Compact 字符串中) export interface JWSSignedOffer { format: "jws"; acceptIndex?: number; signature: string; // "header.payload.signature" } // EIP-712:payload 必需 export interface EIP712SignedOffer { format: "eip712"; acceptIndex?: number; payload: OfferPayload; signature: string; // 0x 前缀、65 字节 r+s+v 的 hex 编码 }关于acceptIndex:它是不参与签名的便利字段,客户端 SHOULD 用它做快速匹配,但 MUST 以 payload 字段比对为准(规范 §4.1.1)。
2.2 JWS 的构造细节
JWS 构造在 signing.ts 的 createJWS 中实现。签名者只需对原始字节签名并返回 base64url 串,其余组装由扩展完成:
- 头部为
{ alg, kid }(alg如 ES256K、EdDSA;kid为可解析到公钥的 DID URL),base64url 编码; - payload 先经JCS 规范化(RFC 8785)再 base64url 编码——规范化规则包括:对象按键的 UTF-16 码元字典序排序、无空白、数字取最短表示、字符串最小转义;
- 签名输入为
`${headerB64}.${payloadB64}`,最终产出header.payload.signature三段式字符串。
源码同时提供了 canonicalize / hashCanonical / getCanonicalBytes 工具函数,以及不校验签名的extractJWSHeader/extractJWSPayload解包函数。
2.3 EIP-712 的构造细节
EIP-712 签名使用固定域(signing.ts):
// Offer: { name: "x402 offer", version: "1", chainId: 1 } // Receipt: { name: "x402 receipt", version: "1", chainId: 1 }规范特别指出chainId硬编码为1是有意为之:EIP-712 在此只是链下签名格式,实际支付网络由 payload 的network字段标识,固定 chainId 可让非 EVM 网络(如 Solana)也统一使用。规范中还有两个版本概念需要区分:域version(string"1",schema 版本,改types/primaryType时必须递增)与 payloadversion(整数1,随工件携带的语义版本)。
签名的types定义是规范性的且不随报文传输(规范 §3.2.1):OFFER_TYPES与RECEIPT_TYPES在 signing.ts 中硬编码,签名方与验证方 MUST 使用同一份定义。一个值得注意的实现细节:EIP-712 固定 schema 要求所有字段存在,因此 createReceiptPayloadForEIP712 在无交易哈希时把transaction填空字符串(规范要求 "MUST set unused fields to empty string");而 JWS 路径的 createReceiptPayloadForJWS 则直接省略该可选字段,实现隐私最小化默认值。
3. Payload 字段定义
Offer 与 Receipt 的 payload 字段定义在规范 §4.2 / §5.2,对应 types.ts:
OfferPayload(必需字段:version、resourceUrl、scheme、network、asset、payTo、amount):
| 字段 | 类型 | 说明 |
|---|---|---|
version | number | schema 版本,当前为1(源码常量EXTENSION_VERSION = 1) |
resourceUrl | string | 被付费的资源 URL |
scheme | string | 支付方案标识,如"exact" |
network | string | 链网络标识,CAIP-2 格式,如"eip155:8453" |
asset | string | 代币合约地址或"native" |
payTo | string | 收款钱包地址 |
amount | string | 应付金额 |
validUntil | number | 过期 Unix 时间戳(秒),由当前时间 + offerValiditySeconds得出 |
ReceiptPayload(必需字段:version、network、resourceUrl、payer、issuedAt):
| 字段 | 类型 | 说明 |
|---|---|---|
version | number | 当前为1 |
network | string | CAIP-2 网络标识 |
resourceUrl | string | 被付费的资源 URL |
payer | string | 付款方标识(通常为钱包地址) |
issuedAt | number | 签发 Unix 时间戳(秒) |
transaction | string | 链上交易哈希,可选(可验证性 vs 隐私的权衡) |
两个可调参数在 types.ts 的 OfferReceiptDeclaration 中定义:
includeTxHash:是否在 Receipt 中包含交易哈希,默认false(隐私优先),设为true换取链上可验证性;offerValiditySeconds:Offer 有效期(秒),默认 300 秒,对应 signing.ts 中的DEFAULT_MAX_TIMEOUT_SECONDS = 300;服务端还会优先取PaymentRequirements.maxTimeoutSeconds(见 server.ts 的 requirementsToOfferInput)。
4. 服务端接入
4.1 安装与整体用法
npm install @x402/extensions包的导出映射见 typescript/packages/extensions/package.json("./offer-receipt"子路径导出)。
服务端接入分三步:创建 Issuer → 注册扩展 → 在路由配置中声明。README 给出的服务端示例如下(注意:实际代码中声明函数名为declareOfferReceiptExtension,见 server.ts,README 示例写作declareOfferReceipt):
import { x402ResourceServer } from "@x402/core/server"; import { createOfferReceiptExtension, createJWSOfferReceiptIssuer, declareOfferReceiptExtension, // README 中写作 declareOfferReceipt } from "@x402/extensions/offer-receipt"; // 1. 创建 Issuer(JWS 或 EIP-712 两种格式) const issuer = createJWSOfferReceiptIssuer("did:web:api.example.com#key-1", jwsSigner); // 2. 注册扩展 const server = new x402ResourceServer(facilitator) .registerExtension(createOfferReceiptExtension(issuer)); // 3. 在路由配置中声明 const routes = { "GET /api/data": { accepts: { payTo, scheme: "exact", price: "$0.01", network: "eip155:8453" }, extensions: { ...declareOfferReceiptExtension({ includeTxHash: false }) } } };4.2 Issuer 工厂与底层接口
server.ts 提供两个 Issuer 工厂:
createJWSOfferReceiptIssuer(kid, jwsSigner):jwsSigner实现JWSSigner接口(kid、sign(payload: Uint8Array)、format: "jws"、algorithm);createEIP712OfferReceiptIssuer(kid, signTypedData):signTypedData为 EIP-712 类型数据签名函数,kid 形如did:pkh:eip155:1:0x...。
两者的返回值统一实现OfferReceiptIssuer接口(types.ts):issueOffer(resourceUrl, input)与issueReceipt(resourceUrl, payer, network, transaction?)。
4.3 扩展钩子的内部行为
createOfferReceiptExtension 返回一个ResourceServerExtension(key 为常量OFFER_RECEIPT = "offer-receipt"),挂接两个生命周期钩子:
enrichPaymentRequiredResponse:从传输上下文或 402 响应体中取得资源 URL;遍历context.requirements中的每一项支付要求,调用issuer.issueOffer逐条签名(单条失败仅记录日志并跳过);最终返回{ info: { offers }, schema: OFFER_SCHEMA }。OFFER_SCHEMA(server.ts)是内联的 JSON Schema(draft 2020-12),用于描述扩展数据。enrichSettlementResponse:仅在结算成功(context.result.success)时工作;从结算结果读取payer与network(缺失则警告并跳过),按声明的includeTxHash === true决定是否附带transaction,然后调用issuer.issueReceipt返回{ info: { receipt }, schema: RECEIPT_SCHEMA }。
从源码结构看,两个钩子都刻意做了防御性处理:签名失败不会中断主支付流程,只是不附加扩展数据——这保证了 Offer/Receipt 作为可选能力不影响基础交易。
5. 客户端接入
客户端工具集中在 client.ts,全部从包入口 index.ts 导出。
5.1 wrapFetchWithPayment 方案
在 wrapFetchWithPayment 包装器上叠加 Offer/Receipt 支持:在onPaymentRequired钩子里捕获 Offer,请求成功后从响应头提取 Receipt:
import { wrapFetchWithPayment, x402Client, x402HTTPClient } from "@x402/fetch"; import { registerExactEvmScheme } from "@x402/evm/exact/client"; import { registerExactSvmScheme } from "@x402/svm/exact/client"; import { extractOffersFromPaymentRequired, decodeSignedOffers, extractReceiptFromResponse, type DecodedOffer, } from "@x402/extensions/offer-receipt"; // ... 配置 evmSigner / svmSigner 与 x402Client 后: let capturedOffers: DecodedOffer[] = []; httpClient.onPaymentRequired(async ({ paymentRequired }) => { const offers = extractOffersFromPaymentRequired(paymentRequired); capturedOffers = decodeSignedOffers(offers); }); const fetchWithPay = wrapFetchWithPayment(fetch, httpClient); const response = await fetchWithPay(url); // 从 PAYMENT-RESPONSE 头提取签名 Receipt const receipt = extractReceiptFromResponse(response); // 用 receipt payload 字段(network、amount 等)匹配此前捕获的 offerREADME 特别指出:此方案不控制选择哪个accepts[]条目——选项由客户端选择器/策略独立决定,与签名 Offer 的匹配是事后进行的。
5.2 Raw Flow 与关键工具函数
需要完全掌控 Offer 选择时,使用原始流程。完整可运行实现见 examples/typescript/clients/offer-receipt/index.ts(250 行),演示五步:
- 发起请求,收到带签名 Offer 的 402;
- 提取并解码 Offer,检视支付选项;
- 选择一个 Offer,并找到匹配的
accepts[]条目; - 完成支付,收到签名 Receipt;
- 验证 Receipt payload。
示例代码中展示了按格式分派的签名验证写法(index.ts):
for (const decoded of decodedOffers) { if (isJWSSignedOffer(decoded.signedOffer)) { await verifyOfferSignatureJWS(decoded.signedOffer); } else { const { signer } = await verifyOfferSignatureEIP712(decoded.signedOffer); // signer 即恢复出的 EVM 地址 } }各工具函数的行为(均定义于 client.ts):
| 函数 | 行为 |
|---|---|
extractOffersFromPaymentRequired(paymentRequired) | 从extensions["offer-receipt"].info.offers读取签名 Offer 数组,缺失返回[] |
decodeSignedOffers(offers) | 把所有 Offer 解码为DecodedOffer——payload 字段提升到顶层并附带signedOffer、format、acceptIndex;JWS 解码只是 base64 解包、无加解密开销,因此全部提前解码成本很低 |
findAcceptsObjectFromSignedOffer(offer, accepts) | 先用acceptIndex做提示快速查找,再逐字段校验(network/scheme/asset/payTo/amount);失配时回退为全量扫描 |
extractReceiptFromResponse(response) | 读取PAYMENT-RESPONSE(或X-PAYMENT-RESPONSE)响应头,解码后取info.receipt;任何解析异常都安全返回undefined |
verifyReceiptMatchesOffer(receipt, offer, payerAddresses, maxAgeSeconds=3600) | 做基础字段校验:resourceUrl一致、network一致、payer属于客户端地址集合(大小写不敏感)、issuedAt距今不超过maxAgeSeconds(默认 1 小时)。注意它不验证签名与密钥绑定 |
关于未来方向:README 提到可能增加wrapFetchWithPaymentExtended包装器,直接基于签名 Offer 而非accepts[]数组来选择支付选项,从而保证所选支付项必然存在对应签名 Offer——在重视凭证证明(attestation proof)的场景下这是更正确的选择。
6. 签名验证、密钥绑定与安全考量
6.1 验证函数与密钥解析
扩展从包入口导出四个验证函数(signing.ts):
verifyOfferSignatureEIP712/verifyReceiptSignatureEIP712:用 viem 的recoverTypedDataAddress从签名恢复签名者 EVM 地址,返回{ signer, payload };verifyOfferSignatureJWS/verifyReceiptSignatureJWS:用jose.compactVerify验证,公钥可显式传入(JWK 或 KeyLike),否则从 JWS 头部的kid自动解析。
kid 的公钥解析由 did.ts 实现,支持三种 DID 方法:
- did:key:base58btc(
z前缀)multibase 解码后按 multicodec 前缀识别 Ed25519、secp256k1、P-256,转换为 JWK; - did:jwk:base64url 解码出内嵌 JWK 直接导入;
- did:web:按 did:web 方法约定请求
https://<domain>/.well-known/did.json(localhost 允许 HTTP),在验证方法中查找对应fragment的公钥(优先assertionMethod,其次authentication)。
6.2 关键设计边界:extractPayload 不做签名校验
README 的"Security Considerations"部分明确:extractPayload()系列函数只解包 payload,不验证签名、不检查签名者授权。这是有意设计——签名者授权需要解析密钥绑定(did:web 文档、attestation 等),因部署而异,超出 x402 客户端工具的范围。生产环境中由下游信任系统完成两件事:
- 签名本身有效(EIP-712 或 JWS);
- 签名密钥对被资源域授权。
6.3 密钥到域的绑定方式
要建立信任,把签名密钥的 DID 绑定到资源域,README 给出三条路径:
- did:web DID 文档:在
https://example.com/.well-known/did.json提供(与 did.ts 的 resolveDidWeb 实现一致); - DNS TXT 记录:添加 TXT 记录把 DID 绑定到域;
- 密钥绑定 Attestation:创建一份声明密钥用途与授权域的证明。
6.4 密钥管理建议
- JWS 签名:使用 HSM 或 KMS 托管密钥;JWS 头部
kid应为可解析到公钥的 DID URL; - EIP-712 签名:签名钱包应为
payTo地址本身,或存在链上/链下授权将其关联到服务; - 密钥轮换:轮换时更新 DID 文档或 attestation;旧 Receipt 在"签发时密钥已被授权"的前提下保持有效。
7. Receipt 作为下游信任证明
签名 Receipt 是商业交易的密码学证明,可提交给下游信任与声誉平台。README 列出两个方向:OMATrust(面向已验证用户评价与服务证明的去中心化声誉系统)与PEAC Protocol(Payment Evidence and Attestation Chain,商业交易证明链)。这两个平台的集成库将在未来版本中加入,当前版本提供的是可独立流转的签名工件本身。
8. 源码文件与参考资料
Offer/Receipt 扩展的模块划分(对应 README 的文件表,实际文件还包含 DID 解析模块):
| 文件 | 职责 |
|---|---|
| types.ts | Offer/Receipt 类型定义、Signer 接口、声明配置与类型守卫 |
| signing.ts | JCS 规范化、JWS 构造、EIP-712 域与类型、Offer/Receipt 创建与验证 |
| server.ts | 服务端扩展工厂(两个生命周期钩子)、Issuer 工厂、JSON Schema |
| client.ts | 客户端提取、解码、匹配与基础校验工具 |
| did.ts | did:key / did:jwk / did:web 的公钥解析 |
| index.ts | 包入口,统一再导出以上全部 API |
进一步深入可参考:
- 规范全文(报文形态、字段规范性要求、acceptIndex 处理规则、示例):specs/extensions/extension-offer-and-receipt.md;
- 单元测试(覆盖规范化、两种格式的创建/验证、钩子行为等):typescript/packages/extensions/test/offer-receipt.test.ts;
- 完整客户端示例(raw flow、签名验证与 Receipt 匹配):examples/typescript/clients/offer-receipt/index.ts。
【免费下载链接】x402A payments protocol for the internet. Built on HTTP.项目地址: https://gitcode.com/GitHub_Trending/x4/x402
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考