自作→ gem 移行:純 -622/+317 行。コントローラがプロトコル配管の 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 に 1 行、config/initializers/x402.rb 29 行、Purchase/Subscription に record_x402! メソッドを 2 つ、対応する model テスト。
これはリファクタではない。自分で書いた部分を、誰かが書いた部分に差し替えただけだ。自作版は 2 週間稼働していた。買い切り、サブスク、tx_hash 記録、全部問題なし。じゃあなぜ移行したのか?
本稿では、この種の移行を Claude にやらせる方法と、移行する価値があるのはいつか、を書く。
x402 は HTTP 402 Payment Required プロトコル。クライアントが EIP-3009 authorization に署名し、サーバは facilitator を通じてオンチェーン取引を検証+決済する。
自作の 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、手動のチェーン切替、手動の X-PAYMENT ヘッダ組み立て。
自分が依存しているプロトコルのエコシステムを、週 1 で Claude に走査させるのはいい習慣だ——とくに x402 のように「出て間もないプロトコル」なら。x402-rails gem(Ruby 側)と x402-fetch(JS 側)の進化を Claude は追え、コミュニティが形になっていくのが見える。
そしてある日:
あなた:「
x402-railsとx402-fetch、今は成熟してる?もし成熟してるなら、移行を手伝って」
Claude は README と changelog を読んで報告してくる:v1 プロトコル安定、non-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 かエラーを render して 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:) 1 行ですべて処理:
X-PAYMENT ヘッダなし → gem が 402 + PaymentRequirements を renderx402-fetch が EIP-3009 authorization に署名し、X-PAYMENT を付けてリトライ/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"
# production → 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 を使う。
ただしこの 2 つのパッケージ、bundle で数百 KB。vendor する(npm の dist/ を vendor/javascript/ にコピー)とリポ体積が爆発する。対処:importmap + jsdelivr CDN + 遅延ロード:
# 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 の 1 回だけ待つ(CDN キャッシュあり)、2 回目以降は瞬発。
自作版は別のプロジェクトのリファレンス実装からコピーしてきたものだった。移行するついでに 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 }
// flow を通す独自コード
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }
自前のエラーを throw するときも code を貼る:
throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })
旧コードは「Connecting wallet...」などの文字列を全部 JS に焼き込んでいた。ERB から注入する data-value 属性に移動:
<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 は 1 文字も変えなくていい。
x402 プロトコルと直接関係はないが、gem の README にも書かれていない罠が 2 つあった。
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.rb に pin があるのを見て OK と思い、vendor/javascript/ に対応ファイルがあるかは自発的に確認しない。この種の診断では Claude に両端を確認させるといい。
0x... を整数と解釈する本番環境の credentials、素直に書くと:
x402:
wallet_address: 0xAbCd...
デプロイ後、x402 クリックのたびに 422、wallet_address が EVM アドレス正規表現に一致しないとのエラー。
YAML が 0xAbCd... を16 進整数として解析していた。Ruby 側で Rails.application.credentials.dig(:x402, :wallet_address) が返すのは Integer であって String ではない。あとで PaymentRequirements に入れる直前に .to_s すると 10 進数文字列になってしまう——有効なアドレスではない。
修正は文字 1 つ——引用符を付ける:
x402:
wallet_address: "0xAbCd..."
この罠を Claude は最初見抜けない。エラーメッセージから逆に辿って YAML のパース層まで降りる必要がある。1 回学習したら、次に 0x で始まる値を YAML に入れるときは反射的に引用符を付けるようにする。
移行後、テストファイル数は減っていないが、位置が変わる:
削除:
- test/services/x402/facilitator_client_test.rb(112 行)
- test/services/x402/payment_handler_test.rb(108 行)
追加:
- test/models/purchase_test.rb に record_x402! のテスト 40 行
- test/models/subscription_test.rb に record_x402! のテスト 69 行
サービス層(プロトコルがどう動くか)のテストが全部消えた。代わりに model 層(決済成功後にデータがどう記録されるか)のテストが生まれた。
これは妥当だ——プロトコルの挙動は gem 側の責任で、gem 自身がテストしている。あなたが測るのは自分で書いた部分だけ:settlement の結果を受け取ったあと、Purchase / Subscription の行をどう insert するか、tx_hash をどう保存するか。
これが「移行すべきか」の強いシグナルにもなる:テストに「自分が送る payload の形が正しいか」「facilitator が isValid=false を返したらどう処理するか」を検査する塊が大量にあるなら——それはプロトコルの挙動で、本来ライブラリに属すべきもの。test/services/ の下に 100 行超えの service テストがあるなら、だいたいその service は本来ライブラリ化すべきプロトコル / 外部インタフェースをテストしている。
「コミュニティが gem を出した」からといって何でも移行すべきではない。Claude に先に以下を問わせる:
0.x はまだ API が動く;1.x になってからロックする価値がある。この 5 つをクリアしたら、移行のプロンプトは 1 文で十分:
「
x402-railsgem v1 が安定した。今のPaymentHandler+FacilitatorClientを差し替えて。controller のエンドポイントとレスポンス形式は同じまま——プロトコルの仕事だけ gem に入れてほしい。テストはそれに応じて model 層に移して」
Claude がやる:gem ドキュメントを読む → initializer を書く → controller を書き換える → 旧 service を削除 → テストを再構築。途中 2、3 回確認を求めてくる(例:「この挙動は残す?」)。終わったら bin/rails test を走らせ、全緑ならコミット。
本当の洞察は「ライブラリが自作より良い」ではない。自作のほうが合う場面もある——プロトコルのカスタマイズ、レイテンシ重視、コンプライアンス。
本当の判断点は:
あなたの services/ フォルダにあるあのファイル、プロトコルが更新されるたびに書き換えなきゃいけないあの 1 ファイル——そのことを専門に保守する gem が、もう存在しているか?
存在するなら、それはあなたのビジネスロジックではない。あなたのプロジェクトで飼っている「プロトコル馴致済み」の野良猫だ。2 週間餌をやって動いている——でもあなたのものじゃない。Claude に頼んでコミュニティに返してもらおう。残すのはプロトコルの結果を自分の model に書き込む部分——そこがあなたのプロジェクト固有のロジックだ。
移行後、私の x402 ディレクトリに残るのは:29 行の initializer + 4 行の controller 呼び出し + record_x402! メソッド 2 つ。自作版の 139 行のサービス層と、それに付随した 220 行のサービス層テスト——全部消えた。コード減、挙動は同じ、テストは精緻。これが成功した移行だ。