自研 → 函式庫遷移:-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/)會讓 repo 體積爆掉。解法: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 標準: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 行服務層測試——全沒了。程式碼變少,行為沒變,測試更精準。這就是成功的遷移。