Free

Claude'a Elle Yazılmış Bir x402 Entegrasyonunu Topluluk Gem'ine Taşıtmak

El yazımı → gem geçişi: net -622/+317 satır. Controller 30 satır protokol işinden 4 satıra düşer. Tuzaklar: importmap pin'i sessizce düşürür, YAML 0x...'i tamsayı okur.


Tek bir commit'in diff'i:

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

Silinen:

app/services/x402/facilitator_client.rb        53 satır
app/services/x402/payment_handler.rb           86 satır
test/services/x402/facilitator_client_test.rb  112 satır
test/services/x402/payment_handler_test.rb     108 satır

Eklenen: Gemfile içinde bir satır, config/initializers/x402.rb (29 satır), Purchase/Subscription üzerinde iki record_x402! metodu + karşılık gelen model testleri.

Bu bir refactor değil — benim yazdığım parçayı bir başkasının yazdığı parçayla değiştirmek. Elle yazılmış sürüm iki haftadır çalışıyordu. Tek seferlik ödeme, abonelik, tx_hash kaydı — hepsi çalışıyor. Peki neden taşınalım?

Bu yazı, Claude'a bu tür bir taşımayı nasıl yaptıracağınızı ve ne zaman değdiğini anlatıyor.


Arka plan: elle yazılmış sürüm nasıl görünüyordu

x402, bir HTTP 402 Payment Required protokolüdür. İstemci bir EIP-3009 yetkilendirmesi imzalar, sunucu bir facilitator üzerinden on-chain bir işlemi doğrulayıp yerleştirir.

Elle yazılmış PaymentHandler kabaca şöyleydi:

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

Controller içinde yaklaşık 30 satır protokol işi: imzayı çöz, requirements'ı kur, verify, settle, hataları yönet. USDC kontrat adresi kod içinde sabit. Frontend de aynı — elle yazılmış window.ethereum.request, manuel chain switch, manuel X-PAYMENT header kurulumu.

Tetikleyici: kütüphaneler olgunlaştı

Bağımlı olduğunuz protokollerin ekosistemini haftalık olarak Claude'a taratmak iyi bir alışkanlıktır — özellikle x402 gibi kısa süre önce çıkmış bir protokol için. Claude, x402-rails gem'ini (Ruby tarafı) ve x402-fetch'i (JS tarafı) takip edebilir, topluluğun şekillendiğini görebilir.

Sonra bir gün:

Siz: "x402-rails ve x402-fetch artık olgun mu? Öyleyse taşı."

Claude README'leri ve changelog'ları okur, rapor eder: v1 protokolü stabil, non-optimistic modu settlement sonucunu verir, facilitator varsayılanı payai.network. Taşıma uygun.

Taşıma sonrası: controller 4 satır olur

Taşıma sonrası aynı subscribe eylemi:

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 veya hata render etti, halt edildi

  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

Protokol kısmı tamamen gem içinde. x402_paywall(amount:) tek satırda halleder:

  • X-PAYMENT header'ı olmayan ilk istek → gem 402 + PaymentRequirements render eder
  • İstemci x402-fetch EIP-3009 yetkilendirmesi imzalar, X-PAYMENT ile tekrar dener
  • Gem, facilitator'ın /verify ve /settle uçlarını çağırır (non-optimistic, settle bitene dek bekler)
  • performed? gem'in zaten render ettiğini algılar ve return ederiz; değilse request.env["x402.settlement_result"] ve request.env["x402.payment"] bu işlemin sonuçlarını tutar

config/initializers/x402.rb içinde başlangıç (29 satır):

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 (gerçek USDC). dev/test → Base Sepolia (ücretsiz testnet USDC)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # facilitator settle bitene dek bekle, böylece tx_hash senkron kaydedilsin
end

İşte "el yazması → kütüphane" hareketinin özü: 139 satır el yazması services + 220 satır services testi, 29 satırlık initializer + 4 satırlık controller çağrısı ile değişiyor.

Frontend: viem + x402-fetch, ama vendor'lamayın

JS tarafında el yazması sürüm imzayı elle kuruyor ve window.ethereum.request'i doğrudan çağırıyordu. Taşımadan sonra: viem ve x402-fetch.

Ancak bu iki paket bundle halinde yüzlerce KB. Vendor'lama (npm'nin dist/'ini vendor/javascript/'e kopyalamak) repo boyutunu patlatır. Çözüm: importmap + jsdelivr CDN + tembel yükleme:

# 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 kilit: first paint'in <link rel="modulepreload">'ine girmezler, bu yüzden çoğu sayfa zaten indirmez.

Stimulus controller'ında, ilk pay tıklamasında yükle:

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
}

Cüzdanı olmayan kullanıcılar o 300+ KB'yi asla yüklemez. MetaMask'ı olup "öde"ye basan kullanıcılar jsdelivr'de bir kez bekler (CDN önbellekli), sonrakiler anında.

Bu arada eski kodun 3 sorununu da düzeltmek

El yazması sürüm başka bir projedeki referans uygulamadan kopyalanmıştı. Taşıma sırasında Claude'a birikmiş kokuya bakmasını söyledim. 3 şey çıktı:

1. Artık selectedAddress kullanmayın

Eski kod:
js
const address = window.ethereum.selectedAddress

Yeni MetaMask'ta selectedAddress deprecated. Doğru yol:

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

eth_requestAccounts bağlantı penceresi de tetikler — kullanıcı bu siteye cüzdanını daha önce bağlamadıysa burası yetkilendirme girişi.

2. Hataları string ile eşleştirmeyin

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

String eşleme, bir sonraki cüzdan implementasyonunun kopya değişikliğinde hep kırılır. Tipli kodlara geçin:

// EIP-1193 standardı: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// flow'dan geçen özel kodlar
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Kendi hatalarınızı fırlatırken code da ekleyin:

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

3. UI string'leri i18n üzerinden, hardcoded İngilizce değil

Eski kodda "Connecting wallet..." ve diğer tüm string'ler JS'e gömülüydü. ERB'den enjekte edilen data-value attribütlerine taşıdım:

<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'yu okur. 19 dil bağımsız çevrilebilir. JS'te tek harf değişmez.

İki gerçek tuzak

Taşıma, x402 protokolüyle hiç ilgisi olmayan ve gem README'sinde yer almayan iki tuzağa çarptı.

Tuzak 1: importmap, vendor dosyası olmayan pin'i sessizce düşürür

x402-rails gem'i kendi Stimulus controller'larından birkaçını getirir. Gem'i kurduktan sonra ödeme butonuna basmak şunu fırlattı:

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

Kazdım. importmap.rb'de açıkça vardı:

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

Ama vendor/javascript/@hotwired--stimulus.js yoktu. importmap bu durumda hata vermiyor — o pin'i sessizce düşürüyor. Sonuç: gem'in controller'ı Stimulus'u bulamıyor, kaydolamıyor ve sonraki tüm controller'lar ölüyor.

Düzeltme: vendor dosyasını koyun:

./bin/importmap pin @hotwired/stimulus

Bu komut npm paketini vendor/javascript/'e indirir. Bu tür sessiz başarısızlıklar Claude'un kaçırmaya yatkın olduğu klasiktir — importmap.rb'de pin'i görünce OK sanır, vendor/javascript/ içinde karşılığın gerçekten olup olmadığını kendiliğinden kontrol etmez. Bir dahaki bu tür tanıda Claude'a iki ucu da kontrol ettirin.

Tuzak 2: credentials.yml 0x...'i tamsayı olarak parse eder

Üretim credentials, sade yazılmış:

x402:
  wallet_address: 0xAbCd...

Deploy'dan sonra her x402 tıklaması 422, wallet_address EVM adres regex'iyle eşleşmiyor hatasıyla.

YAML 0xAbCd...'i onaltılık tamsayı olarak parse etti. Ruby tarafında Rails.application.credentials.dig(:x402, :wallet_address) bir Integer döndürdü, String değil. Sonraki .to_s, PaymentRequirements'a girmeden önce ondalık sayı dizisine dönüştü — artık geçerli bir adres değil.

Düzeltme tek karakter — tırnak ekleyin:

x402:
  wallet_address: "0xAbCd..."

Bu tür tuzakları Claude başta yakalamaz; hata mesajından geriye gidip YAML parse katmanına inmeniz gerekir. Bir kez öğrendikten sonra YAML'da 0x ile başlayan değere refleksle tırnak koyun.

Test şekli değişir (en önemli sinyal bu)

Taşıma sonrası test dosyası sayısı azalmaz ama konum değişir:

Silinen:
- test/services/x402/facilitator_client_test.rb (112 satır)
- test/services/x402/payment_handler_test.rb (108 satır)

Eklenen:
- test/models/purchase_test.rb record_x402! testi için 40 satır kazandı
- test/models/subscription_test.rb record_x402! testi için 69 satır kazandı

Servis katmanı (protokolün nasıl çalıştığı) testleri tamamen gitti. Yerine model katmanı (başarılı ödemeden sonra verinin nasıl kaydedildiği) testleri geldi.

Mantıklı — protokol davranışı gem'e aittir, gem kendi kendini test eder. Siz yalnız kendi yazdığınız kısmı test edersiniz: settlement sonucu geldikten sonra Purchase / Subscription satırının nasıl insert edildiği ve tx_hash'in nasıl saklandığı.

Bu aynı zamanda "taşımalı mıyım?" sorusunun sert sinyalidir: eğer testlerinizde "gönderdiğim payload'un biçimi doğru mu", "facilitator isValid=false dönünce ben ne yaparım" gibi büyük bloklar varsa — bu protokol davranışıdır, kütüphaneye aittir. test/services/ altında 100 satırı geçen bir service testi dosyası varsa, büyük ihtimalle o service aslında kütüphane olması gereken bir protokolü / dış arayüzü test ediyordur.

Claude'a bu tür bir taşımayı ne zaman yaptırmalı

Her "topluluk gem çıkardı" taşımaya değmez. Önce Claude'a şunları sordurun:

  1. Kütüphane sürüm numarası. 0.x kütüphanesinin API'si hâlâ oynar; 1.x kilitlenecek noktadır.
  2. Kod delta ≥ 200 satır. Benim bu seferki net -305 satır. Net azalma < 100 satır ise switching cost değmez.
  3. Test konsolidasyonu gerçek. Taşıma sonrası testleriniz hâlâ aynı şeylerin %90'ını yeni bir stub setiyle iddia ediyorsa — davranış kütüphaneye geçmedi, yalnız API adı değişti. Taşımayın.
  4. Config tek yere toplanır. El yazması sürümde USDC kontrat adresi, network adı, facilitator URL'si 3 yere dağılmıştı. Sonra: hepsi 29 satırlık initializer'da. Değer budur.
  5. Upgrade yolu net. Kütüphane nasıl yükseltilir? Breaking change için changelog kuralı var mı? Yoksa kendi adapter'ınızla sarın ki gem 50 çağrı yerine sızmasın.

Bu 5'i geçince taşıma prompt'u bir cümleye sığar:

"x402-rails gem v1 stabil. Mevcut PaymentHandler + FacilitatorClient'ı değiştir. Aynı endpoint'leri ve yanıt şekillerini koru — ben yalnız protokol işinin gem'e girmesini istiyorum. Testleri ona göre model katmanına taşı."

Claude şunu yapar: gem dokümanlarını oku → initializer yaz → controller'ı yeniden yaz → eski service'i sil → testleri yeniden kur. Yol boyunca iki üç kez onay ister (örn. "bu davranış korunsun mu?"). Bitirdikten sonra bin/rails test, hepsi yeşilse commit.

Alınan ders

Gerçek içgörü "kütüphane, el yazmasından iyidir" değil. Bazen el yazması doğru hamledir — protokol özelleştirme, latans duyarlılığı, uyumluluk.

Asıl karar noktası şudur:

services/ klasörünüzdeki şu dosya — her protokol güncellemesinde değiştirmeniz gereken — artık bu işi özellikle sürdüren bir gem var mı?

Varsa, bu sizin iş mantığınız değildir. Projenize aldığınız "protokol ehlileştirilmiş" sokak kedisidir. İki haftadır besliyor, güzel çalışıyor — ama sizin değil. Claude'a söyleyin, topluluğa iade etsin. Sizde kalan şey protokol sonucunu kendi model'inize yazmak — işte o parça projenize özgüdür.

Taşıma sonrası benim x402 klasörümde yalnız şunlar var: 29 satırlık initializer + 4 satırlık controller çağrısı + iki record_x402! metodu. El yazması sürümün 139 satırlık servis katmanı ve onunla gelen 220 satırlık servis testleri — hepsi gitti. Daha az kod. Aynı davranış. Daha sıkı testler. Başarılı taşıma budur.