ย้าย hand-rolled → gem: สุทธิ -622/+317 บรรทัด. คอนโทรลเลอร์ลดจาก 30 บรรทัดของ plumbing โปรโตคอลเหลือ 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, config/initializers/x402.rb (29 บรรทัด), เมธอด record_x402! สองตัวบน Purchase/Subscription + เทสต์ model ที่ตรงกัน
นี่ไม่ใช่การ refactor — นี่คือ การเปลี่ยนส่วนที่ฉันเขียนด้วยส่วนที่คนอื่นเขียน เวอร์ชันเขียนเองรันมาสองสัปดาห์ จ่ายครั้งเดียว สมัครสมาชิก บันทึก tx_hash ทำงานหมด แล้วทำไมต้องย้าย?
โพสต์นี้พูดเรื่องวิธีให้ Claude ทำการย้ายแบบนี้ และเมื่อไหร่มันคุ้ม
x402 คือโปรโตคอล HTTP 402 Payment Required ไคลเอนต์ลงนามการอนุญาต EIP-3009 เซิร์ฟเวอร์ตรวจสอบและ settle ธุรกรรม on-chain ผ่าน 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 บรรทัดของ plumbing โปรโตคอลในคอนโทรลเลอร์: ถอดรหัสลายเซ็น, สร้าง requirements, verify, settle, จัดการ error ที่อยู่สัญญา USDC ฝังในโค้ด ฝั่ง frontend ก็เหมือนกัน — window.ethereum.request เขียนเอง, สลับ chain เอง, ประกอบ header X-PAYMENT เอง
การให้ Claude สแกนระบบนิเวศของโปรโตคอลที่คุณพึ่งพาอาทิตย์ละครั้งเป็นนิสัยที่ดี — โดยเฉพาะสำหรับโปรโตคอลแบบ x402 ที่เพิ่งออกมา Claude สามารถติดตาม gem x402-rails (ฝั่ง Ruby) และ x402-fetch (ฝั่ง JS) วิวัฒนาการ เห็นชุมชนก่อตัวขึ้น
จนถึงวันหนึ่ง:
คุณ: "
x402-railsและx402-fetchตอนนี้สุกแล้วหรือยัง? ถ้าใช่ช่วยย้ายให้"
Claude อ่าน README และ changelog กลับมารายงาน: โปรโตคอล v1 เสถียร โหมด non-optimistic ให้ผล settlement, facilitator ดีฟอลต์ payai.network ย้ายได้
action 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 หรือ error แล้ว 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:) จัดการในบรรทัดเดียว:
X-PAYMENT → gem เรนเดอร์ 402 + PaymentRequirementsx402-fetch ลงนามการอนุญาต EIP-3009 ลองใหม่ด้วย X-PAYMENT/verify และ /settle ของ facilitator (non-optimistic รอ settle จบก่อนกลับ)performed? ตรวจว่า gem เรนเดอร์ไปแล้ว เรา 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 mainnet (USDC จริง). dev/test → Base Sepolia (USDC testnet ฟรี)
config.chain = Rails.env.production? ? "base" : "base-sepolia"
config.currency = "USDC"
config.version = 1
config.optimistic = false # รอ settle ของ facilitator ก่อนกลับ เพื่อบันทึก tx_hash แบบ sync
end
นี่คือแก่นของการเคลื่อน "เขียนเอง → ไลบรารี": 139 บรรทัด services + 220 บรรทัดเทสต์ services เขียนเอง ถูกแลกกับ initializer 29 บรรทัด + การเรียกคอนโทรลเลอร์ 4 บรรทัด
ฝั่ง JS เวอร์ชันเขียนเองประกอบลายเซ็นเองและเรียก window.ethereum.request ตรง ๆ หลังย้าย: viem และ x402-fetch
แต่สองแพ็กเกจนี้ bundle แล้วหลายร้อย KB vendor (คัดลอก dist/ ของ npm เข้า vendor/javascript/) ทำให้ repo ระเบิด ทางออก: 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 คือกุญแจ: ไม่เข้า <link rel="modulepreload"> ของ first paint หน้าเพจส่วนใหญ่จึงไม่ดาวน์โหลดเลย
ในคอนโทรลเลอร์ Stimulus โหลดเมื่อคลิก 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 (มี CDN cache) คลิกถัดไปทันที
เวอร์ชันเขียนเองคัดลอกจาก reference implementation ในโปรเจกต์อื่น ระหว่างย้ายฉันให้ Claude สแกนกลิ่นสะสม ออกมา 3:
selectedAddressโค้ดเก่า:
js
const address = window.ethereum.selectedAddress
selectedAddress deprecated ใน MetaMask ใหม่ วิธีถูกต้อง:
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" })
const address = accounts[0]
eth_requestAccounts ยังเรียก dialog เชื่อมต่อด้วย — ถ้าผู้ใช้ยังไม่เคยเชื่อมกระเป๋ากับเว็บ นี่คือประตูให้สิทธิ์
เก่า:
js
if (error.message.includes("User rejected")) { ... }
if (error.message.includes("chain")) { ... }
แมตช์สตริงจะพังเสมอเมื่อกระเป๋ารุ่นต่อไปเปลี่ยน copy เปลี่ยนไปใช้ code มีประเภท:
// มาตรฐาน EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// code เอง ทะลุ flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }
เวลา throw error ของตัวเอง ติด code ด้วย:
throw Object.assign(new Error("no_account"), { code: "NO_ACCOUNT" })
โค้ดเก่ามี "Connecting wallet..." และสตริงอื่น ๆ ฝังใน JS ย้ายไปเป็น attribute data-value ที่ inject จาก 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 อ่าน this.labelConnectingValue 19 ภาษาแปลอิสระ JS ไม่ต้องแก้สักตัวอักษร
การย้ายเจอกับดัก 2 ข้อที่ไม่เกี่ยวกับโปรโตคอล x402 และ ไม่มีใน README ของ gem
gem x402-rails มาพร้อม Stimulus controller ของตัวเองหลายตัว หลังติดตั้ง gem คลิกปุ่มจ่าย browser ขึ้น:
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 ในสถานการณ์นี้ ไม่ error — ทิ้ง pin นั้นเงียบ ๆ ผลคือ controller ของ gem ไม่เจอ Stimulus register ล้มเหลว controller ถัดไปตายหมด
แก้ไข: เติมไฟล์ vendor:
./bin/importmap pin @hotwired/stimulus
คำสั่งนี้ดาวน์โหลดแพ็กเกจ npm ไปที่ vendor/javascript/ ความล้มเหลวเงียบแบบนี้เป็นต้นแบบที่ Claude หลุด — เห็น pin ใน importmap.rb แล้วคิดว่า OK ไม่ได้ตรวจจริง ๆ ว่าไฟล์ที่สอดคล้องใน vendor/javascript/ มีหรือเปล่า ครั้งหน้าการวินิจฉัยแบบนี้ให้ Claude ตรวจ ทั้งสองปลาย
0x... เป็นจำนวนเต็มcredentials production เขียนตรง ๆ:
x402:
wallet_address: 0xAbCd...
หลัง deploy แต่ละคลิก x402 คืน 422 error ว่า wallet_address ไม่แมตช์ regex ของ address EVM
YAML parse 0xAbCd... เป็น จำนวนเต็มฐาน 16 ฝั่ง Ruby Rails.application.credentials.dig(:x402, :wallet_address) คืน Integer ไม่ใช่ String .to_s ที่ตามมาก่อนเข้า PaymentRequirements กลายเป็นสตริงเลขฐาน 10 — ไม่ใช่ address ที่ถูกต้องอีกต่อไป
แก้ไขด้วยตัวอักษรเดียว — เพิ่มเครื่องหมายคำพูด:
x402:
wallet_address: "0xAbCd..."
กับดักแบบนี้ Claude ไม่จับตอนแรก ต้อง ย้อนจากข้อความ error ลงไปถึงชั้น parsing YAML เรียนรู้ครั้งเดียว ครั้งต่อไปสะท้อนกลับใส่เครื่องหมายคำพูดให้ค่าที่ขึ้นต้นด้วย 0x ใน YAML ทันที
หลังย้าย จำนวนไฟล์เทสต์ไม่ลด แต่ ตำแหน่งเปลี่ยน:
ลบ:
- test/services/x402/facilitator_client_test.rb (112 บรรทัด)
- test/services/x402/payment_handler_test.rb (108 บรรทัด)
เพิ่ม:
- test/models/purchase_test.rb เพิ่ม 40 บรรทัดเทสต์ record_x402!
- test/models/subscription_test.rb เพิ่ม 69 บรรทัดเทสต์ record_x402!
เทสต์ชั้นเซอร์วิส (โปรโตคอลรันยังไง) — หายหมด ถูกแทนที่ด้วยเทสต์ชั้น model (ข้อมูลถูกบันทึกยังไงหลังจ่ายสำเร็จ)
มีเหตุผล — พฤติกรรมโปรโตคอลเป็นของ gem ที่เทสต์ตัวเอง คุณต้องเทสต์แค่ ส่วนที่คุณเขียนเอง: แถว Purchase / Subscription ถูก insert ยังไงหลังผล settlement มา และ tx_hash ถูกเก็บยังไง
นี่ยังเป็นสัญญาณแข็งของ "ควรย้ายไหม?": ถ้าในเทสต์ของคุณมีก้อนใหญ่ ๆ ยืนยันว่า "payload ที่ฉันส่งมีรูปถูก" หรือ "เมื่อ facilitator คืน isValid=false ฉันจัดการแบบนี้" — นั่นคือพฤติกรรมโปรโตคอล ควรเป็นของไลบรารี ถ้าไฟล์เทสต์ไหนใน test/services/ เกิน 100 บรรทัด มีความเป็นไปได้สูงว่า service นั้นกำลังเทสต์โปรโตคอล / อินเทอร์เฟซภายนอกที่ควรเป็นไลบรารี
ไม่ใช่ทุก "ชุมชนออก gem" คุ้มค่ากับการย้าย ให้ Claude ถามก่อน:
0.x API ยังขยับ; 1.x คือจุดล็อกผ่าน 5 ข้อนี้แล้ว prompt ย้ายพอเขียนประโยคเดียว:
"Gem
x402-railsv1 เสถียรแล้ว เปลี่ยนPaymentHandler+FacilitatorClientปัจจุบัน เก็บ endpoint และรูปแบบ response เดิม — ผมแค่อยากให้งานโปรโตคอลเข้า gem ย้ายเทสต์ไปชั้น model ตามนั้น"
Claude จะทำ: อ่านเอกสาร gem → เขียน initializer → เขียนคอนโทรลเลอร์ใหม่ → ลบ service เก่า → สร้างเทสต์ใหม่ ระหว่างทางจะขอยืนยัน 2-3 ครั้ง (เช่น "คงพฤติกรรมนี้ไหม?") เสร็จแล้วรัน bin/rails test เขียวหมด commit
ข้อคิดที่แท้จริงไม่ใช่ "ไลบรารีเหนือกว่าเขียนเอง" บางครั้งเขียนเองคือท่าที่ถูก — ปรับแต่งโปรโตคอล, ไวต่อ latency, การปฏิบัติตามข้อกำหนด
จุดตัดสินใจที่แท้จริง:
ไฟล์ในโฟลเดอร์ services/ ของคุณ — ไฟล์ที่ต้องเปลี่ยนทุกครั้งโปรโตคอลอัปเดต — มี gem ที่ดูแลสิ่งนั้นโดยเฉพาะแล้วหรือยัง?
ถ้ามี ก็ไม่ใช่ business logic ของคุณ เป็นแมวจรที่ "ถูกทำให้เชื่องทางโปรโตคอล" ที่คุณรับเลี้ยงในโปรเจกต์ ให้อาหารสองสัปดาห์ รันได้ดี — แต่ไม่ใช่ของคุณ ให้ Claude คืนสู่ชุมชน สิ่งที่คุณเก็บไว้คือ เขียนผลโปรโตคอลลง model ของคุณ — ส่วนนั้นเฉพาะสำหรับโปรเจกต์ของคุณ
หลังย้าย ไดเรกทอรี x402 ของผมเหลือแค่: initializer 29 บรรทัด + เรียกคอนโทรลเลอร์ 4 บรรทัด + เมธอด record_x402! สองตัว 139 บรรทัดของ service เขียนเอง และ 220 บรรทัดของเทสต์ service ที่มาพร้อมกัน — หายหมด โค้ดน้อยลง พฤติกรรมเดิม เทสต์แน่นขึ้น นั่นคือการย้ายที่สำเร็จ