Блокчейн4 мин чтения

Чистый отчёт, который врал: как 420 живых пар стали мёртвыми

Наш сканер обещал проверять 577 пулов умирающей сети, а проверял 157: три строки кода превратили неудачное чтение в «здесь пусто».

У инструмента, который мы выложили перед отключением DFK Chain, было одно обещание, записанное в README прямыми словами: он проверяет все 577 пар фабрики, а не четырнадцать документированных. Причина в README тоже названа. Человек мог дать ликвидность в любую пару, которую фабрика когда-либо создала, и инструмент, проверяющий четырнадцать, скажет остальным «у вас чисто» — это худший из возможных отказов для инструмента, который читают за три дня до смерти сети.

28 августа выяснилось, что наш инструмент делал ровно это. Не по злому умыслу и не из-за сложной ошибки: из-за трёх строк, которые каждый из нас писал сотню раз.

Как это вскрылось

Мы четвёртый раз замеряли, сколько денег осталось в пулах DFK Chain. Замер простой: взять список живых пар, прочитать резервы и остатки двух стейкинг-контрактов, оценить всё через USDC.

Получилось $197 121. Предыдущий выпуск дайджеста, вышедший четырьмя днями раньше, называл цифру около $620 тысяч — и падение на две трети за неделю выглядело как отличный сюжет: люди наконец побежали.

Сюжет мы не написали. Вместо этого прогнали тот же скрипт на историческом блоке — на том самом, которым мерили 22 августа. Если метод верен, он должен воспроизвести старое число.

Он его не воспроизвёл. На блоке 22 августа новый прогон дал $424 895 вместо опубликованных $620 тысяч. Расхождение в 31 % на данных, которые не могут измениться задним числом, означает ровно одно: проблема не в сети, а в нас.

Настоящая причина

Список пар собирается на этапе сборки реестра и кэшируется. Скрипт сборки читает у каждой пары три поля, и каждое чтение обёрнуто так:

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

Дальше кэш попадает в генератор рантайма, где стоит фильтр, звучащий совершенно разумно:

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

Обе строки правильны по отдельности. Вместе они дают дефект: узел под нагрузкой отвечает 500, .catch превращает отказ в null, а фильтр читает null как «в паре ничего нет». Пара, которую не удалось прочитать, и пара, в которой пусто, становятся одним и тем же.

Мы проверили на цепочке: из кэша 420 пар из 577 значились нечитаемыми. Взяли двадцать наугад и прочитали живьём — ненулевой supply у всех двадцати. Ни одна из них не была мёртвой. Просто в тот вечер сборки узел отдавал 500 чаще, чем отвечал.

Итог: 577 → 157 в логе сборки выглядел как честная фильтрация мёртвых пар. На деле это был отчёт о том, сколько чтений прошло.

Сколько это стоило

Здесь важно не увлечься. Мы посчитали, а не предположили.

В 414 парах, выпавших из покрытия, на момент отключения сети лежало $776 из $274 634 — три десятых процента. Крупные пулы уцелели, потому что документированные пары шли в инструмент вторым, независимым путём: их четырнадцать, и они прописаны в реестре руками.

То есть дефект грубый, а цена его маленькая. Обе половины этой фразы обязательны. Без первой получается «ничего страшного не случилось», без второй — паника на ровном месте. Реальный масштаб такой: человек с позицией в одной из 414 пар получал чистый отчёт, и таких позиций набралось на $776.

Про наш собственный замер цена была выше: он занижал итог на $77,6 тысячи, потому что фильтровал пары по тому же испорченному кэшу. Если бы мы не сверились с историческим блоком, выпуск дайджеста вышел бы с числом, противоречащим предыдущему выпуску, и объяснить это расхождение было бы нечем.

Что починили

Три вещи, все — про одно и то же различие.

  1. Пауза и повторы вместо пачки. Батч уменьшается при отказах (40 → 10 → 5 → 3), между заходами пауза. Узел умирающей сети — не то место, где стоит торопиться: ретраями там зарабатывают бан, а не данные.
  2. Непрочитанное остаётся null и печатается предупреждением. В артефакт добавлено поле unreadCount. Ноль там — часть отчёта, а не отсутствие проблемы.
  3. Ранний выход учитывает нечитаемые записи. Раньше скрипт видел, что число пар совпало с прошлым разом, и уходил с сообщением «кэш актуален» — вместе со всеми дырами внутри.

После пересборки: 577 живых пар, нечитаемых ноль.

Правило, ради которого это стоит помнить

«Не нашли» и «не проверяли» не должны выглядеть одинаково на выходе.

Это не про блокчейн. Тот же дефект живёт в трёх местах, которые мы встретили на одной неделе:

  • Редакция чувствительных данных в агентных пайплайнах. Рекомендуемая схема чистит ответы API через классификатор персональных данных. Классификатор иногда не срабатывает — и тогда «в тексте нет персональных данных» и «классификатор не отработал» дают на выходе один и тот же поток. Действовать будут по второму.
  • Страницы депрекейтов Chainlink. Объявление ссылается на страницу со сроками, страница рисуется на клиенте, и в HTML дат нет вовсе. Проверка «даты нет» и «дату не удалось прочитать» — снова одно и то же.
  • Живость самой DFK Chain. Сеть остановилась 29 августа в 04:25 UTC, последний блок — 62 472 734. RPC при этом отвечает до сих пор: eth_blockNumber возвращает номер, eth_call читает замороженное состояние. Проверка «сеть жива?» по факту ответа узла показывает «жива» и через трое суток после смерти. Живость видно только по времени последнего блока — и это ровно та проверка, которую никто не делает.

Дешёвый способ не попасться: везде, где в коде стоит catch рядом с внешним чтением, спросить себя, отличит ли вызывающий пустой результат от несостоявшегося. Если не отличит — вы пишете инструмент, который однажды выдаст чистый отчёт человеку, у которого не чисто.

Реестр, сканер и починка — в открытом репозитории sergeipalii/dfk-chain-sunset. Сеть, ради которой он писался, уже не отвечает — а привычка отличать «пусто» от «не прочитали» переживёт её надолго.

Сергей Палий

Основатель, Sepia Software

Обо мне

Читать дальше

Все статьи