diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index 9a7a2ea87..52949a757 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -62,6 +62,7 @@ #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) #include // dash::coin::NodeCoinState (embedded work bundle) +#include // dash::coin::resolve_embedded_arm (#738 arm decision, one place) #include // dash::coin::is_dkg_commitment_window (BLOCKER-1 guard) #include // E1: build_daemonless_qc_plan (serve DKG windows daemonlessly) #include // E1 Phase-L: make_commitment_bls_verifier (real qc verify seam) @@ -218,7 +219,7 @@ void print_banner(const char* argv0) << " [--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" + << " [--embedded-utxo] [--embedded-mainnet]\n" << " [--give-author PCT] [-f|--fee PCT] [--node-owner-address ADDR]\n" << " [--redistribute pplns|fee|boost|donate]\n" << " [--coin-zmq-hashblock tcp://HOST:PORT]\n" @@ -242,6 +243,15 @@ void print_banner(const char* argv0) << " clean_jobs notify on the fallback arm; absent/unreachable => the 3 s\n" << " getbestblockhash poll is the active path (requires dashd\n" << " zmqpubhashblock=tcp://HOST:PORT). No consensus effect.\n" + << " --embedded-mainnet (DEFAULT OFF) is the single opt-in for the\n" + << " DAEMONLESS embedded template arm on MAINNET. It lifts the arm gate\n" + << " AND arms the coin-state feed that populates it (seed-based peer\n" + << " discovery unless --coin-p2p-connect names peers), so one flag takes\n" + << " the arm end to end. WITHOUT it a mainnet node ALWAYS serves the\n" + << " reward-safe dashd-RPC fallback: --coin-p2p-connect/--coin-p2p-discover\n" + << " are transport only and NEVER move the arm. Even when armed, every\n" + << " per-template gate (SML fresh at tip, non-superblock, credit-pool seed\n" + << " height, bestCL, MN-payee cursor, DKG plan) fails closed to dashd.\n" << " --coin-p2p-magic HEX overrides the embedded coin-P2P wire magic\n" << " (default mainnet bf0c6bbd / testnet cee2caff; regtest fcc1b7dc).\n" << " --regtest-force-won-block (regtest E5 harness, fail-closed) drives\n" @@ -1221,11 +1231,42 @@ int run_node(bool testnet, const std::string& rpc_endpoint, std::cout << "[run] web dashboard disabled (--web-port 0)\n"; } - // ── E1: OPT-IN embedded coin-network P2P dial (--coin-p2p-connect) ──── - // GUARANTEE: with no --coin-p2p-connect on argv, coin_p2p stays null and - // this block is a no-op — the run path is unchanged and the mining-hotel - // prod posture (NodeCoinState unpopulated -> dashd-RPC fallback) is - // untouched. The coin-network wire MAGIC (dashd pchMessageStart: mainnet + // ── #738: resolve WHICH template arm this invocation takes, ONCE ────── + // Before this, taking the embedded arm needed TWO conditions that no single + // flag satisfied: the work-source gate (--embedded-mainnet, half 1) AND a + // live coin-state feed (--coin-p2p-connect/--coin-p2p-discover, half 2, + // which is what constructs the maintainer that flips populated()). The + // embedded opt-in armed only half 1, so NodeCoinState was never fed and the + // arm could not be taken from any documented invocation. resolve_embedded_ + // arm() closes that with a ONE-WAY implication: the embedded opt-in implies + // its own feed. The converse is deliberately absent — a transport flag NEVER + // moves the arm (the hotel incident where --coin-p2p-connect activated an + // unguarded embedded arm on a live production node). Pinned by + // test_dash_stratum_work_source's DashRunArmResolution suite. + const dash::coin::ArmResolution run_arm = + dash::coin::resolve_embedded_arm(dash::coin::ArmInputs{ + testnet, embedded_mainnet, + !coin_p2p_targets.empty(), coin_p2p_discover }); + // Discovery is turned on for a pinned-peer-less embedded opt-in: daemonless + // needs somewhere to sync from. Operator-named transport is never overridden. + const bool coin_p2p_discover_eff = coin_p2p_discover || run_arm.discover_implied; + std::cout << "[run] template arm=" << dash::coin::arm_name(run_arm.arm) + << " — " << dash::coin::arm_reason_text(run_arm.reason) + << "\n[run] (embedded_arm_enabled=" + << (run_arm.embedded_arm_enabled ? "yes" : "no") + << " coin_state_feed=" << (run_arm.coin_feed_armed ? "armed" : "off") + << (run_arm.discover_implied ? " discovery=implied-by-opt-in" : "") + << ")\n"; + + // ── E1: OPT-IN embedded coin-network P2P dial ───────────────────────── + // GUARANTEE: this block is a no-op unless the invocation asked for a + // coin-state feed — an explicit --coin-p2p-connect / --coin-p2p-discover, + // or the --embedded-mainnet opt-in that implies one (#738). With NONE of + // those on argv, coin_p2p stays null, the run path is unchanged and the + // mining-hotel prod posture (NodeCoinState unpopulated -> dashd-RPC + // fallback) is untouched. Arming the feed alone still does NOT move the + // arm: without --embedded-mainnet, work_source keeps serving the dashd + // fallback no matter what this block populates. The coin-network wire MAGIC (dashd pchMessageStart: mainnet // bf0c6bbd / testnet cee2caff, same constants as the slice-1 launcher // dispatch) is DISTINCT from the sharechain PREFIX set above — different // layers, never conflated. The client rides the SAME ioc as the @@ -1241,7 +1282,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // 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) { + if (run_arm.coin_feed_armed) { config.coin()->m_testnet = testnet; // Coin-network wire magic (dashd pchMessageStart). Default: mainnet // bf0c6bbd / testnet cee2caff. A dev regtest dashd uses a DISTINCT magic @@ -1258,7 +1299,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, coin_p2p = std::make_unique>( &ioc, &coin_state, &config, "COIN-P2P"); - if (coin_p2p_discover) { + if (coin_p2p_discover_eff) { // ── 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 @@ -1280,6 +1321,10 @@ 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)); + // HTTP peer fallback: when DNS + fixed seeds are exhausted, query + // known c2pool nodes via GET /api/coin_peers for peer addresses — + // the third and last bootstrap tier. Mirrors main_ltc.cpp:1875. + coin_peer_mgr->set_http_peer_seeds({{"voidbind.com", 8080}}); // ── ACTIVE daemon-disjointness (connect+discover / oracle-shadow) ── // When a local dashd RPC is armed, feed its getpeerinfo so the @@ -1345,8 +1390,11 @@ int run_node(bool testnet, const std::string& rpc_endpoint, 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=" + std::cout << "[run] coin-network P2P DISCOVERY armed (" + << (run_arm.discover_implied + ? "implied by --embedded-mainnet" + : "--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() @@ -1378,8 +1426,10 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // holds a non-owning const ref to it). It is declared here, in run_node's // scope, BEFORE work_source; the explicit stratum_server.reset() after // ioc.run() tears the acceptor down while node_coin_state is still alive. - // Default (unpopulated) bundle: populated()==false, so get_work() takes the - // retained dashd-fallback arm -- correct + documented for the 4a standup. + // Constructed UNPOPULATED: populated()==false, so get_work() takes the + // retained dashd-fallback arm until a coin-state feed publishes a tip. With + // no feed armed (run_arm.coin_feed_armed == false) nothing ever publishes + // one, and the fallback arm is the whole run. // // E2b (#738) UTXO/fee lane: utxo_lane is declared BEFORE node_coin_state // deliberately -- attach() hands the lane's UTXOViewCache pointer to the @@ -2599,7 +2649,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // flag) yields nullopt and the arm fails closed to the dashd fallback, // exactly the old refusal. The emit gate re-derives this same plan and // hard-rejects any template whose type-6 set drifts from it. - if (testnet || embedded_mainnet) { + if (run_arm.embedded_arm_enabled) { const auto qc_net = testnet ? dash::coin::LlmqNetwork::Testnet : dash::coin::LlmqNetwork::Mainnet; // Phase-L sourcing leg: collect REAL relayed DKG commitments off @@ -2757,14 +2807,14 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // Only meaningful when the embedded arm actually serves (testnet or // --embedded-mainnet); harmless otherwise (the arm is off, work_source // never consults viability). - node_coin_state.set_require_fresh_bestcl(testnet || embedded_mainnet); + node_coin_state.set_require_fresh_bestcl(run_arm.embedded_arm_enabled); // SOAK FIX (bad-cbtx-assetlocked-amount): the DIP-0027 credit-pool seed // rides a separate on_mnlistdiff step and can lag one block while the SML // hash is already at the tip; the accrual then commits a stale // creditPoolBalance. Refuse the embedded arm unless the credit-pool seed // is current AT the tip, same discipline as the SML axis. - node_coin_state.set_require_fresh_credit_pool(testnet || embedded_mainnet); + node_coin_state.set_require_fresh_credit_pool(run_arm.embedded_arm_enabled); // E4 re-soak fix (bad-cb-payee at 1519827): the projected masternode // payee is only dashd-exact when the payee queue has folded EVERY @@ -2780,7 +2830,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // MnStateMachine's forward-contiguity guard + the maintainer's gap // fail-closed path (wipe + authoritative protx re-seed) close the // gap itself. - node_coin_state.set_require_fresh_mn_payee(testnet || embedded_mainnet); + node_coin_state.set_require_fresh_mn_payee(run_arm.embedded_arm_enabled); // H-6: SML/quorum apply and bestCL adoption move ASYNCHRONOUSLY to the // header tip. When they advance (catching the SML up to a moved tip, or @@ -2946,7 +2996,7 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // getheaders off our current locator + a mempool prime. coin_p2p->set_on_handshake_complete( [cp = coin_p2p.get(), hc = header_chain.get(), sml_base, - discover = coin_p2p_discover]() { + discover = coin_p2p_discover_eff]() { LOG_INFO << "[EMB-DASH] handshake complete -> initial sync:" " getheaders + mempool + mnlistdiff(cold)" << (discover ? " + getaddr (peer crawl)" : ""); diff --git a/src/impl/dash/coin/arm_resolution.hpp b/src/impl/dash/coin/arm_resolution.hpp new file mode 100644 index 000000000..dcaf1f0af --- /dev/null +++ b/src/impl/dash/coin/arm_resolution.hpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +#pragma once + +/// #738 — which template arm a `--run` invocation resolves to, as ONE pure, +/// testable function. +/// +/// THE DEFECT THIS CLOSES +/// --------------------- +/// Taking the embedded (daemonless) template arm needs TWO independent +/// conditions, and before this header NO SINGLE FLAG satisfied both: +/// +/// (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). On mainnet that means +/// `--embedded-mainnet`. +/// (2) a live coin-state FEED — NodeCoinState::populated() only flips once +/// CoinStateMaintainer publishes a tip, and the maintainer + header chain +/// + ingest legs are constructed ONLY inside main_dash's `if (coin_p2p)` +/// block, i.e. only when `--coin-p2p-connect` / `--coin-p2p-discover` +/// built a CoinClient. +/// +/// `--embedded-mainnet` satisfied (1) but not (2): with no coin-P2P flag the +/// bundle was never fed, populated() stayed false forever, and every template +/// came off the dashd fallback. `--coin-p2p-connect` satisfied (2) but not (1). +/// So the whole daemonless stack was unreachable from any documented +/// invocation. This resolver makes the EMBEDDED OPT-IN imply its own feed, so +/// one flag arms both halves end to end. +/// +/// ★ REWARD-SAFETY INVARIANT (hotel incident: `--coin-p2p-connect` once +/// activated an unguarded embedded arm on a live production node) ★ +/// The implication is ONE-WAY and runs only in the safe direction: +/// +/// embedded opt-in => coin-state feed (NEW — this is the wiring) +/// coin-state feed => embedded opt-in (NEVER — explicitly not) +/// +/// A transport flag can therefore never flip the arm. `--coin-p2p-connect` +/// alone on mainnet still resolves DashdFallback, exactly as today, and a bare +/// `--run` touches none of this. Both are pinned by tests. +/// +/// STRICTLY single-coin (src/impl/dash only) and dependency-free: no I/O, no +/// core reach, no allocation. main_dash.cpp is the sole production caller. + +namespace dash { +namespace coin { + +/// Which arm a `--run` invocation resolves to. "EmbeddedEligible" means the +/// arm is ARMED, not that it will serve: every per-template viability gate +/// (SML fresh at tip, non-superblock height, credit-pool seed height, bestCL, +/// MN-payee cursor, DKG plan completeness) still fails closed to the dashd +/// fallback on the hot path. This is the outer, invocation-level decision only. +enum class WorkArm { + DashdFallback, ///< retained dashd getblocktemplate RPC arm (reward-safe default) + EmbeddedEligible, ///< daemonless embedded arm armed AND fed +}; + +/// Why the resolution came out the way it did — the operator-facing diagnosis +/// for the startup banner, and the assertion handle for the tests. +enum class ArmReason { + NoOptIn, ///< mainnet, no --embedded-mainnet: the default posture + MainnetGateOff, ///< a coin-P2P feed was requested but the arm was NOT opted in + NoCoinStateFeed, ///< the arm is enabled but nothing feeds NodeCoinState + Armed, ///< arm enabled AND fed +}; + +/// The invocation-level inputs, straight off argv. +struct ArmInputs { + bool testnet = false; ///< --testnet / --regtest + bool embedded_mainnet = false; ///< --embedded-mainnet (the embedded opt-in) + bool coin_p2p_connect = false; ///< at least one --coin-p2p-connect HOST:PORT + bool coin_p2p_discover = false; ///< --coin-p2p-discover +}; + +struct ArmResolution { + WorkArm arm = WorkArm::DashdFallback; + ArmReason reason = ArmReason::NoOptIn; + + /// Construct the CoinClient + HeaderChain + CoinStateMaintainer + ingest + /// legs? (main_dash's `if (coin_p2p)` block.) + bool coin_feed_armed = false; + + /// Turn on seed-based peer discovery because the embedded opt-in was given + /// with no pinned peer — the daemonless posture needs somewhere to sync + /// from. False whenever the operator named peers or asked for discovery + /// themselves. + bool discover_implied = false; + + /// The work-source gate: DASHWorkSource::set_embedded_mainnet / is_testnet. + bool embedded_arm_enabled = false; +}; + +/// Pure resolution. No hidden state, no ordering dependence. +inline ArmResolution resolve_embedded_arm(const ArmInputs& in) +{ + ArmResolution r; + + // Half (1): the arm gate. Byte-identical to the predicate DASHWorkSource + // applies internally (`is_testnet_ || embedded_mainnet_`), so the two + // halves cannot drift. + r.embedded_arm_enabled = in.testnet || in.embedded_mainnet; + + // Half (2): the feed. An operator-named transport (pinned peers or explicit + // discovery) arms it, exactly as before. + const bool explicit_feed = in.coin_p2p_connect || in.coin_p2p_discover; + + // ★ The one-way implication. ONLY the embedded opt-in may imply a feed, and + // only when the operator named no transport of their own. The converse is + // deliberately absent: no transport flag ever sets embedded_arm_enabled. + r.discover_implied = in.embedded_mainnet && !explicit_feed; + r.coin_feed_armed = explicit_feed || r.discover_implied; + + if (!r.embedded_arm_enabled) { + // Mainnet with no opt-in. A requested feed still runs (block relay, + // witness) but cannot move the arm — the hotel-incident invariant. + r.arm = WorkArm::DashdFallback; + r.reason = explicit_feed ? ArmReason::MainnetGateOff : ArmReason::NoOptIn; + } else if (!r.coin_feed_armed) { + // Reachable for a bare `--testnet` (arm enabled by network, no feed + // requested and none implied, because --embedded-mainnet is what + // implies one). Unchanged behaviour. + r.arm = WorkArm::DashdFallback; + r.reason = ArmReason::NoCoinStateFeed; + } else { + r.arm = WorkArm::EmbeddedEligible; + r.reason = ArmReason::Armed; + } + return r; +} + +inline const char* arm_name(WorkArm a) +{ + switch (a) { + case WorkArm::EmbeddedEligible: return "embedded"; + case WorkArm::DashdFallback: break; + } + return "dashd-fallback"; +} + +inline const char* arm_reason_text(ArmReason r) +{ + switch (r) { + case ArmReason::NoOptIn: + return "no --embedded-mainnet (default reward-safe posture)"; + case ArmReason::MainnetGateOff: + return "coin-P2P feed requested but --embedded-mainnet absent " + "(transport only; the arm never follows the transport)"; + case ArmReason::NoCoinStateFeed: + return "embedded arm enabled but no coin-state feed " + "(--coin-p2p-connect / --coin-p2p-discover / --embedded-mainnet)"; + case ArmReason::Armed: + break; + } + return "embedded opt-in + coin-state feed both present " + "(per-template viability gates still fail closed to dashd)"; +} + +} // namespace coin +} // namespace dash diff --git a/test/test_dash_stratum_work_source.cpp b/test/test_dash_stratum_work_source.cpp index f58b53006..d7ca7e8c2 100644 --- a/test/test_dash_stratum_work_source.cpp +++ b/test/test_dash_stratum_work_source.cpp @@ -27,6 +27,7 @@ #include // CSimplifiedMNListEntry (embedded SML seed) #include // parse_cbtx (read served creditPool) #include // encode_cbtx (GBT-xcheck fallback fixture) +#include // #738 resolve_embedded_arm (run-path arm decision) #include #include @@ -1981,4 +1982,181 @@ TEST(DashStratumCoinP2pTipInvalidate, BumpAloneServesStaleUnderRefreshExecutor) "window invalidate_template_cache() closes"; } +// ═════════════════════════════════════════════════════════════════════════════ +// #738 — RUN-PATH ARM RESOLUTION. +// +// Taking the embedded arm needs TWO independent conditions and, before #738, no +// single flag satisfied both: +// (1) the work-source gate — is_testnet_ || embedded_mainnet_ (pinned by the +// DashStratumC1MainnetGate suite above); +// (2) a live coin-state FEED — the CoinStateMaintainer that flips +// NodeCoinState::populated() is constructed ONLY inside main_dash's +// `if (coin_p2p)` block. +// `--embedded-mainnet` armed (1) only, so the bundle was never fed and the arm +// was unreachable from any documented invocation. resolve_embedded_arm() makes +// the embedded opt-in imply its own feed. +// +// ★ The converse must NEVER hold. A transport flag moving the arm is the exact +// shape of the live-hotel incident (--coin-p2p-connect activating an unguarded +// embedded arm on a production node, real money). The two REWARD-SAFETY pins +// below are load-bearing: a default `--run` and `--coin-p2p-connect` alone must +// BOTH still resolve dashd-fallback. +// ═════════════════════════════════════════════════════════════════════════════ + +namespace { +using dash::coin::ArmInputs; +using dash::coin::ArmReason; +using dash::coin::WorkArm; +using dash::coin::resolve_embedded_arm; + +// argv shorthand: {testnet, embedded_mainnet, coin_p2p_connect, coin_p2p_discover} +ArmInputs argv_bare() { return ArmInputs{false, false, false, false}; } +ArmInputs argv_p2p_connect() { return ArmInputs{false, false, true, false}; } +ArmInputs argv_p2p_discover() { return ArmInputs{false, false, false, true }; } +ArmInputs argv_embedded() { return ArmInputs{false, true, false, false}; } +ArmInputs argv_embedded_pinned() { return ArmInputs{false, true, true, false}; } +ArmInputs argv_testnet() { return ArmInputs{true, false, false, false}; } +ArmInputs argv_testnet_connect() { return ArmInputs{true, false, true, false}; } + +// End-to-end composition mirror: reproduce EXACTLY what run_node does with the +// resolution — construct the coin-state feed iff coin_feed_armed (a fed +// maintainer is what publishes a tip, i.e. flips populated()), build the work +// source on the resolved network flag, apply the embedded opt-in — then ask the +// real DASHWorkSource which arm it serves. This is the reachability proof: it +// exercises the SAME predicate the production get_work() applies, not a +// restatement of the resolver. +dash::coin::WorkSource served_arm_for(const ArmInputs& in) +{ + const auto r = resolve_embedded_arm(in); + + dash::coin::NodeCoinState cs; // declared first -> destroyed last + if (r.coin_feed_armed) + seed_populated(cs); // the feed's published tip + + auto fallback = []() -> dash::coin::DashWorkData { return rich_work(); }; + auto submit = [](const std::vector&, uint32_t, bool) { return true; }; + dash::stratum::DASHWorkSource ws(cs, fallback, submit, + core::stratum::StratumConfig{}, + /*is_testnet=*/in.testnet); + ws.set_embedded_mainnet(in.embedded_mainnet); + + dash::stratum::WorkJobTargetInputs job_in; + job_in.sane_target_min.SetHex( + "0000000000000000000000000000000000000000000000000000000000000001"); + job_in.sane_target_max.SetHex( + "00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + job_in.share_info_bits_target.SetHex( + "0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + return ws.get_work(job_in).source; +} +} // namespace + +// ── REWARD-SAFETY PIN 1: a default `--run` is untouched ────────────────────── +TEST(DashRunArmResolution, DefaultRunResolvesDashdFallback) +{ + const auto r = resolve_embedded_arm(argv_bare()); + EXPECT_EQ(r.arm, WorkArm::DashdFallback); + EXPECT_EQ(r.reason, ArmReason::NoOptIn); + EXPECT_FALSE(r.embedded_arm_enabled); + EXPECT_FALSE(r.coin_feed_armed) << "a bare --run must construct NO coin-P2P client"; + EXPECT_FALSE(r.discover_implied); + EXPECT_EQ(served_arm_for(argv_bare()), dash::coin::WorkSource::DashdFallback); +} + +// ── REWARD-SAFETY PIN 2: the hotel incident. A transport flag alone must NOT +// flip the arm — on its own it brings up the coin-network connection and +// NOTHING else. This is the pin that would have caught that incident. ───────── +TEST(DashRunArmResolution, CoinP2pConnectAloneStillResolvesDashdFallback) +{ + const auto r = resolve_embedded_arm(argv_p2p_connect()); + EXPECT_EQ(r.arm, WorkArm::DashdFallback); + EXPECT_EQ(r.reason, ArmReason::MainnetGateOff); + EXPECT_FALSE(r.embedded_arm_enabled) + << "--coin-p2p-connect must never lift the mainnet embedded gate"; + EXPECT_TRUE(r.coin_feed_armed) << "the transport itself still comes up"; + EXPECT_FALSE(r.discover_implied) << "a pinned peer must not silently add discovery"; + EXPECT_EQ(served_arm_for(argv_p2p_connect()), dash::coin::WorkSource::DashdFallback) + << "a populated coin-state must STILL serve dashd without --embedded-mainnet"; +} + +// Same for the other transport flag. +TEST(DashRunArmResolution, CoinP2pDiscoverAloneStillResolvesDashdFallback) +{ + const auto r = resolve_embedded_arm(argv_p2p_discover()); + EXPECT_EQ(r.arm, WorkArm::DashdFallback); + EXPECT_EQ(r.reason, ArmReason::MainnetGateOff); + EXPECT_FALSE(r.embedded_arm_enabled); + EXPECT_TRUE(r.coin_feed_armed); + EXPECT_EQ(served_arm_for(argv_p2p_discover()), dash::coin::WorkSource::DashdFallback); +} + +// ── THE WIRING: the embedded opt-in now arms BOTH halves ───────────────────── +TEST(DashRunArmResolution, EmbeddedMainnetOptInArmsBothHalvesEndToEnd) +{ + const auto r = resolve_embedded_arm(argv_embedded()); + EXPECT_EQ(r.arm, WorkArm::EmbeddedEligible); + EXPECT_EQ(r.reason, ArmReason::Armed); + EXPECT_TRUE(r.embedded_arm_enabled) << "half 1: the work-source gate"; + EXPECT_TRUE(r.coin_feed_armed) + << "half 2 (#738): the opt-in must imply the coin-state feed that " + "populates NodeCoinState — without it the arm is unreachable"; + EXPECT_TRUE(r.discover_implied) + << "no pinned peer given: daemonless needs seed-based discovery"; + EXPECT_EQ(served_arm_for(argv_embedded()), dash::coin::WorkSource::Embedded); +} + +// With peers named, discovery is NOT force-enabled behind the operator's back. +TEST(DashRunArmResolution, EmbeddedMainnetWithPinnedPeerDoesNotImplyDiscovery) +{ + const auto r = resolve_embedded_arm(argv_embedded_pinned()); + EXPECT_EQ(r.arm, WorkArm::EmbeddedEligible); + EXPECT_TRUE(r.coin_feed_armed); + EXPECT_FALSE(r.discover_implied); + EXPECT_EQ(served_arm_for(argv_embedded_pinned()), dash::coin::WorkSource::Embedded); +} + +// ── NO REGRESSION on the testnet paths (unchanged by #738) ─────────────────── +TEST(DashRunArmResolution, TestnetWithoutFeedIsUnchangedFallback) +{ + const auto r = resolve_embedded_arm(argv_testnet()); + EXPECT_EQ(r.arm, WorkArm::DashdFallback); + EXPECT_EQ(r.reason, ArmReason::NoCoinStateFeed) + << "the network lifts the arm gate, but nothing feeds the bundle"; + EXPECT_TRUE(r.embedded_arm_enabled); + EXPECT_FALSE(r.coin_feed_armed); + EXPECT_EQ(served_arm_for(argv_testnet()), dash::coin::WorkSource::DashdFallback); +} + +TEST(DashRunArmResolution, TestnetWithCoinP2pIsUnchangedEmbedded) +{ + const auto r = resolve_embedded_arm(argv_testnet_connect()); + EXPECT_EQ(r.arm, WorkArm::EmbeddedEligible); + EXPECT_TRUE(r.coin_feed_armed); + EXPECT_FALSE(r.discover_implied); + EXPECT_EQ(served_arm_for(argv_testnet_connect()), dash::coin::WorkSource::Embedded); +} + +// ── The one-way implication, stated as an exhaustive invariant over the whole +// 16-point argv space: no combination WITHOUT the embedded opt-in may ever +// enable the arm on mainnet. Any future edit to the resolver that lets a +// transport flag leak into the arm gate reds here. ─────────────────────────── +TEST(DashRunArmResolution, NoMainnetCombinationWithoutOptInEverEnablesTheArm) +{ + int mainnet_cases = 0; + for (int bits = 0; bits < 16; ++bits) { + ArmInputs in{ (bits & 1) != 0, (bits & 2) != 0, + (bits & 4) != 0, (bits & 8) != 0 }; + const auto r = resolve_embedded_arm(in); + if (in.testnet || in.embedded_mainnet) + continue; // opt-in / testnet cases covered above + ++mainnet_cases; + EXPECT_FALSE(r.embedded_arm_enabled) << "bits=" << bits; + EXPECT_EQ(r.arm, WorkArm::DashdFallback) << "bits=" << bits; + EXPECT_FALSE(r.discover_implied) << "bits=" << bits; + EXPECT_EQ(served_arm_for(in), dash::coin::WorkSource::DashdFallback) + << "bits=" << bits; + } + EXPECT_EQ(mainnet_cases, 4) << "the un-opted-in mainnet quadrant must be exercised"; +} + } // namespace