详解基于以太坊 Base L2 的 x402 支付协议如何通过收据幂等、支付超时处理和重复响应防护避免双重扣费,含失败表设计和具体代码模式。
一位读者在我之前的 x402 教程中提出了一系列问题,这些问题是每个基于 Agent 支付协议构建的工程师迟早都会遇到的:
"将每个支付凭证绑定到请求方法、路径、金额和一个短期过期时间,然后拒绝被重用的收据。一个用于支付后超时和重复响应后重试的小型失败表,可以帮助说明 Agent 如何避免重复扣款。"
这些不是边缘情况。它们是支付流程在演示中能用和你敢让自主 Agent 无人监管地使用之间的差别。让我们逐一讨论每个问题及其解决模式。
核心问题:Agent 支付是最终一致的
x402 支付分为三个阶段:
Request — 买方 Agent 发送 HTTP 请求,收到 402 Payment Required 以及支付详情
Pay — 买方提交链上 USDC(Base L2,gas 约 $0.003)
Deliver — 买方重新发送带 x402-receipt 头的请求,卖方验证链上确认后返回结果
每个阶段都可能独立失败。阶段 2 可能链上成功但阶段 3 永远不完成。阶段 3 可以用相同凭证重放。卖方可能在确认支付后、返回结果前崩溃。Agent 需要处理所有这些情况,且没有人工监控。
模式 1:凭证绑定 — 凭证应该证明什么?
x402 凭证标准绑定到:
{
"request_body_hash": "sha256(...)",
"timestamp": "2026-08-07T17:00:00Z",
"signature": "0x..."
}
这证明了:"有人在此时为这个确切的请求体付了款。"
但它没有证明这个支付是针对哪个端点的。来自 GET /search?q=cats 的凭证在技术上当两个请求体都为空时,对 GET /search?q=dogs 仍然有效——因为请求体哈希匹配。
修复方案:请求上下文绑定
在凭证中添加 request_context 扩展:
{
"request_body_hash": "sha256(...)",
"timestamp": "2026-08-07T17:00:00Z",
"signature": "0x...",
"extensions": {
"request_context": {
"method": "GET",
"path": "/search",
"query_params_hash": "sha256(q=cats)",
"amount_cents": 10
}
}
}
现在凭证绑定到了特定操作,而不仅仅是一个请求体。卖方可以拒绝与当前请求的方法、路径或金额不匹配的凭证。
实现(卖方中间件,Go):
func verifyReceiptContext(receipt x402.Receipt, r *http.Request, expectedAmount int) error {
ctx, ok := receipt.Extensions["request_context"]
if !ok {
return errors.New("missing request_context extension")
}
if ctx.Method != r.Method {
return fmt.Errorf("method mismatch: receipt=%s request=%s", ctx.Method, r.Method)
}
if ctx.Path != r.URL.Path {
return fmt.Errorf("path mismatch")
}
expectedQuery := sha256Hash(r.URL.RawQuery)
if ctx.QueryParamsHash != expectedQuery {
return fmt.Errorf("query params mismatch")
}
if ctx.AmountCents != expectedAmount {
return fmt.Errorf("amount mismatch")
}
return nil
}
模式 2:凭证重放保护 — 幂等性缓存
一旦凭证有效,攻击者(或有 Bug 的 Agent)可以重放它。没有保护的情况下,卖方会执行相同的付费操作两次,买方被扣款一次但得到两份结果。
修复方案:凭证 Nonce 缓存
卖方存储 sha256(receipt_signature),TTL 与凭证的过期窗口匹配:
type ReceiptCache struct {
mu sync.RWMutex
store map[string]CachedResult
}
type CachedResult struct {
Response []byte
StatusCode int
ExpiresAt time.Time
}
func (rc *ReceiptCache) CheckOrStore(sigHash string, ttl time.Duration) (*CachedResult, error) {
rc.mu.Lock()
defer rc.mu.Unlock()
if cached, exists := rc.store[sigHash]; exists {
if time.Now().Before(cached.ExpiresAt) {
return &cached, ErrDuplicateReceipt // 409 Conflict
}
delete(rc.store, sigHash) // expired
}
return nil, nil // new receipt, proceed
}
func (rc *ReceiptCache) Store(sigHash string, result CachedResult) {
rc.mu.Lock()
defer rc.mu.Unlock()
rc.store[sigHash] = result
}
关键洞察:重放的凭证返回 409 Conflict 以及原始响应。买方得到了他们付费的结果;卖方不会重新执行。没有重复扣款,没有重复执行。
模式 3:支付后超时 — 最难处理的情况
买方链上支付 ✅
买方发送带凭证的请求 → 卖方开始处理
网络超时 — 卖方的响应从未到达
买方不知道:到底成功了还是没成功?
修复方案:基于凭证的幂等重试
买方用相同凭证重试。卖方因为已经缓存了结果,从缓存中返回(模式 2 处理这个)。如果卖方从未完成处理(中途崩溃),凭证签名还不在缓存中,所以卖方会重新执行。
但这里有个微妙之处:副作用操作。如果端点发送了邮件或提交了交易,重新执行是危险的。
解决方案是一个显式的 idempotency_key:
type x402Request struct {
IdempotencyKey string `json:"idempotency_key,omitempty"`
Payload json.RawMessage `json:"payload"`
}
对于幂等操作(GET、纯计算):凭证签名就是幂等键。对于有副作用的操作(带外部影响的 POST/PUT):买方在支付流程前生成一个唯一的 idempotency_key,将其包含在请求体哈希中,卖方用它来去重。
模式 4:失败表
读者要求"一个用于支付后超时和重复响应后重试的小型失败表"。如下:
最后一行是链上验证问题,不是协议问题——卖方只接受那些有链上确认(证明支付已到达他们地址)的凭证。
实现:一个完整的卖方中间件
把以上所有内容整合起来,这是卖方侧的中间件:
func x402Middleware(next http.Handler) http.Handler {
cache := NewReceiptCache()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receiptHex := r.Header.Get("x402-receipt")
if receiptHex == "" {
// No receipt → return 402 with payment details
w.Header().Set("x402-payment", buildPaymentDetails(r))
http.Error(w, "Payment Required", 402)
return
}
receipt, err := verifyReceipt(receiptHex, r)
if err != nil {
http.Error(w, "Invalid receipt: "+err.Error(), 403)
return
}
sigHash := sha256Hash(receipt.Signature)
cached, err := cache.CheckOrStore(sigHash, 5*time.Minute)
if err == ErrDuplicateReceipt {
w.Header().Set("x402-idempotent", "true")
w.WriteHeader(cached.StatusCode)
w.Write(cached.Response)
return
}
// Execute the actual handler
rw := &responseRecorder{ResponseWriter: w}
next.ServeHTTP(rw, r)
// Cache the result
cache.Store(sigHash, CachedResult{
Response: rw.body,
StatusCode: rw.statusCode,
ExpiresAt: time.Now().Add(5 * time.Minute),
})
})
}
这对 Agent 自主性意味着什么
一个 Agent 现在可以可靠地处理支付:
1. POST /classify → 402 Payment Required
2. Pay $0.10 USDC on Base → tx confirmed
3. POST /classify + x402-receipt → 200 OK { "label": "spam" }
4. [Same POST + same receipt] → 409 Conflict { "label": "spam" } ← no double charge
5. [POST + receipt, network timeout] → retry with same receipt → 409 + cached result
没有人监控这个循环。没有重复扣款。没有丢单。Agent 只为它实际使用的部分付费。
这些模式是我们在线上 minia2a 运行的。凭证 nonce 缓存处理了约 387K 个请求,没有发生重复扣款事件。request_context 扩展在我们的规范愿望清单上——它是凭证绑定的下一步。如果你正在基于 x402 构建并遇到这些相同的问题,幂等性缓存是你能做的杠杆最高的改动。
感谢 Swapnoneel Saha 提出的问题促成了这篇文章。好的工程问题能带来更好的文档。