Un informe limpio que mentía: cómo 420 pares vivos se dieron por muertos

Nuestro escáner prometía revisar 577 pares de una red moribunda y revisaba 157: tres líneas de código convirtieron una lectura fallida en «aquí no hay nada».

La herramienta que publicamos antes del apagado de DFK Chain hacía una sola promesa, escrita en el README sin rodeos: revisa los 577 pares que la factory haya creado alguna vez, no los catorce documentados. El README también explica por qué. Cualquiera podía aportar liquidez a cualquier par que la factory hubiera creado, y una herramienta que revisa catorce les dice a todos los demás «no tienes nada»: el peor fallo posible para una herramienta que la gente consulta tres días antes de que muera una red.

El 28 de agosto descubrimos que nuestra herramienta hacía exactamente eso. No por mala intención ni por un error enrevesado: por tres líneas que todos hemos escrito cien veces.

Cómo salió a la luz

Estábamos midiendo por cuarta vez cuánto dinero quedaba en los pools de liquidez de DFK Chain. La medición es sencilla: tomar la lista de pares vivos, leer las reservas de cada par y los saldos en dos contratos de staking, y convertirlo todo a USDC.

Salieron 197 121 USD. La cifra anterior, publicada en el boletín cuatro días antes, era de unos 620 000 USD, y una caída de dos tercios en menos de una semana parecía una historia estupenda: la gente por fin había salido corriendo.

No escribimos esa historia. En vez de eso, ejecutamos el mismo script en un bloque histórico: el mismo con el que habíamos medido el 22 de agosto. Si el método es correcto, tiene que reproducir la cifra anterior.

No la reprodujo. En el bloque del 22 de agosto, la nueva ejecución dio 424 895 USD en lugar de los 620 000 USD publicados. Una discrepancia del 31 % sobre datos que no pueden cambiar a posteriori significa exactamente una cosa: el problema no está en la red, es nuestro.

La causa raíz

La lista de pares se genera al construir el registro de contratos y se guarda en caché. El script de generación lee tres campos de cada par, y cada lectura va envuelta así:

client.readContract({ address, abi, functionName: 'totalSupply' }).catch(() => null)

Después, la caché pasa al generador del runtime, donde hay un filtro que parece perfectamente razonable:

// A pair with zero supply cannot hold anyone's balance.
const live = pairs.filter((p) => p.totalSupply && p.totalSupply !== '0')

Cada línea es correcta por separado. Juntas producen el defecto: el nodo responde 500 bajo carga, .catch convierte el fallo en null y el filtro interpreta null como «en este par no hay nada». Un par que no se pudo leer y un par vacío pasan a ser lo mismo.

Lo contrastamos con la red: en la caché, 420 de los 577 pares figuraban como no leídos (seis de ellos estaban además en la lista escrita a mano y siguieron cubiertos, así que los que desaparecieron de la cobertura fueron 414). Tomamos veinte al azar y los leímos directamente de la red: supply distinto de cero en los veinte. Ninguno estaba muerto. Simplemente, la noche en que se generó la caché, el nodo devolvía 500 más veces de las que devolvía datos.

En resumen: el 577 → 157 del log de generación parecía un filtrado honesto de pares muertos. En realidad era un recuento de cuántas lecturas habían salido bien.

Cuánto costó

Aquí conviene no dejarse llevar por la cifra. Hicimos la cuenta, no una suposición.

En los 414 pares que quedaron fuera de la cobertura había, en el momento del apagado de la red, 776 de 274 634 USD: un 0,3 %. Los pares grandes se salvaron porque los pares documentados llegan a la herramienta por una segunda vía independiente: son catorce y están metidos a mano en el registro.

Es decir, el defecto es burdo y el daño, pequeño. Las dos mitades de esa frase son imprescindibles. Sin la primera se queda en «no pasó nada grave»; sin la segunda, en pánico por nada. La magnitud real es esta: una persona con una posición en uno de esos 414 pares recibía un informe limpio, y esas posiciones sumaban 776 USD.

Para nuestra propia medición el daño fue mayor: subestimaba el total en 77 600 USD, porque filtraba los pares con la misma caché defectuosa. Si no hubiéramos contrastado con el bloque histórico, el boletín habría salido con una cifra que contradecía la anterior, y no habría habido forma de explicar la diferencia.

Qué arreglamos

Tres cosas, y las tres giran en torno a la misma distinción.

  1. Pausas y reintentos en lugar de un lote grande. El lote se reduce cuando hay fallos (40 → 10 → 5 → 3), con una pausa entre pasadas. El nodo de una red moribunda no es un lugar donde convenga tener prisa: ahí, a fuerza de reintentos, te ganas un bloqueo, no datos.
  2. Lo que no se pudo leer sigue siendo null y se registra como advertencia. La salida del build tiene ahora un campo unreadCount. Un cero ahí es un dato del informe, no la ausencia de un problema.
  3. La salida anticipada tiene en cuenta las entradas no leídas. Antes, el script veía que el número de pares coincidía con el de la ejecución anterior y terminaba con el mensaje «la caché está al día», con todos los agujeros dentro.

Tras regenerar la caché: 577 pares vivos, cero sin leer.

La regla que vale la pena recordar

«Vacío» y «no leído» no deben verse igual en la salida.

Esto no se limita a blockchain. El mismo defecto apareció en tres lugares con los que nos topamos en la misma semana:

  • El enmascaramiento de datos sensibles en pipelines de agentes. El patrón recomendado limpia las respuestas de la API con un clasificador de datos personales. El clasificador a veces no llega a ejecutarse, y entonces «en el texto no hay datos personales» y «el clasificador no se ejecutó» producen exactamente la misma salida. Lo que venga después en el pipeline tratará el segundo caso como si fuera el primero.
  • Las páginas de deprecación de Chainlink. El anuncio enlaza a una página con las fechas, pero la página se renderiza en el cliente y en el HTML no aparece ninguna. La comprobación «no hay fecha» y la comprobación «no se pudo leer la fecha» son, otra vez, lo mismo.
  • Saber si la propia DFK Chain sigue viva. La red se detuvo el 29 de agosto a las 04:25 UTC; último bloque, 62 472 734. El RPC sigue respondiendo todavía hoy: eth_blockNumber devuelve un número y eth_call lee el estado congelado. Una comprobación de vida basada en si el nodo responde dice «viva» tres días después de la muerte de la red. Que la red esté viva solo se ve en la marca de tiempo del último bloque, y esa es precisamente la comprobación que nadie hace.

La manera más barata de no caer en esto: en cada punto del código donde haya un catch junto a una lectura externa, preguntarse si el código que llama distinguirá un resultado vacío de una lectura que no llegó a ocurrir. Si no lo distingue, estás escribiendo una herramienta que algún día entregará un informe limpio a alguien cuya situación no tiene nada de limpia.

El registro de contratos, el escáner y el arreglo están en el repositorio público sergeipalii/dfk-chain-sunset. La red para la que se escribió ya está parada, pero la costumbre de distinguir «vacío» de «no leído» le sobrevivirá por mucho tiempo.

Sergei Palii

Fundador de Sepia Software

Sobre mí

Sigue leyendo

Todos los artículos