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).
15 KiB
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_activationhandler для protocol featureASSERT_RECOVER_KEY_ACCOUNT(id=24). Фикс пришёл коммитом2c23b8108 fix(protocol_feature): register on_activation handler for ASSERT_RECOVER_KEY_ACCOUNT, но он попал только в финальную сборку. - На каждом блоке implicit
onblockaction не регистрировала 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. Цели
- Восстановить full-history sync как штатную операционную возможность для mainnet Коопеномикс (новые архивные ноды, новые BP, аудиторы).
- Не загрязнять core-код магическими константами с block_num конкретной сети — coopos тиражируется в форки и подсети, они не должны наследовать эту легаси-логику.
- Не создавать поверхность для подделки блоков через 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. Тестовое покрытие
Минимально:
- Нет файла → строгая валидация. Unit-тест: nodeos с искусственно обнулённым action_mroot на синтетическом блоке без exceptions → ассерт валится.
- Файл есть, chain_id не совпадает → отказ на startup. Тест с подменой chain_id в exceptions-файле → ошибка загрузки.
- Файл есть, окно совпадает, mismatch только в action_mroot=0 → bypass применён. Replay через искусственный blocks.log с обнулённым блоком в объявленном окне → нода продолжает.
- Файл есть, окно совпадает, mismatch в другом поле тоже → fail. Не только action_mroot обнулён, но и transaction_mroot искажён → ассерт валится (защита от использования bypass-а как универсальной амнистии).
- Файл есть, окно НЕ совпадает (block_num вне диапазона) → fail. Блок с обнулённым mroot за пределами объявленного окна → ассерт валится.
Тесты лежат в tests/chain_tests/ (или новом tests/historical_exceptions_tests/).
10. План внедрения
- Уточнить верхнюю границу окна на blocks.log BP (скрипт через
leap-util block-log print-log --first 113273322 --last 113280000+ grep по action_mroot). - Получить mainnet chain_id через
get_info. - Реализовать registry (структуры, загрузчик, точка применения в
apply_block) и тесты — отдельный feature branchchain-exception-registry. - Подготовить data-файл
coopenomics-mainnet.jsonс точными значениями. - CI: убедиться что upstream-тесты Antelope проходят без изменений (нет файла → нет регрессии поведения).
- Roll-out: новый минор coopos (например
v5.3.0) с registry. BP и API-ноды mainnet кладут data-файл и указывают путь в config.ini. - Архивная нода: на свежей машине
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.