Dados On-Chain em Produção: O Que Ninguém Te Conta
Dados de blockchain não são limpos, confiaveis ou fáceis. Rate limits de RPC, reorgs de chain, bugs de BigInt e tradeoffs de indexação — lições difíceis de quem entregou produtos DeFi reais.
Existe essa fantasia de que dados on-chain são inerentemente confiaveis. Ledger imutavel. Estado transparente. Basta ler e pronto.
Eu também acreditava nisso. Então coloquei um dashboard DeFi em produção e passei três semanas descobrindo por que nossos saldos de token estavam errados, nosso histórico de eventos tinha lacunas e nosso banco de dados continha transações de blocos que não existiam mais.
Dados on-chain são crus, hostis e cheios de edge cases que vao quebrar sua aplicação de formas que você não vai perceber ate um usuario abrir um bug report. Este post cobre tudo que eu aprendi da pior forma.
A Ilusao de Dados Confiaveis#
Aqui esta a primeira coisa que ninguém te conta: a blockchain não te da dados. Ela te da transicoes de estado. Não existe SELECT * FROM transfers WHERE user = '0x...'. Existem logs, recibos, storage slots e call traces — todos codificados em formatos que requerem contexto para decodificar.
Um log de evento Transfer te da from, to e value. Não te diz o simbolo do token. Não te diz os decimais. Não te diz se e uma transferencia legitima ou um token fee-on-transfer tirando 3% por fora. Não te diz se esse bloco ainda vai existir em 30 segundos.
A parte "imutavel" e verdadeira — uma vez finalizado. Mas a finalizacao não e instantanea. E os dados que você recebe de um node RPC não são necessariamente de um bloco finalizado. A maioria dos desenvolvedores consulta latest e trata como verdade. Isso e um bug, não uma feature.
Depois tem a codificacao. Tudo e hex. Endereços são checksummed em mixed-case (ou não). Quantidades de token são inteiros multiplicados por 10^decimals. Uma transferencia de USDC de $100 aparece como 100000000 on-chain porque USDC tem 6 decimais, não 18. Ja vi código em produção que assumia 18 decimais para todo token ERC-20. Os saldos resultantes estavam errados por um fator de 10^12.
Rate Limits de RPC Vao Arruinar Seu Fim de Semana#
Todo app Web3 em produção conversa com um endpoint RPC. E todo endpoint RPC tem rate limits que são muito mais agressivos do que você espera.
Aqui estao os números que importam:
- Alchemy Free: ~30M compute units/mes, 40 requests/minuto. Parece generoso ate você perceber que uma única chamada
eth_getLogscom um range amplo de blocos pode consumir centenas de CUs. Você vai queimar sua cota mensal em um dia de indexação. - Infura Free: 100K requests/dia, aproximadamente 1,15 req/seg. Tente paginar por 500K blocos de event logs nessa velocidade.
- QuickNode Free: Similar ao Infura — 100K requests/dia.
Os planos pagos ajudam, mas não eliminam o problema. Mesmo a $200/mes no plano Growth da Alchemy, um job pesado de indexação vai atingir limites de throughput. E quando você os atinge, não recebe uma degradacao gradual. Recebe erros 429, as vezes com mensagens inuteis, as vezes sem header retry-after.
A solução e uma combinacao de providers de fallback, lógica de retry e ser muito deliberado sobre quais chamadas você faz. Aqui esta como um setup RPC robusto se parece com viem:
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: fallback(
[
http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://mainnet.infura.io/v3/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://rpc.ankr.com/eth", {
retryCount: 2,
retryDelay: 2000,
timeout: 20_000,
}),
],
{ rank: true }
),
});A opcao rank: true e crítica. Ela diz ao viem para medir latencia e taxa de sucesso para cada transport e automaticamente preferir o mais rápido e confiavel. Se a Alchemy começar a te limitar, o viem desvia o trafego para o Infura. Se o Infura cair, ele faz fallback para o Ankr.
Mas tem uma sutileza: a lógica de retry padrão do viem usa backoff exponencial, que geralmente e o que você quer. Porém, no inicio de 2025, existe um problema conhecido onde retryCount não faz retry adequado de erros a nível de RPC (como 429s) quando o modo batch esta habilitado. Se você esta fazendo batch de requests, teste seu comportamento de retry explicitamente. Não confie que funciona.
Reorgs: O Bug Que Você Não Vai Ver Chegando#
Uma reorganização de chain acontece quando a rede discorda temporariamente sobre qual bloco e canonico. O Node A ve o bloco 1000 com transações [A, B, C]. O Node B ve um bloco 1000 diferente com transações [A, D]. Eventualmente a rede converge e uma versão vence.
Em chains proof-of-work, isso era comum — reorgs de 1-3 blocos aconteciam varias vezes por dia. O Ethereum pós-merge e melhor. Um ataque de reorg bem-sucedido agora requer coordenacao de quase 50% dos validadores. Mas "melhor" não e "impossível". Houve um reorg notavel de 7 blocos na Beacon Chain em maio de 2022, causado por implementacoes inconsistentes do proposer boost fork entre clientes.
E não importa quao raros são os reorgs no Ethereum mainnet. Se você esta construindo em L2s ou sidechains — Polygon, Arbitrum, Optimism — reorgs são mais frequentes. O Polygon historicamente teve reorgs de 10+ blocos.
Aqui esta o problema prático: você indexou o bloco 18.000.000. Gravou eventos no seu banco de dados. Então o bloco 18.000.000 sofreu reorg. Agora seu banco de dados tem eventos de um bloco que não existe na chain canonica. Esses eventos podem referenciar transações que nunca aconteceram. Seus usuarios veem transferencias fantasma.
A solução depende da sua arquitetura:
Opcao 1: Atraso de confirmação. Não indexe dados ate que N blocos de confirmação tenham passado. Para o Ethereum mainnet, 64 blocos (duas epocas) te da garantias de finalidade. Para L2s, verifique o modelo de finalidade da chain específica. Isso e simples, mas adiciona latencia — aproximadamente 13 minutos no Ethereum.
Opcao 2: Deteccao de reorg e rollback. Indexe agressivamente, mas rastreie hashes de blocos. A cada novo bloco, verifique se o parent hash corresponde ao bloco anterior que você indexou. Se não corresponder, você detectou um reorg: delete tudo dos blocos orfaos e re-indexe a chain canonica.
interface IndexedBlock {
number: bigint;
hash: `0x${string}`;
parentHash: `0x${string}`;
}
async function detectReorg(
client: PublicClient,
lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
const currentBlock = await client.getBlock({
blockNumber: lastIndexed.number,
});
if (currentBlock.hash === lastIndexed.hash) {
return { reorged: false, depth: 0 };
}
// Anda para tras para encontrar onde a chain divergiu
let depth = 1;
let checkNumber = lastIndexed.number - 1n;
while (checkNumber > 0n && depth < 128) {
const onChain = await client.getBlock({ blockNumber: checkNumber });
const inDb = await getIndexedBlock(checkNumber); // sua consulta ao banco
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}Isso não e hipotetico. Eu tive um sistema em produção onde indexavamos eventos no tip da chain sem deteccao de reorg. Por três semanas funcionou bem. Então um reorg de 2 blocos no Polygon causou um evento duplicado de mint de NFT no nosso banco de dados. O frontend mostrava um usuario sendo dono de um token que ele não tinha. Esse levou dois dias para debugar porque ninguém estava procurando reorgs como a causa raiz.
O Problema da Indexação: Escolha Sua Dor#
Você tem três opcoes reais para trazer dados estruturados on-chain para sua aplicação.
Chamadas Diretas de RPC#
Basta chamar getLogs, getBlock, getTransaction diretamente. Isso funciona para leituras de pequena escala — verificar o saldo de um usuario, buscar eventos recentes para um único contrato. Não funciona para indexação histórica ou consultas complexas entre contratos.
O problema e combinatorio. Quer todas as swaps do Uniswap V3 nos ultimos 30 dias? São ~200K blocos. Com o limite de 2K blocos por chamada getLogs da Alchemy, são no minimo 100 requests paginados. Cada um conta contra seu rate limit. E se qualquer chamada falhar, você precisa de lógica de retry, rastreamento de cursor e uma forma de retomar de onde parou.
The Graph (Subgraphs)#
O The Graph foi a solução original. Defina um schema, escreva mapeamentos em AssemblyScript, faca deploy e consulte com GraphQL. O Hosted Service foi descontinuado — tudo agora esta na Graph Network descentralizada, o que significa que você paga com tokens GRT por consultas.
O bom: padronizado, bem documentado, grande ecossistema de subgraphs existentes que você pode forkar.
O ruim: AssemblyScript e doloroso. Debugging e limitado. Deploy leva minutos a horas. Se seu subgraph tem um bug, você faz redeploy e espera ele re-sincronizar do zero. A rede descentralizada adiciona latencia e as vezes os indexadores ficam atrasados em relacao ao tip da chain.
Ja usei o The Graph para dashboards de leitura intensiva onde frescor de dados de 30-60 segundos e aceitavel. Funciona bem la. Eu não usaria para nada que exija dados em tempo real ou lógica de negocios complexa nos mapeamentos.
Indexadores Customizados (Ponder, Envio)#
E aqui que o ecossistema amadureceu significativamente. Ponder e Envio permitem que você escreva lógica de indexação em TypeScript (não AssemblyScript), rode localmente durante o desenvolvimento e faca deploy como serviços standalone.
Ponder te da controle maximo. Você define event handlers em TypeScript, ele gerência o pipeline de indexação e você obtem um banco de dados SQL como saida. O tradeoff: você e dono da infraestrutura. Escalabilidade, monitoramento, tratamento de reorgs — e com você.
Envio otimiza para velocidade de sincronizacao. Seus benchmarks mostram tempos de sincronizacao inicial significativamente mais rapidos comparados ao The Graph. Eles tratam reorgs nativamente e suportam HyperSync, um protocolo especializado para busca mais rápida de dados. O tradeoff: você esta comprando a infraestrutura e API deles.
Minha recomendação: se você esta construindo um app DeFi em produção e tem capacidade de engenharia, use Ponder. Se você precisa da sincronizacao mais rápida possível e não quer gerenciar infraestrutura, avalie Envio. Se você precisa de um prototipo rápido ou quer subgraphs mantidos pela comunidade, o The Graph ainda e uma boa opcao.
getLogs E Mais Perigoso Do Que Parece#
O método RPC eth_getLogs e enganosamente simples. De um range de blocos e alguns filtros, receba logs de eventos correspondentes. Aqui esta o que realmente acontece em produção:
Limites de range de blocos variam por provider. A Alchemy limita a 2K blocos (logs ilimitados) ou blocos ilimitados (max 10K logs). O Infura tem limites diferentes. O QuickNode tem limites diferentes. Um RPC público pode limitar a 1K blocos. Seu código deve tratar todos esses.
Limites de tamanho de resposta existem. Mesmo dentro do range de blocos, se um contrato popular emite milhares de eventos por bloco, sua resposta pode exceder o limite de payload do provider (150MB na Alchemy). A chamada não retorna resultados parciais. Ela falha.
Ranges vazios não são gratis. Mesmo se não houver logs correspondentes, o provider ainda escaneia o range de blocos. Isso conta contra suas compute units.
Aqui esta um utilitario de paginacao que lida com essas restricoes:
import type { PublicClient, Log, AbiEvent } from "viem";
async function fetchLogsInChunks<T extends AbiEvent>(
client: PublicClient,
params: {
address: `0x${string}`;
event: T;
fromBlock: bigint;
toBlock: bigint;
maxBlockRange?: bigint;
}
): Promise<Log<bigint, number, false, T, true>[]> {
const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
const allLogs: Log<bigint, number, false, T, true>[] = [];
let currentFrom = fromBlock;
while (currentFrom <= toBlock) {
const currentTo =
currentFrom + maxBlockRange - 1n > toBlock
? toBlock
: currentFrom + maxBlockRange - 1n;
try {
const logs = await client.getLogs({
address,
event,
fromBlock: currentFrom,
toBlock: currentTo,
});
allLogs.push(...logs);
currentFrom = currentTo + 1n;
} catch (error) {
// Se o range e muito grande (muitos resultados), divide ao meio
if (isRangeTooLargeError(error) && currentTo > currentFrom) {
const mid = currentFrom + (currentTo - currentFrom) / 2n;
const firstHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: currentFrom,
toBlock: mid,
maxBlockRange,
});
const secondHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: mid + 1n,
toBlock: currentTo,
maxBlockRange,
});
allLogs.push(...firstHalf, ...secondHalf);
currentFrom = currentTo + 1n;
} else {
throw error;
}
}
}
return allLogs;
}
function isRangeTooLargeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Log response size exceeded") ||
message.includes("query returned more than") ||
message.includes("exceed maximum block range")
);
}O insight chave e a divisao binaria em caso de falha. Se um range de 2K blocos retorna logs demais, divida em dois ranges de 1K. Se 1K ainda for demais, divida novamente. Isso se adapta automaticamente a contratos de alta atividade sem exigir que você saiba a densidade de eventos antecipadamente.
BigInt Vai Te Humilhar#
O tipo Number do JavaScript e um float de 64 bits. Ele pode representar inteiros ate 2^53 - 1 — cerca de 9 quadrilhoes. Parece muito ate você perceber que a quantidade de 1 ETH em wei e 1000000000000000000 — um número com 18 zeros. Isso e 10^18, muito além de Number.MAX_SAFE_INTEGER.
Se você acidentalmente converter um BigInt para Number em qualquer lugar do seu pipeline — JSON.parse, um driver de banco de dados, uma biblioteca de logging — você perde precisao silenciosamente. O número parece aproximadamente correto, mas os ultimos digitos estao errados. Você não vai pegar isso em testes porque suas quantidades de teste são pequenas.
Aqui esta o bug que eu enviei para produção:
// O BUG: Parece inofensivo, nao e
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Para quantidades pequenas funciona bem:
formatTokenAmount(1000000n, 6); // "1.0000" -- correto
// Para quantidades grandes quebra silenciosamente:
formatTokenAmount(123456789012345678n, 18);
// Retorna "0.1235" -- ERRADO, precisao real foi perdida
// Number(123456789012345678n) === 123456789012345680
// Os dois ultimos digitos foram arredondados pelo IEEE 754A solução: nunca converta para Number antes de dividir. Use os utilitarios nativos do viem, que operam em strings e BigInts:
import { formatUnits, parseUnits } from "viem";
// Correto: opera em BigInt, retorna string
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits retorna a string com precisao completa como "0.123456789012345678"
// Trunca (nao arredonda) para a precisao de exibicao desejada
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Tambem critico: use parseUnits para input do usuario, nunca parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits lida com a conversao string-para-BigInt corretamente
return parseUnits(input, decimals);
}Note que eu trunco em vez de arredondar. Isso e deliberado. Em contextos financeiros, mostrar "1.0001 ETH" quando o valor real e "1.00009999..." e melhor do que mostrar "1.0001" quando o valor real e "1.00005001..." e foi arredondado para cima. Usuarios tomam decisoes com base em valores exibidos. Truncamento e a escolha conservadora.
Outra armadilha: JSON.stringify não sabe serializar BigInt. Ele lanca um erro. Toda resposta da sua API que inclui quantidades de token precisa de uma estrategia de serializacao. Eu uso conversao para string na fronteira da API:
// Serializador de resposta da API
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}Estrategia de Cache: O Que, Por Quanto Tempo e Quando Invalidar#
Nem todos os dados on-chain tem os mesmos requisitos de frescor. Aqui esta a hierarquia que eu uso:
Cache permanente (imutavel):
- Recibos de transação (uma vez minerados, não mudam)
- Dados de blocos finalizados (hash do bloco, timestamp, lista de transações)
- Bytecode de contrato
- Logs de eventos historicos de blocos finalizados
Cache por minutos a horas:
- Metadados de token (name, symbol, decimals) — tecnicamente imutavel para a maioria dos tokens, mas upgrades de proxy podem mudar a implementação
- Resolucoes ENS — TTL de 5 minutos funciona bem
- Preços de token — depende dos seus requisitos de precisao, 30 segundos a 5 minutos
Cache por segundos ou nenhum:
- Número do bloco atual
- Saldos e nonce de contas
- Status de transações pendentes
- Logs de eventos não finalizados (o problema de reorg novamente)
A implementação não precisa ser complexa. Um cache de duas camadas com LRU em memoria e Redis cobre a maioria dos casos:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // 1 minuto padrao
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 horas em memoria, permanente no Redis
short: 1000 * 60 * 5, // 5 minutos
volatile: 1000 * 15, // 15 segundos
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// Verifica memoria primeiro
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Depois Redis (se voce tiver)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Uso:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);A lição contraintuitiva: o maior ganho de performance não e cachear respostas RPC. E evitar chamadas RPC inteiramente. Toda vez que você esta prestes a chamar getBlock, pergunte-se: eu realmente preciso de dados da chain agora, ou posso derivar de dados que ja tenho? Posso ouvir eventos via WebSocket em vez de fazer polling? Posso agrupar multiplas leituras em um único multicall?
TypeScript e ABIs de Contratos: O Jeito Certo#
O sistema de tipos do viem, alimentado pelo ABIType, fornece inferencia de tipo completa end-to-end da ABI do seu contrato para o seu código TypeScript. Mas so se você configurar corretamente.
O jeito errado:
// Sem inferencia de tipo — args e unknown[], retorno e unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // parseado em runtime = sem info de tipo
functionName: "balanceOf",
args: ["0x..."],
});O jeito certo:
// Defina a ABI como const para inferencia completa de tipo
const erc20Abi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "success", type: "bool" }],
},
] as const;
// Agora o TypeScript sabe:
// - functionName autocompleta para "balanceOf" | "transfer"
// - args para balanceOf e [address: `0x${string}`]
// - tipo de retorno para balanceOf e bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- totalmente tipadoA asserção as const e o que faz funcionar. Sem ela, o TypeScript amplia o tipo da ABI para { name: string, type: string, ... }[] e toda a maquinaria de inferencia desmorona. Esse e o erro mais comum que vejo em codebases TypeScript de Web3.
Para projetos maiores, use @wagmi/cli para gerar bindings tipados de contrato diretamente do seu projeto Foundry ou Hardhat. Ele le suas ABIs compiladas e produz arquivos TypeScript com assercoes as const ja aplicadas. Sem copia manual de ABI, sem drift de tipos.
A Verdade Desconfortavel#
Dados de blockchain são um problema de sistemas distribuidos disfarçado de problema de banco de dados. No momento em que você trata como "so mais uma API", você comeca a acumular bugs que são invisiveis no desenvolvimento e intermitentes em produção.
As ferramentas melhoraram drasticamente. O viem e uma melhoria massiva em relacao ao ethers.js em seguranca de tipos e experiência de desenvolvedor. Ponder e Envio tornaram a indexação customizada acessivel. Mas os desafios fundamentais — reorgs, rate limits, codificacao, finalidade — são a nível de protocolo. Nenhuma biblioteca os abstrai completamente.
Construa com a suposicao de que seu RPC vai mentir para você, seus blocos vao reorganizar, seus números vao estourar e seu cache vai servir dados desatualizados. Então trate cada caso explicitamente.
E isso que dados on-chain de nível produção parecem de verdade.