Skip to content

Commit 9bcdf81

Browse files
committed
dash: wire the embedded arm so --run can actually take it (#738)
The daemonless embedded template stack (SML/quorum CCbTx, DKG-window serving, BLS-verified commitments, superblock/credit-pool/payee guards) is merged and soak-proven, but no released --run invocation could reach it. Taking the embedded arm needs TWO independent conditions: (1) the work-source arm gate -- DASHWorkSource only consults the embedded bundle when `is_testnet_ || embedded_mainnet_` (work_source.cpp get_work / resource_template_now); (2) a live coin-state FEED -- NodeCoinState::populated() only flips when CoinStateMaintainer publishes a tip, and the maintainer + header chain + 6 ingest legs are constructed ONLY inside main_dash's `if (coin_p2p)` block. No single flag satisfied both. --embedded-mainnet armed (1) only, so the bundle was never fed and populated() stayed false forever; --coin-p2p-connect/--coin-p2p-discover armed (2) only. On top of that --embedded-mainnet was absent from --help entirely, so the undocumented combination was undiscoverable. Net effect: 100% of released --run templates came off the dashd fallback and the whole daemonless stack was unreachable scaffolding. resolve_embedded_arm() (arm_resolution.hpp) makes the decision once, as one pure function, with a strictly ONE-WAY implication: embedded opt-in => coin-state feed (new -- this is the wiring) coin-state feed => embedded opt-in (never -- explicitly not) So --embedded-mainnet now arms both halves end to end (seed-based discovery when no peer is pinned), while a transport flag still cannot move the arm. REWARD SAFETY (this node class runs a live production mining hotel, and there is a documented incident where --coin-p2p-connect activated an unguarded embedded arm on a live node): * a default --run resolves arm=dashd-fallback, unchanged; * --coin-p2p-connect alone resolves arm=dashd-fallback, unchanged -- it brings up the transport and nothing else; * both are pinned by tests, including an exhaustive sweep of the un-opted-in mainnet argv quadrant; * verified empirically: normalized run-log diff of the pre-change vs post-change binary for both invocations differs ONLY by the two added diagnostic lines. Also: --embedded-mainnet documented in --help, and a startup line that names the resolved arm plus the reason, so the arm is field-checkable instead of inferred. Tests fold into the existing allowlisted test_dash_stratum_work_source target (no new add_executable -> no NOT_BUILT sentinel); the allowlist drift-guard passes.
1 parent d7b5013 commit 9bcdf81

3 files changed

Lines changed: 385 additions & 10 deletions

File tree

src/c2pool/main_dash.cpp

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
#include <impl/dash/coin/zmq_tip_notify.hpp> // dash::coin::TipHashDedup / ZmqHashblockSubscriber — dashd ZMQ hashblock INSTANT tip-notify (opt-in, hardening on the #770 poll)
6262
#include <impl/dash/coin/coin_p2p_magic.hpp> // dash::coin::select_coin_p2p_magic — E5 --coin-p2p-magic override (regtest ARM A dial)
6363
#include <impl/dash/coin/node_coin_state.hpp> // dash::coin::NodeCoinState (embedded work bundle)
64+
#include <impl/dash/coin/arm_resolution.hpp> // dash::coin::resolve_embedded_arm (#738 arm decision, one place)
6465
#include <impl/dash/coin/dkg_window.hpp> // dash::coin::is_dkg_commitment_window (BLOCKER-1 guard)
6566
#include <impl/dash/coin/dkg_commitments.hpp> // E1: build_daemonless_qc_plan (serve DKG windows daemonlessly)
6667
#include <impl/dash/coin/vendor/bls_verify.hpp> // E1 Phase-L: make_commitment_bls_verifier (real qc verify seam)
@@ -214,7 +215,7 @@ void print_banner(const char* argv0)
214215
<< " [--stratum [HOST:]PORT] [--coin-p2p-connect HOST:PORT]... [--coin-p2p-discover]\n"
215216
<< " [--web-port PORT] [--web-host ADDR] [--dashboard-dir PATH]\n"
216217
<< " [--external-ip ADDR]\n"
217-
<< " [--embedded-utxo]\n"
218+
<< " [--embedded-utxo] [--embedded-mainnet]\n"
218219
<< " [--give-author PCT] [-f|--fee PCT] [--node-owner-address ADDR]\n"
219220
<< " [--redistribute pplns|fee|boost|donate]\n"
220221
<< " [--coin-zmq-hashblock tcp://HOST:PORT]\n"
@@ -238,6 +239,15 @@ void print_banner(const char* argv0)
238239
<< " clean_jobs notify on the fallback arm; absent/unreachable => the 3 s\n"
239240
<< " getbestblockhash poll is the active path (requires dashd\n"
240241
<< " zmqpubhashblock=tcp://HOST:PORT). No consensus effect.\n"
242+
<< " --embedded-mainnet (DEFAULT OFF) is the single opt-in for the\n"
243+
<< " DAEMONLESS embedded template arm on MAINNET. It lifts the arm gate\n"
244+
<< " AND arms the coin-state feed that populates it (seed-based peer\n"
245+
<< " discovery unless --coin-p2p-connect names peers), so one flag takes\n"
246+
<< " the arm end to end. WITHOUT it a mainnet node ALWAYS serves the\n"
247+
<< " reward-safe dashd-RPC fallback: --coin-p2p-connect/--coin-p2p-discover\n"
248+
<< " are transport only and NEVER move the arm. Even when armed, every\n"
249+
<< " per-template gate (SML fresh at tip, non-superblock, credit-pool seed\n"
250+
<< " height, bestCL, MN-payee cursor, DKG plan) fails closed to dashd.\n"
241251
<< " --coin-p2p-magic HEX overrides the embedded coin-P2P wire magic\n"
242252
<< " (default mainnet bf0c6bbd / testnet cee2caff; regtest fcc1b7dc).\n"
243253
<< " --regtest-force-won-block (regtest E5 harness, fail-closed) drives\n"
@@ -1148,6 +1158,33 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
11481158
std::cout << "[run] web dashboard disabled (--web-port 0)\n";
11491159
}
11501160

1161+
// ── #738: resolve WHICH template arm this invocation takes, ONCE ──────
1162+
// Before this, taking the embedded arm needed TWO conditions that no single
1163+
// flag satisfied: the work-source gate (--embedded-mainnet, half 1) AND a
1164+
// live coin-state feed (--coin-p2p-connect/--coin-p2p-discover, half 2,
1165+
// which is what constructs the maintainer that flips populated()). The
1166+
// embedded opt-in armed only half 1, so NodeCoinState was never fed and the
1167+
// arm could not be taken from any documented invocation. resolve_embedded_
1168+
// arm() closes that with a ONE-WAY implication: the embedded opt-in implies
1169+
// its own feed. The converse is deliberately absent — a transport flag NEVER
1170+
// moves the arm (the hotel incident where --coin-p2p-connect activated an
1171+
// unguarded embedded arm on a live production node). Pinned by
1172+
// test_dash_stratum_work_source's DashRunArmResolution suite.
1173+
const dash::coin::ArmResolution run_arm =
1174+
dash::coin::resolve_embedded_arm(dash::coin::ArmInputs{
1175+
testnet, embedded_mainnet,
1176+
!coin_p2p_targets.empty(), coin_p2p_discover });
1177+
// Discovery is turned on for a pinned-peer-less embedded opt-in: daemonless
1178+
// needs somewhere to sync from. Operator-named transport is never overridden.
1179+
const bool coin_p2p_discover_eff = coin_p2p_discover || run_arm.discover_implied;
1180+
std::cout << "[run] template arm=" << dash::coin::arm_name(run_arm.arm)
1181+
<< "" << dash::coin::arm_reason_text(run_arm.reason)
1182+
<< "\n[run] (embedded_arm_enabled="
1183+
<< (run_arm.embedded_arm_enabled ? "yes" : "no")
1184+
<< " coin_state_feed=" << (run_arm.coin_feed_armed ? "armed" : "off")
1185+
<< (run_arm.discover_implied ? " discovery=implied-by-opt-in" : "")
1186+
<< ")\n";
1187+
11511188
// ── E1: OPT-IN embedded coin-network P2P dial (--coin-p2p-connect) ────
11521189
// GUARANTEE: with no --coin-p2p-connect on argv, coin_p2p stays null and
11531190
// this block is a no-op — the run path is unchanged and the mining-hotel
@@ -1168,7 +1205,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
11681205
// Refresh timer declared LAST -> destroyed FIRST: it stops (and its lambda
11691206
// stops capturing coin_p2p / coin_peer_mgr) before either is torn down.
11701207
std::unique_ptr<core::Timer> coin_dial_refresh_timer;
1171-
if (!coin_p2p_targets.empty() || coin_p2p_discover) {
1208+
if (run_arm.coin_feed_armed) {
11721209
config.coin()->m_testnet = testnet;
11731210
// Coin-network wire magic (dashd pchMessageStart). Default: mainnet
11741211
// bf0c6bbd / testnet cee2caff. A dev regtest dashd uses a DISTINCT magic
@@ -1185,7 +1222,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
11851222
coin_p2p = std::make_unique<dash::coin::p2p::CoinClient<dash::Config>>(
11861223
&ioc, &coin_state, &config, "COIN-P2P");
11871224

1188-
if (coin_p2p_discover) {
1225+
if (coin_p2p_discover_eff) {
11891226
// ── Network-standalone arm: seed-discovered, SCORED, group-diverse
11901227
// peer set INDEPENDENT of the local dashd. The pinned local dashd
11911228
// (--coin-p2p-connect front target, if any) is registered as the
@@ -1272,8 +1309,11 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
12721309
cp->update_dial_targets(std::move(refreshed));
12731310
});
12741311

1275-
std::cout << "[run] coin-network P2P DISCOVERY armed (--coin-p2p-discover): "
1276-
"DASH-isolated scored/diverse peer set, pinned="
1312+
std::cout << "[run] coin-network P2P DISCOVERY armed ("
1313+
<< (run_arm.discover_implied
1314+
? "implied by --embedded-mainnet"
1315+
: "--coin-p2p-discover")
1316+
<< "): DASH-isolated scored/diverse peer set, pinned="
12771317
<< pinned_str << " magic=" << net_magic_hex
12781318
<< " proto=70230 dns_seeds=" << dash::coin::dash_dns_seeds(testnet).size()
12791319
<< " fixed_seeds=" << dash::coin::dash_fixed_seeds(testnet).size()
@@ -2403,7 +2443,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
24032443
// flag) yields nullopt and the arm fails closed to the dashd fallback,
24042444
// exactly the old refusal. The emit gate re-derives this same plan and
24052445
// hard-rejects any template whose type-6 set drifts from it.
2406-
if (testnet || embedded_mainnet) {
2446+
if (run_arm.embedded_arm_enabled) {
24072447
const auto qc_net = testnet ? dash::coin::LlmqNetwork::Testnet
24082448
: dash::coin::LlmqNetwork::Mainnet;
24092449
// Phase-L sourcing leg: collect REAL relayed DKG commitments off
@@ -2561,14 +2601,14 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
25612601
// Only meaningful when the embedded arm actually serves (testnet or
25622602
// --embedded-mainnet); harmless otherwise (the arm is off, work_source
25632603
// never consults viability).
2564-
node_coin_state.set_require_fresh_bestcl(testnet || embedded_mainnet);
2604+
node_coin_state.set_require_fresh_bestcl(run_arm.embedded_arm_enabled);
25652605

25662606
// SOAK FIX (bad-cbtx-assetlocked-amount): the DIP-0027 credit-pool seed
25672607
// rides a separate on_mnlistdiff step and can lag one block while the SML
25682608
// hash is already at the tip; the accrual then commits a stale
25692609
// creditPoolBalance. Refuse the embedded arm unless the credit-pool seed
25702610
// is current AT the tip, same discipline as the SML axis.
2571-
node_coin_state.set_require_fresh_credit_pool(testnet || embedded_mainnet);
2611+
node_coin_state.set_require_fresh_credit_pool(run_arm.embedded_arm_enabled);
25722612

25732613
// E4 re-soak fix (bad-cb-payee at 1519827): the projected masternode
25742614
// payee is only dashd-exact when the payee queue has folded EVERY
@@ -2584,7 +2624,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
25842624
// MnStateMachine's forward-contiguity guard + the maintainer's gap
25852625
// fail-closed path (wipe + authoritative protx re-seed) close the
25862626
// gap itself.
2587-
node_coin_state.set_require_fresh_mn_payee(testnet || embedded_mainnet);
2627+
node_coin_state.set_require_fresh_mn_payee(run_arm.embedded_arm_enabled);
25882628

25892629
// H-6: SML/quorum apply and bestCL adoption move ASYNCHRONOUSLY to the
25902630
// header tip. When they advance (catching the SML up to a moved tip, or
@@ -2750,7 +2790,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint,
27502790
// getheaders off our current locator + a mempool prime.
27512791
coin_p2p->set_on_handshake_complete(
27522792
[cp = coin_p2p.get(), hc = header_chain.get(), sml_base,
2753-
discover = coin_p2p_discover]() {
2793+
discover = coin_p2p_discover_eff]() {
27542794
LOG_INFO << "[EMB-DASH] handshake complete -> initial sync:"
27552795
" getheaders + mempool + mnlistdiff(cold)"
27562796
<< (discover ? " + getaddr (peer crawl)" : "");
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
#pragma once
3+
4+
/// #738 — which template arm a `--run` invocation resolves to, as ONE pure,
5+
/// testable function.
6+
///
7+
/// THE DEFECT THIS CLOSES
8+
/// ---------------------
9+
/// Taking the embedded (daemonless) template arm needs TWO independent
10+
/// conditions, and before this header NO SINGLE FLAG satisfied both:
11+
///
12+
/// (1) the work-source arm gate — DASHWorkSource only consults the embedded
13+
/// bundle when `is_testnet_ || embedded_mainnet_` (work_source.cpp
14+
/// get_work / resource_template_now). On mainnet that means
15+
/// `--embedded-mainnet`.
16+
/// (2) a live coin-state FEED — NodeCoinState::populated() only flips once
17+
/// CoinStateMaintainer publishes a tip, and the maintainer + header chain
18+
/// + ingest legs are constructed ONLY inside main_dash's `if (coin_p2p)`
19+
/// block, i.e. only when `--coin-p2p-connect` / `--coin-p2p-discover`
20+
/// built a CoinClient.
21+
///
22+
/// `--embedded-mainnet` satisfied (1) but not (2): with no coin-P2P flag the
23+
/// bundle was never fed, populated() stayed false forever, and every template
24+
/// came off the dashd fallback. `--coin-p2p-connect` satisfied (2) but not (1).
25+
/// So the whole daemonless stack was unreachable from any documented
26+
/// invocation. This resolver makes the EMBEDDED OPT-IN imply its own feed, so
27+
/// one flag arms both halves end to end.
28+
///
29+
/// ★ REWARD-SAFETY INVARIANT (hotel incident: `--coin-p2p-connect` once
30+
/// activated an unguarded embedded arm on a live production node) ★
31+
/// The implication is ONE-WAY and runs only in the safe direction:
32+
///
33+
/// embedded opt-in => coin-state feed (NEW — this is the wiring)
34+
/// coin-state feed => embedded opt-in (NEVER — explicitly not)
35+
///
36+
/// A transport flag can therefore never flip the arm. `--coin-p2p-connect`
37+
/// alone on mainnet still resolves DashdFallback, exactly as today, and a bare
38+
/// `--run` touches none of this. Both are pinned by tests.
39+
///
40+
/// STRICTLY single-coin (src/impl/dash only) and dependency-free: no I/O, no
41+
/// core reach, no allocation. main_dash.cpp is the sole production caller.
42+
43+
namespace dash {
44+
namespace coin {
45+
46+
/// Which arm a `--run` invocation resolves to. "EmbeddedEligible" means the
47+
/// arm is ARMED, not that it will serve: every per-template viability gate
48+
/// (SML fresh at tip, non-superblock height, credit-pool seed height, bestCL,
49+
/// MN-payee cursor, DKG plan completeness) still fails closed to the dashd
50+
/// fallback on the hot path. This is the outer, invocation-level decision only.
51+
enum class WorkArm {
52+
DashdFallback, ///< retained dashd getblocktemplate RPC arm (reward-safe default)
53+
EmbeddedEligible, ///< daemonless embedded arm armed AND fed
54+
};
55+
56+
/// Why the resolution came out the way it did — the operator-facing diagnosis
57+
/// for the startup banner, and the assertion handle for the tests.
58+
enum class ArmReason {
59+
NoOptIn, ///< mainnet, no --embedded-mainnet: the default posture
60+
MainnetGateOff, ///< a coin-P2P feed was requested but the arm was NOT opted in
61+
NoCoinStateFeed, ///< the arm is enabled but nothing feeds NodeCoinState
62+
Armed, ///< arm enabled AND fed
63+
};
64+
65+
/// The invocation-level inputs, straight off argv.
66+
struct ArmInputs {
67+
bool testnet = false; ///< --testnet / --regtest
68+
bool embedded_mainnet = false; ///< --embedded-mainnet (the embedded opt-in)
69+
bool coin_p2p_connect = false; ///< at least one --coin-p2p-connect HOST:PORT
70+
bool coin_p2p_discover = false; ///< --coin-p2p-discover
71+
};
72+
73+
struct ArmResolution {
74+
WorkArm arm = WorkArm::DashdFallback;
75+
ArmReason reason = ArmReason::NoOptIn;
76+
77+
/// Construct the CoinClient + HeaderChain + CoinStateMaintainer + ingest
78+
/// legs? (main_dash's `if (coin_p2p)` block.)
79+
bool coin_feed_armed = false;
80+
81+
/// Turn on seed-based peer discovery because the embedded opt-in was given
82+
/// with no pinned peer — the daemonless posture needs somewhere to sync
83+
/// from. False whenever the operator named peers or asked for discovery
84+
/// themselves.
85+
bool discover_implied = false;
86+
87+
/// The work-source gate: DASHWorkSource::set_embedded_mainnet / is_testnet.
88+
bool embedded_arm_enabled = false;
89+
};
90+
91+
/// Pure resolution. No hidden state, no ordering dependence.
92+
inline ArmResolution resolve_embedded_arm(const ArmInputs& in)
93+
{
94+
ArmResolution r;
95+
96+
// Half (1): the arm gate. Byte-identical to the predicate DASHWorkSource
97+
// applies internally (`is_testnet_ || embedded_mainnet_`), so the two
98+
// halves cannot drift.
99+
r.embedded_arm_enabled = in.testnet || in.embedded_mainnet;
100+
101+
// Half (2): the feed. An operator-named transport (pinned peers or explicit
102+
// discovery) arms it, exactly as before.
103+
const bool explicit_feed = in.coin_p2p_connect || in.coin_p2p_discover;
104+
105+
// ★ The one-way implication. ONLY the embedded opt-in may imply a feed, and
106+
// only when the operator named no transport of their own. The converse is
107+
// deliberately absent: no transport flag ever sets embedded_arm_enabled.
108+
r.discover_implied = in.embedded_mainnet && !explicit_feed;
109+
r.coin_feed_armed = explicit_feed || r.discover_implied;
110+
111+
if (!r.embedded_arm_enabled) {
112+
// Mainnet with no opt-in. A requested feed still runs (block relay,
113+
// witness) but cannot move the arm — the hotel-incident invariant.
114+
r.arm = WorkArm::DashdFallback;
115+
r.reason = explicit_feed ? ArmReason::MainnetGateOff : ArmReason::NoOptIn;
116+
} else if (!r.coin_feed_armed) {
117+
// Reachable for a bare `--testnet` (arm enabled by network, no feed
118+
// requested and none implied, because --embedded-mainnet is what
119+
// implies one). Unchanged behaviour.
120+
r.arm = WorkArm::DashdFallback;
121+
r.reason = ArmReason::NoCoinStateFeed;
122+
} else {
123+
r.arm = WorkArm::EmbeddedEligible;
124+
r.reason = ArmReason::Armed;
125+
}
126+
return r;
127+
}
128+
129+
inline const char* arm_name(WorkArm a)
130+
{
131+
switch (a) {
132+
case WorkArm::EmbeddedEligible: return "embedded";
133+
case WorkArm::DashdFallback: break;
134+
}
135+
return "dashd-fallback";
136+
}
137+
138+
inline const char* arm_reason_text(ArmReason r)
139+
{
140+
switch (r) {
141+
case ArmReason::NoOptIn:
142+
return "no --embedded-mainnet (default reward-safe posture)";
143+
case ArmReason::MainnetGateOff:
144+
return "coin-P2P feed requested but --embedded-mainnet absent "
145+
"(transport only; the arm never follows the transport)";
146+
case ArmReason::NoCoinStateFeed:
147+
return "embedded arm enabled but no coin-state feed "
148+
"(--coin-p2p-connect / --coin-p2p-discover / --embedded-mainnet)";
149+
case ArmReason::Armed:
150+
break;
151+
}
152+
return "embedded opt-in + coin-state feed both present "
153+
"(per-template viability gates still fail closed to dashd)";
154+
}
155+
156+
} // namespace coin
157+
} // namespace dash

0 commit comments

Comments
 (0)