Free

Deixar o Claude migrar uma integração x402 feita à mão para a gem da comunidade

Migração hand-rolled → gem: líquido -622/+317 linhas. Controller cai de 30 linhas de encanamento de protocolo para 4. Armadilhas: importmap descarta pins em silêncio, YAML lê 0x... como inteiro.


Diff de um commit:

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

Removido:

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

Adicionado: uma linha no Gemfile, config/initializers/x402.rb (29 linhas), dois métodos record_x402! em Purchase/Subscription + os testes de model correspondentes.

Isso não é refactor — é trocar a parte que eu escrevi pela parte que alguém escreveu. A versão feita à mão vinha rodando há duas semanas. Pagamentos avulsos, assinaturas, registro de tx_hash — tudo funcionando. Então por que migrar?

Este post é sobre como fazer o Claude executar esse tipo de migração, e quando vale a pena.


Contexto: como era a versão feita à mão

x402 é um protocolo HTTP 402 Payment Required. O cliente assina uma autorização EIP-3009, o servidor verifica e liquida uma transação on-chain via facilitator.

O PaymentHandler feito à mão, mais ou menos assim:

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

Cerca de 30 linhas de encanamento de protocolo dentro do controller: decodar a assinatura, montar requirements, verify, settle, tratar erros. Endereço do contrato USDC hardcoded no código. No frontend, o mesmo — window.ethereum.request escrito à mão, troca de chain manual, montagem manual do header X-PAYMENT.

O gatilho: as bibliotecas amadureceram

Fazer o Claude varrer o ecossistema dos protocolos dos quais você depende toda semana é um bom hábito — especialmente para um protocolo que existe há pouco tempo. O Claude consegue acompanhar a gem x402-rails (lado Ruby) e x402-fetch (lado JS) evoluírem, ver a comunidade tomar forma.

Até que um dia:

Você: "x402-rails e x402-fetch já estão maduros? Se estiverem, migre."

O Claude lê READMEs e changelogs e retorna: protocolo v1 estável, modo non-optimistic entrega resultado de settlement, facilitator padrão em payai.network. Pode migrar.

Pós-migração: o controller vira 4 linhas

A mesma action subscribe após a migração:

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? # a gem renderizou 402 ou erro, já 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

A parte do protocolo inteira dentro da gem. x402_paywall(amount:) resolve numa linha:

  • Primeira request sem header X-PAYMENT → a gem renderiza 402 + PaymentRequirements
  • Cliente x402-fetch assina uma autorização EIP-3009 e retenta com X-PAYMENT
  • A gem chama /verify e /settle do facilitator (non-optimistic, espera settle antes de voltar)
  • performed? detecta que a gem já renderizou e a gente return; se não, request.env["x402.settlement_result"] e request.env["x402.payment"] trazem o resultado

Inicialização em config/initializers/x402.rb (29 linhas):

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

  config.currency   = "USDC"
  config.version    = 1
  config.optimistic = false # espera o settle do facilitator antes de voltar, para registrar tx_hash sincronamente
end

Esse é o cerne do movimento "feito à mão → biblioteca": 139 linhas de services + 220 linhas de testes de services, à mão, trocadas por 29 linhas de initializer + 4 linhas de chamada no controller.

Frontend: viem + x402-fetch, mas sem vendor

No JS, a versão à mão montava assinatura à mão e chamava window.ethereum.request direto. Pós-migração: viem e x402-fetch.

Só que os dois pacotes bundleados dão centenas de KB. Vendorar (copiar o dist/ do npm pra vendor/javascript/) explode o tamanho do repo. Solução: importmap + CDN do 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 é a sacada: não entram no <link rel="modulepreload"> do first paint, então a maioria das páginas nem baixa.

No controller Stimulus, carrega no primeiro click em 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
}

Usuários sem carteira nunca baixam os 300+ KB. Usuários com MetaMask que clicam "pagar" esperam uma vez no jsdelivr (com cache CDN), nos próximos clicks é instantâneo.

De quebra, corrigir 3 problemas da implementação velha

A versão feita à mão foi copiada de uma implementação de referência de outro projeto. No meio da migração, pedi pro Claude varrer a podridão acumulada. Saíram 3:

1. Para de usar selectedAddress

Código velho:
js
const address = window.ethereum.selectedAddress

selectedAddress está deprecado no MetaMask recente. Forma correta:

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

eth_requestAccounts também dispara o diálogo de conexão — se o usuário ainda não tinha conectado a carteira ao site, esse é o ponto de entrada da autorização.

2. Não faça match de erro por string

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

Matching por string sempre quebra na próxima mudança de copy da carteira. Troca para codes tipados:

// Padrão EIP-1193: 4001 = user rejected
if (error.code === 4001) { this.#showError(this.errorRejectedValue); return }
// codes próprios que atravessam o fluxo
if (error.code === "CHAIN_SWITCH") { ... }
if (error.code === "PAYMENT_REQUIRED") { ... }

Ao dar throw em erros próprios, pendura o code também:

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

3. Strings de UI via i18n, não inglês hardcoded

O código velho tinha "Connecting wallet..." e todas as outras strings embutidas no JS. Movi pra atributos data-value injetados do 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 lê this.labelConnectingValue. Os 19 idiomas traduzem independentemente. Zero alteração no JS.

Duas armadilhas reais

A migração caiu em duas armadilhas sem relação direta com o protocolo x402 e que não estão no README da gem.

Armadilha 1: importmap descarta pins em silêncio quando falta o arquivo vendor

A gem x402-rails traz alguns Stimulus controllers próprios. Instalei a gem, cliquei no botão de pagar, browser cuspiu:

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

Fuçando. O importmap.rb tinha claramente:

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

Mas vendor/javascript/@hotwired--stimulus.js não existia. O importmap não dá erro nesse caso — simplesmente descarta aquele pin em silêncio. Aí o controller da gem não acha Stimulus, falha no registro, e todo controller seguinte morre.

Fix: vendorar o arquivo:

./bin/importmap pin @hotwired/stimulus

Isso baixa o pacote npm pra vendor/javascript/. Esse tipo de falha silenciosa é clássico do que o Claude deixa escapar — vê o pin em importmap.rb e assume OK, sem ir por iniciativa própria ver se o arquivo correspondente em vendor/javascript/ realmente existe. Da próxima vez que fizer esse tipo de diagnóstico, peça ao Claude pra checar as duas pontas.

Armadilha 2: credentials.yml parseia 0x... como inteiro

Credentials de produção, escritas naturalmente:

x402:
  wallet_address: 0xAbCd...

Depois do deploy, cada click x402 retornava 422 dizendo que wallet_address não batia com a regex de endereço EVM.

O YAML parseou 0xAbCd... como inteiro hexadecimal. Do lado Ruby, Rails.application.credentials.dig(:x402, :wallet_address) devolveu Integer, não String. O .to_s seguinte, antes de entrar em PaymentRequirements, virou um número decimal — não é endereço válido.

Fix é um caractere — aspas:

x402:
  wallet_address: "0xAbCd..."

Esse tipo de armadilha o Claude não pega de cara; você precisa voltar da mensagem de erro até a camada de parsing do YAML. Uma vez aprendido, a partir da próxima, reflexo de aspas em qualquer valor que comece com 0x em YAML.

A forma dos testes muda (esse é o sinal mais importante)

Pós-migração, o número de arquivos de teste não caiu, mas a localização mudou:

Removidos:
- test/services/x402/facilitator_client_test.rb (112 linhas)
- test/services/x402/payment_handler_test.rb (108 linhas)

Adicionados:
- test/models/purchase_test.rb ganhou 40 linhas testando record_x402!
- test/models/subscription_test.rb ganhou 69 linhas testando record_x402!

Testes da camada de serviço (como o protocolo roda) — todos fora. Substituídos por testes da camada model (como o dado é registrado após pagamento bem-sucedido).

Faz sentido — comportamento do protocolo pertence à gem, que se testa sozinha. Você só precisa testar a parte que você escreveu: como a linha Purchase / Subscription é inserida após o settlement chegar, e como o tx_hash é salvo.

Esse também é o sinal duro de "devo migrar?": se seus testes têm blocões afirmando "o payload que eu mando tem a forma certa" ou "quando o facilitator retorna isValid=false, eu trato assim" — isso é comportamento de protocolo, pertence à biblioteca. Se algum arquivo de teste em test/services/ passa de 100 linhas, provavelmente aquele service está testando um protocolo / interface externa que deveria ser biblioteca.

Quando deixar o Claude tocar esse tipo de migração

Nem todo "a comunidade lançou uma gem" vale a migração. Peça ao Claude pra perguntar antes:

  1. Versão da biblioteca. Biblioteca 0.x ainda mexe na API; 1.x é quando você trava.
  2. Delta de código ≥ 200 linhas. A minha rendeu -305 líquido. Abaixo de 100 linhas, switching cost não compensa.
  3. Consolidação de teste é real. Se após a migração seus testes ainda afirmam 90% das mesmas coisas com outro conjunto de stubs — o comportamento não foi pra biblioteca, só a API mudou de nome. Não migre.
  4. Config consolida. Na versão à mão, endereço do contrato USDC, nome da rede, URL do facilitator espalhados em 3 lugares. Depois: tudo num initializer de 29 linhas. Isso é valor.
  5. Caminho de upgrade claro. Como a biblioteca sobe? Tem convenção de changelog para breaking changes? Se não, envolve num adapter próprio pra gem não vazar em 50 call sites.

Com esses 5 OK, o prompt de migração cabe numa frase:

"A gem x402-rails v1 está estável. Substitua PaymentHandler + FacilitatorClient atuais. Mantenha os mesmos endpoints e shapes de resposta — quero só o trabalho de protocolo dentro da gem. Mova os testes para a camada model em correspondência."

Claude vai: ler docs da gem → escrever initializer → reescrever controller → apagar service velho → reconstruir testes. No meio vai pedir duas ou três confirmações (ex.: "quer preservar esse comportamento?"). Quando termina, roda bin/rails test, tudo verde, commit.

A moral

O insight real não é "bibliotecas ganham de feito à mão". Às vezes feito à mão é a jogada certa — customização de protocolo, sensibilidade a latência, compliance.

O ponto de decisão verdadeiro é:

Aquele arquivo na sua pasta services/ — o que você precisa mudar toda vez que o protocolo atualiza — existe uma gem que mantém especificamente essa coisa?

Se sim, não é sua lógica de negócio. É um gato de rua "domesticado em protocolo" que você adotou no projeto. Duas semanas alimentando, roda bem — mas não é seu. Manda o Claude devolver pra comunidade. O que você guarda é gravar o resultado do protocolo no seu model — essa parte é específica do seu projeto.

Pós-migração, meu diretório x402 só tem: um initializer de 29 linhas + uma chamada de controller de 4 linhas + dois métodos record_x402!. As 139 linhas de serviços à mão, e os 220 linhas de testes de serviço que vinham com eles — todos fora. Menos código. Mesmo comportamento. Testes mais apertados. Essa é uma migração bem-sucedida.