diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9d04b761..42aeed8cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,6 +68,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy dgb_share_test \ + rpc_request_test softfork_check_test genesis_check_test \ v37_test \ -j$(nproc) @@ -195,6 +196,7 @@ jobs: test_mweb_builder \ test_address_resolution test_compute_share_target \ test_utxo test_dgb_subsidy dgb_share_test \ + rpc_request_test softfork_check_test genesis_check_test \ test_coin_broadcaster test_multiaddress_pplns test_pplns_stress \ v37_test \ -j$(nproc) diff --git a/src/impl/dgb/coin/rpc.cpp b/src/impl/dgb/coin/rpc.cpp new file mode 100644 index 000000000..e1cbdbdfb --- /dev/null +++ b/src/impl/dgb/coin/rpc.cpp @@ -0,0 +1,436 @@ +#include "rpc.hpp" + +#include +#include +#include + +#include +#include +namespace dgb +{ + +namespace coin +{ + +// DGB chain-identity genesis hashes, DGB_MIN_DAEMON_VERSION and +// make_gbt_request: see impl/dgb/coin/rpc_request.hpp (oracle/identity SSOT). +// check() probes getblockheader(dgb_genesis_hash(IS_TESTNET)) to confirm the +// daemon is a real digibyted on the selected network. + +NodeRPC::NodeRPC(io::io_context* context, dgb::interfaces::Node* coin, bool testnet) + : m_context(context), IS_TESTNET(testnet), m_resolver(*context), m_stream(*context), + m_client(*this, RPC_VER), m_coin(coin) +{ +} + +void NodeRPC::connect(NetService address, std::string userpass) +{ + m_address = address; + m_userpass = userpass; + + m_auth = std::make_unique(); + m_http_request = {http::verb::post, "/", 11}; + + m_auth->host = address.to_string(); + m_http_request.set(http::field::host, m_auth->host); + + m_http_request.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + m_http_request.set(http::field::content_type, "application/json"); + m_http_request.set(http::field::connection, "keep-alive"); + + std::string encoded_login2; + encoded_login2.resize(boost::beast::detail::base64::encoded_size(userpass.size())); + const auto result = boost::beast::detail::base64::encode(&encoded_login2[0], userpass.data(), userpass.size()); + encoded_login2.resize(result); + m_auth->authorization = "Basic " + encoded_login2; + + m_http_request.set(http::field::authorization, m_auth->authorization); + + // Async DNS resolve — must NOT use the blocking m_resolver.resolve() overload + // as that stalls the entire io_context thread for the DNS round-trip. + m_resolver.async_resolve(address.address(), address.port_str(), + [this](boost::system::error_code ec, boost::asio::ip::tcp::resolver::results_type results) + { + if (ec) + { + LOG_ERROR << "CoindRPC DNS resolve failed: " << ec.message() << ", retrying in 15s"; + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); + return; + } + boost::asio::ip::tcp::endpoint endpoint = *results.begin(); + m_stream.async_connect(endpoint, + [this](boost::system::error_code ec) + { + if (ec) + { + if (ec == boost::system::errc::operation_canceled) + return; + + LOG_ERROR << "CoindRPC error when try connect: [" << ec.message() << "]."; + } else + { + try + { + if (check()) + { + m_connected = true; + LOG_INFO << "...CoindRPC connected!"; + return; + } + } + catch(const std::runtime_error& ec) + { + LOG_ERROR << "Error when try check CoindRPC: " << ec.what(); + } + } + + LOG_INFO << "Retry after 15 seconds..."; + m_connected = false; + m_stream.close(); + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); + } + ); + }); +} + +NodeRPC::~NodeRPC() +{ + beast::error_code ec; + m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); + if (ec) + { + // shutdown errors on close are typically benign; ignore + } +} + +void NodeRPC::reconnect() +{ + if (!m_connected) + return; // already reconnecting or never connected + m_connected = false; + LOG_WARNING << "RPC connection lost — reconnecting in 15 seconds..."; + m_stream.close(); + m_reconnect_timer = std::make_unique(m_context, false); + m_reconnect_timer->start(15, [this]() { connect(m_address, m_userpass); }); +} + +void NodeRPC::sync_reconnect() +{ + beast::error_code ec; + m_stream.socket().shutdown(io::ip::tcp::socket::shutdown_both, ec); + m_stream.close(); + + // Blocking resolve + connect for immediate retry + auto results = m_resolver.resolve(m_address.address(), m_address.port_str(), ec); + if (ec) { + LOG_WARNING << "CoindRPC sync_reconnect resolve failed: " << ec.message(); + return; + } + m_stream.connect(*results.begin(), ec); + if (ec) { + LOG_WARNING << "CoindRPC sync_reconnect connect failed: " << ec.message(); + return; + } + LOG_INFO << "CoindRPC reconnected (sync)"; +} + +std::string NodeRPC::Send(const std::string &request) +{ + // Retry once after synchronous reconnect on write/read failure + for (int attempt = 0; attempt < 2; ++attempt) + { + m_http_request.body() = request; + m_http_request.prepare_payload(); + try + { + http::write(m_stream, m_http_request); + } + catch(const std::exception& e) + { + LOG_WARNING << "CoindRPC write failed: " << e.what() + << (attempt == 0 ? " — reconnecting..." : ""); + if (attempt == 0) { + sync_reconnect(); + continue; + } + return {}; + } + + beast::flat_buffer buffer; + boost::beast::http::response response; + + try + { + boost::beast::http::read(m_stream, buffer, response); + } + catch (const std::exception& ex) + { + LOG_WARNING << "CoindRPC read failed: " << ex.what() + << (attempt == 0 ? " — reconnecting..." : ""); + if (attempt == 0) { + sync_reconnect(); + continue; + } + return {}; + } + + auto body = boost::beast::buffers_to_string(response.body().data()); + if (body.empty()) { + static int _empty_count = 0; + if (_empty_count++ < 5) + LOG_WARNING << "CoindRPC empty response: HTTP " << response.result_int() + << " content-length=" << response[http::field::content_length] + << " connection=" << response[http::field::connection]; + if (attempt == 0 && response.result_int() != 200) { + sync_reconnect(); + continue; + } + } + return body; + } + return {}; +} + +nlohmann::json NodeRPC::CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params) +{ + return m_client.CallMethod(ID, method, params); +} + +bool NodeRPC::check() +{ + uint256 genesis = uint256S(dgb_genesis_hash(IS_TESTNET)); + bool has_block = check_blockheader(genesis); + bool is_main_chain = getblockchaininfo()["chain"].get() == "main"; + nlohmann::json blockchaininfo; + + if (is_main_chain && !has_block) + { + LOG_ERROR << "Check failed! Make sure that you're connected to the right digibyted with --digibyte-rpc-port, and that it has finished syncing!" << std::endl; + return false; + } + + try + { + auto networkinfo = getnetworkinfo(); + bool version_check_result = daemon_version_acceptable(networkinfo["version"].get()); + if (!version_check_result) + { + LOG_ERROR << "DigiByte daemon too old! Upgrade!"; + return false; + } + } catch (const jsonrpccxx::JsonRpcException& ex) + { + LOG_WARNING << "NodeRPC::check() exception: " << ex.what(); + return false; + } + + try + { + blockchaininfo = getblockchaininfo(); + } catch (const jsonrpccxx::JsonRpcException& ex) + { + return false; + } + + std::set softforks_supported; + + if (blockchaininfo.contains("softforks")) + dgb::coin::collect_softfork_names(blockchaininfo["softforks"], softforks_supported); + if (blockchaininfo.contains("bip9_softforks")) + dgb::coin::collect_softfork_names(blockchaininfo["bip9_softforks"], softforks_supported); + + // Fallback for daemons that don't populate getblockchaininfo softfork fields. + if (softforks_supported.empty()) + { + try + { + auto gbt = getblocktemplate({"segwit"}); + if (gbt.contains("rules") && gbt["rules"].is_array()) + { + for (const auto& rule : gbt["rules"]) + { + if (!rule.is_string()) continue; + auto r = rule.get(); + if (!r.empty() && r[0] == '!') r.erase(r.begin()); + softforks_supported.insert(r); + } + } + } + catch (const std::exception&) + { + // Keep empty set; missing forks check below will fail safely. + } + } + + std::vector missing; + for (const auto& req : dgb::PoolConfig::SOFTFORKS_REQUIRED) + { + if (!softforks_supported.contains(req)) + missing.push_back(req); + } + + if (!missing.empty()) + { + std::string joined; + for (size_t i = 0; i < missing.size(); ++i) + { + if (i) joined += ", "; + joined += missing[i]; + } + LOG_ERROR << "DigiByte daemon missing required softfork features: " << joined; + LOG_ERROR << "Refusing to start to avoid mining invalid/non-consensus blocks."; + return false; + } + + return true; +} + +bool NodeRPC::check_blockheader(uint256 header) +{ + try + { + getblockheader(header); + return true; + } catch (const jsonrpccxx::JsonRpcException& ex) + { + return false; + } +} + +rpc::WorkData NodeRPC::getwork() +{ + auto start = core::timestamp(); + // DGB: Scrypt-only template. "segwit" is the BIP9 rule; the Scrypt algo + // is injected as the separate "algo" GBT param by getblocktemplate(). + auto work = getblocktemplate({"segwit"}); + auto end = core::timestamp(); + + if (!m_coin->txidcache.is_started()) + m_coin->txidcache.start(); + + // DGB family-1 seam carries txhashes only (WorkData is trimmed; no m_txs + // until the coin/transaction work re-homes it -- see rpc_data.hpp). The + // merkle tree uses the non-witness txid: prefer GBT's "txid" field, else + // hash the stripped "data" with the txidcache. + std::vector txhashes; + for (auto& packed_tx : work["transactions"]) + { + PackStream ps_tx; + + uint256 txid; + std::string x; + + if (packed_tx.contains("data")) + x = packed_tx["data"].get(); + else + x = packed_tx.get(); + + if (packed_tx.is_object() && packed_tx.contains("txid")) { + txid.SetHex(packed_tx["txid"].get()); + txhashes.push_back(txid); + } else if (m_coin->txidcache.exist(x)) + { + txid = m_coin->txidcache.get(x); + txhashes.push_back(txid); + } else + { + ps_tx = PackStream(ParseHex(x)); + txid = Hash(ps_tx.get_span()); + m_coin->txidcache.add(x, txid); + txhashes.push_back(txid); + } + } + + if ((core::timestamp() - m_coin->txidcache.time()) > 1800) + { + std::map keepers; + for (int i = 0; i < txhashes.size(); i++) + { + auto x = work["transactions"].at(i); + std::string keep; + if (x.contains("data")) + keep = x["data"].get(); + else + keep = x.get(); + uint256 txid = txhashes[i]; + keepers[keep] = txid; + } + m_coin->txidcache.clear(); + m_coin->txidcache.add(keepers); + } + + if (!work.contains("height")) + { + uint256 previous_block_hash = work["previousblockhash"].get(); + work["height"] = getblock(previous_block_hash)["height"].get() + 1; + } + + return rpc::WorkData{work, txhashes, end - start}; +} + +void NodeRPC::submit_block(BlockType& block, bool ignore_failure) +{ + // DGB is non-MWEB, btc-shaped: full-block packing includes witness data, + // no MWEB tail to append. + PackStream packed_block = pack(block); + auto result = m_client.CallMethod(ID, "submitblock", {HexStr(packed_block.get_span())}); + bool success = result.is_null(); + + auto success_expected = true; + + if ((!success && success_expected && !ignore_failure) || (success && !success_expected)) + LOG_ERROR << "Block submittal result: " << success << "(" << result.dump() << ") Expected: " << success_expected; +} + +bool NodeRPC::submit_block_hex(const std::string& block_hex, bool ignore_failure) +{ + auto result = m_client.CallMethod(ID, "submitblock", {block_hex}); + bool success = result.is_null(); + if (!success && !ignore_failure) + LOG_ERROR << "submit_block_hex result: " << result.dump(); + else if (success) + LOG_INFO << "submit_block_hex accepted"; + return success; +} + +// RPC Methods + +nlohmann::json NodeRPC::getblocktemplate(std::vector rules) +{ + // Body shape (segwit rule + separate Scrypt algo param) is the rpc_request.hpp SSOT. + return CallAPIMethod("getblocktemplate", {make_gbt_request(rules)}); +} + +nlohmann::json NodeRPC::getnetworkinfo() +{ + return CallAPIMethod("getnetworkinfo"); +} + +nlohmann::json NodeRPC::getblockchaininfo() +{ + return CallAPIMethod("getblockchaininfo"); +} + +nlohmann::json NodeRPC::getmininginfo() +{ + return CallAPIMethod("getmininginfo"); +} + +// verbose: true -- json result, false -- hex-encode result; +nlohmann::json NodeRPC::getblockheader(uint256 header, bool verbose) +{ + return CallAPIMethod("getblockheader", {header, verbose}); +} + +// verbosity: 0 for hex-encoded data, 1 for a json object, and 2 for json object with transaction data +nlohmann::json NodeRPC::getblock(uint256 blockhash, int verbosity) +{ + return CallAPIMethod("getblock", {blockhash, verbosity}); +} + +} // namespace coin + + +} // namespace dgb diff --git a/src/impl/dgb/coin/rpc.hpp b/src/impl/dgb/coin/rpc.hpp index 40a01f444..32b041b58 100644 --- a/src/impl/dgb/coin/rpc.hpp +++ b/src/impl/dgb/coin/rpc.hpp @@ -1,34 +1,47 @@ #pragma once // --------------------------------------------------------------------------- -// dgb::coin::NodeRPC -- external coin-RPC client STUB (Path A minimal-stub, -// "rpc stubs" scope item). +// dgb::coin::NodeRPC -- external coin-RPC client (M3 transport). // -// btc/ltc NodeRPC is a boost::beast + jsonrpccxx HTTP JSON-RPC client whose -// header drags in block.hpp / transaction.hpp -- types dgb does not port -// until M3. This stub keeps the call surface coin_node.cpp and coin/node.hpp -// consume, with no transport behind it: +// Real boost::beast + jsonrpccxx HTTP JSON-RPC client to an external +// digibyted, mirroring src/impl/btc/coin/rpc.{hpp,cpp}. This is the +// EXTERNAL-DAEMON FALLBACK path that V36 mandates persist alongside the +// embedded daemon (v36-master-plan external_fallback: "embedded primary, +// external fallback persists -- do NOT remove external-daemon code paths"). // -// getwork() -> throws std::runtime_error. This is the ICoinNode -// contract for "no template available" -- web_server -// already handles the throw, so a wired-but-stub DGB -// node degrades loudly, not silently. -// submit_block_hex() -> false ("no RPC sink"; the same result the null- -// m_rpc guard in CoinNode::submit_block_hex yields). -// check() -> false (never connected). +// DGB divergences from btc (conformed at port time): +// - getblocktemplate(): DigiByte GBT requires the "segwit" rule AND a +// separate top-level "algo" param. V36 is Scrypt-only, so the call is +// {"rules":[...],"algo":"scrypt"}. ("scrypt" is the mining algorithm, +// NOT a BIP9 rule -- the prior Path-A stub note rules=["scrypt"] was +// incorrect and is fixed here.) +// - check(): chain-identity probe uses the DigiByte genesis hash +// (mainnet/testnet selected by IS_TESTNET), not BTC's early-block hash. +// - WorkData is the trimmed (no m_txs) family-1 seam payload; getwork() +// populates m_data/m_hashes/m_latency only (see rpc_data.hpp). // -// connect()/reconnect()/the RPC method set (getblocktemplate etc.) arrive -// with the real transport in M3 as a body-only swap mirroring -// src/impl/btc/coin/rpc.{hpp,cpp}; DGB's getblocktemplate call passes -// rules=["scrypt"] per the Scrypt GBT filter point (template_builder.hpp, -// impl plan section 3) so only Scrypt-eligible templates are returned. +// Construction-site wiring (stub Node* -> io_context*,Node*,testnet) and the +// CMake/OBJECT-lib registration of rpc.cpp land post-#145 (the surface #145 +// moves); this file + rpc.cpp are additive coin-layer source on master. // --------------------------------------------------------------------------- -#include -#include - -#include "node_interface.hpp" +#include "block.hpp" #include "rpc_data.hpp" +#include "node_interface.hpp" + +#include + +#include +#include + +#include +#include +#include +#include + +namespace io = boost::asio; +namespace beast = boost::beast; +namespace http = beast::http; namespace dgb { @@ -36,28 +49,66 @@ namespace dgb namespace coin { -class NodeRPC +struct RPCAuthData; +class NodeRPC : public jsonrpccxx::IClientConnector { - // Shared-state surface the real client fills (work variable); the stub - // holds the pointer so the M3 swap does not change construction sites. - dgb::interfaces::Node* m_coin = nullptr; + const std::string ID = "curltest"; + const jsonrpccxx::version RPC_VER = jsonrpccxx::version::v2; + + const bool IS_TESTNET; +private: + dgb::interfaces::Node* m_coin; + + io::io_context* m_context; + beast::tcp_stream m_stream; + boost::asio::ip::tcp::resolver m_resolver; + http::request m_http_request; + + std::unique_ptr m_auth; + jsonrpccxx::JsonRpcClient m_client; + + // Reconnection state + NetService m_address; + std::string m_userpass; + bool m_connected = false; + std::unique_ptr m_reconnect_timer; + + std::string Send(const std::string &request) override; + nlohmann::json CallAPIMethod(const std::string& method, const jsonrpccxx::positional_parameter& params = {}); public: - explicit NodeRPC(dgb::interfaces::Node* coin = nullptr) : m_coin(coin) {} + NodeRPC(io::io_context* context, dgb::interfaces::Node* coin, bool testnet); + ~NodeRPC(); - // Never connected: no transport in the stub. - bool check() const { return false; } + void connect(NetService address, std::string userpass); + void reconnect(); + void sync_reconnect(); + bool check(); + bool check_blockheader(uint256 header); + rpc::WorkData getwork(); + void submit_block(BlockType& block, bool ignore_failure); + // Submit a pre-serialised block passed in as a hex string (avoids re-packing) + // Returns true if the daemon accepted the block (result is null), false otherwise. + bool submit_block_hex(const std::string& block_hex, bool ignore_failure); - rpc::WorkData getwork() - { - throw std::runtime_error( - "dgb: NodeRPC is a Path A stub -- no external RPC transport until M3"); - } + // RPC Methods + // DGB: rules are versionbits softforks; the Scrypt algo is a separate GBT + // param injected by the implementation (V36 Scrypt-only). + nlohmann::json getblocktemplate(std::vector rules); + nlohmann::json getnetworkinfo(); + nlohmann::json getblockchaininfo(); + nlohmann::json getmininginfo(); + // 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 + nlohmann::json getblock(uint256 blockhash, int verbosity = 1); - bool submit_block_hex(const std::string& /*block_hex*/, bool /*ignore_failure*/) - { - return false; - } +}; + +struct RPCAuthData +{ + std::string authorization; + std::string host; }; } // namespace coin diff --git a/src/impl/dgb/coin/rpc_request.hpp b/src/impl/dgb/coin/rpc_request.hpp new file mode 100644 index 000000000..1d5dc66d5 --- /dev/null +++ b/src/impl/dgb/coin/rpc_request.hpp @@ -0,0 +1,71 @@ +#pragma once + +// --------------------------------------------------------------------------- +// dgb::coin -- M3 RPC request-shape SSOT (pure, dependency-light). +// +// Single source of truth for the two oracle-pinned external-RPC contract +// values that NodeRPC::check()/getwork() depend on, factored out of rpc.cpp +// so a standalone test TU can guard them WITHOUT linking the boost::beast / +// jsonrpccxx transport (i.e. without entering the dgb OBJECT lib): +// +// 1. DGB_MIN_DAEMON_VERSION -- the getnetworkinfo["version"] accept floor, +// conformed to oracle frstrtr/p2pool-dgb-scrypt networks/digibyte.py +// VERSION_CHECK (82202 == DigiByte Core 7.17.2, oracle HEAD 22761e7). +// 2. make_gbt_request() -- DigiByte getblocktemplate body. DGB GBT requires +// the "segwit" rule AND a SEPARATE top-level "algo" param; V36 is +// Scrypt-only so algo is always "scrypt". "scrypt" is the mining +// algorithm, NOT a BIP9 rule -- the prior Path-A stub note rules= +// ["scrypt"] was wrong; the shape is {"rules":[...],"algo":"scrypt"}. +// +// Header-only + nlohmann-only: includes nothing from the transport stack, so +// it builds on master today and the guard test is a clean standalone link. +// --------------------------------------------------------------------------- + +#include +#include + +#include + +namespace dgb +{ + +namespace coin +{ + +// Minimum acceptable digibyted getnetworkinfo["version"] int (oracle-equivalent +// to p2pool-dgb-scrypt VERSION_CHECK: floor 82202 == DigiByte Core 7.17.2). +inline constexpr int DGB_MIN_DAEMON_VERSION = 82202; + +// True iff a daemon advertising getnetworkinfo["version"]==v clears the floor. +inline bool daemon_version_acceptable(int version) +{ + return DGB_MIN_DAEMON_VERSION <= version; +} + +// Build the DigiByte getblocktemplate request body: segwit rule(s) plus the +// mandatory separate Scrypt algo param. V36 c2pool-dgb is Scrypt-only. +inline nlohmann::json make_gbt_request(const std::vector& rules) +{ + return nlohmann::json::object({{"rules", rules}, {"algo", "scrypt"}}); +} + +// DGB chain-identity genesis hashes (DigiByte Core kernel/chainparams.cpp). +// NodeRPC::check() probes getblockheader(genesis) to confirm it is talking to +// a real digibyted (a wrong-coin daemon does not answer). These are a bucket-1 +// ISOLATION PRIMITIVE -- KEEP per-coin in v36 AND v37, never standardize; they +// are pinned in this SSOT only so a standalone TU can guard against accidental +// drift WITHOUT linking the boost::beast transport. +inline constexpr const char* DGB_GENESIS_MAIN = + "7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496"; +inline constexpr const char* DGB_GENESIS_TEST = + "308ea0711d5763be2995670dd9ca9872753561285a84da1d58be58acaa822252"; + +// The genesis hash NodeRPC::check() probes for the selected network. +inline const char* dgb_genesis_hash(bool testnet) +{ + return testnet ? DGB_GENESIS_TEST : DGB_GENESIS_MAIN; +} + +} // namespace coin + +} // namespace dgb diff --git a/src/impl/dgb/coin/softfork_check.hpp b/src/impl/dgb/coin/softfork_check.hpp new file mode 100644 index 000000000..02a8d2e9a --- /dev/null +++ b/src/impl/dgb/coin/softfork_check.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include +#include + +namespace dgb::coin { + +/** + * Populate `out` with all softfork names found in a single getblockchaininfo + * field value (either the "softforks" or "bip9_softforks" entry). + * + * Handles three formats produced by different digibyted versions: + * - Array of objects: [{"id":"segwit",...}, ...] (modern digibyted) + * - Array of strings: ["segwit", "taproot", ...] (compact form) + * - Object with keys: {"segwit":{...}, "taproot":{...}} (BIP9 style) + */ +inline void collect_softfork_names(const nlohmann::json& value, + std::set& out) +{ + if (value.is_array()) + { + for (const auto& item : value) + { + if (item.is_object() && item.contains("id") && item["id"].is_string()) + out.insert(item["id"].get()); + else if (item.is_string()) + out.insert(item.get()); + } + } + else if (value.is_object()) + { + for (auto it = value.begin(); it != value.end(); ++it) + out.insert(it.key()); + } +} + +} // namespace dgb::coin diff --git a/src/impl/dgb/test/CMakeLists.txt b/src/impl/dgb/test/CMakeLists.txt index 2f4c1b5be..c96043cbd 100644 --- a/src/impl/dgb/test/CMakeLists.txt +++ b/src/impl/dgb/test/CMakeLists.txt @@ -16,4 +16,19 @@ if (BUILD_TESTING AND GTest_FOUND) include(GoogleTest) include_directories(${gtest_SOURCE_DIR}/include ${gtest_SOURCE_DIR}) gtest_add_tests(dgb_share_test "" AUTO) + + # --- M3 RPC-transport standalone regression guards --------------------- + # Header-only guards over the external-daemon RPC coin-layer SSOTs + # (rpc_request.hpp request-shape + version floor + genesis identity, and + # softfork_check.hpp softfork-name collection). They link no OBJECT lib — + # only GTest + nlohmann_json — so they stay buildable even while the + # transport rpc.cpp wiring is deferred (Option-B scope). Each MUST appear + # in BOTH this ctest registration AND the build.yml --target allowlist, + # or it becomes a #143-style NOT_BUILT sentinel that reds master. + foreach(dgb_guard rpc_request_test softfork_check_test genesis_check_test) + add_executable(${dgb_guard} ${dgb_guard}.cpp) + target_link_libraries(${dgb_guard} PRIVATE + GTest::gtest_main GTest::gtest nlohmann_json::nlohmann_json) + gtest_add_tests(${dgb_guard} "" AUTO) + endforeach() endif() diff --git a/src/impl/dgb/test/genesis_check_test.cpp b/src/impl/dgb/test/genesis_check_test.cpp new file mode 100644 index 000000000..3b0a9f332 --- /dev/null +++ b/src/impl/dgb/test/genesis_check_test.cpp @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------- +// dgb M3 chain-identity (genesis) regression guard. +// +// Pins the DigiByte genesis hashes that NodeRPC::check() probes via +// getblockheader(dgb_genesis_hash(IS_TESTNET)) to confirm the external +// digibyted is a real DigiByte node on the selected network. These are a +// bucket-1 ISOLATION PRIMITIVE -- coin-identity, KEEP per-coin in v36 AND +// v37, never standardized -- so this guard locks them against accidental +// drift (a copy/paste from another coin, or main<->test swap) BEFORE the +// deferred transport wiring lands post-#145 and rpc.cpp finally CI-links. +// +// Links ONLY the pure SSOT header (rpc_request.hpp) + gtest -- no +// boost::beast/jsonrpccxx transport, so it builds standalone without +// entering the dgb OBJECT lib. +// --------------------------------------------------------------------------- + +#include + +#include + +#include + +using namespace dgb::coin; + +// Canonical values from DigiByte Core kernel/chainparams.cpp. Independent +// literals so the test fails loudly if the SSOT constant is ever edited. +static const char* CANON_MAIN = + "7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496"; +static const char* CANON_TEST = + "308ea0711d5763be2995670dd9ca9872753561285a84da1d58be58acaa822252"; + +TEST(DgbGenesis, MainnetHashIsCanonical) +{ + EXPECT_STREQ(DGB_GENESIS_MAIN, CANON_MAIN); +} + +TEST(DgbGenesis, TestnetHashIsCanonical) +{ + EXPECT_STREQ(DGB_GENESIS_TEST, CANON_TEST); +} + +TEST(DgbGenesis, MainAndTestDiffer) +{ + // Guards against a copy/paste that would make both networks probe the + // same hash -- a wrong-coin daemon could then pass the testnet check. + EXPECT_STRNE(DGB_GENESIS_MAIN, DGB_GENESIS_TEST); +} + +TEST(DgbGenesis, HashesAre64HexChars) +{ + EXPECT_EQ(std::strlen(DGB_GENESIS_MAIN), 64u); + EXPECT_EQ(std::strlen(DGB_GENESIS_TEST), 64u); + for (const char* h : {DGB_GENESIS_MAIN, DGB_GENESIS_TEST}) + for (const char* c = h; *c; ++c) + EXPECT_TRUE(std::isxdigit(static_cast(*c))) << "non-hex in " << h; +} + +// --- Network selector NodeRPC::check() relies on --------------------------- + +TEST(DgbGenesis, SelectorPicksMainnet) +{ + EXPECT_STREQ(dgb_genesis_hash(false), DGB_GENESIS_MAIN); +} + +TEST(DgbGenesis, SelectorPicksTestnet) +{ + EXPECT_STREQ(dgb_genesis_hash(true), DGB_GENESIS_TEST); +} + +TEST(DgbGenesis, SelectorBranchesDiffer) +{ + EXPECT_STRNE(dgb_genesis_hash(true), dgb_genesis_hash(false)); +} diff --git a/src/impl/dgb/test/rpc_request_test.cpp b/src/impl/dgb/test/rpc_request_test.cpp new file mode 100644 index 000000000..eb008ba06 --- /dev/null +++ b/src/impl/dgb/test/rpc_request_test.cpp @@ -0,0 +1,84 @@ +// --------------------------------------------------------------------------- +// dgb M3 RPC request-shape regression guard. +// +// Pins the two oracle-conformed external-RPC contract values that NodeRPC +// depends on, BEFORE the deferred wiring (CMake + OBJECT-lib reg + ctor swap) +// lands post-#145 and rpc.cpp finally CI-links. Guards against silent drift of: +// +// 1. the getnetworkinfo["version"] accept floor (oracle p2pool-dgb-scrypt +// VERSION_CHECK == 82202, DigiByte Core 7.17.2), and +// 2. the getblocktemplate body shape: DGB requires the "segwit" rule AND a +// SEPARATE top-level "algo":"scrypt" param -- "scrypt" must NOT leak into +// the rules array (the prior Path-A stub bug rules=["scrypt"]). +// +// Links ONLY the pure SSOT header (rpc_request.hpp) + nlohmann + gtest -- no +// boost::beast/jsonrpccxx transport, so it builds standalone without entering +// the dgb OBJECT lib. +// --------------------------------------------------------------------------- + +#include + +#include + +using namespace dgb::coin; + +// --- Daemon-version accept floor (oracle VERSION_CHECK 82202) --------------- + +TEST(DgbRpcRequest, VersionFloorIsOracleValue) +{ + EXPECT_EQ(DGB_MIN_DAEMON_VERSION, 82202); // DigiByte Core 7.17.2 +} + +TEST(DgbRpcRequest, VersionAtFloorAccepts) +{ + EXPECT_TRUE(daemon_version_acceptable(82202)); +} + +TEST(DgbRpcRequest, VersionAboveFloorAccepts) +{ + EXPECT_TRUE(daemon_version_acceptable(82203)); + EXPECT_TRUE(daemon_version_acceptable(90000)); +} + +TEST(DgbRpcRequest, VersionBelowFloorRejects) +{ + EXPECT_FALSE(daemon_version_acceptable(82201)); + EXPECT_FALSE(daemon_version_acceptable(71700)); // old placeholder floor + EXPECT_FALSE(daemon_version_acceptable(0)); +} + +// --- getblocktemplate request body shape ------------------------------------ + +TEST(DgbRpcRequest, GbtCarriesSeparateScryptAlgo) +{ + auto j = make_gbt_request({"segwit"}); + ASSERT_TRUE(j.contains("algo")); + EXPECT_EQ(j["algo"].get(), "scrypt"); +} + +TEST(DgbRpcRequest, GbtRulesContainSegwit) +{ + auto j = make_gbt_request({"segwit"}); + ASSERT_TRUE(j.contains("rules")); + ASSERT_TRUE(j["rules"].is_array()); + ASSERT_EQ(j["rules"].size(), 1u); + EXPECT_EQ(j["rules"][0].get(), "segwit"); +} + +TEST(DgbRpcRequest, ScryptIsAlgoNotRule) +{ + // Regression guard for the Path-A bug: "scrypt" is the mining algorithm, + // NOT a BIP9 rule -- it must never appear in the rules array. + auto j = make_gbt_request({"segwit"}); + for (const auto& r : j["rules"]) + EXPECT_NE(r.get(), "scrypt"); +} + +TEST(DgbRpcRequest, GbtPreservesCallerRulesAndForcesScrypt) +{ + auto j = make_gbt_request({"segwit", "!extra"}); + ASSERT_EQ(j["rules"].size(), 2u); + EXPECT_EQ(j["rules"][0].get(), "segwit"); + EXPECT_EQ(j["rules"][1].get(), "!extra"); + EXPECT_EQ(j["algo"].get(), "scrypt"); +} diff --git a/src/impl/dgb/test/softfork_check_test.cpp b/src/impl/dgb/test/softfork_check_test.cpp new file mode 100644 index 000000000..57db47f42 --- /dev/null +++ b/src/impl/dgb/test/softfork_check_test.cpp @@ -0,0 +1,108 @@ +// --------------------------------------------------------------------------- +// dgb M3 softfork-detection regression guard. +// +// Pins collect_softfork_names() (softfork_check.hpp), the parser NodeRPC uses +// to decide whether "segwit" (and friends) are active on the external +// digibyted BEFORE c2pool-dgb trusts the Scrypt getblocktemplate path. The +// parser must tolerate all three getblockchaininfo["softforks"]/["bip9_softforks"] +// encodings shipped by different DigiByte Core versions: +// +// 1. array of objects: [{"id":"segwit",...}, ...] (modern digibyted) +// 2. array of strings: ["segwit","taproot", ...] (compact form) +// 3. object with keys: {"segwit":{...}, ...} (BIP9 style) +// +// Header-only + nlohmann + gtest -- no boost::beast/jsonrpccxx transport, so it +// builds standalone without entering the dgb OBJECT lib (same gate-safe shape +// as rpc_request_test.cpp). +// --------------------------------------------------------------------------- + +#include + +#include + +#include +#include + +using dgb::coin::collect_softfork_names; +using json = nlohmann::json; + +// --- Format 1: array of objects (modern digibyted) -------------------------- + +TEST(DgbSoftforkCheck, ArrayOfObjectsCollectsIds) +{ + auto v = json::parse(R"([{"id":"segwit","active":true},{"id":"taproot"}])"); + std::set out; + collect_softfork_names(v, out); + EXPECT_EQ(out, (std::set{"segwit", "taproot"})); +} + +TEST(DgbSoftforkCheck, ArrayObjectMissingOrNonStringIdSkipped) +{ + // An object without a string "id" must be ignored, not crash the parse. + auto v = json::parse(R"([{"id":"segwit"},{"name":"noid"},{"id":42}])"); + std::set out; + collect_softfork_names(v, out); + EXPECT_EQ(out, (std::set{"segwit"})); +} + +// --- Format 2: array of strings (compact form) ------------------------------ + +TEST(DgbSoftforkCheck, ArrayOfStringsCollectsValues) +{ + auto v = json::parse(R"(["segwit","taproot"])"); + std::set out; + collect_softfork_names(v, out); + EXPECT_EQ(out, (std::set{"segwit", "taproot"})); +} + +// --- Format 3: object keyed by softfork name (BIP9 style) ------------------- + +TEST(DgbSoftforkCheck, ObjectCollectsKeys) +{ + auto v = json::parse(R"({"segwit":{"status":"active"},"taproot":{"status":"defined"}})"); + std::set out; + collect_softfork_names(v, out); + EXPECT_EQ(out, (std::set{"segwit", "taproot"})); +} + +// --- The contract that actually matters: segwit is detectable in every form - + +TEST(DgbSoftforkCheck, SegwitDetectedAcrossAllThreeEncodings) +{ + for (const auto& src : {R"([{"id":"segwit"}])", R"(["segwit"])", R"({"segwit":{}})"}) + { + std::set out; + collect_softfork_names(json::parse(src), out); + EXPECT_TRUE(out.count("segwit")) << "segwit not found in: " << src; + } +} + +// --- Accumulation + robustness --------------------------------------------- + +TEST(DgbSoftforkCheck, AccumulatesIntoExistingSet) +{ + // Caller merges the "softforks" and "bip9_softforks" fields into one set; + // collect_softfork_names must append, never clear. + std::set out{"preexisting"}; + collect_softfork_names(json::parse(R"(["segwit"])"), out); + EXPECT_EQ(out, (std::set{"preexisting", "segwit"})); +} + +TEST(DgbSoftforkCheck, EmptyContainersYieldNothing) +{ + std::set out; + collect_softfork_names(json::parse("[]"), out); + collect_softfork_names(json::parse("{}"), out); + EXPECT_TRUE(out.empty()); +} + +TEST(DgbSoftforkCheck, ScalarValueIsNoOp) +{ + // A missing field deserialized as null/number/string must be a safe no-op + // (the field may be absent on very old daemons). + std::set out; + collect_softfork_names(json(nullptr), out); + collect_softfork_names(json(7), out); + collect_softfork_names(json("segwit"), out); // bare scalar, not a container + EXPECT_TRUE(out.empty()); +}