Skip to content

discovery/test_discovery.cpp

Classes

Name
struct ChainTarget
struct DialStats
struct ChainRuntime

Functions

Name
std::optional< uint8_t > hex_to_nibble(char c)
eth::Hash256 hash256_from_hex(const char * hex)
std::vector< ChainTarget > all_chain_targets()
eth::Hash256 mainnet_genesis()
void dial_connect_only(discv4::ValidatedPeer vp, std::function< void(rlpx::DisconnectReason)> on_done, std::function< void(std::shared_ptr< rlpx::RlpxSession >)> on_connected, std::function< void()> on_eth_message, boost::asio::yield_context yield, std::shared_ptr< DialStats > stats, eth::ForkId fork_id, eth::Hash256 genesis, uint64_t network_id)
std::optional< eth::ForkId > load_chain_fork_id(const ChainTarget & target, const std::string & argv0)
void enqueue_chain_peers(const ChainTarget & target, const std::optional< std::filesystem::path > & chain_peers_json_file, const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result, const std::shared_ptr< discv4::DialScheduler > & scheduler, const std::shared_ptr< std::atomic< int > > & chain_peers_loaded)
void seed_bootnodes(boost::asio::io_context & io, const std::shared_ptr< discv4::discv4_client > & dv4, const ChainTarget & target, const std::vector< discv4::ValidatedPeer > & bootstrap_peers)
std::vector< discv4::ValidatedPeer > load_bootstrap_peers(const ChainTarget & target, const std::optional< std::filesystem::path > & chain_peers_json_file, const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result)
std::optional< ChainRuntime > create_chain_runtime(boost::asio::io_context & io, const ChainTarget & target, int max_dials, int min_connections, const std::optional< std::filesystem::path > & chain_peers_json_file, const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result, const rlpx::crypto::Ecdh::KeyPair & keypair, boost::asio::steady_timer & deadline, const std::string & argv0, bool stop_on_connection)
int main(int argc, char ** argv)

Attributes

Name
uint64_t kMainnetNetworkId
uint8_t kEthOffset
const char * kForkHashChainKey
const char * kChainPeerCacheChainKey
const char * kChainPeersUrlDefault
const std::array< uint8_t, 4U > kMainnetForkHashFallback

Functions Documentation

function hex_to_nibble

static std::optional< uint8_t > hex_to_nibble(
    char c
)

function hash256_from_hex

static eth::Hash256 hash256_from_hex(
    const char * hex
)

function all_chain_targets

static std::vector< ChainTarget > all_chain_targets()

function mainnet_genesis

static eth::Hash256 mainnet_genesis()

function dial_connect_only

static void dial_connect_only(
    discv4::ValidatedPeer vp,
    std::function< void(rlpx::DisconnectReason)> on_done,
    std::function< void(std::shared_ptr< rlpx::RlpxSession >)> on_connected,
    std::function< void()> on_eth_message,
    boost::asio::yield_context yield,
    std::shared_ptr< DialStats > stats,
    eth::ForkId fork_id,
    eth::Hash256 genesis,
    uint64_t network_id
)

function load_chain_fork_id

static std::optional< eth::ForkId > load_chain_fork_id(
    const ChainTarget & target,
    const std::string & argv0
)

function enqueue_chain_peers

static void enqueue_chain_peers(
    const ChainTarget & target,
    const std::optional< std::filesystem::path > & chain_peers_json_file,
    const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result,
    const std::shared_ptr< discv4::DialScheduler > & scheduler,
    const std::shared_ptr< std::atomic< int > > & chain_peers_loaded
)

function seed_bootnodes

static void seed_bootnodes(
    boost::asio::io_context & io,
    const std::shared_ptr< discv4::discv4_client > & dv4,
    const ChainTarget & target,
    const std::vector< discv4::ValidatedPeer > & bootstrap_peers
)

function load_bootstrap_peers

static std::vector< discv4::ValidatedPeer > load_bootstrap_peers(
    const ChainTarget & target,
    const std::optional< std::filesystem::path > & chain_peers_json_file,
    const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result
)

function create_chain_runtime

static std::optional< ChainRuntime > create_chain_runtime(
    boost::asio::io_context & io,
    const ChainTarget & target,
    int max_dials,
    int min_connections,
    const std::optional< std::filesystem::path > & chain_peers_json_file,
    const std::optional< discv4::ChainPeerCacheRefreshResult > & refresh_result,
    const rlpx::crypto::Ecdh::KeyPair & keypair,
    boost::asio::steady_timer & deadline,
    const std::string & argv0,
    bool stop_on_connection
)

function main

int main(
    int argc,
    char ** argv
)

Attributes Documentation

variable kMainnetNetworkId

static uint64_t kMainnetNetworkId = 1;

variable kEthOffset

static uint8_t kEthOffset = 0x10;

variable kForkHashChainKey

static const char * kForkHashChainKey = "ethereum-mainnet";

variable kChainPeerCacheChainKey

static const char * kChainPeerCacheChainKey = "ethereum-mainnet";

variable kChainPeersUrlDefault

static const char * kChainPeersUrlDefault = "https://enodes.gnus.ai/chain_enodes.json.gz";

variable kMainnetForkHashFallback

static const std::array< uint8_t, 4U > kMainnetForkHashFallback { 0x07, 0xc9, 0x46, 0x2e };

Source code

// Copyright 2026 Genius Ventures, Inc.
// SPDX-License-Identifier: MIT
//
// examples/discovery/test_discovery.cpp
//
// Functional test for discv4/chain peer cache seeding against live Ethereum peers.
// Uses DialScheduler to seed and observe discovery/chain peer cache inputs for each
// configured chain.
//
// Checks (GTest-style output):
//   1. At least one chain peer is loaded from the current cache / refresh path
//   2. Dial / connect statistics are reported for diagnostics only
//
// Exit code 0 = chain peer cache loading checks pass, 1 = any chain peer cache loading check failed.
//
// Usage:
//   ./test_discovery [--log-level debug] [--timeout 30] [--connections 1] [--stop-on-connection]

#include <array>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

#include <boost/asio/io_context.hpp>
#include <boost/asio/redirect_error.hpp>
#include <boost/asio/signal_set.hpp>
#include <boost/asio/spawn.hpp>
#include <boost/asio/steady_timer.hpp>

#include <discv4/bootstrap_peers.hpp>
#include <discv4/chain_peers.hpp>
#include <discv4/dial_scheduler.hpp>
#include <discv4/discv4_client.hpp>
#include <eth/eth_types.hpp>
#include <eth/messages.hpp>
#include <rlpx/crypto/ecdh.hpp>
#include <rlpx/framing/message_stream.hpp>
#include <rlpx/protocol/messages.hpp>
#include <rlpx/rlpx_error.hpp>
#include <rlpx/rlpx_session.hpp>
#include <base/rlp-logger.hpp>

#include <spdlog/spdlog.h>

#include "../chain_config.hpp"

// ── Ethereum mainnet chain constants ─────────────────────────────────────────

static constexpr uint64_t kMainnetNetworkId = 1;
static constexpr uint8_t  kEthOffset        = 0x10;
static constexpr const char* kForkHashChainKey = "ethereum-mainnet";
static constexpr const char* kChainPeerCacheChainKey = "ethereum-mainnet";
static constexpr const char* kChainPeersUrlDefault = "https://enodes.gnus.ai/chain_enodes.json.gz";

struct ChainTarget
{
    const char* chain_key;
    const char* chain_peer_cache_key;
    uint64_t network_id = 0;
    const char* genesis_hex;
    std::optional<std::array<uint8_t, 4U>> fork_hash_fallback;
};

static std::optional<uint8_t> hex_to_nibble(char c)
{
    if (c >= '0' && c <= '9')
    {
        return static_cast<uint8_t>(c - '0');
    }
    if (c >= 'a' && c <= 'f')
    {
        return static_cast<uint8_t>(10 + c - 'a');
    }
    if (c >= 'A' && c <= 'F')
    {
        return static_cast<uint8_t>(10 + c - 'A');
    }
    return std::nullopt;
}

static eth::Hash256 hash256_from_hex(const char* hex)
{
    eth::Hash256 h{};
    for (size_t i = 0; i < 32; ++i)
    {
        const auto hi = hex_to_nibble(hex[i * 2]).value_or(0);
        const auto lo = hex_to_nibble(hex[i * 2 + 1]).value_or(0);
        h[i] = static_cast<uint8_t>((hi << 4) | lo);
    }
    return h;
}

static std::vector<ChainTarget> all_chain_targets()
{
    return {
        { "ethereum-mainnet", "ethereum-mainnet", 1, "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3", std::array<uint8_t, 4U>{ 0x07, 0xc9, 0x46, 0x2e } },
        { "ethereum-sepolia", "ethereum-sepolia", 11155111, "25a5cc106eea7138acab33231d7160d69cb777ee0c2c553fcddf5138993e6dd9", std::array<uint8_t, 4U>{ 0x26, 0x89, 0x56, 0xb6 } },
        { "ethereum-holesky", "ethereum-holesky", 17000, "b5f7f912443c940f21fd611f12828d75b534364ed9e95ca4e307729a4661bde4", std::array<uint8_t, 4U>{ 0x9b, 0xc6, 0xcb, 0x31 } }
    };
}

static eth::Hash256 mainnet_genesis()
{
    return hash256_from_hex("d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3");
}

// Ethereum mainnet fallback hash — used only when chain_enodes.json(.gz) is not found.
// Update chain_enodes.json instead of this constant when the fork advances.
static const std::array<uint8_t, 4U> kMainnetForkHashFallback{ 0x07, 0xc9, 0x46, 0x2e };

// ── Dial-attempt statistics ───────────────────────────────────────────────────

struct DialStats
{
    std::atomic<int> dialed{0};                     
    std::atomic<int> connect_failed{0};             
    std::atomic<int> wrong_chain{0};                
    std::atomic<int> status_timeout{0};             
    std::atomic<int> too_many_peers{0};             
    std::atomic<int> too_many_peers_right_chain{0}; 
    std::atomic<int> connected{0};                  
    std::atomic<int> eth_messages{0};               
};


// Does not set up EthWatchService — just validates the chain and returns.

static void dial_connect_only(
    discv4::ValidatedPeer                                           vp,
    std::function<void(rlpx::DisconnectReason)> on_done,
    std::function<void(std::shared_ptr<rlpx::RlpxSession>)>       on_connected,
    std::function<void()>                                          on_eth_message,
    boost::asio::yield_context                                     yield,
    std::shared_ptr<DialStats>                                     stats,
    eth::ForkId                                                    fork_id,
    eth::Hash256                                                   genesis,
    uint64_t                                                       network_id)
{
    static auto log = rlp::base::createLogger("test_discovery");
    ++stats->dialed;

    auto keypair_result = rlpx::crypto::Ecdh::generate_ephemeral_keypair();
    if (!keypair_result)
    {
        ++stats->connect_failed;
        on_done(rlpx::DisconnectReason::kTcpError);
        return;
    }
    const auto& keypair = keypair_result.value();

    const rlpx::SessionConnectParams params{
        vp.peer.ip,
        vp.peer.tcp_port,
        keypair.public_key,
        keypair.private_key,
        vp.pubkey,
        "rlp-test-discovery",
        0
    };

    auto session_result = rlpx::RlpxSession::connect(params, yield);
    if (!session_result)
    {
        ++stats->connect_failed;
        on_done(rlpx::DisconnectReason::kTcpError);
        return;
    }
    auto session = std::move(session_result.value());

    // Send ETH Status (69)
    {
        eth::StatusMessage69 status69{
            69,
            network_id,
            genesis,
            fork_id,
            0,
            0,
            genesis,
        };
        eth::StatusMessage status = status69;
        auto encoded = eth::protocol::encode_status(status);
        if (encoded)
        {
            (void)session->post_message(rlpx::framing::Message{
                static_cast<uint8_t>(kEthOffset + eth::protocol::kStatusMessageId),
                std::move(encoded.value())
            });
        }
    }

    auto executor           = yield.get_executor();
    auto status_received    = std::make_shared<std::atomic<bool>>(false);
    auto status_timeout     = std::make_shared<boost::asio::steady_timer>(executor);
    auto lifetime           = std::make_shared<boost::asio::steady_timer>(executor);
    auto disconnect_reason  = std::make_shared<std::atomic<int>>(
                                  static_cast<int>(rlpx::DisconnectReason::kRequested));
    status_timeout->expires_after(eth::protocol::kStatusHandshakeTimeout);
    lifetime->expires_after(std::chrono::seconds(10)); // stay connected briefly after handshake

    session->set_disconnect_handler(
        [lifetime, status_timeout, disconnect_reason]
        (const rlpx::protocol::DisconnectMessage& msg)
        {
            disconnect_reason->store(static_cast<int>(msg.reason));
            lifetime->cancel();
            status_timeout->cancel();
        });

    session->set_ping_handler([session](const rlpx::protocol::PingMessage&) {
        const rlpx::protocol::PongMessage pong;
        auto encoded = pong.encode();
        if (!encoded) { return; }
        (void)session->post_message(rlpx::framing::Message{
            rlpx::kPongMessageId,
            std::move(encoded.value())
        });
    });

    session->set_generic_handler([session, status_received, status_timeout,
                                   on_connected, on_eth_message, genesis, stats, network_id](const rlpx::protocol::Message& msg)
    {
        static auto gh_log = rlp::base::createLogger("test_discovery");
        if (msg.id < kEthOffset) { return; }
        const auto eth_id = static_cast<uint8_t>(msg.id - kEthOffset);

        if (eth_id != eth::protocol::kStatusMessageId)
        {
            if (status_received->load())
            {
                ++stats->eth_messages;
                on_eth_message();
            }
            return;
        }

        const rlp::ByteView payload(msg.payload.data(), msg.payload.size());
        auto decoded = eth::protocol::decode_status(payload);
        if (!decoded)
        {
            status_timeout->cancel();
            (void)session->disconnect(rlpx::DisconnectReason::kSubprotocolError);
            return;
        }
        auto valid = eth::protocol::validate_status(decoded.value(), network_id, genesis);
        if (!valid)
        {
            SPDLOG_LOGGER_DEBUG(gh_log, "ETH Status validation failed: {}",
                                static_cast<int>(valid.error()));
            ++stats->wrong_chain;
            status_timeout->cancel();
            (void)session->disconnect(rlpx::DisconnectReason::kSubprotocolError);
            return;
        }
        ++stats->connected;
        status_received->store(true);
        status_timeout->cancel();
        on_connected(session);
    });

    boost::system::error_code hs_ec;
    status_timeout->async_wait(boost::asio::redirect_error(yield, hs_ec));

    if (!status_received->load())
    {
        if (hs_ec)  // timer was cancelled — peer disconnected us before Status
        {
            const auto reason = static_cast<rlpx::DisconnectReason>(disconnect_reason->load());
            if (reason == rlpx::DisconnectReason::kTooManyPeers)
            {
                ++stats->too_many_peers;
            }
            else
            {
                ++stats->connect_failed;
            }
        }
        else  // timer fired naturally — no Status received within timeout
        {
            ++stats->status_timeout;
        }
        (void)session->disconnect(rlpx::DisconnectReason::kTimeout);
        on_done(rlpx::DisconnectReason::kTcpError);
        return;
    }

    // Stay briefly connected so on_connected can be counted
    boost::system::error_code lt_ec;
    lifetime->async_wait(boost::asio::redirect_error(yield, lt_ec));
    on_done(rlpx::DisconnectReason::kTcpError);
}

// ── runtime types and helper functions ────────────────────────────────────────

struct ChainRuntime
{
    ChainTarget                                target{};
    eth::Hash256                               genesis{};
    eth::ForkId                                fork_id{};
    std::shared_ptr<DialStats>                 stats;
    std::shared_ptr<discv4::WatcherPool>       pool;
    std::shared_ptr<discv4::DialScheduler>     scheduler;
    std::shared_ptr<discv4::discv4_client>     dv4;
    std::shared_ptr<std::atomic<int>>          peers_count;
    std::shared_ptr<std::atomic<int>>          chain_peers_loaded;
    std::vector<discv4::ValidatedPeer>         bootstrap_peers;
    std::shared_ptr<std::function<void()>>     on_connection_activity;
};

static std::optional<eth::ForkId> load_chain_fork_id(
    const ChainTarget&  target,
    const std::string&  argv0)
{
    const auto loaded_hash = load_fork_hash(target.chain_key, argv0);
    if (!loaded_hash && !target.fork_hash_fallback.has_value())
    {
        return std::nullopt;
    }

    eth::ForkId fork_id{};
    fork_id.fork_hash = loaded_hash.value_or(target.fork_hash_fallback.value());
    fork_id.next_fork = 0;
    return fork_id;
}

static void enqueue_chain_peers(
    const ChainTarget&                                target,
    const std::optional<std::filesystem::path>&       chain_peers_json_file,
    const std::optional<discv4::ChainPeerCacheRefreshResult>& refresh_result,
    const std::shared_ptr<discv4::DialScheduler>&     scheduler,
    const std::shared_ptr<std::atomic<int>>&          chain_peers_loaded)
{
    auto enqueue_loaded_peers =
        [&scheduler, &chain_peers_loaded]
        (std::vector<discv4::ValidatedPeer> chain_peers)
        {
            for (auto& peer : chain_peers)
            {
                if (peer.peer.tcp_port == 0)
                {
                    continue;
                }
                if (!rlpx::crypto::Ecdh::verify_public_key(peer.pubkey))
                {
                    continue;
                }
                ++(*chain_peers_loaded);
                scheduler->enqueue(std::move(peer));
            }
        };

    if (chain_peers_json_file.has_value())
    {
        enqueue_loaded_peers(discv4::load_chain_peers_from_json(
            target.chain_peer_cache_key,
            *chain_peers_json_file));

        std::cout << "[  INFO    ] [" << target.chain_key << "] loaded "
                  << chain_peers_loaded->load() << " chain peer(s) for '"
                  << target.chain_peer_cache_key << "' from "
                  << chain_peers_json_file->string() << "\n";
        return;
    }

    if (refresh_result.has_value())
    {
        enqueue_loaded_peers(discv4::load_chain_peers_from_json(
            target.chain_peer_cache_key,
            refresh_result->cache_path));

        std::cout << "[  INFO    ] [" << target.chain_key << "] loaded "
                  << chain_peers_loaded->load() << " chain peer(s) for '"
                  << target.chain_peer_cache_key << "' from "
                  << refresh_result->cache_path.string() << "\n";
    }
}

static void seed_bootnodes(
    boost::asio::io_context&                         io,
    const std::shared_ptr<discv4::discv4_client>&   dv4,
    const ChainTarget&                               target,
    const std::vector<discv4::ValidatedPeer>&        bootstrap_peers)
{
    if (!bootstrap_peers.empty())
    {
        for (const auto& bootnode_peer : bootstrap_peers)
        {
            const std::string host_copy = bootnode_peer.peer.ip;
            const uint16_t port_copy = bootnode_peer.peer.udp_port;
            const discv4::NodeId bn_id = bootnode_peer.peer.node_id;
            boost::asio::spawn(io,
                [dv4, host_copy, port_copy, bn_id](boost::asio::yield_context yc)
                {
                    (void)dv4->find_node(host_copy, port_copy, bn_id, yc);
                });
        }
        return;
    }

}

static std::vector<discv4::ValidatedPeer> load_bootstrap_peers(
    const ChainTarget&                                target,
    const std::optional<std::filesystem::path>&       chain_peers_json_file,
    const std::optional<discv4::ChainPeerCacheRefreshResult>& refresh_result)
{
    if (chain_peers_json_file.has_value())
    {
        return discv4::load_bootstrap_peers_from_json(
            target.chain_peer_cache_key,
            *chain_peers_json_file);
    }

    if (refresh_result.has_value())
    {
        return discv4::load_bootstrap_peers_from_json(
            target.chain_peer_cache_key,
            refresh_result->cache_path);
    }

    return {};
}

static std::optional<ChainRuntime> create_chain_runtime(
    boost::asio::io_context&                             io,
    const ChainTarget&                                   target,
    int                                                  max_dials,
    int                                                  min_connections,
    const std::optional<std::filesystem::path>&          chain_peers_json_file,
    const std::optional<discv4::ChainPeerCacheRefreshResult>& refresh_result,
    const rlpx::crypto::Ecdh::KeyPair&                   keypair,
    boost::asio::steady_timer&                           deadline,
    const std::string&                                   argv0,
    bool                                                 stop_on_connection)
{
    const auto fork_id = load_chain_fork_id(target, argv0);
    if (!fork_id)
    {
        std::cout << "[  WARN    ] [" << target.chain_key << "] missing fork hash configuration\n";
        return std::nullopt;
    }

    ChainRuntime runtime{};
    runtime.target = target;
    runtime.genesis = hash256_from_hex(target.genesis_hex);
    runtime.fork_id = *fork_id;
    runtime.stats = std::make_shared<DialStats>();
    runtime.pool = std::make_shared<discv4::WatcherPool>(50, max_dials * 2);
    runtime.peers_count = std::make_shared<std::atomic<int>>(0);
    runtime.chain_peers_loaded = std::make_shared<std::atomic<int>>(0);
    runtime.bootstrap_peers = load_bootstrap_peers(target, chain_peers_json_file, refresh_result);

    discv4::discv4Config dv4_cfg;
    dv4_cfg.bind_port = 0;
    std::copy(keypair.private_key.begin(), keypair.private_key.end(), dv4_cfg.private_key.begin());
    std::copy(keypair.public_key.begin(), keypair.public_key.end(), dv4_cfg.public_key.begin());
    runtime.dv4 = std::make_shared<discv4::discv4_client>(io, dv4_cfg);

    auto sched_ref = std::make_shared<discv4::DialScheduler*>(nullptr);
    runtime.on_connection_activity = std::make_shared<std::function<void()>>();
    runtime.scheduler = std::make_shared<discv4::DialScheduler>(io, runtime.pool,
        [stats = runtime.stats, fork_id_value = runtime.fork_id, genesis = runtime.genesis,
         network_id = runtime.target.network_id, on_connection_activity = runtime.on_connection_activity]
        (discv4::ValidatedPeer                                      vp,
         std::function<void(rlpx::DisconnectReason)> on_done,
         std::function<void(std::shared_ptr<rlpx::RlpxSession>)>   on_connected,
         boost::asio::yield_context                                 yc) mutable
        {
            dial_connect_only(vp, std::move(on_done),
                [on_connected, on_connection_activity]
                (std::shared_ptr<rlpx::RlpxSession> s) mutable
                {
                    on_connected(s);
                    (*on_connection_activity)();
                },
                [on_connection_activity]()
                {
                    (*on_connection_activity)();
                },
                yc, stats, fork_id_value, genesis, network_id);
        });
    *sched_ref = runtime.scheduler.get();

    *runtime.on_connection_activity = [&deadline, stop_on_connection]()
    {
        if (stop_on_connection)
        {
            deadline.cancel();
        }
    };

    // Do not pre-filter discovery/chain peer candidates by ENR fork id.
    // Chain validation is performed after connect via ETH Status.
    runtime.scheduler->filter_fn = {};

    runtime.dv4->set_peer_discovered_callback(
        [scheduler = runtime.scheduler, peers_count = runtime.peers_count](const discv4::DiscoveredPeer& peer)
        {
            discv4::ValidatedPeer vp;
            vp.peer = peer;
            std::copy(peer.node_id.begin(), peer.node_id.end(), vp.pubkey.begin());
            if (!rlpx::crypto::Ecdh::verify_public_key(vp.pubkey))
            {
                return;
            }
            ++(*peers_count);
            scheduler->enqueue(std::move(vp));
        });

    runtime.dv4->set_error_callback([](const std::string&) {});

    enqueue_chain_peers(
        runtime.target,
        chain_peers_json_file,
        refresh_result,
        runtime.scheduler,
        runtime.chain_peers_loaded);

    return runtime;
}

// ── Test framework ────────────────────────────────────────────────────────────

namespace {

struct TestSuite
{
    int run = 0, passed = 0, failed = 0;
    std::string current;

    void start(const std::string& name)
    {
        current = name;
        ++run;
        std::cout << "[ RUN      ] " << name << "\n";
    }
    void pass(const std::string& detail = "")
    {
        ++passed;
        std::cout << "[       OK ] " << current << "\n";
        if (!detail.empty()) std::cout << "            " << detail << "\n";
    }
    void fail(const std::string& detail = "")
    {
        ++failed;
        std::cout << "[  FAILED  ] " << current << "\n";
        if (!detail.empty()) std::cout << "            " << detail << "\n";
    }
    void header(int n)
    {
        std::cout << "\n[==========] DiscoveryTest (" << n << " checks)\n\n";
    }
    void footer()
    {
        std::cout << "\n[==========] " << run << " check(s)\n";
        std::cout << "[  PASSED  ] " << passed << "\n";
        if (failed) std::cout << "[  FAILED  ] " << failed << "\n";
        std::cout << "\n";
    }
};

} // namespace

// ── main ──────────────────────────────────────────────────────────────────────

int main(int argc, char** argv)
{
    int timeout_secs    = 30;
    int min_connections = 1;
    int max_dials       = 16;  // target dialed peers (go-ethereum: MaxPeers/dialRatio = 50/3 ≈ 16)
                               // active concurrent attempts = min(target*2, 50) per go-ethereum's freeDialSlots()
    std::string chain_peers_json_path;
    std::string chain_peers_url = kChainPeersUrlDefault;
    bool chain_peers_url_enabled = true;
    bool stop_on_connection = false;

    for (int i = 1; i < argc; ++i)
    {
        std::string_view arg(argv[i]);
        if (arg == "--log-level" && i + 1 < argc)
        {
            std::string_view lvl(argv[++i]);
            if (lvl == "debug")    spdlog::set_level(spdlog::level::debug);
            else if (lvl == "info") spdlog::set_level(spdlog::level::info);
            else if (lvl == "warn") spdlog::set_level(spdlog::level::warn);
            else if (lvl == "off")  spdlog::set_level(spdlog::level::off);
        }
        else if (arg == "--timeout" && i + 1 < argc)   { timeout_secs    = std::atoi(argv[++i]); }
        else if (arg == "--connections" && i + 1 < argc){ min_connections = std::atoi(argv[++i]); }
        else if (arg == "--dials" && i + 1 < argc)      { max_dials       = std::atoi(argv[++i]); }
        else if ((arg == "--chain-peers-json" || arg == "--bootstrap-peers-json") && i + 1 < argc)
        {
            chain_peers_json_path = argv[++i];
        }
        else if ((arg == "--chain-peers-url" || arg == "--bootstrap-peers-url") && i + 1 < argc)
        {
            chain_peers_url = argv[++i];
            chain_peers_url_enabled = true;
        }
        else if (arg == "--no-chain-peers-url" || arg == "--no-bootstrap-peers-url")
        {
            chain_peers_url_enabled = false;
        }
        else if (arg == "--stop-on-connection")
        {
            stop_on_connection = true;
        }
    }

    // ── Fork hash — loaded from chain_enodes.json(.gz), fallback to compiled-in value ──────
    const auto loaded_hash = load_fork_hash(kForkHashChainKey, argv[0]);
    if ( !loaded_hash )
    {
        std::cout << "[  WARN    ] chain_enodes.json(.gz) not found or missing 'ethereum-mainnet' forkId — "
                     "using compiled-in fallback hash.\n";
    }
    const eth::ForkId mainnet_fork_id{
        loaded_hash.value_or(kMainnetForkHashFallback),
        0
    };

    TestSuite suite;
    const auto chain_targets = all_chain_targets();
    suite.header(static_cast<int>(chain_targets.size()));

    boost::asio::io_context io;

    // Shared result counters (written only from the single io_context thread)
    auto chain_runtimes = std::vector<ChainRuntime>{};

    // ── discv4 setup ─────────────────────────────────────────────────────────
    auto keypair_result = rlpx::crypto::Ecdh::generate_ephemeral_keypair();
    if (!keypair_result)
    {
        std::cout << "Failed to generate keypair\n";
        return 1;
    }
    const auto& keypair = keypair_result.value();

    // ── Overall test timeout ─────────────────────────────────────────────────
    boost::asio::steady_timer deadline(io, std::chrono::seconds(timeout_secs));

    std::optional<discv4::ChainPeerCacheRefreshResult> refresh_result;
    if (chain_peers_json_path.empty() && chain_peers_url_enabled)
    {
        refresh_result = discv4::refresh_chain_peer_cache_json(
            discv4::chain_peer_cache_json_path(argv[0]),
            chain_peers_url);
        if (refresh_result.has_value())
        {
            std::cout << "[  INFO    ] chain peer cache refresh: "
                      << (refresh_result->cache_updated ? "updated" :
                          (refresh_result->cache_available ? "unchanged" : "unavailable"))
                      << " " << refresh_result->cache_path.string() << "\n";
        }
    }

    const auto chain_peers_json_file =
        discv4::find_chain_peer_cache_json_path(argv[0], chain_peers_json_path);
    if (!chain_peers_json_path.empty() && !chain_peers_json_file.has_value())
    {
        std::cout << "Chain peer cache file not found: " << chain_peers_json_path << "\n";
        return 1;
    }
    if (chain_peers_json_file.has_value())
    {
        std::cout << "[  INFO    ] chain peer cache path: "
                  << chain_peers_json_file->string() << "\n";
    }

    for (const auto& target : chain_targets)
    {
        auto runtime = create_chain_runtime(
            io,
            target,
            max_dials,
            min_connections,
            chain_peers_json_file,
            refresh_result,
            keypair,
            deadline,
            argv[0],
            stop_on_connection);
        if (runtime.has_value())
        {
            chain_runtimes.push_back(std::move(*runtime));
        }
    }

    if (chain_runtimes.empty())
    {
        std::cout << "No chain runtimes configured\n";
        return 1;
    }

    deadline.async_wait([&](boost::system::error_code) {
        for (auto& runtime : chain_runtimes)
        {
            runtime.scheduler->stop();
            runtime.dv4->stop();
        }
        io.stop();
    });

    // ── Signal handler ───────────────────────────────────────────────────────
    boost::asio::signal_set signals(io, SIGINT, SIGTERM);
    signals.async_wait([&](boost::system::error_code, int) {
        deadline.cancel();
        for (auto& runtime : chain_runtimes)
        {
            runtime.scheduler->stop();
            runtime.dv4->stop();
        }
        io.stop();
    });

    for (auto& runtime : chain_runtimes)
    {
        const auto start_result = runtime.dv4->start();
        if (!start_result)
        {
            std::cout << "Failed to start discv4 for chain " << runtime.target.chain_key << "\n";
            return 1;
        }

        seed_bootnodes(io, runtime.dv4, runtime.target, runtime.bootstrap_peers);
    }

    io.run();

    for (auto& runtime : chain_runtimes)
    {
        std::cout << "\n[  STATS   ] [" << runtime.target.chain_key << "] Dial breakdown:\n"
                  << "              chain peers loaded:       " << runtime.chain_peers_loaded->load() << "\n"
                  << "              discovered peers:             " << runtime.peers_count->load() << "\n"
                  << "              dialed:                       " << runtime.stats->dialed.load() << "\n"
                  << "              connect failed:               " << runtime.stats->connect_failed.load() << "\n"
                  << "              wrong chain:                  " << runtime.stats->wrong_chain.load() << "\n"
                  << "              too many peers:               " << runtime.stats->too_many_peers.load() << "\n"
                  << "              too many peers (right chain): " << runtime.stats->too_many_peers_right_chain.load() << "\n"
                  << "              status timeout:               " << runtime.stats->status_timeout.load() << "\n"
                  << "              connected (right chain):      " << runtime.stats->connected.load() << "\n"
                  << "              eth messages observed:        " << runtime.stats->eth_messages.load() << "\n";

        const int connections = runtime.scheduler->total_validated;

        suite.start(std::string("DiscoveryTest.") + runtime.target.chain_key + ".ChainPeersLoaded");
        if (runtime.chain_peers_loaded->load() > 0)
        {
            suite.pass(std::to_string(runtime.chain_peers_loaded->load()) + " chain peer(s) loaded");
        }
        else
        {
            suite.fail("No chain peers loaded from chain_enodes cache/refresh path");
        }

        std::cout << "[  INFO    ] [" << runtime.target.chain_key << "] active ETH Status connection(s): "
                  << connections << "\n";
    }

    // std::exit bypasses stack-variable destructors (including io_context), which avoids
    // boost::coroutines::detail::forced_unwind being thrown during io cleanup when
    // active coroutines are present at shutdown (TCP connect, etc.).
    std::cout.flush();
    std::exit(suite.failed > 0 ? 1 : 0);
}

Updated on 2026-06-05 at 17:22:18 -0700