HF16 — заморозка цены no-contest: чеклист активации и семантика бэкфилла
Статус: реализовано; активация назначена на 31.10.2026 00:00:00 UTC. Форк зарегистрирован в обеих конфигурациях (CHAIN_NUM_HARDFORKS = 16, CHAIN_VERSION = 4.2.0) и гейтится временем активации плюс кворумом валидаторов: production-бинарь применит его, как только это время наступит, а до тех пор ничего не выполняется. Эта заметка — операторский чеклист: что именно замораживается, как на рынке различаются два состояния, что меняется в layout объектов и снапшоте, как проверить активацию и почему seed-проход для строк, уже находящихся в окне спора на момент форка, не нужен.
1. Что меняет форк
Проблема. pm_no_contest лишь объявляет void и открывает окно pm_dispute_grace_sec, а штраф считается на сеттлменте. До HF16 сумма бралась как floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000) из медианного chain_properties_pmна момент сеттлмента. Оба входа — медианно голосуемые параметры, поэтому голосование внутри окна спора меняло счёт после того, как оракул уже объявил void: оракул объявлял одно число, а платил другое, и узнать итоговую цену при объявлении было невозможно.
Фикс: число фиксируется при объявлении. pm_no_contest_evaluator считает ту же формулу от медианы блока объявления и сохраняет её на рынке в pm_market_object::no_contest_fee. settle_market(win < 0) списывает ровно эту сумму, а не перечитывает медиану. Кап по страховке не меняется и по-прежнему применяется на каждом сеттлменте: penalty = min(frozen_fee, oracle.insurance).
Два состояния, один флаг. pm_market_object::no_contest_fee_frozen говорит, была ли сумма вообще заморожена:
true— void объявлен под HF16; сеттлмент списываетno_contest_feeкак есть.false— строка объявлена до HF16 (или импортирована из до-HF16-снапшота); сеттлмент сохраняет legacy-вычисление от медианы — ровно поведение до форка.
Флаг отделён от суммы намеренно. Замороженный штраф 0 (процент штрафа был проголосован в 0 на момент объявления) обязан остаться нулём, даже если позже процент поднимут: «замороженный ноль» и «никогда не замораживался» — разные состояния, и вывод legacy-ности из fee == 0 молча переоценил бы первое. Рынок, объявленный под HF16 без ставок, тоже записывает frozen = true, fee = 0; это безвредно, потому что сеттлмент списывает только при settlement.stake_total > 0.
Счётчик атрибуции. pm_oracle_object::no_contest_slashed_total — монотонный итог штрафов no-contest, списанных с оракула; он инкрементируется в том же modify(), что растит total_insurance_slashed. Это даёт классу no-contest точную атрибуцию без virtual operation, и счётчик растёт на обоих путях (legacy-строка, рассчитавшаяся после активации, — всё равно no-contest-списание). Он накапливается вперёд от HF16: штрафы, списанные до форка, не бэкфиллятся — агрегатная дельта осталась единственной записью, и вычитать её задним числом было бы догадкой. Только отображение/атрибуция; консенсус его никогда не читает.
Layout объектов. Оба поля дописаны в конец своих структур и в соответствующий FC_REFLECT:
pm_market_object:share_type no_contest_fee; bool no_contest_fee_frozen = false;pm_oracle_object:share_type no_contest_slashed_total;
Импорт снапшота защищён contains() для всех трёх, поэтому до-HF16-снапшот импортируется с дефолтами (0 / false) — а это ровно состояние «legacy-строка», которого ждёт сеттлмент. Экспорт снапшота общий (EXPORT_INDEX) и правки не требует. В apply_hardfork случая для HF16 нет (default: break здесь корректен): нет синглтона для создания и нечего пересчитывать при активации.
Что едет без форка. Эти read-only добавки полезны и до активации и не гейтятся — они выводятся из того же состояния, которое прочитает сеттлмент:
get_oracle(и блок оракула вget_market_full) получаетno_contest_pending_count,no_contest_pending_stake,no_contest_fee_nominal,no_contest_fee_capped— цена объявленных этим оракулом void'ов, которые ещё в окне спора (status 3, payout pending, спор не подан) и несут ставки. Показываются и номинальная, и урезанная страховкой суммы: иначе срабатывание капа скрыло бы номинальную цену. Считается на чтении, кэш на блок.- Карточка рынка получает
settlement_charge: сколько спишется, если этот рынок рассчитается void сейчас (0 для всего, что не является ожидающим no-contest со ставками). get_pm_chain_propertiesсохраняет все прежние поля и добавляетpm_no_contest_fee_per_market = dispute_fee × penalty_percent / 10000— номинальную единицу, которую клиенты и скрипты иначе пересчитывают руками. RPC теперь возвращает variant; JSON-клиенты видят тот же объект плюс один ключ.
2. Как форк регистрируется и активируется
- Регистрация безусловна в обеих конфигурациях.
0-preamble.hfставитCHAIN_NUM_HARDFORKS= 16;hardfork.d/16.hfопределяетCHAIN_HARDFORK_16/CHAIN_PM_NO_CONTEST_FREEZE_HARDFORKиCHAIN_HARDFORK_16_VERSION;database_hardfork.cppрегистрирует_hardfork_times[16]/_hardfork_versions[16]для любой сборки. Массивы размера[CHAIN_NUM_HARDFORKS + 1]. Гейт — таймстемп плюс кворум, а не flavour сборки — то же правило, что у HF15, и по той же причине: shelter-тестнет — production-config-деплой, так что testnet-only форк там активироваться не смог бы никогда. Полное обоснование —pm-audit-fix-upgrade.md§2. - Активация: 2026-10-31 00:00:00 UTC (
CHAIN_HARDFORK_16_TIME= 1793404800 в обеих конфигурациях, задано владельцем 20.10.2026). Объявление валидаторам должно называть именно этот момент, и до него 17 слотов расписания должны голосовать за 4.2.0 (в расписание попадают только валидаторы с ключом подписи; проверка —scripts/hardfork_readiness.py, см. HF16). Держать значение в будущем: с прошедшим таймстемпом форк активируется в том блоке, где случился кворум, и объявляемого момента нет. Менять его вместе с текстом объявления и никогда — после публикации. - Версия, а не ревизия.
CHAIN_VERSIONпереезжает на4.2.0в обеих конфигурациях,config.hppиconfig_testnet.hpp; средняя компонента — это hardfork-версия, за которую голосуют валидаторы, иdatabase_hardfork.cppпроверяет её равенство_hardfork_versions[CHAIN_NUM_HARDFORKS]. (В двух конфигах раньше стояли 4.1.2 и 4.1.0 — обе переезжают на один и тот же 4.2.0.) - Чеклист активации. (1) Подтвердить или заменить провизорную дату и объявить её. (2) Замерить популяцию из §4 на свежем снапшоте и закрыть там вопрос (ожидаемый ответ: действий не нужно). (3) Выкатить образ; голоса валидаторов автоматические. (4) Подтвердить активацию по логу ноды и прогнать проверки из §3. (5) Помнить ловушку emergency consensus из
pm-audit-fix-upgrade.md§3: на цепи в emergency consensus форк запланировать нельзя вообще, и вкомпилированное время — мёртвая ручка.testnet_plugin --testnet-hardfork 16форсит форк на старте production-config-тестнета для проверки; плагин ничего не делает, если не загружен и не получил цель. - Откат. До таймстемпа передеплой прошлого образа безопасен — форк остаётся pending. После активации маркер — состояние цепи: откатываться нельзя (до-HF16-бинарь рассчитал бы ожидающие no-contest'ы от медианы, пока цепь считает иначе). Только вперёд.
3. Проверка
До активации (read-only добавки работают на живой цепи):
- в
get_pm_chain_propertiesестьpm_no_contest_fee_per_market, численно равныйpm_dispute_fee × pm_no_contest_penalty_percent / 10000; - у оракула с void'ом в окне спора и ставками на нём
get_oracleпоказываетno_contest_pending_count ≥ 1иno_contest_fee_nominal = count × unit; карточка соответствующего рынка показываетsettlement_charge; - ни один рынок ещё не несёт
no_contest_fee_frozen = true— флаг пишется только под HF16.
После активации:
- рынок, объявленный void через
pm_no_contest, сразу показываетno_contest_fee_frozen = trueиno_contest_fee, равный единице блока объявления; - проголосовать
pm_dispute_fee/pm_no_contest_penalty_percent, пока рынок в окне спора: итоговое списание равно замороженной сумме, а не новой медиане; - рынок, объявленный до активации с
frozen = false, рассчитывается от текущей на тот момент медианы, т.е. дофорковое поведение для него сохраняется; oracle.no_contest_slashed_totalрастёт ровно на списанную сумму, синхронно сtotal_insurance_slashed;- обычные post-redeploy-инварианты: SHARES delta 0, TOKEN delta равен legacy-якорю цепи,
restarts 0, листинги непустые.
Консенсус-регрессия. tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp (BUILD_TESTNET) гоняет одну цепь с тремя рынками, объявленными вокруг точки форка — legacy до форка, замороженный после форка, замороженный в ноль, — затем двигает медиану и проверяет, что падение страховки и no_contest_slashed_total равны 8u + 1u + 0 для legacy/замороженной/нулевой ноги соответственно. Замер 19.10.2026 (BUILD_TESTNET, consensus_sim_tests --run_test=pm_no_contest_fee_three_states): страховка 5 000 000 → 4 100 000 raw, то есть ровно 9u; тест прошёл без ошибок.
4. Строки, уже находящиеся в окне спора на момент активации
Seed-проход не нужен, и он был бы неверен. У строки, объявленной до форка, замороженной суммы нет; она обязана сохранить дофорковое правило (медиана на сеттлменте), потому что именно по нему её объявляли. Запись замороженной суммы при активации изменила бы счёт оракула задним числом — ровно тот дефект, который HF16 убирает. Флаг false — маркер, который делает это автоматическим: ни кода миграции, ни переписывания снапшота, а старый снапшот восстанавливается ровно в то состояние, которого ждёт сеттлмент.
Остаточная экспозиция поэтому ограничена и названа: строки, объявленные до форка и всё ещё находящиеся в окне спора на момент активации, могут быть переоценены голосованием между активацией и их сеттлментом. Их число измеримо из цепи; если оно не ноль и сеть не хочет никакой ретроактивной переоценки, единственный чистый ответ — дождаться окончания этих окон до любого голосования по параметрам, а не миграция.
Замер перед планированием. Пройти по ожидающим no-contest-строкам оракулов (status 3, payout pending, resolved_outcome = -1) и посчитать те, у которых bets_sum > 0, плюс их стейк: get_oracle отдаёт ровно это по каждому оракулу (no_contest_pending_count / _stake), так что новый прибор не нужен. Замер на живом production-config-тестнете 2026-10-19: 112 рынков в окне спора, 0 из них со ставками — популяция, на которую могло бы повлиять голосование внутри окна, на тот момент была пуста. Перемерить на свежем снапшоте непосредственно перед планированием: число ходит вместе с популяцией оракулов и о будущем не говорит ничего.