免費

讓 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/)會讓 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 快取),第二次瞬發。

順手改掉舊實現裡的 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 標準: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 行服務層測試——全沒了。程式碼變少,行為沒變,測試更精準。這就是成功的遷移。