Free

Claude に自作 x402 実装をコミュニティ Gem に移行させる

自作→ 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/Subscriptionrecord_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-railsx402-fetch、今は成熟してる?もし成熟してるなら、移行を手伝って」

Claude は README と changelog を読んで報告してくる:v1 プロトコル安定、non-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 かエラーを 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 を render
  • クライアント x402-fetch が EIP-3009 authorization に署名し、X-PAYMENT を付けてリトライ
  • 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"

  # 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 呼び出しに置き換える。

フロント:viem + x402-fetch、でもvendor しない

JS 側は、自作版では自分で署名を組み立てて window.ethereum.request を直接叩いていた。ライブラリに切り替えて viemx402-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 回目以降は瞬発。

旧実装の 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 }
// flow を通す独自コード
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

自前のエラーを throw するときも code を貼る:

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

3. UI 文字列は i18n、英語を埋め込まない

旧コードは「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 文字も変えなくていい。

現場で踏んだ 2 つの罠

x402 プロトコルと直接関係はないが、gem の README にも書かれていない罠が 2 つあった。

罠 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.rb に pin があるのを見て OK と思い、vendor/javascript/ に対応ファイルがあるかは自発的に確認しない。この種の診断では Claude に両端を確認させるといい。

罠 2:credentials.yml が 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.rbrecord_x402! のテスト 40 行
- test/models/subscription_test.rbrecord_x402! のテスト 69 行

サービス層(プロトコルがどう動くか)のテストが全部消えた。代わりに model 層(決済成功後にデータがどう記録されるか)のテストが生まれた。

これは妥当だ——プロトコルの挙動は gem 側の責任で、gem 自身がテストしている。あなたが測るのは自分で書いた部分だけ:settlement の結果を受け取ったあと、Purchase / Subscription の行をどう insert するか、tx_hash をどう保存するか。

これが「移行すべきか」の強いシグナルにもなる:テストに「自分が送る payload の形が正しいか」「facilitator が isValid=false を返したらどう処理するか」を検査する塊が大量にあるなら——それはプロトコルの挙動で、本来ライブラリに属すべきもの。test/services/ の下に 100 行超えの service テストがあるなら、だいたいその service は本来ライブラリ化すべきプロトコル / 外部インタフェースをテストしている。

Claude にこの種の移行をやらせるのはいつか

「コミュニティが gem を出した」からといって何でも移行すべきではない。Claude に先に以下を問わせる:

  1. ライブラリのバージョン0.x はまだ API が動く;1.x になってからロックする価値がある。
  2. コード削減 ≥ 200 行。私の今回は純 -305 行。純削減 < 100 行なら switching cost が合わない。
  3. テストの統合が実体を伴う。移行後もテストが 90% 同じことを別の stub で主張しているなら——挙動はライブラリに移らず、API 名が変わっただけ。移行するな。
  4. 設定が一本化する。自作版では USDC コントラクトアドレス、ネットワーク名、facilitator URL が 3 箇所に散らばっていた。移行後は全部 29 行の initializer に集約。これが価値。
  5. アップグレード経路が明確。ライブラリは今後どう上げる?breaking change の changelog 規約はあるか?なければ自分で adapter を 1 枚噛ませ、gem が 50 箇所の呼び出し元に滲み出ないようにする。

この 5 つをクリアしたら、移行のプロンプトは 1 文で十分:

x402-rails gem 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 行のサービス層テスト——全部消えた。コード減、挙動は同じ、テストは精緻。これが成功した移行だ。