diff --git a/scripts/gen_shadow_yaml.sh b/scripts/gen_shadow_yaml.sh new file mode 100755 index 0000000..2b0cb7e --- /dev/null +++ b/scripts/gen_shadow_yaml.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# Generate a Shadow network YAML from a genesis folder (e.g., genesis_test) +# Default output name: shadow_network.yaml +# Usage: +# gen_shadow_yaml.sh -g [-o ] [options] +# Options: +# -g GENESIS_DIR Path to the genesis directory containing config.yaml, validators.yaml, nodes.yaml, node_*.key (required) +# -o OUTPUT_YAML Output YAML path (default: ./shadow_network.yaml) +# -t STOP_TIME Shadow stop_time value (default: 60s) +# -u UDP_BASE Base UDP port for --listen-addr (default: 9000) +# -p PROM_BASE Base Prometheus port (default: 9100) +# -i IP_BASE_LAST_OCTET Base last octet for IPs starting at 10.0.0.X (default: 10) +# -x QLEAN_PATH Path to qlean executable (default: /build/src/executable/qlean) +# -m MODULES_DIR Path to modules dir (default: /build/src/modules) +# -r PROJECT_ROOT Project root to use for defaults (default: parent dir of this script) +# +# Notes: +# - Node count is inferred from node_*.key files in GENESIS_DIR. +# - Ports increment by +index per node. +# - IPs are assigned as 10.0.0.(base+idx) +# - Paths are emitted literally in YAML and quoted; override with -x/-m if needed. + +set -euo pipefail + +print_usage() { + sed -n '1,45p' "$0" | sed 's/^# \{0,1\}//' +} + +# Defaults +OUTPUT_YAML="shadow_network.yaml" +STOP_TIME="60s" +UDP_BASE=9000 +PROM_BASE=9100 +IP_BASE_LAST_OCTET=10 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT_DEFAULT="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$PROJECT_ROOT_DEFAULT" +QLEAN_PATH_DEFAULT="$PROJECT_ROOT/build/src/executable/qlean" +MODULES_DIR_DEFAULT="$PROJECT_ROOT/build/src/modules" +QLEAN_PATH="$QLEAN_PATH_DEFAULT" +MODULES_DIR="$MODULES_DIR_DEFAULT" +GENESIS_DIR="" + +# Network/graph defaults (host/switched bandwidth, latency, packet loss) +BANDWIDTH_HOST="100 Mbit" +BANDWIDTH_SWITCH="1 Gbit" +LINK_LATENCY="1 ms" +PACKET_LOSS="0.0" + +while getopts ":g:o:t:u:p:i:x:m:r:h" opt; do + case $opt in + g) GENESIS_DIR="$OPTARG" ;; + o) OUTPUT_YAML="$OPTARG" ;; + t) STOP_TIME="$OPTARG" ;; + u) UDP_BASE="$OPTARG" ;; + p) PROM_BASE="$OPTARG" ;; + i) IP_BASE_LAST_OCTET="$OPTARG" ;; + x) QLEAN_PATH="$OPTARG" ;; + m) MODULES_DIR="$OPTARG" ;; + r) PROJECT_ROOT="$OPTARG" ; QLEAN_PATH_DEFAULT="$PROJECT_ROOT/build/src/executable/qlean"; MODULES_DIR_DEFAULT="$PROJECT_ROOT/build/src/modules" ;; + h) print_usage; exit 0 ;; + :) echo "Error: Option -$OPTARG requires an argument" >&2; print_usage; exit 2 ;; + \?) echo "Error: Invalid option -$OPTARG" >&2; print_usage; exit 2 ;; + esac +done + +# Apply defaults that depend on PROJECT_ROOT if user didn't override +if [[ "$QLEAN_PATH" == "$QLEAN_PATH_DEFAULT" && ! -x "$QLEAN_PATH_DEFAULT" ]]; then + # Keep default even if not built yet; just warn + echo "Warning: qlean not found at $QLEAN_PATH_DEFAULT; ensure you build it or pass -x" >&2 +fi +if [[ "$MODULES_DIR" == "$MODULES_DIR_DEFAULT" && ! -d "$MODULES_DIR_DEFAULT" ]]; then + echo "Warning: modules dir not found at $MODULES_DIR_DEFAULT; ensure you build modules or pass -m" >&2 +fi + +# Validate genesis dir +if [[ -z "$GENESIS_DIR" ]]; then + echo "Error: -g GENESIS_DIR is required" >&2 + print_usage + exit 2 +fi +if [[ ! -d "$GENESIS_DIR" ]]; then + echo "Error: GENESIS_DIR '$GENESIS_DIR' does not exist or is not a directory" >&2 + exit 2 +fi + +# Resolve absolute paths using Python for macOS portability (realpath -f is not standard) +py_abspath() { python3 - "$1" <<'PY' +import os,sys +p=sys.argv[1] +print(os.path.abspath(p)) +PY +} + +GENESIS_DIR_ABS="$(py_abspath "$GENESIS_DIR")" +QLEAN_PATH_ABS="$(py_abspath "$QLEAN_PATH")" +MODULES_DIR_ABS="$(py_abspath "$MODULES_DIR")" +OUTPUT_YAML_ABS="$(py_abspath "$(dirname "$OUTPUT_YAML")")/$(basename "$OUTPUT_YAML")" + +CONFIG_YAML="$GENESIS_DIR_ABS/config.yaml" +VALIDATORS_YAML="$GENESIS_DIR_ABS/validators.yaml" +VALIDATOR_CONFIG_YAML="$GENESIS_DIR_ABS/validator-config.yaml" +NODES_YAML="$GENESIS_DIR_ABS/nodes.yaml" + +# Prefer validator-config.yaml for parsing enrFields (it's used in some genesis folders). +if [[ -f "$VALIDATOR_CONFIG_YAML" ]]; then + PARSE_VALIDATOR_FILE="$VALIDATOR_CONFIG_YAML" +else + PARSE_VALIDATOR_FILE="$VALIDATORS_YAML" +fi + +for f in "$CONFIG_YAML" "$VALIDATORS_YAML" "$NODES_YAML"; do + if [[ ! -f "$f" ]]; then + echo "Error: required file missing in genesis dir: $f" >&2 + exit 2 + fi +done + +# Collect node keys (portable, numerically sorted by index) +export GENESIS_DIR_ABS +NODE_KEY_FILES=() +while IFS= read -r line; do + NODE_KEY_FILES+=("$line") +done < <(python3 - <<'PY' +import os, re, sys +root = os.environ.get('GENESIS_DIR_ABS') +pat = re.compile(r'^node_(\d+)\.key$') +items = [] +for name in os.listdir(root): + m = pat.match(name) + if m: + items.append((int(m.group(1)), os.path.join(root, name))) +for _, path in sorted(items, key=lambda x: x[0]): + print(path) +PY +) +NODE_COUNT=${#NODE_KEY_FILES[@]} +if [[ "$NODE_COUNT" -eq 0 ]]; then + echo "Error: no node_*.key files found in $GENESIS_DIR_ABS" >&2 + exit 2 +fi + +# Try to read validator count and warn on mismatch +if VAL_COUNT=$(grep -E '^\s*VALIDATOR_COUNT\s*:' "$CONFIG_YAML" | awk -F: '{gsub(/ /,"",$2); print $2}'); then + if [[ -n "$VAL_COUNT" && "$VAL_COUNT" != "$NODE_COUNT" ]]; then + echo "Warning: VALIDATOR_COUNT ($VAL_COUNT) != number of node_*.key files ($NODE_COUNT)" >&2 + fi +fi + +# Parse validators.yaml to extract enrFields.ip and enrFields.quic per validator (if present). +# We prefer these values over generated defaults so shadow uses the same IPs/ports as the genesis. +VALIDATOR_IPS=() +VALIDATOR_QUICS=() +# This python snippet prints one line per node index: " " (empty strings if not found) +while IFS= read -r _line; do + VALIDATOR_IPS+=("$(echo "$_line" | awk '{print $1}')") + VALIDATOR_QUICS+=("$(echo "$_line" | awk '{print $2}')") +done < <(python3 - "$PARSE_VALIDATOR_FILE" "$NODE_COUNT" <<'PY' +import sys, re +path = sys.argv[1] +node_count = int(sys.argv[2]) +mapping = {} +name = None +in_enr = False +with open(path) as f: + for raw in f: + line = raw.rstrip('\n') + m = re.match(r'^\s*-\s*name:\s*(\S+)', line) + if m: + name = m.group(1) + in_enr = False + continue + if re.match(r'^\s*enrFields:\s*', line): + in_enr = True + continue + if in_enr and name is not None: + m_ip = re.match(r'^\s*ip:\s*(\S+)', line) + if m_ip: + mapping.setdefault(name, {})['ip'] = m_ip.group(1) + continue + m_quic = re.match(r'^\s*quic:\s*(\S+)', line) + if m_quic: + mapping.setdefault(name, {})['quic'] = m_quic.group(1) + continue +# Emit ip and quic for node_0 .. node_{N-1} +for i in range(node_count): + nm = f'node_{i}' + ent = mapping.get(nm, {}) + ip = ent.get('ip', '') + quic = str(ent.get('quic', '')) + print(ip + ' ' + quic) +PY +) + +# Helper: YAML double-quoted string escape +yaml_escape() { + local s="$1" + s="${s//\\/\\\\}" # escape backslashes + s="${s//\"/\\\"}" # escape double quotes + printf '%s' "$s" +} + +# Start writing YAML +mkdir -p "$(dirname "$OUTPUT_YAML_ABS")" +{ + printf "general:\n" + printf " stop_time: %s\n" "$STOP_TIME" + printf " model_unblocked_syscall_latency: true\n" + printf "experimental:\n" + printf " native_preemption_enabled: true\n" + printf "network:\n" + # Emit an inline GML graph: create one node per host with 100 Mbit and a central switch with 1 Gbit + printf " graph:\n" + printf " type: gml\n" + printf " inline: |\n" + printf " graph [\n" + printf " directed 0\n" + + # Print node entries for each host + for ((i=0; i + if [[ -n "${VALIDATOR_IPS[$i]}" ]]; then + ip="${VALIDATOR_IPS[$i]}" + else + ip_last=$((IP_BASE_LAST_OCTET + i)) + ip="10.0.0.$ip_last" + fi + + # Prefer the quic port from validators.yaml if present; otherwise use UDP_BASE + index + if [[ -n "${VALIDATOR_QUICS[$i]}" ]]; then + udp_port="${VALIDATOR_QUICS[$i]}" + else + udp_port=$((UDP_BASE + i)) + fi + + prom_port=$((PROM_BASE + i)) + + # Build args string + args_str=( + "--modules-dir" "$MODULES_DIR_ABS" + "--bootnodes" "$NODES_YAML" + "--genesis" "$CONFIG_YAML" + "--validator-registry-path" "$VALIDATORS_YAML" + "--node-id" "node_${i}" + "--node-key" "$key_file" + "--listen-addr" "/ip4/0.0.0.0/udp/${udp_port}/quic-v1" + "--prometheus-port" "$prom_port" + ) + # Join args preserving spaces + IFS=' ' read -r -a _dummy <<< "" # reset + joined="${args_str[*]}" + + printf " %s:\n" "$node_name" + printf " network_node_id: %d\n" "$i" + printf " ip_addr: %s\n" "$ip" + printf " processes:\n" + printf " - path: %s\n" "$QLEAN_PATH_ABS" + printf " args: \"%s\"\n" "$(yaml_escape "$joined")" + printf " expected_final_state: running\n\n" + done +} > "$OUTPUT_YAML_ABS" + +echo "Wrote $OUTPUT_YAML_ABS with $NODE_COUNT node(s)." diff --git a/src/app/configuration.cpp b/src/app/configuration.cpp index 02a2834..3425265 100644 --- a/src/app/configuration.cpp +++ b/src/app/configuration.cpp @@ -60,6 +60,10 @@ namespace lean::app { return node_key_.value(); } + const std::optional &Configuration::maxBootnodes() const { + return max_bootnodes_; + } + const Configuration::DatabaseConfig &Configuration::database() const { return database_; } diff --git a/src/app/configuration.hpp b/src/app/configuration.hpp index faf49d7..16cfdf9 100644 --- a/src/app/configuration.hpp +++ b/src/app/configuration.hpp @@ -48,6 +48,7 @@ namespace lean::app { [[nodiscard]] virtual const std::optional & listenMultiaddr() const; [[nodiscard]] virtual const libp2p::crypto::KeyPair &nodeKey() const; + [[nodiscard]] virtual const std::optional &maxBootnodes() const; [[nodiscard]] virtual const DatabaseConfig &database() const; @@ -66,6 +67,7 @@ namespace lean::app { std::filesystem::path genesis_config_path_; std::optional listen_multiaddr_; std::optional node_key_; + std::optional max_bootnodes_; DatabaseConfig database_; MetricsConfig metrics_; diff --git a/src/app/configurator.cpp b/src/app/configurator.cpp index e2e91b9..942991a 100644 --- a/src/app/configurator.cpp +++ b/src/app/configurator.cpp @@ -117,6 +117,7 @@ namespace lean::app { ("name,n", po::value(), "Set name of node.") ("node-id", po::value(), "Node id from validator registry (genesis/validators.yaml).") ("node-key", po::value(), "Set secp256k1 node key as hex string (with or without 0x prefix).") + ("max-bootnodes", po::value(), "Max bootnodes count to connect to.") ("log,l", po::value>(), "Sets a custom logging filter.\n" "Syntax: =, e.g., -llibp2p=off.\n" @@ -173,10 +174,12 @@ namespace lean::app { } if (vm.contains("help")) { + auto exe = std::filesystem::path{argv_[0]}.filename().string(); std::cout << "Lean-node version " << buildVersion() << '\n'; std::cout << cli_options_ << '\n'; std::println(std::cout, "Other commands:"); - std::println(std::cout, " qlean key generate-node-key"); + std::println(std::cout, " {} key generate-node-key", exe); + std::println(std::cout, " {} generate-genesis", exe); return true; } @@ -413,6 +416,10 @@ namespace lean::app { fail = true; } }); + if (auto max_bootnodes = + find_argument(cli_values_map_, "max-bootnodes")) { + config_->max_bootnodes_ = *max_bootnodes; + } find_argument( cli_values_map_, "base-path", [&](const std::string &value) { config_->base_path_ = value; diff --git a/src/blockchain/fork_choice.cpp b/src/blockchain/fork_choice.cpp index 8473eeb..a9865b5 100644 --- a/src/blockchain/fork_choice.cpp +++ b/src/blockchain/fork_choice.cpp @@ -301,7 +301,7 @@ namespace lean { auto time_since_genesis = now_sec - config_.genesis_time; std::vector> result{}; - while (time_ < time_since_genesis) { + while (time_ <= time_since_genesis) { Slot current_slot = time_ / INTERVALS_PER_SLOT; if (current_slot == 0) { // Skip actions for slot zero, which is the genesis slot @@ -408,11 +408,6 @@ namespace lean { const Checkpoint &root, const ForkChoiceStore::Votes &latest_votes, uint64_t min_score) { - // If no votes, return the starting root immediately - if (latest_votes.empty()) { - return root.root; - } - // For each block, count the number of votes for that block. A vote for // any descendant of a block also counts as a vote for that block std::unordered_map vote_weights; diff --git a/src/commands/generate_genesis.hpp b/src/commands/generate_genesis.hpp new file mode 100644 index 0000000..a912117 --- /dev/null +++ b/src/commands/generate_genesis.hpp @@ -0,0 +1,105 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include +#include + +#include + +#include "utils/sample_peer.hpp" + +inline int cmdGenerateGenesis(auto &&getArg) { + auto cmd = [](std::filesystem::path genesis_directory, + size_t validator_count, + bool shadow) { + auto build_yaml = [](std::filesystem::path path, auto &&build) { + std::ofstream file{path}; + YAML::Node yaml; + build(yaml); + file << yaml << "\n"; + file.close(); + }; + + auto now = shadow + ? std::chrono::seconds{946684800} + : std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()); + uint64_t genesis_time = (now + std::chrono::seconds{3}).count(); + + std::filesystem::create_directories(genesis_directory); + + build_yaml(genesis_directory / "config.yaml", [&](YAML::Node &yaml) { + yaml["GENESIS_TIME"] = genesis_time; + yaml["VALIDATOR_COUNT"] = validator_count; + }); + + auto node_id = [](size_t index) { return std::format("node_{}", index); }; + std::vector peers; + for (size_t index = 0; index < validator_count; ++index) { + peers.emplace_back(index, shadow); + } + + for (auto &peer : peers) { + std::ofstream node_key{genesis_directory + / std::format("{}.key", node_id(peer.index))}; + std::println(node_key, + "{}", + qtils::ByteView{peer.keypair.privateKey.data}.toHex()); + node_key.close(); + } + + build_yaml(genesis_directory / "nodes.yaml", [&](YAML::Node &yaml) { + for (auto &peer : peers) { + yaml.push_back(peer.enr); + } + }); + + build_yaml(genesis_directory / "validators.yaml", [&](YAML::Node &yaml) { + for (auto &peer : peers) { + yaml[node_id(peer.index)].push_back(peer.index); + } + }); + + build_yaml(genesis_directory / "validator-config.yaml", + [&](YAML::Node &yaml) { + yaml["shuffle"] = "roundrobin"; + for (auto &peer : peers) { + YAML::Node yaml_peer; + yaml_peer["name"] = node_id(peer.index); + yaml_peer["privkey"] = + qtils::ByteView{peer.keypair.privateKey.data}.toHex(); + auto yaml_enr = yaml_peer["enrFields"]; + yaml_enr["ip"] = lean::enr::toString(peer.enr_ip); + yaml_enr["quic"] = peer.port; + yaml_peer["count"] = 1; + yaml["validators"].push_back(yaml_peer); + } + }); + }; + if (auto arg_2 = getArg(2)) { + std::filesystem::path genesis_directory{*arg_2}; + if (auto arg_3 = getArg(3)) { + size_t validator_count = std::stoul(std::string{*arg_3}); + if (validator_count != 0) { + auto arg_4 = getArg(4); + auto shadow = arg_4 == "shadow"; + if (not arg_4 or shadow) { + cmd(genesis_directory, validator_count, shadow); + return EXIT_SUCCESS; + } + } + } + } + auto exe = std::filesystem::path{getArg(0).value()}.filename().string(); + std::println(std::cerr, + "Usage: {} generate-genesis (genesis_directory) " + "(validator_count) (shadow?)", + exe); + return EXIT_FAILURE; +} diff --git a/src/executable/cmd_key_generate_node_key.hpp b/src/commands/key_generate_node_key.hpp similarity index 100% rename from src/executable/cmd_key_generate_node_key.hpp rename to src/commands/key_generate_node_key.hpp diff --git a/src/executable/lean_node.cpp b/src/executable/lean_node.cpp index b59ce3a..7ac932d 100644 --- a/src/executable/lean_node.cpp +++ b/src/executable/lean_node.cpp @@ -17,7 +17,8 @@ #include "app/application.hpp" #include "app/configuration.hpp" #include "app/configurator.hpp" -#include "executable/cmd_key_generate_node_key.hpp" +#include "commands/generate_genesis.hpp" +#include "commands/key_generate_node_key.hpp" #include "injector/node_injector.hpp" #include "loaders/loader.hpp" #include "log/logger.hpp" @@ -130,14 +131,12 @@ int main(int argc, const char **argv, const char **env) { return EXIT_FAILURE; } - if (getArg(1) == "key") { - if (getArg(2) == "generate-node-key") { - cmdKeyGenerateNodeKey(); - return EXIT_SUCCESS; - } - std::println(std::cerr, "Expected one of following commands:"); - std::println(std::cerr, " qlean key generate-node-key"); - return EXIT_FAILURE; + if (getArg(1) == "key" and getArg(2) == "generate-node-key") { + cmdKeyGenerateNodeKey(); + return EXIT_SUCCESS; + } + if (getArg(1) == "generate-genesis") { + return cmdGenerateGenesis(getArg); } auto app_configurator = diff --git a/src/modules/networking/networking.cpp b/src/modules/networking/networking.cpp index 303e7e8..2ca7122 100644 --- a/src/modules/networking/networking.cpp +++ b/src/modules/networking/networking.cpp @@ -7,8 +7,10 @@ #include "modules/networking/networking.hpp" +#include #include #include +#include #include #include @@ -37,8 +39,6 @@ #include "modules/networking/types.hpp" namespace lean::modules { - // TODO(turuslan): gossip [from,seqno,signature,key]=None - inline auto gossipTopic(std::string_view type) { return std::format("/leanconsensus/devnet0/{}/ssz_snappy", type); } @@ -169,16 +169,35 @@ namespace lean::modules { // Add bootnodes from chain spec if (!bootnodes.empty()) { - SL_INFO(logger_, - "Adding {} bootnodes to address repository", - bootnodes.size()); + SL_INFO(logger_, "Found {} bootnodes in chain spec", bootnodes.size()); auto &address_repo = host->getPeerRepository().getAddressRepository(); - for (const auto &bootnode : bootnodes.getBootnodes()) { - if (bootnode.peer_id == peer_id) { + // Collect candidates (exclude ourselves) + auto all_bootnodes = bootnodes.getBootnodes(); + std::vector candidates; + candidates.reserve(all_bootnodes.size()); + for (const auto &b : all_bootnodes) { + if (b.peer_id == peer_id) { continue; } + candidates.push_back(b); + } + + // Randomly choose no more than `maxBootnodes` bootnodes to connect to. + if (config_->maxBootnodes().has_value() + and candidates.size() > *config_->maxBootnodes()) { + std::ranges::shuffle(candidates, std::default_random_engine{}); + // `resize` doesn't work without default constructor + candidates.erase(candidates.begin() + *config_->maxBootnodes(), + candidates.end()); + } + + SL_INFO(logger_, + "Adding {} bootnodes to address repository", + candidates.size()); + + for (const auto &bootnode : candidates) { std::vector addresses{bootnode.address}; // Add bootnode addresses with permanent TTL @@ -221,30 +240,55 @@ namespace lean::modules { SL_DEBUG(logger_, "No bootnodes configured"); } - auto on_peer_connected = - [weak_self{weak_from_this()}]( - std::weak_ptr - weak_connection) { - auto connection = weak_connection.lock(); - if (not connection) { - return; - } - auto self = weak_self.lock(); - if (not self) { - return; - } - auto peer_id = connection->remotePeer(); - self->loader_.dispatch_peer_connected( - qtils::toSharedPtr(messages::PeerConnectedMessage{peer_id})); - if (connection->isInitiator()) { - libp2p::coroSpawn( - *self->io_context_, - [status_protocol{self->status_protocol_}, - connection]() -> libp2p::Coro { - std::ignore = co_await status_protocol->connect(connection); - }); - } - }; + // Restore peer connection handlers and protocol startup + auto on_peer_connected = [host, weak_self{weak_from_this()}]( + std::weak_ptr< + libp2p::connection::CapableConnection> + weak_connection) { + auto connection = weak_connection.lock(); + if (not connection) { + return; + } + auto self = weak_self.lock(); + if (not self) { + return; + } + auto peer_id = connection->remotePeer(); + self->loader_.dispatch_peer_connected( + qtils::toSharedPtr(messages::PeerConnectedMessage{peer_id})); + if (connection->isInitiator()) { + libp2p::coroSpawn(*self->io_context_, + [status_protocol{self->status_protocol_}, + connection]() -> libp2p::Coro { + std::ignore = + co_await status_protocol->connect(connection); + }); + } else { + // Non-initiator: record remote address in peer repository + auto addr_res = connection->remoteMultiaddr(); + if (!addr_res.has_value()) { + SL_WARN( + self->logger_, "remoteMultiaddr() failed: {}", addr_res.error()); + return; + } + + std::vector addrs; + addrs.emplace_back(addr_res.value()); + + if (auto result = + host->getPeerRepository().getAddressRepository().addAddresses( + peer_id, + std::span(addrs), + libp2p::peer::ttl::kRecentlyConnected); + not result.has_value()) { + SL_WARN(self->logger_, + "Failed to add addresses for peer {}: {}", + peer_id, + result.error()); + } + } + }; + auto on_peer_disconnected = [weak_self{weak_from_this()}](libp2p::PeerId peer_id) { auto self = weak_self.lock(); @@ -254,6 +298,7 @@ namespace lean::modules { self->loader_.dispatch_peer_disconnected( qtils::toSharedPtr(messages::PeerDisconnectedMessage{peer_id})); }; + on_peer_connected_sub_ = host->getBus() .getChannel() @@ -297,16 +342,20 @@ namespace lean::modules { identify_->start(); gossip_blocks_topic_ = gossipSubscribe( - "block", [weak_self{weak_from_this()}](SignedBlock &&block) { + "block", + [weak_self{weak_from_this()}]( + SignedBlock &&block, std::optional received_from) { auto self = weak_self.lock(); if (not self) { return; } block.message.setHash(); - self->receiveBlock(std::nullopt, std::move(block)); + self->receiveBlock(received_from, std::move(block)); }); gossip_votes_topic_ = gossipSubscribe( - "vote", [weak_self{weak_from_this()}](SignedVote &&signed_vote) { + "vote", + [weak_self{weak_from_this()}](SignedVote &&signed_vote, + std::optional) { auto self = weak_self.lock(); if (not self) { return; @@ -320,9 +369,9 @@ namespace lean::modules { res.error()); return; } - SL_INFO(self->logger_, - "Received vote for target {}", - signed_vote.data.target); + SL_DEBUG(self->logger_, + "Received vote for target {}", + signed_vote.data.target); }); io_thread_.emplace([io_context{io_context_}] { @@ -358,13 +407,10 @@ namespace lean::modules { libp2p::coroSpawn( *io_context_, [this, type, topic, f{std::move(f)}]() -> libp2p::Coro { - while (auto raw_result = co_await topic->receive()) { + while (auto raw_result = co_await topic->receiveMessage()) { auto &raw = raw_result.value(); - if (auto uncompressed_res = snappyUncompress(raw)) { - auto &uncompressed = uncompressed_res.value(); - if (auto r = decode(uncompressed)) { - f(std::move(r.value())); - } + if (auto r = decodeSszSnappy(raw.data)) { + f(std::move(r.value()), raw.received_from); } } }); @@ -418,11 +464,11 @@ namespace lean::modules { void NetworkingImpl::receiveBlock(std::optional from_peer, SignedBlock &&signed_block) { auto slot_hash = signed_block.message.slotHash(); - SL_DEBUG(logger_, - "receiveBlock slot {} hash {} parent {}", - slot_hash.slot, - slot_hash.hash, - signed_block.message.parent_root); + SL_INFO(logger_, + "receiveBlock slot {} hash {} parent {}", + slot_hash.slot, + slot_hash.hash, + signed_block.message.parent_root); auto remove = [&](auto f) { std::vector queue{slot_hash.hash}; while (not queue.empty()) { diff --git a/src/serde/enr.cpp b/src/serde/enr.cpp index d1cc935..809e12d 100644 --- a/src/serde/enr.cpp +++ b/src/serde/enr.cpp @@ -8,7 +8,11 @@ #include +#include +#include #include +#include +#include #include #include @@ -252,20 +256,43 @@ namespace lean::rlp { namespace lean::enr { enum class Error { + EXPECTED_SECP256K1_KEYPAIR, INVALID_PREFIX, INVALID_ID, + SIGNATURE_VERIFICATION_FAILED, }; Q_ENUM_ERROR_CODE(Error) { using E = decltype(e); switch (e) { + case E::EXPECTED_SECP256K1_KEYPAIR: + return "Expected secp256k1 keypair"; case E::INVALID_PREFIX: return "Invalid ENR prefix"; case E::INVALID_ID: return "Invalid ENR id"; + case E::SIGNATURE_VERIFICATION_FAILED: + return "Signature verification failed"; } abort(); } + Ip makeIp(uint32_t i) { + Ip ip; + boost::endian::store_big_u32(ip.data(), i); + return ip; + } + + Ip makeIp(std::string_view base, uint32_t i) { + auto ip_host_endian = boost::asio::ip::make_address_v4(base).to_uint(); + return makeIp(ip_host_endian + i); + } + + std::string toString(const Ip &ip) { + auto ip_host_endian = boost::endian::load_big_u32(ip.data()); + auto ip_asio = boost::asio::ip::make_address_v4(ip_host_endian); + return ip_asio.to_string(); + } + libp2p::PeerId Enr::peerId() const { return libp2p::peerIdFromSecp256k1(public_key); } @@ -279,11 +306,8 @@ namespace lean::enr { libp2p::Multiaddress Enr::connectAddress() const { auto &ip = this->ip.value(); return libp2p::Multiaddress::create( - std::format("/ip4/{}.{}.{}.{}/udp/{}/quic-v1/p2p/{}", - ip[0], - ip[1], - ip[2], - ip[3], + std::format("/ip4/{}/udp/{}/quic-v1/p2p/{}", + toString(ip), port.value(), peerId().toBase58())) .value(); @@ -293,6 +317,24 @@ namespace lean::enr { return {peerId(), {connectAddress()}}; } + inline void encodeContent(rlp::Encoder &rlp, const Enr &enr) { + rlp.uint(enr.sequence); + rlp.str("id"); + rlp.str("v4"); + rlp.str("ip"); + rlp.bytes(enr.ip.value()); + rlp.str("quic"); + rlp.uint(enr.port.value()); + rlp.str("secp256k1"); + rlp.bytes(enr.public_key); + } + + qtils::ByteVec Enr::signable() const { + rlp::Encoder rlp; + encodeContent(rlp, *this); + return rlp.list(); + } + outcome::result decode(std::string_view str) { constexpr std::string_view s_enr{"enr:"}; if (not str.starts_with(s_enr)) { @@ -333,22 +375,48 @@ namespace lean::enr { BOOST_OUTCOME_TRY(enr.port, kv_quic->second.uint()); } + libp2p::crypto::secp256k1::Secp256k1ProviderImpl secp256k1{nullptr}; + BOOST_OUTCOME_TRY( + auto valid_signature, + secp256k1.verifyCompact(libp2p::Keccak::hash(enr.signable()), + enr.signature, + enr.public_key)); + if (not valid_signature) { + return Error::SIGNATURE_VERIFICATION_FAILED; + } + return enr; } - std::string encode(const Secp256k1PublicKey &public_key, Port port) { - Enr enr{Secp256k1Signature{}, 1, public_key, Ip{127, 0, 0, 1}, port}; + outcome::result encode(const libp2p::crypto::KeyPair &keypair, + Ip ip, + Port port) { + if (keypair.privateKey.type != libp2p::crypto::Key::Type::Secp256k1) { + return Error::EXPECTED_SECP256K1_KEYPAIR; + } + if (keypair.publicKey.type != libp2p::crypto::Key::Type::Secp256k1) { + return Error::EXPECTED_SECP256K1_KEYPAIR; + } + BOOST_OUTCOME_TRY(auto private_key, + Secp256k1PrivateKey::fromSpan(keypair.privateKey.data)); + BOOST_OUTCOME_TRY(auto public_key, + Secp256k1PublicKey::fromSpan(keypair.publicKey.data)); + + Enr enr{ + .sequence = 1, + .public_key = public_key, + .ip = ip, + .port = port, + }; + + libp2p::crypto::secp256k1::Secp256k1ProviderImpl secp256k1{nullptr}; + enr.signature = Secp256k1Signature{ + secp256k1.signCompact(libp2p::Keccak::hash(enr.signable()), private_key) + .value()}; + rlp::Encoder rlp; rlp.bytes(enr.signature); - rlp.uint(enr.sequence); - rlp.str("id"); - rlp.str("v4"); - rlp.str("ip"); - rlp.bytes(enr.ip.value()); - rlp.str("quic"); - rlp.uint(enr.port.value()); - rlp.str("secp256k1"); - rlp.bytes(enr.public_key); + encodeContent(rlp, enr); return "enr:" + cppcodec::base64_url_unpadded::encode(rlp.list()); } } // namespace lean::enr diff --git a/src/serde/enr.hpp b/src/serde/enr.hpp index 096a273..1a48852 100644 --- a/src/serde/enr.hpp +++ b/src/serde/enr.hpp @@ -12,13 +12,22 @@ #include #include +namespace libp2p::crypto { + struct KeyPair; +} // namespace libp2p::crypto + namespace lean::enr { using Secp256k1Signature = qtils::ByteArr<64>; using Sequence = uint64_t; + using Secp256k1PrivateKey = qtils::ByteArr<32>; using Secp256k1PublicKey = qtils::ByteArr<33>; using Ip = qtils::ByteArr<4>; using Port = uint16_t; + Ip makeIp(uint32_t i); + Ip makeIp(std::string_view base, uint32_t i); + std::string toString(const Ip &ip); + struct Enr { Secp256k1Signature signature; Sequence sequence; @@ -30,9 +39,13 @@ namespace lean::enr { libp2p::Multiaddress listenAddress() const; libp2p::Multiaddress connectAddress() const; libp2p::PeerInfo connectInfo() const; + + qtils::ByteVec signable() const; }; outcome::result decode(std::string_view str); - std::string encode(const Secp256k1PublicKey &public_key, Port port); + outcome::result encode(const libp2p::crypto::KeyPair &keypair, + Ip ip, + Port port); } // namespace lean::enr diff --git a/src/utils/sample_peer.hpp b/src/utils/sample_peer.hpp index f1cbf34..7abd68c 100644 --- a/src/utils/sample_peer.hpp +++ b/src/utils/sample_peer.hpp @@ -12,14 +12,28 @@ namespace lean { struct SamplePeer : libp2p::SamplePeer { - SamplePeer(size_t index) : libp2p::SamplePeer{makeSecp256k1(index)} {} - - std::string enr() const { - enr::Secp256k1PublicKey public_key; - assert(keypair.publicKey.data.size() == public_key.size()); - memcpy( - public_key.data(), keypair.publicKey.data.data(), public_key.size()); - return enr::encode(public_key, port); + static enr::Ip makeIp(size_t index, bool shadow) { + if (shadow) { + return enr::makeIp("10.0.0.0", index); + } + return enr::makeIp("127.0.0.1", 0); } + + SamplePeer(size_t index, bool shadow) + : libp2p::SamplePeer{ + index, + enr::toString(makeIp(index, shadow)), + samplePort(index), + Secp256k1, + }, + enr_ip{makeIp(index,shadow)}, + enr{enr::encode( + keypair, + enr_ip, + port + ).value()} {} + + enr::Ip enr_ip; + std::string enr; }; } // namespace lean diff --git a/tests/unit/blockchain/fork_choice_test.cpp b/tests/unit/blockchain/fork_choice_test.cpp index 7c20d1c..4b98ce5 100644 --- a/tests/unit/blockchain/fork_choice_test.cpp +++ b/tests/unit/blockchain/fork_choice_test.cpp @@ -267,16 +267,22 @@ TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_with_votes) { EXPECT_EQ(head, target.hash()); } -// Test get_fork_choice_head with no votes returns the root. -TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_no_votes) { +/** + * Test fork choice algorithm with no attestations walks to the leaf. + * + * With no attestations, fork choice should walk down the tree and select the + * leaf block (the furthest descendant), breaking ties by lexicographic hash. + */ +TEST(TestForkChoiceHeadFunction, test_fork_choice_no_attestations) { auto blocks = makeBlocks(3); auto &root = blocks.at(0); + auto &leaf = blocks.at(2); ForkChoiceStore::Votes empty_votes; auto head = getForkChoiceHead( makeBlockMap(blocks), Checkpoint::from(root), empty_votes, 0); - EXPECT_EQ(head, root.hash()); + EXPECT_EQ(head, leaf.hash()); } // Test get_fork_choice_head respects minimum score. diff --git a/tests/unit/serde/enr_test.cpp b/tests/unit/serde/enr_test.cpp index 7a4d969..dc8c041 100644 --- a/tests/unit/serde/enr_test.cpp +++ b/tests/unit/serde/enr_test.cpp @@ -13,33 +13,19 @@ #include +#include "utils/sample_peer.hpp" + using lean::enr::Enr; using lean::enr::Ip; using lean::enr::Port; using lean::enr::Secp256k1PublicKey; using lean::enr::Secp256k1Signature; -namespace { - - // Known valid compressed secp256k1 public key: generator point G - static constexpr std::array kCompressedG{ - 0x02, 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, - 0x62, 0x95, 0xCE, 0x87, 0x0B, 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, - 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, 0x17, 0x98}; - - Secp256k1PublicKey makeKey() { - Secp256k1PublicKey k{}; - std::copy(kCompressedG.begin(), kCompressedG.end(), k.begin()); - return k; - } - -} // namespace - TEST(EnrTest, EncodeDecodeRoundTrip) { - auto pub = makeKey(); - Port port = 9000; + lean::SamplePeer peer{0, false}; - auto encoded = lean::enr::encode(pub, port); + ASSERT_OUTCOME_SUCCESS( + encoded, lean::enr::encode(peer.keypair, peer.enr_ip, peer.port)); // Encoded string must start with "enr:" ASSERT_TRUE(encoded.rfind("enr:", 0) == 0) << encoded; @@ -47,12 +33,7 @@ TEST(EnrTest, EncodeDecodeRoundTrip) { // Basic fields EXPECT_EQ(enr.sequence, 1u); - EXPECT_EQ(enr.public_key, pub); - - // Signature is all-zero (not actually signed) - EXPECT_TRUE(std::all_of(enr.signature.begin(), - enr.signature.end(), - [](uint8_t b) { return b == 0; })); + EXPECT_EQ(SpanAdl{enr.public_key}, peer.keypair.publicKey.data); // IP and port present and correct ASSERT_TRUE(enr.ip.has_value()); @@ -61,7 +42,7 @@ TEST(EnrTest, EncodeDecodeRoundTrip) { EXPECT_EQ(enr.ip.value(), expected_ip); ASSERT_TRUE(enr.port.has_value()); - EXPECT_EQ(enr.port.value(), port); + EXPECT_EQ(enr.port.value(), peer.port); // PeerId must be derivable and non-empty auto pid = enr.peerId().toBase58(); @@ -73,20 +54,23 @@ TEST(EnrTest, EncodeDecodeRoundTrip) { } TEST(EnrTest, DeterministicEncoding) { - auto pub = makeKey(); - Port port = 12345; - auto e1 = lean::enr::encode(pub, port); - auto e2 = lean::enr::encode(pub, port); + lean::SamplePeer peer{0, false}; + lean::SamplePeer peer2{1, false}; + + ASSERT_OUTCOME_SUCCESS( + e1, lean::enr::encode(peer.keypair, peer.enr_ip, peer.port)); + ASSERT_OUTCOME_SUCCESS( + e2, lean::enr::encode(peer.keypair, peer.enr_ip, peer.port)); EXPECT_EQ(e1, e2); // Changing port changes ENR - auto e3 = lean::enr::encode(pub, static_cast(port + 1)); + ASSERT_OUTCOME_SUCCESS( + e3, lean::enr::encode(peer.keypair, peer.enr_ip, peer2.port)); EXPECT_NE(e1, e3); // Changing key changes ENR - auto pub2 = pub; - pub2[32] ^= 0x01; // tweak - auto e4 = lean::enr::encode(pub2, port); + ASSERT_OUTCOME_SUCCESS( + e4, lean::enr::encode(peer2.keypair, peer.enr_ip, peer.port)); EXPECT_NE(e1, e4); } diff --git a/vcpkg-overlay/leanp2p/portfile.cmake b/vcpkg-overlay/leanp2p/portfile.cmake index ad30b84..5da83a5 100644 --- a/vcpkg-overlay/leanp2p/portfile.cmake +++ b/vcpkg-overlay/leanp2p/portfile.cmake @@ -2,8 +2,8 @@ vcpkg_check_linkage(ONLY_STATIC_LIBRARY) vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO qdrvm/leanp2p - REF refs/tags/v0.0.2 - SHA512 72a82d313ca84fe0095c755daa733cfdd09609080a6a9f627587355d57d0bf4a34448884282f29ebd1e23fcc21d8f5181ada63ff3d87772e060e5e9f554406a2 + REF refs/tags/v0.0.4 + SHA512 9ab9ab55b08306198d2bdaf3b80086126b06b2ac04e6f795e22cb1ba7a44f4ae8d8505183622ba91abc1d0c4e65f3148939e5ccdbef7829b5b625a8bf87139fd ) vcpkg_cmake_configure(SOURCE_PATH "${SOURCE_PATH}") vcpkg_cmake_install()