feat(chain): v5.3.1 — onblock skip + non-zero mroot bypass + builtin registry (#7)

Three coupled mechanisms in chain_historical_exceptions so archival nodes can
replay coopenomics mainnet past the 2026-05-11 v5.2.0-dev incident:

- chain_historical_exceptions gains onblock_skip_windows and
  suppressed_activations vectors; both are optional in the JSON form so v5.3.0
  files keep parsing.
- apply_block bypass no longer requires action_mroot == 0 inside a window —
  any divergent mroot is accepted when every other header field matches.
  Needed because a sound replay computes a non-zero mroot in the dirty window
  (onblock works) while the canonical chain stored zero.
- start_block skips the implicit onblock transaction inside any configured
  window. Without this the chainbase (block_summary_object etc.) mutated by
  onblock diverges from canonical state and every later block fails to
  validate, defeating the mroot bypass.
- trigger_activation_handler can be suppressed per feature_digest via the
  registry — guards against future incidents where a marked-activated feature
  must not run its handler during replay.
- New chain_exceptions_builtin.cpp ships a compiled-in registry keyed by
  chain_id; for coopenomics mainnet it carries action_mroot + onblock-skip
  windows [113273322..113275716]. Operators no longer need to distribute a
  separate JSON file; the .deb is self-contained. File option still wins
  per-node when set.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-06-04 10:12:24 +05:00
committed by GitHub
parent c7cd80af29
commit b94c791b10
7 changed files with 342 additions and 36 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ set( CXX_STANDARD_REQUIRED ON)
set(VERSION_MAJOR 5)
set(VERSION_MINOR 3)
set(VERSION_PATCH 0)
set(VERSION_PATCH 1)
set(VERSION_SUFFIX "" CACHE STRING "Optional pre-release suffix appended to version (e.g. rc1, beta). Empty = clean release.")
if(VERSION_SUFFIX)
+13
View File
@@ -172,6 +172,19 @@ v5.1.0-abc123def456...
Ожидаемый `chain_id` основной сети: `6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b`.
### Полный ресинк mainnet — встроенные исторические исключения
Начиная с **v5.3.1** реестр исторических исключений (`chain_historical_exceptions`) поставляется *внутри бинаря* для известных production-цепочек. Для основной сети Кооперативной Экономики (chain_id `6e37f9ac…26c1b`) встроены два окна вокруг инцидента 2026-05-11 (релиз `v5.2.0-dev-294edf3b8`):
- `action_mroot_zero_windows = [113273322 .. 113275716]` — bypass расхождения action_mroot в окне;
- `onblock_skip_windows = [113273322 .. 113275716]` — пропуск implicit-транзакции `onblock`, чтобы state нашего реплея не разъехался с канонической цепью.
Дополнительно — в `apply_block` снято строгое требование `action_mroot == 0` в bypass'е: внутри окна допускается *любое* расхождение action_mroot при условии полного совпадения остальных полей заголовка.
Никакого внешнего JSON-файла для mainnet больше не требуется. Достаточно установить `.deb` и запустить полный ресинк — bypass применится автоматически по совпадению `chain_id`. Форки и тестнеты с другим chain_id видят пустой реестр и работают идентично апстрим-Antelope.
Для разовой переопределения (например, расследование на одной ноде) ещё работает старая опция `chain-historical-exceptions = /path/to/file.json` — она полностью заменяет встроенную запись на этой ноде. JSON формата v5.3.0 (только `action_mroot_zero_windows`) грузится в v5.3.1 без правок.
### Первый запуск с genesis (полный ресинк с блока 1)
`--genesis-json` принимается nodeos **только если `data-dir` пуст** (отсутствуют каталоги `state`, `blocks`, `state-history`, `snapshots`). Поэтому при первом запуске или при намеренном пересинке:
+1
View File
@@ -101,6 +101,7 @@ add_library( eosio_chain
eosio_contract_abi_bin.cpp
chain_config.cpp
chain_id_type.cpp
chain_exceptions_builtin.cpp
genesis_state.cpp
${CMAKE_CURRENT_BINARY_DIR}/genesis_state_root_key.cpp
@@ -0,0 +1,96 @@
#include <eosio/chain/chain_exceptions.hpp>
namespace eosio { namespace chain {
namespace {
/**
* Compile-time registry of known historical exceptions, keyed by chain_id.
*
* Add a new entry here when a fix-binary release of coopos must, on a
* specific production chain, bypass a permanent inconsistency in the
* irreversible block log left behind by an earlier broken producer build.
* Each entry is scoped strictly to its chain_id; forks/testnets/devnets with
* a different chain_id are unaffected.
*
* The intent of an entry is to ship enough information in the binary that
* any node operator on the named chain can simply install the .deb and run
* a full-history replay — no extra config files, no out-of-band JSON.
*
* To override a builtin entry on a specific node (e.g. during forensic
* investigation) set `chain-historical-exceptions` in config.ini to a JSON
* file with the matching chain_id; the file replaces the builtin record
* entirely for that node.
*/
const std::vector<chain_historical_exceptions> _builtin_registry = [] {
std::vector<chain_historical_exceptions> v;
// -----------------------------------------------------------------------
// Coopenomics mainnet (chain_id 6e37f9ac…26c1b)
//
// 2026-05-11 ~10:18 UTC: the BP deployed `coopos v5.2.0-dev-294edf3b8`
// which had two latent defects relative to v5.2.0 release:
// - intrinsic table for `assert_recover_key_account` was inserted in
// the middle of the table, shifting the IDs of every later intrinsic
// (fixed in 5bb051f9a "переместить в конец таблиц — ABI compat");
// - the on_activation handler for ASSERT_RECOVER_KEY_ACCOUNT was not
// registered (fixed in 2c23b8108).
// While that build was producing, the implicit onblock action emitted
// no receipts (`_action_receipt_digests` stayed empty) for 2395 blocks,
// finalizing `action_mroot = 0`. The BP restarted onto a fixed v5.2.0
// build at ~10:43:39 UTC; block 113275717 is the first post-recovery
// block with a non-zero action_mroot. ASSERT_RECOVER_KEY_ACCOUNT itself
// was only formally activated much later, at block 113318028 (16:36 UTC).
//
// For a sound replay against this chain we need to:
// (a) accept zero-mroot blocks in [113273322..113275716] even though
// our build would compute a non-zero local mroot, because the
// canonical chain stores zero;
// (b) skip onblock execution in that same window so we do NOT mutate
// chainbase (e.g. block_summary_object) for those 2395 blocks —
// the canonical chain skipped those mutations and our local state
// must match for downstream blocks to validate.
// No handler suppression is needed: the activation handler that adds
// `assert_recover_key_account` to whitelisted_intrinsics runs at block
// 113318028, by which time the BP was already on the fixed binary, so
// the canonical chain and a v5.3.1 replay agree from that block onward.
// -----------------------------------------------------------------------
{
chain_historical_exceptions e;
e.chain_id = chain_id_type(
std::string("6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b") );
e.action_mroot_zero_windows.push_back({
/* from_block */ 113273322u,
/* to_block */ 113275716u,
/* reason */
"Coopenomics mainnet: 2395 irreversible blocks finalized with empty "
"_action_receipt_digests (action_mroot = 0) by v5.2.0-dev-294edf3b8 "
"between 2026-05-11 10:18 and 10:43 UTC; window boundaries verified "
"via leap-util block-log print-log on archived blocks.log."
});
e.onblock_skip_windows.push_back({
/* from_block */ 113273322u,
/* to_block */ 113275716u,
/* reason */
"Coopenomics mainnet: onblock implicit action did not record receipts "
"on the buggy BP build (mis-ordered intrinsic table), so the canonical "
"chain skipped onblock side-effects for these 2395 blocks. A correct "
"replay must skip onblock here too, otherwise chainbase mutations "
"diverge from canonical state and every later block fails to validate."
});
v.push_back( std::move(e) );
}
return v;
}();
} // anonymous namespace
const std::vector<chain_historical_exceptions>& get_builtin_historical_exceptions() {
return _builtin_registry;
}
} } // namespace eosio::chain
+78 -23
View File
@@ -701,32 +701,69 @@ struct controller_impl {
*/
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;
bool from_file = false;
if( !p.empty() ) {
if( !std::filesystem::exists( p ) ) {
wlog( "chain-historical-exceptions path ${p} does not exist; falling back to compiled-in registry",
("p", p.string()) );
} else {
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) );
from_file = true;
ilog( "loaded chain historical exceptions from file ${p}", ("p", p.string()) );
}
}
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) );
if( !from_file ) {
// Builtin fallback: match by chain_id.
for( const auto& b : get_builtin_historical_exceptions() ) {
if( b.chain_id == chain_id ) {
ex = b;
ilog( "applying compiled-in chain historical exceptions for chain ${cid}",
("cid", chain_id) );
break;
}
}
}
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}",
wlog( "historical action_mroot bypass window [${a}..${b}]: ${r}",
("a", w.from_block)("b", w.to_block)("r", w.reason) );
}
for( const auto& w : historical_exceptions.onblock_skip_windows ) {
wlog( "historical onblock skip window [${a}..${b}]: ${r}",
("a", w.from_block)("b", w.to_block)("r", w.reason) );
}
for( const auto& s : historical_exceptions.suppressed_activations ) {
wlog( "historical on_activation suppression for feature ${d}: ${r}",
("d", s.feature_digest)("r", s.reason) );
}
}
bool is_onblock_skipped_for_block( uint32_t block_num ) const {
for( const auto& w : historical_exceptions.onblock_skip_windows ) {
if( block_num >= w.from_block && block_num <= w.to_block ) return true;
}
return false;
}
const historical_suppressed_activation* find_suppressed_activation( const digest_type& d ) const {
for( const auto& s : historical_exceptions.suppressed_activations ) {
if( s.feature_digest == d ) return &s;
}
return nullptr;
}
static auto validate_db_version( const chainbase::database& db ) {
@@ -1815,7 +1852,12 @@ struct controller_impl {
}
if( f.builtin_feature ) {
trigger_activation_handler( *f.builtin_feature );
if( const auto* suppress = find_suppressed_activation( feature_digest ) ) {
wlog( "historical exception: suppressing on_activation handler for feature ${d} at block ${bn}: ${r}",
("d", feature_digest)("bn", pbhs.block_num)("r", suppress->reason) );
} else {
trigger_activation_handler( *f.builtin_feature );
}
}
protocol_features.activate_feature( feature_digest, pbhs.block_num );
@@ -1867,6 +1909,10 @@ struct controller_impl {
});
}
const uint32_t next_block_num = head->block_num + 1;
if( is_onblock_skipped_for_block( next_block_num ) ) {
wlog( "historical exception: skipping onblock implicit transaction for block ${bn}", ("bn", next_block_num) );
} else {
try {
transaction_metadata_ptr onbtrx =
transaction_metadata::create_no_recover_keys( std::make_shared<packed_transaction>( get_on_block_transaction() ),
@@ -1895,6 +1941,7 @@ struct controller_impl {
} catch( ... ) {
elog( "on block transaction failed due to unknown exception" );
}
}
clear_expired_input_transactions(deadline);
update_producers_authority();
@@ -2213,15 +2260,23 @@ struct controller_impl {
if( producer_block_id != ab._id ) {
bool historical_bypass = false;
if( !historical_exceptions.action_mroot_zero_windows.empty()
&& b->action_mroot == digest_type() )
{
if( !historical_exceptions.action_mroot_zero_windows.empty() ) {
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;
// Bypass demands that EVERY block header field other than
// action_mroot matches what the producer recorded. The
// canonical chain may carry either a zero action_mroot
// (broken build emitted empty digests) or a non-zero one
// that simply diverges from what a sound replay computes;
// in either case the only allowed difference within a
// declared window is action_mroot itself.
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) );
wlog( "historical action_mroot bypass applied for block ${bn} in window [${a}..${c}] "
"(producer mroot ${pm}, local mroot ${lm}): ${r}",
("bn", bn)("a", w.from_block)("c", w.to_block)
("pm", b->action_mroot)("lm", ab._unsigned_block->action_mroot)
("r", w.reason) );
historical_bypass = true;
break;
}
@@ -8,10 +8,11 @@
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.
* A single window of block numbers in which `action_mroot` is allowed to
* diverge from the locally-computed value. Used to recover full-history
* replay past historical incidents where a buggy producer build emitted
* blocks whose action_mroot is permanently part of the irreversible chain
* yet cannot be reproduced by a sound replay.
*/
struct historical_action_mroot_window {
uint32_t from_block = 0;
@@ -20,10 +21,40 @@ struct historical_action_mroot_window {
};
/**
* 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.
* A window in which the implicit `onblock` transaction must be skipped during
* apply_block. Use when the producer build of record failed to record onblock
* receipts: re-executing onblock during replay would mutate state (e.g.
* block_summary_object) inconsistently with the canonical chain and cause
* permanent divergence from this window onward.
*/
struct historical_onblock_skip_window {
uint32_t from_block = 0;
uint32_t to_block = 0;
std::string reason;
};
/**
* Suppress the on_activation handler for a specific protocol feature digest.
* Use when the producer build of record marked a feature as activated but
* failed to run its activation handler — re-running the handler during replay
* mutates chainbase (e.g. whitelisted_intrinsics in protocol_state_object)
* differently from the canonical chain.
*/
struct historical_suppressed_activation {
digest_type feature_digest;
std::string reason;
};
/**
* Per-chain set of historical validation exceptions. May be loaded from an
* external JSON file at startup, OR matched from a compiled-in registry
* keyed by `chain_id`. The controller refuses to load a file whose
* `chain_id` does not match the running chain. Absent file AND no builtin
* match => strict upstream validation, no bypass.
*
* Fields beyond chain_id are optional in the JSON form — older files
* declaring only action_mroot_zero_windows continue to parse cleanly into
* a record whose new vectors are empty.
*/
struct chain_historical_exceptions {
// chain_id_type's default ctor is private upstream; provide an explicit
@@ -32,14 +63,36 @@ struct chain_historical_exceptions {
chain_historical_exceptions()
: chain_id( chain_id_type::empty_chain_id() ) {}
chain_id_type chain_id;
std::vector<historical_action_mroot_window> action_mroot_zero_windows;
chain_id_type chain_id;
std::vector<historical_action_mroot_window> action_mroot_zero_windows;
std::vector<historical_onblock_skip_window> onblock_skip_windows;
std::vector<historical_suppressed_activation> suppressed_activations;
};
/**
* Compiled-in registry of historical exceptions, keyed by chain_id. Empty
* unless `coopos` ships with known mainnet incidents baked in. The loader
* consults this list when no external file is configured: any entry whose
* chain_id equals the running controller's chain_id is applied verbatim,
* so archival nodes on known chains work out of the box without an extra
* config artifact. Forks/testnets with a different chain_id see no entry
* and behave like upstream Antelope.
*/
const std::vector<chain_historical_exceptions>& get_builtin_historical_exceptions();
} } // namespace eosio::chain
FC_REFLECT( eosio::chain::historical_action_mroot_window,
(from_block)(to_block)(reason) )
FC_REFLECT( eosio::chain::historical_onblock_skip_window,
(from_block)(to_block)(reason) )
FC_REFLECT( eosio::chain::historical_suppressed_activation,
(feature_digest)(reason) )
FC_REFLECT( eosio::chain::chain_historical_exceptions,
(chain_id)(action_mroot_zero_windows) )
(chain_id)
(action_mroot_zero_windows)
(onblock_skip_windows)
(suppressed_activations) )
+89 -1
View File
@@ -4,12 +4,18 @@
* 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)
* - block with NON-zero divergent action_mroot inside window → bypass also
* accepts (v5.3.1: dropped the `action_mroot==0` strict guard, because in
* practice a sound replay may compute a non-zero mroot inside the window
* that simply does not match what the buggy producer recorded)
* - chain_historical_exceptions JSON without the new optional fields
* (onblock_skip_windows, suppressed_activations) still round-trips
* (backward compatibility with files produced for v5.3.0)
*
* 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
@@ -218,4 +224,86 @@ BOOST_AUTO_TEST_CASE(in_window_but_other_field_altered_rejected) {
fc::exception );
}
// v5.3.1: bypass no longer requires action_mroot == 0. Any divergent mroot
// inside a declared window is accepted as long as the rest of the header
// matches. Models the real coopenomics mainnet case where 113275717+ have
// non-zero but divergent action_mroot for some span.
BOOST_AUTO_TEST_CASE(in_window_nonzero_divergent_mroot_bypass) {
tester main;
main.produce_block();
main.create_account( "newacc"_n );
auto good_block = main.produce_block();
// Replace mroot with arbitrary non-zero digest that the validator will not
// reproduce locally.
auto divergent = clone_block_with_action_mroot(
main, good_block,
digest_type::hash( std::string("synthetic-divergent-mroot") ) );
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-nonzero.json";
write_exceptions_file(
ex_path,
main.control->get_chain_id(),
{ { 1, 1000, "v5.3.1: bypass accepts any divergent mroot in window" } } );
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( divergent->calculate_id(), divergent );
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{} ) );
}
// v5.3.1: chain_historical_exceptions struct gained two optional vectors
// (onblock_skip_windows, suppressed_activations). A JSON file produced for
// v5.3.0 (mentioning only action_mroot_zero_windows) must still parse, with
// the new vectors defaulting to empty.
BOOST_AUTO_TEST_CASE(legacy_json_without_new_fields_round_trips) {
const std::string legacy_json = R"({
"chain_id": "6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b",
"action_mroot_zero_windows": [
{ "from_block": 1, "to_block": 10, "reason": "legacy v5.3.0 file" }
]
})";
chain_historical_exceptions ex;
BOOST_REQUIRE_NO_THROW(
ex = fc::json::from_string( legacy_json ).as<chain_historical_exceptions>() );
BOOST_REQUIRE_EQUAL( ex.action_mroot_zero_windows.size(), 1u );
BOOST_REQUIRE_EQUAL( ex.action_mroot_zero_windows[0].from_block, 1u );
BOOST_REQUIRE_EQUAL( ex.action_mroot_zero_windows[0].to_block, 10u );
BOOST_REQUIRE( ex.onblock_skip_windows.empty() );
BOOST_REQUIRE( ex.suppressed_activations.empty() );
}
// v5.3.1: the compiled-in registry is consulted when no file is configured.
// It must contain the coopenomics mainnet entry by chain_id.
BOOST_AUTO_TEST_CASE(builtin_registry_contains_mainnet_entry) {
const auto& reg = get_builtin_historical_exceptions();
BOOST_REQUIRE( !reg.empty() );
const chain_id_type mainnet(
std::string("6e37f9ac0f0ea717bfdbf57d1dd5d7f0e2d773227d9659a63bbf86eec0326c1b") );
bool found = false;
for( const auto& e : reg ) {
if( e.chain_id == mainnet ) {
found = true;
BOOST_REQUIRE( !e.action_mroot_zero_windows.empty() );
BOOST_REQUIRE( !e.onblock_skip_windows.empty() );
break;
}
}
BOOST_REQUIRE( found );
}
BOOST_AUTO_TEST_SUITE_END()