自研 → 库迁移:-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-rails和x402-fetch现在成熟了吗?如果成熟,帮我迁过去。
Claude 去看 README 和 changelog,回来汇报:v1 协议稳定、非 optimistic 模式能拿到 settlement 结果、facilitator 默认对 payai.network。可以迁。
迁完之后同样一个 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 调用。
JS 这边,手写版本里自己拼签名 / 调 window.ethereum.request。换成库之后改用 viem 和 x402-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 缓存),第二次瞬发。
手写版本是从另一个项目抄过来的参考实现。迁的时候让 Claude 顺手扫了一遍有没有积累下来的毛病,改了 3 个:
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 还会触发一个连接弹窗——如果用户之前没连过钱包,这就是授权入口。
旧代码:
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" })
旧代码里所有 "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 里的坑。
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 检查两头。
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 是在测一个本该用库的协议 / 外部接口。
不是所有「社区出了 gem」都要迁。让 Claude 先问这几件事:
0.x 的库 API 还会变;1.x 才值得锁。符合这 5 条之后,迁移的 prompt 一句话就够了:
「
x402-railsgem 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 行服务层测试——全没了。代码变少,行为没变,测试更精准。这就是成功的迁移。