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.
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.
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-railsdanx402-fetchsekarang 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.
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:
X-PAYMENT → gem merender 402 + PaymentRequirementsx402-fetch menandatangani otorisasi EIP-3009, retry dengan X-PAYMENT/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 hasilInisialisasi 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.
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.
Versi tulis tangan disalin dari implementasi referensi di proyek lain. Saat migrasi, saya minta Claude memindai akumulasi bau busuk. Keluar 3:
selectedAddressKode 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.
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" })
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.
Migrasi menabrak dua jebakan yang tidak ada urusan dengan protokol x402 dan tidak ada di README gem.
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.
0x... sebagai integerCredentials 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.
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.
Tidak semua "komunitas rilis gem" layak dimigrasi. Suruh Claude bertanya dulu:
0.x API masih berubah; 1.x waktu tepat untuk mengunci.Setelah 5 ini lolos, prompt migrasi cukup satu kalimat:
"Gem
x402-railsv1 sudah stabil. GantiPaymentHandler+FacilitatorClientsaat 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.
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.