Free

Để Claude chuyển cài đặt x402 tự viết sang gem cộng đồng

Migration tự viết → gem: ròng -622/+317 dòng. Controller giảm từ 30 dòng plumbing giao thức xuống 4. Bẫy: importmap âm thầm bỏ pin, YAML đọc 0x... như số nguyên.


Diff của một commit:

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

Đã xóa:

app/services/x402/facilitator_client.rb        53 dòng
app/services/x402/payment_handler.rb           86 dòng
test/services/x402/facilitator_client_test.rb  112 dòng
test/services/x402/payment_handler_test.rb     108 dòng

Đã thêm: một dòng trong Gemfile, config/initializers/x402.rb (29 dòng), hai phương thức record_x402! trên Purchase/Subscription + test model tương ứng.

Đây không phải refactor — đây là thay phần mình viết bằng phần người khác viết. Bản tự viết đã chạy hai tuần. Thanh toán lẻ, đăng ký, ghi tx_hash — tất cả hoạt động. Vậy tại sao chuyển?

Bài này nói về cách bảo Claude làm kiểu chuyển đổi này, và khi nào đáng chuyển.


Bối cảnh: bản tự viết trông thế nào

x402 là giao thức HTTP 402 Payment Required. Client ký một ủy quyền EIP-3009, server xác minh + thanh toán một giao dịch on-chain qua facilitator.

PaymentHandler tự viết đại khái:

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

Khoảng 30 dòng plumbing giao thức ngay trong controller: decode chữ ký, ghép requirements, verify, settle, xử lý lỗi. Địa chỉ hợp đồng USDC hardcode trong code. Frontend cũng vậy — window.ethereum.request viết tay, chuyển chain thủ công, ghép header X-PAYMENT thủ công.

Kích hoạt: thư viện đã trưởng thành

Bảo Claude quét hệ sinh thái của các giao thức bạn đang dùng hằng tuần là thói quen tốt — nhất là với giao thức như x402 mới ra. Claude có thể theo dõi gem x402-rails (phía Ruby) và x402-fetch (phía JS) tiến hóa, thấy cộng đồng dần hình thành.

Cho đến một ngày:

Bạn: "x402-railsx402-fetch giờ đã trưởng thành chưa? Nếu rồi, chuyển sang giúp mình."

Claude đọc README và changelog, báo cáo: giao thức v1 ổn định, chế độ non-optimistic cho kết quả settlement, facilitator mặc định payai.network. Có thể chuyển.

Sau chuyển: controller còn 4 dòng

Cùng action subscribe sau khi chuyển:

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 đã render 402 hoặc lỗi, đã 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

Phần giao thức toàn bộ nằm trong gem. x402_paywall(amount:) xử một dòng:

  • Request đầu không có header X-PAYMENT → gem render 402 + PaymentRequirements
  • Client x402-fetch ký ủy quyền EIP-3009, thử lại với X-PAYMENT
  • Gem gọi /verify/settle của facilitator (non-optimistic, chờ settle xong mới trả)
  • performed? phát hiện gem đã render thì ta return; nếu không, request.env["x402.settlement_result"]request.env["x402.payment"] là kết quả giao dịch

Khởi tạo trong config/initializers/x402.rb (29 dòng):

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 thật). dev/test → Base Sepolia (USDC testnet miễn phí)
  config.chain = Rails.env.production? ? "base" : "base-sepolia"

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # chờ facilitator settle xong mới trả, để ghi tx_hash đồng bộ
end

Đây là cốt lõi pha "tự viết → thư viện": 139 dòng services + 220 dòng test services, viết tay, đổi lấy initializer 29 dòng + 4 dòng gọi trong controller.

Frontend: viem + x402-fetch, nhưng đừng vendor

Phía JS, bản tự viết ghép chữ ký và gọi window.ethereum.request trực tiếp. Sau chuyển: viemx402-fetch.

Nhưng hai gói này bundle chung hàng trăm KB. Vendor (chép dist/ của npm vào vendor/javascript/) làm kho nổ. Giải pháp: 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 là mấu chốt: không vào <link rel="modulepreload"> first paint, nên đa số trang không tải.

Controller Stimulus, tải ngay click đầu 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
}

Người dùng không có ví không bao giờ tải 300+ KB đó. Người dùng có MetaMask và click "thanh toán" đợi một lần ở jsdelivr (có cache CDN), click tiếp theo tức thời.

Nhân tiện sửa 3 vấn đề trong bản cũ

Bản tự viết được sao chép từ cài đặt tham chiếu của dự án khác. Lúc chuyển, tôi bảo Claude quét mùi hôi tích tụ. 3 thứ lộ ra:

1. Đừng dùng selectedAddress

Code cũ:
js
const address = window.ethereum.selectedAddress

selectedAddress đã deprecated ở MetaMask mới. Cách đúng:

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

eth_requestAccounts còn bật hộp thoại kết nối — nếu người dùng chưa từng kết nối ví với site này, đây là cửa ủy quyền.

2. Đừng match lỗi bằng chuỗi

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

Match chuỗi luôn vỡ khi bản ví tiếp theo đổi copy. Chuyển sang code có kiểu:

// Chuẩn EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// code tự định nghĩa xuyên suốt flow
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Khi throw lỗi của mình, gắn code luôn:

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

3. Chuỗi UI đi qua i18n, đừng hardcode tiếng Anh

Code cũ có "Connecting wallet..." và mọi chuỗi khác gắn trong JS. Chuyển sang thuộc tính data-value inject từ 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 đọc this.labelConnectingValue. 19 ngôn ngữ dịch độc lập. JS không đổi chữ nào.

Hai bẫy thực tế

Việc chuyển đâm vào hai bẫy không liên quan đến giao thức x402 và không có trong README của gem.

Bẫy 1: importmap âm thầm bỏ pin không có file vendor

Gem x402-rails đi kèm vài Stimulus controller riêng. Sau khi cài gem, click nút thanh toán, trình duyệt phun:

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

Đào. importmap.rb rõ ràng có:

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

Nhưng vendor/javascript/@hotwired--stimulus.js không tồn tại. importmap không báo lỗi tình huống này — cứ thế bỏ pin đó trong im lặng. Kết quả controller của gem không tìm thấy Stimulus, đăng ký thất bại, mọi controller sau đó đều chết.

Sửa: chêm file vendor:

./bin/importmap pin @hotwired/stimulus

Lệnh này tải gói npm về vendor/javascript/. Loại lỗi im lặng này điển hình cho chuyện Claude hay bỏ sót — thấy pin trong importmap.rb là giả định OK, không tự kiểm tra xem file tương ứng trong vendor/javascript/ có thật không. Lần sau chẩn đoán kiểu này, bảo Claude kiểm tra cả hai đầu.

Bẫy 2: credentials.yml phân tích 0x... thành số nguyên

Credentials sản xuất, viết ngây thơ:

x402:
  wallet_address: 0xAbCd...

Deploy xong, mỗi click x402 trả 422, lỗi bảo wallet_address không khớp regex địa chỉ EVM.

YAML phân tích 0xAbCd... thành số nguyên hex. Phía Ruby Rails.application.credentials.dig(:x402, :wallet_address) trả Integer, không phải String. .to_s sau đó trước khi vào PaymentRequirements biến thành chuỗi số thập phân — không còn là địa chỉ hợp lệ.

Sửa bằng một ký tự — thêm dấu nháy:

x402:
  wallet_address: "0xAbCd..."

Loại bẫy này Claude ban đầu không thấy; bạn phải lần ngược từ thông báo lỗi xuống lớp phân tích YAML. Học một lần rồi, lần sau phản xạ thêm dấu nháy cho mọi giá trị bắt đầu bằng 0x trong YAML.

Hình dạng test thay đổi (tín hiệu quan trọng nhất)

Sau khi chuyển, số file test không giảm, nhưng vị trí dời:

Đã xóa:
- test/services/x402/facilitator_client_test.rb (112 dòng)
- test/services/x402/payment_handler_test.rb (108 dòng)

Đã thêm:
- test/models/purchase_test.rb thêm 40 dòng test record_x402!
- test/models/subscription_test.rb thêm 69 dòng test record_x402!

Test lớp service (giao thức chạy thế nào) — biến mất hết. Thay bằng test lớp model (dữ liệu được ghi thế nào sau thanh toán thành công).

Hợp lý — hành vi giao thức thuộc gem, gem tự test. Bạn chỉ cần test phần bạn viết: hàng Purchase / Subscription được insert thế nào sau khi settlement về, và tx_hash lưu thế nào.

Đây cũng là tín hiệu cứng "có nên chuyển?": nếu test của bạn có mảng lớn khẳng định "payload tôi gửi đi có hình dạng đúng" hoặc "khi facilitator trả isValid=false, tôi xử thế này" — đó là hành vi giao thức, vốn thuộc về thư viện. Nếu có file test trong test/services/ vượt 100 dòng, khả năng cao service đó đang test một giao thức / giao diện ngoài đáng lẽ thuộc thư viện.

Khi nào để Claude làm kiểu chuyển này

Không phải cứ "cộng đồng ra gem" là đáng chuyển. Bảo Claude hỏi trước mấy điều:

  1. Phiên bản thư viện. 0.x API còn dịch chuyển; 1.x mới đáng khóa.
  2. Giảm code ≥ 200 dòng. Lần này tôi ròng -305 dòng. Dưới 100 dòng, switching cost không đáng.
  3. Test thực sự gộp được. Nếu sau khi chuyển test của bạn vẫn khẳng định 90% những thứ y chang với set stub khác — hành vi không sang thư viện, chỉ là tên API đổi. Đừng chuyển.
  4. Cấu hình gộp lại. Bản tự viết, địa chỉ hợp đồng USDC, tên network, URL facilitator rải ở 3 chỗ. Sau chuyển, toàn bộ vào initializer 29 dòng. Đây là giá trị.
  5. Lộ trình nâng cấp rõ. Thư viện nâng cấp thế nào sau này? Có quy ước changelog cho breaking change? Không có thì tự bọc adapter một lớp để gem không tràn vào 50 call site.

Đạt 5 điều này, prompt chuyển một câu là đủ:

"Gem x402-rails v1 đã ổn định. Thay PaymentHandler + FacilitatorClient hiện tại. Giữ nguyên endpoint và shape phản hồi — tôi chỉ cần phần giao thức vào gem. Chuyển test tương ứng sang lớp model."

Claude sẽ: đọc doc gem → viết initializer → viết lại controller → xóa service cũ → dựng lại test. Giữa chừng sẽ hỏi xác nhận hai ba lần (ví dụ: "hành vi này có giữ không?"). Xong chạy bin/rails test, xanh hết thì commit.

Thu hoạch

Cái nhìn thật sự không phải "thư viện hơn tự viết". Đôi khi tự viết đúng — giao thức tùy biến, độ trễ nhạy, tuân thủ.

Điểm quyết định thật sự:

File trong thư mục services/ của bạn — cái phải đổi mỗi khi giao thức cập nhật — đã có gem chuyên duy trì thứ đó chưa?

Có rồi, thì nó không phải business logic của bạn. Đó là con mèo hoang "được thuần giao thức" mà bạn nhận nuôi trong dự án. Cho ăn hai tuần, chạy tốt — nhưng không phải của bạn. Bảo Claude trả về cộng đồng. Thứ bạn giữ là ghi kết quả giao thức vào model của bạn — phần đó mới đặc thù dự án của bạn.

Sau khi chuyển, thư mục x402 của tôi chỉ còn: initializer 29 dòng + 4 dòng gọi controller + hai phương thức record_x402!. 139 dòng service tự viết, và 220 dòng test service đi kèm — biến hết. Ít code hơn. Cùng hành vi. Test chặt hơn. Đó là chuyển đổi thành công.