Чистый отчёт, который врал: как 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 тысячи, потому что фильтровал пары по тому же испорченному кэшу. Если бы мы не сверились с историческим блоком, выпуск дайджеста вышел бы с числом, противоречащим предыдущему выпуску, и объяснить это расхождение было бы нечем.
Что починили
Три вещи, все — про одно и то же различие.
- Пауза и повторы вместо пачки. Батч уменьшается при отказах (40 → 10 → 5 → 3), между заходами пауза. Узел умирающей сети — не то место, где стоит торопиться: ретраями там зарабатывают бан, а не данные.
- Непрочитанное остаётся
nullи печатается предупреждением. В артефакт добавлено полеunreadCount. Ноль там — часть отчёта, а не отсутствие проблемы. - Ранний выход учитывает нечитаемые записи. Раньше скрипт видел, что число пар совпало с прошлым разом, и уходил с сообщением «кэш актуален» — вместе со всеми дырами внутри.
После пересборки: 577 живых пар, нечитаемых ноль.
Правило, ради которого это стоит помнить
«Не нашли» и «не проверяли» не должны выглядеть одинаково на выходе.
Это не про блокчейн. Тот же дефект живёт в трёх местах, которые мы встретили на одной неделе:
- Редакция чувствительных данных в агентных пайплайнах. Рекомендуемая схема чистит ответы API через классификатор персональных данных. Классификатор иногда не срабатывает — и тогда «в тексте нет персональных данных» и «классификатор не отработал» дают на выходе один и тот же поток. Действовать будут по второму.
- Страницы депрекейтов Chainlink. Объявление ссылается на страницу со сроками, страница рисуется на клиенте, и в HTML дат нет вовсе. Проверка «даты нет» и «дату не удалось прочитать» — снова одно и то же.
- Живость самой DFK Chain. Сеть остановилась 29 августа в 04:25 UTC, последний блок — 62 472 734. RPC при этом отвечает до сих пор:
eth_blockNumberвозвращает номер,eth_callчитает замороженное состояние. Проверка «сеть жива?» по факту ответа узла показывает «жива» и через трое суток после смерти. Живость видно только по времени последнего блока — и это ровно та проверка, которую никто не делает.
Дешёвый способ не попасться: везде, где в коде стоит catch рядом с внешним чтением, спросить себя, отличит ли вызывающий пустой результат от несостоявшегося. Если не отличит — вы пишете инструмент, который однажды выдаст чистый отчёт человеку, у которого не чисто.
Реестр, сканер и починка — в открытом репозитории sergeipalii/dfk-chain-sunset. Сеть, ради которой он писался, уже не отвечает — а привычка отличать «пусто» от «не прочитали» переживёт её надолго.
Читать дальше
Что оказалось в реестре умирающей сети
Собрали реестр контрактов DFK Chain за две недели до отключения. 84% ликвидности лежит в стейкинге, пятая часть — в контракте, который проект давно заменил.
Отключение DFK Chain: что сделать до 28 августа
DFK Chain отключают 28 августа 2026 года. Что сказано в анонсе, где он заканчивается и три места, где активы прячутся от баланса кошелька.
Infra Graveyard Weekly №05: DFK Chain остановилась, а $236 000 остались внутри
Пять замеров одной умирающей сети за месяц. Половина денег ушла за шесть дней, а крупнейшим держателем оставшегося оказался брошенный контракт.