Skip to content

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-рынок не разошёлся, вопрос снимается и форк можно планировать как есть.