跳到主要内容

04 — HTTP 传输与结算时序

本章是给"想把 x402 集成进自己服务"的人。讲两件事:抽象消息怎么塞进 HTTP header,以及中间件那套"先干活、成功才收钱"的精巧时序。

4.1 三个 header,承载三个结构

HTTP 是 Representation 层的一种。它的活很纯粹:把第 1 章那三个 JSON 结构 JSON.stringify 再 base64,塞进 header。编解码就四对函数,全在 core/src/http/index.ts

方向header 名装的结构编码函数
服务器 → 客户端(报价)PAYMENT-REQUIREDPaymentRequiredencodePaymentRequiredHeaderhttp/index.ts:40
客户端 → 服务器(付款)PAYMENT-SIGNATUREPaymentPayloadencodePaymentSignatureHeaderhttp/index.ts:17
服务器 → 客户端(回执)PAYMENT-RESPONSESettleResponseencodePaymentResponseHeaderhttp/index.ts:63
// core/src/http/index.ts:17-19 — 编码就是 stringify + base64
export function encodePaymentSignatureHeader(paymentPayload: PaymentPayload): string {
return safeBase64Encode(JSON.stringify(paymentPayload));
}

解码端会先用 Base64EncodedRegex 校验格式再解(decodePaymentSignatureHeader:27),防止畸形输入。

HTTP 状态码的两个约定:

  • 缺付款 → 402 Payment Required(终于名副其实地用上了这个码)。
  • 一个特例:报价 errorpermit2_allowance_required 时用 412 Precondition Failed,提示客户端"先去授权 Permit2 再重试"(x402HTTPResourceServer.createHTTPResponse:1067)。

v1 兼容: 服务端 extractPaymentPAYMENT-SIGNATUREx402HTTPResourceServer.ts:1023),而 Express 适配层同时认旧的 X-PAYMENT header(http/express/src/index.ts:142),客户端重试时也两个名字都查(http/fetch/src/index.ts:108)。

4.2 客户端:把 fetch 包成"自动付款 fetch"

wrapFetchWithPaymenthttp/fetch/src/index.ts:40)把第 1 章的两次往返封成对调用方透明的一次调用:

wrapFetchWithPayment 流程(http/fetch/src/index.ts:46)
① 正常发请求
② 不是 402?直接返回(绝大多数请求走这条,零开销)
③ 是 402:从 header(v2)/body(v1) 解出 PaymentRequired
④ client.createPaymentPayload() → 选报价 + 签名
⑤ 防无限循环:clonedRequest 已带付款 header? → 抛 "Payment already attempted"
⑥ 把 PAYMENT-SIGNATURE 加到请求,重试
⑦ 跑 payment-response hook;hook 说 recovered 就用新 payload 再试一次(仅一次)

第⑤步那个"已尝试过就抛错"是防呆关键——避免服务器一直回 402、客户端无限签名重试烧钱。第⑦步的恢复也严格限一次(注释 bounded to one recovery:131)。

4.3 服务器:"先干活、成功才收钱"的时序(最精巧的一段)

这是整个 HTTP 集成里最值得学的设计。问题是:verify 通过后,到底什么时候 settle?

天真的做法是"verify 完立刻 settle,再跑业务"。但那样会出事:万一业务 handler 崩了、或返回 500,钱已经扣了、东西却没给——客户端付了钱拿不到货。

x402 Express 中间件(http/express/src/index.tspayment-verified 分支,:202)的解法是把响应缓冲起来,等 handler 跑完、确认成功,才结算

Express 中间件 payment-verified 分支时序(http/express/src/index.ts:202)

verify 通过

├─ 劫持 res.write / res.end / res.writeHead (:235-267)
│ handler 写的东西先存进 bufferedCalls[],不发给客户端

├─ next() 跑业务 handler
│ ├─ handler 抛异常 → cancellationDispatcher.cancel("handler_threw") (:273)
│ │ 回放缓冲、不结算、把错误交给 next
│ └─ handler 正常 res.end() → 解析 endPromise

├─ res.statusCode >= 400? (:286)
│ 是 → cancel("handler_failed")、回放缓冲、不结算

├─ processSettlement() ← 只有 handler 成功才走到这 (:321)
│ ├─ settle 失败 → 丢弃缓冲、回 402(客户没拿到数据,也没真扣钱)
│ └─ settle 成功 → 给响应加 PAYMENT-RESPONSE header

└─ 回放 bufferedCalls[],把缓冲的业务响应真正发出去

这套"缓冲—判定—结算—回放"保证了 x402 的核心安全不变式:钱和货同生共死。handler 没成功,付款方的授权根本不会被提交上链(cancellationDispatcher 还会触发 onVerifiedPaymentCanceled hook 让业务方知道"这笔验过的付款被取消了")。

// http/express/src/index.ts:286-290(节选)—— 状态 >= 400 不结算
if (res.statusCode >= 400) {
await cancellationDispatcher.cancel({ reason: "handler_failed", responseStatus: res.statusCode });
res.removeHeader(SETTLEMENT_OVERRIDES_HEADER);
restoreResponseMethods();
// ... 回放缓冲、直接返回,跳过 settle
}

4.4 部分结算:handler 决定真实扣多少

上面缓冲的时候还顺手收集了响应 header。这服务于 upto(封顶额)方案的"按用量计费":handler 干完活后,通过 setSettlementOverrides(res, { amount }) 在响应头里写明"这次实际扣多少"(http/express/src/index.ts:26),中间件结算前读出来覆盖金额。

金额支持三种写法,由 resolveSettlementOverrideAmount 解析(core/src/server/x402ResourceServer.ts:207):

写法含义例子
原子单位直接是链上数量"1000"
百分比授权上限的百分比"50%""33.33%"
美元价按资产小数位换算"$0.05"

这正是 upto 方案规范(specs/schemes/upto/scheme_upto.md:52-55)说的 amount 字段"phase-dependent"语义:verify 时它是"授权上限",settle 时它是"实际扣款"。LLM 按 token 计费、带宽按流量计费就靠这个——先授权一个封顶额、用完再按实际结算

4.5 浏览器场景:paywall

同一个 402,如果请求方是浏览器(Accept: text/html 且 UA 含 MozillaisWebBrowser:1042),中间件不返回 JSON,而是返回一个 HTML 付款墙(paywall)——内嵌钱包连接 + 付款 UI,给人类用。没装 @x402/paywall 时退化成一个静态提示页(FALLBACK_PAYWALL_HTML:350)。

这个静态退化页有个安全考量值得一提(注释 :344-349):它不插入任何请求/配置派生的内容,避免把攻击者可控的字节反射进 HTML(XSS);真正的付款要求仍走 header + JSON body,任何 agent/SDK 都读得到。

下一章收口:把全套的巧妙之处、边界、和兄弟协议的对比、以及代码地图汇总。