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.
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.
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-railsex402-fetchjá 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.
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:
X-PAYMENT → a gem renderiza 402 + PaymentRequirementsx402-fetch assina uma autorização EIP-3009 e retenta com X-PAYMENT/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 resultadoInicializaçã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.
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.
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:
selectedAddressCó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.
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" })
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.
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.
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.
0x... como inteiroCredentials 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.
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.
Nem todo "a comunidade lançou uma gem" vale a migração. Peça ao Claude pra perguntar antes:
0.x ainda mexe na API; 1.x é quando você trava.Com esses 5 OK, o prompt de migração cabe numa frase:
"A gem
x402-railsv1 está estável. SubstituaPaymentHandler+FacilitatorClientatuais. 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.
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.