Free

Menyuruh Claude Migrasikan Integrasi x402 Buatan Sendiri ke Gem Komunitas

Migrasi buatan-sendiri → gem: net -622/+317 baris. Controller turun dari 30 baris plumbing protokol ke 4. Jebakan: importmap diam-diam buang pin, YAML baca 0x... sebagai integer.


Diff satu commit:

19 files changed, 317 insertions(+), 622 deletions(-)

Dihapus:

app/services/x402/facilitator_client.rb        53 baris
app/services/x402/payment_handler.rb           86 baris
test/services/x402/facilitator_client_test.rb  112 baris
test/services/x402/payment_handler_test.rb     108 baris

Ditambah: satu baris di Gemfile, config/initializers/x402.rb (29 baris), dua metode record_x402! di Purchase/Subscription + tes model yang bersangkutan.

Ini bukan refactor — ini menukar bagian yang saya tulis dengan bagian yang ditulis orang lain. Versi buatan sendiri sudah jalan dua minggu. Bayar sekali, langganan, rekam tx_hash — semua lancar. Lalu kenapa migrasi?

Tulisan ini tentang cara membuat Claude melakukan migrasi jenis ini, dan kapan layak dilakukan.


Latar: seperti apa versi buatan sendiri

x402 adalah protokol HTTP 402 Payment Required. Klien menandatangani otorisasi EIP-3009, server memverifikasi + menyelesaikan transaksi on-chain lewat facilitator.

PaymentHandler buatan sendiri kira-kira begini:

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

Sekitar 30 baris plumbing protokol di dalam controller: decode tanda tangan, rakit requirements, verify, settle, tangani error. Alamat kontrak USDC hardcode di kode. Di frontend sama saja — window.ethereum.request tulis tangan, ganti chain manual, rakit header X-PAYMENT manual.

Pemicu: librari sudah matang

Membuat Claude memindai ekosistem protokol yang Anda pakai tiap minggu adalah kebiasaan bagus — terutama untuk protokol seperti x402 yang baru muncul. Claude bisa memantau gem x402-rails (Ruby) dan x402-fetch (JS) berevolusi, melihat komunitas terbentuk.

Sampai suatu hari:

Anda: "x402-rails dan x402-fetch sekarang sudah matang? Kalau iya, migrasikan."

Claude membaca README dan changelog, lapor: protokol v1 stabil, mode non-optimistic memberi hasil settlement, facilitator default di payai.network. Siap migrasi.

Pasca-migrasi: controller jadi 4 baris

Aksi subscribe yang sama pasca-migrasi:

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 merender 402 atau error, sudah 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

Bagian protokol seluruhnya di dalam gem. x402_paywall(amount:) menyelesaikan satu baris:

  • Request pertama tanpa header X-PAYMENT → gem merender 402 + PaymentRequirements
  • Klien x402-fetch menandatangani otorisasi EIP-3009, retry dengan X-PAYMENT
  • Gem memanggil /verify dan /settle facilitator (non-optimistic, menunggu settle selesai baru balik)
  • performed? mendeteksi gem sudah render dan kita return; kalau tidak, request.env["x402.settlement_result"] dan request.env["x402.payment"] memegang hasil

Inisialisasi di config/initializers/x402.rb (29 baris):

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 mainnet (USDC asli). dev/test → Base Sepolia (USDC testnet gratis)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # tunggu settle facilitator sebelum balik, supaya bisa rekam tx_hash sinkron
end

Itulah inti pergerakan "buatan sendiri → librari": 139 baris services + 220 baris tes services tulis tangan, ditukar jadi initializer 29 baris + panggilan controller 4 baris.

Frontend: viem + x402-fetch, tapi jangan vendor

Di JS, versi tulis tangan merakit tanda tangan sendiri dan memanggil window.ethereum.request langsung. Pasca-migrasi: viem dan x402-fetch.

Hanya saja dua paket ini bundle-nya ratusan KB. Vendor (salin dist/ npm ke vendor/javascript/) bikin ukuran repo meledak. Solusi: importmap + CDN jsdelivr + 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 kuncinya: tidak masuk <link rel="modulepreload"> first paint, jadi mayoritas halaman tidak mengunduh.

Di controller Stimulus, muat hanya saat klik pertama 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
}

Pengguna tanpa dompet tidak pernah memuat 300+ KB itu. Pengguna dengan MetaMask yang klik "bayar" menunggu sekali di jsdelivr (cache CDN), klik berikutnya instan.

Sekalian memperbaiki 3 masalah dari implementasi lama

Versi tulis tangan disalin dari implementasi referensi di proyek lain. Saat migrasi, saya minta Claude memindai akumulasi bau busuk. Keluar 3:

1. Jangan pakai selectedAddress

Kode lama:
js
const address = window.ethereum.selectedAddress

selectedAddress deprecated di MetaMask baru. Cara benarnya:

const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]

eth_requestAccounts juga memunculkan dialog koneksi — kalau pengguna belum pernah menyambungkan dompet ke situs ini, di sinilah pintu otorisasinya.

2. Jangan cocokkan error dengan string

Lama:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }

Cocokkan string pasti jebol saat dompet berikutnya mengubah copy. Ganti ke code bertipe:

// Standar EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// code sendiri yang menembus flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Saat throw error sendiri, sisipkan code juga:

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

3. String UI lewat i18n, bukan Inggris hardcode

Kode lama menyisipkan "Connecting wallet..." dan string lain ke JS. Pindah ke atribut data-value yang di-inject dari 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 baca this.labelConnectingValue. 19 bahasa bisa diterjemah independen. JS tidak berubah sehuruf pun.

Dua jebakan nyata

Migrasi menabrak dua jebakan yang tidak ada urusan dengan protokol x402 dan tidak ada di README gem.

Jebakan 1: importmap diam-diam membuang pin tanpa file vendor

Gem x402-rails menyertakan beberapa Stimulus controller sendiri. Setelah install gem, klik tombol bayar, browser muntah:

Uncaught Error: no Stimulus controller registered for "x402-pay"

Gali. importmap.rb jelas-jelas punya:

pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2

Tapi vendor/javascript/@hotwired--stimulus.js tidak ada. importmap tidak error dalam kondisi ini — langsung buang pin itu diam-diam. Akibatnya controller gem tidak menemukan Stimulus, gagal register, dan seluruh controller berikutnya mati.

Perbaikan: sediakan file vendornya:

./bin/importmap pin @hotwired/stimulus

Perintah ini mengunduh paket npm ke vendor/javascript/. Kegagalan diam ini khas yang Claude kelewatan — ia melihat pin di importmap.rb dan menganggap OK, tidak otomatis mengecek apakah file di vendor/javascript/ benar-benar ada. Lain kali diagnosis jenis ini, suruh Claude cek dua sisi.

Jebakan 2: credentials.yml mem-parse 0x... sebagai integer

Credentials produksi, ditulis polos:

x402:
  wallet_address: 0xAbCd...

Setelah deploy, tiap klik x402 balik 422, error bahwa wallet_address tidak cocok regex alamat EVM.

YAML mem-parse 0xAbCd... sebagai integer heksadesimal. Di Ruby, Rails.application.credentials.dig(:x402, :wallet_address) mengembalikan Integer, bukan String. .to_s berikutnya sebelum masuk PaymentRequirements jadi angka desimal — bukan alamat valid.

Perbaikan satu karakter — tambah tanda kutip:

x402:
  wallet_address: "0xAbCd..."

Jebakan semacam ini Claude awalnya tidak nangkap; Anda harus mundur dari pesan error ke lapisan parsing YAML. Sekali belajar, berikutnya refleks tambah tanda kutip setiap nilai berawalan 0x di YAML.

Bentuk tes berubah (ini sinyal terpenting)

Pasca-migrasi jumlah file tes tidak berkurang, tapi letaknya pindah:

Dihapus:
- test/services/x402/facilitator_client_test.rb (112 baris)
- test/services/x402/payment_handler_test.rb (108 baris)

Ditambah:
- test/models/purchase_test.rb bertambah 40 baris tes record_x402!
- test/models/subscription_test.rb bertambah 69 baris tes record_x402!

Tes lapisan services (bagaimana protokol berjalan) — semua hilang. Digantikan tes lapisan model (bagaimana data dicatat setelah pembayaran sukses).

Masuk akal — perilaku protokol tanggung jawab gem, yang menguji dirinya sendiri. Anda hanya perlu menguji bagian yang Anda tulis: bagaimana baris Purchase / Subscription ter-insert setelah hasil settlement datang, dan bagaimana tx_hash tersimpan.

Ini juga sinyal kuat "haruskah saya migrasi?": kalau tes Anda punya bongkah besar yang menguji "payload yang saya kirim formatnya benar" atau "saat facilitator balas isValid=false, saya tangani begini" — itu perilaku protokol, seharusnya milik librari. Kalau ada file tes di bawah test/services/ yang lewat 100 baris, besar kemungkinan service itu sedang menguji protokol / antarmuka eksternal yang seharusnya jadi librari.

Kapan menyuruh Claude melakukan migrasi jenis ini

Tidak semua "komunitas rilis gem" layak dimigrasi. Suruh Claude bertanya dulu:

  1. Versi librari. Librari 0.x API masih berubah; 1.x waktu tepat untuk mengunci.
  2. Delta kode ≥ 200 baris. Saya kali ini netto -305 baris. Di bawah 100 baris, switching cost tidak sepadan.
  3. Konsolidasi tes nyata. Kalau pasca-migrasi tes Anda masih menguji 90% hal yang sama dengan set stub baru — perilakunya tidak pindah ke librari, cuma nama API yang berubah. Jangan migrasi.
  4. Konfigurasi terkonsolidasi. Di versi tangan, alamat kontrak USDC, nama network, URL facilitator tersebar di 3 tempat. Pasca-migrasi semua di initializer 29 baris. Ini nilai.
  5. Jalur upgrade jelas. Bagaimana librari naik versi ke depan? Ada konvensi changelog untuk breaking change? Kalau tidak, bungkus dengan adapter sendiri supaya gem tidak merembes ke 50 call site.

Setelah 5 ini lolos, prompt migrasi cukup satu kalimat:

"Gem x402-rails v1 sudah stabil. Ganti PaymentHandler + FacilitatorClient saat ini. Pertahankan endpoint dan shape respons yang sama — saya cuma mau kerja protokol masuk ke gem. Pindahkan tes ke lapisan model sesuai itu."

Claude akan: baca dokumen gem → tulis initializer → tulis ulang controller → hapus service lama → rebuild tes. Di tengah akan minta konfirmasi dua tiga kali (mis. "perilaku ini dipertahankan?"). Selesai, jalankan bin/rails test, semua hijau, commit.

Pelajaran

Wawasan sebenarnya bukan "librari mengalahkan buatan sendiri". Kadang buatan sendiri tepat — kustomisasi protokol, latensi sensitif, kepatuhan.

Titik keputusan sebenarnya:

File di folder services/ Anda — yang harus diubah tiap protokol di-update — apakah sudah ada gem yang mengurus hal itu secara khusus?

Kalau ya, itu bukan logika bisnis Anda. Itu kucing jalanan "terjinakkan protokol" yang Anda adopsi di proyek. Dikasih makan dua minggu dan lancar — tapi bukan milik Anda. Suruh Claude kembalikan ke komunitas. Yang Anda simpan adalah menulis hasil protokol ke model Anda — bagian itu spesifik proyek Anda.

Pasca-migrasi, direktori x402 saya hanya berisi: initializer 29 baris + panggilan controller 4 baris + dua metode record_x402!. 139 baris layanan tulis tangan, dan 220 baris tes layanan yang menyertai — semua hilang. Kode lebih sedikit. Perilaku sama. Tes lebih ketat. Itu migrasi yang sukses.