免费

让 Claude 把手写 x402 实现迁到社区库

自研 → 库迁移:-622/+317 净行。controller 从 30 行协议活儿变 4 行。真实坑:importmap 静默丢 pin、YAML 把 0x 当整数。


一次提交的 diff:

19 files changed, 317 insertions(+), 622 deletions(-)

删掉的:

app/services/x402/facilitator_client.rb    53 行
app/services/x402/payment_handler.rb       86 行
test/services/x402/facilitator_client_test.rb   112 行
test/services/x402/payment_handler_test.rb      108 行

加上来的:Gemfile 一行 gem、config/initializers/x402.rb 29 行、Purchase/Subscription 两个 record_x402! 方法 + 对应 model 测试。

这不是重构,是把自己写的那部分换成别人写好的那部分。手写版本能跑了两个星期,商业付款、订阅、tx_hash 记录都正常。那为什么迁?

本文讲怎么让 Claude 做这种迁移,以及何时该让它做。


背景:当时的手写实现

x402 是一个 HTTP 402 Payment Required 协议,客户端签一个 EIP-3009 授权,服务端通过 facilitator 验证 + settle 一笔链上交易。

手写版本的 PaymentHandler 大概这样:

handler = X402::PaymentHandler.new
payment_payload = handler.decode_payment_signature(params[:payment_signature])
requirements = {
  scheme: "exact",
  network: X402::PaymentHandler::NETWORK.call,
  maxAmountRequired: (plan.price_cents * 10_000).to_s,
  payTo: X402::PaymentHandler::WALLET_ADDRESS.call,
  token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  description: "#{plan.key} subscription"
}

verify_result = handler.facilitator.verify(payment_payload, requirements)
unless verify_result["isValid"]
  render json: { error: verify_result["invalidReason"] || "Verification failed" }, status: :unprocessable_entity and return
end

settle_result = handler.facilitator.settle(payment_payload, requirements)
unless settle_result["success"]
  render json: { error: settle_result["errorReason"] || "Settlement failed" }, status: :unprocessable_entity and return
end

大约 30 行在 controller 里做协议活儿:解码签名、构造 requirements、verify、settle、错误处理。USDC 合约地址硬编码在代码里。前端那边也一样——自己写 window.ethereum.request、自己处理 chain switch、自己拼 X-PAYMENT header。

触发:库成熟了

让 Claude 每周扫一遍用到的协议的生态是一个好习惯——尤其是 x402 这种「刚出来没多久的协议」。Claude 能看到 x402-rails gem(Ruby 端)和 x402-fetch(JS 端)的变化,社区开始成型。

直到某次:

你:x402-railsx402-fetch 现在成熟了吗?如果成熟,帮我迁过去。

Claude 去看 README 和 changelog,回来汇报:v1 协议稳定、非 optimistic 模式能拿到 settlement 结果、facilitator 默认对 payai.network。可以迁。

迁移后:controller 变 4 行

迁完之后同样一个 subscribe 动作:

def subscribe
  plan = Plan.find(params[:plan])

  if Current.user.subscriptions.active.exists?(plan: plan.key)
    render json: { success: true, plan: plan.key, already_active: true }
    return
  end

  x402_paywall(amount: plan.price_dollars)
  return if performed? # gem 渲染了 402 或错误,已经 halt

  settlement = request.env["x402.settlement_result"]
  payment    = request.env["x402.payment"]
  return render_failure("settlement failed") unless settlement&.success?

  Subscription.record_x402!(user: Current.user, plan: plan, payment: payment, settlement: settlement)
end

协议部分全进了 gem。x402_paywall(amount:) 一行搞定:
- 第一次请求没带 X-PAYMENT header → gem 渲染 402 + PaymentRequirements
- 客户端 x402-fetch 签一个 EIP-3009 授权重试 → 带 X-PAYMENT header 再打进来
- Gem 调 facilitator /verify/settle(non-optimistic,先 settle 完再返回)
- performed? 检测到 gem 已经 render 了就 return;否则 request.env["x402.settlement_result"]request.env["x402.payment"] 就是这次交易的结果

初始化配置在 config/initializers/x402.rb(29 行):

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator    = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                          "https://facilitator.payai.network"

  # 生产 → Base 主网(真 USDC)。dev/test → Base Sepolia(免费测试网 USDC)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # 等 facilitator settle 完再返回,这样能同步记录 tx_hash
end

这就是「自研切库」的核心动作:手写的 139 行 services + 220 行 services 测试,换成29 行 initializer + 4 行 controller 调用

前端:viem + x402-fetch,但不 vendor

JS 这边,手写版本里自己拼签名 / 调 window.ethereum.request。换成库之后改用 viemx402-fetch

但这两个库 bundle 在一起好几百 KB,如果 vendor 下来(把 npm 包的 dist 目录复制进 vendor/javascript/)会让仓库体积炸掉。解法:importmap + jsdelivr CDN + lazy load

# config/importmap.rb
pin "viem",        to: "https://cdn.jsdelivr.net/npm/viem/+esm",        preload: false
pin "viem/chains", to: "https://cdn.jsdelivr.net/npm/viem/chains/+esm", preload: false
pin "x402-fetch",  to: "https://cdn.jsdelivr.net/npm/x402-fetch/+esm",  preload: false

preload: false 很关键:这些包不进首屏 <link rel="modulepreload">,大部分页面根本不会下载。

Stimulus controller 里首次 pay 点击才触发加载:

async loadDeps() {
  if (this._deps) return this._deps
  const [{ wrapFetchWithPayment }, { createWalletClient, custom }, { base, baseSepolia }] =
    await Promise.all([
      import("x402-fetch"),
      import("viem"),
      import("viem/chains")
    ])
  this._deps = { wrapFetchWithPayment, createWalletClient, custom, base, baseSepolia }
  return this._deps
}

没钱包的用户永远不加载这 300+ KB。装了 MetaMask 又点了「付款」的用户,首次等一次下载(jsdelivr 有 CDN 缓存),第二次瞬发。

顺手改掉旧实现里的 3 个问题

手写版本是从另一个项目抄过来的参考实现。迁的时候让 Claude 顺手扫了一遍有没有积累下来的毛病,改了 3 个:

1. 别再用 selectedAddress

旧代码:
js
const address = window.ethereum.selectedAddress

selectedAddress 在新版 MetaMask 已经 deprecated。正确做法:

const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]

eth_requestAccounts 还会触发一个连接弹窗——如果用户之前没连过钱包,这就是授权入口。

2. 错误别靠字符串匹配

旧代码:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }

字符串匹配永远会被钱包实现的下一次改文案打穿。改成 typed code:

// EIP-1193 standard: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// 自定义 code 穿透 flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

抛自己的错误时也手动贴 code:

throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })

3. UI 字符串走 i18n,不硬编码英文

旧代码里所有 "Connecting wallet..." 之类的文案直接写在 JS 里。改成 data-value 属性从 ERB 注入:

<button data-controller="x402-payment"
        data-x402-payment-label-connecting-value="<%= t('paywall.x402.connecting') %>"
        data-x402-payment-label-signing-value="<%= t('paywall.x402.signing') %>"
        data-x402-payment-error-rejected-value="<%= t('paywall.x402.error.rejected') %>"
        ...>
  <%= t('paywall.x402.pay_button') %>
</button>

JS 里走 this.labelConnectingValue。19 种语言都能独立翻译,JS 一行字都不用动。

两个真实的坑

迁移过程中踩到两个跟 x402 协议无关、但没人写到 gem README 里的坑。

坑 1:importmap 会静默丢弃没 vendor 文件的 pin

x402-rails gem 带了几个自己的 Stimulus controller。装 gem 之后点付款按钮,浏览器报:

Uncaught Error: no Stimulus controller registered for "x402-pay"

查半天。importmap.rb 里明明有:

pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2

vendor/javascript/@hotwired--stimulus.js 不存在。importmap 对这种情况不报错——就直接把这条 pin 当没有。结果 gem 里的 controller 找不到 Stimulus 就注册失败,后面所有 controller 都挂了。

修复:把 vendor 文件补上:

./bin/importmap pin @hotwired/stimulus

这条会下载 npm 包到 vendor/javascript/。这种静默失败很典型,是 Claude 容易漏的——它看见 importmap 里的 pin 就以为 OK 了,不会主动去 vendor/javascript/ 里看对应文件是否真的存在。下次做这类诊断,让 Claude 检查两头

坑 2:credentials.yml 把 0x... 当整数

生产环境凭证写法:

x402:
  wallet_address: 0xAbCd...

部署上去之后每次 x402 点击都 422,错误信息说 wallet_address 不匹配 EVM 地址正则。

YAML 把 0xAbCd...十六进制整数解析了。Ruby 这边 Rails.application.credentials.dig(:x402, :wallet_address) 拿到的是 Integer,不是 String。后面写到 PaymentRequirements 再 .to_s 就变成了十进制数字——完全不是有效的地址。

修复是一个字符——加引号:

x402:
  wallet_address: "0xAbCd..."

这种坑 Claude 一开始看不出来,得从错误信息反推到 YAML parsing 层才定位得到。学会一次之后下次看到 0x 开头的值塞进 YAML 就先加引号。

测试形状变了(这是最重要的信号)

迁完之后测试文件数量没少,但位置换了

删掉
- test/services/x402/facilitator_client_test.rb(112 行)
- test/services/x402/payment_handler_test.rb(108 行)

加上
- test/models/purchase_test.rb 里新加 40 行测 record_x402!
- test/models/subscription_test.rb 里新加 69 行测 record_x402!

服务层(协议怎么运行)的测试全删了,改为测试 model 层(付款成功之后数据怎么记录)。

这很合理——协议行为归 gem 负责,gem 自己测过了。你只要测自己写的那部分:拿到 settlement 结果之后怎么 insert Purchase / Subscription 记录,tx_hash 怎么存。

这也是「该不该迁」的硬信号:如果你的测试里有大块是测「我发出去的 payload 格式对不对」「facilitator 返回 isValid=false 时我怎么处理」——这些是协议行为,本就该属于库。如果 test/services/ 下某个 service 测试文件超过 100 行,大概率表明这个 service 是在测一个本该用库的协议 / 外部接口。

何时让 Claude 做这种迁移

不是所有「社区出了 gem」都要迁。让 Claude 先问这几件事:

  1. 库的版本号0.x 的库 API 还会变;1.x 才值得锁。
  2. 代码减量 ≥ 200 行。我这次是 -305 净行。如果净减量 < 100 行,switching cost 不值。
  3. 测试能大幅归并。如果迁完之后你的测试 90% 还在测同样的东西,只是换了一套 stub——说明行为没搬进库,只是 API 改了个名,别迁。
  4. 配置能归一。手写版本里 USDC 合约地址、network name、facilitator URL 散在 3 个地方。迁之后全进 initializer 29 行。这是价值。
  5. 升级策略清楚。库后续怎么升?有没有 breaking change 的 changelog 规则?没有的话自己包一层 adapter,别让 gem 渗透到 50 个 call site。

符合这 5 条之后,迁移的 prompt 一句话就够了:

x402-rails gem v1 稳定了。把现在的 PaymentHandler + FacilitatorClient 换掉。控制器保持一样的 endpoint 和返回格式——我只要协议活儿进 gem。测试相应搬到 model 层。」

Claude 就会做:读 gem 文档 → 写 initializer → 改 controller → 删旧 service → 重建测试。中途会问你两三次确认(比如「这个行为要不要保留」)。做完跑一遍 bin/rails test,全绿就 commit。

收获

真正的洞察不是「用库比自研好」——有时候自研更合适(协议定制、延迟敏感、合规)。真正的判断点是:

你的 services/ 目录里那个文件,每次协议更新都要改的那个——它是不是已经有一个 gem 专门维护这件事?

如果是,那它就不是你的业务逻辑,只是你项目里养的一只「协议驯化」的流浪猫。喂了两个星期能跑起来,但不是你的。让 Claude 把它放回社区,你留下的只是把协议结果写进你的 model 这一段——那才是你项目特有的逻辑。

迁完之后我的 x402 目录里只剩:29 行 initializer + 4 行 controller 调用 + 两个 record_x402! 方法。手写版本的 139 行服务层,以及它带来的 220 行服务层测试——全没了。代码变少,行为没变,测试更精准。这就是成功的迁移。