From 8b36c9f90b0336f2b79f20e40d9e89cb1255e72b Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Fri, 24 Apr 2026 11:44:58 +0300 Subject: [PATCH 1/9] add autoactivate options on disabled witness Co-authored-by: Copilot --- plugins/witness_guard/CMakeLists.txt | 44 +++ .../plugins/witness_guard/witness_guard.hpp | 48 +++ plugins/witness_guard/witness_guard.cpp | 293 ++++++++++++++++++ programs/vizd/main.cpp | 5 +- 4 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 plugins/witness_guard/CMakeLists.txt create mode 100644 plugins/witness_guard/include/graphene/plugins/witness_guard/witness_guard.hpp create mode 100644 plugins/witness_guard/witness_guard.cpp diff --git a/plugins/witness_guard/CMakeLists.txt b/plugins/witness_guard/CMakeLists.txt new file mode 100644 index 0000000000..428e7f0f44 --- /dev/null +++ b/plugins/witness_guard/CMakeLists.txt @@ -0,0 +1,44 @@ +set(CURRENT_TARGET witness_guard) + +list(APPEND CURRENT_TARGET_HEADERS + include/graphene/plugins/witness_guard/witness_guard.hpp + ) + +list(APPEND CURRENT_TARGET_SOURCES + witness_guard.cpp + ) + +if(BUILD_SHARED_LIBRARIES) + add_library(graphene_${CURRENT_TARGET} SHARED + ${CURRENT_TARGET_HEADERS} + ${CURRENT_TARGET_SOURCES} + ) +else() + add_library(graphene_${CURRENT_TARGET} STATIC + ${CURRENT_TARGET_HEADERS} + ${CURRENT_TARGET_SOURCES} + ) +endif() + +add_library(graphene::${CURRENT_TARGET} ALIAS graphene_${CURRENT_TARGET}) +set_property(TARGET graphene_${CURRENT_TARGET} PROPERTY EXPORT_NAME ${CURRENT_TARGET}) + +target_link_libraries( + graphene_${CURRENT_TARGET} + graphene::chain_plugin + graphene::p2p + graphene::protocol + graphene_utilities + graphene_time + appbase +) + +target_include_directories(graphene_${CURRENT_TARGET} + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") + +install(TARGETS + graphene_${CURRENT_TARGET} + RUNTIME DESTINATION bin + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + ) \ No newline at end of file diff --git a/plugins/witness_guard/include/graphene/plugins/witness_guard/witness_guard.hpp b/plugins/witness_guard/include/graphene/plugins/witness_guard/witness_guard.hpp new file mode 100644 index 0000000000..11fdaee2e7 --- /dev/null +++ b/plugins/witness_guard/include/graphene/plugins/witness_guard/witness_guard.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace witness_guard { + +class witness_guard_plugin final + : public appbase::plugin { +public: + APPBASE_PLUGIN_REQUIRES( + (graphene::plugins::chain::plugin) + (graphene::plugins::p2p::p2p_plugin) + ) + + constexpr static const char *plugin_name = "witness_guard"; + + static const std::string &name() { + static std::string name = plugin_name; + return name; + } + + witness_guard_plugin(); + ~witness_guard_plugin(); + + void set_program_options( + boost::program_options::options_description &command_line_options, + boost::program_options::options_description &config_file_options + ) override; + + void plugin_initialize( + const boost::program_options::variables_map &options + ) override; + + void plugin_startup() override; + void plugin_shutdown() override; + +private: + struct impl; + std::unique_ptr pimpl; +}; + +} // witness_guard +} // plugins +} // graphene \ No newline at end of file diff --git a/plugins/witness_guard/witness_guard.cpp b/plugins/witness_guard/witness_guard.cpp new file mode 100644 index 0000000000..455e2335fc --- /dev/null +++ b/plugins/witness_guard/witness_guard.cpp @@ -0,0 +1,293 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace graphene { +namespace plugins { +namespace witness_guard { + +namespace bpo = boost::program_options; + +// ─── impl ──────────────────────────────────────────────────────────────────── + +struct witness_guard_plugin::impl { + impl() + : chain_(appbase::app().get_plugin()) + , p2p_(appbase::app().get_plugin()) + {} + + graphene::chain::database& db() { return chain_.db(); } + + // ── config ──────────────────────────────────────────────────────────────── + bool _enabled = true; + uint32_t _check_interval = 20; // blocuri între verificări + + // Witness accounts de monitorizat + std::set _witnesses; + + // signing_pub → signing_priv (cheia cu care semnează blocuri) + std::map _signing_keys; + + // active_pub → active_priv (cheia cu care semnăm witness_update) + std::map _active_keys; + + // guard anti-spam: nu trimitem a doua oară până nodul nu repornește + std::set _restore_sent; + + // ── core ────────────────────────────────────────────────────────────────── + void check_and_restore(); + void send_witness_update(const std::string& witness_name, + const graphene::chain::witness_object& obj); + + graphene::plugins::chain::plugin& chain_; + graphene::plugins::p2p::p2p_plugin& p2p_; +}; + +// ─── check_and_restore ─────────────────────────────────────────────────────── + +void witness_guard_plugin::impl::check_and_restore() { + auto& database = db(); + + // Verificăm doar dacă nodul e sincronizat + // (head block în ultimele 2 * CHAIN_BLOCK_INTERVAL secunde) + const auto head_time = database.head_block_time(); + const auto now = fc::time_point_sec(graphene::time::now()); + if (head_time < now - fc::seconds(CHAIN_BLOCK_INTERVAL * 2)) { + dlog("witness_guard: node not in sync, skipping check"); + return; + } + + const auto& idx = database + .get_index() + .indices() + .get(); + + static const graphene::protocol::public_key_type null_key; + + for (const auto& name : _witnesses) { + // Deja am trimis restore pentru acesta la această sesiune + if (_restore_sent.count(name)) continue; + + auto itr = idx.find(name); + if (itr == idx.end()) { + wlog("witness_guard: witness '${w}' not found in database", ("w", name)); + continue; + } + + if (itr->signing_key != null_key) continue; // cheia e ok + + ilog("witness_guard: '${w}' has null signing key on-chain — initiating restore", + ("w", name)); + send_witness_update(name, *itr); + } +} + +// ─── send_witness_update ───────────────────────────────────────────────────── + +void witness_guard_plugin::impl::send_witness_update( + const std::string& witness_name, + const graphene::chain::witness_object& obj) +{ + try { + // 1. Găsim cheia de signing din config + if (_signing_keys.empty()) { + elog("witness_guard: no witness-guard-signing-key configured for '${w}'", + ("w", witness_name)); + return; + } + const auto& [signing_pub, signing_priv] = *_signing_keys.begin(); + + // 2. Găsim cheia active din config + if (_active_keys.empty()) { + elog("witness_guard: no witness-guard-active-key configured for '${w}'", + ("w", witness_name)); + return; + } + const auto& [active_pub, active_priv] = *_active_keys.begin(); + + // 3. URL curent din blockchain (nu îl suprascriem) + std::string url = fc::to_string(obj.url); + + // 4. Construim operația + graphene::protocol::witness_update_operation op; + op.owner = witness_name; + op.url = url; + op.block_signing_key = signing_pub; + + // 5. Construim tranzacția + graphene::chain::signed_transaction tx; + tx.operations.push_back(op); + tx.set_expiration(db().head_block_time() + fc::seconds(30)); + tx.set_reference_block(db().head_block_id()); + + // 6. Semnăm cu cheia active + tx.sign(active_priv, db().get_chain_id()); + + ilog("witness_guard: broadcasting witness_update for '${w}' " + "— restoring signing key to ${k}", + ("w", witness_name)("k", signing_pub)); + + // 7. Push local + broadcast în rețea + db().push_transaction(tx, graphene::chain::database::skip_nothing); + p2p_.broadcast_transaction(tx); + + // 8. Marcăm ca trimis — nu mai trimitem în această sesiune + _restore_sent.insert(witness_name); + + ilog("witness_guard: witness_update for '${w}' sent successfully", ("w", witness_name)); + + } catch (const fc::exception& e) { + elog("witness_guard: witness_update FAILED for '${w}': ${e}", + ("w", witness_name)("e", e.to_detail_string())); + // Nu adăugăm în _restore_sent — vom reîncerca la următoarea verificare + } +} + +// ─── plugin lifecycle ──────────────────────────────────────────────────────── + +witness_guard_plugin::witness_guard_plugin() = default; +witness_guard_plugin::~witness_guard_plugin() = default; + +void witness_guard_plugin::set_program_options( + bpo::options_description& cli, + bpo::options_description& cfg) +{ + cfg.add_options() + ("witness-guard-enabled", + bpo::value()->default_value(true), + "Enable witness key auto-restore. " + "When true, the plugin monitors configured witnesses and sends " + "witness_update if the on-chain signing key is reset to null.") + + ("witness-guard-account", + bpo::value>()->composing()->multitoken(), + "Witness account name to monitor (can be specified multiple times).") + + ("witness-guard-signing-key", + bpo::value>()->composing()->multitoken(), + "WIF private key to restore as the signing key on-chain. " + "This is the block-signing key (same as private-key in witness plugin).") + + ("witness-guard-active-key", + bpo::value>()->composing()->multitoken(), + "WIF private key with active authority on the witness account. " + "Used to sign the witness_update transaction. " + "WARNING: stored in plaintext in config.ini — use a dedicated key.") + + ("witness-guard-interval", + bpo::value()->default_value(20), + "How often to check witness signing keys, in blocks (default: 20 ≈ 60s).") + ; + + cli.add(cfg); +} + +void witness_guard_plugin::plugin_initialize( + const bpo::variables_map& options) +{ + try { + ilog("witness_guard: plugin_initialize() begin"); + pimpl = std::make_unique(); + + // enabled flag + if (options.count("witness-guard-enabled")) { + pimpl->_enabled = options["witness-guard-enabled"].as(); + } + if (!pimpl->_enabled) { + ilog("witness_guard: disabled via config, skipping initialization"); + return; + } + + // interval + pimpl->_check_interval = options["witness-guard-interval"].as(); + if (pimpl->_check_interval == 0) pimpl->_check_interval = 1; + + // witness accounts + if (options.count("witness-guard-account")) { + const auto& names = + options["witness-guard-account"].as>(); + for (const auto& n : names) { + pimpl->_witnesses.insert(n); + } + } + + // signing keys + if (options.count("witness-guard-signing-key")) { + const auto& keys = + options["witness-guard-signing-key"].as>(); + for (const auto& wif : keys) { + auto priv = graphene::utilities::wif_to_key(wif); + FC_ASSERT(priv.valid(), "witness-guard-signing-key: invalid WIF key"); + pimpl->_signing_keys[priv->get_public_key()] = *priv; + } + } + + // active keys + if (options.count("witness-guard-active-key")) { + const auto& keys = + options["witness-guard-active-key"].as>(); + for (const auto& wif : keys) { + auto priv = graphene::utilities::wif_to_key(wif); + FC_ASSERT(priv.valid(), "witness-guard-active-key: invalid WIF key"); + pimpl->_active_keys[priv->get_public_key()] = *priv; + } + } + + // Validare minimă + if (!pimpl->_witnesses.empty() && + pimpl->_signing_keys.empty()) { + wlog("witness_guard: witness-guard-account set but no " + "witness-guard-signing-key configured — plugin will not restore keys"); + } + if (!pimpl->_witnesses.empty() && + pimpl->_active_keys.empty()) { + wlog("witness_guard: witness-guard-account set but no " + "witness-guard-active-key configured — plugin will not restore keys"); + } + + ilog("witness_guard: plugin_initialize() end — " + "monitoring ${n} witness(es), interval=${i} blocks", + ("n", pimpl->_witnesses.size())("i", pimpl->_check_interval)); + + } FC_LOG_AND_RETHROW() +} + +void witness_guard_plugin::plugin_startup() { + ilog("witness_guard: plugin_startup() begin"); + + if (!pimpl->_enabled || pimpl->_witnesses.empty()) { + ilog("witness_guard: nothing to monitor, plugin inactive"); + return; + } + + // Hook pe fiecare bloc aplicat + pimpl->db().applied_block.connect( + [this](const graphene::chain::signed_block& b) { + if (!pimpl->_enabled) return; + if (b.block_num() % pimpl->_check_interval == 0) { + pimpl->check_and_restore(); + } + } + ); + + ilog("witness_guard: plugin_startup() end — active"); +} + +void witness_guard_plugin::plugin_shutdown() { + ilog("witness_guard: plugin_shutdown()"); +} + +} // witness_guard +} // plugins +} // graphene \ No newline at end of file diff --git a/programs/vizd/main.cpp b/programs/vizd/main.cpp index 42b19ce8e1..1e7e54e23d 100644 --- a/programs/vizd/main.cpp +++ b/programs/vizd/main.cpp @@ -30,6 +30,8 @@ #include #include +#include + #include #include #include @@ -87,7 +89,8 @@ namespace graphene { appbase::app().register_plugin(); appbase::app().register_plugin(); appbase::app().register_plugin(); - appbase::app().register_plugin(); + appbase::app().register_plugin(); + appbase::app().register_plugin(); ///plugins }; } From 37d5448077c99980d66807f084a3343ab4ae56cb Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 02:34:20 +0300 Subject: [PATCH 2/9] add autoactivate options on disabled witness --- build-linux.sh | 1 + plugins/witness_guard/witness_guard.cpp | 17 +++++++++-------- programs/vizd/CMakeLists.txt | 1 + programs/vizd/main.cpp | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/build-linux.sh b/build-linux.sh index a621451843..f0eb5c559c 100644 --- a/build-linux.sh +++ b/build-linux.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash + # ============================================================================ # VIZ Linux Build Script # diff --git a/plugins/witness_guard/witness_guard.cpp b/plugins/witness_guard/witness_guard.cpp index 455e2335fc..12cf00a390 100644 --- a/plugins/witness_guard/witness_guard.cpp +++ b/plugins/witness_guard/witness_guard.cpp @@ -6,6 +6,7 @@ #include #include #include +#include // Adăugat pentru conversia fc::shared_string la std::string #include @@ -117,7 +118,7 @@ void witness_guard_plugin::impl::send_witness_update( const auto& [active_pub, active_priv] = *_active_keys.begin(); // 3. URL curent din blockchain (nu îl suprascriem) - std::string url = fc::to_string(obj.url); + std::string url = std::string(obj.url.c_str()); // 4. Construim operația graphene::protocol::witness_update_operation op; @@ -139,7 +140,7 @@ void witness_guard_plugin::impl::send_witness_update( ("w", witness_name)("k", signing_pub)); // 7. Push local + broadcast în rețea - db().push_transaction(tx, graphene::chain::database::skip_nothing); + // db().push_transaction(tx, graphene::chain::database::skip_nothing); p2p_.broadcast_transaction(tx); // 8. Marcăm ca trimis — nu mai trimitem în această sesiune @@ -273,13 +274,13 @@ void witness_guard_plugin::plugin_startup() { // Hook pe fiecare bloc aplicat pimpl->db().applied_block.connect( - [this](const graphene::chain::signed_block& b) { - if (!pimpl->_enabled) return; - if (b.block_num() % pimpl->_check_interval == 0) { - pimpl->check_and_restore(); - } + [this](const graphene::chain::signed_block& b) { + if (!pimpl->_enabled) return; + if (b.block_num() % pimpl->_check_interval == 0) { + pimpl->check_and_restore(); } - ); + } +); ilog("witness_guard: plugin_startup() end — active"); } diff --git a/programs/vizd/CMakeLists.txt b/programs/vizd/CMakeLists.txt index 68e43f4cc8..8126b4c519 100644 --- a/programs/vizd/CMakeLists.txt +++ b/programs/vizd/CMakeLists.txt @@ -42,6 +42,7 @@ target_link_libraries( graphene::paid_subscription_api graphene::custom_protocol_api graphene::snapshot + graphene::witness_guard ${MONGO_LIB} graphene_protocol fc diff --git a/programs/vizd/main.cpp b/programs/vizd/main.cpp index 1e7e54e23d..ccc19c6f3f 100644 --- a/programs/vizd/main.cpp +++ b/programs/vizd/main.cpp @@ -89,7 +89,7 @@ namespace graphene { appbase::app().register_plugin(); appbase::app().register_plugin(); appbase::app().register_plugin(); - appbase::app().register_plugin(); + appbase::app().register_plugin(); appbase::app().register_plugin(); ///plugins }; From 2f10a60e17b114433a51a932f6a196399bcaa6a4 Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 04:24:19 +0300 Subject: [PATCH 3/9] refactor(witness): update witness configuration handling and improve key management for multiple witness monitor --- plugins/witness_guard/witness_guard.cpp | 168 ++++++++++-------------- 1 file changed, 69 insertions(+), 99 deletions(-) diff --git a/plugins/witness_guard/witness_guard.cpp b/plugins/witness_guard/witness_guard.cpp index 12cf00a390..99ffd0d63d 100644 --- a/plugins/witness_guard/witness_guard.cpp +++ b/plugins/witness_guard/witness_guard.cpp @@ -7,6 +7,8 @@ #include #include #include // Adăugat pentru conversia fc::shared_string la std::string +#include +#include #include @@ -30,26 +32,24 @@ struct witness_guard_plugin::impl { // ── config ──────────────────────────────────────────────────────────────── bool _enabled = true; - uint32_t _check_interval = 20; // blocuri între verificări + uint32_t _check_interval = 20; // blocks between checks - // Witness accounts de monitorizat - std::set _witnesses; + struct witness_info { + fc::ecc::private_key signing_key; + fc::ecc::private_key active_key; + }; - // signing_pub → signing_priv (cheia cu care semnează blocuri) - std::map _signing_keys; + // Mapping witness_name -> config (keys) + std::map _witness_configs; - // active_pub → active_priv (cheia cu care semnăm witness_update) - std::map _active_keys; - - // guard anti-spam: nu trimitem a doua oară până nodul nu repornește + // anti-spam guard: don't send a second time until the node restarts std::set _restore_sent; // ── core ────────────────────────────────────────────────────────────────── void check_and_restore(); void send_witness_update(const std::string& witness_name, - const graphene::chain::witness_object& obj); + const graphene::chain::witness_object& obj, + const witness_info& config); graphene::plugins::chain::plugin& chain_; graphene::plugins::p2p::p2p_plugin& p2p_; @@ -60,8 +60,8 @@ struct witness_guard_plugin::impl { void witness_guard_plugin::impl::check_and_restore() { auto& database = db(); - // Verificăm doar dacă nodul e sincronizat - // (head block în ultimele 2 * CHAIN_BLOCK_INTERVAL secunde) + // Check only if the node is synchronized + // (head block within the last 2 * CHAIN_BLOCK_INTERVAL seconds) const auto head_time = database.head_block_time(); const auto now = fc::time_point_sec(graphene::time::now()); if (head_time < now - fc::seconds(CHAIN_BLOCK_INTERVAL * 2)) { @@ -76,21 +76,26 @@ void witness_guard_plugin::impl::check_and_restore() { static const graphene::protocol::public_key_type null_key; - for (const auto& name : _witnesses) { - // Deja am trimis restore pentru acesta la această sesiune - if (_restore_sent.count(name)) continue; - + for (const auto& [name, config] : _witness_configs) { auto itr = idx.find(name); if (itr == idx.end()) { wlog("witness_guard: witness '${w}' not found in database", ("w", name)); continue; } - if (itr->signing_key != null_key) continue; // cheia e ok + if (itr->signing_key != null_key) { + // If the key is valid on-chain, reset the guard for this witness. + // This allows the plugin to intervene again if the key becomes null later. + _restore_sent.erase(name); + continue; + } + + // If restore has already been sent and the transaction is not yet included in a block, wait. + if (_restore_sent.count(name)) continue; ilog("witness_guard: '${w}' has null signing key on-chain — initiating restore", ("w", name)); - send_witness_update(name, *itr); + send_witness_update(name, *itr, config); } } @@ -98,52 +103,39 @@ void witness_guard_plugin::impl::check_and_restore() { void witness_guard_plugin::impl::send_witness_update( const std::string& witness_name, - const graphene::chain::witness_object& obj) + const graphene::chain::witness_object& obj, + const witness_info& config) { try { - // 1. Găsim cheia de signing din config - if (_signing_keys.empty()) { - elog("witness_guard: no witness-guard-signing-key configured for '${w}'", - ("w", witness_name)); - return; - } - const auto& [signing_pub, signing_priv] = *_signing_keys.begin(); - - // 2. Găsim cheia active din config - if (_active_keys.empty()) { - elog("witness_guard: no witness-guard-active-key configured for '${w}'", - ("w", witness_name)); - return; - } - const auto& [active_pub, active_priv] = *_active_keys.begin(); + const auto signing_pub = config.signing_key.get_public_key(); + const auto& active_priv = config.active_key; - // 3. URL curent din blockchain (nu îl suprascriem) + // Current URL from blockchain (do not overwrite) std::string url = std::string(obj.url.c_str()); - // 4. Construim operația + // Construct the operation graphene::protocol::witness_update_operation op; op.owner = witness_name; op.url = url; op.block_signing_key = signing_pub; - // 5. Construim tranzacția + // Construct the transaction graphene::chain::signed_transaction tx; tx.operations.push_back(op); tx.set_expiration(db().head_block_time() + fc::seconds(30)); tx.set_reference_block(db().head_block_id()); - // 6. Semnăm cu cheia active + // Sign with the active key tx.sign(active_priv, db().get_chain_id()); ilog("witness_guard: broadcasting witness_update for '${w}' " "— restoring signing key to ${k}", ("w", witness_name)("k", signing_pub)); - // 7. Push local + broadcast în rețea - // db().push_transaction(tx, graphene::chain::database::skip_nothing); - p2p_.broadcast_transaction(tx); + // Only network broadcast + p2p_.broadcast_transaction(tx); - // 8. Marcăm ca trimis — nu mai trimitem în această sesiune + // Mark as sent — do not send again in this session _restore_sent.insert(witness_name); ilog("witness_guard: witness_update for '${w}' sent successfully", ("w", witness_name)); @@ -151,7 +143,7 @@ void witness_guard_plugin::impl::send_witness_update( } catch (const fc::exception& e) { elog("witness_guard: witness_update FAILED for '${w}': ${e}", ("w", witness_name)("e", e.to_detail_string())); - // Nu adăugăm în _restore_sent — vom reîncerca la următoarea verificare + // Do not add to _restore_sent — we will retry at the next check } } @@ -171,20 +163,9 @@ void witness_guard_plugin::set_program_options( "When true, the plugin monitors configured witnesses and sends " "witness_update if the on-chain signing key is reset to null.") - ("witness-guard-account", + ("witness-guard-witness", bpo::value>()->composing()->multitoken(), - "Witness account name to monitor (can be specified multiple times).") - - ("witness-guard-signing-key", - bpo::value>()->composing()->multitoken(), - "WIF private key to restore as the signing key on-chain. " - "This is the block-signing key (same as private-key in witness plugin).") - - ("witness-guard-active-key", - bpo::value>()->composing()->multitoken(), - "WIF private key with active authority on the witness account. " - "Used to sign the witness_update transaction. " - "WARNING: stored in plaintext in config.ini — use a dedicated key.") + "Witness to monitor: name signing_wif active_wif (triplets). Can be specified multiple times.") ("witness-guard-interval", bpo::value()->default_value(20), @@ -214,52 +195,41 @@ void witness_guard_plugin::plugin_initialize( pimpl->_check_interval = options["witness-guard-interval"].as(); if (pimpl->_check_interval == 0) pimpl->_check_interval = 1; - // witness accounts - if (options.count("witness-guard-account")) { - const auto& names = - options["witness-guard-account"].as>(); - for (const auto& n : names) { - pimpl->_witnesses.insert(n); + // witness configs (triplets) + if (options.count("witness-guard-witness")) { + const auto& entries = options["witness-guard-witness"].as>(); + for (const auto& entry : entries) { + try { + // Parse each line as a JSON array: ["name", "signing_wif", "active_wif"] + auto arr = fc::json::from_string(entry).get_array(); + FC_ASSERT(arr.size() == 3, "witness-guard-witness expects [name, signing_wif, active_wif]"); + + std::string name = arr[0].as_string(); + auto sign_priv = graphene::utilities::wif_to_key(arr[1].as_string()); + auto active_priv = graphene::utilities::wif_to_key(arr[2].as_string()); + + FC_ASSERT(sign_priv.valid(), "witness-guard-witness: invalid signing WIF for ${n}", ("n", name)); + FC_ASSERT(active_priv.valid(), "witness-guard-witness: invalid active WIF for ${n}", ("n", name)); + + pimpl->_witness_configs[name] = { *sign_priv, *active_priv }; + + ilog("witness_guard: monitoring witness '${w}' (signing key: ${k})", + ("w", name)("k", sign_priv->get_public_key())); + + } catch (const fc::exception& e) { + elog("witness_guard: failed to parse witness entry '${entry}': ${e}", + ("entry", entry)("e", e.to_detail_string())); + } } } - // signing keys - if (options.count("witness-guard-signing-key")) { - const auto& keys = - options["witness-guard-signing-key"].as>(); - for (const auto& wif : keys) { - auto priv = graphene::utilities::wif_to_key(wif); - FC_ASSERT(priv.valid(), "witness-guard-signing-key: invalid WIF key"); - pimpl->_signing_keys[priv->get_public_key()] = *priv; - } - } - - // active keys - if (options.count("witness-guard-active-key")) { - const auto& keys = - options["witness-guard-active-key"].as>(); - for (const auto& wif : keys) { - auto priv = graphene::utilities::wif_to_key(wif); - FC_ASSERT(priv.valid(), "witness-guard-active-key: invalid WIF key"); - pimpl->_active_keys[priv->get_public_key()] = *priv; - } - } - - // Validare minimă - if (!pimpl->_witnesses.empty() && - pimpl->_signing_keys.empty()) { - wlog("witness_guard: witness-guard-account set but no " - "witness-guard-signing-key configured — plugin will not restore keys"); - } - if (!pimpl->_witnesses.empty() && - pimpl->_active_keys.empty()) { - wlog("witness_guard: witness-guard-account set but no " - "witness-guard-active-key configured — plugin will not restore keys"); + if (pimpl->_witness_configs.empty()) { + wlog("witness_guard: no witnesses configured for monitoring"); } ilog("witness_guard: plugin_initialize() end — " "monitoring ${n} witness(es), interval=${i} blocks", - ("n", pimpl->_witnesses.size())("i", pimpl->_check_interval)); + ("n", pimpl->_witness_configs.size())("i", pimpl->_check_interval)); } FC_LOG_AND_RETHROW() } @@ -267,12 +237,12 @@ void witness_guard_plugin::plugin_initialize( void witness_guard_plugin::plugin_startup() { ilog("witness_guard: plugin_startup() begin"); - if (!pimpl->_enabled || pimpl->_witnesses.empty()) { + if (!pimpl->_enabled || pimpl->_witness_configs.empty()) { ilog("witness_guard: nothing to monitor, plugin inactive"); return; } - // Hook pe fiecare bloc aplicat + // Hook on every applied block pimpl->db().applied_block.connect( [this](const graphene::chain::signed_block& b) { if (!pimpl->_enabled) return; From 50f4f3a8b4ad202c953ef8286a2b4b3478ac6eb1 Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 04:31:43 +0300 Subject: [PATCH 4/9] feat(witness): add witness guard plugin specification with configuration and algorithms --- .qoder/docs/witness-guard-spec.json | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .qoder/docs/witness-guard-spec.json diff --git a/.qoder/docs/witness-guard-spec.json b/.qoder/docs/witness-guard-spec.json new file mode 100644 index 0000000000..34a9730237 --- /dev/null +++ b/.qoder/docs/witness-guard-spec.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "witness-guard-spec.json", + "title": "Witness Guard Plugin Specification", + "description": "Technical specification for the automated witness key restoration plugin", + "version": "1.0.0", + + "definitions": { + "witness_entry": { + "type": "array", + "description": "A JSON array representing a witness configuration triplet", + "items": [ + { "name": "account_name", "type": "string", "description": "The name of the witness account to monitor" }, + { "name": "signing_wif", "type": "string", "description": "The WIF private key to be restored as the signing key" }, + { "name": "active_wif", "type": "string", "description": "The WIF private key with active authority to sign the update transaction" } + ], + "example": "[\"mywitness\", \"5Ksigning...\", \"5Kactive...\"]" + } + }, + + "configuration": { + "options": [ + { + "name": "witness-guard-enabled", + "type": "boolean", + "default": true, + "description": "Global toggle for the plugin logic" + }, + { + "name": "witness-guard-witness", + "type": "vector", + "description": "List of witness triplets in JSON format. Can be specified multiple times.", + "ref": "#/definitions/witness_entry" + }, + { + "name": "witness-guard-interval", + "type": "uint32", + "default": 20, + "description": "Frequency of checks measured in blocks (20 blocks is approx. 60 seconds)" + } + ] + }, + + "algorithms": { + "check_and_restore": { + "description": "Iterative process to detect and fix null signing keys", + "steps": [ + { "step": 1, "action": "Verify node sync: head_block_time must be within 2 * CHAIN_BLOCK_INTERVAL" }, + { "step": 2, "action": "Fetch witness index from database" }, + { "step": 3, "action": "For each configured witness: find record by name" }, + { "step": 4, "condition": "signing_key is NOT null", "action": "Remove witness from 'restore_sent' cache to allow future monitoring" }, + { "step": 5, "condition": "signing_key IS null AND NOT in 'restore_sent'", "action": "Invoke send_witness_update" } + ] + }, + + "send_witness_update": { + "description": "Constructs and broadcasts a witness_update_operation", + "steps": [ + { "step": 1, "action": "Extract current URL from the chain object (preserves metadata)" }, + { "step": 2, "action": "Create witness_update_operation with target signing_pub_key" }, + { "step": 3, "action": "Wrap operation in a signed_transaction" }, + { "step": 4, "action": "Set expiration (30s) and reference block (head)" }, + { "step": 5, "action": "Sign with the configured active_private_key" }, + { "step": 6, "action": "Broadcast via P2P plugin" }, + { "step": 7, "action": "Add witness name to 'restore_sent' cache to prevent duplicate broadcasts" } + ] + } + }, + + "internal_state": { + "restore_sent": { + "type": "std::set", + "description": "In-memory cache of witnesses for which a restoration transaction has been broadcast in the current block cycle or since the last key reset" + } + } +} \ No newline at end of file From e3ff8206d43543b9b75f785fa9967d323fa1648a Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 04:38:00 +0300 Subject: [PATCH 5/9] docs(witness): add comprehensive documentation for witness guard plugin functionality and configuration --- .qoder/docs/witness-guard.md | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .qoder/docs/witness-guard.md diff --git a/.qoder/docs/witness-guard.md b/.qoder/docs/witness-guard.md new file mode 100644 index 0000000000..9e8e9543a5 --- /dev/null +++ b/.qoder/docs/witness-guard.md @@ -0,0 +1,87 @@ +# Witness Guard Plugin + +The `witness_guard` plugin is an automated maintenance tool designed for VIZ witness node operators. Its primary purpose is to monitor configured witness accounts and automatically restore their signing keys if they are reset to a null state (effectively disabling the witness). + +## Purpose + +In Graphene-based networks like VIZ, a witness might be disabled (signing key set to null) due to manual intervention, security protocols, or certain network conditions. If an operator wants to ensure their witness stays active without manual monitoring, this plugin automates the "restore" process. + +When the plugin detects a null signing key on-chain for a monitored account, it constructs, signs, and broadcasts a `witness_update_operation` to re-enable the witness using the provided private keys. + +## Configuration + +The plugin can be configured via the `config.ini` file or command-line arguments. + +### Options + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `witness-guard-enabled` | boolean | `true` | Enables or disables the plugin logic globally. | +| `witness-guard-interval` | uint32 | `20` | Frequency of checks measured in blocks (default 20 blocks $\approx$ 60 seconds). | +| `witness-guard-witness` | vector\ | N/A | A JSON triplet containing the witness name, the signing WIF, and the active WIF. | + +### Enabling the Plugin + +To use the plugin, add it to the list of active plugins in your `config.ini`: + +```ini +plugin = witness_guard +``` + +## Usage Example + +To monitor one or more witnesses, add the following lines to your `config.ini`. Note that the witness configuration must be a valid JSON array string. + +```ini +# Automatically restore winet1 +witness-guard-witness = ["winet1", "5K_SIGNING_PRIVATE_WIF", "5K_ACTIVE_PRIVATE_WIF"] + +# You can monitor multiple witnesses by repeating the option +witness-guard-witness = ["winet2", "5J_SIGNING_PRIVATE_WIF", "5J_ACTIVE_PRIVATE_WIF"] + +# Check every 10 blocks instead of 20 +witness-guard-interval = 10 +``` + +## Internal Logic (How it works) + +1. **Sync Check**: The plugin only executes if the node's head block time is within a reasonable range (2 * `CHAIN_BLOCK_INTERVAL`), ensuring it doesn't attempt restores while the node is still catching up to the network. +2. **On-Chain Verification**: Every `X` blocks (configured by interval), the plugin looks up the witness object for the configured account names. +3. **Null Key Detection**: If the `block_signing_key` found on the blockchain matches the `null_key` (all zeros): + * The plugin prepares a `witness_update_operation`. + * It preserves the existing `url` from the on-chain object. + * It sets the `block_signing_key` to the public key derived from the provided `signing_wif`. +4. **Signing and Broadcast**: The transaction is signed using the `active_wif`. This is necessary because updating a witness object requires active authority. +5. **Anti-Spam Protection**: Once a restore transaction is sent, the plugin will not attempt to send another one for that specific witness until the node is restarted or the key is successfully reset on-chain (preventing a loop of transactions if the first one is pending). + +## Security Considerations + +> [!CAUTION] +> **Private Key Exposure** +> This plugin requires the **Active Private Key** to be stored in plain text within your `config.ini`. Because the active key has significant control over your account (including the ability to transfer funds or change permissions), ensure that your `config.ini` file has strictly restricted file system permissions (e.g., `chmod 600 config.ini` on Linux). + +## Logs + +The plugin provides clear feedback in the node logs: + +* **Initialization**: + `witness_guard: monitoring witness 'winet1' (signing key: VIZ...)` +* **Restore Triggered**: + `witness_guard: 'winet1' has null signing key on-chain — initiating restore` +* **Success**: + `witness_guard: witness_update for 'winet1' sent successfully` +* **Failure**: + `witness_guard: witness_update FAILED for 'winet1': [error details]` + +## Troubleshooting + +**Erorr: witness-guard-witness must be triplets** +Ensure each entry is a valid JSON array with exactly 3 strings: `["name", "signing_wif", "active_wif"]`. Ensure you are using double quotes for strings inside the brackets. + +**Restore not triggering** +1. Check if `witness-guard-enabled` is set to `true`. +2. Ensure the node is fully synchronized with the network. +3. Verify that the account name provided is an actual registered witness on the network. + +**Transaction failed** +Check that the `active_wif` provided actually belongs to the witness account. If the account's active authority has been changed, the plugin will be unable to sign the update. \ No newline at end of file From 6c069c48785e8008346822da3bc6095463e504ce Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 11:44:30 +0300 Subject: [PATCH 6/9] Implement two-level fork collision resolution in witness plugin - Added `compare_fork_branches()` method to `database` for vote-weighted comparison of fork branches. - Refactored `_push_block()` to utilize `compare_fork_branches()` for determining fork weight during block production. - Introduced `remove_blocks_by_number()` in `fork_database` to prune stale competing blocks from dead forks. - Enhanced `maybe_produce_block()` in the witness plugin to implement a two-level fork collision resolution strategy: - Level 1: Vote-weighted comparison when both forks are present in the fork database. - Level 2: Timeout mechanism to produce on the current fork if the head is stuck after a defined number of deferrals. - Updated logging and reset mechanisms for fork collision defer count. - Ensured compatibility with pre-HF12 nodes by applying timeout logic universally. --- .../Fork_Collision_Resolution_Fix_24537a6e.md | 328 ++++++++++++++++++ libraries/chain/database.cpp | 91 +++-- libraries/chain/fork_database.cpp | 7 + .../chain/include/graphene/chain/database.hpp | 11 + .../include/graphene/chain/fork_database.hpp | 6 + .../graphene/network/peer_connection.hpp | 3 + libraries/network/node.cpp | 83 ++++- plugins/p2p/p2p_plugin.cpp | 32 ++ plugins/witness/witness.cpp | 93 ++++- 9 files changed, 610 insertions(+), 44 deletions(-) create mode 100644 .qoder/plans/Fork_Collision_Resolution_Fix_24537a6e.md diff --git a/.qoder/plans/Fork_Collision_Resolution_Fix_24537a6e.md b/.qoder/plans/Fork_Collision_Resolution_Fix_24537a6e.md new file mode 100644 index 0000000000..6252b053c7 --- /dev/null +++ b/.qoder/plans/Fork_Collision_Resolution_Fix_24537a6e.md @@ -0,0 +1,328 @@ + +# Fork Collision Resolution Fix + +## Problem +When a witness node detects a fork collision (competing block at same height in fork_db), it defers block production forever. The competing block from the dead fork is never removed from fork_db, so `maybe_produce_block()` keeps returning `fork_collision` every 250ms. Meanwhile, blocks from the longer/healthier chain can't be applied because the head is stuck, creating a growing gap that eventually makes fork switching impossible. + +## Root Cause Analysis + +### How vote-weighted fork comparison works (existing HF12 code) + +`compute_branch_weight()` in `database.cpp:_push_block()` (lines 1300-1314): +1. `fetch_branch_from(tip_a, tip_b)` walks both chains back to their **common ancestor** +2. Returns two vectors: `branches.first` = all blocks from fork A's tip to common ancestor, `branches.second` = same for fork B +3. For each branch, iterates through **ALL blocks** in that branch +4. For each block, gets the `witness` name (who produced that block) +5. Looks up `witness_object.votes` from current DB state — this is **on-chain stake vote count** (how many VIZ tokens voted for that witness), NOT the scheduled slot +6. Uses `flat_set` to count each witness **only once** (even if it produced multiple blocks) +7. Skips `CHAIN_EMERGENCY_WITNESS_ACCOUNT` +8. The branch with higher total vote weight wins; ties broken by longer chain +9. **NEW: Longer chain gets +10% bonus on its vote weight** — each block produced is a consensus "vote" by the producing witness. Witnesses on the longer chain didn't defer and kept producing by consensus rules, which is strong evidence of network support. + +**Answer: it sums `wit_obj.votes` (on-chain stake) for ALL unique witnesses that produced blocks in the divergent portion of each fork, not just the top block. The longer chain gets a +10% bonus on its total weight.** + +### Where the witness plugin goes wrong +In `witness.cpp:maybe_produce_block()` (lines 549-577), the fork collision check does NOT use vote-weight comparison: +- It only checks "does any competing block exist at `head_block_num+1` with a different parent?" +- If yes -> blindly defers, no evaluation of which fork is better +- It never asks: "which fork has more vote weight? Should I produce on my fork or switch?" +- This creates a deadlock: the witness waits for fork resolution, but fork resolution only happens via `_push_block()` which can't run because the head is stuck + +### The deadlock sequence +1. Head at 79402010, competing block at 79402011 from dead fork in fork_db +2. Witness scheduled at 79402011 -> sees competing block -> defers +3. P2P blocks 79402012+ arrive but can't be pushed (gap, parent unknown in fork_db) +4. `_push_block()` never gets a chance to do vote-weight comparison +5. Witness keeps deferring every 250ms -> permanently stuck +6. Gap grows until fork_db window (2400 blocks) is exceeded -> no recovery possible + +### Critical constraint: `compute_branch_weight` CANNOT solve the stuck scenario +In your scenario (head at 79402010, network at 79404861+), the longer chain's blocks are NOT in fork_db — they were rejected because the parent was unknown (gap too large). So `fetch_branch_from()` cannot compute the branch for the longer chain. The existing vote-weight comparison **requires both chain tips to exist in fork_db**. + +This means we need TWO levels of fix: +- **Level 1 (immediate)**: When fork_db has competing blocks at `head+1`, use vote-weight comparison to decide whether to produce or switch. This handles the case where BOTH forks are in fork_db. +- **Level 2 (stuck recovery)**: When the witness has been deferring and head is stuck (not advancing), bypass the collision check entirely — the competing block is from a dead fork that clearly lost (the network moved on without it). Produce on our fork. + +## Fix Strategy: Two-Level Fork Decision in Witness Plugin + +### Level 1: Vote-weighted comparison (when both forks are in fork_db) +When a competing block at `head+1` exists in fork_db: +1. **Evaluate fork weights** using `compute_branch_weight()` logic (same as `_push_block()`) +2. **If our fork has more vote weight** -> produce on our fork (the competing block is from a losing fork) +3. **If the competing fork has more vote weight** -> switch to it first, then produce on it +4. **If tied** -> defer briefly (1-2 slots) + +### Level 2: Stuck-head timeout (when fork_db comparison is impossible) +When the witness has been deferring for N consecutive slots and head hasn't advanced: +1. The competing block is clearly from a dead fork (network moved on) +2. Remove the competing block(s) from fork_db +3. Produce on our fork +4. This handles the case where the longer chain's blocks aren't in fork_db + +### Level 3: Prune dead-fork entries on block apply (defensive) +When `_push_block()` successfully applies a block, remove competing blocks at that height from fork_db that are from dead forks + +## Detailed Changes + +### 1. `libraries/chain/include/graphene/chain/database.hpp` — Expose fork comparison as public method + +```cpp +/// Compare two fork branches by vote weight (HF12 logic). +/// Sums wit_obj.votes (on-chain stake) for all unique witnesses in each branch, +/// from the tip back to the common ancestor. +/// The longer chain gets a +10% bonus on its total weight (reflects that more +/// witnesses kept producing on it by consensus rules without deferring). +/// Returns: >0 if branch_a is heavier, <0 if branch_b is heavier, 0 if tied +/// Returns 0 if either tip is not in fork_db (cannot compare) +int compare_fork_branches(const block_id_type& branch_a_tip, const block_id_type& branch_b_tip) const; +``` + +### 2. `libraries/chain/database.cpp` — Implement `compare_fork_branches()` + +Extract `compute_branch_weight` lambda from lines 1300-1314 into the new public method. +Add +10% bonus to the longer chain's weight. +Wrap in try/catch to return 0 when `fetch_branch_from()` fails (one tip not in fork_db). +Refactor `_push_block()` to call `compare_fork_branches()` instead of inline code. + +```cpp +int database::compare_fork_branches(const block_id_type& branch_a_tip, const block_id_type& branch_b_tip) const { + try { + if (!_fork_db.is_known_block(branch_a_tip) || !_fork_db.is_known_block(branch_b_tip)) + return 0; // Cannot compare — one or both tips not in fork_db + + auto branches = _fork_db.fetch_branch_from(branch_a_tip, branch_b_tip); + + auto compute_branch_weight = [&](const fork_database::branch_type& branch) -> share_type { + flat_set seen_witnesses; + share_type total_weight = 0; + for (const auto& item : branch) { + const auto& wit_name = item->data.witness; + if (wit_name == CHAIN_EMERGENCY_WITNESS_ACCOUNT) continue; + if (seen_witnesses.insert(wit_name).second) { + try { + const auto& wit_obj = get_witness(wit_name); + total_weight += wit_obj.votes; + } catch (...) {} + } + } + return total_weight; + }; + + share_type weight_a = compute_branch_weight(branches.first); + share_type weight_b = compute_branch_weight(branches.second); + + // Longer chain gets +10% bonus on its vote weight. + // Each block produced is a consensus "vote" — witnesses on the longer + // chain didn't defer and kept producing by consensus rules. + // This reflects the stronger network support signal. + auto a_num = block_header::num_from_id(branch_a_tip); + auto b_num = block_header::num_from_id(branch_b_tip); + if (a_num > b_num) { + weight_a = weight_a + weight_a / 10; // +10% + } else if (b_num > a_num) { + weight_b = weight_b + weight_b / 10; // +10% + } + + if (weight_a > weight_b) return 1; // branch_a is heavier + if (weight_b > weight_a) return -1; // branch_b is heavier + return 0; // tied + } catch (...) { + return 0; // Cannot compare + } +} +``` + +### 3. `libraries/chain/include/graphene/chain/fork_database.hpp` — Add `remove_blocks_by_number()` + +```cpp +void remove_blocks_by_number(uint32_t num); +``` + +### 4. `libraries/chain/fork_database.cpp` — Implement `remove_blocks_by_number()` + +```cpp +void fork_database::remove_blocks_by_number(uint32_t num) { + auto blocks = fetch_block_by_number(num); + for (const auto& b : blocks) { + _index.get().erase(b->id); + } +} +``` + +### 5. `libraries/chain/database.cpp` — Prune dead-fork blocks after apply + +In `_push_block()`, after successfully applying a block that extends the current chain (no fork switch, around line 1417), add: + +```cpp +// Prune stale competing blocks from dead forks at this height +auto competing = _fork_db.fetch_block_by_number(new_block.block_num()); +for (const auto& cb : competing) { + if (cb->id != new_head->id && cb->data.previous != head_block_id()) { + wlog("Pruning stale competing block ${id} at height ${n} from fork_db (dead fork)", + ("id", cb->id)("n", new_block.block_num())); + _fork_db.remove(cb->id); + } +} +``` + +### 6. `plugins/witness/include/graphene/plugins/witness/witness.hpp` — Add config + +No header changes needed; the timeout is internal to `impl`. + +### 7. `plugins/witness/witness.cpp` — Two-level fork decision + +**Add to impl struct:** +```cpp +std::atomic fork_collision_defer_count_{0}; +uint32_t _fork_collision_timeout_blocks = 21; // safety timeout: one full witness round (21 blocks = 63s) +fc::time_point _fork_collision_start_time; // when we first started deferring +uint32_t _fork_collision_head_num = 0; // head_block_num when collision started +``` + +**Add CLI option:** `--fork-collision-timeout-blocks` (default: 21, i.e. one full witness schedule round = 63 seconds. After a full round, all scheduled witnesses have produced on the longer chain, confirming it's canonical.) + +**Replace the fork collision block (lines 545-578) with two-level logic:** + +```cpp +// Check if a competing block already exists in the fork database for this block height. +{ + auto existing_blocks = db.get_fork_db().fetch_block_by_number(db.head_block_num() + 1); + if (existing_blocks.size() > 0) { + bool has_competing_block = false; + item_ptr competing_block; + + if (dgp.emergency_consensus_active) { + has_competing_block = true; + competing_block = existing_blocks[0]; + } else { + for (const auto &eb : existing_blocks) { + if (eb->data.witness != scheduled_witness && + eb->data.previous != db.head_block_id()) { + has_competing_block = true; + competing_block = eb; + break; + } + } + } + + if (has_competing_block && competing_block) { + fork_collision_defer_count_++; + + // LEVEL 2: Stuck-head timeout + // If we've been deferring and the head hasn't advanced, the competing + // block is from a dead fork. The network has moved on without it. + // After 21 consecutive deferrals (one full witness round = 63s), + // we can be sure the longer chain had all scheduled witnesses + // produce on it — confirming it's the canonical chain. + if (fork_collision_defer_count_ > _fork_collision_timeout_blocks) { + wlog("Fork collision timeout exceeded (${n} deferrals, head stuck at ${h}). " + "Removing dead-fork competing block and producing on our chain.", + ("n", fork_collision_defer_count_.load())("h", db.head_block_num())); + db.get_fork_db().remove_blocks_by_number(db.head_block_num() + 1); + // Fall through to produce block + } + // LEVEL 1: Vote-weighted comparison (when both forks are in fork_db) + else if (db.has_hardfork(CHAIN_HARDFORK_12)) { + int weight_cmp = db.compare_fork_branches( + competing_block->id, db.head_block_id()); + + if (weight_cmp < 0) { + // Our fork has MORE vote weight -> produce on our fork + wlog("Our fork has more vote weight at height ${h}. " + "Producing despite competing block from weaker fork.", + ("h", db.head_block_num() + 1)); + // Remove the losing competing block + db.get_fork_db().remove(competing_block->id); + // Fall through to produce block + } else if (weight_cmp > 0) { + // Competing fork has MORE vote weight + // The competing branch is in fork_db and has more support. + // We should switch to it. The normal _push_block path will + // handle the switch when the competing block's children arrive. + // For now, defer to let the fork switch happen naturally. + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + wlog("Competing fork at height ${h} has more vote weight. " + "Deferring to allow fork switch to stronger chain.", + ("h", db.head_block_num() + 1)); + return block_production_condition::fork_collision; + } else { + // Tied (or comparison impossible — one tip not in fork_db) + // Defer briefly, timeout will kick in + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + wlog("Fork collision at height ${h} with tied/unknown vote weight. " + "Deferring (attempt ${n}/${max}).", + ("h", db.head_block_num() + 1) + ("n", fork_collision_defer_count_.load()) + ("max", _fork_collision_timeout_blocks)); + return block_production_condition::fork_collision; + } + } + // Pre-HF12: original defer behavior with timeout + else { + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + return block_production_condition::fork_collision; + } + } + } +} +``` + +**Reset `fork_collision_defer_count_`** to 0 in `block_production_loop()` when result is: +- `produced` — block was made, no collision +- `not_my_turn` / `not_time_yet` — normal skips, collision resolved +- Any result where `db.head_block_num()` has changed since last check + +## File Change Summary + +| File | Change | +|------|--------| +| `libraries/chain/include/graphene/chain/database.hpp` | Add `compare_fork_branches()` declaration | +| `libraries/chain/database.cpp` | Extract `compute_branch_weight` into `compare_fork_branches()`, prune dead fork blocks after apply | +| `libraries/chain/include/graphene/chain/fork_database.hpp` | Add `remove_blocks_by_number()` declaration | +| `libraries/chain/fork_database.cpp` | Implement `remove_blocks_by_number()` | +| `plugins/witness/witness.cpp` | Two-level fork decision: vote-weighted comparison + stuck-head timeout | + +## Key Design Decisions + +1. **`wit_obj.votes` = on-chain stake** — the vote-weight comparison sums the stake (VIZ tokens) that voted for each unique witness that produced blocks in the divergent portion of each fork. It is NOT the scheduled slot count. +2. **All blocks in the divergent branch count** — not just the top block. `fetch_branch_from()` walks from each tip back to the common ancestor, and all unique witnesses on each branch contribute their stake weight. +3. **Longer chain gets +10% bonus** — each block is a consensus "vote" by the producing witness. Witnesses on the longer chain didn't defer and kept producing by consensus rules. The +10% bonus ensures that a slightly shorter chain with slightly more stake cannot override a clearly longer chain that more witnesses kept building on. +4. **Our fork wins ties** — when vote weights (including bonus) are equal, we produce on our own chain (less disruptive) +5. **Competing fork with more weight -> defer for switch** — if the other chain has more support, we defer to allow `_push_block()` to naturally switch us when more blocks arrive +6. **Stuck-head timeout = 21 blocks (one full witness round)** — after 21 consecutive deferrals (63 seconds), all scheduled witnesses have had a chance to produce on the longer chain. We can be confident the longer chain is canonical. The competing block from the dead fork is removed and production resumes. +7. **Pruning on block apply** — when a block is applied, competing blocks from dead forks at that height are removed, preventing them from causing false collision detection in the future + +## Verification +- Short micro-forks (1-2 blocks): vote-weight comparison resolves quickly, no stuck +- Our fork has more votes: witness produces immediately +- Competing fork has more votes: witness defers for natural switch via `_push_block()` +- Stuck head (your scenario): timeout after 21 slots (one full witness round) removes dead-fork block, witness produces +- Stale fork_db entries pruned on each block apply +- Emergency mode: existing behavior preserved (any competing block = defer, timeout applies) + +--- + +## Implementation Status + +All planned changes have been implemented. Deviations from the original plan are noted below. + +| # | Planned Change | File | Status | Notes | +|---|---------------|------|--------|-------| +| 1 | Add `compare_fork_branches()` declaration | `database.hpp` | Done | Matches plan exactly | +| 2 | Implement `compare_fork_branches()` with +10% longer-chain bonus | `database.cpp` | Done | Matches plan; refactored `_push_block()` to use it instead of inline lambda | +| 3 | Refactor `_push_block()` HF12 fork-switch to use `compare_fork_branches()` | `database.cpp` | Done | Replaced 26-line inline lambda with 4-line call to `compare_fork_branches()` | +| 4 | Add `remove_blocks_by_number()` declaration | `fork_database.hpp` | Done | Matches plan exactly | +| 5 | Implement `remove_blocks_by_number()` | `fork_database.cpp` | Done | Matches plan exactly | +| 6 | Prune dead-fork blocks after block apply | `database.cpp` | Done | Slight deviation: uses `new_block.id()` instead of `new_head->id` (code is in the non-fork-switch path where `new_head` is out of scope) | +| 7 | Add fork collision state fields to `impl` struct | `witness.cpp` | Done | **Deviation**: removed `_fork_collision_head_num` (dead code) and `_fork_collision_start_time` (unused). Only `fork_collision_defer_count_` and `_fork_collision_timeout_blocks` remain | +| 8 | Add `--fork-collision-timeout-blocks` CLI option | `witness.cpp` | Done | Default 21, matches plan | +| 9 | Two-level fork decision in `maybe_produce_block()` | `witness.cpp` | Done | **Deviation**: Level 2 timeout runs BEFORE the HF12 check, so pre-HF12 nodes also benefit from the timeout. The plan had Level 2 inside the HF12 branch | +| 10 | Reset `fork_collision_defer_count_` in `block_production_loop()` | `witness.cpp` | Done | Reset on `produced`, `not_synced`, `not_my_turn`. **Not reset** on `not_time_yet` (timer hasn't fired yet, count should persist) | + +### Bugs Found and Fixed During Review + +| # | Severity | Bug | Fix | +|---|----------|-----|-----| +| 1 | Critical | Pre-HF12 path deferred forever (same as original bug) — Level 2 timeout was inside the `else if (has_hardfork(HF12))` branch | Moved Level 2 timeout check before the HF12 branch so all nodes benefit | +| 2 | Low | `_fork_collision_head_num` declared but never read or written | Removed the field | +| 3 | Info | `fork_collision_defer_count_` was planned as `std::atomic` but implemented as `uint32_t` | Kept as `uint32_t` — all access is single-threaded (block production loop) | diff --git a/libraries/chain/database.cpp b/libraries/chain/database.cpp index 83f825c6be..7e4a82c6e9 100644 --- a/libraries/chain/database.cpp +++ b/libraries/chain/database.cpp @@ -1213,6 +1213,52 @@ namespace graphene { namespace chain { return; } + int database::compare_fork_branches(const block_id_type& branch_a_tip, const block_id_type& branch_b_tip) const { + try { + if (!_fork_db.is_known_block(branch_a_tip) || !_fork_db.is_known_block(branch_b_tip)) + return 0; // Cannot compare — one or both tips not in fork_db + + auto branches = _fork_db.fetch_branch_from(branch_a_tip, branch_b_tip); + + auto compute_branch_weight = [&](const fork_database::branch_type& branch) -> share_type { + flat_set seen_witnesses; + share_type total_weight = 0; + for (const auto& item : branch) { + const auto& wit_name = item->data.witness; + if (wit_name == CHAIN_EMERGENCY_WITNESS_ACCOUNT) continue; + if (seen_witnesses.insert(wit_name).second) { + try { + const auto& wit_obj = get_witness(wit_name); + total_weight += wit_obj.votes; + } catch (...) {} + } + } + return total_weight; + }; + + share_type weight_a = compute_branch_weight(branches.first); + share_type weight_b = compute_branch_weight(branches.second); + + // Longer chain gets +10% bonus on its vote weight. + // Each block produced is a consensus "vote" — witnesses on the longer + // chain didn't defer and kept producing by consensus rules. + // This reflects the stronger network support signal. + auto a_num = block_header::num_from_id(branch_a_tip); + auto b_num = block_header::num_from_id(branch_b_tip); + if (a_num > b_num) { + weight_a = weight_a + weight_a / 10; // +10% + } else if (b_num > a_num) { + weight_b = weight_b + weight_b / 10; // +10% + } + + if (weight_a > weight_b) return 1; // branch_a is heavier + if (weight_b > weight_a) return -1; // branch_b is heavier + return 0; // tied + } catch (...) { + return 0; // Cannot compare + } + } + bool database::_push_block(const signed_block &new_block, uint32_t skip) { try { // Early rejection: if the block is at or before our head block number @@ -1290,35 +1336,13 @@ namespace graphene { namespace chain { //Only switch forks if new_head is actually higher than head bool should_switch = false; if (has_hardfork(CHAIN_HARDFORK_12)) { - // HF12: Vote-weighted chain comparison - // Primary criterion: sum of raw votes of unique non-committee witnesses per branch - // Secondary criterion (tie): longer chain wins + // HF12: Vote-weighted chain comparison with +10% longer-chain bonus if (new_head->data.block_num() >= head_block_num() && _fork_db.is_known_block(head_block_id())) { - auto branches = _fork_db.fetch_branch_from(new_head->data.id(), head_block_id()); - - auto compute_branch_weight = [&](const fork_database::branch_type& branch) -> share_type { - flat_set seen_witnesses; - share_type total_weight = 0; - for (const auto& item : branch) { - const auto& wit_name = item->data.witness; - if (wit_name == CHAIN_EMERGENCY_WITNESS_ACCOUNT) continue; - if (seen_witnesses.insert(wit_name).second) { - try { - const auto& wit_obj = get_witness(wit_name); - total_weight += wit_obj.votes; - } catch (...) {} - } - } - return total_weight; - }; - - share_type new_weight = compute_branch_weight(branches.first); - share_type old_weight = compute_branch_weight(branches.second); - - if (new_weight > old_weight) { + int cmp = compare_fork_branches(new_head->data.id(), head_block_id()); + if (cmp > 0) { should_switch = true; - } else if (new_weight == old_weight) { + } else if (cmp == 0) { // Tie: longer chain wins should_switch = (new_head->data.block_num() > head_block_num()); } @@ -1423,6 +1447,21 @@ namespace graphene { namespace chain { throw; } + // Prune stale competing blocks from dead forks at this height. + // After successfully applying a block, any other block at the same + // height with a different parent is from a dead fork and should be + // removed to prevent false fork-collision detection in the witness plugin. + { + auto competing = _fork_db.fetch_block_by_number(new_block.block_num()); + for (const auto& cb : competing) { + if (cb->id != new_block.id() && cb->data.previous != head_block_id()) { + wlog("Pruning stale competing block ${id} at height ${n} from fork_db (dead fork)", + ("id", cb->id)("n", new_block.block_num())); + _fork_db.remove(cb->id); + } + } + } + return false; } FC_CAPTURE_AND_RETHROW() } diff --git a/libraries/chain/fork_database.cpp b/libraries/chain/fork_database.cpp index 291d486713..296fbdd191 100644 --- a/libraries/chain/fork_database.cpp +++ b/libraries/chain/fork_database.cpp @@ -266,5 +266,12 @@ namespace graphene { _index.get().erase(id); } + void fork_database::remove_blocks_by_number(uint32_t num) { + auto blocks = fetch_block_by_number(num); + for (const auto& b : blocks) { + _index.get().erase(b->id); + } + } + } } // graphene::chain diff --git a/libraries/chain/include/graphene/chain/database.hpp b/libraries/chain/include/graphene/chain/database.hpp index ce4e53a8e3..d065786e7d 100644 --- a/libraries/chain/include/graphene/chain/database.hpp +++ b/libraries/chain/include/graphene/chain/database.hpp @@ -532,6 +532,17 @@ namespace graphene { namespace chain { void check_block_post_validation_chain(); + /** + * Compare two fork branches by vote weight (HF12 logic). + * Sums wit_obj.votes (on-chain stake) for all unique witnesses in each branch, + * from the tip back to the common ancestor. + * The longer chain gets a +10% bonus on its total weight (reflects that more + * witnesses kept producing on it by consensus rules without deferring). + * @return >0 if branch_a is heavier, <0 if branch_b is heavier, 0 if tied + * @return 0 if either tip is not in fork_db (cannot compare) + */ + int compare_fork_branches(const block_id_type& branch_a_tip, const block_id_type& branch_b_tip) const; + protected: //Mark pop_undo() as protected -- we do not want outside calling pop_undo(); it should call pop_block() instead //void pop_undo() { object_database::pop_undo(); } diff --git a/libraries/chain/include/graphene/chain/fork_database.hpp b/libraries/chain/include/graphene/chain/fork_database.hpp index 1cb7444e30..883ef65fe8 100644 --- a/libraries/chain/include/graphene/chain/fork_database.hpp +++ b/libraries/chain/include/graphene/chain/fork_database.hpp @@ -64,6 +64,12 @@ namespace graphene { void remove(block_id_type b); + /** + * Remove all blocks at the given height from the fork database. + * Used to clear stale competing blocks from dead forks. + */ + void remove_blocks_by_number(uint32_t num); + void set_head(shared_ptr h); bool is_known_block(const block_id_type &id) const; diff --git a/libraries/network/include/graphene/network/peer_connection.hpp b/libraries/network/include/graphene/network/peer_connection.hpp index aeef542b88..a1472fb526 100644 --- a/libraries/network/include/graphene/network/peer_connection.hpp +++ b/libraries/network/include/graphene/network/peer_connection.hpp @@ -276,6 +276,9 @@ namespace graphene { // HF12: soft-ban peers on losing forks during emergency consensus fc::time_point fork_rejected_until; + // Reason for disconnect (set before move_peer_to_closing_list) + std::string closing_reason; + fc::future accept_or_connect_task_done; firewall_check_state_data *firewall_check_state; diff --git a/libraries/network/node.cpp b/libraries/network/node.cpp index bd66b31558..31283c9047 100644 --- a/libraries/network/node.cpp +++ b/libraries/network/node.cpp @@ -597,8 +597,13 @@ namespace graphene { std::set _trusted_peer_ips; // Soft-ban durations - static constexpr uint32_t SOFT_BAN_DURATION_SEC = 3600; // 1 hour (default) + static constexpr uint32_t SOFT_BAN_DURATION_SEC = 900; // 15 minutes (default) static constexpr uint32_t TRUSTED_SOFT_BAN_DURATION_SEC = 300; // 5 minutes (trusted peers) + static constexpr uint32_t DISCONNECT_RECONNECT_COOLDOWN_SEC = 30; // cooldown before reconnecting to a recently disconnected peer + + // Per-IP cooldown after disconnect to prevent rapid reconnect loops. + // Key: 32-bit IP address, Value: time point when cooldown expires. + std::map _disconnect_cooldown; bool _node_is_shutting_down; // set to true when we begin our destructor, used to prevent us from starting new tasks while we're shutting down @@ -998,6 +1003,15 @@ namespace graphene { bool initiated_connection_this_pass = false; _potential_peer_database_updated = false; + // Clean up expired disconnect cooldowns + { + auto now = fc::time_point::now(); + for (auto it = _disconnect_cooldown.begin(); it != _disconnect_cooldown.end(); ) { + if (it->second <= now) it = _disconnect_cooldown.erase(it); + else ++it; + } + } + for (peer_database::iterator iter = _potential_peer_db.begin(); iter != _potential_peer_db.end() && is_wanting_new_connections(); @@ -1006,6 +1020,16 @@ namespace graphene { (iter->number_of_failed_connection_attempts + 1) * _peer_connection_retry_timeout); + // Skip peers in disconnect cooldown to prevent rapid reconnect loops + uint32_t peer_ip = uint32_t(iter->endpoint.get_address()); + auto cooldown_it = _disconnect_cooldown.find(peer_ip); + if (cooldown_it != _disconnect_cooldown.end() && cooldown_it->second > fc::time_point::now()) { + dlog("Skipping peer ${ep}: disconnect cooldown (${sec}s remaining)", + ("ep", iter->endpoint) + ("sec", (cooldown_it->second - fc::time_point::now()).count() / 1000000)); + continue; + } + if (!is_connection_to_endpoint_in_progress(iter->endpoint) && ((iter->last_connection_disposition != last_connection_failed && @@ -3027,6 +3051,16 @@ namespace graphene { VERIFY_CORRECT_THREAD(); originating_peer->they_have_requested_close = true; + // Record per-IP reconnect cooldown (receiver side) + auto remote_ep = originating_peer->get_remote_endpoint(); + if (remote_ep.valid()) { + uint32_t ip = uint32_t(remote_ep->get_address()); + _disconnect_cooldown[ip] = fc::time_point::now() + fc::seconds(DISCONNECT_RECONNECT_COOLDOWN_SEC); + } + + // Store reason on peer so move_peer_to_closing_list can display it + originating_peer->closing_reason = "remote: " + closing_connection_message_received.reason_for_closing; + if (closing_connection_message_received.closing_due_to_error) { elog("Peer ${peer} is disconnecting us because of an error: ${msg}, exception: ${error}", ("peer", originating_peer->get_remote_endpoint()) @@ -3145,7 +3179,7 @@ namespace graphene { try { std::vector contained_transaction_message_ids; - fc_ilog(fc::logger::get("sync"), + fc_dlog(fc::logger::get("sync"), "p2p pushing sync block #${block_num} ${block_hash}", ("block_num", block_message_to_send.block.block_num()) ("block_hash", block_message_to_send.block_id)); @@ -3566,7 +3600,7 @@ namespace graphene { _most_recent_blocks_accepted.end()) { std::vector contained_transaction_message_ids; _message_ids_currently_being_processed.insert(message_hash); - fc_ilog(fc::logger::get("sync"), + fc_dlog(fc::logger::get("sync"), "\033[90mp2p pushing block #${block_num} ${block_hash} from ${peer} (message_id was ${id})\033[0m", ("block_num", block_message_to_process.block.block_num()) ("block_hash", block_message_to_process.block_id) @@ -4434,6 +4468,21 @@ namespace graphene { try { _tcp_server.accept(new_peer->get_socket()); + + // Check disconnect cooldown for inbound connections + { + fc::ip::endpoint remote_ep = new_peer->get_socket().remote_endpoint(); + uint32_t remote_ip = uint32_t(remote_ep.get_address()); + auto cooldown_it = _disconnect_cooldown.find(remote_ip); + if (cooldown_it != _disconnect_cooldown.end() && cooldown_it->second > fc::time_point::now()) { + auto remaining_sec = (cooldown_it->second - fc::time_point::now()).count() / 1000000; + ilog("Rejecting inbound connection from ${ep}: disconnect cooldown (${sec}s remaining)", + ("ep", remote_ep)("sec", remaining_sec)); + new_peer->get_socket().close(); + continue; + } + } + ilog("accepted inbound connection from ${remote_endpoint}", ("remote_endpoint", new_peer->get_socket().remote_endpoint())); if (_node_is_shutting_down) { return; @@ -4893,9 +4942,16 @@ namespace graphene { _handshaking_connections.erase(peer); _closing_connections.insert(peer); _terminating_connections.erase(peer); - fc_ilog(fc::logger::get("sync"), CLOG_ORANGE "Peer connection closing (${peer}), now ${count} active peers" CLOG_RESET, - ("peer", peer->get_remote_endpoint()) - ("count", _active_connections.size())); + if (peer->closing_reason.empty()) { + fc_ilog(fc::logger::get("sync"), CLOG_ORANGE "Peer connection closing (${peer}), now ${count} active peers" CLOG_RESET, + ("peer", peer->get_remote_endpoint()) + ("count", _active_connections.size())); + } else { + fc_ilog(fc::logger::get("sync"), CLOG_ORANGE "Peer connection closing (${peer}): ${reason}, now ${count} active peers" CLOG_RESET, + ("peer", peer->get_remote_endpoint()) + ("reason", peer->closing_reason) + ("count", _active_connections.size())); + } } void node_impl::move_peer_to_terminating_list(const peer_connection_ptr &peer) { @@ -4953,6 +5009,17 @@ namespace graphene { bool caused_by_error /* = false */, const fc::oexception &error /* = fc::oexception() */ ) { VERIFY_CORRECT_THREAD(); + + // Store reason on peer so move_peer_to_closing_list can display it + peer_to_disconnect->closing_reason = reason_for_disconnect; + + // Record per-IP reconnect cooldown to prevent rapid reconnect loops + auto remote_ep = peer_to_disconnect->get_remote_endpoint(); + if (remote_ep.valid()) { + uint32_t ip = uint32_t(remote_ep->get_address()); + _disconnect_cooldown[ip] = fc::time_point::now() + fc::seconds(DISCONNECT_RECONNECT_COOLDOWN_SEC); + } + move_peer_to_closing_list(peer_to_disconnect->shared_from_this()); if (peer_to_disconnect->they_have_requested_close) { @@ -4990,9 +5057,7 @@ namespace graphene { << " for reason: " << reason_for_disconnect; _delegate->error_encountered(error_message.str(), fc::oexception()); - dlog(error_message.str()); - } else - dlog("Disconnecting from ${peer} for ${reason}", ("peer", peer_to_disconnect->get_remote_endpoint())("reason", reason_for_disconnect)); + } // peer_to_disconnect->close_connection(); } diff --git a/plugins/p2p/p2p_plugin.cpp b/plugins/p2p/p2p_plugin.cpp index b3bfb6245c..65f4e6cc26 100644 --- a/plugins/p2p/p2p_plugin.cpp +++ b/plugins/p2p/p2p_plugin.cpp @@ -567,6 +567,38 @@ namespace graphene { } } _stats_bytes_received_last = std::move(new_bytes_map); + + // Dump potential peer database: shows all known peers including + // failed, rejected, and banned ones that are no longer connected. + // This is critical for debugging post-snapshot sync failures. + auto potential_peers = node->get_potential_peers(); + uint32_t failed_count = 0; + for (const auto &pp : potential_peers) { + if (pp.last_connection_disposition != graphene::network::last_connection_succeeded && + pp.last_connection_disposition != graphene::network::never_attempted_to_connect) { + ++failed_count; + std::string disposition; + switch (pp.last_connection_disposition) { + case graphene::network::last_connection_failed: disposition = "failed"; break; + case graphene::network::last_connection_rejected: disposition = "rejected"; break; + case graphene::network::last_connection_handshaking_failed: disposition = "handshake_failed"; break; + default: disposition = "unknown"; break; + } + std::string error_str; + if (pp.last_error) { + error_str = pp.last_error->to_string(); + } + dlog(CLOG_CYAN "P2P peer_db | ${ep} | status: ${disp} | last_attempt: ${time} | fails: ${f} | error: ${err}" CLOG_RESET, + ("ep", pp.endpoint)("disp", disposition) + ("time", pp.last_connection_attempt_time.to_iso_string()) + ("f", pp.number_of_failed_connection_attempts) + ("err", error_str)); + } + } + if (failed_count > 0) { + dlog(CLOG_CYAN "P2P peer_db: ${n} peers with failed/rejected status (of ${total} total)" CLOG_RESET, + ("n", failed_count)("total", potential_peers.size())); + } } catch (const fc::exception &e) { wlog("Exception in P2P stats task: ${e}", ("e", e.to_detail_string())); } catch (...) { diff --git a/plugins/witness/witness.cpp b/plugins/witness/witness.cpp index e2cdbdd4a7..2009224d3b 100644 --- a/plugins/witness/witness.cpp +++ b/plugins/witness/witness.cpp @@ -116,6 +116,10 @@ namespace graphene { std::set _witnesses; fc::time_point last_block_post_validation_time; + + // Fork collision resolution state + uint32_t fork_collision_defer_count_ = 0; + uint32_t _fork_collision_timeout_blocks = 21; // one full witness round (21 blocks = 63s) }; void witness_plugin::set_program_options( @@ -149,6 +153,9 @@ namespace graphene { "Rejection threshold as a percentage of the absolute moving average; deltas deviating more are rejected (default: 50).") ("ntp-rejection-min-threshold", bpo::value()->default_value(5), "Minimum rejection threshold in milliseconds, applied regardless of the percentage rule (default: 5).") + ("fork-collision-timeout-blocks", bpo::value()->default_value(21), + "Number of consecutive fork-collision deferrals (block slots) before forcing production. " + "One full witness schedule round is 21 blocks (63 seconds). Default: 21.") ; config_file_options.add(command_line_options); @@ -212,6 +219,10 @@ namespace graphene { graphene::time::configure_ntp(ntp_cfg); } + if (options.count("fork-collision-timeout-blocks")) { + pimpl->_fork_collision_timeout_blocks = options["fork-collision-timeout-blocks"].as(); + } + ilog("witness plugin: plugin_initialize() end"); } FC_LOG_AND_RETHROW() } @@ -334,14 +345,17 @@ namespace graphene { switch (result) { case block_production_condition::produced: ilog("\033[92mGenerated block #${n} with timestamp ${t} at time ${c} by ${w}\033[0m", (capture)); + fork_collision_defer_count_ = 0; break; case block_production_condition::not_synced: // This log-record is commented, because it outputs very often // ilog("Not producing block because production is disabled until we receive a recent block (see: --enable-stale-production)"); + fork_collision_defer_count_ = 0; break; case block_production_condition::not_my_turn: // This log-record is commented, because it outputs very often // ilog("Not producing block because it isn't my turn"); + fork_collision_defer_count_ = 0; break; case block_production_condition::not_time_yet: // This log-record is commented, because it outputs very often @@ -543,36 +557,97 @@ namespace graphene { } // Check if a competing block already exists in the fork database for this block height. - // If another block at the same height already exists, it means we are on a fork - // and producing would create a collision. Skip production to let fork resolution proceed. + // Two-level fork collision resolution: + // Level 1: Vote-weighted comparison when both forks are in fork_db + // Level 2: Stuck-head timeout after one full witness round (21 blocks = 63s) { auto existing_blocks = db.get_fork_db().fetch_block_by_number(db.head_block_num() + 1); if (existing_blocks.size() > 0) { bool has_competing_block = false; + graphene::chain::item_ptr competing_block; if (dgp.emergency_consensus_active) { // During emergency mode: ANY block at this height is competing. // Multiple nodes with the emergency key may have produced. // Defer to the deterministic hash-based resolution in fork_db. has_competing_block = true; + competing_block = existing_blocks[0]; } else { // Normal mode: only count blocks from different witnesses - // on a different parent as competing (existing logic) + // on a different parent as competing for (const auto &eb : existing_blocks) { if (eb->data.witness != scheduled_witness && eb->data.previous != db.head_block_id()) { has_competing_block = true; + competing_block = eb; break; } } } - if (has_competing_block) { - capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); - wlog("Skipping block production at height ${h} due to existing competing block " - "in fork database (witness ${w} deferring to allow fork resolution)", - ("h", db.head_block_num() + 1)("w", scheduled_witness)); - return block_production_condition::fork_collision; + if (has_competing_block && competing_block) { + fork_collision_defer_count_++; + + // LEVEL 2: Stuck-head timeout + // If we've been deferring and the head hasn't advanced, the competing + // block is from a dead fork. The network has moved on without it. + // After 21 consecutive deferrals (one full witness round = 63s), + // we can be sure the longer chain had all scheduled witnesses + // produce on it — confirming it's the canonical chain. + // This applies regardless of hardfork version — even pre-HF12 + // nodes must not defer forever. + if (fork_collision_defer_count_ > _fork_collision_timeout_blocks) { + wlog("Fork collision timeout exceeded (${n} deferrals, head stuck at ${h}). " + "Removing dead-fork competing block and producing on our chain.", + ("n", fork_collision_defer_count_)("h", db.head_block_num())); + db.get_fork_db().remove_blocks_by_number(db.head_block_num() + 1); + fork_collision_defer_count_ = 0; + // Fall through to produce block + } + // LEVEL 1: Vote-weighted comparison (when both forks are in fork_db) + else if (db.has_hardfork(CHAIN_HARDFORK_12)) { + int weight_cmp = db.compare_fork_branches( + competing_block->id, db.head_block_id()); + + if (weight_cmp < 0) { + // Our fork has MORE vote weight -> produce on our fork + wlog("Our fork has more vote weight at height ${h}. " + "Producing despite competing block from weaker fork.", + ("h", db.head_block_num() + 1)); + // Remove the losing competing block + db.get_fork_db().remove(competing_block->id); + fork_collision_defer_count_ = 0; + // Fall through to produce block + } else if (weight_cmp > 0) { + // Competing fork has MORE vote weight + // Defer to let the fork switch happen naturally via _push_block. + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + wlog("Competing fork at height ${h} has more vote weight. " + "Deferring to allow fork switch to stronger chain.", + ("h", db.head_block_num() + 1)); + return block_production_condition::fork_collision; + } else { + // Tied or comparison impossible (one tip not in fork_db) + // Defer briefly, timeout will kick in + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + wlog("Fork collision at height ${h} with tied/unknown vote weight. " + "Deferring (attempt ${n}/${max}).", + ("h", db.head_block_num() + 1) + ("n", fork_collision_defer_count_) + ("max", _fork_collision_timeout_blocks)); + return block_production_condition::fork_collision; + } + } + // Pre-HF12: defer, but timeout still applies on next iteration + else { + capture("height", db.head_block_num() + 1)("scheduled_witness", scheduled_witness); + wlog("Fork collision at height ${h} (pre-HF12). " + "Deferring (attempt ${n}/${max}).", + ("h", db.head_block_num() + 1) + ("n", fork_collision_defer_count_) + ("max", _fork_collision_timeout_blocks)); + return block_production_condition::fork_collision; + } } } } From 7d8316e07ce2becab7080c2d26e459089a50b096 Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sat, 25 Apr 2026 20:23:12 +0300 Subject: [PATCH 7/9] feat(witness_guard): enhance synchronization checks and transaction confirmation handling Co-authored-by: Copilot --- plugins/witness_guard/witness_guard.cpp | 162 +++++++++++++++++++----- share/vizd/docker/Dockerfile-production | 3 +- thirdparty/chainbase | 2 +- 3 files changed, 136 insertions(+), 31 deletions(-) diff --git a/plugins/witness_guard/witness_guard.cpp b/plugins/witness_guard/witness_guard.cpp index 99ffd0d63d..6f4e9b390b 100644 --- a/plugins/witness_guard/witness_guard.cpp +++ b/plugins/witness_guard/witness_guard.cpp @@ -29,11 +29,15 @@ struct witness_guard_plugin::impl { {} graphene::chain::database& db() { return chain_.db(); } + graphene::chain::database& db() const { return chain_.db(); } // ── config ──────────────────────────────────────────────────────────────── - bool _enabled = true; - uint32_t _check_interval = 20; // blocks between checks + bool _enabled = true; + uint32_t _check_interval = 20; // blocks between checks + bool _initial_check_done = false; // Whether we've detected that the node is synchronized at startup + fc::connection _applied_block_connection; // Connection for applied_block signal + // --- witness_info struct --- struct witness_info { fc::ecc::private_key signing_key; fc::ecc::private_key active_key; @@ -42,11 +46,15 @@ struct witness_guard_plugin::impl { // Mapping witness_name -> config (keys) std::map _witness_configs; - // anti-spam guard: don't send a second time until the node restarts - std::set _restore_sent; + // Tracking pending restores: witness_name -> expiration_time + std::map _restore_pending; + + // Tracking transaction IDs to confirm their inclusion in a block + std::map> _pending_confirmations; // ── core ────────────────────────────────────────────────────────────────── void check_and_restore(); + bool check_and_restore_internal(); void send_witness_update(const std::string& witness_name, const graphene::chain::witness_object& obj, const witness_info& config); @@ -56,8 +64,8 @@ struct witness_guard_plugin::impl { }; // ─── check_and_restore ─────────────────────────────────────────────────────── - -void witness_guard_plugin::impl::check_and_restore() { +// Returns true if the node is in sync and a full check was performed, false otherwise. +bool witness_guard_plugin::impl::check_and_restore_internal() { auto& database = db(); // Check only if the node is synchronized @@ -66,7 +74,22 @@ void witness_guard_plugin::impl::check_and_restore() { const auto now = fc::time_point_sec(graphene::time::now()); if (head_time < now - fc::seconds(CHAIN_BLOCK_INTERVAL * 2)) { dlog("witness_guard: node not in sync, skipping check"); - return; + return false; // Node not in sync, full check not performed + } + + // Clean up _pending_confirmations once per check, not inside the witness loop. + // Removes trackers that have expired or are no longer relevant. + for (auto it = _pending_confirmations.begin(); it != _pending_confirmations.end(); ) { + // If the transaction ID is expired, remove it from confirmation tracking + if (now > it->second.second) { + // If a transaction expires from _pending_confirmations, it means it was not included. + // We should also remove the corresponding entry from _restore_pending to allow a retry + // without waiting for _restore_pending's own expiration. + _restore_pending.erase(it->second.first); + it = _pending_confirmations.erase(it); + } else { + ++it; + } } const auto& idx = database @@ -84,19 +107,23 @@ void witness_guard_plugin::impl::check_and_restore() { } if (itr->signing_key != null_key) { - // If the key is valid on-chain, reset the guard for this witness. - // This allows the plugin to intervene again if the key becomes null later. - _restore_sent.erase(name); + // Key is healthy on-chain, clear any pending retry state for this witness + _restore_pending.erase(name); continue; } - // If restore has already been sent and the transaction is not yet included in a block, wait. - if (_restore_sent.count(name)) continue; + // If a restore is already in flight and hasn't expired yet, wait. + if (_restore_pending.count(name)) { + if (now <= _restore_pending[name]) continue; + ilog("witness_guard: previous restore for '${w}' expired, retrying", ("w", name)); + } ilog("witness_guard: '${w}' has null signing key on-chain — initiating restore", ("w", name)); send_witness_update(name, *itr, config); } + + return true; // Node was in sync, full check performed } // ─── send_witness_update ───────────────────────────────────────────────────── @@ -110,33 +137,32 @@ void witness_guard_plugin::impl::send_witness_update( const auto signing_pub = config.signing_key.get_public_key(); const auto& active_priv = config.active_key; - // Current URL from blockchain (do not overwrite) - std::string url = std::string(obj.url.c_str()); - // Construct the operation graphene::protocol::witness_update_operation op; op.owner = witness_name; - op.url = url; + op.url = std::string(obj.url); // Conversie directă sigură op.block_signing_key = signing_pub; + // Set expiration to 30 seconds from now + auto expiration = graphene::time::now() + fc::seconds(30); + // Construct the transaction graphene::chain::signed_transaction tx; tx.operations.push_back(op); - tx.set_expiration(db().head_block_time() + fc::seconds(30)); + tx.set_expiration(expiration); tx.set_reference_block(db().head_block_id()); // Sign with the active key - tx.sign(active_priv, db().get_chain_id()); + tx.sign(active_priv, db().get_chain_id()); // tx.id() is computed here - ilog("witness_guard: broadcasting witness_update for '${w}' " - "— restoring signing key to ${k}", - ("w", witness_name)("k", signing_pub)); + const auto tx_id = tx.id(); // Store tx.id() to avoid re-computation + ilog("witness_guard: broadcasting witness_update [ID: ${id}] for '${w}' — restoring key to ${k}", + ("id", tx_id)("w", witness_name)("k", signing_pub)); - // Only network broadcast - p2p_.broadcast_transaction(tx); + p2p_.broadcast_transaction(tx); - // Mark as sent — do not send again in this session - _restore_sent.insert(witness_name); + _restore_pending[witness_name] = expiration; + _pending_confirmations[tx_id] = { witness_name, expiration }; ilog("witness_guard: witness_update for '${w}' sent successfully", ("w", witness_name)); @@ -211,6 +237,8 @@ void witness_guard_plugin::plugin_initialize( FC_ASSERT(sign_priv.valid(), "witness-guard-witness: invalid signing WIF for ${n}", ("n", name)); FC_ASSERT(active_priv.valid(), "witness-guard-witness: invalid active WIF for ${n}", ("n", name)); + + pimpl->_witness_configs[name] = { *sign_priv, *active_priv }; ilog("witness_guard: monitoring witness '${w}' (signing key: ${k})", @@ -241,13 +269,86 @@ void witness_guard_plugin::plugin_startup() { ilog("witness_guard: nothing to monitor, plugin inactive"); return; } + // --- NEW: Authority Check moved here --- + // At this point, the chain_plugin has started and the database is open. + for (auto it = pimpl->_witness_configs.begin(); it != pimpl->_witness_configs.end(); ) { + const std::string& name = it->first; + try { + const auto& account_obj = pimpl->db().get_account(name); + const auto active_pub_key = it->second.active_key.get_public_key(); + bool active_key_has_authority = false; + for (const auto& auth : account_obj.active.key_auths) { + if (auth.first == active_pub_key) { + active_key_has_authority = true; + break; + } + } + if (!active_key_has_authority) { + elog("witness_guard: WARNING: Configured active key for witness '${w}' " + "does NOT have authority on-chain. Restoration will fail.", ("w", name)); + } + ++it; + } catch (const graphene::chain::unknown_account_exception& e) { + elog("witness_guard: ERROR: Account '${w}' not found on chain. Removing from monitor.", ("w", name)); + it = pimpl->_witness_configs.erase(it); + } + } + // --- END Authority Check --- + + if (pimpl->_witness_configs.empty()) return; - // Hook on every applied block - pimpl->db().applied_block.connect( + // Perform an initial check at startup. + // If the node is already synchronized, mark the initial check as completed. + // The check_and_restore_internal() function now returns true if the node is in sync. + if (pimpl->check_and_restore_internal()) { + pimpl->_initial_check_done = true; + } + + // Hook on every applied block and store the connection + pimpl->_applied_block_connection = pimpl->db().applied_block.connect( [this](const graphene::chain::signed_block& b) { if (!pimpl->_enabled) return; - if (b.block_num() % pimpl->_check_interval == 0) { - pimpl->check_and_restore(); + + // 1. Check for transaction confirmations in the new block + if (!pimpl->_pending_confirmations.empty()) { + for (const auto& tx : b.transactions) { + auto it = pimpl->_pending_confirmations.find(tx.id()); + if (it != pimpl->_pending_confirmations.end()) { + const auto tx_id = it->first; + const auto w_name = it->second.first; + pimpl->_restore_pending.erase(w_name); + pimpl->_pending_confirmations.erase(it); + ilog("witness_guard: CONFIRMED restoration for '${w}' in block #${n} [TX: ${id}]", + ("w", w_name)("n", b.block_num())("id", tx_id)); + + } + } + } + + // 2. Look-ahead: If any of our witnesses are scheduled in the next 3 slots, check now! + bool scheduled_soon = false; + if (pimpl->_initial_check_done) { + for (uint32_t i = 1; i <= 3; ++i) { + if (pimpl->_witness_configs.count(pimpl->db().get_scheduled_witness(i))) { + scheduled_soon = true; + break; + } + } + } + + // If the node was not synchronized at startup, check on each new block + // until we detect that synchronization has finished. + if (scheduled_soon) { + pimpl->check_and_restore_internal(); + } + else if (!pimpl->_initial_check_done && (b.block_num() % 10 == 0)) { + // The sync check is now inside check_and_restore_internal() + if (pimpl->check_and_restore_internal()) { + pimpl->_initial_check_done = true; + } + } + else if (b.block_num() % pimpl->_check_interval == 0) { + pimpl->check_and_restore_internal(); } } ); @@ -256,6 +357,9 @@ void witness_guard_plugin::plugin_startup() { } void witness_guard_plugin::plugin_shutdown() { + if (pimpl && pimpl->_applied_block_connection.connected()) { + pimpl->_applied_block_connection.disconnect(); + } ilog("witness_guard: plugin_shutdown()"); } diff --git a/share/vizd/docker/Dockerfile-production b/share/vizd/docker/Dockerfile-production index 4c7a953261..8e74297255 100644 --- a/share/vizd/docker/Dockerfile-production +++ b/share/vizd/docker/Dockerfile-production @@ -66,7 +66,8 @@ RUN \ -DENABLE_MONGO_PLUGIN=FALSE \ .. \ && \ - make -j$(nproc) + make -j$(($(nproc)-22)) + RUN set -xe ;\ cd $APPDIR/build ;\ diff --git a/thirdparty/chainbase b/thirdparty/chainbase index 8a9097080b..51246d7b26 160000 --- a/thirdparty/chainbase +++ b/thirdparty/chainbase @@ -1 +1 @@ -Subproject commit 8a9097080b8ff48984572934d6765ad9ed6860ca +Subproject commit 51246d7b26b4e35e8e492e15de9d451e8394a3cf From 4c113e925fc9752edce993e55394a76c34283a13 Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sun, 26 Apr 2026 02:15:26 +0300 Subject: [PATCH 8/9] feat(witness_guard): add detection for potential long forks based on LIB age --- plugins/witness_guard/witness_guard.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/witness_guard/witness_guard.cpp b/plugins/witness_guard/witness_guard.cpp index 6f4e9b390b..22537a44ef 100644 --- a/plugins/witness_guard/witness_guard.cpp +++ b/plugins/witness_guard/witness_guard.cpp @@ -77,6 +77,20 @@ bool witness_guard_plugin::impl::check_and_restore_internal() { return false; // Node not in sync, full check not performed } + // Detect potential long fork by checking Last Irreversible Block (LIB) age + const auto& dgp = database.get_dynamic_global_properties(); + const uint32_t lib_num = dgp.last_irreversible_block_num; + auto lib_header = database.fetch_block_header_by_number(lib_num); + if (lib_header) { + const auto lib_time = lib_header->timestamp; + // If LIB is older than 200 seconds, we are likely on a long fork or network is stalled + if (now - lib_time > fc::seconds(200)) { + wlog("witness_guard: POTENTIAL LONG FORK DETECTED! LIB #${n} is ${sec}s old. Skipping restoration.", + ("n", lib_num)("sec", (now - lib_time).to_seconds())); + return false; + } + } + // Clean up _pending_confirmations once per check, not inside the witness loop. // Removes trackers that have expired or are no longer relevant. for (auto it = _pending_confirmations.begin(); it != _pending_confirmations.end(); ) { From 437f019a5ec13e8fb15d70720bf6619e89e0fa0a Mon Sep 17 00:00:00 2001 From: M0ssa99 Date: Sun, 26 Apr 2026 11:23:58 +0300 Subject: [PATCH 9/9] chore: update subproject commits for appbase, chainbase, and fc --- thirdparty/appbase | 2 +- thirdparty/chainbase | 2 +- thirdparty/fc | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/thirdparty/appbase b/thirdparty/appbase index 0bbe811c3e..02476aaf29 160000 --- a/thirdparty/appbase +++ b/thirdparty/appbase @@ -1 +1 @@ -Subproject commit 0bbe811c3e54ae6c7839de634120450bc6535e82 +Subproject commit 02476aaf2958c40f8eaf48aee642b59a2a3df5c7 diff --git a/thirdparty/chainbase b/thirdparty/chainbase index 51246d7b26..c8c527e567 160000 --- a/thirdparty/chainbase +++ b/thirdparty/chainbase @@ -1 +1 @@ -Subproject commit 51246d7b26b4e35e8e492e15de9d451e8394a3cf +Subproject commit c8c527e56740857e29656eee4ba9f88c63063a1b diff --git a/thirdparty/fc b/thirdparty/fc index fa5b5001af..ace1f68986 160000 --- a/thirdparty/fc +++ b/thirdparty/fc @@ -1 +1 @@ -Subproject commit fa5b5001afcdbb60667dc3a4db4acd5f907430e8 +Subproject commit ace1f68986bbb228fd8ce2522f5e6629426a3b01