Skip to content

Commit 6ef26d3

Browse files
committed
dash(stratum): dashd ZMQ hashblock INSTANT tip-notify on the fallback arm
Hardening stacked on the #770 fallback-arm 3 s getbestblockhash poll: dashd publishes a `hashblock` ZMQ message the instant it connects a new block, so a SUB subscriber closes the lost-block window from <=3 s (poll) to ~0 s. The hotel lost DASH block 2508008 because the dashd-fallback template arm served a stale masternode-payee template after a tip move; #770 cut that to <=3 s, this cuts it to instant. WHAT - New opt-in flag --coin-zmq-hashblock tcp://HOST:PORT. When set (and libzmq is compiled in), a background SUB subscriber dials dashd's `hashblock` topic and on every new block hash fires the SAME refresh trio the poll fires: invalidate_template_cache() + bump_work_generation() + notify_all() (clean_jobs). Logs "[Stratum] zmq hashblock: <hash> -> instant template refresh + notify". - The #770 3 s poll STAYS as a backstop. Both paths share ONE last-seen-tip dedup (TipHashDedup), so a poll+ZMQ double-fire on the same block coalesces to a single refresh (the second is_new_tip() is a no-op). - The ZMQ callback hops onto the io_context (boost::asio::post) before touching work_source/stratum, so the refresh always runs single-threaded on the run loop -- no cross-thread race with the poll. ZERO REGRESSION WHEN ZMQ ABSENT - The flag is opt-in. Unset => a clean log line notes ZMQ is not configured and the 3 s poll is the active path -- byte-identical to #770. - libzmq is an OPTIONAL build dep gated by the C2POOL_WITH_ZMQ CMake option / C2POOL_ZMQ define. Absent => the subscriber class is not compiled and c2pool-dash builds + runs poll-only. An unreachable/late endpoint never crashes or blocks the run loop: the recv loop uses a receive timeout and reconnects with a bounded backoff. DEPENDENCY - conanfile.txt: + zeromq/4.3.5 (options: encryption=False -- plain unauthenticated SUB, no libsodium). conan.lock updated. CMake links the conan `libzmq`/`libzmq-static` target only when found. CONSENSUS-NEUTRAL: template-refresh / stratum-notify path only. No difficulty retarget, share-selection, reward-split, or block-acceptance code is touched. TEST: extends test_dash_stratum_work_source (already in both build.yml --target allowlists -- no workflow edit) with the frame-decode byte-order KAT and the instant-notify trio + double-fire dedup KAT (33/33 pass). The KAT compiles the pure helpers without libzmq.
1 parent 08a47a0 commit 6ef26d3

9 files changed

Lines changed: 550 additions & 42 deletions

File tree

CMakeLists.txt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,35 @@ find_package(Boost QUIET OPTIONAL_COMPONENTS system)
3434
find_package(nlohmann_json REQUIRED)
3535
find_package(yaml-cpp REQUIRED) # hoisted to root so yaml-cpp::yaml-cpp is visible to src/c2pool OBJECT libs (sibling of src/core)
3636

37+
# Optional dashd ZMQ `hashblock` INSTANT tip-notify (DASH fallback-arm hardening
38+
# on top of the #770 3 s poll). libzmq is an OPTIONAL dependency: when present
39+
# the c2pool-dash --coin-zmq-hashblock path is compiled (C2POOL_ZMQ define);
40+
# when absent c2pool-dash builds + runs exactly as the poll-only path. The conan
41+
# recipe (zeromq/4.3.5) exposes the CMake target `libzmq` via package `ZeroMQ`.
42+
option(C2POOL_WITH_ZMQ "Build dashd ZMQ hashblock instant tip-notify (optional; needs libzmq)" ON)
43+
set(C2POOL_ZMQ_AVAILABLE OFF)
44+
set(C2POOL_ZMQ_TARGET "")
45+
if(C2POOL_WITH_ZMQ)
46+
find_package(ZeroMQ QUIET)
47+
if(TARGET libzmq)
48+
set(C2POOL_ZMQ_AVAILABLE ON)
49+
set(C2POOL_ZMQ_TARGET libzmq)
50+
elseif(TARGET libzmq-static)
51+
set(C2POOL_ZMQ_AVAILABLE ON)
52+
set(C2POOL_ZMQ_TARGET libzmq-static)
53+
elseif(TARGET zmq)
54+
set(C2POOL_ZMQ_AVAILABLE ON)
55+
set(C2POOL_ZMQ_TARGET zmq)
56+
endif()
57+
if(C2POOL_ZMQ_AVAILABLE)
58+
message(STATUS "dashd ZMQ hashblock tip-notify: ENABLED (libzmq via ${C2POOL_ZMQ_TARGET})")
59+
else()
60+
message(STATUS "dashd ZMQ hashblock tip-notify: DISABLED (libzmq not found; c2pool-dash builds poll-only)")
61+
endif()
62+
else()
63+
message(STATUS "dashd ZMQ hashblock tip-notify: DISABLED (C2POOL_WITH_ZMQ=OFF)")
64+
endif()
65+
3766
# Threading (needed by LevelDB on some platforms)
3867
find_package(Threads REQUIRED)
3968

conan.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"version": "0.5",
33
"requires": [
44
"zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1777558780.503",
5+
"zeromq/4.3.5",
56
"yaml-cpp/0.8.0#1aa371213f0307605d88ad5445581aff%1773246038.815",
67
"snappy/1.1.10#9cff2aa2cf616aa98532b1872ef44dcf%1743607712.928",
78
"nlohmann_json/3.12.0#2d634ab0ec8d9f56353e5ccef6d6612c%1744735883.94",

conanfile.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ nlohmann_json/3.12.0
44
yaml-cpp/0.8.0
55
gtest/1.14.0
66
leveldb/1.23
7+
zeromq/4.3.5
78

89
[options]
910
boost/*:without_exception=False
1011
boost/*:without_cobalt=True
12+
# dashd `hashblock` is a plain unauthenticated SUB socket: no CURVE/encryption
13+
# needed, so drop the libsodium transitive dep and keep the build lean.
14+
zeromq/*:encryption=False
1115

1216
[generators]
1317
CMakeDeps

src/c2pool/main_dash.cpp

Lines changed: 122 additions & 40 deletions
Large diffs are not rendered by default.

src/impl/dash/CMakeLists.txt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,23 @@ target_link_libraries(dash_block_replay PRIVATE core nlohmann_json::nlohmann_jso
3434
# links it is c2pool-dash (this slice), so the ltc/btc/doge/dgb build + ctest
3535
# surfaces are untouched. Per-coin isolation held: src/impl/dash only, no
3636
# shared-base/core SOURCE edit, dashd RPC fallback preserved.
37-
add_library(dash_rpc STATIC coin/rpc.cpp)
37+
add_library(dash_rpc STATIC coin/rpc.cpp coin/zmq_tip_notify.cpp)
3838
target_include_directories(dash_rpc PRIVATE
3939
${CMAKE_SOURCE_DIR}/src
4040
${CMAKE_SOURCE_DIR}/src/btclibs
4141
${CMAKE_SOURCE_DIR}/include)
4242
target_link_libraries(dash_rpc PRIVATE core nlohmann_json::nlohmann_json ${Boost_LIBRARIES})
4343

44+
# dashd ZMQ `hashblock` instant tip-notify (coin/zmq_tip_notify.cpp). libzmq is
45+
# OPTIONAL: when available, compile the subscriber (C2POOL_ZMQ) and link libzmq.
46+
# The define + link are PUBLIC so the ONLY consumer (c2pool-dash / main_dash.cpp)
47+
# inherits them — one wiring point. When libzmq is absent only the pure hex
48+
# helper compiles and c2pool-dash is byte-identical to the poll-only path.
49+
if(C2POOL_ZMQ_AVAILABLE)
50+
target_compile_definitions(dash_rpc PUBLIC C2POOL_ZMQ)
51+
target_link_libraries(dash_rpc PUBLIC ${C2POOL_ZMQ_TARGET})
52+
endif()
53+
4454
# S8 sharechain-p2p dispatch layer (slice S8-p2p.2). Mirrors the dgb pool OBJECT
4555
# lib (src/impl/dgb/CMakeLists.txt add_library(dgb OBJECT ...)): compiles the
4656
# Legacy/Actual established-peer protocol handlers so the 12-message dispatch
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// dashd ZMQ `hashblock` instant tip-notify -- implementation.
2+
// See zmq_tip_notify.hpp for the contract. libzmq usage is guarded by
3+
// C2POOL_ZMQ; the pure hex helper is always compiled so the KAT can pin the
4+
// frame-decode contract without libzmq.
5+
6+
#include "zmq_tip_notify.hpp"
7+
8+
// NOTE: all #includes MUST be at file scope, never inside the namespace below.
9+
#ifdef C2POOL_ZMQ
10+
#include <zmq.h>
11+
#include <cerrno>
12+
#include <chrono>
13+
#include <cstring>
14+
#include <iostream>
15+
#include <thread>
16+
#include <utility>
17+
#endif
18+
19+
namespace dash::coin {
20+
21+
std::string zmq_hashblock_frame_to_hex(const unsigned char* body, unsigned long len)
22+
{
23+
if (!body || len != 32)
24+
return {};
25+
static const char* const hexd = "0123456789abcdef";
26+
std::string out;
27+
out.resize(64);
28+
// dashd publishes the hash ALREADY in RPC/display (reversed) byte order:
29+
// CZMQPublishHashBlockNotifier::NotifyBlock writes data[31-i]=hash.begin()[i]
30+
// before SendZmqMessage, i.e. it reverses the internal little-endian array
31+
// for us (Bitcoin Core doc/zmq.md: hashes are published "in reversed byte
32+
// order, the same format as the RPC interface"). So the wire frame
33+
// hex-encodes DIRECTLY to the getbestblockhash string -- do NOT reverse
34+
// again, or the ZMQ hash never matches the poll's and the shared
35+
// TipHashDedup never coalesces the poll+ZMQ double-fire.
36+
for (unsigned i = 0; i < 32; ++i) {
37+
const unsigned char b = body[i];
38+
out[i * 2] = hexd[b >> 4];
39+
out[i * 2 + 1] = hexd[b & 0x0f];
40+
}
41+
return out;
42+
}
43+
44+
#ifdef C2POOL_ZMQ
45+
46+
ZmqHashblockSubscriber::ZmqHashblockSubscriber(
47+
std::string endpoint, std::function<void(const std::string&)> on_new_tip)
48+
: endpoint_(std::move(endpoint)), on_new_tip_(std::move(on_new_tip))
49+
{
50+
}
51+
52+
ZmqHashblockSubscriber::~ZmqHashblockSubscriber()
53+
{
54+
stop();
55+
}
56+
57+
void ZmqHashblockSubscriber::start()
58+
{
59+
if (running_.load() || thread_.joinable())
60+
return;
61+
ctx_ = zmq_ctx_new();
62+
if (!ctx_) {
63+
std::cout << "[Stratum] zmq hashblock: zmq_ctx_new failed; "
64+
"falling back to the 3 s poll only\n";
65+
return;
66+
}
67+
stop_.store(false);
68+
thread_ = std::thread([this] { run_loop(); });
69+
}
70+
71+
void ZmqHashblockSubscriber::stop()
72+
{
73+
stop_.store(true);
74+
if (thread_.joinable())
75+
thread_.join();
76+
if (ctx_) {
77+
zmq_ctx_term(ctx_);
78+
ctx_ = nullptr;
79+
}
80+
}
81+
82+
void ZmqHashblockSubscriber::run_loop()
83+
{
84+
running_.store(true);
85+
while (!stop_.load()) {
86+
void* sub = zmq_socket(ctx_, ZMQ_SUB);
87+
if (!sub) {
88+
// Cannot create a socket -- back off and retry (never spin).
89+
for (int i = 0; i < 20 && !stop_.load(); ++i)
90+
std::this_thread::sleep_for(std::chrono::milliseconds(100));
91+
continue;
92+
}
93+
int rcv_timeout_ms = 500; // so the loop re-checks stop_ ~2x/s
94+
zmq_setsockopt(sub, ZMQ_RCVTIMEO, &rcv_timeout_ms, sizeof rcv_timeout_ms);
95+
int linger = 0;
96+
zmq_setsockopt(sub, ZMQ_LINGER, &linger, sizeof linger);
97+
zmq_setsockopt(sub, ZMQ_SUBSCRIBE, "hashblock", 9);
98+
99+
if (zmq_connect(sub, endpoint_.c_str()) != 0) {
100+
std::cout << "[Stratum] zmq hashblock: connect to " << endpoint_
101+
<< " failed (" << zmq_strerror(zmq_errno())
102+
<< "); the 3 s poll remains the active path; retrying\n";
103+
zmq_close(sub);
104+
for (int i = 0; i < 20 && !stop_.load(); ++i)
105+
std::this_thread::sleep_for(std::chrono::milliseconds(100));
106+
continue;
107+
}
108+
std::cout << "[Stratum] zmq hashblock: subscribed at " << endpoint_
109+
<< " (primary instant tip-notify; 3 s poll is the backstop)\n";
110+
111+
// Receive loop. dashd hashblock messages are multipart:
112+
// [0] topic "hashblock"
113+
// [1] body 32-byte block hash (little-endian internal order)
114+
// [2] seq 4-byte LE sequence (drained, unused)
115+
while (!stop_.load()) {
116+
zmq_msg_t topic;
117+
zmq_msg_init(&topic);
118+
int r = zmq_msg_recv(&topic, sub, 0);
119+
if (r < 0) {
120+
zmq_msg_close(&topic);
121+
if (zmq_errno() == EAGAIN)
122+
continue; // timeout: re-check stop_ and retry
123+
break; // real transport error: reconnect
124+
}
125+
std::string hex;
126+
int more = 0;
127+
size_t more_sz = sizeof more;
128+
zmq_getsockopt(sub, ZMQ_RCVMORE, &more, &more_sz);
129+
if (more) {
130+
zmq_msg_t body;
131+
zmq_msg_init(&body);
132+
if (zmq_msg_recv(&body, sub, 0) >= 0) {
133+
hex = zmq_hashblock_frame_to_hex(
134+
static_cast<const unsigned char*>(zmq_msg_data(&body)),
135+
zmq_msg_size(&body));
136+
}
137+
zmq_msg_close(&body);
138+
}
139+
zmq_msg_close(&topic);
140+
// Drain any trailing frames (sequence number etc).
141+
more = 0;
142+
more_sz = sizeof more;
143+
zmq_getsockopt(sub, ZMQ_RCVMORE, &more, &more_sz);
144+
while (more && !stop_.load()) {
145+
zmq_msg_t drop;
146+
zmq_msg_init(&drop);
147+
zmq_msg_recv(&drop, sub, 0);
148+
zmq_msg_close(&drop);
149+
more = 0;
150+
more_sz = sizeof more;
151+
zmq_getsockopt(sub, ZMQ_RCVMORE, &more, &more_sz);
152+
}
153+
if (!hex.empty() && on_new_tip_) {
154+
try {
155+
on_new_tip_(hex);
156+
} catch (...) {
157+
// never let a callback throw kill the subscriber thread
158+
}
159+
}
160+
}
161+
zmq_close(sub);
162+
// Bounded backoff before reconnect on a broken stream.
163+
for (int i = 0; i < 20 && !stop_.load(); ++i)
164+
std::this_thread::sleep_for(std::chrono::milliseconds(100));
165+
}
166+
running_.store(false);
167+
}
168+
169+
#endif // C2POOL_ZMQ
170+
171+
} // namespace dash::coin
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#pragma once
2+
// dashd ZMQ `hashblock` instant tip-notify for the DASH fallback arm.
3+
//
4+
// This is a HARDENING on top of the #770 fallback-arm 3 s getbestblockhash
5+
// poll: dashd publishes a `hashblock` ZMQ message the instant a new block
6+
// connects, so a SUB subscriber closes the lost-block window from <=3 s
7+
// (poll) to ~0 s. On a new tip it fires the SAME refresh trio the poll fires
8+
// (invalidate_template_cache + bump_work_generation + notify_all), and it
9+
// shares the poll's last-seen-tip dedup so a ZMQ notify and a poll tick on the
10+
// same tip coalesce to a single refresh.
11+
//
12+
// OPT-IN + optional: the subscriber is only constructed when the operator
13+
// passes --coin-zmq-hashblock ENDPOINT. libzmq itself is an OPTIONAL build
14+
// dependency guarded by C2POOL_ZMQ; when libzmq is absent the class is not
15+
// declared and c2pool-dash builds + runs exactly as the poll-only #770 branch.
16+
//
17+
// CONSENSUS-NEUTRAL: template-refresh / stratum-notify path only. No
18+
// difficulty retarget, share-selection, reward-split, or block-acceptance code
19+
// is touched.
20+
21+
#include <functional>
22+
#include <string>
23+
24+
#ifdef C2POOL_ZMQ
25+
#include <atomic>
26+
#include <thread>
27+
#endif
28+
29+
namespace dash::coin {
30+
31+
// Pure dedup helper -- testable without libzmq. Returns true iff `hash_hex`
32+
// is a NEW tip (non-empty AND != the last observed). The fallback-arm poll and
33+
// the ZMQ subscriber share ONE instance so a double-fire on the same tip (both
34+
// paths racing on the same block) resolves to a single refresh trio.
35+
class TipHashDedup
36+
{
37+
public:
38+
bool is_new_tip(const std::string& hash_hex)
39+
{
40+
if (hash_hex.empty() || hash_hex == last_)
41+
return false;
42+
last_ = hash_hex;
43+
return true;
44+
}
45+
const std::string& last() const { return last_; }
46+
void set_last(const std::string& h) { last_ = h; }
47+
48+
private:
49+
std::string last_;
50+
};
51+
52+
// Convert a raw dashd ZMQ `hashblock` body (the 32-byte block hash) to the hex
53+
// string that getbestblockhash reports. dashd publishes the hash ALREADY in
54+
// RPC/display (reversed) byte order -- CZMQPublishHashBlockNotifier::NotifyBlock
55+
// reverses the internal little-endian array before sending -- so this helper
56+
// hex-encodes the frame DIRECTLY (NO further reversal). That is what makes a ZMQ
57+
// notify and a poll tick produce the SAME hash string, so they dedup against
58+
// each other. Returns "" if len != 32 or body is null. Pure; no libzmq needed.
59+
std::string zmq_hashblock_frame_to_hex(const unsigned char* body, unsigned long len);
60+
61+
#ifdef C2POOL_ZMQ
62+
// dashd ZMQ `hashblock` SUB subscriber. Owns a background thread that dials
63+
// `endpoint` (e.g. tcp://127.0.0.1:28332), subscribes to the "hashblock"
64+
// topic, and invokes `on_new_tip(hash_hex)` for every block-hash frame. The
65+
// callback runs on the subscriber thread -- the caller is responsible for
66+
// hopping onto its run-loop thread (e.g. boost::asio::post) before touching
67+
// work_source / stratum state.
68+
//
69+
// Never throws into the caller. A down/unreachable endpoint does NOT crash or
70+
// block the node: the recv loop uses a receive timeout, and transport errors
71+
// trigger a bounded reconnect backoff. stop() joins the thread.
72+
class ZmqHashblockSubscriber
73+
{
74+
public:
75+
ZmqHashblockSubscriber(std::string endpoint,
76+
std::function<void(const std::string&)> on_new_tip);
77+
~ZmqHashblockSubscriber();
78+
79+
ZmqHashblockSubscriber(const ZmqHashblockSubscriber&) = delete;
80+
ZmqHashblockSubscriber& operator=(const ZmqHashblockSubscriber&) = delete;
81+
82+
// Spawn the subscriber thread. Idempotent-safe to call once.
83+
void start();
84+
// Signal the thread to stop and join it; tears down the ZMQ context.
85+
void stop();
86+
87+
bool running() const { return running_.load(); }
88+
const std::string& endpoint() const { return endpoint_; }
89+
90+
private:
91+
void run_loop();
92+
93+
std::string endpoint_;
94+
std::function<void(const std::string&)> on_new_tip_;
95+
void* ctx_ = nullptr; // zmq context (void* -- keeps zmq.h out of the header)
96+
std::thread thread_;
97+
std::atomic<bool> stop_{false};
98+
std::atomic<bool> running_{false};
99+
};
100+
#endif // C2POOL_ZMQ
101+
102+
} // namespace dash::coin

test/CMakeLists.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,12 @@ if (BUILD_TESTING AND GTest_FOUND)
652652
# DASHWorkSource (dash::stratum): concrete core::stratum::IWorkSource for
653653
# DASH X11 -- 4b construction + contract KAT. Links the dash_stratum OBJECT
654654
# lib (work_source.cpp) on top of the header-only coin selector link set.
655-
add_executable(test_dash_stratum_work_source test_dash_stratum_work_source.cpp)
655+
# Includes coin/zmq_tip_notify.cpp directly (NOT the C2POOL_ZMQ-guarded
656+
# subscriber — only the pure TipHashDedup / frame-decode helpers) so the
657+
# ZMQ hashblock instant-tip-notify KAT links without libzmq.
658+
add_executable(test_dash_stratum_work_source
659+
test_dash_stratum_work_source.cpp
660+
${CMAKE_SOURCE_DIR}/src/impl/dash/coin/zmq_tip_notify.cpp)
656661
target_link_libraries(test_dash_stratum_work_source PRIVATE
657662
GTest::gtest_main GTest::gtest
658663
dash_stratum dash_x11 core

0 commit comments

Comments
 (0)