Saltar al contenido
·13 min de lectura

Datos On-Chain en Producción: Lo Que Nadie Te Cuenta

Los datos de blockchain no son limpios, confiables ni fáciles. Limites de tasa RPC, reorganizaciones de cadena, errores de BigInt y tradeoffs de indexación — lecciones duras de lanzar productos DeFi reales.

Compartir:X / TwitterLinkedIn

Existe esta fantasia de que los datos on-chain son inherentemente confiables. Libro mayor inmutable. Estado transparente. Solo leelos y listo.

Yo también lo creia. Luego lance un dashboard DeFi a producción y pase tres semanas averiguando por que nuestros balances de tokens estaban mal, nuestro historial de eventos tenia vacios y nuestra base de datos contenia transacciones de bloques que ya no existian.

Los datos on-chain son crudos, hostiles y llenos de casos extremos que romperan tu aplicación de formas que no notaras hasta que un usuario reporte un bug. Este post cubre todo lo que aprendí a las malas.

La Ilusion de Datos Confiables#

Aquí esta lo primero que nadie te dice: la blockchain no te da datos. Te da transiciones de estado. No hay un SELECT * FROM transfers WHERE user = '0x...'. Hay logs, recibos, slots de almacenamiento y trazas de llamadas, todo codificado en formatos que requieren contexto para decodificar.

Un log de evento Transfer te da from, to y value. No te dice el símbolo del token. No te dice los decimales. No te dice si es una transferencia legitima o un token con fee-on-transfer que descuenta el 3%. No te dice si este bloque seguira existiendo en 30 segundos.

La parte "inmutable" es cierta, una vez finalizado. Pero la finalizacion no es instantanea. Y los datos que recibes de un nodo RPC no necesariamente son de un bloque finalizado. La mayoria de los desarrolladores consultan latest y lo tratan como verdad. Eso es un bug, no una funcionalidad.

Luego esta la codificacion. Todo es hexadecimal. Las direcciones tienen checksums en mayusculas y minusculas (o no). Las cantidades de tokens son enteros multiplicados por 10^decimals. Una transferencia de USDC de $100 se ve como 100000000 on-chain porque USDC tiene 6 decimales, no 18. He visto código en producción que asumia 18 decimales para cada token ERC-20. Los balances resultantes estaban desfasados por un factor de 10^12.

Los Limites de Tasa RPC Arruinaran Tu Fin de Semana#

Toda aplicación Web3 en producción se comunica con un endpoint RPC. Y cada endpoint RPC tiene limites de tasa que son mucho mas agresivos de lo que esperas.

Aquí estan los numeros que importan:

  • Alchemy Free: ~30M unidades de computo/mes, 40 solicitudes/minuto. Eso suena generoso hasta que te das cuenta de que una sola llamada eth_getLogs sobre un rango amplio de bloques puede consumir cientos de CUs. Agotaras tu cuota mensual en un dia de indexación.
  • Infura Free: 100K solicitudes/dia, aproximadamente 1.15 req/seg. Intenta paginar a través de 500K bloques de logs de eventos a ese ritmo.
  • QuickNode Free: Similar a Infura — 100K solicitudes/dia.

Los planes de pago ayudan, pero no eliminan el problema. Incluso a $200/mes en el plan Growth de Alchemy, un trabajo pesado de indexación alcanzara los limites de rendimiento. Y cuando los alcanzas, no obtienes una degradacion elegante. Obtienes errores 429, a veces con mensajes poco útiles, a veces sin header retry-after.

La solución es una combinacion de proveedores de respaldo, lógica de reintentos y ser muy deliberado sobre que llamadas haces. Así es como se ve una configuración RPC robusta con viem:

typescript
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 }
  ),
});

La opcion rank: true es crítica. Le dice a viem que mida la latencia y la tasa de exito para cada transport y que automáticamente prefiera el mas rápido y confiable. Si Alchemy empieza a limitarte, viem redirige el tráfico a Infura. Si Infura se cae, recurre a Ankr.

Pero hay una sutileza: la lógica de reintentos por defecto de viem usa backoff exponencial, que es generalmente lo que quieres. Sin embargo, a principios de 2025, hay un problema conocido donde retryCount no reintenta correctamente los errores a nivel RPC (como los 429) cuando el modo batch esta habilitado. Si estas haciendo batching de solicitudes, prueba tu comportamiento de reintentos explicitamente. No confies en que funciona.

Reorgs: El Bug Qué No Veras Venir#

Una reorganización de cadena ocurre cuando la red temporalmente no esta de acuerdo sobre cual bloque es el canonico. El Nodo A ve el bloque 1000 con transacciones [A, B, C]. El Nodo B ve un bloque 1000 diferente con transacciones [A, D]. Eventualmente la red converge, y una versión gana.

En cadenas de proof-of-work, esto era comun — reorganizaciones de 1-3 bloques sucedian multiples veces por dia. Ethereum post-merge es mejor. Un ataque de reorganización exitoso ahora requiere la coordinacion de cerca del 50% de los validadores. Pero "mejor" no es "imposible". Hubo una reorganización notable de 7 bloques en la Beacon Chain en mayo de 2022, causada por implementaciones inconsistentes del proposer boost fork entre clientes.

Y no importa cuan raras sean las reorganizaciones en Ethereum mainnet. Si estas construyendo sobre L2s o sidechains — Polygon, Arbitrum, Optimism — las reorganizaciones son mas frecuentes. Polygon historicamente ha tenido reorganizaciones de 10+ bloques.

Aquí esta el problema práctico: indexaste el bloque 18,000,000. Escribiste eventos en tu base de datos. Luego el bloque 18,000,000 fue reorganizado. Ahora tu base de datos tiene eventos de un bloque que no existe en la cadena canonica. Esos eventos podrian referenciar transacciones que nunca ocurrieron. Tus usuarios ven transferencias fantasma.

La solución depende de tu arquitectura:

Opcion 1: Retraso de confirmación. No indexes datos hasta que hayan pasado N bloques de confirmaciones. Para Ethereum mainnet, 64 bloques (dos epochs) te da garantias de finalidad. Para L2s, consulta el modelo de finalidad de la cadena específica. Esto es simple pero agrega latencia — aproximadamente 13 minutos en Ethereum.

Opcion 2: Deteccion de reorgs y rollback. Indexa agresivamente pero rastrea los hashes de bloques. En cada nuevo bloque, verifica que el hash padre coincida con el bloque anterior que indexaste. Si no coincide, has detectado una reorganización: elimina todo de los bloques huerfanos y re-indexa la cadena canonica.

typescript
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 };
  }
 
  // Retrocede para encontrar donde la cadena divergio
  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); // tu consulta a la BD
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

Esto no es hipotetico. Tuve un sistema en producción donde indexabamos eventos en la punta de la cadena sin deteccion de reorgs. Durante tres semanas funciono bien. Luego una reorganización de 2 bloques en Polygon causo un evento duplicado de mint de NFT en nuestra base de datos. El frontend mostraba a un usuario como dueno de un token que no tenia. Ese caso tomo dos dias depurar porque nadie buscaba reorganizaciones como causa raiz.

El Problema de Indexación: Elige Tu Dolor#

Tienes tres opciones reales para obtener datos on-chain estructurados en tu aplicación.

Llamadas RPC Directas#

Simplemente llama getLogs, getBlock, getTransaction directamente. Esto funciona para lecturas a pequeña escala — verificar el balance de un usuario, obtener eventos recientes de un solo contrato. No funciona para indexación histórica o consultas complejas entre contratos.

El problema es combinatorio. Quieres todos los swaps de Uniswap V3 en los ultimos 30 dias? Eso son ~200K bloques. Con el limite de 2K bloques por llamada getLogs de Alchemy, son 100 solicitudes paginadas como minimo. Cada una cuenta contra tu limite de tasa. Y si cualquier llamada falla, necesitas lógica de reintentos, seguimiento de cursor y una forma de retomar donde te quedaste.

The Graph (Subgraphs)#

The Graph fue la solución original. Define un schema, escribe mappings en AssemblyScript, despliega y consulta con GraphQL. El Hosted Service fue deprecado — todo esta ahora en la red descentralizada de Graph Network, lo que significa que pagas con tokens GRT por consultas.

Lo bueno: estandarizado, bien documentado, gran ecosistema de subgraphs existentes que puedes bifurcar.

Lo malo: AssemblyScript es doloroso. La depuración es limitada. El despliegue tarda de minutos a horas. Si tu subgraph tiene un bug, redespliegas y esperas a que se re-sincronice desde cero. La red descentralizada agrega latencia y a veces los indexadores van por detrás de la punta de la cadena.

He usado The Graph para dashboards de lectura intensiva donde la frescura de datos de 30-60 segundos es aceptable. Funciona bien ahi. No lo usaria para nada que requiera datos en tiempo real o lógica de negocio compleja en los mappings.

Indexadores Personalizados (Ponder, Envio)#

Aquí es donde el ecosistema ha madurado significativamente. Ponder y Envio te permiten escribir lógica de indexación en TypeScript (no AssemblyScript), ejecutar localmente durante el desarrollo y desplegar como servicios independientes.

Ponder te da control maximo. Defines manejadores de eventos en TypeScript, gestiona el pipeline de indexación y obtienes una base de datos SQL como salida. El tradeoff: la infraestructura es tu responsabilidad. Escalado, monitoreo, manejo de reorgs — esta en tus manos.

Envio optimiza para velocidad de sincronizacion. Sus benchmarks muestran tiempos de sincronizacion inicial significativamente mas rapidos comparados con The Graph. Manejan reorgs nativamente y soportan HyperSync, un protocolo especializado para obtencion mas rápida de datos. El tradeoff: te comprometes con su infraestructura y API.

Mi recomendación: si estas construyendo una aplicación DeFi en producción y tienes capacidad de ingenieria, usa Ponder. Si necesitas la sincronizacion mas rápida posible y no quieres gestionar infraestructura, evalua Envio. Si necesitas un prototipo rápido o quieres subgraphs mantenidos por la comunidad, The Graph sigue siendo una buena opcion.

getLogs Es Mas Peligroso De Lo Qué Parece#

El método RPC eth_getLogs es enganosamente simple. Dale un rango de bloques y algunos filtros, y devuelve los logs de eventos que coincidan. Esto es lo que realmente pasa en producción:

Los limites de rango de bloques varian segun el proveedor. Alchemy limita a 2K bloques (logs ilimitados) o bloques ilimitados (maximo 10K logs). Infura tiene limites diferentes. QuickNode tiene limites diferentes. Un RPC público podría limitar a 1K bloques. Tu código debe manejar todos estos.

Existen limites de tamaño de respuesta. Incluso dentro del rango de bloques, si un contrato popular emite miles de eventos por bloque, tu respuesta puede exceder el limite de payload del proveedor (150MB en Alchemy). La llamada no devuelve resultados parciales. Falla.

Los rangos vacios no son gratis. Incluso si hay cero logs que coincidan, el proveedor aun escanea el rango de bloques. Esto cuenta contra tus unidades de computo.

Aquí hay una utilidad de paginacion que maneja estas restricciones:

typescript
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) {
      // Si el rango es demasiado grande (demasiados resultados), dividirlo a la mitad
      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")
  );
}

La idea clave es la division binaria ante fallos. Si un rango de 2K bloques devuelve demasiados logs, dividelo en dos rangos de 1K. Si 1K sigue siendo demasiado, divide de nuevo. Esto se adapta automáticamente a contratos de alta actividad sin requerir que conozcas la densidad de eventos de antemano.

BigInt Te Pondra en Tu Lugar#

El tipo Number de JavaScript es un float de 64 bits. Puede representar enteros hasta 2^53 - 1 — alrededor de 9 cuatrillones. Eso suena como mucho hasta que te das cuenta de que una cantidad de token de 1 ETH en wei es 1000000000000000000 — un número con 18 ceros. Eso es 10^18, muy por encima de Number.MAX_SAFE_INTEGER.

Si accidentalmente conviertes un BigInt a Number en cualquier parte de tu pipeline — JSON.parse, un driver de base de datos, una libreria de logging — obtienes pérdida de precision silenciosa. El número se ve aproximadamente correcto pero los ultimos digitos estan mal. No detectaras esto en testing porque tus cantidades de prueba son pequenas.

Aquí esta el bug que lance a producción:

typescript
// EL BUG: Parece inofensivo, no lo es
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// Para cantidades pequenas esto funciona bien:
formatTokenAmount(1000000n, 6); // "1.0000" -- correcto
 
// Para cantidades grandes se rompe silenciosamente:
formatTokenAmount(123456789012345678n, 18);
// Devuelve "0.1235" -- INCORRECTO, se perdio precision
// Number(123456789012345678n) === 123456789012345680
// Los ultimos dos digitos fueron redondeados por IEEE 754

La solución: nunca conviertas a Number antes de dividir. Usa las utilidades integradas de viem, que operan con strings y BigInts:

typescript
import { formatUnits, parseUnits } from "viem";
 
// Correcto: opera con BigInt, devuelve string
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits devuelve el string con precision completa como "0.123456789012345678"
  // Truncar (no redondear) a la precision de visualizacion deseada
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// Tambien critico: usa parseUnits para input del usuario, nunca parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits maneja la conversion de string a BigInt correctamente
  return parseUnits(input, decimals);
}

Observa que trunco en lugar de redondear. Esto es deliberado. En contextos financieros, mostrar "1.0001 ETH" cuando el valor real es "1.00009999..." es mejor que mostrar "1.0001" cuando el valor real es "1.00005001..." y fue redondeado hacia arriba. Los usuarios toman decisiones basadas en las cantidades mostradas. El truncamiento es la opcion conservadora.

Otra trampa: JSON.stringify no sabe como serializar BigInt. Lanza un error. Cada respuesta de tu API que incluya cantidades de tokens necesita una estrategia de serializacion. Yo uso conversion a string en la frontera de la API:

typescript
// Serializador de respuesta API
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

Estrategia de Cache: Qué, Por Cuanto Tiempo y Cuando Invalidar#

No todos los datos on-chain tienen los mismos requisitos de frescura. Aquí esta la jerarquia que uso:

Cachear para siempre (inmutable):

  • Recibos de transacciones (una vez minadas, no cambian)
  • Datos de bloques finalizados (hash del bloque, timestamp, lista de transacciones)
  • Bytecode de contratos
  • Logs de eventos historicos de bloques finalizados

Cachear por minutos a horas:

  • Metadatos de tokens (nombre, símbolo, decimales) — tecnicamente inmutable para la mayoria de los tokens, pero las actualizaciones de proxy pueden cambiar la implementación
  • Resoluciones ENS — un TTL de 5 minutos funciona bien
  • Precios de tokens — depende de tus requisitos de precision, de 30 segundos a 5 minutos

Cachear por segundos o nada:

  • Número de bloque actual
  • Balances y nonce de cuentas
  • Estado de transacciones pendientes
  • Logs de eventos no finalizados (el problema de reorgs de nuevo)

La implementación no necesita ser compleja. Un cache de dos niveles con LRU en memoria y Redis cubre la mayoria de los casos:

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 1 minuto por defecto
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 24 horas en memoria, permanente en 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> {
  // Verificar memoria primero
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // Luego Redis (si lo tienes)
  // 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 })
);

La leccion contraintuitiva: la mayor ganancia de rendimiento no es cachear respuestas RPC. Es evitar llamadas RPC por completo. Cada vez que estes a punto de llamar getBlock, preguntate: realmente necesito datos de la cadena ahora mismo, o puedo derivarlos de datos que ya tengo? Puedo escuchar eventos via WebSocket en lugar de hacer polling? Puedo agrupar multiples lecturas en un solo multicall?

TypeScript y ABIs de Contratos: La Forma Correcta#

El sistema de tipos de viem, potenciado por ABIType, proporciona inferencia de tipos completa de extremo a extremo desde el ABI de tu contrato hasta tu código TypeScript. Pero solo si lo configuras correctamente.

La forma incorrecta:

typescript
// Sin inferencia de tipos — args es unknown[], return es unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // parseado en runtime = sin info de tipos
  functionName: "balanceOf",
  args: ["0x..."],
});

La forma correcta:

typescript
// Define el ABI como const para inferencia completa de tipos
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;
 
// Ahora TypeScript sabe:
// - functionName autocompleta a "balanceOf" | "transfer"
// - args para balanceOf es [address: `0x${string}`]
// - el tipo de retorno para balanceOf es bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- completamente tipado

La asercion as const es lo que lo hace funcionar. Sin ella, TypeScript amplia el tipo del ABI a { name: string, type: string, ... }[] y toda la maquinaria de inferencia colapsa. Este es el error mas comun que veo en bases de código Web3 con TypeScript.

Para proyectos mas grandes, usa @wagmi/cli para generar bindings tipados de contratos directamente desde tu proyecto Foundry o Hardhat. Lee tus ABIs compilados y produce archivos TypeScript con aserciones as const ya aplicadas. Sin copia manual de ABIs, sin desfase de tipos.

La Verdad Incomoda#

Los datos de blockchain son un problema de sistemas distribuidos disfrazado de problema de base de datos. En el momento en que lo tratas como "solo otra API", empiezas a acumular bugs que son invisibles en desarrollo e intermitentes en producción.

Las herramientas han mejorado dramaticamente. Viem es una mejora masiva sobre ethers.js en seguridad de tipos y experiencia de desarrollador. Ponder y Envio han hecho accesible la indexación personalizada. Pero los desafios fundamentales — reorgs, limites de tasa, codificacion, finalidad — son a nivel de protocolo. Ninguna libreria los abstrae por completo.

Construye con la suposicion de que tu RPC te mentira, tus bloques se reorganizaran, tus numeros desbordaran y tu cache servira datos obsoletos. Luego maneja cada caso explicitamente.

Así es como se ven los datos on-chain de nivel producción.

Artículos relacionados