HF15 — фиксы PM-аудита: чеклист активации и миграция устаревшего состояния
Статус: оба фикса реализованы, но на mainnet не запланированы. Форк зарегистрирован в обеих конфигурациях (CHAIN_NUM_HARDFORKS = 15 везде) и гейтится временем активации плюс кворумом валидаторов, так что production-бинарь применит его, как только это время наступит; до тех пор ничего не выполняется, и миграция не нужна ни в каком случае. Два вкомпилированных таймстемпа — в §2. Эта заметка — операторский чеклист: что именно гейтится, как форк регистрируется и активируется, что проверять и какое решение принять по состоянию, которое legacy-путь уже оставил после себя.
1. Что меняет форк
Оба гейта читаются как has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK) в pm_evaluator.cpp; до форка историческое поведение сохраняется байт-в-байт, так что обычный реплей не задет.
A. Частичный вывод LMSR-ликвидности (pm_withdraw_liquidity, market_type == 1). Кривая держит lmsr_b на рынок, а каждая LP-строка — свой b_share, т.е. свою долю этого lmsr_b. При частичном выводе раньше вычитался floored b_remove из market.lmsr_b, а b_share строки оставался нетронутым → строка продолжала претендовать на кривую больше, чем рынок держит. Следующий вывод из этой же строки (обычно полный выход) забирал весь устаревший b_share и мог загнать lmsr_b в <= 0. После фикса обе записи уменьшаются на один и тот же b_remove, а вывод, у которого b_remove превысил бы market.lmsr_b, отклоняется (FC_ASSERT), а не клампится.
Почему отказ, а не кламп: lmsr_b <= 0 — это не «плоская кривая». lmsr_q96 падает мягко — lmsr_price, lmsr_buy_cost и lmsr_tokens_for_amount возвращают 0 при b <= 0, вообще не доходя до validate_domain — т.е. все исходы стоят ноль и ставка бесплатна, при том что на рынке остаются и капитал LP, и стейки беттеров. Кламп в ноль передал бы это состояние следующему вызывающему; отказ сохраняет кривую ценообразования живой, а отклонённый LP не теряет принципал (см. §4 — он возвращается целиком на сеттлменте).
B. Прямые ставки с mode = 1 (pm_place_bet). Рынок с allow_instant_bet = false и allow_batch = true существует, чтобы заставить использовать поток, устойчивый к front-run, и поддерживаемый вход туда — pm_commit_bet → pm_reveal_bet (escrow, строка в статусе 5, исполнение на границе батча). Прямой pm_place_bet с mode = 1 вместо этого попадал на instant-путь, т.е. обходил ровно ту защиту, которую рынок выбрал, по цене instant-гейта. После фикса он отклоняется. История не затронута: mode = 1 в уже произведённых блоках реплеится по до-форковым правилам. Это закрывает обход; само по себе оно не доказывает, что немедленное исполнение было выгодно тому, кто им пользовался.
Ни один из фиксов не меняет layout объектов и не требует бампа схемы: в apply_hardfork для HF15 нет case (путь default: break здесь корректен), импорт снапшота не требует новой секции, и на активации нечего пересчитывать.
2. Как форк регистрируется и активируется
- Регистрация безусловна, в обеих конфигурациях.
0-preamble.hfставитCHAIN_NUM_HARDFORKS= 15 для любой сборки,hardfork.d/15.hfвсегда определяетCHAIN_HARDFORK_15/CHAIN_PM_AUDIT_FIX_HARDFORKиCHAIN_HARDFORK_15_VERSION, аdatabase_hardfork.cppбезусловно регистрирует_hardfork_times[15]/_hardfork_versions[15]. Массивы имеют размер[CHAIN_NUM_HARDFORKS + 1], так что индексы в границах в обеих сборках. Форк гейтится таймстемпом активации и кворумом валидаторов — а не вариантом сборки. - Почему production тоже несёт форк. Публичный тестнет (
testnet.viz.world, та самая нода, с которой работают парсеры, оракулы и клиенты) — это production-конфигурация: он отдаётCHAIN_NAME "VIZ",CHAIN_ID = sha256("VIZ")иCHAIN_HARDFORK_REQUIRED_VALIDATORS = 17.config_testnet.hppсменил быCHAIN_NAMEнаVIZTEST(а значит и chain id) и уронил бы кворум до 1 — выкатить такой образ на существующий тестнет нельзя, цепь перестала бы быть той цепью, которой принадлежат её снапшот и клиенты. Форк, зарегистрированный только подBUILD_TESTNET, поэтому никогда не активируется на тестнете, которым мы реально пользуемся:has_hardfork(15)там навсегда false, и деплой ничего не доказывает. Регистрация в обеих конфигурациях, где различие — только время, делает возможной проверку «сначала тестнет» на том самом артефакте, который уедет в прод. - Единственная ручка — время активации, и оно вкомпилировано. В
15.hfдва таймстемпа:CHAIN_HARDFORK_15_TIME= 2026-09-27 08:33:20 UTC дляBUILD_TESTNET(свежая testnet-сборка может активироваться сразу) и = 2026-10-05 00:00:00 UTC для production. Production-значение предварительное — это не назначенная дата mainnet, оно существует, чтобы production-тестнет мог дойти до активации и её можно было наблюдать; см. чеклист ниже. Смена — это правка одной строки плюс сборка-выкатка, поэтому дату нужно переподтвердить, когда релиз HF14+HF15 будет реально планироваться. Держать её в будущем: с таймстемпом в прошлом форк применяется в том блоке, где 17-й валидатор случайно обновится, без объявляемого момента (то же предупреждение, что в14.hf). - Версия, не ревизия.
version(m, h, r)упаковывает версию хардфорка в средний компонент, иCHAIN_HARDFORK_VERSION— это именно он. Новый форк обязан двигать самCHAIN_VERSION— теперь иconfig.hpp, иconfig_testnet.hppстоят на4.1.0— потому чтоhardfork_versionотбрасывает ревизию, так что4.0.1для голосования неотличим от4.0.0.database_hardfork.cppассертитCHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS], и именно поэтому версия иCHAIN_NUM_HARDFORKSобязаны двигаться вместе в обеих сборках. - Голосование автоматическое.
database.cpp::_generate_blockинжектитhardfork_version_vote, когда записанный голос валидатора отличается от следующего форка бинаря, аprocess_hardforks()применяет форк, когда сошлисьCHAIN_HARDFORK_REQUIRED_VALIDATORS(17 на production-конфигурации, которую крутит тестнет, 1 на testnet-сборке) и достигнут таймстемп активации. Все 21 слот валидаторов тестнета ведёт один аккаунт, так что кворум там мгновенный и решение принимает только таймстемп. - Чеклист активации на mainnet. (1) Подтвердить или заменить предварительную дату и объявить её заранее с запасом до таймстемпа — это единственное оставшееся решение по расписанию. (2) Прогнать детектор устаревшего состояния из §4 и закрыть там же вопрос миграции. (3) Выкатить образ и дать валидаторам обновиться — голос автоматический. (4) Подтвердить активацию по логу ноды и перепроверить §3 уже на живой цепи. Отметить, что собственная дата HF14 на mainnet (2026-08-28) уже в прошлом, поэтому первый mainnet-деплой, несущий оба форка, активирует их вместе в одном блоке (
process_hardforksидёт циклом, пока_hardfork_versions[last] < next_hardfork); тестнет, в снапшоте которого HF14 уже обработан, — единственное место, где HF15 можно проверить отдельно, но только если эта цепь не в emergency-консенсусе (см. §3: emergency-committee и не голосует, и не считается, так что форк там не становится даже pending). - Откат. До таймстемпа активации вернуть предыдущий образ безопасно: форк просто остаётся ожидающим (состояние держит проголосованный-но-неприменённый форк; возврат нового образа его снимает). После активации маркер — это состояние цепи, поэтому откатываться нельзя: до-HF15-бинарь не знает про форк и оценивал бы гейтед-операции по старым правилам, пока цепь говорит обратное. Нужно двигаться вперёд.
3. Проверка
До активации (после деплоя нового образа, до таймстемпа) состояние читается двумя методами, которые эта сборка реально отдаёт, — database_api.get_hardfork_property на VIZ не зарегистрирован, вызов падает с Could not find method:
database_api.get_hardfork_version— применённая версия форка (4.0.0, пока текущий — HF14);database_api.get_next_scheduled_hardfork—hf_version/live_timeфорка, который назначил tally голосов валидаторов. После того как валидаторы нового образа проголосуют, тут будет версия HF15 и вкомпилированное время активации; детектор из §4 (scripts/pm_stale_bshare_detect.py) отчитывается по состоянию, которое вот-вот попадёт под гейт.
Production-конфигурация тестнета в emergency-консенсусе до этого состояния дойти не может — это и есть та ловушка, которую надо знать. Пока dynamic_global_property_object.emergency_consensus_active истинно, расписание валидаторов заполнено аккаунтом CHAIN_EMERGENCY_VALIDATOR_ACCOUNT (= committee, database.cpp:575), а этот аккаунт исключён из голосования за форки в обе стороны: database.cpp:2811 не инжектит голос для производящего валидатора, а цикл подсчёта (database.cpp:3413) пропускает его слоты, чтобы одна сущность, держащая много слотов, не раздувала собственный вес. Наблюдаемое следствие, замерено на тестнете 27.09.2026 после деплоя 4.1.0 поверх цепи на 4.0.0: каждый произведённый блок несёт пустой extensions (никакого hardfork_version_vote), а get_next_scheduled_hardfork продолжает отдавать 4.0.0 со временем предыдущего форка. Tally пуст, process_hardforks держит next_hardfork равным current_hardfork_version, и ни один форк не может быть назначен — вкомпилированное время активации оказывается мёртвой ручкой. Выход из emergency-консенсуса требует, чтобы обновились CHAIN_HARDFORK_REQUIRED_VALIDATORS реальных валидаторов (правило выхода из HF12), а тестнет, чьи валидаторы — импортированная история мейннета, этого не достигает.
То есть на такой цепи «сначала развернуть и посмотреть, как форк активируется» проверяет деплой (образ поднимается, снапшот импортируется, инварианты держатся), но не активацию — tally там не может дать результата в принципе. Выхода два: свежая сборка BUILD_TESTNET (кворум 1, обычный валидатор голосует и применяет форк сам) либо testnet_plugin ниже, который форсирует форк прямо на production-конфигурации тестнета.
Форсирование форка на старте: testnet_plugin
testnet_plugin сделан ровно под этот случай и не требует правок консенсуса. Он добавляет одну команду на старте:
--testnet-hardfork <версия|номер> # например 4.1.0 или 15
Если плагин загружен (plugin = testnet_plugin, уже есть в config_testnet.ini) и ему задана цель, он применяет все форки до указанного сразу после загрузки состояния цепи — после импорта снапшота, до старта производства блоков — через database::set_hardfork(n, true). Tally голосов валидаторов обходится полностью, поэтому это работает и при emergency_consensus_active = true. Именно это делает возможной проверку активации на production-конфигурации тестнета до прод-даты.
Безопасность: плагин не делает ничего, пока не загружен и не получил цель, поэтому production-образ может его содержать; production-config.ini его не включает. Форсированный форк нельзя откатить — наводить его можно только на свою цепь, никогда на мейннет.
Порядок действий (тестнет на shelter): сначала развернуть образ, в котором плагин есть — строка plugin = с плагином, которого нет в бинаре, роняет старт с unable to find plugin: testnet_plugin, — затем перезапустить контейнер с VIZD_EXTRA_OPTS="--testnet-hardfork 15" или с testnet-hardfork = 15 в конфиге. Нода пишет в лог
*** testnet_plugin: FORCING HARDFORK 15 (requested '15') at head=#... ***
*** testnet_plugin: hardfork 15 applied at head=#...: last_hardfork=15, current_hardfork_version=4.1.0 ***
и get_hardfork_version с этого момента отдаёт 4.1.0. После прогона опцию убрать: форк уже в состоянии цепи, а повторный форс при рестарте со старого снапшота безвреден, но шумен.
Одна ловушка при проверке деплоя: --testnet-hardfork объявлена как опция конфига, которую appbase принимает и в командной строке (argv парсится по объединённому набору cli + cfg), но --help печатает только командные опции — в справке флага не будет. Проверять флаг надо запуском ноды и баннером FORCING HARDFORK, а не грепом --help. Опция, объявленная в обоих наборах, хуже невидимой: boost тогда валит каждый старт с option '--testnet-hardfork' is ambiguous and matches different versions of '--testnet-hardfork'.
После активации:
- форк появился в
processed_hardforks, в логе ноды видна активация; - прямой
pm_place_bet(mode = 1)на рынке сallow_instant_bet = falseотклоняется (mode=1больше не доходит до instant-исполнения), аpm_commit_bet→pm_reveal_betработает; - частичный вывод LMSR оставляет
Σ b_shareпо активным строкам рынка равнойmarket.lmsr_b; - полный выход по строке, которую legacy-путь оставил устаревшей, отклоняется ассертом «would drain the LMSR pricing curve», и рынок продолжает ценообразование;
- обычные пост-деплойные инварианты: SHARES delta 0, TOKEN delta равна legacy-якорю цепи,
restarts 0, листинги не пусты.
4. Устаревшее состояние: что детектировать и что решить
Пост-фиксовый инвариант на LMSR-рынок: Σ b_share по его активным LP-строкам равна market.lmsr_b. Legacy-расхождение однонаправленное — кривая потеряла b_remove, а строка сохранила полный b_share, т.е. строки в сумме претендуют на больше, чем рынок держит. Поэтому детект — это один проход по рынкам с market_type == 1 с суммированием b_share активных строк и сравнением с lmsr_b; любой рынок, где сумма больше, имеет как минимум одну устаревшую строку, и его следующий полный выход — ровно та операция, которую отклоняет новый гейт.
В таком состоянии могут быть только рынки, чей LP делал частичный вывод до активации; созданное после форка — не может, а рынок, где все выходы LP были «всё или ничего», согласован по построению.
Детектор
scripts/pm_stale_bshare_detect.py <snapshot-block-NNNN.vizjson> проходит снапшот и печатает по каждому рынку Σ b_share активных (status 0) строк против lmsr_b. Read-only: не нужны ни нода, ни доступ к цепи. Вердикт — код возврата: 0 = инвариант выполнен на всех LMSR-рынках (мигрировать нечего), 1 = есть расходящиеся рынки (список печатается), 2 = снапшот не прочитался или секции не найдены. .vizjson — это zlib-сжатый JSON, поэтому нужен собственный снапшот ноды, а не пере-сериализованный экспорт.
Где лежит снапшот: с --snapshot-auto-latest нода пишет snapshot-block-*.vizjson в свой vizhome (/var/lib/vizd/snapshots/ внутри контейнера, т.е. <vizhome>/snapshots/ на хосте; на текущем тестнете — каждые 15 минут). На shelter это /root/testnethome/snapshots/, читается только через sudo — сперва скопировать: sudo cp <snap> /tmp/snap.vizjson && sudo chown $USER /tmp/snap.vizjson.
Замер 2026-09-27 на снапшоте тестнета блок 83748900 (состояние, которое вот-вот попадёт под HF15): 129 806 рынков, из них 9 615 LMSR; у 8 251 есть активные LP-строки, и все 8 251 точно удовлетворяют инварианту (Σ b_share == lmsr_b), расходящихся — 0. То есть на этой цепи legacy-путь частичного вывода не оставил следа: отклонять нечего, и одноразовый ремонт атрибуции из вариантов ниже не нужен. Перед планированием форка детектор стоит прогнать на свежем снапшоте — цепь, которая с тех пор отслужила ещё частичные выводы LMSR, может отличаться.
Варианты и что с каждым делает цепь:
- Ничего не делать (текущее поведение). Устаревшая строка отклоняется на выходе, т.е. LP не может досрочно вытащить остаток своего
b_share. Ничего не теряется: принципал возвращается целиком на сеттлменте, где пол ликвидности живого рынка уже не применяется, и рынок всё это время продолжает ценообразование. Цена: опция досрочного выхода для этой строки мертва, а несогласованность видна только через отказ. Это консервативный вариант без изменения консенсуса, и он дефолтный, потому что расхождение можно измерить, но нельзя реконструировать — цепь держит только текущее состояние, а истории по каждому выводу, чтобы восстановить истинную атрибуцию, нет. - Одноразовый ремонт атрибуции. Для каждого разошедшегося рынка уменьшить
b_shareактивных строк pro-rata доmarket.lmsr_b(с floor, остаток — последней строке), чтобы строки снова сошлись с кривой и полные выходы заработали. Это детерминированно и идемпотентно на одном и том же состоянии, но это консенсусно-видимая мутация состояния, и ей нужна своя форк-гейтед миграция, свои тесты и своё ревью — т.е. это отдельное изменение, а не довесок к этому. - Ручные действия по рынку (подтолкнуть затронутых LP или резолвнуть/сеттлить рынок) — не фикс: сеттлмент всё равно возвращает принципал, так что на кону только окно досрочного выхода.
Итоговое решение, которое сети нужно принять до планирования HF15: устраивает ли её «отказать в устаревшем выходе, вернуть принципал на сеттлменте», или нужен ещё и одноразовый ремонт атрибуции. Решать должен вывод детектора: если ни один LMSR-рынок не разошёлся, вопрос снимается и форк можно планировать как есть.