From 3c473c7ccb2243211a54a8d6467ea0499c12401c Mon Sep 17 00:00:00 2001 From: frstrtr Date: Tue, 21 Jul 2026 10:20:57 +0400 Subject: [PATCH 1/2] dash(embedded): port DASH-isolated CoinPeerManager for network-standalone coin arm The DASH embedded coin arm carried no peer-scoring: a single static --coin-p2p-connect target (usually the local dashd itself), so its network view was not independent of that daemon. This lands a DASH-isolated port of the peer-prioritization subsystem (the c2pool port of the Python p2pool broadcaster, live today only on the merged LTC+DOGE path) so the embedded arm reaches the Dash network on its own scored, group-diverse peer set. New src/impl/dash/coin/coin_peer_manager.hpp (dash::coin::DashCoinPeerManager): source scoring (addr-crawl +50 / daemon-learned -20), daemon-peer overlap filter, /16-/32 Sybil group caps, anchors (partition resistance), protected pinned local node, DNS/fixed/HTTP seed discovery, JSON peer-db persistence. Wired via new opt-in --coin-p2p-discover in main_dash.cpp: the single-connection E1 CoinClient dials a scored+diverse rotation set fed by the manager, refreshed every 60s; addr-crawl (getaddr on handshake) and connect/disconnect feed scoring and anchors through two new CoinClient seams. An explicit --coin-p2p-connect peer is registered as the pinned/preferred protected node (redundant block-relay leg) alongside the discovered set. --coin-p2p-discover without --coin-p2p-connect is fully daemonless (seeds only). With neither flag the path is byte-unchanged. Isolation fence: DASH-local self-contained copy, NOT a reuse of the shared merged tree (honours the single-coin fence that caused the original drop). The duplication against src/c2pool/merged/coin_peer_manager.hpp is a known Consensus-neutral: DASH-only, no shared-core / other-coin / share-consensus surface. Uses real DASH mainnet seeds (dnsseed.dash.org, fixed seeds), port 9999, magic bf0c6bbd. KAT test/test_dash_coin_peer_manager.cpp (folded into the allowlisted test_dash_p2p_node target): source-scoring daemon-penalty swing, selection ordering, /16 Sybil cap, anchor persistence across reload, pinned-node prune survival, canonical seed sanity. Builds clean; 22/22 in the target pass. --- src/c2pool/main_dash.cpp | 131 ++- src/impl/dash/coin/coin_peer_manager.hpp | 1113 ++++++++++++++++++++++ src/impl/dash/coin/p2p_client.hpp | 32 + test/CMakeLists.txt | 6 +- test/test_dash_coin_peer_manager.cpp | 207 ++++ 5 files changed, 1471 insertions(+), 18 deletions(-) create mode 100644 src/impl/dash/coin/coin_peer_manager.hpp create mode 100644 test/test_dash_coin_peer_manager.cpp diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index ad724054e..8338ae7ed 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -55,6 +55,8 @@ #include // dash.conf creds resolution (rpcpassword off argv) #include #include // dash::coin::p2p::CoinClient — OPT-IN coin-network dial (E1, --coin-p2p-connect) +#include // dash::coin::DashCoinPeerManager — DASH-ISOLATED scored/diverse peer discovery (--coin-p2p-discover; network-standalone gate) +#include // dash::coin::dash_dns_seeds / dash_fixed_seeds — DASH mainnet/testnet seed bootstrap #include // dash::coin::broadcast_won_block — S8 dual-path won-block dispatcher (embedded P2P primary + submitblock RPC backup) #include // dash::coin::TipHashDedup / ZmqHashblockSubscriber — dashd ZMQ hashblock INSTANT tip-notify (opt-in, hardening on the #770 poll) #include // dash::coin::select_coin_p2p_magic — E5 --coin-p2p-magic override (regtest ARM A dial) @@ -205,7 +207,7 @@ void print_banner(const char* argv0) << " " << argv0 << " --run [--coin-rpc H:P] [--coin-rpc-auth PATH]\n" << " [--testnet] [--submit-block HEX | --submit-block-file PATH]\n" << " [--listen [HOST:]PORT] [--addnode HOST:PORT]... [--connect HOST:PORT]...\n" - << " [--stratum [HOST:]PORT] [--coin-p2p-connect HOST:PORT]...\n" + << " [--stratum [HOST:]PORT] [--coin-p2p-connect HOST:PORT]... [--coin-p2p-discover]\n" << " [--web-port PORT] [--web-host ADDR] [--dashboard-dir PATH]\n" << " [--external-ip ADDR]\n" << " [--embedded-utxo]\n" @@ -236,6 +238,12 @@ void print_banner(const char* argv0) << " (default mainnet bf0c6bbd / testnet cee2caff; regtest fcc1b7dc).\n" << " --regtest-force-won-block (regtest E5 harness, fail-closed) drives\n" << " ONE real won block through the run-path dual-path dispatch.\n" + << " --coin-p2p-discover arms the DASH-isolated peer manager: seed\n" + << " (dnsseed.dash.org + fixed) bootstrap, source-scored + group-diverse\n" + << " (Sybil-capped) peer selection, anchors, and a self-healing dial\n" + << " rotation INDEPENDENT of the local dashd — the network-standalone\n" + << " arm (daemonless witness). Any --coin-p2p-connect peer is kept as a\n" + << " pinned/preferred node alongside the discovered set.\n" << " --web-port PORT (alias --http-port, default 8080) serves the FULL\n" << " c2pool web dashboard + JSON API on --web-host (default 0.0.0.0)\n" << " from --dashboard-dir (default web-static); --web-port 0 disables.\n" @@ -413,6 +421,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, const std::string& web_host, uint16_t web_port, const std::string& dashboard_dir, const std::vector& coin_p2p_targets, + bool coin_p2p_discover, bool embedded_utxo, double dev_donation, double node_owner_fee, const std::string& node_owner_address, @@ -1064,7 +1073,13 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // sharechain node and stratum; declared after config/coin_state (both of // which it borrows), so it is destroyed before them at scope exit. std::unique_ptr> coin_p2p; - if (!coin_p2p_targets.empty()) { + // DASH-ISOLATED scored/diverse peer discovery (--coin-p2p-discover). Owns + // its own peer table + seed bootstrap + scoring; feeds the single embedded + // connection an INDEPENDENT (non-dashd) diverse peer set. Declared BEFORE + // coin_p2p so it outlives the client that borrows it (reverse-destruction). + std::unique_ptr coin_peer_mgr; + std::unique_ptr coin_dial_refresh_timer; + if (!coin_p2p_targets.empty() || coin_p2p_discover) { config.coin()->m_testnet = testnet; // Coin-network wire magic (dashd pchMessageStart). Default: mainnet // bf0c6bbd / testnet cee2caff. A dev regtest dashd uses a DISTINCT magic @@ -1075,21 +1090,94 @@ int run_node(bool testnet, const std::string& rpc_endpoint, const std::string net_magic_hex = dash::coin::select_coin_p2p_magic(coin_p2p_magic_hex, testnet); config.coin()->m_p2p.prefix = ParseHexBytes(net_magic_hex); - config.coin()->m_p2p.address = coin_p2p_targets.front(); + if (!coin_p2p_targets.empty()) + config.coin()->m_p2p.address = coin_p2p_targets.front(); coin_p2p = std::make_unique>( &ioc, &coin_state, &config, "COIN-P2P"); - coin_p2p->connect(coin_p2p_targets); - std::cout << "[run] coin-network P2P client dialing " - << coin_p2p_targets.front().to_string() - << (coin_p2p_targets.size() > 1 - ? " (+" + std::to_string(coin_p2p_targets.size() - 1) - + " alternate[s], reconnect rotates)" - : "") - << " magic=" << net_magic_hex - << " proto=70230 (E1: dial+handshake+keep-alive only;\n" - "[run] ingest legs are later slices — templates still source from\n" - "[run] the dashd-RPC fallback until NodeCoinState is fed)\n"; + + if (coin_p2p_discover) { + // ── Network-standalone arm: seed-discovered, SCORED, group-diverse + // peer set INDEPENDENT of the local dashd. The pinned local dashd + // (--coin-p2p-connect front target, if any) is registered as the + // PROTECTED/preferred peer that carries the redundant block-relay + // leg; it coexists with — never suppresses — the discovered set. + const uint16_t coin_port = testnet ? 19999 : 9999; + dash::coin::DashPeerManagerConfig pm_cfg; + pm_cfg.valid_ports = { coin_port }; + const std::string pm_data_dir = (core::filesystem::config_path() + / net_subdir / "dash_embedded_peers").string(); + coin_peer_mgr = std::make_unique( + ioc, "DASH", pm_data_dir, pm_cfg); + + // Pin the local dashd (if supplied) as the protected preferred peer. + std::string pinned_str = "(none — fully daemonless)"; + if (!coin_p2p_targets.empty()) { + if (coin_peer_mgr->set_local_node(coin_p2p_targets.front())) + pinned_str = coin_p2p_targets.front().to_string(); + } + coin_peer_mgr->set_dns_seeds(dash::coin::dash_dns_seeds(testnet)); + coin_peer_mgr->set_fixed_seeds(dash::coin::dash_fixed_seeds(testnet)); + coin_peer_mgr->start(); + + // addr-crawl discoveries feed back into the manager (source-scored + // +50); handshake connect/disconnect drive scoring + anchors. + coin_p2p->set_addr_callback( + [mgr = coin_peer_mgr.get()](const std::vector& addrs) { + for (auto& a : addrs) mgr->add_discovered_peer(a); + }); + coin_p2p->set_on_peer_connected( + [mgr = coin_peer_mgr.get()](const NetService& s) { + mgr->notify_connected(s.to_string()); + }); + coin_p2p->set_on_peer_disconnected( + [mgr = coin_peer_mgr.get()](const NetService& s) { + mgr->notify_disconnected(s.to_string()); + }); + + // Initial dial plan: pinned local dashd first (preferred), then the + // top scored+diverse discovered peers. + std::vector dial; + for (auto& t : coin_p2p_targets) dial.push_back(t); + for (auto& ep : coin_peer_mgr->get_peers_to_connect({})) + dial.push_back(ep.to_net_service()); + coin_p2p->connect(dial); + + // Periodic dial-plan refresh (60s): rebuild from the freshly-scored, + // group-diverse set so newly-discovered high-score peers enter the + // rotation on the next reconnect — the self-healing witness loop. + coin_dial_refresh_timer = std::make_unique(&ioc, /*repeat=*/true); + coin_dial_refresh_timer->start(60, [cp = coin_p2p.get(), + mgr = coin_peer_mgr.get(), + pinned = coin_p2p_targets]() { + std::vector refreshed = pinned; // pinned always preferred + for (auto& ep : mgr->get_peers_to_connect({})) + refreshed.push_back(ep.to_net_service()); + cp->update_dial_targets(std::move(refreshed)); + }); + + std::cout << "[run] coin-network P2P DISCOVERY armed (--coin-p2p-discover): " + "DASH-isolated scored/diverse peer set, pinned=" + << pinned_str << " magic=" << net_magic_hex + << " proto=70230 dns_seeds=" << dash::coin::dash_dns_seeds(testnet).size() + << " fixed_seeds=" << dash::coin::dash_fixed_seeds(testnet).size() + << " initial_dial=" << dial.size() << " target[s]\n" + "[run] (network-standalone witness: independent peers -> independent\n" + "[run] mempool/relay view; oracle-shadow standalone graduation gate)\n"; + } else { + // Legacy single/pinned dial (--coin-p2p-connect only, no discovery). + coin_p2p->connect(coin_p2p_targets); + std::cout << "[run] coin-network P2P client dialing " + << coin_p2p_targets.front().to_string() + << (coin_p2p_targets.size() > 1 + ? " (+" + std::to_string(coin_p2p_targets.size() - 1) + + " alternate[s], reconnect rotates)" + : "") + << " magic=" << net_magic_hex + << " proto=70230 (E1: dial+handshake+keep-alive only;\n" + "[run] ingest legs are later slices — templates still source from\n" + "[run] the dashd-RPC fallback until NodeCoinState is fed)\n"; + } } // ── S8 miner-facing Stratum accept-loop standup (run-path caller) ───── @@ -2434,11 +2522,17 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // Kick the initial sync once the version/verack handshake completes: // getheaders off our current locator + a mempool prime. coin_p2p->set_on_handshake_complete( - [cp = coin_p2p.get(), hc = header_chain.get(), sml_base]() { + [cp = coin_p2p.get(), hc = header_chain.get(), sml_base, + discover = coin_p2p_discover]() { LOG_INFO << "[EMB-DASH] handshake complete -> initial sync:" - " getheaders + mempool + mnlistdiff(cold)"; + " getheaders + mempool + mnlistdiff(cold)" + << (discover ? " + getaddr (peer crawl)" : ""); cp->send_getheaders(70230, hc->get_locator(), uint256::ZERO); cp->send_mempool(); + // Peer-crawl: getaddr feeds set_addr_callback -> the isolated + // peer manager (addr-crawl source, +50 scored) so the diverse + // independent peer set grows off live wire discovery. + if (discover) cp->send_getaddr(); // Cold-start SML sync: full snapshot (base=ZERO) up to our best // known header tip. Steady-state incremental diffs then ride the // tip-changed driver. If the header chain is still empty at @@ -3015,6 +3109,7 @@ int main(int argc, char** argv) std::vector addnode_raw; // --addnode HOST:PORT (persistent outbound) std::vector connect_raw; // --connect HOST:PORT (connect-only) std::vector coin_p2p_raw; // --coin-p2p-connect HOST:PORT (repeatable; E1 opt-in coin-network dial) + bool coin_p2p_discover = false; // --coin-p2p-discover: DASH-isolated scored/diverse peer discovery (network-standalone arm; independent of local dashd) bool no_p2p_relay = false; // --no-p2p-relay: suppress the embedded P2P-relay won-block arm (A/B isolation; RPC backup stays live) bool embedded_mainnet = false; // --embedded-mainnet: gate-lift, allow the daemonless embedded template arm on MAINNET (byte-parity proven; default OFF = dashd fallback) std::string coin_p2p_magic = ""; // --coin-p2p-magic HEX: override the embedded CoinClient wire magic (e.g. regtest fcc1b7dc); default mainnet/testnet @@ -3079,6 +3174,8 @@ int main(int argc, char** argv) connect_raw.emplace_back(argv[++i]); else if (std::strcmp(argv[i], "--coin-p2p-connect") == 0 && i + 1 < argc) coin_p2p_raw.emplace_back(argv[++i]); + else if (std::strcmp(argv[i], "--coin-p2p-discover") == 0) + coin_p2p_discover = true; else if (std::strcmp(argv[i], "--no-p2p-relay") == 0) no_p2p_relay = true; else if (std::strcmp(argv[i], "--embedded-mainnet") == 0) @@ -3200,7 +3297,7 @@ int main(int argc, char** argv) } return run_node(testnet, rpc_endpoint, rpc_conf_path, submit_hex, peer, stratum_host, stratum_port, web_host, web_port, - dashboard_dir, coin_p2p_targets, + dashboard_dir, coin_p2p_targets, coin_p2p_discover, embedded_utxo, dev_donation, node_owner_fee, node_owner_address, redistribute_mode, no_p2p_relay, embedded_mainnet, diff --git a/src/impl/dash/coin/coin_peer_manager.hpp b/src/impl/dash/coin/coin_peer_manager.hpp new file mode 100644 index 000000000..07bab4e23 --- /dev/null +++ b/src/impl/dash/coin/coin_peer_manager.hpp @@ -0,0 +1,1113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// DASH-ISOLATED multi-peer manager for the embedded coin-daemon P2P arm. +/// +/// This is the network-standalone gate for a fully daemonless c2pool-dash: +/// it lets the embedded CoinClient (coin/p2p_client.hpp) reach the Dash +/// network on its OWN scored, group-diverse peer set — independent of the +/// local dashd — so the embedded arm becomes a genuinely independent network +/// witness (its own mempool/relay/tip view), which is the oracle-shadow's +/// network-standalone graduation gate and the precondition for disabling the +/// external dashd entirely. +/// +/// Behaviour follows the Python p2pool broadcaster design (chain-agnostic +/// source-scoring): daemon-learned peers are penalised (-20), addr-crawl +/// peers are preferred (+50), the daemon's own peer set is tracked for an +/// overlap filter, /16 (IPv4) and /32 (IPv6) network-group caps give Sybil +/// resistance, anchor peers give partition resistance across restarts, and +/// DNS/fixed seeds bootstrap discovery. +/// +/// ISOLATION FENCE (deliberate duplication): this is a SELF-CONTAINED copy of +/// src/c2pool/merged/coin_peer_manager.hpp, NOT a reuse of the shared merged +/// tree. The merged manager was DELIBERATELY NOT ported onto DASH to preserve +/// the single-coin isolation fence (DASH lives entirely under src/impl/dash, +/// no coupling to the LTC/DOGE merged-mining tree). Honouring that fence here +/// means an isolated copy. The duplication against the merged manager is a +/// KNOWN #759-class consolidation follow-up (unify the two behind a shared +/// core/ peer-manager once the fence can be lifted safely) — tracked, not +/// resolved here, because breaking the fence now is out of scope. +/// +/// Depends only on shared CORE primitives (NetService / PeerEndpoint / +/// AddrClass / DnsSeeder) — those are core/, not the merged tree, so reusing +/// them does not cross the fence. +/// +/// Header-only to match the sibling dash coin leaves (coin/*.hpp). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +// ─── Network group for Sybil resistance ───────────────────────────────────── +// IPv4: /16 prefix (first two octets, e.g. "192.168") +// IPv6: /32 prefix (first 4 bytes hex) +// Unresolvable: the full IP string as its own group +inline std::string peer_network_group(const std::string& ip) +{ + // Try IPv4: "a.b.c.d" → "a.b" + boost::system::error_code ec; + auto addr = boost::asio::ip::make_address(ip, ec); + if (!ec) { + if (addr.is_v4()) { + auto bytes = addr.to_v4().to_bytes(); + return std::to_string(bytes[0]) + "." + std::to_string(bytes[1]); + } + if (addr.is_v6()) { + auto v6 = addr.to_v6(); + if (v6.is_v4_mapped()) { + auto v4 = boost::asio::ip::make_address_v4( + boost::asio::ip::v4_mapped, v6).to_bytes(); + return std::to_string(v4[0]) + "." + std::to_string(v4[1]); + } + // /32 prefix: first 4 bytes + auto bytes = v6.to_bytes(); + char buf[12]; + std::snprintf(buf, sizeof(buf), "%02x%02x:%02x%02x", + bytes[0], bytes[1], bytes[2], bytes[3]); + return std::string(buf); + } + } + return ip; // fallback: treat entire IP as unique group +} + +// ─── Peer info tracked per endpoint ────────────────────────────────────────── +struct DashPeerInfo +{ + enum class Source { coind, addr_crawl, manual, dns_seed, fixed_seed }; + + NetService address; + AddrClass addr_class{AddrClass::invalid}; // cached classification (Bitcoin Core style) + int score{0}; + Source source{Source::coind}; + bool is_protected{false}; // local dashd node — never drop + bool in_tried{false}; // successfully connected at least once + std::string network_group; // /16 IPv4 or /32 IPv6 group + + // Timing + std::chrono::steady_clock::time_point first_seen; + std::chrono::steady_clock::time_point last_seen; + std::chrono::steady_clock::time_point last_attempt; + + // Backoff state + int backoff_sec{30}; + int attempt_count{0}; + int max_attempts{10}; + + // Stats + int broadcast_successes{0}; + int broadcast_failures{0}; + int connection_successes{0}; + int blocks_relayed{0}; + + std::string key() const { return address.to_string(); } + + void record_success() + { + ++broadcast_successes; + ++blocks_relayed; + score += 10; + } + + void record_failure() + { + ++broadcast_failures; + score -= 5; + } + + void record_connected() + { + ++connection_successes; + backoff_sec = 30; // reset backoff + attempt_count = 0; + score += 10; + in_tried = true; + last_seen = std::chrono::steady_clock::now(); + } + + void record_disconnected() + { + ++attempt_count; + backoff_sec = std::min(backoff_sec * 2, is_protected ? 600 : 3600); + } + + bool can_retry() const + { + if (is_protected) return true; + if (attempt_count >= max_attempts) return false; + auto elapsed = std::chrono::steady_clock::now() - last_attempt; + return elapsed >= std::chrono::seconds(backoff_sec); + } + + int compute_score() const + { + if (is_protected) return 999999; + + int s = score; + + // Source bonus: prefer addr-crawl peers, penalise daemon-learned peers. + // This is what pulls the embedded arm OFF the local dashd's peer view + // onto an independent one. + if (source == Source::addr_crawl) s += 50; + else if (source == Source::coind) s -= 20; + + // Broadcast success rate + int total = broadcast_successes + broadcast_failures; + if (total > 0) { + s += static_cast(100.0 * broadcast_successes / total); + } + + // Age modifiers + auto age = std::chrono::steady_clock::now() - first_seen; + auto hours = std::chrono::duration_cast(age).count(); + if (hours < 1) s += 50; + else if (hours > 24) s -= 50; + else if (hours > 6) s -= 20; + + // Block relay activity + if (blocks_relayed > 10) s += 30; + else if (blocks_relayed > 5) s += 20; + else if (blocks_relayed > 0) s += 10; + + // Connection stability + if (connection_successes > 0) { + auto uptime = std::chrono::steady_clock::now() - last_seen; + if (std::chrono::duration_cast(uptime).count() < 1) + s += 20; + } + + return s; + } +}; + +// ─── Peer manager configuration ────────────────────────────────────────────── +struct DashPeerManagerConfig +{ + int max_peers{20}; + int min_peers{5}; + int max_concurrent_connections{3}; + int max_connections_per_cycle{5}; + int base_backoff_sec{30}; + int max_backoff_sec{3600}; + int max_connection_attempts{10}; + int refresh_interval_sec{1800}; // 30 min getpeerinfo re-bootstrap + int peer_db_save_interval_sec{300}; + int maintenance_interval_sec{5}; + bool disable_discovery{false}; // isolated network — only connect to specified peers + std::set valid_ports; // only connect to peers on known ports (DASH: 9999/19999) + + // Hardening: network group limits (Sybil resistance) + int max_peers_per_group{4}; // max peers from same /16 (IPv4) or /32 (IPv6) + int max_new_peers_per_group{3}; // stricter limit for unverified (new) peers + int anchor_count{2}; // number of anchor connections to persist +}; + +// ─── DashCoinPeerManager ───────────────────────────────────────────────────── +class DashCoinPeerManager +{ +public: + using GetPeerInfoFn = std::function()>; + + DashCoinPeerManager(boost::asio::io_context& ioc, + const std::string& symbol, + const std::string& data_dir, + const DashPeerManagerConfig& config) + : m_ioc(ioc) + , m_symbol(symbol) + , m_data_dir(data_dir) + , m_config(config) + , m_refresh_timer(ioc) + , m_save_timer(ioc) + , m_maintenance_timer(ioc) + , m_fixed_seed_timer(ioc) + , m_http_seed_timer(ioc) + { + } + + ~DashCoinPeerManager() { stop(); } + + /// Set callback to get peers from dashd via RPC getpeerinfo (optional). + void set_getpeerinfo_fn(GetPeerInfoFn fn) { m_getpeerinfo_fn = std::move(fn); } + + /// Register the protected/pinned local dashd node. Score=999999, never + /// dropped, and it becomes the always-preferred dial target that carries + /// the redundant block-broadcast leg — coexisting with the discovered + /// diverse peer set. Returns true if the address is valid and registered. + /// Allows local/private addresses (the daemon IS local), but rejects + /// empty/unparseable hosts via PeerEndpoint validation. + bool set_local_node(const NetService& addr) + { + auto ep = PeerEndpoint::from(addr); + if (!ep) { + LOG_WARNING << "[" << m_symbol + << "] Rejected invalid local node address: " << addr.to_string(); + return false; + } + + std::lock_guard lock(m_mutex); + auto key = ep->to_string(); + auto& peer = m_peers[key]; + peer.address = ep->to_net_service(); + peer.addr_class = ep->addr_class(); + peer.score = 999999; + peer.is_protected = true; + peer.source = DashPeerInfo::Source::manual; + peer.first_seen = std::chrono::steady_clock::now(); + peer.last_seen = std::chrono::steady_clock::now(); + peer.max_attempts = 999999; // never give up + m_local_node_key = key; + LOG_INFO << "[" << m_symbol << "] Protected local dashd node (pinned): " << key + << " (class=" << static_cast(ep->addr_class()) << ")"; + return true; + } + + /// Set DNS seeds for this chain. Call before start(). + void set_dns_seeds(std::vector seeds) + { + m_dns_seeds = std::move(seeds); + } + + /// Set hardcoded fixed seeds (fallback if DNS fails). Call before start(). + void set_fixed_seeds(std::vector seeds) + { + m_fixed_seeds = std::move(seeds); + } + + /// Set c2pool seed node URLs for HTTP peer discovery fallback. + /// When DNS + fixed seeds are exhausted, these nodes are queried via + /// GET /api/coin_peers to bootstrap peer addresses. + /// Format: {"host", port} (HTTP, no path needed) + void set_http_peer_seeds(std::vector> seeds) + { + m_http_peer_seeds = std::move(seeds); + } + + /// Start peer management (bootstrap + periodic refresh + maintenance). + void start() + { + load_peers(); + boost_anchor_scores(); + bootstrap_from_getpeerinfo(); + bootstrap_from_dns_seeds(); + schedule_refresh(); + schedule_save(); + schedule_maintenance(); + schedule_fixed_seed_fallback(); + schedule_http_peer_fallback(); + m_running = true; + + auto stats = peer_stats(); + LOG_INFO << "[" << m_symbol << "] DashCoinPeerManager started: " + << stats.total << " peers (tried=" << stats.tried + << " new=" << stats.new_peers + << " groups=" << stats.unique_groups + << " anchors=" << stats.anchor_count << ")" + << " dns_seeds=" << m_dns_seeds.size() + << " fixed_seeds=" << m_fixed_seeds.size(); + } + + void stop() + { + m_running = false; + m_refresh_timer.cancel(); + m_save_timer.cancel(); + m_maintenance_timer.cancel(); + m_fixed_seed_timer.cancel(); + m_http_seed_timer.cancel(); + save_peers(); + } + + /// Add a peer discovered via P2P addr message. + /// Validates via PeerEndpoint: rejects empty, unparseable, and non-routable addresses. + void add_discovered_peer(const NetService& addr) + { + std::lock_guard lock(m_mutex); + try_add_peer_locked(addr, DashPeerInfo::Source::addr_crawl, /*require_routable=*/true); + } + + /// Get list of peers that should be connected right now. + /// Returns up to max_connections_per_cycle validated PeerEndpoints sorted + /// by score, excluding already-connected, backed-off, and invalid peers. + /// Enforces network group diversity: max 2 connections per /16 group. + std::vector get_peers_to_connect( + const std::set& connected_keys) const + { + std::lock_guard lock(m_mutex); + int pending = static_cast(connected_keys.size()); + if (pending >= m_config.max_peers) return {}; + + // Count how many more we can connect + int budget = std::min( + m_config.max_connections_per_cycle, + std::min(m_config.max_concurrent_connections, + m_config.max_peers - pending)); + if (budget <= 0) return {}; + + // Build group count of already-connected peers + std::map connected_groups; + for (auto& [key, peer] : m_peers) { + if (connected_keys.count(key)) { + auto grp = peer.network_group.empty() + ? peer_network_group(peer.address.address()) : peer.network_group; + connected_groups[grp]++; + } + } + + // Score-sorted candidates, preferring tried peers (50% bonus). + struct Candidate { int score; PeerEndpoint endpoint; std::string group; }; + std::vector candidates; + for (auto& [key, peer] : m_peers) { + if (connected_keys.count(key)) continue; + if (!peer.can_retry()) continue; + auto ep = PeerEndpoint::from(peer.address); + if (!ep) continue; // invalid address — skip silently + int s = peer.compute_score(); + if (peer.in_tried) s += 50; // prefer verified peers + auto grp = peer.network_group.empty() + ? peer_network_group(ep->host()) : peer.network_group; + candidates.push_back({s, *ep, grp}); + } + + std::sort(candidates.begin(), candidates.end(), + [](const auto& a, const auto& b) { return a.score > b.score; }); + + // Select with group diversity: max 2 outbound connections per /16 + static constexpr int MAX_OUTBOUND_PER_GROUP = 2; + std::vector result; + for (auto& c : candidates) { + if (static_cast(result.size()) >= budget) break; + int grp_total = connected_groups[c.group]; + if (grp_total >= MAX_OUTBOUND_PER_GROUP && !c.group.empty()) continue; + result.push_back(c.endpoint); + connected_groups[c.group]++; + } + return result; + } + + /// Notify that connection to a peer succeeded. + void notify_connected(const std::string& key) + { + std::lock_guard lock(m_mutex); + auto it = m_peers.find(key); + if (it != m_peers.end()) { + it->second.record_connected(); + // Track as anchor candidate (most recent successful connections) + update_anchors(key); + } + if (!m_bootstrapped) m_bootstrapped = true; + } + + /// Notify that connection to a peer was lost. + void notify_disconnected(const std::string& key) + { + std::lock_guard lock(m_mutex); + auto it = m_peers.find(key); + if (it != m_peers.end()) { + it->second.record_disconnected(); + } + } + + /// Record broadcast success for a peer. + void record_broadcast_success(const std::string& key) + { + std::lock_guard lock(m_mutex); + auto it = m_peers.find(key); + if (it != m_peers.end()) { + it->second.record_success(); + } + } + + /// Record broadcast failure for a peer. + void record_broadcast_failure(const std::string& key) + { + std::lock_guard lock(m_mutex); + auto it = m_peers.find(key); + if (it != m_peers.end()) { + it->second.record_failure(); + } + } + + /// Check if we're below min_peers — triggers emergency refresh. + bool needs_emergency_refresh(int connected_count) const + { + if (m_config.disable_discovery) return false; + return connected_count < m_config.min_peers; + } + + const DashPeerManagerConfig& config() const { return m_config; } + + /// Whether discovery (getaddr) should be enabled. + bool discovery_enabled() const + { + if (m_config.disable_discovery) return false; + std::lock_guard lock(m_mutex); + return static_cast(m_peers.size()) < m_config.max_peers; + } + + /// Whether a given peer key is the daemon's own peer (overlap filter). + /// Exposed so the dialer can prefer independent (non-overlapping) peers — + /// the crux of "different peers → independent mempool/relay view". + bool is_daemon_overlap_peer(const std::string& key) const + { + std::lock_guard lock(m_mutex); + return m_coind_peers.count(key) > 0; + } + + /// Remove peers that are exhausted (max attempts reached, not protected). + void prune_dead_peers() + { + std::lock_guard lock(m_mutex); + for (auto it = m_peers.begin(); it != m_peers.end(); ) { + if (!it->second.is_protected && + it->second.attempt_count >= it->second.max_attempts) + { + LOG_DEBUG_COIND << "[" << m_symbol << "] Pruning dead peer: " << it->first; + it = m_peers.erase(it); + } else { + ++it; + } + } + } + + size_t peer_count() const + { + std::lock_guard lock(m_mutex); + return m_peers.size(); + } + + /// Counts of tried vs new peers, and unique network groups. + struct PeerStats { + int total{0}; + int tried{0}; + int new_peers{0}; + int unique_groups{0}; + int anchor_count{0}; + }; + + PeerStats peer_stats() const + { + std::lock_guard lock(m_mutex); + PeerStats s; + s.total = static_cast(m_peers.size()); + s.anchor_count = static_cast(m_anchors.size()); + std::set groups; + for (auto& [k, p] : m_peers) { + if (p.in_tried) ++s.tried; else ++s.new_peers; + auto grp = p.network_group.empty() + ? peer_network_group(p.address.address()) : p.network_group; + groups.insert(grp); + } + s.unique_groups = static_cast(groups.size()); + return s; + } + + const std::string& symbol() const { return m_symbol; } + + /// Return a random subset of verified (tried) peers for sharing via API. + /// Only includes peers we've successfully connected to AND that are globally + /// routable — prevents serving unverified, private, or loopback addresses. + /// max_count: cap on returned peers (default 25). + std::vector get_tried_peers(int max_count = 25) const + { + std::lock_guard lock(m_mutex); + std::vector result; + for (auto& [k, p] : m_peers) { + if (!p.in_tried) continue; + auto ep = PeerEndpoint::from(p.address); + if (!ep || !ep->is_routable()) continue; + result.push_back(*ep); + } + // Shuffle for privacy (don't reveal connection order/topology) + if (result.size() > 1) { + thread_local std::mt19937 rng(std::random_device{}()); + std::shuffle(result.begin(), result.end(), rng); + } + if (static_cast(result.size()) > max_count) + result.erase(result.begin() + max_count, result.end()); + return result; + } + +private: + /// Unified peer validation and insertion (caller must hold m_mutex). + /// Validates the address via PeerEndpoint::from(), applies port filtering, + /// capacity gating, dedup, the daemon-peer overlap filter, and network + /// group limits. + /// + /// @param require_routable If true, rejects private/loopback/link-local + /// addresses. Use for externally-sourced peers + /// (addr crawl, DNS, HTTP). Set false for daemon + /// peers (getpeerinfo) which may be LAN hosts. + /// @return true if the peer was added, false if rejected. + bool try_add_peer_locked(const NetService& addr, DashPeerInfo::Source source, + bool require_routable) + { + // ── Validate via PeerEndpoint (type-safe, Bitcoin Core classification) ── + auto ep = PeerEndpoint::from(addr); + if (!ep) { + LOG_DEBUG_COIND << "[" << m_symbol << "] Rejected invalid peer address: " + << addr.to_string(); + return false; + } + if (require_routable && !ep->is_routable()) { + LOG_DEBUG_COIND << "[" << m_symbol << "] Rejected non-routable peer: " + << ep->to_string() << " (class=" + << static_cast(ep->addr_class()) << ")"; + return false; + } + + // ── Port filter ── + if (!is_valid_port(ep->port())) return false; + + // ── Capacity gating ── + if (static_cast(m_peers.size()) >= m_config.max_peers) return false; + + // ── Dedup + daemon-peer overlap filter ── + // A non-daemon source whose address matches one the daemon already + // knows is REJECTED: the whole point is an INDEPENDENT peer set, so we + // don't re-add the dashd's own peers under a preferred source. + auto key = ep->to_string(); + if (m_peers.count(key)) return false; + if (source != DashPeerInfo::Source::coind && m_coind_peers.count(key)) return false; + + // ── Network group limits (Sybil resistance) ── + auto group = peer_network_group(ep->host()); + bool is_untried_source = (source == DashPeerInfo::Source::addr_crawl + || source == DashPeerInfo::Source::dns_seed + || source == DashPeerInfo::Source::fixed_seed); + if (is_untried_source + && group_count(group, false) >= m_config.max_new_peers_per_group) return false; + if (group_count(group, true) >= m_config.max_peers_per_group) return false; + + // ── Insert ── + auto& peer = m_peers[key]; + peer.address = ep->to_net_service(); + peer.addr_class = ep->addr_class(); + peer.source = source; + peer.network_group = group; + peer.first_seen = std::chrono::steady_clock::now(); + peer.last_seen = std::chrono::steady_clock::now(); + peer.max_attempts = m_config.max_connection_attempts; + return true; + } + + bool is_valid_port(uint16_t port) const + { + if (m_config.valid_ports.empty()) return true; + return m_config.valid_ports.count(port) > 0; + } + + /// Count peers in a given network group. If include_tried=true, count all; + /// if false, count only new (untried) peers. + int group_count(const std::string& group, bool include_tried) const + { + int count = 0; + for (auto& [k, p] : m_peers) { + auto grp = p.network_group.empty() + ? peer_network_group(p.address.address()) : p.network_group; + if (grp == group) { + if (include_tried || !p.in_tried) ++count; + } + } + return count; + } + + /// Update anchor list: keep the N most recent successfully connected peers. + void update_anchors(const std::string& key) + { + // Remove if already present (will re-add at front) + m_anchors.erase( + std::remove(m_anchors.begin(), m_anchors.end(), key), + m_anchors.end()); + m_anchors.insert(m_anchors.begin(), key); + while (static_cast(m_anchors.size()) > m_config.anchor_count) { + m_anchors.pop_back(); + } + } + + /// On startup, boost anchor peer scores so they're connected first. + /// Partition resistance: we reconnect to known-good peers before any new ones. + void boost_anchor_scores() + { + for (auto& anchor_key : m_anchors) { + auto it = m_peers.find(anchor_key); + if (it != m_peers.end()) { + it->second.score += 200; // strong preference + it->second.backoff_sec = 0; // immediate retry + it->second.attempt_count = 0; + LOG_INFO << "[" << m_symbol << "] Anchor peer boosted: " << anchor_key; + } + } + } + + void bootstrap_from_getpeerinfo() + { + if (!m_getpeerinfo_fn) return; + try { + auto peers = m_getpeerinfo_fn(); + std::lock_guard lock(m_mutex); + m_coind_peers.clear(); + int added = 0; + for (auto& addr : peers) { + // Track daemon's own peers for overlap filtering + auto ep = PeerEndpoint::from(addr); + if (ep) m_coind_peers.insert(ep->to_string()); + // Daemon peers may be LAN — don't require routable + if (try_add_peer_locked(addr, DashPeerInfo::Source::coind, + /*require_routable=*/false)) + ++added; + } + LOG_INFO << "[" << m_symbol << "] Bootstrap: " << peers.size() + << " peers from getpeerinfo, " << added << " new, " + << m_peers.size() << " total"; + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_symbol << "] getpeerinfo failed: " << e.what(); + } + } + + void schedule_refresh() + { + m_refresh_timer.expires_after( + std::chrono::seconds(m_config.refresh_interval_sec)); + m_refresh_timer.async_wait([this](const boost::system::error_code& ec) { + if (ec || !m_running) return; + schedule_refresh(); + try { + bootstrap_from_getpeerinfo(); + prune_dead_peers(); + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_symbol << "] Peer refresh error: " << e.what(); + } + }); + } + + void schedule_save() + { + m_save_timer.expires_after( + std::chrono::seconds(m_config.peer_db_save_interval_sec)); + m_save_timer.async_wait([this](const boost::system::error_code& ec) { + if (ec || !m_running) return; + schedule_save(); + try { + save_peers(); + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_symbol << "] Peer save error: " << e.what(); + } + }); + } + + void schedule_maintenance() + { + m_maintenance_timer.expires_after( + std::chrono::seconds(m_config.maintenance_interval_sec)); + m_maintenance_timer.async_wait([this](const boost::system::error_code& ec) { + if (ec || !m_running) return; + schedule_maintenance(); + }); + } + + // ─── JSON persistence ──────────────────────────────────────────────── + + std::string db_path() const + { + std::filesystem::path dir; + if (m_data_dir.empty() || m_data_dir == ".") + dir = ::core::filesystem::config_path() / "dash_embedded_peers"; + else + dir = m_data_dir; + std::filesystem::create_directories(dir); + // Defense-in-depth (CodeQL cpp/path-injection): m_symbol is a hardcoded + // ticker literal at every production ctor site, but never let it become a + // path component. Reject anything non-alphanumeric. + std::string sym = m_symbol; + const bool clean = !sym.empty() && sym.size() <= 16 && + std::all_of(sym.begin(), sym.end(), + [](unsigned char c) { return std::isalnum(c) != 0; }); + if (!clean) { + LOG_WARNING << "[DashPeerManager] non-alphanumeric coin symbol '" + << m_symbol << "' -> using peers_unknown.json"; + sym = "unknown"; + } + return (dir / ("peers_" + sym + ".json")).string(); + } + + void save_peers() + { + try { + std::lock_guard lock(m_mutex); + nlohmann::json j; + j["bootstrapped"] = m_bootstrapped; + j["saved_at"] = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + nlohmann::json peers_j = nlohmann::json::object(); + for (auto& [key, peer] : m_peers) { + nlohmann::json pj; + pj["score"] = peer.score; + pj["source"] = static_cast(peer.source); + pj["protected"] = peer.is_protected; + pj["in_tried"] = peer.in_tried; + pj["network_group"] = peer.network_group; + pj["attempt_count"] = peer.attempt_count; + pj["backoff_sec"] = peer.backoff_sec; + pj["broadcast_successes"] = peer.broadcast_successes; + pj["broadcast_failures"] = peer.broadcast_failures; + pj["blocks_relayed"] = peer.blocks_relayed; + peers_j[key] = pj; + } + j["peers"] = peers_j; + + // Persist anchor connections + j["anchors"] = nlohmann::json::array(); + for (auto& a : m_anchors) { + j["anchors"].push_back(a); + } + + // Atomic write: .tmp → rename + std::string tmp_path = db_path() + ".tmp"; + { + std::ofstream ofs(tmp_path); + ofs << j.dump(2); + } + std::rename(tmp_path.c_str(), db_path().c_str()); + LOG_DEBUG_COIND << "[" << m_symbol << "] Saved " << m_peers.size() << " peers"; + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_symbol << "] Failed to save peers: " << e.what(); + } + } + + void load_peers() + { + try { + std::ifstream ifs(db_path()); + if (!ifs.is_open()) return; + + auto j = nlohmann::json::parse(ifs); + m_bootstrapped = j.value("bootstrapped", false); + + if (j.contains("peers") && j["peers"].is_object()) { + std::lock_guard lock(m_mutex); + int loaded = 0, rejected = 0; + for (auto& [key, pj] : j["peers"].items()) { + // Parse "host:port" back into NetService + auto colon = key.rfind(':'); + if (colon == std::string::npos) continue; + std::string host = key.substr(0, colon); + uint16_t port = static_cast( + std::stoul(key.substr(colon + 1))); + + // Validate via PeerEndpoint — reject stale invalid entries + auto ep = PeerEndpoint::from(host, port); + if (!ep) { + ++rejected; + continue; + } + + auto validated_key = ep->to_string(); + auto& peer = m_peers[validated_key]; + peer.address = ep->to_net_service(); + peer.addr_class = ep->addr_class(); + peer.score = pj.value("score", 0); + peer.source = static_cast( + pj.value("source", 0)); + peer.is_protected = pj.value("protected", false); + peer.in_tried = pj.value("in_tried", false); + peer.network_group = pj.value("network_group", ""); + if (peer.network_group.empty()) { + peer.network_group = peer_network_group(ep->host()); + } + peer.attempt_count = pj.value("attempt_count", 0); + peer.backoff_sec = pj.value("backoff_sec", 30); + peer.broadcast_successes = pj.value("broadcast_successes", 0); + peer.broadcast_failures = pj.value("broadcast_failures", 0); + peer.blocks_relayed = pj.value("blocks_relayed", 0); + peer.first_seen = std::chrono::steady_clock::now(); + peer.last_seen = std::chrono::steady_clock::now(); + peer.max_attempts = peer.is_protected ? 999999 + : m_config.max_connection_attempts; + ++loaded; + } + if (rejected > 0) { + LOG_WARNING << "[" << m_symbol << "] Rejected " << rejected + << " invalid peers from saved database"; + } + + // Restore anchor connections + if (j.contains("anchors") && j["anchors"].is_array()) { + for (auto& a : j["anchors"]) { + auto anchor_key = a.get(); + if (m_peers.count(anchor_key)) { + m_anchors.push_back(anchor_key); + } + } + } + + LOG_INFO << "[" << m_symbol << "] Loaded " << m_peers.size() + << " peers from " << db_path() + << " (anchors=" << m_anchors.size() << ")"; + } + } catch (const std::exception& e) { + LOG_DEBUG_COIND << "[" << m_symbol << "] No saved peers: " << e.what(); + } + } + + // ─── DNS seed bootstrap ───────────────────────────────────────────── + + void bootstrap_from_dns_seeds() + { + if (m_dns_seeds.empty()) return; + + LOG_INFO << "[" << m_symbol << "] Resolving " << m_dns_seeds.size() << " DNS seeds..."; + c2pool::dns::DnsSeeder seeder(m_ioc, m_dns_seeds); + auto peers = seeder.resolve_all_sync(); + + if (peers.empty()) { + LOG_WARNING << "[" << m_symbol << "] DNS seeds returned 0 peers"; + return; + } + + std::lock_guard lock(m_mutex); + int added = 0; + for (auto& addr : peers) { + // DNS seeds should only return routable public IPs + if (try_add_peer_locked(addr, DashPeerInfo::Source::dns_seed, + /*require_routable=*/true)) + ++added; + } + LOG_INFO << "[" << m_symbol << "] DNS seeds: " << peers.size() + << " resolved, " << added << " new peers added, " + << m_peers.size() << " total"; + } + + void load_fixed_seeds() + { + if (m_fixed_seeds.empty()) return; + + std::lock_guard lock(m_mutex); + // Only load fixed seeds if we still have very few peers + int non_protected = 0; + for (auto& [k, p] : m_peers) { + if (!p.is_protected) ++non_protected; + } + if (non_protected >= m_config.min_peers) { + LOG_DEBUG_COIND << "[" << m_symbol << "] Skipping fixed seeds: " + << non_protected << " peers already known"; + return; + } + + int added = 0; + for (auto& addr : m_fixed_seeds) { + // Fixed seeds are curated public IPs — require routable + if (try_add_peer_locked(addr, DashPeerInfo::Source::fixed_seed, + /*require_routable=*/true)) + ++added; + } + if (added > 0) { + LOG_INFO << "[" << m_symbol << "] Loaded " << added + << " fixed seed peers (fallback), " << m_peers.size() << " total"; + } + } + + void schedule_fixed_seed_fallback() + { + if (m_fixed_seeds.empty()) return; + + // Load fixed seeds after 60s if we still have few peers + m_fixed_seed_timer.expires_after(std::chrono::seconds(60)); + m_fixed_seed_timer.async_wait([this](const boost::system::error_code& ec) { + if (ec || !m_running) return; + try { + load_fixed_seeds(); + } catch (const std::exception& e) { + LOG_WARNING << "[" << m_symbol << "] Fixed seed load error: " << e.what(); + } + }); + } + + /// HTTP peer discovery: fetch peers from c2pool seed nodes via + /// GET /api/coin_peers. Runs on a detached thread to avoid blocking + /// io_context. Fires 90s after start (after DNS + fixed seeds tried). + void schedule_http_peer_fallback() + { + if (m_http_peer_seeds.empty()) return; + + m_http_seed_timer.expires_after(std::chrono::seconds(90)); + m_http_seed_timer.async_wait([this](const boost::system::error_code& ec) { + if (ec || !m_running) return; + + // Only fetch if we still have very few tried peers + int tried = 0; + { + std::lock_guard lock(m_mutex); + for (auto& [k, p] : m_peers) + if (p.in_tried && !p.is_protected) ++tried; + } + if (tried >= m_config.min_peers) { + LOG_DEBUG_COIND << "[" << m_symbol + << "] Skipping HTTP peer fetch: " << tried << " tried peers"; + return; + } + + LOG_INFO << "[" << m_symbol << "] Fetching peers from " + << m_http_peer_seeds.size() << " c2pool seed nodes..."; + + // Detached thread: blocking HTTP fetch, results posted to ioc + auto seeds = m_http_peer_seeds; // copy for thread + auto symbol = m_symbol; + auto* self = this; + std::thread([seeds, symbol, self]() { + std::vector result; + // JSON key for the DASH chain feed. + std::string key = "dash"; + + for (auto& [host, port] : seeds) { + try { + result = http_fetch_coin_peers(host, port, key); + if (!result.empty()) { + LOG_INFO << "[" << symbol << "] HTTP seed " << host + << ":" << port << " returned " + << result.size() << " peers"; + break; // got peers from one seed, done + } + } catch (const std::exception& e) { + LOG_WARNING << "[" << symbol << "] HTTP seed " + << host << ":" << port << " failed: " << e.what(); + } + } + + if (!result.empty()) { + boost::asio::post(self->m_ioc, [self, result]() { + self->add_http_peers(result); + }); + } + }).detach(); + }); + } + + /// Add peers from an HTTP /api/coin_peers fetch. + void add_http_peers(const std::vector& peers) + { + std::lock_guard lock(m_mutex); + int added = 0; + for (auto& addr : peers) { + // HTTP seeds are external — require routable + if (try_add_peer_locked(addr, DashPeerInfo::Source::addr_crawl, + /*require_routable=*/true)) + ++added; + } + if (added > 0) + LOG_INFO << "[" << m_symbol << "] Added " << added + << " peers from HTTP seed, " << m_peers.size() << " total"; + } + + /// Lightweight blocking HTTP GET — fetches /api/coin_peers from a + /// c2pool seed node. Uses raw TCP sockets (no boost::beast dependency). + static std::vector http_fetch_coin_peers( + const std::string& host, uint16_t port, const std::string& chain_key) + { + std::vector result; + try { + boost::asio::io_context tmp_ioc; + boost::asio::ip::tcp::resolver resolver(tmp_ioc); + auto endpoints = resolver.resolve(host, std::to_string(port)); + + boost::asio::ip::tcp::socket sock(tmp_ioc); + boost::asio::connect(sock, endpoints); + + // Send HTTP GET + std::string request = + "GET /api/coin_peers HTTP/1.0\r\n" + "Host: " + host + "\r\n" + "User-Agent: c2pool/0.1\r\n" + "Connection: close\r\n\r\n"; + boost::asio::write(sock, boost::asio::buffer(request)); + + // Read response + std::string response; + boost::system::error_code ec; + char buf[4096]; + while (true) { + size_t n = sock.read_some(boost::asio::buffer(buf), ec); + if (n > 0) response.append(buf, n); + if (ec) break; + } + + // Parse: skip HTTP headers, find JSON body + auto body_pos = response.find("\r\n\r\n"); + if (body_pos == std::string::npos) return result; + std::string body = response.substr(body_pos + 4); + + auto j = nlohmann::json::parse(body); + if (!j.contains(chain_key) || !j[chain_key].is_array()) + return result; + + for (auto& peer_str : j[chain_key]) { + std::string s = peer_str.get(); + // Parse "ip:port" + auto colon = s.rfind(':'); + if (colon == std::string::npos) continue; + std::string ip = s.substr(0, colon); + uint16_t p = static_cast( + std::stoi(s.substr(colon + 1))); + if (!ip.empty() && p > 0) + result.emplace_back(ip, p); + } + } catch (...) { + // Connection failed, DNS failed, parse failed — return empty + } + return result; + } + + // ─── Members ───────────────────────────────────────────────────────── + + boost::asio::io_context& m_ioc; + std::string m_symbol; + std::string m_data_dir; + DashPeerManagerConfig m_config; + + boost::asio::steady_timer m_refresh_timer; + boost::asio::steady_timer m_save_timer; + boost::asio::steady_timer m_maintenance_timer; + boost::asio::steady_timer m_fixed_seed_timer; + + GetPeerInfoFn m_getpeerinfo_fn; + + // DNS + fixed + HTTP seeds + std::vector m_dns_seeds; + std::vector m_fixed_seeds; + std::vector> m_http_peer_seeds; + boost::asio::steady_timer m_http_seed_timer; + + mutable std::mutex m_mutex; + std::map m_peers; // key = "host:port" + std::set m_coind_peers; // daemon's own peers (overlap filter) + std::string m_local_node_key; + std::vector m_anchors; // last N successfully connected (partition resistance) + bool m_bootstrapped{false}; + bool m_running{false}; +}; + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/p2p_client.hpp b/src/impl/dash/coin/p2p_client.hpp index dee8002ea..d2f328478 100644 --- a/src/impl/dash/coin/p2p_client.hpp +++ b/src/impl/dash/coin/p2p_client.hpp @@ -209,6 +209,13 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor PeerHeightCallback m_on_peer_height; using HandshakeCallback = std::function; HandshakeCallback m_on_handshake_complete; + // Peer lifecycle seams for the DASH-isolated CoinPeerManager scoring feed + // (coin/coin_peer_manager.hpp). Fired with the peer's "host:port" key so the + // manager can score connects/disconnects and persist anchors. Both optional; + // unset on the legacy single-peer --coin-p2p-connect path (no behaviour change). + using PeerLifecycleCallback = std::function; + PeerLifecycleCallback m_on_peer_connected; + PeerLifecycleCallback m_on_peer_disconnected; // E1 Phase-L member-set sourcing DEMUX: a filter that consumes HISTORICAL // mnlistdiff replies (full base=ZERO snapshots at old quorum-base / work @@ -260,6 +267,21 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor }); } + /// Refresh the reconnect dial plan in place WITHOUT tearing the current + /// connection. The DASH-isolated CoinPeerManager calls this periodically + /// with a freshly-scored, group-diverse target set (pinned local dashd + /// first, then the highest-scoring discovered peers) so that on the next + /// reconnect the single embedded connection rotates onto an INDEPENDENT + /// peer — the mechanism that graduates the embedded arm to a network- + /// standalone witness. Empty target lists are ignored (never wedge redial). + void update_dial_targets(std::vector targets) + { + if (targets.empty()) return; + m_dial_plan.set_targets(std::move(targets)); + LOG_DEBUG_COIND << "[" << m_chain_label << "] dial plan refreshed (" + << m_dial_plan.size() << " scored target[s])"; + } + // INetwork void connected(std::shared_ptr socket) override { @@ -270,6 +292,8 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor LOG_INFO << "[" << m_chain_label << "] connected to " << m_peer->get_addr().to_string() << " — sending version (proto " << PROTOCOL_VERSION << ")"; + if (m_on_peer_connected) + m_on_peer_connected(m_peer->get_addr()); // Require version/verack progress soon after connect. ensure_timeout_timer(); @@ -323,6 +347,12 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor void set_on_handshake_complete(HandshakeCallback cb) { m_on_handshake_complete = std::move(cb); } /// Install the Phase-L historical-mnlistdiff demux (QuorumMemberSource). void set_historical_mnlistdiff_filter(MnListDiffFilter f) { m_historical_mnlistdiff_filter = std::move(f); } + /// Fired on socket connect (before handshake) with the peer endpoint — the + /// DashCoinPeerManager scores the connect + tracks anchors off this. + void set_on_peer_connected(PeerLifecycleCallback cb) { m_on_peer_connected = std::move(cb); } + /// Fired on disconnect/error with the peer endpoint — the DashCoinPeerManager + /// scores the drop + applies exponential backoff off this. + void set_on_peer_disconnected(PeerLifecycleCallback cb) { m_on_peer_disconnected = std::move(cb); } /// Send a getheaders request (E2 sync driver seam; unused by E1 run_node). void send_getheaders(uint32_t version, const std::vector& locator, const uint256& stop) @@ -395,6 +425,8 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor LOG_WARNING << "[" << m_chain_label << "] peer " << svc_copy.to_string() << " disconnected: " << err << (m_reconnect_enabled ? " (reconnect armed)" : ""); + if (m_on_peer_disconnected) + m_on_peer_disconnected(svc_copy); if (m_peer) m_peer.reset(); // else: already disconnected (double-fire race) — safe to ignore diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8a8429f1a..fbdefb24d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -855,7 +855,11 @@ if (BUILD_TESTING AND GTest_FOUND) # client (p2p_client.hpp): handshake state machine, dial-plan rotation, and # the byte-exact version/verack handshake drive. Same target, no new # allowlist entry needed. - add_executable(test_dash_p2p_node test_dash_p2p_node.cpp test_dash_coin_p2p_client.cpp) + # Third TU: test_dash_coin_peer_manager.cpp — the DASH-ISOLATED CoinPeerManager + # (coin/coin_peer_manager.hpp): source scoring (daemon penalty / addr-crawl + # bonus), selection ordering, /16 Sybil group caps, anchor persistence, and + # the pinned protected local node. Same target, no new allowlist entry. + add_executable(test_dash_p2p_node test_dash_p2p_node.cpp test_dash_coin_p2p_client.cpp test_dash_coin_peer_manager.cpp) target_link_libraries(test_dash_p2p_node PRIVATE GTest::gtest_main GTest::gtest dash_block_replay # E1: coin/vendor/blockencodings.cpp — the full p2p Handler diff --git a/test/test_dash_coin_peer_manager.cpp b/test/test_dash_coin_peer_manager.cpp new file mode 100644 index 000000000..2fe423d64 --- /dev/null +++ b/test/test_dash_coin_peer_manager.cpp @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// DASH-isolated CoinPeerManager (network-standalone arm) — scoring/selection KATs +/// +/// Exercises src/impl/dash/coin/coin_peer_manager.hpp — the DASH-local peer +/// manager that lets the embedded coin-network arm reach the Dash network on +/// its OWN scored, group-diverse peer set (independent of the local dashd), +/// the precondition for a daemonless c2pool-dash. This is a self-contained +/// copy of the merged manager (isolation fence — see the header preamble); +/// these KATs pin the ported behaviour on the DASH side: +/// +/// (a) source scoring — daemon-learned (coind) peers penalised (-20), +/// addr-crawl peers preferred (+50): the 70-point swing that pulls the +/// embedded arm OFF the dashd peer view onto an independent one. +/// (b) selection ordering — get_peers_to_connect() returns addr-crawl peers +/// ahead of daemon-learned peers. +/// (c) network-group (Sybil) cap — no more than max_new_peers_per_group +/// untried peers admitted from a single /16. +/// (d) anchor persistence — a successfully-connected peer is remembered as +/// an anchor across a save/reload (partition resistance across restart). +/// (e) protected local node — the pinned dashd is admitted (local/private +/// allowed), scored 999999, and never pruned. +/// +/// Pure manager-level tests (no sockets) — compiled as a second TU into the +/// EXISTING allowlisted test_dash_p2p_node target (no new test target, no +/// workflow edit). + +#include + +#include +#include + +#include + +#include + +#include +#include +#include + +using dash::coin::DashCoinPeerManager; +using dash::coin::DashPeerInfo; +using dash::coin::DashPeerManagerConfig; +using dash::coin::peer_network_group; + +namespace { + +DashPeerManagerConfig make_cfg() +{ + DashPeerManagerConfig cfg; + cfg.valid_ports = {9999}; // DASH mainnet coin-P2P port + return cfg; +} + +std::string unique_tmp_dir(const std::string& tag) +{ + auto dir = std::filesystem::temp_directory_path() + / ("c2pool_dash_pm_" + tag + "_" + std::to_string(::getpid()) + "_" + + std::to_string(reinterpret_cast(&tag))); + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + return dir.string(); +} + +// ── (a) source scoring: daemon-peer penalty vs addr-crawl bonus ────────────── + +TEST(DashCoinPeerManager, source_scoring_daemon_penalty_vs_addr_bonus) +{ + auto now = std::chrono::steady_clock::now(); + + DashPeerInfo coind; + coind.source = DashPeerInfo::Source::coind; + coind.first_seen = now; + coind.last_seen = now; + + DashPeerInfo crawl; + crawl.source = DashPeerInfo::Source::addr_crawl; + crawl.first_seen = now; + crawl.last_seen = now; + + // Both freshly seen (<1h => +50 age bonus each). Only the source term + // differs: coind -20, addr_crawl +50 => a 70-point swing favouring the + // independent (crawl) peer. + EXPECT_GT(crawl.compute_score(), coind.compute_score()); + EXPECT_EQ(crawl.compute_score() - coind.compute_score(), 70); +} + +// ── (b) selection ordering: addr-crawl ahead of daemon-learned ─────────────── + +TEST(DashCoinPeerManager, selection_prefers_independent_over_daemon_peer) +{ + boost::asio::io_context ioc; + DashCoinPeerManager pm(ioc, "DASH", unique_tmp_dir("order"), make_cfg()); + + // A daemon-learned peer (coind source) via getpeerinfo bootstrap... + pm.set_getpeerinfo_fn([]() -> std::vector { + return { NetService{"1.2.3.4", 9999} }; + }); + pm.start(); // bootstraps the coind peer (no DNS seeds set => no network I/O) + + // ...and an independent addr-crawl peer in a DIFFERENT /16 group. + pm.add_discovered_peer(NetService{"5.6.7.8", 9999}); + + auto picks = pm.get_peers_to_connect({}); + ASSERT_GE(picks.size(), 2u); + // Highest score first: the addr-crawl peer beats the daemon-learned one. + EXPECT_EQ(picks.front().host(), "5.6.7.8"); + // The daemon peer is present but ranked below. + bool daemon_seen = false; + for (auto& p : picks) if (p.host() == "1.2.3.4") daemon_seen = true; + EXPECT_TRUE(daemon_seen); + + pm.stop(); +} + +// ── (c) network-group (Sybil) cap on untried peers per /16 ─────────────────── + +TEST(DashCoinPeerManager, sybil_group_cap_limits_new_peers_per_16) +{ + boost::asio::io_context ioc; + auto cfg = make_cfg(); + cfg.max_new_peers_per_group = 3; // stricter untried cap + DashCoinPeerManager pm(ioc, "DASH", unique_tmp_dir("sybil"), cfg); + pm.start(); + + // Five routable peers all in the SAME /16 group "9.9". + pm.add_discovered_peer(NetService{"9.9.1.1", 9999}); + pm.add_discovered_peer(NetService{"9.9.2.2", 9999}); + pm.add_discovered_peer(NetService{"9.9.3.3", 9999}); + pm.add_discovered_peer(NetService{"9.9.4.4", 9999}); // over cap + pm.add_discovered_peer(NetService{"9.9.5.5", 9999}); // over cap + + EXPECT_EQ(peer_network_group("9.9.4.4"), "9.9"); + // Only max_new_peers_per_group untried peers admitted from the group. + EXPECT_EQ(pm.peer_count(), 3u); + + pm.stop(); +} + +// ── (d) anchor persistence across save/reload ──────────────────────────────── + +TEST(DashCoinPeerManager, anchor_persists_across_reload) +{ + boost::asio::io_context ioc; + const auto data_dir = unique_tmp_dir("anchor"); + const std::string key = "5.6.7.8:9999"; + + { + DashCoinPeerManager pm(ioc, "DASH", data_dir, make_cfg()); + pm.start(); + pm.add_discovered_peer(NetService{"5.6.7.8", 9999}); + pm.notify_connected(key); // in_tried + anchor + auto st = pm.peer_stats(); + EXPECT_EQ(st.anchor_count, 1); + EXPECT_EQ(st.tried, 1); + pm.stop(); // saves peers + anchors to disk + } + + // Fresh manager, SAME data_dir: load restores the anchor. + { + DashCoinPeerManager pm2(ioc, "DASH", data_dir, make_cfg()); + pm2.start(); + EXPECT_EQ(pm2.peer_stats().anchor_count, 1); + pm2.stop(); + } + + std::filesystem::remove_all(data_dir); +} + +// ── (e) protected local node: admitted, top-scored, never pruned ───────────── + +TEST(DashCoinPeerManager, protected_local_node_is_pinned_and_survives_prune) +{ + boost::asio::io_context ioc; + DashCoinPeerManager pm(ioc, "DASH", unique_tmp_dir("pinned"), make_cfg()); + + // A private/LAN dashd address — rejected as a discovered peer, but ACCEPTED + // as the protected local node (the daemon IS local). + ASSERT_TRUE(pm.set_local_node(NetService{"192.168.1.50", 9999})); + EXPECT_EQ(pm.peer_count(), 1u); + + // Pinned node is offered for connection even against an empty routable set. + auto picks = pm.get_peers_to_connect({}); + ASSERT_GE(picks.size(), 1u); + EXPECT_EQ(picks.front().host(), "192.168.1.50"); + + // Pruning dead peers never removes the protected node. + pm.prune_dead_peers(); + EXPECT_EQ(pm.peer_count(), 1u); + + pm.stop(); +} + +// ── DASH seed sanity: the real canonical mainnet seed is present ───────────── + +TEST(DashCoinPeerManager, dash_mainnet_seeds_are_canonical) +{ + auto dns = dash::coin::dash_dns_seeds(/*testnet=*/false); + ASSERT_FALSE(dns.empty()); + EXPECT_EQ(dns.front().hostname, "dnsseed.dash.org"); + EXPECT_EQ(dns.front().default_port, 9999); + + auto fixed = dash::coin::dash_fixed_seeds(/*testnet=*/false); + EXPECT_FALSE(fixed.empty()); + for (auto& s : fixed) EXPECT_EQ(s.port(), 9999); +} + +} // namespace From e1d7a96883eb2f561c54d6d81493e79509c97b1a Mon Sep 17 00:00:00 2001 From: frstrtr Date: Tue, 21 Jul 2026 10:48:10 +0400 Subject: [PATCH 2/2] dash(embedded): fix CR-1 cold-start dial wedge + CR-2 dormant daemon-disjointness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the CoinPeerManager port (#794) required two fixes, both in the new --coin-p2p-discover opt-in path. CR-1 — daemonless cold-start dial wedge: CoinClient::connect() early-returned on an empty target list without arming the reconnect loop, and update_dial_targets() ignored empty lists. So --coin-p2p-discover WITHOUT --coin-p2p-connect on a first run with DNS unavailable (empty initial dial) armed nothing and wedged until restart. Fix: connect() arms the 30s reconnect loop unconditionally (empty-plan guard on the tick so current()/advance() never throw), and update_dial_targets() kicks an immediate dial on the empty->non-empty transition — so the arm connects as soon as fixed seeds (t+60s) / HTTP seeds (t+90s) arrive. Extracted arm_reconnect_timer() helper. Regression pin added (empty_connect_arms_without_wedging). CR-2 — overlap filter + coind -20 penalty were dormant as wired: main_dash never called set_getpeerinfo_fn, so m_coind_peers stayed empty and no peer was ever Source::coind — the active daemon-disjointness never engaged (only passive independence). Fix: added NodeRPC::getpeerinfo() to the DASH RPC (parses each entry's addr, IPv6-bracket aware) and wired it in the discover path when a dashd RPC is armed, so the -20 penalty + overlap filter engage in the connect+discover / oracle-shadow deployment. Absent RPC (fully daemonless) = seeds-only passive independence. Mirrors main_ltc.cpp getpeerinfo wiring. Nits: pinned target no longer duplicated in the dial list (pinned keys passed as the get_peers_to_connect exclusion set); dead is_daemon_overlap_peer() removed; declaration/destruction order corrected so the manager outlives the client and refresh timer that borrow it. Build clean; full test_dash_p2p_node target 23/23 (was 22 + CR-1 pin); --selftest unchanged. --- src/c2pool/main_dash.cpp | 46 +++++++++++++++--- src/impl/dash/coin/coin_peer_manager.hpp | 9 ---- src/impl/dash/coin/p2p_client.hpp | 62 ++++++++++++++++++------ src/impl/dash/coin/rpc.cpp | 34 +++++++++++++ src/impl/dash/coin/rpc.hpp | 8 +++ test/test_dash_coin_p2p_client.cpp | 17 +++++++ 6 files changed, 144 insertions(+), 32 deletions(-) diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 8338ae7ed..6e97295a0 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -1072,12 +1072,15 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // layers, never conflated. The client rides the SAME ioc as the // sharechain node and stratum; declared after config/coin_state (both of // which it borrows), so it is destroyed before them at scope exit. - std::unique_ptr> coin_p2p; // DASH-ISOLATED scored/diverse peer discovery (--coin-p2p-discover). Owns // its own peer table + seed bootstrap + scoring; feeds the single embedded - // connection an INDEPENDENT (non-dashd) diverse peer set. Declared BEFORE - // coin_p2p so it outlives the client that borrows it (reverse-destruction). + // connection an INDEPENDENT (non-dashd) diverse peer set. Declared FIRST of + // the three so it is destroyed LAST — the CoinClient's callbacks and the + // refresh timer both capture it by raw pointer, so it must outlive both. std::unique_ptr coin_peer_mgr; + std::unique_ptr> coin_p2p; + // Refresh timer declared LAST -> destroyed FIRST: it stops (and its lambda + // stops capturing coin_p2p / coin_peer_mgr) before either is torn down. std::unique_ptr coin_dial_refresh_timer; if (!coin_p2p_targets.empty() || coin_p2p_discover) { config.coin()->m_testnet = testnet; @@ -1118,6 +1121,25 @@ int run_node(bool testnet, const std::string& rpc_endpoint, } coin_peer_mgr->set_dns_seeds(dash::coin::dash_dns_seeds(testnet)); coin_peer_mgr->set_fixed_seeds(dash::coin::dash_fixed_seeds(testnet)); + + // ── ACTIVE daemon-disjointness (connect+discover / oracle-shadow) ── + // When a local dashd RPC is armed, feed its getpeerinfo so the + // manager tracks the daemon's OWN peers (m_coind_peers). That is + // what makes the coind-source -20 penalty AND the daemon-peer + // overlap filter engage — without it those ported mechanisms stay + // dormant (m_coind_peers empty => no peer ever Source::coind) and + // only passive independence holds. Mirrors main_ltc.cpp:5484. + // Absent RPC (fully daemonless) => seeds-only, passive independence. + if (rpc) { + coin_peer_mgr->set_getpeerinfo_fn( + [rp = rpc.get()]() -> std::vector { + try { return rp->getpeerinfo(); } + catch (...) { return {}; } + }); + std::cout << "[run] daemon-disjointness ACTIVE: dashd getpeerinfo " + "wired -> coind -20 penalty + overlap filter engage\n"; + } + coin_peer_mgr->start(); // addr-crawl discoveries feed back into the manager (source-scored @@ -1135,11 +1157,18 @@ int run_node(bool testnet, const std::string& rpc_endpoint, mgr->notify_disconnected(s.to_string()); }); + // Pinned keys: passed to get_peers_to_connect() as the "already + // handled" set so the protected pinned node (score 999999, always + // ranked first) is NOT also appended by the scorer — the pinned + // entries are prepended explicitly, so this dedups them out. + std::set pinned_keys; + for (auto& t : coin_p2p_targets) pinned_keys.insert(t.to_string()); + // Initial dial plan: pinned local dashd first (preferred), then the - // top scored+diverse discovered peers. + // top scored+diverse discovered peers (pinned excluded from that set). std::vector dial; for (auto& t : coin_p2p_targets) dial.push_back(t); - for (auto& ep : coin_peer_mgr->get_peers_to_connect({})) + for (auto& ep : coin_peer_mgr->get_peers_to_connect(pinned_keys)) dial.push_back(ep.to_net_service()); coin_p2p->connect(dial); @@ -1149,9 +1178,10 @@ int run_node(bool testnet, const std::string& rpc_endpoint, coin_dial_refresh_timer = std::make_unique(&ioc, /*repeat=*/true); coin_dial_refresh_timer->start(60, [cp = coin_p2p.get(), mgr = coin_peer_mgr.get(), - pinned = coin_p2p_targets]() { - std::vector refreshed = pinned; // pinned always preferred - for (auto& ep : mgr->get_peers_to_connect({})) + pinned = coin_p2p_targets, + pinned_keys]() { + std::vector refreshed = pinned; // pinned always preferred, first + for (auto& ep : mgr->get_peers_to_connect(pinned_keys)) refreshed.push_back(ep.to_net_service()); cp->update_dial_targets(std::move(refreshed)); }); diff --git a/src/impl/dash/coin/coin_peer_manager.hpp b/src/impl/dash/coin/coin_peer_manager.hpp index 07bab4e23..e3f278a27 100644 --- a/src/impl/dash/coin/coin_peer_manager.hpp +++ b/src/impl/dash/coin/coin_peer_manager.hpp @@ -467,15 +467,6 @@ class DashCoinPeerManager return static_cast(m_peers.size()) < m_config.max_peers; } - /// Whether a given peer key is the daemon's own peer (overlap filter). - /// Exposed so the dialer can prefer independent (non-overlapping) peers — - /// the crux of "different peers → independent mempool/relay view". - bool is_daemon_overlap_peer(const std::string& key) const - { - std::lock_guard lock(m_mutex); - return m_coind_peers.count(key) > 0; - } - /// Remove peers that are exhausted (max attempts reached, not protected). void prune_dead_peers() { diff --git a/src/impl/dash/coin/p2p_client.hpp b/src/impl/dash/coin/p2p_client.hpp index d2f328478..c228a5ea1 100644 --- a/src/impl/dash/coin/p2p_client.hpp +++ b/src/impl/dash/coin/p2p_client.hpp @@ -246,25 +246,27 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor /// Dial the given targets with automatic reconnection (30s interval, /// round-robin over the target list on each retry). + /// + /// An EMPTY target list is legal in the --coin-p2p-discover cold-start case + /// (fresh peer-db + DNS unavailable): the reconnect loop is armed anyway and + /// simply idles (the empty-plan guard below), so when update_dial_targets() + /// later delivers seed-discovered peers (fixed seeds at t+60s / HTTP at + /// t+90s) the dial starts — no restart needed. Without this, an initially + /// empty discover peer set would wedge permanently. void connect(std::vector targets) { - if (targets.empty()) return; m_dial_plan.set_targets(std::move(targets)); m_reconnect_enabled = true; - LOG_INFO << "[" << m_chain_label << "] dialing " - << m_dial_plan.current().to_string() - << " (" << m_dial_plan.size() << " target[s] in plan)"; - core::Factory::connect(m_dial_plan.current()); - - m_reconnect_timer = std::make_unique(m_context, /*repeat=*/true); - m_reconnect_timer->start(RECONNECT_INTERVAL_SEC, [this]() { - if (!m_peer && m_reconnect_enabled) { - const auto& target = m_dial_plan.advance(); - LOG_INFO << "[" << m_chain_label << "] reconnecting to " - << target.to_string() << "..."; - core::Factory::connect(target); - } - }); + if (!m_dial_plan.empty()) { + LOG_INFO << "[" << m_chain_label << "] dialing " + << m_dial_plan.current().to_string() + << " (" << m_dial_plan.size() << " target[s] in plan)"; + core::Factory::connect(m_dial_plan.current()); + } else { + LOG_INFO << "[" << m_chain_label << "] no initial dial targets; " + "reconnect loop armed, awaiting seed discovery"; + } + arm_reconnect_timer(); } /// Refresh the reconnect dial plan in place WITHOUT tearing the current @@ -274,12 +276,24 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor /// reconnect the single embedded connection rotates onto an INDEPENDENT /// peer — the mechanism that graduates the embedded arm to a network- /// standalone witness. Empty target lists are ignored (never wedge redial). + /// + /// Cold-start kick: if the plan was EMPTY (the discover daemonless case) and + /// we are currently disconnected with the reconnect loop armed, dial the + /// first new target immediately rather than waiting up to 30s for the next + /// reconnect tick — so the arm connects as soon as seeds arrive. void update_dial_targets(std::vector targets) { if (targets.empty()) return; + bool was_empty = m_dial_plan.empty(); m_dial_plan.set_targets(std::move(targets)); LOG_DEBUG_COIND << "[" << m_chain_label << "] dial plan refreshed (" << m_dial_plan.size() << " scored target[s])"; + if (was_empty && !m_peer && m_reconnect_enabled) { + LOG_INFO << "[" << m_chain_label << "] cold-start dial " + << m_dial_plan.current().to_string() + << " (first seed-discovered target)"; + core::Factory::connect(m_dial_plan.current()); + } } // INetwork @@ -473,6 +487,24 @@ class CoinClient : public core::ICommunicator, public core::INetwork, public cor } private: + /// Arm the 30s round-robin reconnect loop. The empty-plan guard makes it + /// safe to arm before any target exists (--coin-p2p-discover cold start): + /// the tick no-ops until update_dial_targets() supplies seed-discovered + /// peers, then rotates over them. current()/advance() on an empty plan + /// would throw, hence the guard. + void arm_reconnect_timer() + { + m_reconnect_timer = std::make_unique(m_context, /*repeat=*/true); + m_reconnect_timer->start(RECONNECT_INTERVAL_SEC, [this]() { + if (!m_peer && m_reconnect_enabled && !m_dial_plan.empty()) { + const auto& target = m_dial_plan.advance(); + LOG_INFO << "[" << m_chain_label << "] reconnecting to " + << target.to_string() << "..."; + core::Factory::connect(target); + } + }); + } + void ensure_timeout_timer() { if (!m_timeout_timer) diff --git a/src/impl/dash/coin/rpc.cpp b/src/impl/dash/coin/rpc.cpp index a35d681bb..66fe5cfe9 100644 --- a/src/impl/dash/coin/rpc.cpp +++ b/src/impl/dash/coin/rpc.cpp @@ -533,6 +533,40 @@ std::string NodeRPC::getbestblockhash() return {}; } +// getpeerinfo -> the dashd's own connected-peer addresses. Each entry's "addr" +// is "host:port" (IPv6 as "[::1]:9999"); parsed to NetService. Empty on a +// non-array/absent result. Validation (routable/port) is the peer manager's. +std::vector NodeRPC::getpeerinfo() +{ + std::vector peers; + auto result = CallAPIMethod("getpeerinfo"); + if (!result.is_array()) + return peers; + for (auto& entry : result) + { + if (!entry.contains("addr") || !entry["addr"].is_string()) + continue; + std::string addr = entry["addr"].get(); + auto colon = addr.rfind(':'); + if (colon == std::string::npos) + continue; + std::string host = addr.substr(0, colon); + uint16_t port = 0; + try { + port = static_cast(std::stoul(addr.substr(colon + 1))); + } catch (...) { + continue; + } + // Strip IPv6 brackets: "[2001:db8::1]" -> "2001:db8::1". + if (host.size() >= 2 && host.front() == '[' && host.back() == ']') + host = host.substr(1, host.size() - 2); + if (host.empty() || port == 0) + continue; + peers.emplace_back(host, port); + } + return peers; +} + // verbose: true -- json result, false -- hex-encode result; nlohmann::json NodeRPC::getblockheader(uint256 header, bool verbose) { diff --git a/src/impl/dash/coin/rpc.hpp b/src/impl/dash/coin/rpc.hpp index f1d2742f1..0cc80100f 100644 --- a/src/impl/dash/coin/rpc.hpp +++ b/src/impl/dash/coin/rpc.hpp @@ -165,6 +165,14 @@ class NodeRPC : public jsonrpccxx::IClientConnector // template refresh without waiting on the 30 s staleness TTL. Empty string // on a null/absent result. std::string getbestblockhash(); + // getpeerinfo -> the dashd's OWN connected-peer addresses (the "addr" field + // of each entry), parsed to NetService. Feeds the embedded + // DashCoinPeerManager's daemon-peer overlap filter + coind-source -20 score + // penalty so the embedded arm actively avoids mirroring the local dashd's + // peers (network-view disjointness). Returns empty on a null/absent result; + // transport errors propagate (the caller swallows them). No dashd config + // change required (getpeerinfo is a default RPC). + std::vector getpeerinfo(); // verbose: true -- json result, false -- hex-encode result; nlohmann::json getblockheader(uint256 header, bool verbose = true); // verbosity: 0 for hex-encoded data, 1 for a json object, and 2 for json object with transaction data diff --git a/test/test_dash_coin_p2p_client.cpp b/test/test_dash_coin_p2p_client.cpp index 8087496e4..35cbe6d67 100644 --- a/test/test_dash_coin_p2p_client.cpp +++ b/test/test_dash_coin_p2p_client.cpp @@ -173,6 +173,23 @@ struct ClientRig } }; +// ── (b') Cold-start empty dial plan (daemonless --coin-p2p-discover) ──────── +// +// Regression pin for the discover cold-start wedge: connect() with an EMPTY +// target list (fresh peer-db + DNS unavailable) must NOT early-return into a +// dead state — it arms the reconnect loop and idles, leaving the client +// safely disconnected (no throw, no session) until seed-discovered peers land +// via update_dial_targets(). An empty update is a safe no-op. +TEST(DashCoinP2PClient, empty_connect_arms_without_wedging) +{ + ClientRig rig; + EXPECT_NO_THROW(rig.client.connect({})); // empty initial dial plan + EXPECT_FALSE(rig.client.is_connected()); + EXPECT_FALSE(rig.client.is_handshake_complete()); + EXPECT_NO_THROW(rig.client.update_dial_targets({})); // empty refresh: no-op + EXPECT_FALSE(rig.client.is_connected()); +} + TEST(DashCoinP2PClient, client_completes_handshake_and_captures_peer_metadata) { ClientRig rig;