x402 Offer/Receipt 扩展详解:为 HTTP 支付协议添加服务端签名的 Offer 与 Receipt
2026/9/17 7:12:18 网站建设 项目流程

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 字段(networkassetpayToamount等)而非数组下标来匹配二者。

为什么需要 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 字段不同:

字段类型是否必需说明
formatstring"eip712""jws"
payloadobject仅 EIP-712规范化 payload 字段(JWS 时必须省略)
signaturestring格式相关的签名编码
acceptIndexintegeraccepts[]下标(仅 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 串,其余组装由扩展完成:

  1. 头部为{ alg, kid }alg如 ES256K、EdDSA;kid为可解析到公钥的 DID URL),base64url 编码;
  2. payload 先经JCS 规范化(RFC 8785)再 base64url 编码——规范化规则包括:对象按键的 UTF-16 码元字典序排序、无空白、数字取最短表示、字符串最小转义;
  3. 签名输入为`${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_TYPESRECEIPT_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):

字段类型说明
versionnumberschema 版本,当前为1(源码常量EXTENSION_VERSION = 1
resourceUrlstring被付费的资源 URL
schemestring支付方案标识,如"exact"
networkstring链网络标识,CAIP-2 格式,如"eip155:8453"
assetstring代币合约地址或"native"
payTostring收款钱包地址
amountstring应付金额
validUntilnumber过期 Unix 时间戳(秒),由当前时间 + offerValiditySeconds得出

ReceiptPayload(必需字段:version、network、resourceUrl、payer、issuedAt):

字段类型说明
versionnumber当前为1
networkstringCAIP-2 网络标识
resourceUrlstring被付费的资源 URL
payerstring付款方标识(通常为钱包地址)
issuedAtnumber签发 Unix 时间戳(秒)
transactionstring链上交易哈希,可选(可验证性 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接口(kidsign(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)时工作;从结算结果读取payernetwork(缺失则警告并跳过),按声明的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 等)匹配此前捕获的 offer

README 特别指出:此方案不控制选择哪个accepts[]条目——选项由客户端选择器/策略独立决定,与签名 Offer 的匹配是事后进行的。

5.2 Raw Flow 与关键工具函数

需要完全掌控 Offer 选择时,使用原始流程。完整可运行实现见 examples/typescript/clients/offer-receipt/index.ts(250 行),演示五步:

  1. 发起请求,收到带签名 Offer 的 402;
  2. 提取并解码 Offer,检视支付选项;
  3. 选择一个 Offer,并找到匹配的accepts[]条目;
  4. 完成支付,收到签名 Receipt;
  5. 验证 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 字段提升到顶层并附带signedOfferformatacceptIndex;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 客户端工具的范围。生产环境中由下游信任系统完成两件事:

  1. 签名本身有效(EIP-712 或 JWS);
  2. 签名密钥对被资源域授权。

6.3 密钥到域的绑定方式

要建立信任,把签名密钥的 DID 绑定到资源域,README 给出三条路径:

  1. did:web DID 文档:在https://example.com/.well-known/did.json提供(与 did.ts 的 resolveDidWeb 实现一致);
  2. DNS TXT 记录:添加 TXT 记录把 DID 绑定到域;
  3. 密钥绑定 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.tsOffer/Receipt 类型定义、Signer 接口、声明配置与类型守卫
signing.tsJCS 规范化、JWS 构造、EIP-712 域与类型、Offer/Receipt 创建与验证
server.ts服务端扩展工厂(两个生命周期钩子)、Issuer 工厂、JSON Schema
client.ts客户端提取、解码、匹配与基础校验工具
did.tsdid: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),仅供参考

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

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

立即咨询