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).
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# 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`:
|
||||
|
||||
```cpp
|
||||
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** (новая):
|
||||
|
||||
```ini
|
||||
chain-historical-exceptions = /etc/coopos/exceptions/coopenomics-mainnet.json
|
||||
```
|
||||
|
||||
**Загрузчик** в `controller_impl::startup()` (или ранее, до первого
|
||||
`apply_block`):
|
||||
|
||||
```cpp
|
||||
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 )`):
|
||||
|
||||
```cpp
|
||||
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`:
|
||||
|
||||
```cpp
|
||||
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` (опциональный) или поставляется отдельно
|
||||
оператором.
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <eosio/chain/thread_utils.hpp>
|
||||
#include <eosio/chain/platform_timer.hpp>
|
||||
#include <eosio/chain/deep_mind.hpp>
|
||||
#include <eosio/chain/chain_exceptions.hpp>
|
||||
|
||||
#include <chainbase/chainbase.hpp>
|
||||
#include <eosio/vm/allocator.hpp>
|
||||
@@ -245,6 +246,7 @@ struct controller_impl {
|
||||
protocol_feature_manager protocol_features;
|
||||
controller::config conf;
|
||||
const chain_id_type chain_id; // read by thread_pool threads, value will not be changed
|
||||
chain_historical_exceptions historical_exceptions; // empty unless loaded by load_historical_exceptions()
|
||||
bool replaying = false;
|
||||
bool is_producer_node = false; // true if node is configured as a block producer
|
||||
db_read_mode read_mode = db_read_mode::HEAD;
|
||||
@@ -689,6 +691,44 @@ struct controller_impl {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load the per-chain historical exceptions file (if configured) and verify
|
||||
* its `chain_id` matches the chain this controller was constructed for.
|
||||
*
|
||||
* Empty/absent path => no exceptions loaded => strict upstream validation.
|
||||
* chain_id mismatch => fatal error on startup; we refuse to run with
|
||||
* exceptions intended for a different chain.
|
||||
*/
|
||||
void load_historical_exceptions() {
|
||||
const auto& p = conf.chain_historical_exceptions_path;
|
||||
if( p.empty() ) return;
|
||||
if( !std::filesystem::exists( p ) ) {
|
||||
wlog( "chain-historical-exceptions path ${p} does not exist; running in strict mode",
|
||||
("p", p.string()) );
|
||||
return;
|
||||
}
|
||||
|
||||
chain_historical_exceptions ex;
|
||||
try {
|
||||
ex = fc::json::from_file( p ).as<chain_historical_exceptions>();
|
||||
} catch( const fc::exception& e ) {
|
||||
elog( "failed to parse chain-historical-exceptions file ${p}: ${err}",
|
||||
("p", p.string())("err", e.to_detail_string()) );
|
||||
throw;
|
||||
}
|
||||
|
||||
EOS_ASSERT( ex.chain_id == chain_id, chain_id_type_exception,
|
||||
"chain-historical-exceptions chain_id (${ex}) does not match running chain (${rc})",
|
||||
("ex", ex.chain_id)("rc", chain_id) );
|
||||
|
||||
historical_exceptions = std::move( ex );
|
||||
|
||||
for( const auto& w : historical_exceptions.action_mroot_zero_windows ) {
|
||||
wlog( "loaded historical action_mroot=0 exception window [${a}..${b}]: ${r}",
|
||||
("a", w.from_block)("b", w.to_block)("r", w.reason) );
|
||||
}
|
||||
}
|
||||
|
||||
static auto validate_db_version( const chainbase::database& db ) {
|
||||
// check database version
|
||||
const auto& header_idx = db.get_index<database_header_multi_index>().indices().get<by_id>();
|
||||
@@ -736,6 +776,8 @@ struct controller_impl {
|
||||
|
||||
protocol_features.init( db );
|
||||
|
||||
load_historical_exceptions();
|
||||
|
||||
// At startup, no transaction specific logging is possible
|
||||
if (auto dm_logger = get_deep_mind_logger(false)) {
|
||||
dm_logger->on_startup(db, head->block_num);
|
||||
@@ -2060,6 +2102,25 @@ struct controller_impl {
|
||||
#undef EOS_REPORT
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff every header field except `action_mroot` matches between the
|
||||
* producer block and the locally re-assembled block. Used to gate the
|
||||
* historical action_mroot=0 exception bypass: bypass is allowed only when
|
||||
* the sole mismatch is the (known-zero) action_mroot — any other field
|
||||
* divergence must still fail validation as usual.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
void apply_block( controller::block_report& br, const block_state_legacy_ptr& bsp, controller::block_status s,
|
||||
const trx_meta_cache_lookup& trx_lookup )
|
||||
@@ -2151,11 +2212,28 @@ struct controller_impl {
|
||||
auto& ab = std::get<assembled_block>(pending->_block_stage);
|
||||
|
||||
if( producer_block_id != ab._id ) {
|
||||
elog( "Validation block id does not match producer block id" );
|
||||
report_block_header_diff( *b, *ab._unsigned_block );
|
||||
// this implicitly asserts that all header fields (less the signature) are identical
|
||||
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) );
|
||||
bool historical_bypass = false;
|
||||
if( !historical_exceptions.action_mroot_zero_windows.empty()
|
||||
&& b->action_mroot == digest_type() )
|
||||
{
|
||||
const uint32_t bn = b->block_num();
|
||||
for( const auto& w : historical_exceptions.action_mroot_zero_windows ) {
|
||||
if( bn < w.from_block || bn > w.to_block ) continue;
|
||||
if( !other_header_fields_match( *b, *ab._unsigned_block ) ) continue;
|
||||
wlog( "historical action_mroot=0 exception applied for block ${bn} in window [${a}..${c}]: ${r}",
|
||||
("bn", bn)("a", w.from_block)("c", w.to_block)("r", w.reason) );
|
||||
historical_bypass = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if( !historical_bypass ) {
|
||||
elog( "Validation block id does not match producer block id" );
|
||||
report_block_header_diff( *b, *ab._unsigned_block );
|
||||
// this implicitly asserts that all header fields (less the signature) are identical
|
||||
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) );
|
||||
}
|
||||
}
|
||||
|
||||
if( !use_bsp_cached ) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/chain/types.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace eosio { namespace chain {
|
||||
|
||||
/**
|
||||
* A single window of block numbers in which `action_mroot` is allowed to be
|
||||
* the zero digest. Used to recover full-history replay past historical
|
||||
* incidents where a buggy producer build emitted blocks with zero action_mroot
|
||||
* that are now permanently part of the irreversible chain.
|
||||
*/
|
||||
struct historical_action_mroot_window {
|
||||
uint32_t from_block = 0;
|
||||
uint32_t to_block = 0;
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-chain set of historical validation exceptions. Loaded from an external
|
||||
* JSON file at startup; the file must declare the `chain_id` of the chain it
|
||||
* targets, and the controller refuses to load it for any other chain. Absent
|
||||
* file => strict upstream validation, no bypass.
|
||||
*/
|
||||
struct chain_historical_exceptions {
|
||||
chain_id_type chain_id;
|
||||
std::vector<historical_action_mroot_window> action_mroot_zero_windows;
|
||||
};
|
||||
|
||||
} } // namespace eosio::chain
|
||||
|
||||
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) )
|
||||
@@ -72,6 +72,7 @@ namespace eosio { namespace chain {
|
||||
path blocks_dir = chain::config::default_blocks_dir_name;
|
||||
block_log_config blog;
|
||||
path state_dir = chain::config::default_state_dir_name;
|
||||
path chain_historical_exceptions_path; // empty => strict upstream validation
|
||||
uint64_t state_size = chain::config::default_state_size;
|
||||
uint64_t state_guard_size = chain::config::default_state_guard_size;
|
||||
uint32_t sig_cpu_bill_pct = chain::config::default_sig_cpu_bill_pct;
|
||||
|
||||
@@ -269,6 +269,11 @@ void chain_plugin::set_program_options(options_description& cli, options_descrip
|
||||
"the location of the state directory (absolute path or relative to application data dir)")
|
||||
("protocol-features-dir", bpo::value<std::filesystem::path>()->default_value("protocol_features"),
|
||||
"the location of the protocol_features directory (absolute path or relative to application config dir)")
|
||||
("chain-historical-exceptions", bpo::value<std::filesystem::path>(),
|
||||
"Path to a JSON file declaring historical validation exceptions for this chain "
|
||||
"(absolute, or relative to application config dir). The file must specify a chain_id "
|
||||
"matching this node's chain — otherwise nodeos refuses to start. Absent / empty path "
|
||||
"preserves strict upstream Antelope validation behaviour. See coopos/exception-notes.md.")
|
||||
("checkpoint", bpo::value<vector<string>>()->composing(), "Pairs of [BLOCK_NUM,BLOCK_ID] that should be enforced as checkpoints.")
|
||||
("wasm-runtime", bpo::value<eosio::chain::wasm_interface::vm_type>()->value_name("runtime")->notifier([](const auto& vm){
|
||||
#ifndef EOSIO_EOS_VM_OC_DEVELOPER
|
||||
@@ -568,6 +573,12 @@ void chain_plugin_impl::plugin_initialize(const variables_map& options) {
|
||||
pfs = initialize_protocol_features( protocol_features_dir );
|
||||
}
|
||||
|
||||
if( options.count( "chain-historical-exceptions" ) ) {
|
||||
auto p = options.at( "chain-historical-exceptions" ).as<std::filesystem::path>();
|
||||
chain_config->chain_historical_exceptions_path =
|
||||
p.is_relative() ? app().config_dir() / p : p;
|
||||
}
|
||||
|
||||
if( options.count("checkpoint") ) {
|
||||
auto cps = options.at("checkpoint").as<vector<string>>();
|
||||
loaded_checkpoints.reserve(cps.size());
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Tests for the chain-historical-exceptions registry — the mechanism that
|
||||
* lets a controller bypass action_mroot validation inside declared
|
||||
* block-number windows for a specific chain_id.
|
||||
*
|
||||
* Covered:
|
||||
* - JSON round-trip of the exceptions struct
|
||||
* - chain_id mismatch in the file → controller refuses to start
|
||||
* - block with action_mroot=0 inside declared window → bypass accepts it
|
||||
* - block with action_mroot=0 outside declared window → still rejected
|
||||
* - block with other header field also corrupted → still rejected (bypass
|
||||
* is not a universal amnesty)
|
||||
*
|
||||
* The "no file → strict" baseline is implicit: a default tester carries no
|
||||
* exceptions, and a block with zero'd action_mroot would not pass push_block
|
||||
* — see the in/out-of-window tests below, where the same crafted block is
|
||||
* accepted only when an in-window exception is configured.
|
||||
*/
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <eosio/chain/chain_exceptions.hpp>
|
||||
#include <eosio/testing/tester.hpp>
|
||||
|
||||
#include <fc/io/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
using namespace eosio;
|
||||
using namespace testing;
|
||||
using namespace chain;
|
||||
|
||||
namespace {
|
||||
|
||||
/// Clone a block, overwrite its action_mroot with the given digest, and
|
||||
/// re-sign with the producer's default active key. Returns the new block.
|
||||
signed_block_ptr clone_block_with_action_mroot( base_tester& main,
|
||||
const signed_block_ptr& src,
|
||||
const digest_type& new_action_mroot ) {
|
||||
auto copy_b = std::make_shared<signed_block>( src->clone() );
|
||||
copy_b->action_mroot = new_action_mroot;
|
||||
|
||||
auto header_bmroot = digest_type::hash(
|
||||
std::make_pair( copy_b->digest(),
|
||||
main.control->head_block_state()->blockroot_merkle.get_root() ) );
|
||||
auto sig_digest = digest_type::hash(
|
||||
std::make_pair( header_bmroot,
|
||||
main.control->head_block_state()->pending_schedule.schedule_hash ) );
|
||||
copy_b->producer_signature =
|
||||
main.get_private_key( config::system_account_name, "active" ).sign( sig_digest );
|
||||
return copy_b;
|
||||
}
|
||||
|
||||
/// Write a chain_historical_exceptions JSON file at `out`. The file is owned
|
||||
/// by the test and removed by the temp_directory destructor.
|
||||
void write_exceptions_file( const std::filesystem::path& out,
|
||||
const chain_id_type& chain_id,
|
||||
std::vector<historical_action_mroot_window> windows ) {
|
||||
chain_historical_exceptions ex;
|
||||
ex.chain_id = chain_id;
|
||||
ex.action_mroot_zero_windows = std::move( windows );
|
||||
|
||||
std::ofstream f( out );
|
||||
f << fc::json::to_pretty_string( ex );
|
||||
f.close();
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
BOOST_AUTO_TEST_SUITE(chain_exceptions_tests)
|
||||
|
||||
// 1. JSON round-trip: ensure the FC_REFLECT macros let us serialise and
|
||||
// deserialise the structures without loss.
|
||||
BOOST_AUTO_TEST_CASE(json_roundtrip) {
|
||||
chain_historical_exceptions ex;
|
||||
ex.chain_id = chain_id_type( "0000000000000000000000000000000000000000000000000000000000000001" );
|
||||
ex.action_mroot_zero_windows.push_back( { 100, 200, "test window A" } );
|
||||
ex.action_mroot_zero_windows.push_back( { 500, 510, "test window B" } );
|
||||
|
||||
auto s = fc::json::to_string( ex );
|
||||
auto back = fc::json::from_string( s ).as<chain_historical_exceptions>();
|
||||
|
||||
BOOST_REQUIRE_EQUAL( back.chain_id.str(), ex.chain_id.str() );
|
||||
BOOST_REQUIRE_EQUAL( back.action_mroot_zero_windows.size(), 2u );
|
||||
BOOST_REQUIRE_EQUAL( back.action_mroot_zero_windows[0].from_block, 100u );
|
||||
BOOST_REQUIRE_EQUAL( back.action_mroot_zero_windows[0].to_block, 200u );
|
||||
BOOST_REQUIRE_EQUAL( back.action_mroot_zero_windows[1].reason, "test window B" );
|
||||
}
|
||||
|
||||
// 2. A file declaring a chain_id different from the running chain must
|
||||
// cause the controller to refuse to start.
|
||||
BOOST_AUTO_TEST_CASE(chain_id_mismatch_fails_startup) {
|
||||
fc::temp_directory tempdir;
|
||||
auto cfg_pair = base_tester::default_config( tempdir );
|
||||
auto cfg = cfg_pair.first;
|
||||
|
||||
auto ex_path = tempdir.path() / "exceptions-bad-chain.json";
|
||||
write_exceptions_file(
|
||||
ex_path,
|
||||
chain_id_type( "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" ),
|
||||
{ { 1, 1000, "wrong chain test" } } );
|
||||
cfg.chain_historical_exceptions_path = ex_path;
|
||||
|
||||
BOOST_REQUIRE_THROW( tester t( cfg, cfg_pair.second ), chain_id_type_exception );
|
||||
}
|
||||
|
||||
// 3. A block whose only header divergence is action_mroot=0, inside a
|
||||
// declared window, is accepted by a validator that has the matching
|
||||
// exception loaded.
|
||||
BOOST_AUTO_TEST_CASE(in_window_action_mroot_zero_bypass) {
|
||||
tester main;
|
||||
main.produce_block();
|
||||
main.create_account( "newacc"_n );
|
||||
auto good_block = main.produce_block();
|
||||
auto zeroed = clone_block_with_action_mroot( main, good_block, digest_type() );
|
||||
|
||||
fc::temp_directory tempdir;
|
||||
auto cfg_pair = base_tester::default_config( tempdir );
|
||||
auto cfg = cfg_pair.first;
|
||||
|
||||
auto ex_path = tempdir.path() / "exceptions-in-window.json";
|
||||
write_exceptions_file(
|
||||
ex_path,
|
||||
main.control->get_chain_id(),
|
||||
{ { 1, 1000, "test bypass window" } } );
|
||||
cfg.chain_historical_exceptions_path = ex_path;
|
||||
|
||||
tester validator( cfg, cfg_pair.second );
|
||||
// Replay the prefix so validator's head matches good_block's parent.
|
||||
for( uint32_t bn = 2; bn < good_block->block_num(); ++bn ) {
|
||||
auto prev = main.control->fetch_block_by_number( bn );
|
||||
auto bsf = validator.control->create_block_state_future( prev->calculate_id(), prev );
|
||||
controller::block_report br;
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} );
|
||||
}
|
||||
|
||||
auto bsf = validator.control->create_block_state_future( zeroed->calculate_id(), zeroed );
|
||||
validator.control->abort_block();
|
||||
controller::block_report br;
|
||||
BOOST_REQUIRE_NO_THROW(
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} ) );
|
||||
}
|
||||
|
||||
// 4. The same zeroed block, but the declared window does not cover its
|
||||
// block_num — bypass must not fire.
|
||||
BOOST_AUTO_TEST_CASE(out_of_window_action_mroot_zero_rejected) {
|
||||
tester main;
|
||||
main.produce_block();
|
||||
main.create_account( "newacc"_n );
|
||||
auto good_block = main.produce_block();
|
||||
auto zeroed = clone_block_with_action_mroot( main, good_block, digest_type() );
|
||||
|
||||
fc::temp_directory tempdir;
|
||||
auto cfg_pair = base_tester::default_config( tempdir );
|
||||
auto cfg = cfg_pair.first;
|
||||
|
||||
auto ex_path = tempdir.path() / "exceptions-out-of-window.json";
|
||||
write_exceptions_file(
|
||||
ex_path,
|
||||
main.control->get_chain_id(),
|
||||
{ { 1000000, 1000010, "window far away" } } );
|
||||
cfg.chain_historical_exceptions_path = ex_path;
|
||||
|
||||
tester validator( cfg, cfg_pair.second );
|
||||
for( uint32_t bn = 2; bn < good_block->block_num(); ++bn ) {
|
||||
auto prev = main.control->fetch_block_by_number( bn );
|
||||
auto bsf = validator.control->create_block_state_future( prev->calculate_id(), prev );
|
||||
controller::block_report br;
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} );
|
||||
}
|
||||
|
||||
auto bsf = validator.control->create_block_state_future( zeroed->calculate_id(), zeroed );
|
||||
validator.control->abort_block();
|
||||
controller::block_report br;
|
||||
BOOST_REQUIRE_THROW(
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} ),
|
||||
fc::exception );
|
||||
}
|
||||
|
||||
// 5. Block has action_mroot=0 inside the window, but ALSO has another
|
||||
// header field altered. Bypass must refuse — it only forgives the known
|
||||
// action_mroot-zero shape, not arbitrary header corruption.
|
||||
BOOST_AUTO_TEST_CASE(in_window_but_other_field_altered_rejected) {
|
||||
tester main;
|
||||
main.produce_block();
|
||||
main.create_account( "newacc"_n );
|
||||
auto good_block = main.produce_block();
|
||||
|
||||
// First zero the action_mroot...
|
||||
auto copy_b = std::make_shared<signed_block>( good_block->clone() );
|
||||
copy_b->action_mroot = digest_type();
|
||||
// ...then also tamper with another header field that is part of the diff.
|
||||
copy_b->confirmed = static_cast<uint16_t>( copy_b->confirmed + 1 );
|
||||
|
||||
auto header_bmroot = digest_type::hash(
|
||||
std::make_pair( copy_b->digest(),
|
||||
main.control->head_block_state()->blockroot_merkle.get_root() ) );
|
||||
auto sig_digest = digest_type::hash(
|
||||
std::make_pair( header_bmroot,
|
||||
main.control->head_block_state()->pending_schedule.schedule_hash ) );
|
||||
copy_b->producer_signature =
|
||||
main.get_private_key( config::system_account_name, "active" ).sign( sig_digest );
|
||||
|
||||
fc::temp_directory tempdir;
|
||||
auto cfg_pair = base_tester::default_config( tempdir );
|
||||
auto cfg = cfg_pair.first;
|
||||
|
||||
auto ex_path = tempdir.path() / "exceptions-strict-shape.json";
|
||||
write_exceptions_file(
|
||||
ex_path,
|
||||
main.control->get_chain_id(),
|
||||
{ { 1, 1000, "window covers, but block shape is wrong" } } );
|
||||
cfg.chain_historical_exceptions_path = ex_path;
|
||||
|
||||
tester validator( cfg, cfg_pair.second );
|
||||
for( uint32_t bn = 2; bn < good_block->block_num(); ++bn ) {
|
||||
auto prev = main.control->fetch_block_by_number( bn );
|
||||
auto bsf = validator.control->create_block_state_future( prev->calculate_id(), prev );
|
||||
controller::block_report br;
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} );
|
||||
}
|
||||
|
||||
auto bsf = validator.control->create_block_state_future( copy_b->calculate_id(), copy_b );
|
||||
validator.control->abort_block();
|
||||
controller::block_report br;
|
||||
BOOST_REQUIRE_THROW(
|
||||
validator.control->push_block( br, bsf.get(), forked_branch_callback{}, trx_meta_cache_lookup{} ),
|
||||
fc::exception );
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
Reference in New Issue
Block a user