Files
coopos/exception-notes.md
coopops de24cd822e feat(chain): chain-historical-exceptions registry for action_mroot bypass
Adds a generic per-chain exception mechanism that lets a controller skip
the producer_block_id != ab._id assert in apply_block when the only
header divergence is a known historical action_mroot=0 inside a declared
block-number window for the matching chain_id.

Motivation: on 2026-05-11 the mainnet Коопеномикс BP briefly ran a dev
build (v5.2.0-dev-294edf3b8) that did not register the on_activation
handler for ASSERT_RECOVER_KEY_ACCOUNT (id=24). For ~20 minutes (blocks
113273322..113275716) onblock did not register intrinsics,
_action_receipt_digests was empty and finalized action_mroot was the
zero digest. The window is now permanently irreversible. Any current
binary refuses to replay past block 113273322 with
block_validate_exception, so new full-history archive nodes / fresh BPs
/ --hard-replay-blockchain on mainnet are blocked.

Design (see exception-notes.md):
- New struct chain_historical_exceptions { chain_id, windows[] }
  loaded from a JSON file path supplied via the new config option
  chain-historical-exceptions. Absent / empty file => no change in
  behavior, strict upstream Antelope validation. Forks and subnets
  reusing this codebase without supplying a file are unaffected.
- Loader fires from controller_impl::init() right after
  protocol_features.init(db) (where self.get_chain_id() is valid) and
  EOS_ASSERTs that the file's chain_id matches the running chain — so
  the mainnet exception file cannot accidentally be applied to a
  different chain.
- Bypass site is the existing producer_block_id != ab._id branch in
  apply_block. Bypass fires only when all three hold: block_num is
  inside a declared window, b->action_mroot is strictly the zero
  digest, and a new other_header_fields_match helper confirms every
  other header field (timestamp, producer, confirmed, previous,
  transaction_mroot, schedule_version, new_producers,
  header_extensions) matches the locally-assembled block. Any other
  header divergence still throws block_validate_exception as before.
- Every bypass application is logged via wlog with the configured
  reason for forensics.

The data file for mainnet Коопеномикс is shipped separately (lives in
the playbooks repo, deployed to /etc/coopos/exceptions/) so the data
never leaks into forks that simply pull this codebase.

Unit tests cover JSON round-trip, chain_id mismatch -> startup refusal,
in-window action_mroot=0 -> bypass accepts, out-of-window -> rejected,
and in-window-but-other-field-altered -> rejected.

Refs: incident 2026-05-11, coopos commit 2c23b8108 (root-cause fix that
arrived too late for the dirty window).
2026-06-03 06:19:06 +00:00

15 KiB
Raw Permalink Blame History

chain-exception registry — спецификация реализации

Заметка-набросок к реализации механизма исторических исключений валидации для coopos. Цель: восстановить штатную возможность full-history синхронизации (genesis → head) на mainnet Коопеномикс при сохранении чистоты core-кода для форков и подсетей.

1. Контекст инцидента

Когда: 11 мая 2026, ~10:18 UTC.

Что произошло:

  • На BP coopenomics-mainnet был задеплоен бинарь coopos v5.2.0-dev-294edf3b8 (dev-сборка, а не финальная v5.2.0-2c23b810).
  • В этой dev-сборке не был зарегистрирован on_activation handler для protocol feature ASSERT_RECOVER_KEY_ACCOUNT (id=24). Фикс пришёл коммитом 2c23b8108 fix(protocol_feature): register on_activation handler for ASSERT_RECOVER_KEY_ACCOUNT, но он попал только в финальную сборку.
  • На каждом блоке implicit onblock action не регистрировала intrinsics, _action_receipt_digests оставалась пустой, action_mroot финализированного блока вычислялся как нулевой digest.
  • Через ~30 минут (~3000 блоков) что-то самостоятельно активировало feature (видимо preactivation tx), и action_mroot снова стал не-нулевым.

Дефектное окно блоков: [113 273 322 .. 113 275 716] — 2395 блоков, ~20 минут. Границы подтверждены leap-util block-log print-log по blocks.log BP 2026-06-03: блок 113 273 321 — последний с не-нулевым action_mroot (нормальный), 113 275 717 — первый с не-нулевым после самовосстановления.

Содержимое окна: в выборках из 11 блоков trxs=0. Пользовательских транзакций не было — только пустые onblock. Утрачена только криптографическая верифицируемость action_mroot для этих блоков, но не данные.

Последствия для full replay:

  • Эти блоки навсегда в blocks.log с action_mroot = 0.
  • Любой текущий бинарь coopos (включая финальный v5.2.0-2c23b810) при apply_block вычисляет ожидаемый не-нулевой action_mroot и отвергает блок 113 273 322 через block_validate_exception в controller.cpp (текущая строка ~2154, ассерт producer_block_id == ab._id).
  • Любая попытка полного p2p sync от genesis или --hard-replay-blockchain на текущий момент невозможна — даже на BP с собственным blocks.log.

chain_id mainnet Коопеномикс: 6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b (сверено curl https://api.coopenomics.world/v1/chain/get_info 2026-06-03).

2. Цели

  1. Восстановить full-history sync как штатную операционную возможность для mainnet Коопеномикс (новые архивные ноды, новые BP, аудиторы).
  2. Не загрязнять core-код магическими константами с block_num конкретной сети — coopos тиражируется в форки и подсети, они не должны наследовать эту легаси-логику.
  3. Не создавать поверхность для подделки блоков через bypass — bypass должен срабатывать только на конкретное окно конкретной цепи и только при ровно том mismatch, который наблюдается (action_mroot = 0, всё остальное совпадает).

3. Архитектурный подход — chain-exception registry

Паттерн заимствован из go-ethereum (ChainConfig с DAOForkBlock и аналогичными hardfork-полями). Core имеет generic механизм, конкретные данные живут в отдельном файле per chain_id.

Контракт:

  • Файла нет → _historical_exceptions пустой → bypass-код мёртв → core ведёт себя строго как апстрим Antelope.
  • Файл есть, но chain_id в нём не равен текущему self.get_chain_id() → файл отвергается с ошибкой на startup. Невозможно «подсунуть» окна другой сети.
  • Файл есть и chain_id совпадает → объявленные окна разрешены к bypass.

4. Структуры данных

Новый заголовок libraries/chain/include/eosio/chain/chain_exceptions.hpp:

namespace eosio { namespace chain {

struct historical_action_mroot_window {
   uint32_t    from_block = 0;
   uint32_t    to_block   = 0;
   std::string reason;          // для логов и forensics
};

struct chain_historical_exceptions {
   chain_id_type chain_id;
   std::vector<historical_action_mroot_window> action_mroot_zero_windows;
};

}}

FC_REFLECT(eosio::chain::historical_action_mroot_window,
           (from_block)(to_block)(reason))
FC_REFLECT(eosio::chain::chain_historical_exceptions,
           (chain_id)(action_mroot_zero_windows))

5. Загрузка файла

Опция config.ini (новая):

chain-historical-exceptions = /etc/coopos/exceptions/coopenomics-mainnet.json

Загрузчик в controller_impl::startup() (или ранее, до первого apply_block):

void load_historical_exceptions( const fc::path& p ) {
   if( p.empty() || !fc::exists(p) ) return;     // no file → strict mode
   auto ex = fc::json::from_file(p).as<chain_historical_exceptions>();
   EOS_ASSERT( ex.chain_id == self.get_chain_id(),
               chain_exception,
               "historical exceptions file chain_id ${ex} does not match running chain ${rc}",
               ("ex", ex.chain_id)("rc", self.get_chain_id()) );
   _historical_exceptions = std::move(ex);
   for( const auto& w : _historical_exceptions.action_mroot_zero_windows ) {
      wlog("Loaded historical action_mroot_zero window [${a}..${b}]: ${r}",
           ("a", w.from_block)("b", w.to_block)("r", w.reason));
   }
}

6. Точка применения в validation

libraries/chain/controller.cpp, в apply_block() (текущая строка ~2153, блок if( producer_block_id != ab._id )):

if( producer_block_id != ab._id ) {
   const uint32_t bn = b->block_num();
   bool window_bypass = false;
   for( const auto& w : _historical_exceptions.action_mroot_zero_windows ) {
      if( bn < w.from_block || bn > w.to_block ) continue;
      if( b->action_mroot != digest_type() ) continue;          // ровно нулевой
      if( !other_header_fields_match(*b, *ab._unsigned_block) ) continue;
      wlog("historical action_mroot_zero exception applied for block ${bn}: ${r}",
           ("bn", bn)("r", w.reason));
      window_bypass = true;
      break;
   }
   if( !window_bypass ) {
      elog("Validation block id does not match producer block id");
      report_block_header_diff(*b, *ab._unsigned_block);
      EOS_ASSERT( producer_block_id == ab._id, block_validate_exception,
                  "Block ID does not match",
                  ("producer_block_id", producer_block_id)("validator_block_id", ab._id) );
   }
}

other_header_fields_match() — новый помощник в том же файле, проверяющий равенство всех полей header'а кроме action_mroot:

static bool other_header_fields_match( const block_header& b,
                                       const block_header& ab ) {
   return b.timestamp          == ab.timestamp
       && b.producer           == ab.producer
       && b.confirmed          == ab.confirmed
       && b.previous           == ab.previous
       && b.transaction_mroot  == ab.transaction_mroot
       && b.schedule_version   == ab.schedule_version
       && b.new_producers      == ab.new_producers
       && b.header_extensions  == ab.header_extensions;
}

Это гарантирует: bypass срабатывает строго на тот mismatch, который мы понимаем (action_mroot обнулён). Любой другой расход — обычный фейл, никакой амнистии.

7. Файл данных для mainnet Коопеномикс

Путь (рекомендуется): /etc/coopos/exceptions/coopenomics-mainnet.json. В deb-пакет coopos-mainnet-config (опциональный) или поставляется отдельно оператором.

{
  "chain_id": "6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b",
  "action_mroot_zero_windows": [
    {
      "from_block": 113273322,
      "to_block": 113275716,
      "reason": "v5.2.0-dev-294edf3b8 deployed on mainnet 2026-05-11 10:18 UTC; ASSERT_RECOVER_KEY_ACCOUNT (id=24) on_activation handler was not registered; ~20min (2395 blocks) of zero action_mroot before self-recovery; user trxs in window: 0; fix landed in coopos 2c23b8108"
    }
  ]
}

Финальный артефакт лежит в ~/playbooks/blockchain/exceptions/coopenomics-mainnet.json. На целевой ноде разворачивается по пути /etc/coopos/exceptions/coopenomics-mainnet.json, путь указывается в config.ini опцией chain-historical-exceptions.

8. Свойства решения

Форки/подсети coopos:

  • Запускаются без chain-historical-exceptions или с пустым путём.
  • _historical_exceptions пустой → цикл проверки for(... windows ...) не итерирует → bypass-код является мёртвой веткой.
  • Никаких magic block_num в коде — только generic-механизм.

Перенос на другую сеть невозможен:

  • Скопировать файл coopenomics-mainnet.json в другую сеть бессмысленно — при startup проверка ex.chain_id == self.get_chain_id() фейлится, nodeos падает на старте с понятной ошибкой.

Подделка блоков в окне невозможна:

  • Bypass принимает блок только если он действительно от producer'а (валидная подпись, корректный previous-link к предыдущему irreversible блоку, все остальные поля как у assembled — кроме action_mroot=0).
  • Все block_id в окне уже irreversible (LIB сейчас ~114 800 000+, окно на 1.5M блоков позади). Подменить irreversible block_id невозможно без collision на SHA256 и без приватного ключа producer'а на момент 11.05.2026.

Криптографическая честность:

  • Bypass прозрачен — каждое применение пишется в лог через wlog с reason.
  • Файл exceptions можно подписать BP-multisig'ом и распространять подписанным, если в будущем потребуется усилить аудит (не в первой итерации).

9. Тестовое покрытие

Минимально:

  1. Нет файла → строгая валидация. Unit-тест: nodeos с искусственно обнулённым action_mroot на синтетическом блоке без exceptions → ассерт валится.
  2. Файл есть, chain_id не совпадает → отказ на startup. Тест с подменой chain_id в exceptions-файле → ошибка загрузки.
  3. Файл есть, окно совпадает, mismatch только в action_mroot=0 → bypass применён. Replay через искусственный blocks.log с обнулённым блоком в объявленном окне → нода продолжает.
  4. Файл есть, окно совпадает, mismatch в другом поле тоже → fail. Не только action_mroot обнулён, но и transaction_mroot искажён → ассерт валится (защита от использования bypass-а как универсальной амнистии).
  5. Файл есть, окно НЕ совпадает (block_num вне диапазона) → fail. Блок с обнулённым mroot за пределами объявленного окна → ассерт валится.

Тесты лежат в tests/chain_tests/ (или новом tests/historical_exceptions_tests/).

10. План внедрения

  1. Уточнить верхнюю границу окна на blocks.log BP (скрипт через leap-util block-log print-log --first 113273322 --last 113280000 + grep по action_mroot).
  2. Получить mainnet chain_id через get_info.
  3. Реализовать registry (структуры, загрузчик, точка применения в apply_block) и тесты — отдельный feature branch chain-exception-registry.
  4. Подготовить data-файл coopenomics-mainnet.json с точными значениями.
  5. CI: убедиться что upstream-тесты Antelope проходят без изменений (нет файла → нет регрессии поведения).
  6. Roll-out: новый минор coopos (например v5.3.0) с registry. BP и API-ноды mainnet кладут data-файл и указывают путь в config.ini.
  7. Архивная нода: на свежей машине nodeos --hard-replay-blockchain с v5.3.0 + data-файл → должна пройти через окно и достичь head'а.

11. Связанные документы

  • ~/.claude/projects/-home-admin/memory/reference_coopos_v520_dirty_window.md — исходный диагноз инцидента и его последствия.
  • ~/.claude/projects/-home-admin/memory/reference_eosio_replay_eos_vm_oc.md — отдельный фикс OOM при replay (eos-vm-oc-enable = none).
  • ~/.claude/projects/-home-admin/memory/reference_eosio_sighup_startup_bug.md — баг appbase с SIGHUP во время startup.
  • coopos/CLAUDE.md — раздел про миграции версий и Dockerfile.publish.