Skip to content

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 из них со ставками — популяция, на которую могло бы повлиять голосование внутри окна, на тот момент была пуста. Перемерить на свежем снапшоте непосредственно перед планированием: число ходит вместе с популяцией оракулов и о будущем не говорит ничего.