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:
coopops
2026-06-03 06:19:06 +00:00
parent fa8502eccc
commit de24cd822e
6 changed files with 634 additions and 5 deletions
+268
View File
@@ -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.
+83 -5
View File
@@ -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;
+11
View File
@@ -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());
+232
View File
@@ -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()