Free

Letting Claude Migrate a Hand-Rolled x402 Integration to the Community Gem

Hand-rolled → library migration: net -622/+317 lines. Controller drops from 30 lines of protocol plumbing to 4. Real gotchas: importmap silently drops pins, YAML parses 0x... as an integer.


One commit's diff:

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

Deleted:

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

Added: one line in Gemfile, config/initializers/x402.rb (29 lines), two record_x402! methods on Purchase/Subscription + the corresponding model tests.

This isn't a refactor — it's swapping the part I wrote for the part someone else wrote. The hand-rolled version had been running for two weeks. One-shot payments, subscriptions, tx_hash recording — all working. So why migrate?

This post is about how to make Claude do this kind of migration, and when it's worth it.


Background: what the hand-rolled version looked like

x402 is an HTTP 402 Payment Required protocol. The client signs an EIP-3009 authorization, the server verifies and settles an on-chain transaction through a facilitator.

The hand-rolled PaymentHandler roughly:

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

About 30 lines of protocol plumbing inside the controller: decode signature, build requirements, verify, settle, handle errors. The USDC contract address was hardcoded in the code. The frontend was the same — hand-written window.ethereum.request, manual chain switching, manual X-PAYMENT header wiring.

The trigger: the libraries matured

Getting Claude to scan the ecosystem of any protocol you depend on, weekly, is a good habit — especially for a protocol that's only existed for a short while. Claude can watch the x402-rails gem (Ruby side) and x402-fetch (JS side) evolve, see the community shape up.

Until one day:

You: "Are x402-rails and x402-fetch mature now? If they are, migrate."

Claude reads the READMEs and changelogs, reports back: v1 protocol stable, non-optimistic mode gives you settlement results, facilitator defaults to payai.network. Go.

After migration: the controller becomes 4 lines

The same subscribe action after migration:

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 rendered 402 or error, already halted

  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

The protocol part is all inside the gem. x402_paywall(amount:) handles it in one line:

  • First request without X-PAYMENT header → gem renders 402 + PaymentRequirements
  • Client x402-fetch signs an EIP-3009 authorization, retries with X-PAYMENT
  • Gem calls the facilitator's /verify and /settle (non-optimistic, meaning it waits for settle before returning)
  • performed? detects that the gem has already rendered and we return; otherwise request.env["x402.settlement_result"] and request.env["x402.payment"] hold the result

Initialization in config/initializers/x402.rb (29 lines):

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

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # wait for facilitator settle before returning, so we can record tx_hash synchronously
end

That's the core of the "hand-rolled → library" move: 139 lines of services + 220 lines of service tests, hand-written, swapped for 29-line initializer + 4-line controller call.

Frontend: viem + x402-fetch, but don't vendor

JS side, the hand-rolled version signed authorizations by hand and called window.ethereum.request directly. Post-migration: use viem and x402-fetch.

But bundled these two packages are hundreds of KB. Vendoring them (copying the npm dist/ into vendor/javascript/) would balloon the repo. Fix: importmap + jsdelivr CDN + 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 is the key bit: these don't hit first-paint <link rel="modulepreload">, so most pages never download them.

In the Stimulus controller, load on first pay click:

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
}

Users without a wallet never load those 300+ KB. Users with MetaMask who click "pay" wait once on jsdelivr (CDN-cached), and subsequent clicks are instant.

Fixed 3 problems in the old implementation along the way

The hand-rolled version was copied from a reference implementation in another project. While migrating, I had Claude scan for accumulated rot. Three things got fixed:

1. Stop using selectedAddress

Old code:
js
const address = window.ethereum.selectedAddress

selectedAddress is deprecated in newer MetaMask. The correct way:

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

eth_requestAccounts also triggers the connect prompt — if the user hasn't connected the wallet to this site before, this is the authorization entry point.

2. Don't error-match on strings

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

String matching will always get broken by the next wallet's copy change. Switch to typed codes:

// EIP-1193 standard: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// custom codes that flow through
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

When throwing our own errors, attach codes too:

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

3. UI strings through i18n, not hardcoded English

The old code had "Connecting wallet..." and every other string baked into the JS. Moved them to data-value attributes injected from 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 reads this.labelConnectingValue. All 19 languages can translate independently. Zero JS changes.

Two real gotchas

The migration hit two traps that have nothing to do with the x402 protocol itself and aren't in the gem README.

Gotcha 1: importmap silently drops pins without a vendor file

The x402-rails gem ships a few of its own Stimulus controllers. After installing the gem, clicking the pay button threw:

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

Dug around. importmap.rb clearly had:

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

But vendor/javascript/@hotwired--stimulus.js didn't exist. importmap doesn't error on this — it just silently drops the pin. The gem's controller then can't find Stimulus and fails to register, which breaks every controller after it.

Fix: vendor the file:

./bin/importmap pin @hotwired/stimulus

This downloads the npm package into vendor/javascript/. This kind of silent failure is typical of the class Claude misses — it sees the pin in importmap.rb and assumes OK, without actually checking whether the corresponding file in vendor/javascript/ exists. Next time you do this kind of diagnosis, have Claude check both ends.

Gotcha 2: credentials.yml parses 0x... as an integer

Production credentials, written naively:

x402:
  wallet_address: 0xAbCd...

After deploy, every x402 click 422'd with an error about wallet_address not matching the EVM address regex.

YAML parsed 0xAbCd... as a hexadecimal integer. The Ruby side got an Integer from Rails.application.credentials.dig(:x402, :wallet_address), not a String. The later .to_s on the way into PaymentRequirements turned it into a decimal number — no longer a valid address at all.

The fix is one character — add quotes:

x402:
  wallet_address: "0xAbCd..."

Claude won't catch this at first; you have to work backwards from the error message down to the YAML parsing layer. Learn it once, then quote anything starting with 0x in YAML going forward.

The test shape changed (this is the most important signal)

After migrating, the test file count didn't drop, but the location moved:

Deleted:
- test/services/x402/facilitator_client_test.rb (112 lines)
- test/services/x402/payment_handler_test.rb (108 lines)

Added:
- test/models/purchase_test.rb gained 40 lines testing record_x402!
- test/models/subscription_test.rb gained 69 lines testing record_x402!

Service-layer tests (how the protocol runs) — all gone. Replaced by model-layer tests (how data gets recorded after a successful payment).

This makes sense: the protocol behavior belongs to the gem, which tests itself. You only need to test the part you wrote: how a Purchase / Subscription row gets inserted after a settlement result lands, and how tx_hash gets stored.

This is also the hard signal for "should I migrate": if your tests have big chunks asserting "the payload I send out is correctly shaped" or "when the facilitator returns isValid=false, I handle it this way" — that's protocol behavior, which belongs to the library. If any test file under test/services/ is over 100 lines, it probably means that service is testing a protocol / external interface that should be a library.

When to let Claude run this kind of migration

Not every "community shipped a gem" is worth migrating to. Have Claude ask these first:

  1. Library version number. A 0.x library's API will still move; 1.x is when you lock in.
  2. Code delta ≥ 200 lines. Mine netted -305. Below 100 lines, switching cost isn't worth it.
  3. Test consolidation is real. If post-migration your tests still assert 90% of the same things with a new set of stubs — the behavior didn't move into the library, only the API changed names. Don't migrate.
  4. Config consolidates. In the hand-rolled version, USDC contract address, network name, facilitator URL were scattered in 3 places. After: all in a 29-line initializer. That's value.
  5. Upgrade path is clear. How does the library get upgraded? Is there a changelog convention for breaking changes? If not, wrap it behind your own adapter so the gem doesn't bleed into 50 call sites.

Once these 5 check out, the migration prompt is one sentence:

"x402-rails v1 is stable. Swap out the current PaymentHandler + FacilitatorClient. Keep the same endpoints and response shapes — I only want the protocol work in the gem. Move tests to the model layer accordingly."

Claude will: read the gem docs → write the initializer → rewrite the controller → delete the old service → rebuild tests. It'll ask for confirmation two or three times on the way (e.g., "do you want to preserve this behavior?"). After it's done, run bin/rails test, all green, then commit.

The takeaway

The real insight isn't "libraries beat hand-rolled." Sometimes hand-rolled is the right call — protocol customization, latency sensitivity, compliance.

The actual decision point is:

That file in your services/ folder — the one that has to change every time the protocol updates — is there a gem that now specifically maintains that thing?

If yes, then it's not your business logic. It's a "protocol-tamed" stray cat you adopted into your project. Fed for two weeks, running nicely — but it isn't yours. Let Claude return it to the community. What you keep is writing the protocol result into your model — that part is specific to your project.

After migrating, my x402 directory holds only: a 29-line initializer + a 4-line controller call + two record_x402! methods. The 139 lines of hand-written services, and the 220 lines of service tests that came with them — all gone. Less code. Same behavior. Tighter tests. That's a successful migration.